auth/token_scope.ts

TokenScope — the per-credential authority narrowing stored on api_token.scope.

Twin of the Rust spine's fuz_auth::token_scope. The stored document is the wire contract; both spines read the same api_token.scope JSONB, so the kind strings, the field names, and the version constant must match exactly.

The whole vocabulary

Two variants, and three rules:

  1. full must be spelled at mint. account_token_create takes a required scope, the column is NOT NULL, and session / daemon-token credentials resolve to full by construction. There is no absent state — which is the whole point. The 2026-02 scope column stored full / read / write and nothing enforced them; the reason that was worse than nothing is that the absent/unenforced case was indistinguishable from a decision.
  2. methods denies every method it does not list. An empty list is legal and renders the token inert for RPC — unlike an empty credential_types allowlist, which is rejected because it renders an *action* unreachable.
  3. A narrowed token is RPC-only. methods denies every non-RPC spine surface outright — the db-admin browser, the bare-hash fact read, the audit SSE stream, and the WS upgrade. See token_scope_admits_non_rpc.
view source

Declarations
#

22 declarations

is_token_surface
#

auth/token_scope.ts view source

(value: string): value is "db_admin" | "fact_bare" | "audit_stream" | "ws_upgrade" import {is_token_surface} from '@fuzdev/fuz_app/auth/token_scope.js';

Is value one of the surfaces the spine mounts?

Membership, not well-formedness — parse_token_scope_capability is the format check the guard resolver runs on a declaration, and it is deliberately looser. Used where the *spine's* own surfaces are the question: the direct-call denial helpers, and the census assertion that fuz_app declares no surface it doesn't mount.

value

type string

returns

boolean

MAX_TOKEN_SCOPE_BYTES
#

parse_stored_token_scope
#

auth/token_scope.ts view source

(value: unknown): { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; } | null import {parse_stored_token_scope} from '@fuzdev/fuz_app/auth/token_scope.js';

Read a stored api_token.scope value back into a TokenScope, or null when the document is unreadable.

The DB driver may hand scope back already-parsed (JSONB → object) or as text depending on backend, so both are normalized here rather than at each call site. Lives here rather than beside the queries so the pure scope module owns every parse — and so mocking the query module doesn't drag this along.

value

type unknown

returns

{ version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; } | null

parse_token_scope
#

auth/token_scope.ts view source

(raw: string): { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; } | null import {parse_token_scope} from '@fuzdev/fuz_app/auth/token_scope.js';

Parse a stored document, enforcing the size cap and the schema.

Fail-closed: returns null on an unreadable document, and every caller on the enforcement path treats null as "refuse the credential" rather than "allow everything". fuz_forge's hydrate path deliberately widens on a DB blip — safe there only because a role gate still applies. Here the scope *is* the gate, so widening on a parse failure would hand a narrowed token full authority exactly when something is already wrong.

raw

type string

returns

{ version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; } | null

parse_token_scope_capability
#

auth/token_scope.ts view source

(value: string): TokenScopeCapability | null import {parse_token_scope_capability} from '@fuzdev/fuz_app/auth/token_scope.js';

Parse a capability string into the question it asks, or null when it is not a capability at all.

Callers turn null into a registration-time throw: RouteAuth.required_scope is typed as a plain string so http/ needs no auth/ import, and this is the single point where that string becomes a mounted guard, so it is where the value gets checked. Refusing an unknown prefix is what keeps the surface: namespace fuz_app's own — a route cannot mint an rpc:-looking capability out of a surface gate, which would put a method name in front of a whole-surface refusal.

value

the declared capability string, e.g. surface:audit_stream

type string

returns

TokenScopeCapability | null

the parsed capability, or null on an unknown prefix / empty or malformed identifier

serialize_token_scope
#

auth/token_scope.ts view source

(scope: { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }): string import {serialize_token_scope} from '@fuzdev/fuz_app/auth/token_scope.js';

Serialize for storage, enforcing the size cap.

Throws on an over-cap document — the mint path maps that to a 400, matching the Rust side's TokenScopeError::TooLarge.

scope

type { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }

returns

string

token_scope_admits_capability
#

auth/token_scope.ts view source

(scope: { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }, capability: TokenScopeCapability): boolean import {token_scope_admits_capability} from '@fuzdev/fuz_app/auth/token_scope.js';

Does scope admit capability?

The one place the two arms of the vocabulary meet their two predicates, so a surface: capability can never be answered by a method-list membership test (which would let a narrowed token onto a surface by listing a method named after it) and an rpc: capability can never be answered by rule 3 (which would refuse a token the method it was minted for).

scope

type { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }

capability

returns

boolean

token_scope_admits_method
#

auth/token_scope.ts view source

(scope: { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }, method: string): boolean import {token_scope_admits_method} from '@fuzdev/fuz_app/auth/token_scope.js';

Does scope admit dispatching method?

Consulted by perform_action between the credential gate and the role gate.

scope

type { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }

method

type string

returns

boolean

token_scope_admits_non_rpc
#

auth/token_scope.ts view source

(scope: { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }): boolean import {token_scope_admits_non_rpc} from '@fuzdev/fuz_app/auth/token_scope.js';

Does scope admit a non-RPC spine surface?

A narrowed token reaches no non-RPC surface at all — the load-bearing half of token scoping, and what "rule 3" names wherever this module's callers and the Rust twin use that shorthand. See docs/security.md §Token scoping.

A method-name allowlist alone would have been a false promise: the db-admin browser serves paginated rows of any public table (including account.password_hash, auth_session, and api_token) plus row DELETE, gated on a global role a bearer credential satisfies. A token whose UI badge read "scoped to cell_get" could still delete an account row.

Denying every non-RPC surface outright is strictly more restrictive than naming each one, keeps no vocabulary to maintain, and fits in one sentence a user can hold: *a scoped token is RPC-only*.

scope

type { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }

returns

boolean

token_scope_denied_body
#

auth/token_scope.ts view source

(capability: string): { error: "token_scope_required"; required_scope: string; } import {token_scope_denied_body} from '@fuzdev/fuz_app/auth/token_scope.js';

Build the flat denial body a non-RPC route returns when a narrowed token reaches a capability it lacks: {error: 'token_scope_required', required_scope: '<section>:<id>'} at 403.

One shape for every capability, matching the Rust token_scope_surface_denied_response. These are REST-ish routes whose sibling denials are already flat, so the JSON-RPC envelope stays with the dispatcher's per-method gate and stops there.

capability

type string

returns

{ error: "token_scope_required"; required_scope: string; }

token_scope_from_input
#

auth/token_scope.ts view source

(input: { kind: "full"; } | { kind: "methods"; methods: string[]; }): { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; } import {token_scope_from_input} from '@fuzdev/fuz_app/auth/token_scope.js';

Build the stored scope from a mint-time input.

input

type { kind: "full"; } | { kind: "methods"; methods: string[]; }

returns

{ version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }

token_scope_full
#

auth/token_scope.ts view source

(): { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; } import {token_scope_full} from '@fuzdev/fuz_app/auth/token_scope.js';

A deliberately-minted full-authority scope.

returns

{ version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }

token_scope_label
#

auth/token_scope.ts view source

(scope: { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }): string import {token_scope_label} from '@fuzdev/fuz_app/auth/token_scope.js';

Stable label for the token list / UI.

scope

type { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }

returns

string

token_scope_method_capability
#

auth/token_scope.ts view source

(method: string): string import {token_scope_method_capability} from '@fuzdev/fuz_app/auth/token_scope.js';

The capability string an action method is refused under: rpc:<method>.

Exported because two sites *compute* one — the dispatcher's per-method gate and the REST bridge, which must agree — where a surface: capability is always written as a literal on a route spec, so nothing computes one.

method

type string

returns

string

token_scope_methods
#

auth/token_scope.ts view source

(methods: string[]): { version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; } import {token_scope_methods} from '@fuzdev/fuz_app/auth/token_scope.js';

A narrowed scope over methods.

methods

type string[]

returns

{ version: 1; kind: "full"; grandfathered?: boolean | undefined; } | { version: 1; kind: "methods"; methods: string[]; grandfathered?: boolean | undefined; }

TOKEN_SCOPE_VERSION
#

TOKEN_SURFACE_NAME_REGEX
#

auth/token_scope.ts view source

RegExp import {TOKEN_SURFACE_NAME_REGEX} from '@fuzdev/fuz_app/auth/token_scope.js';

Name format for the identifier half of a capability string. Mirrors RoleName / CredentialTypeName / ScopeKindName.

Applied to the surface: arm only. The rpc: arm's identifier is an action method name, whose vocabulary belongs to ActionSpec (and includes shapes like peer/ping this regex would refuse) — the capability parser checks it is non-empty and leaves the rest to the registry that owns it.

TOKEN_SURFACES
#

auth/token_scope.ts view source

readonly ["db_admin", "fact_bare", "audit_stream", "ws_upgrade"] import {TOKEN_SURFACES} from '@fuzdev/fuz_app/auth/token_scope.js';

The non-RPC surfaces the spine itself mounts (rule 3). Twin of the Rust TokenSurface enum, name for name.

Not a registry a consumer registers into. RouteAuth.required_scope accepts any well-formed surface:<name> (see parse_token_scope_capability), so a consumer names its own non-RPC surface without an upstream release. This tuple is what the *spine* may declare, and what its surface census iterates — the closure is a check on fuz_app's own routes, not a vocabulary consumers have to ask permission to extend.

A surface name never decides anything: rule 3 is all-or-nothing (token_scope_admits_non_rpc), so the name only labels *which* surface refused, in the required_scope a denial reports. That is the same field whose rpc:<method> arm has always carried arbitrary consumer method names.

TokenScope
#

auth/token_scope.ts view source

ZodDiscriminatedUnion<[ZodObject<{ version: ZodLiteral<1>; kind: ZodLiteral<"full">; grandfathered: ZodOptional<ZodBoolean>; }, $strict>, ZodObject<...>], "kind"> import type {TokenScope} from '@fuzdev/fuz_app/auth/token_scope.js';

The stored api_token.scope document.

grandfathered is a fact about the *row* — the api_token_scope migration wrote it; no minter chose it — that only the token list cares about. The enforcement path never branches on it, which is what stops "grandfathered" from becoming a third authority level.

Strict on both arms so a v2 document carrying a grant arm a v1 reader doesn't understand is refused rather than parsed as permissively-narrower-than-stored. The discriminated union also refuses the contradiction {kind: 'full', methods: [...]}.

TokenScopeCapability
#

auth/token_scope.ts view source

TokenScopeCapability import type {TokenScopeCapability} from '@fuzdev/fuz_app/auth/token_scope.js';

A parsed required_scope capability — what a route demands of the calling credential's TokenScope.

capability is the wire string verbatim, carried rather than re-derived so a denial reports exactly what the route declared. The arms are asymmetric on purpose, and the asymmetry is the whole design: the rpc: identifier is consumed by a predicate (token_scope_admits_method), so it is kept; the surface: identifier is pure label, because rule 3 is all-or-nothing (token_scope_admits_non_rpc never looks at which surface). That is why one half of the vocabulary needs no registry.

TokenScopeInput
#

auth/token_scope.ts view source

ZodDiscriminatedUnion<[ZodObject<{ kind: ZodLiteral<"full">; }, $strict>, ZodObject<{ kind: ZodLiteral<"methods">; methods: ZodArray<ZodString>; }, $strict>], "kind"> import type {TokenScopeInput} from '@fuzdev/fuz_app/auth/token_scope.js';

The scope input accepted by account_token_create — the stored shape minus the row-owned fields.

A minter cannot set version or grandfathered: the first is stamped by token_scope_full / token_scope_methods, and the second is the migration's mark for tokens that predate scoping. Letting a caller forge grandfathered would turn a tracked debt into a laundered one.

TokenSurface
#

auth/token_scope.ts view source

"db_admin" | "fact_bare" | "audit_stream" | "ws_upgrade" import type {TokenSurface} from '@fuzdev/fuz_app/auth/token_scope.js';

Imported by
#