// The app's authorization vocabulary, in ONE place. // // Split out of `app.config.ts` for the same reason `api-rbac` splits its role // map: the tests can build the real resolver from the real scopes. A test that // redeclares the vocabulary it is testing proves only that its own copy is // self-consistent — the copy drifts, the test stays green, and the drift is // exactly the thing worth catching. // // ── Three gates, three different questions ───────────────────────────────── // // This template stacks all three, and conflating any two of them is the // classic SaaS authorization bug: // // tenant() WHICH ROWS — the runtime AND-merges // `tenantId = subject.tenantId` into every read. // requireEntitlement() HOW MANY — the plan's metered quota. It refuses the // fourth project and allows the first three, to anyone. // guards: [{ scope }] WHO MAY CALL IT AT ALL — this file. // // A quota is not an access control and neither is a tenant filter. The gate // that answers "may this caller do this" is the third one, and every // wire-exposed procedure here declares it (or an `openAccess:` reason — there // is exactly one, `session.me`). import type { Subject } from '@voltro/protocol' /** * What a signed-in member of a tenant may do. * * **One role, on purpose.** This template has exactly one kind of account: a * member of the tenant they signed up into. Inventing `owner` / `billing-admin` * / `viewer` here would be a role map written to look secure rather than to * describe this app — and a scope every caller already holds is a guard that * reads as protection and enforces nothing. * * The line these guards DO draw is real: **signed in or not.** With no cookie a * caller cannot list a tenant's projects, cannot read the invite roster (which * carries teammates' email addresses), and cannot spend a seat. * * Split it by BLAST RADIUS the day your product has two kinds of account. * `team:invite` is already separate from `projects:write` for that reason: * inviting a person costs a seat and puts an outsider inside the tenant, which * is a bigger blast than creating a project. */ export const MEMBER_SCOPES = [ 'projects:read', 'projects:write', 'team:read', 'team:invite', ] as const /** * Where a caller's authority comes from. * * `auth.resolveScopes` runs ONLY on a subject a strategy MATCHED — never for * anonymous, where there is no identity to look anything up for. That is * exactly what makes these guards satisfiable AND meaningful: * * • no session cookie → `voltroPasswordStrategy` skips → anonymous Subject, * no scopes, every guard denies. * • valid cookie → matched `user` Subject → this runs → member scopes. * * A session cookie deliberately carries no `scopes` of its own (identity is * settled at sign-in; authority is re-read per request), so this function is * the only thing that can grant them — which is why removing a member's access * takes effect on their EXISTING session rather than when the cookie expires. * * PRODUCTION replaces the body with a lookup: `subject.metadata.roles` (set by * your strategy) or a read against your own tables through the `store` the * framework hands the resolver. Failure posture: return * `{ kind: 'unavailable', reason }` if the lookup itself fails — the request * then fails CLOSED with a named reason, instead of an empty array being * applied as though it were a policy decision. */ export const resolveScopes = (_subject: Subject): ReadonlyArray => [...MEMBER_SCOPES]