Permissions
Four roles per org_members row, in descending authority: owner,
developer, member, viewer. The column is text with a "member" default
(src/lib/db/schema/organizations.ts) — there is no Postgres enum, so the set
is enforced in code by isRole / VALID_ROLES in
src/lib/auth/permissions.ts, and a row
carrying anything else resolves to no role at all (getRole returns null,
which every gate treats as unauthenticated). Roles are scoped per-org — a user
can be owner in one org and viewer in another.
| Role | Can |
|---|---|
owner |
Everything: billing, API keys, team, org / tenants, SSO + SCIM, IP pools, DLT registration, delete org. |
developer |
Send and configure: templates, audiences, webhooks, suppressions, domains, brands / campaigns / phone numbers, message cancel + resend, webhook test-fire + replay. Cannot touch billing, team, or API keys. |
member |
Read-only on sensitive surfaces; functionally a viewer with a friendlier label. Kept distinct so future divergence doesn't need a migration. |
viewer |
Read-only across all resources. Safest default for ops / support access. |
Helpers
src/lib/auth/session.ts — three gates, matching the three tiers of write:
requireSession()— any valid member of the active org, whatever the role. Returns{ userId, email, orgId, defaultOrgId, role, projectId }ornull. Used for every read.requireDeveloperSession()—ownerordeveloper. Used for send-config mutations. Returnsnullformemberandviewer.requireOwnerSession()—owneronly. Used for admin mutations. Returnsnullif the user's role in the active org isn'towner.
There is a fourth, requireWriteSession() (anyone except viewer), which is
intentionally unused on internal mutations: member ≈ viewer for writes,
so a mutation that admitted members would be widening the gate rather than
narrowing it.
All read the active-org cookie (sendoka_active_org) so the resolved orgId reflects the sidebar switcher, not the sign-in default.
requireUserSession() sits beside them and is not a role gate at all: identity only
({ userId, email }, re-read from users), no org and no role. It is for the personal
endpoints that must keep working for a user who belongs to no org: account erasure and
export, the user's own sessions, their second factor. Never use it to read or write org
data. See Team → No organization.
Which gate each endpoint uses
Owner-only is a smaller set than this page used to claim. Domains, webhooks and templates are developer-and-up — they are the surfaces a developer ships on day to day, and gating them on owner meant a two-person team could not deploy without sharing the billing account.
| Endpoint | Gate | Effect |
|---|---|---|
POST /api/internal/api-keys |
requireOwnerSession |
create key |
DELETE /api/internal/api-keys?id= |
requireOwnerSession |
revoke key |
POST /api/internal/team/invite |
requireOwnerSession |
invite a member |
PATCH /api/internal/team/members |
requireOwnerSession |
change member role |
DELETE /api/internal/team/members?id= |
requireOwnerSession |
remove a member |
POST /api/internal/billing |
requireOwnerSession |
upgrade (checkout session) |
POST /api/internal/domains |
requireDeveloperSession |
add domain |
PATCH /api/internal/domains |
requireDeveloperSession |
re-check verification |
DELETE /api/internal/domains?id= |
requireDeveloperSession |
remove domain |
PATCH /api/internal/domains/{id}/region |
requireDeveloperSession |
set region / fallback region |
PATCH /api/internal/domains/{id}/tenant |
requireDeveloperSession |
rebind tenant |
POST /api/internal/domains/{id}/warmup |
requireDeveloperSession |
start warmup |
POST /api/internal/webhooks |
requireDeveloperSession |
create endpoint |
DELETE /api/internal/webhooks?id= |
requireDeveloperSession |
remove endpoint |
POST /api/internal/webhooks/rotate |
requireDeveloperSession |
rotate secret |
POST /api/internal/templates |
requireDeveloperSession |
create template |
DELETE /api/internal/templates?id= |
requireDeveloperSession |
delete template |
The full owner-only surface is wider than the rows above — billing, API keys, IP
pools, tenants, DLT, org settings + exports, SSO, SCIM and team invites/members
are all owner-gated. Keep OWNER_ONLY_SCOPES in src/lib/api/scopes.ts in sync
with it: that constant caps what an OAuth consent screen is allowed to grant, so
a scope that drifts out of it becomes grantable to a third-party app.
Non-mutating reads (GET on the same paths) require just requireSession() — members and viewers can see what exists.
Guards baked into member management
- Cannot demote the user referenced by
organizations.owner_id. - Cannot remove the user referenced by
organizations.owner_id. - Cannot remove yourself — "ask another owner." Prevents accidental lockout.
- First owner (the user who created the org) is always safe.
Ownership succession on account deletion
DELETE /api/internal/user erases the account, which means organizations.owner_id
has to move for every org it still points at (the column is NOT NULL with an FK).
memberSuccessor() in src/app/api/internal/user/route.ts picks the heir, in order:
- The longest-standing other member who already holds the owner role. Filtering
on the role is what stops the pointer landing on a
viewer— a privilege grant nobody requested, and since ownership also carries a billing entitlement (Comped accounts), a spend authorization too. TheORDER BY created_at, idmatters as much as the filter: an unorderedLIMIT 1returns heap order, i.e. insertion order, which is something whoever did the inviting gets to arrange. - Failing that, the longest-standing member of any role, promoted. Reached only
when the org has no owner-role member at all — a state that is genuinely
reachable, because SCIM deprovisioning and the member-management guards both key
on the
owner_idpointer rather than the role. The promotion writesorg_members.role = 'owner'alongside the pointer: moving one without the other is what creates the drift in the first place. Refusing instead would be a dead end — the org has no owner to appoint one, and the departing user is 403'd out of member management, so an erasure request could never be satisfied. - Nobody at all — the org has no other members, so it cannot outlive the
account. Folded into the existing
409 SOLE_OWNERand itsdelete_owned_orgsopt-in rather than a separate refusal.
Every transfer writes an org.ownership_transferred audit row against the org that
changed hands, with promoted_to_owner recording which of the two branches ran.
Ownership moving used to be the one write in this handler that left no trace.
The pre-flight runs before a single row is deleted. Neon HTTP has no multi-statement transaction, so discovering the problem mid-erasure leaves a half-deleted account behind.
403 vs 401
- 401
Unauthorized— no valid session at all. - 403
Owner role required— logged in, but notownerfor the active org. - 403
Developer or owner role required— logged in asmemberorvieweron an endpoint gated byrequireDeveloperSession.
Both 403s are also what a valid session gets when its role string is one the
code doesn't recognize, since getRole returns null for anything outside
VALID_ROLES and every gate builds on requireSession.
Gaps
- Finer-grained permissions (per-surface read-only, sub-roles like "billing admin") not yet implemented. The four roles collapse to three gates, and
memberis currently indistinguishable fromviewerin behaviour — it is kept separate so divergence later doesn't need a migration. - No server-side check that hides owner-only dashboard controls from non-owners. Currently members see the buttons but get a 403 when clicking — add a
rolefetch to the dashboard layout and conditionally render.