// 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. // // ── Every procedure in this app declares an access decision ──────────────── // // `guards: [{ scope }]` naming a scope from this file, or an `openAccess:` // reason. The framework refuses to boot on a procedure that declares neither // (`security.defaultDeny`, on by default). There is exactly one `openAccess` // here — `session.me`, which an anonymous browser MUST be able to call, or // nothing can tell it to sign in. import type { Subject } from '@voltro/protocol' /** * What a signed-in editor may do. * * **One role, on purpose, and read the reason before you copy it.** This * template has exactly one kind of account: an editor. Inventing `author` / * `reviewer` / `publisher` 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, and it is the line the template is * about: **signed in or not.** `content.saveDraft` / `publish` / `unpublish` * are refused to an anonymous caller, and the README's claim that only * signed-in editors reach the write surface stops being a comment and becomes * something the dispatch spine enforces before the executor runs. * * Split it the day your product has two kinds of account — by BLAST RADIUS, * not by job title: publishing to the live site is a bigger blast than saving * a draft, so `content:publish` is the natural second scope. */ export const EDITOR_SCOPES = ['content:read', 'content:write'] 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 * precisely what makes these guards satisfiable AND meaningful here: * * • no session cookie → `voltroPasswordStrategy` skips → anonymous Subject, * no scopes, every `content:*` guard denies. * • valid cookie → matched `user` Subject → this runs → editor 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 an editor's * access takes effect on their EXISTING session rather than when it expires. * * PRODUCTION replaces the body with a lookup: `subject.metadata.roles` (set by * your strategy) or a read against your own tables via 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 a policy decision. */ export const resolveScopes = (_subject: Subject): ReadonlyArray => [...EDITOR_SCOPES]