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 } or null. Used for every read.
  • requireDeveloperSession()owner or developer. Used for send-config mutations. Returns null for member and viewer.
  • requireOwnerSession()owner only. Used for admin mutations. Returns null if the user's role in the active org isn't owner.

There is a fourth, requireWriteSession() (anyone except viewer), which is intentionally unused on internal mutations: memberviewer 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:

  1. 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. The ORDER BY created_at, id matters as much as the filter: an unordered LIMIT 1 returns heap order, i.e. insertion order, which is something whoever did the inviting gets to arrange.
  2. 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_id pointer rather than the role. The promotion writes org_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.
  3. Nobody at all — the org has no other members, so it cannot outlive the account. Folded into the existing 409 SOLE_OWNER and its delete_owned_orgs opt-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 not owner for the active org.
  • 403 Developer or owner role required — logged in as member or viewer on an endpoint gated by requireDeveloperSession.

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 member is currently indistinguishable from viewer in 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 role fetch to the dashboard layout and conditionally render.