Account creator
Manages billing, deletes the account, transfers ownership. Exactly one active owner per account.
Authorization and feature gates
ACL is multi-tenant from day one. Solo-user products are the degenerate case: every signup creates a personal account, owner membership, and Free plan row in one transaction. The team UI appears only when memberships make it real.
role
what the user can do
plan
what the account paid for
CASL
server-side ability builder
Stripe never knows about feature keys or roles. The app never queries Stripe at request time. The two systems touch only at the webhook boundary.
For every feature key at request time, resolved per account:
account_feature_overrides (not expired, not revoked).plan_features row for the account’s current account_plans row.src/lib/acl/acl.constants.ts (FEATURES[key].default).First match wins. The CASL ability is built from the resolved feature set after that pass. The resolver (resolveFeatures() in src/lib/acl/feature-resolution.ts) is pure: feed it the plan rows and override rows, get back a typed ResolvedFeatures object. Tested in isolation.
Four roles ship by default, per-membership (a user is owner of Account A, viewer of Account B):
Manages billing, deletes the account, transfers ownership. Exactly one active owner per account.
Manages members and settings, but does not bypass plan checks.
Default authenticated role. Reads and writes account-scoped resources they own.
Guest, support, and demo role. Never writes.
Role rules carry CASL conditions ({ accountId: membership.accountId }) so resource ownership is encoded right at the rule, not duplicated in every handler.
FEATURE_KEYS is a code const tuple. Adding a feature is a TypeScript edit + a bun run generate:acl-types round-trip:
export const FEATURE_KEYS = [ "can_export", "can_invite_team", "max_seats", "max_widgets",] as const;
export const FEATURES = { can_export: { kind: "boolean", default: false }, max_seats: { kind: "limit", default: 1 }, // ...} as const;Feature gates compose with role rules via CASL’s cannot(...) rules: a missing feature forbids the action regardless of role. Owner is not special here.
Every account-scoped Drizzle table carries a // @account-scoped accountId comment above its pgTable declaration. The companion ESLint rule (drizzle-conventions/account-scoped-tables-require-where, defense-in-depth, deferred from the main ACL pass) refuses to merge any db.query.<table>.findX that doesn’t include the scope column in WHERE.
The cross-account isolation matrix in tests/api/widgets/widgets.routes.test.ts is the proof: same user with two accounts, resource IDs unique across accounts, every method (GET, PATCH, DELETE) returns 404 when the resource belongs to the other account.
Per-request DB lookup with a 30s in-process TTL cache. Confirms
the JWT-claimed (user, account) still maps to an active
membership and that the parent account is not soft-deleted.
Cache-bypassing variant for the highest-stakes calls: account deletion, ownership transfer, billing-portal creation, member removal, role changes. Always refetches.
Returns { accountId } so account-scoped queries pull their
WHERE argument from one place.
Throws ApiErrors.forbidden() with the action+subject in the
message when the ability denies. Wraps every privileged DB call.
Throws ApiErrors.limitExceeded (status 402) with
{ feature, current, limit } so the UI can render an inline
upgrade CTA. Wrap inside a transaction for race safety.
{ "user": { "id": "...", "email": "...", "firstName": "...", "lastName": "...", "emailVerified": true }, "account": { "id": "...", "name": "..." }, "role": "owner", "memberships": [ { "accountId": "...", "accountName": "...", "role": "owner" } ], "features": { "can_export": false, "can_invite_team": false, "max_seats": 1, "max_widgets": 5 }}Single endpoint, every piece the UI needs to render the right buttons. The features block is the resolved set; overrides have already been applied.
src/lib/acl/: types, constants, feature-resolution, ability builder, scopedTo, enforceLimit.src/api/accounts/: accountsService, invitationsService, routes.src/api/admin/: adminBillingService (grantFeature / revokeFeature / grantPlan).src/middleware/require-active-membership.ts: runtime membership recheck.src/queues/account-maintenance/: five daily idempotent sweep jobs (expire overrides, expire admin plans, downgrade canceled Stripe plans, hard-delete soft-deleted accounts, clean expired invitations).(user_id, account_id); account switch issues a fresh JWT.account_plans; plan status feeds effective-features mapping.