scope
account_id on scoped rows
Every product table that belongs to a tenant carries the tenant key explicitly.
Tenant boundary from day one
Accounts, memberships, invitations, and billing plans ship as product infrastructure. A solo user is just one user with one account, so the app can grow into teams without a second data model.
account
tenant boundary
aid
active account JWT claim
owner
single active owner index
Every account-scoped row carries account_id. Routes derive that ID from the JWT (the aid claim, populated at login / refresh / register / OAuth callback), never from URL params.
scope
Every product table that belongs to a tenant carries the tenant key explicitly.
jwt
Handlers derive the active account from the cookie-backed JWT, not from request params.
owner
A partial unique index keeps each account from ever having two active owners.
audit
Revoked memberships and deleted accounts keep enough state for audit trails and grace windows.
Account creation is deliberately deferred until the email is verified, so abandoned signups never leave orphan tenant rows. The flow splits across two endpoints, both converging on a single function (accountsService.provisionAfterVerification) that creates the accounts row + owner membership atomically.
sequenceDiagram
participant B as Browser
participant API as API (Elysia)
participant DB as Postgres
participant Mail
B->>API: POST /auth/register
API->>DB: INSERT users (email_verified_at = NULL)
API->>DB: INSERT user_auth_providers (password hash)
API->>DB: INSERT email_verification_tokens
API->>Mail: send verification link
API-->>B: 200 message envelope (no cookies, account, or membership yet)
B->>B: user clicks link in email
B->>API: POST /auth/verify-email { token }
API->>DB: UPDATE users SET email_verified_at = now()
API->>DB: provisionAfterVerification then INSERT accounts + memberships
API-->>B: 200 + auth_token JWT (carries user_id + account_id)
provisionAfterVerification is idempotent: a doubled-up verify click or an OAuth-then-password collision can call it twice, and only one active owner membership per user exists. The buildPersonalAccountName({ firstName, lastName, email }) util produces the account name; if both names are empty it falls back to the email.
The OAuth callback uses the same provision function inline at the end of the callback transaction, so an OAuth user with a provider-verified email lands fully provisioned in one round-trip. OAuth refuses to issue a session when the IdP says the email is unverified; the transaction rolls back, and the caller has to verify through the password flow first. See Authentication for the full state machine.
auth.account_memberships is the (user, account, role) join. Two partial unique indexes lock down the invariants the application logic depends on:
uniq_account_memberships_active_user: (account_id, user_id) WHERE revoked_at IS NULL. At most one active membership per (user, account).uniq_account_memberships_active_owner: (account_id) WHERE role = 'owner' AND revoked_at IS NULL. At most one active owner per account.Revoked memberships keep their row (soft-delete via revoked_at) so the audit trail survives.
POST /api/v1/accounts/:id/invitations // owner | adminPOST /api/v1/invitations/accept // any authenticated user holding the raw tokenPOST /api/v1/accounts/:id/invitations/:iid/resendDELETE /api/v1/accounts/:id/invitations/:iidThe route response carries the raw token exactly once (so the caller can
hand it to whatever email pipeline they want). The DB only stores
sha256(token + pepper).
Rotating on resend invalidates any leaked old link. The caller re-emails the new raw token. Old emails stop working immediately.
Subsequent accept attempts fail with invitation_revoked. The daily
cleanExpiredInvitationsJob background sweep also soft-revokes unaccepted
invitations past their TTL.
At invitation creation AND at acceptance time. An admin could revoke a seat between create + accept; the second check catches that.
POST /api/v1/accounts/:id/transfer-ownership (owner-only, cache-bypassing
resolveFreshMembership). Atomically demotes the current owner to admin,
promotes the target to owner. The partial unique index is never violated
mid-transaction. accountsService.transferOwnership demotes the outgoing
owner FIRST so the index never sees two owners simultaneously.
An owner cannot leave their account; they must transfer first or delete the account. An owner cannot be removed by an admin.
DELETE /api/v1/accounts/:id (owner-only) sets accounts.deleted_at = now(). The hardDeleteSoftDeletedAccountsJob background sweep hard-deletes
rows past the grace window, cascading to memberships, invitations, feature
overrides, account_plans, and every @account-scoped application table.
audit.audit_log survives by design. GDPR redaction (hash the user id, keep
the row) is a separate path.
Off by default. Flip ACCOUNT_DOMAIN_CLAIMING=true and the first verified signup with a non-public email domain claims that domain on its personal account. Subsequent verified signups from the same domain fail with DOMAIN_CLAIMED (409) and the existing account’s name in the error message + accountId in details. The blocked user can still be invited via the standard account_invitations flow.
The flag is the right shape for a B2B product where one email domain maps to one workspace (think Linear, Vercel, or Dreamdata’s signup). For consumer products, leave it off; the public-email allowlist would be moot anyway.
Clicking the verification link demonstrates control of an inbox at the domain. That’s enough for a starter template; harder evidence (DNS TXT records, SAML/SCIM) is a follow-up an operator can layer on without changing the claim mechanism.
src/lib/email-domain/public-domains.ts ships a 51-entry allowlist
(gmail.com, outlook.com, proton.me, …). Signups from those addresses always
get a fresh personal account because no single company owns those domains.
uniq_accounts_claimed_domain_active is the DB-level safety net.
Soft-deleted accounts (deleted_at IS NOT NULL) release their claim, so a
successor signup after a deletion gets the domain back. Two live accounts
can never share a claim.
app.account_join_requests is in the schema with the partial unique index
uniq_account_join_requests_pending. The request / approve / deny endpoints
are intentionally not shipped. Operators flipping the flag on can wire that
surface to whatever notification model suits their product (Slack ping,
in-app inbox, email approval).
sequenceDiagram
participant B as Browser
participant API as API
participant DB as Postgres
B->>API: POST /auth/verify-email { token } (founder@acme.corp)
API->>DB: provisionAfterVerification then claims acme.corp
API-->>B: 200 + cookies, account exists
Note over API,DB: Some time later
B->>API: POST /auth/verify-email { token } (intruder@acme.corp)
API->>DB: provisionAfterVerification then existing claim found
API-->>B: 409 DOMAIN_CLAIMED { message: "…Acme Corp…", details: { accountId, domain } }
The decision lives entirely inside provisionAfterVerification: read the flag, extract the domain via extractDomain(email), bail if isPublicEmailDomain(domain), otherwise look up an active claim and either reuse / claim / throw.
POST /api/v1/accounts/switch with a target accountId re-issues the JWT with the new active account in the aid claim. The client re-fetches /me. Old JWTs continue to work against the old account until their 15-minute access TTL expires; the refresh-time membership recheck blocks renewal if the user no longer has an active membership on that account.
tests/api/widgets/widgets.routes.test.ts is the proof point. Same user holds memberships in two accounts; resource IDs are unique across accounts; every method on Account B’s widget returns 404 (not 403, not 200) when the request comes from Account A’s JWT. The matrix is the canonical pattern to copy for any new account-scoped resource.
src/api/accounts/src/clients/postgres/schema/app.schema.ts: accounts, account_invitations, account_feature_overrides, widgets (sample account-scoped resource).src/clients/postgres/schema/memberships.schema.ts: account_memberships with both partial unique indexes.Read-only psql snippets for tenant-shape questions. Run inside the app database (docker compose exec postgres psql -U app -d app).
-- All active memberships for one account.SELECT m.role, u.email, m.created_atFROM auth.account_memberships mJOIN auth.users u ON u.id = m.user_idWHERE m.account_id = '<account-uuid>' AND m.revoked_at IS NULLORDER BY m.role, m.created_at;-- Members per role per account (top 20 accounts by size).SELECT a.id AS account_id, a.name, m.role, count(*) AS membersFROM auth.account_memberships mJOIN app.accounts a ON a.id = m.account_idWHERE m.revoked_at IS NULLGROUP BY 1, 2, 3ORDER BY members DESCLIMIT 20;-- Accounts with NO active owner. Should always return zero rows.-- If it doesn't, something bypassed the owner-transfer flow.SELECT a.id, a.name, a.created_atFROM app.accounts aWHERE NOT EXISTS ( SELECT 1 FROM auth.account_memberships m WHERE m.account_id = a.id AND m.role = 'owner' AND m.revoked_at IS NULL);-- Users who belong to more than one active account (team members or operators).SELECT u.email, count(*) AS accountsFROM auth.users uJOIN auth.account_memberships m ON m.user_id = u.id AND m.revoked_at IS NULLGROUP BY 1HAVING count(*) > 1ORDER BY 2 DESC;-- Pending invitations older than 14 days, by account.SELECT i.account_id, i.email, i.created_atFROM app.account_invitations iWHERE i.accepted_at IS NULL AND i.revoked_at IS NULL AND i.created_at < now() - interval '14 days'ORDER BY i.created_at;aid (active account); session refresh re-validates membership.