diff --git a/.changeset/dark-bottles-juggle.md b/.changeset/dark-bottles-juggle.md new file mode 100644 index 00000000..1adb7da5 --- /dev/null +++ b/.changeset/dark-bottles-juggle.md @@ -0,0 +1,11 @@ +--- +'@fuzdev/fuz_app': minor +--- + +feat: actor-targetable offers + dispatcher-resolved acting actor + +- `audit_log.target_actor_id` + `permit_offer.to_actor_id` columns +- auth is account-only; acting actor resolved per-request by route-spec wrapper / RPC dispatcher +- routes opt in via `acting?: ActingActor` or permit-requiring auth (`role` / `keeper`) +- account-grain routes (logout, password_change, account_verify) run with `RequestContext.actor: null` +- REST `wrap_error_catch` emits flat `ApiError` shape `{error, message?, ...}`; RPC unchanged diff --git a/CLAUDE.md b/CLAUDE.md index 4c27bd68..b3a5a4de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,20 @@ NOTE: AI-generated For coding conventions, see Skill(fuz-stack). +## Cleanest architecture takes priority + +When two designs are on the table — one narrow and one with cleaner layering +— choose the cleaner one even when it costs effort, churn, or breakage. +Layered shapes (e.g. domain code that returns `{status, body}` and lets each +transport bind, vs. domain code that emits transport-shaped responses +in-line) compound across consumers and across time; "narrow diff" reasoning +is local optimization that ships drift to every dispatcher and test that +extends the surface later. Pay the churn once at the source so every +follow-up is on the right side of the line. Sample applications: the +dispatcher authorization phase fold (auth-domain `{status, body}` → +transport-bound responses) and most other refactors that touch a shared +boundary. + | Doc | Content | | ---------------------- | ------------------------------------------------- | | ./docs/identity.md | Auth design rationale | @@ -126,20 +140,22 @@ deps). Consumers destructure `ctx.deps` when calling them. 5. **Trusted proxy** (`*`) — resolves client IP from XFF; must run before auth/rate-limiting 6. **Origin verification** (`/api/*`) 7. **Session parsing** (`/api/*`) — parses cookie, sets identity on context -8. **Request context** (`/api/*`) — session → account → actor → permits -9. **Bearer auth** (`/api/*`) — CLI clients; rejected when `Origin` or `Referer` is present -10. **Routes** — `apply_route_specs` with `fuz_auth_guard_resolver` (params → auth → input validation → handler) +8. **Request context** (`/api/*`) — validates the session and sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY`. Account-only — does not load actor or permits. +9. **Bearer auth** (`/api/*`) — CLI clients; same account-only shape. Rejected when `Origin` or `Referer` is present. +10. **Routes** — `apply_route_specs` with `fuz_auth_guard_resolver` (params → query → **pre-validation auth (401)** → **authorization phase** → **post-authorization auth (403)** → input validation → handler). The auth gate is split in two: `require_auth` fires before any body parsing so unauthenticated callers never see route-shape information from input parse failures, then the authorization phase resolves the acting actor against `c.var.account_id` (when the route's input declares `acting?: ActingActor` or its auth requires permits — `role` / `keeper`), then `require_role` / `require_keeper` consume the populated `RequestContext`. Account-grain routes skip resolution and run with `RequestContext.actor: null`. Same priority order as the RPC dispatcher (`actions/action_rpc.ts`): 401 → 403 → 400 → handler. 11. **Static serving** (optional) — SvelteKit static fallback Session parsing is separate from auth enforcement — login and bootstrap routes -participate in cookie refresh without being blocked. +participate in cookie refresh without being blocked. Acting-actor resolution +is separate from authentication — multi-actor accounts can hit account-grain +routes (logout, password_change, account_verify) without picking a persona. ### Route Spec System Routes are data (`RouteSpec[]`). `apply_route_specs` registers them with -auto-validation (params → auth guards → input validation → handler → -DEV-only output + error validation). Duplicate method+path throws at -registration. Declarative transactions: `transaction?: boolean` defaults +auto-validation (params → query → pre-validation auth → authorization +phase → post-authorization auth → input validation → handler → DEV-only +output + error validation). Duplicate method+path throws at registration. Declarative transactions: `transaction?: boolean` defaults to `false` for GET, `true` for mutations. Handlers receive `(c, route)` where `route` satisfies `QueryDeps`; use `route.background_db` for fire-and-forget effects that must outlive the transaction. `generate_app_surface()` diff --git a/docs/architecture.md b/docs/architecture.md index 36d838dd..7ff1fa9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,6 +119,16 @@ adapters directly instead of duplicating transaction wiring. ## Migrations +**Pre-stable schema.** fuz_app's schema is not stabilized yet, so the +"append-only after publish" rule does not apply today: migration bodies, +names, and positions can change freely between versions, and consumers +upgrading across a schema change are expected to drop and re-bootstrap +their dev/test databases. Bias toward editing the existing v0/v1 entries +rather than appending v2 patch migrations. The runner contract below is +the one that will apply once the schema is declared stable (the cliff +will be called out in that release's notes); until then it is the shape +the runner enforces but not the policy authors are held to. + `run_migrations(db, namespaces)` (from `db/migrate.ts`) applies pending migrations per namespace. The shared `schema_version` table records one row per applied migration: `(namespace, name, sequence, applied_at)`, @@ -136,12 +146,6 @@ binary-older case with a rename in the overlap doesn't surface as a phantom `name-divergence-at-N`. Up-to-date namespaces are omitted from the result array. -**Append-only after first publish.** Once a fuz_app version containing -a migration is published, that migration's name and position are frozen. -Pre-publish, anything goes; the cliff is the publish event. Body edits -to a published migration slip past the runner (no content hashing) and -are caught by schema-snapshot tests in consumers. - **`MigrationError`** is the only error class thrown from `run_migrations` and `baseline`. Branch on `.kind`, never on message text. Kinds: `binary-older-than-db`, `name-divergence-at-N`, @@ -246,14 +250,19 @@ shapes: `ApiError`, `ValidationError`, `PermissionError`, `KeeperError`, **Three-layer merge**: derived → middleware → explicit route. -- `derive_error_schemas(auth, has_input, has_params, has_query, rate_limit)` auto-populates +- `derive_error_schemas({auth, has_input?, has_params?, has_query?, rate_limit?, acting_aware?})` auto-populates auth/validation/rate-limit errors. 400 is derived when `has_input`, `has_params`, or - `has_query` is true. + `has_query` is true. `acting_aware: true` widens the 400 union with + `ActorRequiredError` / `ActorNotOnAccountError` and adds a 500 union of + `NoActorsOnAccountError` / `AccountVanishedError` so DEV-mode error-schema validation + matches what the dispatcher's authorization phase actually emits. - `MiddlewareSpec.errors` declares what each middleware layer can return (origin → 403, bearer_auth → 401/429, daemon_token → 401/500/503) - Routes declare handler-specific errors via `RouteSpec.errors` -- `merge_error_schemas(spec, middleware_errors?)` merges all three — later layers - override earlier for the same status code +- `merge_error_schemas(spec, middleware_errors?, acting_aware?)` merges all three — + later layers override earlier for the same status code. `acting_aware` flows + through to `derive_error_schemas` and is computed at the call site (it depends + on the canonical `ActingActor` schema in `auth/`) `RouteSpec.rate_limit?: RateLimitKey` (`'ip' | 'account' | 'both'`) declares what a route's rate limiter is keyed on — metadata for surface introspection and policy @@ -283,6 +292,27 @@ and tests. different shapes at the same status code. The `error` field is the contract; extra context fields (`required_role`, `retry_after`, `detail`) are diagnostic. +**Thrown errors serialize per-transport.** Handlers that throw a +`ThrownJsonrpcError` (e.g. `jsonrpc_errors.forbidden(...)`) hit the +transport's catch wrapper: + +- **REST** — `wrap_error_catch` in `http/route_spec.ts` flattens to the + `ApiError` shape `{error: , message?, ...rest}`. `reason` comes + from `err.data.reason` (handler override) or falls back to + `jsonrpc_error_code_to_name(err.code)` (e.g. `-32600` → + `invalid_request`). HTTP status comes from `jsonrpc_error_code_to_status`. +- **JSON-RPC** — the dispatcher's catch in `actions/action_rpc.ts` wraps + into the JSON-RPC envelope `{jsonrpc, id, error: {code, message, data}}`, + preserving `err.code` and `err.data` directly. +- **WS** — `register_action_ws` mirrors the JSON-RPC envelope onto the wire. + +The two shapes diverge intentionally: REST clients consume the flat +`{error, ...}` they have always consumed; JSON-RPC clients consume the +envelope shape the protocol mandates. Both expose `error.data.reason` +(REST) / `error.error.data.reason` (JSON-RPC) as the machine-parseable +discriminant — consumer assertions key on the reason, not the code or +HTTP status. + ## DEV-only Output Validation `input` schemas on `RouteSpec` and `ActionSpec` are validated unconditionally @@ -326,7 +356,7 @@ Per-request `Array>` on Hono's `ContextVariableMap` for tracking background effects (audit logging, session touch, token usage tracking). Three standalone functions follow this pattern: -- `audit_log_fire_and_forget(route, input, deps)` — `route: Pick`, uses `background_db` so entries persist even if the transaction rolls back. `deps` is an `AuditLogFireAndForgetDeps` bundle (`{log, on_audit_event, audit_log_config?}`), structurally compatible with `Pick` so call sites pass the surrounding deps object. The `on_audit_event` callback receives the inserted `AuditLogEvent` row (via `RETURNING *`) after INSERT succeeds — used to broadcast audit events via SSE (noop when SSE is not wired). `audit_log_config` defaults to `BUILTIN_AUDIT_LOG_CONFIG` when absent on the deps object +- `audit_log_fire_and_forget(route, input, deps)` — `route: Pick`, uses `background_db` so entries persist even if the transaction rolls back. `deps` is the shared `AuditEmitDeps` bundle (`{log, on_audit_event, audit_log_config?}`) defined in `auth/deps.ts` and aliased by every action-factory deps type (`AdminActionDeps`, `AccountActionDeps`, `PermitOfferActionDeps`, `SelfServiceRoleActionDeps`); structurally compatible with `Pick` so call sites pass the surrounding deps object. The `on_audit_event` callback receives the inserted `AuditLogEvent` row (via `RETURNING *`) after INSERT succeeds — used to broadcast audit events via SSE (noop when SSE is not wired). `audit_log_config` defaults to `BUILTIN_AUDIT_LOG_CONFIG` when absent on the deps object - `session_touch_fire_and_forget(deps, token_hash, pending_effects, log)` - `query_validate_api_token(deps, raw_token, ip, pending_effects)` (internal tracking, `deps` includes `log`) diff --git a/docs/identity.md b/docs/identity.md index 6c94e48d..0683656f 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -21,9 +21,12 @@ Actors are the universal interface for everything that acts — humans, AI agent personas. Cell ownership, SAES actions, and audit trails all reference actor_id. The account is just the auth boundary (credentials, sessions, password hashes). -For v1, every account has exactly one actor. The schema supports multiple actors -per account from day one so the extension (personas, AI agents) doesn't require -migration. +An account may host one or more actors. The schema supports multi-actor accounts +end-to-end — bootstrap and signup create a single actor by default; additional +actors can be created via consumer flows for personas / AI agents. The +dispatcher's authorization phase resolves the acting actor per-request via the +optional `acting?: ActingActor` field on action / route inputs (omit on +single-actor accounts; supply on multi-actor). ### Why permits, not flags @@ -100,19 +103,25 @@ work only for local CLI access (bypassing nginx). See **The daemon token is the only path to keeper.** Session cookies and API tokens have a privilege ceiling of admin even if the account holds a keeper permit. Both the `require_keeper` middleware (REST routes) and the RPC dispatcher's -`check_action_auth` (JSON-RPC endpoints) check the credential type (must be -daemon token) and an active keeper permit. +post-authorization auth gate (`check_action_auth_post_authorization`, JSON-RPC +endpoints) check the credential type (must be daemon token) and an active keeper +permit. -Sessions reference accounts, not actors. The actor is resolved from the account -in request context middleware. +Sessions reference accounts, not actors. Authentication middleware sets only +account-grain identity (`ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`); the acting +actor is resolved by the route-spec wrapper / RPC dispatcher's authorization +phase against the validated `acting` value (or transparently when the account +has a single actor). Account-grain operations (logout, password change, +account verify) skip resolution and run with `RequestContext.actor: null`. ## Key Decisions Distilled from design exploration — the choices that most affect consumers: 1. **Table name `account`**, not `users` — matches the identity model -2. **Sessions reference accounts** — actor resolved per-request in middleware, - supporting future multi-actor-per-account +2. **Sessions reference accounts** — actor resolved per-request by the + dispatcher's authorization phase (not auth middleware); multi-actor accounts + pass `acting?: ActingActor` to pick a persona, single-actor resolves transparently 3. **Permits target actors** — not accounts. All ownership and authorization goes through actors 4. **Permits can be resource-scoped** — `permit.scope_id` (nullable) diff --git a/docs/security.md b/docs/security.md index f2fe09c9..f4fc0f35 100644 --- a/docs/security.md +++ b/docs/security.md @@ -131,10 +131,11 @@ legitimate operator. from exploiting a token extracted via browser-side code - **Soft-fail for invalid tokens**: Bearer middleware never returns 401 or error diagnostics. Invalid, expired, or empty tokens are treated as "no - credential" — downstream auth enforcement (`check_action_auth` or - `require_auth`) returns generic errors without leaking token-specific - information (`invalid_token`, `account_not_found`). Rate limiting (429) - is the only hard-fail from bearer middleware + credential" — downstream auth enforcement (the RPC dispatcher's + pre-validation auth gate, or `require_auth` on REST) returns generic + errors without leaking token-specific information (`invalid_token`, + `account_not_found`). Rate limiting (429) is the only hard-fail from + bearer middleware - **Token limits**: Per-account cap (default 10, configurable). Oldest token evicted on creation when limit is reached @@ -146,8 +147,9 @@ Rotating filesystem credential for keeper-level operations: - Token rotated every 30 seconds (configurable); the previous token is also accepted to cover the rotation race window - Both `require_keeper` middleware (REST routes) and the RPC dispatcher's - `check_action_auth` (JSON-RPC endpoints) check **both**: daemon token credential - type AND an active keeper permit + post-authorization auth gate (`check_action_auth_post_authorization`, + JSON-RPC endpoints) check **both**: daemon token credential type AND an + active keeper permit - Compromising the web layer cannot escalate to keeper — filesystem access required ## SSE Connection Security diff --git a/docs/usage.md b/docs/usage.md index f9bcfaf2..a6766869 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -981,7 +981,7 @@ natural. But in a component that consumes two or more domains (see noise migrated from `$props()` to local bindings; it didn't disappear. The reference shape for app-wide composition is zzz's `frontend_context` -(see `~/dev/zzz/src/lib/frontend.svelte.ts`): +(see ~/dev/zzz/src/lib/frontend.svelte.ts): ```ts // zzz declares one context holding the whole app cell. diff --git a/src/lib/actions/CLAUDE.md b/src/lib/actions/CLAUDE.md index d31eb0c9..9198fb7b 100644 --- a/src/lib/actions/CLAUDE.md +++ b/src/lib/actions/CLAUDE.md @@ -65,8 +65,10 @@ the dispatcher's per-action rate-limit hook. Same hook fires on the HTTP RPC dispatcher (`create_rpc_endpoint`) and the WebSocket dispatcher (`register_action_ws`) — one budget per action, not per transport. `'ip'` keys on the resolved client IP; `'account'` keys on -`request_context.actor.id` (post-auth) and is rejected at registration -when paired with `auth: 'public'` (no actor to key on); `'both'` runs +`request_context.account.id` (post-auth, account-grain — every +authenticated action has an account regardless of whether an actor was +resolved) and is rejected at registration when paired with +`auth: 'public'` (no account to key on); `'both'` runs both checks. **Throttle-requests semantics** — every invocation records, regardless of outcome (different from REST login's throttle-failures that resets on success). The motivating threat is admin mutation oracles @@ -233,15 +235,20 @@ route specs on the same path (GET + POST) that share one internal dispatcher. Per-action auth lives inside the dispatcher; the outer routes use `auth: {type: 'none'}` and `transaction: false`. -Dispatcher phase order (POST; GET differs only at step 1): +Dispatcher phase order (POST; GET differs only at step 1). Mirrors the +REST authorization order in `http/route_spec.ts` so HTTP RPC and REST +fail with the same priority (401 → 403 → 400 → handler): 1. **Parse envelope** — POST body as `JsonrpcRequest` (parse errors → JSON-RPC `parse_error` 400). GET reads `method`, `id`, `params` from query string; missing `method`/`id` → 400 `invalid_request`. Integer `id` normalization: `?id=42` matches `{id: 42}`. 2. **Lookup method** — `Map`. Unknown method → `method_not_found`. Duplicate methods throw at construction. 3. **GET read restriction** — GET is rejected for `side_effects: true` actions (`invalid_request` with "must use POST"). -4. **Auth check** — via `check_action_auth(spec.auth, request_context, credential_type)`. `keeper` requires `credential_type === 'daemon_token'` AND `has_role(request_context, 'keeper')` — the `has_role` alone is insufficient, session/bearer cannot elevate. `{role}` uses `has_role`. Failure → `unauthenticated` / `forbidden`. -5. **Validate params** — `spec.input.safeParse(raw_params ?? null-if-null-schema)`. Failure → `invalid_params` with `{issues}`. -6. **Dispatch** — `spec.side_effects` picks transaction (`route.db.transaction(tx => execute(tx))`) vs pool (`route.db`). Handler throws roll back the transaction — the catch sits outside the transaction boundary. -7. **DEV-only output validation** — `spec.output.safeParse(output)` runs only under `DEV` (from `esm-env`). On mismatch: `log.error(...)`, return response unchanged; never throws, never mutates status. +4. **Pre-validation auth** — `check_action_auth_pre_validation(spec.auth, account_id)`. Short-circuits with `unauthenticated` (-32001 / 401) when `auth !== 'public'` and no `ACCOUNT_ID_KEY` is on the request. Fires before input validation so unauthenticated callers don't leak `invalid_params` for methods with required input. Public actions skip the rest of the auth path. +5. **Authorization phase** — for non-public actions, when `is_actor_implying_auth(spec.auth)` (`'keeper'` or `{role}`) or `input_schema_declares_acting(spec.input)` (the input has the canonical `acting?: ActingActor` field), `apply_authorization_phase` resolves the actor against `c.var.account_id` plus the raw `acting` string read off `params` (no schema validation yet), builds the `{account, actor, permits}` `RequestContext`, and sets `REQUEST_CONTEXT_KEY`. Authenticated-but-actor-less routes still build an account-only context via `build_account_context`. Resolution failures come back as `AuthorizationFailure` (`{status, body}`) — the auth domain stops short of producing a `Response` so each transport binds it. The RPC dispatcher folds the failure into a JSON-RPC envelope: `error.code` from `http_status_to_jsonrpc_error_code(failure.status)` (400 → `invalid_params` for `actor_required` / `actor_not_on_account`, 500 → `internal_error` for `no_actors_on_account` and `account_vanished`), `error.message` from the reason string, and `error.data: {reason, ...rest}` flattens any diagnostic fields (e.g. `available[]` for `actor_required`). The two 500 reasons are kept distinct: `no_actors_on_account` names a signup invariant violation (the actor enumeration succeeded and came back empty); `account_vanished` names a torn-read race (the account or actor row was deleted between credential validation and the dispatcher's follow-up `build_request_context` / `build_account_context` step). REST emits the same `body` directly via `c.json(body, status)` so its surface stays consistent with other middleware-emitted plain bodies. See `../auth/CLAUDE.md` § Middleware and the root `../../../CLAUDE.md` § Cleanest architecture takes priority for the rationale. +6. **Post-authorization auth** — `check_action_auth_post_authorization(spec.auth, request_context, credential_type)`. `keeper` requires `credential_type === 'daemon_token'` AND `has_role(request_context, 'keeper')` — the `has_role` alone is insufficient, session/bearer cannot elevate; failure attaches `{reason: ERROR_KEEPER_REQUIRES_DAEMON_TOKEN, credential_type}` under `error.data`. `{role}` uses `has_role`; failure attaches `{reason: ERROR_INSUFFICIENT_PERMISSIONS, required_role}`. Both surface as `forbidden` (-32002 / 403). `'authenticated'` already cleared step 4. +7. **Validate params** — `spec.input.safeParse(params)` where `params` is `raw_params` for `z.void()` schemas, otherwise `raw_params ?? {}` (HTTP convention: empty body = empty object). Registration rejects `z.null()` inputs because JSON-RPC 2.0 §4.2 forbids `params: null`. Failure → `invalid_params` with `{issues}`. +8. **Rate limit** — `spec.rate_limit` (`'ip' | 'account' | 'both'`); shared limiter pair with the WS dispatcher. Throttle-requests semantics — every invocation records, regardless of outcome. Account-keyed limiting bills `request_context.account.id` (every authenticated action has one). Failure → `rate_limited` (-32006 / 429) with `{retry_after}`. +9. **Dispatch** — `spec.side_effects` picks transaction (`route.db.transaction(tx => execute(tx))`) vs pool (`route.db`). Handler throws roll back the transaction — the catch sits outside the transaction boundary. +10. **DEV-only output validation** — `spec.output.safeParse(output)` runs only under `DEV` (from `esm-env`). On mismatch: `log.error(...)`, return response unchanged; never throws, never mutates status. Error paths: `ThrownJsonrpcError` (duck-typed via `err instanceof Error && typeof err.code === 'number'`) preserves code + data verbatim, status via @@ -284,9 +291,9 @@ Use this at every spec → handler binding site so handler-type errors surface at the factory call instead of at runtime: ```ts -export const create_admin_actions = (deps, options) => [ - rpc_action(admin_account_list_action_spec, account_list_handler), - rpc_action(admin_session_revoke_all_action_spec, session_revoke_all_handler), +export const create_account_actions = (deps, options) => [ + rpc_action(account_verify_action_spec, verify_handler), + rpc_action(account_session_list_action_spec, session_list_handler), // … ]; ``` @@ -297,9 +304,57 @@ handlers close over factory-captured deps (`log`, `on_audit_event`, `options.app_settings`, `options.max_tokens`), so per-pair typing via `rpc_action()` is the right shape here: the binding happens at construction time and the handler keeps its closure. Applied across -`admin_actions.ts` + `permit_offer_actions.ts` + `account_actions.ts` -— each pairs a spec imported from its `*_action_specs.ts` sibling with -a closure-bound handler. +`account_actions.ts` for the account-grain self-service surface (auth: +`'authenticated'`, no `acting` in input — the dispatcher does not +resolve an actor); the actor-implying registries (`admin_actions.ts`, +`permit_offer_actions.ts`, `self_service_role_actions.ts`) use the +`rpc_actor_action` variant below. + +### `rpc_actor_action(spec, handler)` — actor-narrowed variant + +Sibling factory for handlers whose dispatcher always resolves an acting +actor — actions with `auth: 'keeper' | {role}` or input that declares +`acting?: ActingActor`. The dispatcher's authorization phase populates +`ctx.auth` with a non-null `RequestActorContext` before any of these +handlers runs, so `rpc_actor_action`'s handler signature types +`ctx: ActionActorContext` (with `auth: RequestActorContext`) and the +handler body skips the `require_request_actor(ctx.auth)` narrowing +call: + +```ts +rpc_actor_action(permit_revoke_action_spec, async (input, ctx) => { + // ctx.auth is RequestActorContext — no narrowing needed. + const revoker_id = ctx.auth.actor.id; + // … +}); +``` + +The runtime binding is identical to `rpc_action` — both register the +same `RpcAction` shape on the action map. The change is compile-time +only: forgetting the actor narrowing on an actor-implying action used +to require either an `auth.actor!` non-null assertion or a +`require_request_actor` call; `rpc_actor_action` lets the type +reflect what the dispatcher already guarantees, which closes the bug +class where the narrowing call is missed and the handler is left +operating against a possibly-null actor. + +Applied uniformly across the actor-implying registries: every handler +in `admin_actions.ts` (all eleven specs declare `auth: {role: 'admin'}` + +- `acting: ActingActor` on input, so the dispatcher always resolves an + actor — list-style handlers that don't read `ctx.auth.actor` still bind + through `rpc_actor_action` for type-uniformity), every handler in + `permit_offer_actions.ts` (every spec there declares + `acting: ActingActor`), and the single `self_service_role_set` handler + in `self_service_role_actions.ts`. The rule is "actor-implying spec → + `rpc_actor_action`" regardless of whether the handler body reads + `ctx.auth.actor` — the dispatcher's runtime guarantee is what the type + should reflect, and uniform binding keeps a future handler that does + need the actor from accidentally landing on the looser binder. + Account-grain handlers in `account_actions.ts` keep `rpc_action`: + their auth is `'authenticated'`, their inputs don't declare `acting`, + so the dispatcher genuinely runs in `needs_actor: false` mode and + `ctx.auth.actor` is null. ## Transports (`transports.ts`, `transports_http.ts`, `transports_ws.ts`, `transports_ws_backend.ts`) diff --git a/src/lib/actions/action_codegen.ts b/src/lib/actions/action_codegen.ts index ccfedd5f..e5a95554 100644 --- a/src/lib/actions/action_codegen.ts +++ b/src/lib/actions/action_codegen.ts @@ -398,6 +398,19 @@ export const to_action_spec_output_identifier = (method: string): string => * follows so wrappers no longer pre-register imports a per-spec emit might * not actually use. * + * **Optional-input detection.** The emitted parameter is `input?:` (caller + * may omit the argument) when either (a) the schema accepts `undefined` — + * `z.optional(z.strictObject(...))` and similar wrappers — or (b) the + * schema accepts the empty object `{}` — `z.strictObject({acting: +ActingActor})` and other all-optional-fields strict objects. The second + * probe mirrors the dispatcher's HTTP convention (`raw_params ?? {}` for + * non-`z.void()` schemas in `actions/action_rpc.ts` / `http/route_spec.ts`): + * if a request with no params reaches the handler, this is the value the + * schema is asked to validate. A schema with required fields fails both + * probes and stays `input:` (required at the typed surface). Refinements + * and transforms run as part of `safeParse`, so their accept/reject + * decisions feed into the optional/required choice naturally. + * * @param options.sync_returns_value - When true (default), sync `local_call` * methods return the output value directly; when false they're wrapped in * `Result<{value, error}>` like async methods. Set to `false` if your @@ -415,8 +428,10 @@ export const generate_actions_api_method_signature = ( const collections_path = options?.collections_path ?? DEFAULT_COLLECTIONS_PATH; const innermost_type_name = zod_get_base_type(spec.input); const has_input = innermost_type_name !== 'null' && innermost_type_name !== 'void'; + const input_optional = + has_input && (spec.input.safeParse(undefined).success || spec.input.safeParse({}).success); const input_param = has_input - ? `input${spec.input.safeParse(undefined).success ? '?' : ''}: ActionInputs['${spec.method}']` + ? `input${input_optional ? '?' : ''}: ActionInputs['${spec.method}']` : 'input?: void'; if (has_input) imports.add_type(collections_path, 'ActionInputs'); imports.add_type(collections_path, 'ActionOutputs'); diff --git a/src/lib/actions/action_rpc.ts b/src/lib/actions/action_rpc.ts index 1640e96f..1fac106d 100644 --- a/src/lib/actions/action_rpc.ts +++ b/src/lib/actions/action_rpc.ts @@ -17,11 +17,19 @@ import {z} from 'zod'; import {DEV} from 'esm-env'; import type {Logger} from '@fuzdev/fuz_util/log.js'; -import type {RequestResponseActionSpec} from './action_spec.js'; +import type {ActionAuth, RequestResponseActionSpec} from './action_spec.js'; import {type RouteContext, type RouteSpec} from '../http/route_spec.js'; import {get_client_ip} from '../http/proxy.js'; -import {get_request_context, has_role, type RequestContext} from '../auth/request_context.js'; -import {CREDENTIAL_TYPE_KEY, type CredentialType} from '../hono_context.js'; +import { + apply_authorization_phase, + get_request_context, + has_role, + input_schema_declares_acting, + is_actor_implying_auth, + type RequestActorContext, + type RequestContext, +} from '../auth/request_context.js'; +import {ACCOUNT_ID_KEY, CREDENTIAL_TYPE_KEY, type CredentialType} from '../hono_context.js'; import type {Db} from '../db/db.js'; import {is_null_schema, is_void_schema} from '../http/schema_helpers.js'; import { @@ -34,6 +42,7 @@ import { import { jsonrpc_error_messages, jsonrpc_error_code_to_http_status, + http_status_to_jsonrpc_error_code, JSONRPC_ERROR_CODES, } from '../http/jsonrpc_errors.js'; import { @@ -100,6 +109,29 @@ export type ActionHandler = ( ctx: ActionContext, ) => TOutput | Promise; +/** + * `ActionContext` narrowed to a resolved acting actor. + * + * Returned to handlers bound via `rpc_actor_action` — the dispatcher's + * authorization phase has already run for actor-implying auth or + * `acting`-declaring inputs, so `ctx.auth.actor` is non-null and the + * handler skips the `require_request_actor(ctx.auth)` narrowing call. + */ +export interface ActionActorContext extends Omit { + auth: RequestActorContext; +} + +/** + * Handler function for an RPC action whose dispatcher always resolves an + * acting actor (`auth: 'keeper' | {role}` or input declaring + * `acting?: ActingActor`). Mirrors `ActionHandler` but tightens the + * `ctx.auth` slot to the non-null `RequestActorContext`. + */ +export type ActorActionHandler = ( + input: TInput, + ctx: ActionActorContext, +) => TOutput | Promise; + /** * An RPC action — combines an action spec with its handler. * @@ -137,6 +169,37 @@ export const rpc_action = ( handler: handler as ActionHandler, }); +/** + * Variant of `rpc_action` for handlers whose spec always resolves an + * acting actor — actions with `auth: 'keeper' | {role}` or inputs that + * declare `acting?: ActingActor`. The dispatcher's authorization phase + * runs before the handler, populates `ctx.auth` with a non-null + * `RequestActorContext`, and `rpc_actor_action` reflects that + * guarantee in the handler signature so the handler body skips the + * `require_request_actor(ctx.auth)` narrowing call (and the bug class + * where forgetting that call fails open against a `null` actor). + * + * The runtime binding is identical to `rpc_action` — both register the + * same `RpcAction` shape on the action map. Only the compile-time + * handler signature differs. + * + * @example + * ```ts + * rpc_actor_action(permit_revoke_action_spec, async (input, ctx) => { + * // ctx.auth is RequestActorContext — no require_request_actor() needed. + * const revoker_id = ctx.auth.actor.id; + * // ... + * }); + * ``` + */ +export const rpc_actor_action = ( + spec: TSpec, + handler: ActorActionHandler, z.infer>, +): RpcAction => ({ + spec, + handler: handler as ActionHandler, +}); + /** Options for `create_rpc_endpoint`. */ export interface CreateRpcEndpointOptions { /** Mount path for the endpoint (e.g., `/api/rpc`). */ @@ -154,10 +217,12 @@ export interface CreateRpcEndpointOptions { */ action_ip_rate_limiter?: RateLimiter | null; /** - * Per-actor rate limiter consulted for actions whose spec declares + * Per-account rate limiter consulted for actions whose spec declares * `rate_limit: 'account'` or `'both'`. Keyed on - * `request_context.actor.id`. `null` disables the account check. - * Same limiter is shared with the WebSocket action dispatcher. + * `request_context.account.id` (account-grain — billed to the + * authenticated account regardless of which actor was resolved). + * `null` disables the account check. Same limiter is shared with the + * WebSocket action dispatcher. */ action_account_rate_limiter?: RateLimiter | null; } @@ -172,18 +237,41 @@ const jsonrpc_error_response = ( }); /** - * Check auth for an action spec against the request context. + * Pre-validation auth gate — fires before input validation so missing + * credentials short-circuit with `unauthenticated` instead of leaking + * a `invalid_params` error for methods with required input. * - * @returns a JSON-RPC error object if auth fails, or `null` if authorized + * Reads `c.var.auth_account_id` (set by the auth middleware). Returns + * `unauthenticated` when `auth !== 'public'` and no account is on the + * request. Role / keeper checks are deferred until after the + * authorization phase populates the request context — see + * `check_action_auth_post_authorization`. + * + * @returns a JSON-RPC error object if no account is on the request, or `null` */ -const check_action_auth = ( - auth: RequestResponseActionSpec['auth'], +const check_action_auth_pre_validation = ( + auth: ActionAuth, + account_id: string | null, +): JsonrpcErrorObject | null => { + if (auth === 'public') return null; + if (account_id == null) return jsonrpc_error_messages.unauthenticated(); + return null; +}; + +/** + * Post-authorization auth gate — fires after the dispatcher's authorization + * phase has populated `REQUEST_CONTEXT_KEY` with the resolved actor + + * permits. Enforces `role` and `keeper` requirements; `'public'` and + * `'authenticated'` already cleared the pre-validation gate. + * + * @returns a JSON-RPC error object if permit / credential check fails, or `null` + */ +const check_action_auth_post_authorization = ( + auth: ActionAuth, request_context: RequestContext | null, credential_type: CredentialType | null, ): JsonrpcErrorObject | null => { - if (auth === 'public') return null; - if (!request_context) return jsonrpc_error_messages.unauthenticated(); - if (auth === 'authenticated') return null; + if (auth === 'public' || auth === 'authenticated') return null; if (auth === 'keeper') { // keeper requires daemon_token credential type AND the keeper role. // API tokens and session cookies cannot access keeper actions even @@ -219,9 +307,18 @@ const check_action_auth = ( * 1. **Parse envelope** — POST: JSON body as `JsonrpcRequest`. GET: `method` * and `params` from query string. * 2. **Lookup method** — find the `RpcAction` by method name. - * 3. **Auth check** — verify identity against the action's `auth` requirement. - * 4. **Validate params** — parse input against the action's `input` schema. - * 5. **Dispatch** — acquire DB handle (transaction for mutations, pool for reads), + * 3. **Pre-validation auth** — short-circuit `unauthenticated` when no + * account is on the request, before input validation runs. + * 4. **Authorization phase** — resolve the acting actor (when the action's + * auth requires permits or its input declares `acting?: ActingActor`) + * and build the request context. Runs before input validation so + * permit-grain auth checks return 403 before 400 invalid_params; + * `acting` is read from raw params via a string typeguard. + * 5. **Post-authorization auth** — enforce role / keeper requirements + * against the request context. + * 6. **Validate params** — parse input against the action's `input` schema. + * 7. **Rate limit** — per-action IP / account throttling. + * 8. **Dispatch** — acquire DB handle (transaction for mutations, pool for reads), * construct `ActionContext`, call handler, return JSON-RPC response. * * GET is restricted to `side_effects: false` actions (cacheable reads). @@ -247,7 +344,6 @@ export const create_rpc_endpoint = (options: CreateRpcEndpointOptions): Array(); for (const action of actions) { if (action_map.has(action.spec.method)) { @@ -291,10 +387,7 @@ export const create_rpc_endpoint = (options: CreateRpcEndpointOptions): Array { @@ -430,13 +581,13 @@ export const create_rpc_endpoint = (options: CreateRpcEndpointOptions): Array { */ action_ip_rate_limiter?: RateLimiter | null; /** - * Per-actor rate limiter consulted for actions whose spec declares + * Per-account rate limiter consulted for actions whose spec declares * `rate_limit: 'account'` or `'both'`. Keyed on - * `request_context.actor.id`. `null` (or omitted) disables the + * `request_context.account.id`. `null` (or omitted) disables the * account check. Same limiter is shared with the HTTP RPC dispatcher. */ action_account_rate_limiter?: RateLimiter | null; @@ -266,7 +266,7 @@ export const register_action_ws = ( // Upgrade-time auth extraction — `require_auth` middleware has already // rejected unauthenticated requests, so request_context is guaranteed // non-null by the time we get here. - const request_context = get_request_context(c)!; + const request_context = require_request_context(c); const account_id: Uuid = request_context.account.id; // Resolved at upgrade — every message on this socket shares the // same client IP, so we capture once and reuse for rate-limit @@ -517,14 +517,14 @@ export const register_action_ws = ( } } if (account_check) { - const result = action_account_rate_limiter.check(request_context.actor.id); + const result = action_account_rate_limiter.check(request_context.account.id); if (!result.allowed) { send_rate_limited(result.retry_after); return; } } if (ip_check) action_ip_rate_limiter.record(client_ip); - if (account_check) action_account_rate_limiter.record(request_context.actor.id); + if (account_check) action_account_rate_limiter.record(request_context.account.id); } // Look up handler — method is validated against spec_by_method above. diff --git a/src/lib/actions/register_ws_endpoint.ts b/src/lib/actions/register_ws_endpoint.ts index 37f68695..b9ba7265 100644 --- a/src/lib/actions/register_ws_endpoint.ts +++ b/src/lib/actions/register_ws_endpoint.ts @@ -7,7 +7,12 @@ * 1. `verify_request_source(allowed_origins)` — reject disallowed origins * before the upgrade handshake runs. * 2. `require_auth` — reject unauthenticated upgrades. - * 3. Optional `require_role(required_role)` — for endpoints gated to a + * 3. **Authorization phase** — resolve the acting actor against the + * authenticated account plus an optional `?acting=` query string, + * and build the `RequestContext` that per-message dispatch reads. + * Multi-actor accounts must supply `?acting` to pick a persona; + * single-actor accounts work without it. + * 4. Optional `require_role(required_role)` — for endpoints gated to a * specific role. * * Then delegates to `register_action_ws` for per-message JSON-RPC @@ -16,11 +21,13 @@ * @module */ +import type {Context, MiddlewareHandler} from 'hono'; import {Logger} from '@fuzdev/fuz_util/log.js'; -import {require_auth, require_role} from '../auth/request_context.js'; +import {apply_authorization_phase, require_auth, require_role} from '../auth/request_context.js'; import {verify_request_source} from '../http/origin.js'; import type {RoleName} from '../auth/role_schema.js'; +import type {Db} from '../db/db.js'; import { register_action_ws, type RegisterActionWsOptions, @@ -39,32 +46,67 @@ export interface RegisterWsEndpointOptions< */ allowed_origins: Array; /** - * Role required to upgrade. Omit for any authenticated account (`require_auth` - * alone); set to e.g. `ROLE_ADMIN` to gate the endpoint behind a role. The - * per-action `auth` in each spec still applies at dispatch time — this is - * a coarse upgrade-time gate. + * Pool-level database used for upgrade-time actor resolution + permit + * load. Ran once per connection, then the result is reused for every + * message on the socket. + */ + db: Db; + /** + * Role required to upgrade. Omit for any authenticated account + * (`require_auth` + actor resolution alone); set to e.g. `ROLE_ADMIN` + * to gate the endpoint behind a role. The per-action `auth` in each + * spec still applies at dispatch time — this is a coarse upgrade-time + * gate. */ required_role?: RoleName; } +/** + * Upgrade-time authorization middleware. Resolves the acting actor for + * the WS connection (single-actor default; multi-actor must supply + * `?acting=`) and builds the `RequestContext` that per-message + * dispatch reads. Returns 400 on resolution failure. + */ +const create_ws_authorization_middleware = (db: Db): MiddlewareHandler => { + return async (c: Context, next): Promise => { + const acting_param = c.req.query('acting'); + // `apply_authorization_phase` is a no-op when the test-harness flag + // `TEST_CONTEXT_PRESET_KEY` is set (escape hatch for pre-baked + // `RequestContext`). Failure shape is `{status, body}`; the WS + // upgrade is a plain HTTP response, so bind it the same way REST does. + const failure = await apply_authorization_phase({db}, c, true, acting_param ?? undefined); + if (failure) return c.json(failure.body, failure.status); + await next(); + }; +}; + /** * Mount a WebSocket endpoint with the standard upgrade stack (origin check - * + auth + optional role) and JSON-RPC dispatch. + * + auth + actor resolution + optional role) and JSON-RPC dispatch. * * Returns the `BackendWebsocketTransport` (supplied or freshly * created), same as `register_action_ws` — retain it to wire * `create_ws_auth_guard` on `on_audit_event` or to broadcast. * - * @mutates options.app - applies origin/auth/role middleware via `app.use`, + * @mutates options.app - applies origin/auth/authorization/role middleware via `app.use`, * then registers the `GET path` route via the inner `register_action_ws` */ export const register_ws_endpoint = ( options: RegisterWsEndpointOptions, ): RegisterActionWsResult => { - const {app, path, allowed_origins, required_role, log = new Logger('[ws]'), ...rest} = options; + const { + app, + path, + allowed_origins, + db, + required_role, + log = new Logger('[ws]'), + ...rest + } = options; app.use(path, verify_request_source(allowed_origins)); app.use(path, require_auth); + app.use(path, create_ws_authorization_middleware(db)); if (required_role !== undefined) { app.use(path, require_role(required_role)); } diff --git a/src/lib/actions/transports.ts b/src/lib/actions/transports.ts index 0edfabe7..7124fb41 100644 --- a/src/lib/actions/transports.ts +++ b/src/lib/actions/transports.ts @@ -95,7 +95,6 @@ export class Transports { register_transport(transport: Transport): void { this.#transport_by_name.set(transport.transport_name, transport); // TODO maybe ensure unregistering of any previous transport? - // Set current transport if not already set if (!this.#current_transport) { this.#current_transport = transport; } @@ -158,7 +157,6 @@ export class Transports { } #get_first_ready(transport_name?: TransportName | Array): Transport | null { - // First try the specified transport(s) if provided if (transport_name) { const transport_names = Array.isArray(transport_name) ? transport_name : [transport_name]; @@ -170,12 +168,10 @@ export class Transports { } } - // Then try the current transport if it's ready if (this.#current_transport?.is_ready()) { return this.#current_transport; } - // Finally, try any other available transport for (const transport of this.#transport_by_name.values()) { if (transport.is_ready()) { return transport; diff --git a/src/lib/auth/CLAUDE.md b/src/lib/auth/CLAUDE.md index dc8a7f51..c8e1bb06 100644 --- a/src/lib/auth/CLAUDE.md +++ b/src/lib/auth/CLAUDE.md @@ -84,8 +84,10 @@ Design notes: ### Identity entities (`account_schema.ts`) - `Account` (primary identity, holds `password_hash`), `Actor` (the entity - that acts — owns cells, holds permits, appears in audit trails; 1:1 with - account in v1), `Permit` (time-bounded, revocable grant of a role to an + that acts — owns cells, holds permits, appears in audit trails; an account + may host one or more actors, with the dispatcher's authorization phase + resolving the acting actor per-request via `acting?: ActingActor` on + inputs), `Permit` (time-bounded, revocable grant of a role to an actor — carries `scope_id`, `source_offer_id`, `revoked_reason`), `AuthSession` (server-side, keyed by blake3), `ApiToken`. - Every `id` / `*_id` field on entity interfaces, `*Json` schemas, and @@ -209,6 +211,50 @@ Zod enum; `AuditOutcome` is `'success' | 'failure'`. `AuditLogListOptions` (supports `since_seq` for SSE reconnection gap fill); `AUDIT_LOG_DEFAULT_LIMIT = 50` (default page size, lives on the schema side so client codegen can import it without dragging in the query layer). + `target_actor_id` lives parallel to `target_account_id` on both row + and input. **Rule** — `target_actor_id` is populated when the event + subject is bound to a specific actor. Concretely: `permit_revoke` + and `permit_grant` (admin direct-grant, self-service toggle, and + in-tx accept all populate both target columns — the grantee is the + subject regardless of initiator), in-tx `permit_offer_accept` on + accept, and `permit_offer_decline` always populate both target + columns (decline joins `from_account_id` into the RETURNING so the + "both populated → same account" invariant holds uniformly). + Offer-shape events (`permit_offer_create`, `_expire`, `_retract`, + `_supersede`) populate `target_actor_id` when the offer was + actor-targeted at create time (`permit_offer.to_actor_id` set), + null when the offer was account-grain (any actor on + `to_account_id` may accept). Account-shape events (login, logout, + signup, bootstrap, password change, session/token revoke, + app_settings update, invite events) stay account-grain on both + `target_actor_id` **and** `actor_id` — the operation is performed + by the account, and a multi-actor user must be able to log out + (or change password, or revoke sessions) without first picking an + acting actor. Permit/admin/offer events keep recording the + initiator's actor in `actor_id`. + SSE/WS socket-close keys on `target_account_id ?? account_id` + (sessions stay account-grain at the routing layer even though + they bind to a specific actor at request-context resolution time — + see request_context.ts). +- **Actor-targetable offers** — `permit_offer.to_actor_id` is the + optional column that flips an offer from account-grain (null, + default) to actor-grain (non-null). `query_permit_offer_create` + validates the actor↔account binding in one SELECT and rejects with + `PermitOfferActorAccountMismatchError` when the supplied actor isn't + on `to_account_id`. `query_accept_offer` rejects wrong-actor accepts + on actor-targeted offers with `PermitOfferActorMismatchError` — + surfaced to RPC callers as `permit_offer_actor_mismatch`. Closes the + audit hole where offer-shape events left `target_actor_id` null even + when the recipient binding was known at offer time. +- **`emit_permit_target_event` helper** — the canonical entry point + for permit-shape audit emissions. Takes `(ctx, auth, deps, {event_type, +target_account_id, target_actor_id, metadata, outcome?})` and lifts + the `actor_id` / `account_id` / `ip` boilerplate that every + `permit_*` audit emit site repeats. Use this instead of + `audit_log_fire_and_forget` for any event populating one of the + `target_*_id` columns; reach for the lower-level helper only when + the event is non-permit-shape (e.g., `app_settings_update`, + bootstrap, signup). - Client-safe: `AuditLogEventJson`, `AuditLogEventWithUsernamesJson`, `PermitHistoryEventJson`, `AdminSessionJson`. - `get_audit_metadata(event)` type-narrows after checking `event_type`. @@ -334,7 +380,12 @@ CRUD + listing: - `query_update_account_password`, `query_delete_account` (cascades to actors, permits, sessions, tokens). - `query_account_has_any` — used by bootstrap for belt-and-suspenders check. -- `query_actor_by_account`, `query_actor_by_id`. +- `query_actors_by_account` — list every actor on an account, ordered + by `created_at`. Used by `resolve_acting_actor` to pick the unique + actor on single-actor accounts or surface `actor_required` when the + account has multiple actors. +- `query_actor_by_id` — direct lookup by id; preferred when the caller + already has an actor id in scope. - `query_admin_account_list` — composes accounts + actors + active permits + pending inbound offers with **four flat queries** instead of N+1. Pending offers exclude `message` on purpose (cross-admin visibility). Returns @@ -347,8 +398,14 @@ CRUD + listing: uses `IS NOT DISTINCT FROM` (plain `=` would miss the NULL-scope conflict case). - `query_permit_find_active_role_for_actor(deps, permit_id, actor_id)` — - actor-scoped read, so IDOR protection is consistent with revoke. Returns - `{role}` or `null`. + actor-scoped read, so IDOR protection is consistent with revoke. + Returns `{role, account_id}` (the actor's `account_id` joined in) or + `null`. The `account_id` flows into the audit envelope's + `target_account_id` and the SSE/WS socket-close fan-out target — + collapsing what used to be a second `query_actor_by_id` round-trip in + the revoke handler into one read closes the small TOCTOU window + where the actor row could be deleted between the IDOR check and the + actor lookup. - **`query_revoke_permit(deps, permit_id, actor_id, revoked_by, reason?)`** — actor-scoped IDOR guard (returns `null` if the permit belongs to a different actor). Supersedes pending offers for the revoked permit's @@ -375,8 +432,9 @@ CRUD + listing: active permit at `scope_id` (role-agnostic) and supersedes every pending offer at `scope_id` (tuple-matched and orphan, undifferentiated) in the caller's transaction. Returns `RevokeForScopeResult = {revoked, superseded_offers}` - — `revoked` carries `account_id` for `permit_revoke` fan-out; - `superseded_offers` carries `from_account_id`. Caller emits + — `revoked` carries both `actor_id` (drives `target_actor_id` audit + envelopes) and `account_id` (drives `target_account_id` for socket-close + fan-out); `superseded_offers` carries `from_account_id`. Caller emits `permit_offer_supersede` audits with `reason: 'scope_destroyed'` and `cause_id: ` per superseded offer (the cause is the scope deletion, not any individual permit revoke). Use from a @@ -389,9 +447,12 @@ CRUD + listing: Error classes (all extend `Error` with stable `.name` — never use `instanceof` against plain messages): -- `PermitOfferSelfTargetError` — grantor offered themselves. Enforced via - cross-row JOIN in `query_permit_offer_create` (rather than CHECK) to avoid - denormalized columns. +- `PermitOfferSelfTargetError` — grantor offered themselves. Enforced + via a single SELECT on the grantor's `actor.account_id` in + `query_permit_offer_create` (resolving from the grantor side keeps + the check multi-actor-correct — the grantor → account binding stays + 1:1 by definition of `actor`, while the recipient account may host + many actors under multi-actor). - `PermitOfferAlreadyTerminalError` — offer exists for the caller but is accepted / declined / retracted / superseded. - `PermitOfferExpiredError` — pending but past `expires_at` (distinct from @@ -515,19 +576,24 @@ run'` if the seed somehow missed (defensive — migrations always seed). - `query_audit_log_list(deps, options?)` — supports `event_type`, `event_type_in`, `account_id` (matches `account_id` OR `target_account_id`), `outcome`, `since_seq`, `limit`, `offset`. -- `query_audit_log_list_with_usernames` — joins twice to `account`. + `target_actor_id` filtering is not yet exposed; will land alongside + the admin-viewer's actor-grain forensics pass. +- `query_audit_log_list_with_usernames` — joins twice to `account` + (chains `target_account_id` for the `target_username` field). + `target_actor_id` is on the row but not currently joined to actor + for a name; the admin viewer will resolve via `actor_lookup` / + `actor.name` when the actor-grain forensics pass lands. - `query_audit_log_list_for_account`, `query_audit_log_list_permit_history` (filters to `permit_grant` / `permit_revoke`). - `query_audit_log_cleanup_before`. - **`audit_log_fire_and_forget(route, input, deps)`** — writes to `route.background_db` (pool-level), so audit entries persist - even when the request transaction rolls back. `deps` is an - `AuditLogFireAndForgetDeps` bundle (`{log, on_audit_event, audit_log_config?}`) - — structurally compatible with `Pick`, - so call sites pass the surrounding deps object directly. Bundling - replaces the prior 5-arg positional signature; consumers that forgot - the trailing `config` would silently fall back to - `BUILTIN_AUDIT_LOG_CONFIG`. Write and `on_audit_event` callback + even when the request transaction rolls back. `deps` is the shared + `AuditEmitDeps` bundle (`{log, on_audit_event, audit_log_config?}`) + from `auth/deps.ts`, so call sites pass the surrounding deps object + directly. Bundling replaces the prior 5-arg positional signature; + consumers that forgot the trailing `config` would silently fall back + to `BUILTIN_AUDIT_LOG_CONFIG`. Write and `on_audit_event` callback failures are logged separately. Pushes onto `route.pending_effects` for test flushing. @@ -566,11 +632,14 @@ by `sequence`, then enforces: 3. **Run the pending tail** (`code[applied.length..]`) inside a single chain transaction; each `INSERT` uses `sequence = max(sequence) + 1`. -**Append-only after first publish.** Once a fuz_app version containing a -migration is published, the migration's name and position are frozen. -Pre-publish, anything goes; the cliff is the publish event. Body edits to -a published migration slip past the runner (no content hashing) — schema- -snapshot tests in consumers catch these. +**Schema is not stabilized yet — append-only is NOT the rule today.** +While fuz_app is pre-stable, migration bodies, names, and positions can +change freely between versions and consumers upgrading across a schema +change are expected to drop and re-bootstrap their dev/test databases. +Once the schema is declared stable, a hard append-only-after-publish rule +will apply (with the cliff called out in that release's notes). Until +then bias toward editing the existing migration entries rather than +appending patch migrations. `MigrationError` is the only error class thrown from `run_migrations` / `baseline`; branch on `.kind` (never on message text). Kinds: @@ -637,47 +706,109 @@ consciously violate the contract. ## Middleware -Side of the chain ordering (concept-level — see the root `../../../CLAUDE.md` -§Middleware Ordering for the canonical assembly order): - -**Session parsing is separate from auth enforcement.** The session / -request-context middleware populates `{account, actor, permits}` from a -cookie but does not 401; `require_auth` / `require_role` / `require_keeper` -enforce. This lets `/login` and `/bootstrap` participate in cookie refresh -without being blocked. +See the root `../../../CLAUDE.md` §Middleware Ordering for the canonical +assembly order. Two-phase identity: + +- **Authentication** runs in middleware (session / bearer / daemon + token). Sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY` on a valid + credential. Account-only — never loads actor or permits, never + populates `REQUEST_CONTEXT_KEY`. **Production-middleware invariant**: + no production middleware on the auth path (session / bearer / daemon + token) populates `REQUEST_CONTEXT_KEY`; identity-related context vars + it does set are `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, and (for + sessions / bearer) `AUTH_SESSION_TOKEN_HASH_KEY` / + `AUTH_API_TOKEN_ID_KEY`. Other middleware (proxy, app server, + session-cookie parser) sets unrelated vars like `client_ip`, + `pending_effects`, and the session-token slot keyed by + `session_options.context_key` (default `auth_session_id`) — those + are out of scope for this invariant. Test harnesses pre-populate + `REQUEST_CONTEXT_KEY` + `TEST_CONTEXT_PRESET_KEY` to bypass DB-backed + actor resolution; production code that consults + `REQUEST_CONTEXT_KEY` is reading test escape-hatch state, never live + middleware output. +- **Authorization** runs in the route-spec wrapper / RPC dispatcher + before input validation (matches the RPC dispatcher's order so 401 / + 403 surface ahead of `invalid_params`). When the route's input + declares `acting?: ActingActor` or its auth requires permits + (`role` / `keeper`), the authorization phase calls + `resolve_acting_actor` over the raw `acting` value extracted from + query (GET) or pre-parsed body (mutating methods), builds the + actor-bound `RequestContext`, and sets `REQUEST_CONTEXT_KEY` before + the role / keeper guards fire. Account-grain routes skip resolution + and run with `RequestContext.actor: null`. Resolution failures come + back as `AuthorizationFailure` (`{status, body}`) — the auth domain + stops short of constructing a `Response` so each transport binds the + same failure to its wire shape: REST emits `c.json(body, status)`; + the WS upgrade does the same; the RPC dispatcher folds it into a + JSON-RPC envelope (`{jsonrpc, id, error: {code, message, data}}`) + with `error.message` carrying the reason string and + `error.data: {reason, ...rest}` flattening any diagnostic fields + (e.g. `available[]` for `actor_required`). The two 500 reasons the + phase emits are kept distinct: `no_actors_on_account` names a signup + invariant violation (`resolve_acting_actor` enumerated zero actors); + `account_vanished` names a torn-read race (`build_request_context` / + `build_account_context` returned null after a successful resolve — + the account or actor row was deleted between credential validation + and the dispatcher's follow-up read). See the root + `../../../CLAUDE.md` § Cleanest architecture takes priority for the + rationale. + +Session parsing is separate from auth enforcement — login / bootstrap +participate in cookie refresh without being blocked. `require_auth` / +`require_role` / `require_keeper` are the gates. ### `request_context.ts` -- `RequestContext = {account, actor, permits}`. +- `RequestContext = {account, actor: Actor | null, permits}`. `actor` + is null on account-grain routes (no `acting`, no permit-requiring + auth); `permits` is empty in that case. - `REQUEST_CONTEXT_KEY` — Hono context variable name. - **`AUTH_SESSION_TOKEN_HASH_KEY`** — holds the blake3 session hash. Set on successful session lookup; `null` for unauthenticated or non-session credentials. Exposed so SSE endpoints can scope per-session resource identity (the audit-log SSE uses this to close only the revoked session's stream on `session_revoke`). -- `get_request_context(c)`, `require_request_context(c)` (throws on misuse - — misconfigured middleware surfaces immediately). +- `get_request_context(c)`, `require_request_context(c)` (throws on + misuse — handler ran without authorization phase wiring). - **In-memory permit predicates** — `has_role(ctx, role, now?)`, `has_scoped_role(ctx, role, scope_id, now?)`, `has_any_scoped_role(ctx, roles, scope_id, now?)`. All three take - `RequestContext | null` (null returns `false`) so they drop into - `auth: 'public'` handlers without a manual narrow. `scope_id === null` - matches global permits only; UUID matches that exact scope. Empty - `roles` short-circuits `has_any_scoped_role` to `false`. Decide-time - predicates only — the predicate / mutation race window is the same as - the SQL `query_permit_has_role` style and only a transactional re-check - inside the UPDATE/INSERT closes it. -- `build_request_context(deps, account_id)` — shared helper used by - session, bearer, and daemon token middleware; does - `account → actor → permits` and returns `null` if either lookup misses. + `RequestContext | null` and return `false` for null ctx and for + account-grain ctx (`actor: null`, empty `permits`); they drop into + `auth: 'public'` and account-grain handlers without a manual narrow. + `scope_id === null` matches global permits only; UUID matches that + exact scope. Empty `roles` short-circuits `has_any_scoped_role` to + `false`. Decide-time predicates only — the predicate / mutation + race window is the same as the SQL `query_permit_has_role` style and + only a transactional re-check inside the UPDATE/INSERT closes it. +- `build_request_context(deps, account_id, actor_id)` — loads + `account` + the named `actor` + active permits. Verifies + `actor.account_id === account.id`; returns `null` when the account + or actor is missing, or when they don't bind to each other. Called + by the authorization phase after `resolve_acting_actor` succeeds — + a null return there is a torn read (account/actor deleted mid-request) + rather than the missing-actor invariant `resolve_acting_actor` would + have caught upstream, so the phase surfaces `ERROR_ACCOUNT_VANISHED` + on null. Not called from middleware. +- `resolve_acting_actor(deps, account_id, acting_actor_id)` — uniform + resolver. Resolves to `{ok: true, actor_id}` for 1 actor (any + `acting`) or matching supplied id; `actor_required` with the + available list when multi-actor and `acting` is missing; + `actor_not_on_account` when supplied id doesn't belong; `no_actors` + defensively. - `refresh_permits(ctx, deps)` — reloads permits without mutating the - original (concurrent-safe). Useful for long-lived WebSocket connections. + original (concurrent-safe). Useful for long-lived WebSocket + connections that have an acting actor. - `create_request_context_middleware(deps, log, session_context_key?)` — - reads session token from context, hashes, validates, loads context, sets - `CREDENTIAL_TYPE_KEY = 'session'`, fires `session_touch_fire_and_forget`. -- `require_auth` — 401 (`ERROR_AUTHENTICATION_REQUIRED`) on no context. -- `require_role(role)` — 401 on no context, 403 (`ERROR_INSUFFICIENT_PERMISSIONS` - - `required_role`) on missing role. + validates the session and sets `c.var.account_id` + + `CREDENTIAL_TYPE_KEY = 'session'` + `AUTH_SESSION_TOKEN_HASH_KEY`. + Touches the session fire-and-forget. Does not load actor / permits. +- `require_auth` — 401 (`ERROR_AUTHENTICATION_REQUIRED`) when + `account_id` is null. Does not require an acting actor. +- `require_role(role)` — 401 on no auth, 403 + (`ERROR_INSUFFICIENT_PERMISSIONS` + `required_role`) when permits + don't carry the role. Implies the authorization phase ran (a + role-gated route always resolves an actor). ### `bearer_auth.ts` @@ -951,7 +1082,7 @@ Closure state: `all_admin_action_specs: Array` — codegen-ready registry of all eleven specs (always includes the two app-settings specs). -Deps: `AdminActionDeps = Pick`. The `audit_log_config` slot flows through to `audit_log_fire_and_forget` so consumer-extended event-type metadata gets validated. +Deps: `AdminActionDeps = AuditEmitDeps` — the shared `Pick` slice every audit-emitting site picks (defined in `auth/deps.ts`). The `audit_log_config` slot flows through to `audit_log_fire_and_forget` so consumer-extended event-type metadata gets validated. ### `permit_offer_action_specs.ts` + `permit_offer_actions.ts` — seven RPC actions @@ -986,15 +1117,19 @@ Six offer-lifecycle methods plus `permit_revoke`. Authorization is a mix: **`actor_id`, not `account_id`** — permits are actor-scoped and deriving actor from account collapses under multi-actor accounts. -| Spec | Input | Output | -| ---------------------------------- | -------------------------------------------- | ------------------------------------------ | -| `permit_offer_create_action_spec` | `{to_account_id, role, scope_id?, message?}` | `{offer}` | -| `permit_offer_accept_action_spec` | `{offer_id}` | `{permit_id, offer, superseded_offer_ids}` | -| `permit_offer_decline_action_spec` | `{offer_id, reason?}` | `{ok}` | -| `permit_offer_retract_action_spec` | `{offer_id}` | `{ok}` | -| `permit_offer_list_action_spec` | `{account_id?}` | `{offers}` | -| `permit_offer_history_action_spec` | `{account_id?, limit?, offset?}` | `{offers}` | -| `permit_revoke_action_spec` | `{actor_id, permit_id, reason?}` | `{ok, revoked}` | +Every input row below also carries the shared `acting?: ActingActor` +field that the dispatcher's authorization phase reads off the raw +params (omitted from the table for brevity). + +| Spec | Input | Output | +| ---------------------------------- | ---------------------------------------------------------- | ------------------------------------------ | +| `permit_offer_create_action_spec` | `{to_account_id, to_actor_id?, role, scope_id?, message?}` | `{offer}` | +| `permit_offer_accept_action_spec` | `{offer_id}` | `{permit_id, offer, superseded_offer_ids}` | +| `permit_offer_decline_action_spec` | `{offer_id, reason?}` | `{ok}` | +| `permit_offer_retract_action_spec` | `{offer_id}` | `{ok}` | +| `permit_offer_list_action_spec` | `{account_id?}` | `{offers}` | +| `permit_offer_history_action_spec` | `{account_id?, limit?, offset?}` | `{offers}` | +| `permit_revoke_action_spec` | `{actor_id, permit_id, reason?}` | `{ok, revoked}` | Error reason constants (exported as `as const` literals): @@ -1004,6 +1139,11 @@ Error reason constants (exported as `as const` literals): - `ERROR_OFFER_NOT_FOUND` (`'offer_not_found'` — 404-over-403 IDOR mask) - `ERROR_OFFER_ROLE_NOT_GRANTABLE` (`'offer_role_not_grantable'`) - `ERROR_OFFER_NOT_AUTHORIZED` (`'offer_not_authorized'`) +- `ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH` (`'offer_actor_account_mismatch'` — + `permit_offer_create` was called with a `to_actor_id` that does not + belong to `to_account_id`) +- `ERROR_OFFER_ACTOR_MISMATCH` (`'offer_actor_mismatch'` — + actor-targeted offer was accepted by an actor other than `to_actor_id`) Plus re-uses from `../http/error_schemas.ts`: `ERROR_PERMIT_NOT_FOUND`, `ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_INSUFFICIENT_PERMISSIONS`, @@ -1020,11 +1160,18 @@ Failure-outcome audit events emitted (success and failure rows both carry `ip: ctx.client_ip` — uniform with the admin and self-service surfaces): - `permit_offer_create` failure — `web_grantable` denial, `authorize` - denial, self-target rejection (all three denial paths emit the same - audit row with `target_account_id`). + denial, self-target rejection, and actor-account mismatch all emit + the same audit row via `emit_create_failure_audit`. `target_account_id` + carries `input.to_account_id`; `target_actor_id` echoes + `input.to_actor_id` when supplied so failure rows match the + success-shape envelope of actor-targeted offers (null on + account-grain offers — see audit_log_schema rule). - `permit_revoke` failure — `web_grantable` denial after IDOR / role lookup succeeded. The admin-role-denied path (pre-IDOR) emits no audit, - matching the middleware auth-guard precedent. + matching the middleware auth-guard precedent. `target_account_id` + + `target_actor_id` both populated (the IDOR-passing branch resolves + the target actor before the gate; the subject is an actor-bound + permit). WS notifications (post-commit via `emit_after_commit` from `../http/pending_effects.js` — swallows exceptions so one failed send @@ -1038,7 +1185,7 @@ can't starve others; see `../http/CLAUDE.md` §Pending Effects): - Revoke → `permit_revoke` to revokee + one `permit_offer_supersede` per superseded sibling. -Deps: `PermitOfferActionDeps extends Pick & {notification_sender?: NotificationSender | null}`. +Deps: `PermitOfferActionDeps extends AuditEmitDeps & {notification_sender?: NotificationSender | null}`. Notification sender is optional — when absent, WS fan-out is silently skipped (DB-only side effects still happen). @@ -1141,7 +1288,7 @@ Audit events emitted (via `audit_log_fire_and_forget` with `ip: ctx.client_ip`): IP is the resolved trusted-proxy value from `ActionContext.client_ip`, matching the REST handler convention. -Deps: `AccountActionDeps = Pick`. +Deps: `AccountActionDeps = AuditEmitDeps`. Options: `{max_tokens?: number | null}` — defaults to `DEFAULT_MAX_TOKENS` from `account_routes.ts`; `null` disables the cap. @@ -1195,7 +1342,7 @@ roundtrip — then `query_grant_permit` for the actual insert. Revoke branch fil `create_standard_rpc_actions` — `eligible_roles` is app-specific, opt-in, spread alongside the standard bundle when needed. -Deps: `SelfServiceRoleActionDeps = Pick`. +Deps: `SelfServiceRoleActionDeps = AuditEmitDeps`. `all_self_service_role_action_specs: ReadonlyArray` — codegen-ready registry of the single unified spec. @@ -1245,6 +1392,12 @@ resulting permit. - **`RouteFactoryDeps = Omit`** — for route factories. Route handlers receive DB access via `RouteContext`, so factories don't capture a pool-level `Db`. +- **`AuditEmitDeps = Pick`** + — the slice every audit-emitting site needs. Used by `audit_log_fire_and_forget` + / `emit_permit_target_event` (the primitives) and aliased by every + action-factory deps type (`AdminActionDeps`, `AccountActionDeps`, + `PermitOfferActionDeps`, `SelfServiceRoleActionDeps`) so the five + factories stop spelling the same `Pick` independently. See root `../../../CLAUDE.md` §AppDeps Vocabulary for the capability / options / runtime-state split across the whole project. diff --git a/src/lib/auth/account_actions.ts b/src/lib/auth/account_actions.ts index 6c3b4122..004b8a55 100644 --- a/src/lib/auth/account_actions.ts +++ b/src/lib/auth/account_actions.ts @@ -38,7 +38,8 @@ import { import {generate_api_token} from './api_token.js'; import {audit_log_fire_and_forget} from './audit_log_queries.js'; import {DEFAULT_MAX_TOKENS} from './account_routes.js'; -import type {RouteFactoryDeps} from './deps.js'; +import type {AuditEmitDeps} from './deps.js'; +import {require_request_auth} from './request_context.js'; import { account_verify_action_spec, account_session_list_action_spec, @@ -76,15 +77,12 @@ export interface AccountActionOptions { /** * Dependencies for `create_account_actions`. * - * Shares shape with `AdminActionDeps` / `PermitOfferActionDeps` so consumers - * can pass the same deps to every action factory. `audit_log_config` is - * carried through `AppDeps` and consumed by `audit_log_fire_and_forget`; - * absent → defaults to `BUILTIN_AUDIT_LOG_CONFIG`. + * Aliases the shared `AuditEmitDeps` (the `log` / `on_audit_event` / + * optional `audit_log_config` slice every audit-emitting site picks). + * `audit_log_config` is consumed by `audit_log_fire_and_forget`; absent → + * defaults to `BUILTIN_AUDIT_LOG_CONFIG`. */ -export type AccountActionDeps = Pick< - RouteFactoryDeps, - 'log' | 'on_audit_event' | 'audit_log_config' ->; +export type AccountActionDeps = AuditEmitDeps; /** * Create the self-service account RPC actions. @@ -100,7 +98,7 @@ export const create_account_actions = ( const {max_tokens = DEFAULT_MAX_TOKENS} = options; const verify_handler = (_input: VerifyInput, ctx: ActionContext): SessionAccountJson => { - const auth = ctx.auth!; + const auth = require_request_auth(ctx.auth); return to_session_account(auth.account); }; @@ -108,7 +106,7 @@ export const create_account_actions = ( _input: SessionListInput, ctx: ActionContext, ): Promise => { - const auth = ctx.auth!; + const auth = require_request_auth(ctx.auth); const sessions = await query_session_list_for_account(ctx, auth.account.id); return {sessions}; }; @@ -117,14 +115,13 @@ export const create_account_actions = ( input: SessionRevokeInput, ctx: ActionContext, ): Promise => { - const auth = ctx.auth!; + const auth = require_request_auth(ctx.auth); const revoked = await query_session_revoke_for_account(ctx, input.session_id, auth.account.id); void audit_log_fire_and_forget( ctx, { event_type: 'session_revoke', outcome: revoked ? 'success' : 'failure', - actor_id: auth.actor.id, account_id: auth.account.id, ip: ctx.client_ip, metadata: {session_id: input.session_id}, @@ -138,13 +135,12 @@ export const create_account_actions = ( _input: SessionRevokeAllInput, ctx: ActionContext, ): Promise => { - const auth = ctx.auth!; + const auth = require_request_auth(ctx.auth); const count = await query_session_revoke_all_for_account(ctx, auth.account.id); void audit_log_fire_and_forget( ctx, { event_type: 'session_revoke_all', - actor_id: auth.actor.id, account_id: auth.account.id, ip: ctx.client_ip, metadata: {count}, @@ -158,7 +154,7 @@ export const create_account_actions = ( input: TokenCreateInput, ctx: ActionContext, ): Promise => { - const auth = ctx.auth!; + const auth = require_request_auth(ctx.auth); const {token, id, token_hash} = generate_api_token(); await query_create_api_token(ctx, id, auth.account.id, input.name, token_hash); if (max_tokens != null) { @@ -168,7 +164,6 @@ export const create_account_actions = ( ctx, { event_type: 'token_create', - actor_id: auth.actor.id, account_id: auth.account.id, ip: ctx.client_ip, metadata: {token_id: id, name: input.name}, @@ -182,7 +177,7 @@ export const create_account_actions = ( _input: TokenListInput, ctx: ActionContext, ): Promise => { - const auth = ctx.auth!; + const auth = require_request_auth(ctx.auth); const tokens = await query_api_token_list_for_account(ctx, auth.account.id); return {tokens}; }; @@ -191,14 +186,13 @@ export const create_account_actions = ( input: TokenRevokeInput, ctx: ActionContext, ): Promise => { - const auth = ctx.auth!; + const auth = require_request_auth(ctx.auth); const revoked = await query_revoke_api_token_for_account(ctx, input.token_id, auth.account.id); void audit_log_fire_and_forget( ctx, { event_type: 'token_revoke', outcome: revoked ? 'success' : 'failure', - actor_id: auth.actor.id, account_id: auth.account.id, ip: ctx.client_ip, metadata: {token_id: input.token_id}, diff --git a/src/lib/auth/account_queries.ts b/src/lib/auth/account_queries.ts index 256bd376..41459546 100644 --- a/src/lib/auth/account_queries.ts +++ b/src/lib/auth/account_queries.ts @@ -160,15 +160,21 @@ export const query_create_actor = async ( }; /** - * Find the actor for an account. + * List every actor on an account, ordered by `created_at`. * - * For v1, each account has exactly one actor. + * Used by `resolve_acting_actor` to resolve the acting actor for a + * request: 1 actor picks transparently, multiple require an explicit + * `acting` field on the request payload. For lookups by id, use + * `query_actor_by_id` instead. */ -export const query_actor_by_account = async ( +export const query_actors_by_account = async ( deps: QueryDeps, account_id: string, -): Promise => { - return deps.db.query_one(`SELECT * FROM actor WHERE account_id = $1`, [account_id]); +): Promise> => { + return deps.db.query( + `SELECT * FROM actor WHERE account_id = $1 ORDER BY created_at ASC, id ASC`, + [account_id], + ); }; /** @@ -262,7 +268,13 @@ export const query_admin_account_list = async ( ), ]); - // Index actors by account_id (1:1 in v1) + // Index actors by account_id. Multi-actor TODO: this Map keyed by + // account_id silently overwrites earlier actors when an account + // hosts more than one — when multi-actor lands, the admin row shape + // must change from "account → one actor" to "account → Array" + // (or split into a separate per-actor row). The JSON shape change + // will ripple into the admin UI; bundle that with the multi-actor + // session-actor-selector work. const actor_by_account = new Map(); for (const actor of actors) { actor_by_account.set(actor.account_id, actor); diff --git a/src/lib/auth/account_routes.ts b/src/lib/auth/account_routes.ts index 01661081..6b3a5cc5 100644 --- a/src/lib/auth/account_routes.ts +++ b/src/lib/auth/account_routes.ts @@ -45,7 +45,14 @@ import { } from './account_queries.js'; import {query_revoke_all_api_tokens_for_account} from './api_token_queries.js'; import {audit_log_fire_and_forget} from './audit_log_queries.js'; -import {get_request_context, require_request_context} from './request_context.js'; +import { + build_account_context, + build_request_context, + get_request_context, + require_request_context, + resolve_acting_actor, +} from './request_context.js'; +import {ACCOUNT_ID_KEY} from '../hono_context.js'; import {get_route_input, type RouteSpec} from '../http/route_spec.js'; import {get_client_ip} from '../http/proxy.js'; import {rate_limit_exceeded_response, type RateLimiter} from '../rate_limiter.js'; @@ -65,18 +72,15 @@ export type AccountStatusInput = z.infer; /** * Output for `GET /api/account/status` on the authenticated path. * - * `account` and `actor` are the caller's own identity entities (v1 is 1:1 - * account/actor, but `actor` is first-class so consumers don't have to - * derive `actor_id` from the permit list). Permits are already - * active-filtered by `build_request_context` via - * `query_permit_find_active_for_actor` — `revoked_at` / `revoked_by` / - * `revoked_reason` are never populated here, so `PermitSummaryJson` - * carries the fields a client actually needs (including `scope_id` for - * per-scope auth decisions). + * `account` is always populated for authenticated callers. `actor` and + * `permits` are populated when the caller's account has a unique actor or + * the request supplies `?acting=`; on multi-actor accounts + * without an `acting` query, `actor` is `null` and `permits` is empty so + * the frontend can show a persona picker without a separate roundtrip. */ export const AccountStatusOutput = z.strictObject({ account: SessionAccountJson, - actor: ActorSummaryJson, + actor: ActorSummaryJson.nullable(), permits: z.array(PermitSummaryJson), }); export type AccountStatusOutput = z.infer; @@ -111,10 +115,24 @@ export const create_account_status_route_spec = (options?: AccountStatusOptions) errors: { 401: AccountStatusUnauthenticatedError, }, - handler: (c) => { - const ctx = get_request_context(c); - if (ctx) { - const permits: Array = ctx.permits.map((p) => ({ + handler: async (c, route) => { + const account_id: string | null = c.get(ACCOUNT_ID_KEY) ?? null; + if (!account_id) { + return c.json( + { + error: ERROR_AUTHENTICATION_REQUIRED, + ...(options?.bootstrap_status?.available ? {bootstrap_available: true} : {}), + }, + 401, + ); + } + // Honor a pre-populated request context. The dispatcher's authorization + // phase doesn't run for `auth: 'none'` routes, but a caller (test + // harness, or future middleware) may still populate the context — use + // it directly to avoid redundant lookups. + const existing = get_request_context(c); + if (existing && existing.account.id === account_id) { + const permits: Array = existing.permits.map((p) => ({ id: p.id, role: p.role, scope_id: p.scope_id, @@ -123,18 +141,50 @@ export const create_account_status_route_spec = (options?: AccountStatusOptions) granted_by: p.granted_by, })); return c.json({ - account: to_session_account(ctx.account), - actor: {id: ctx.actor.id, name: ctx.actor.name}, + account: to_session_account(existing.account), + actor: existing.actor ? {id: existing.actor.id, name: existing.actor.name} : null, permits, }); } - return c.json( - { - error: ERROR_AUTHENTICATION_REQUIRED, - ...(options?.bootstrap_status?.available ? {bootstrap_available: true} : {}), - }, - 401, - ); + // Resolve actor + permits when the caller is unambiguous (single-actor + // account, or supplied `?acting=`). On multi-actor accounts + // without `acting`, fall back to account-only so the frontend can + // surface a persona picker. + const acting = c.req.query('acting') ?? undefined; + const acting_result = await resolve_acting_actor(route, account_id, acting); + if (acting_result.ok) { + const ctx = await build_request_context(route, account_id, acting_result.actor_id); + if (ctx) { + const permits: Array = ctx.permits.map((p) => ({ + id: p.id, + role: p.role, + scope_id: p.scope_id, + created_at: p.created_at, + expires_at: p.expires_at, + granted_by: p.granted_by, + })); + return c.json({ + account: to_session_account(ctx.account), + actor: {id: ctx.actor.id, name: ctx.actor.name}, + permits, + }); + } + } + const account_ctx = await build_account_context(route, account_id); + if (!account_ctx) { + return c.json( + { + error: ERROR_AUTHENTICATION_REQUIRED, + ...(options?.bootstrap_status?.available ? {bootstrap_available: true} : {}), + }, + 401, + ); + } + return c.json({ + account: to_session_account(account_ctx.account), + actor: null, + permits: [], + }); }, }); @@ -415,11 +465,13 @@ export const create_account_route_specs = ( await query_session_revoke_by_hash_unscoped(route, token_hash); } clear_session_cookie(c, session_options); + // Account-grain operation — no `actor_id` (which actor was + // resolved per-request is incidental to "this account ended + // its session"). Mirrors `login`. void audit_log_fire_and_forget( route, { event_type: 'logout', - actor_id: ctx.actor.id, account_id: ctx.account.id, ip: get_client_ip(c), }, @@ -471,7 +523,6 @@ export const create_account_route_specs = ( { event_type: 'password_change', outcome: 'failure', - actor_id: ctx.actor.id, account_id: ctx.account.id, ip: get_client_ip(c), }, @@ -485,18 +536,23 @@ export const create_account_route_specs = ( if (login_account_rate_limiter) login_account_rate_limiter.reset(ctx.account.id); const new_hash = await password.hash_password(new_password); - await query_update_account_password(route, ctx.account.id, new_hash, ctx.actor.id); + // Account-grain operation — `updated_by` stays null (the per-request + // actor is incidental; password is account-level state). + await query_update_account_password(route, ctx.account.id, new_hash, null); // revoke all sessions and API tokens (force re-auth everywhere) const sessions_revoked = await query_session_revoke_all_for_account(route, ctx.account.id); const tokens_revoked = await query_revoke_all_api_tokens_for_account(route, ctx.account.id); clear_session_cookie(c, session_options); + // Account-grain operation — no `actor_id`. The password is + // account-level state; which per-request actor was resolved + // has no semantic bearing on "this account changed its + // password". Mirrors `login`/`logout`. void audit_log_fire_and_forget( route, { event_type: 'password_change', - actor_id: ctx.actor.id, account_id: ctx.account.id, ip: get_client_ip(c), metadata: {sessions_revoked, tokens_revoked}, diff --git a/src/lib/auth/account_schema.ts b/src/lib/auth/account_schema.ts index 1e491269..34d2cfec 100644 --- a/src/lib/auth/account_schema.ts +++ b/src/lib/auth/account_schema.ts @@ -40,6 +40,29 @@ export type UsernameProvided = z.infer; export const Email = z.email(); export type Email = z.infer; +/** + * `acting` field shared by every action input that needs the caller's + * acting actor. Declaring `acting: ActingActor` on an action's input + * is the signal to the RPC dispatcher / route-spec wrapper to resolve + * an actor against the authenticated account: the authorization phase + * runs `resolve_acting_actor`, builds the actor-bound `RequestContext`, + * and loads permits before auth guards fire. + * + * Resolution rules: omitted + 1 actor → use it; omitted + multiple + * actors → `actor_required` with the available list; supplied + on + * the account → use it; supplied + foreign actor → `actor_not_on_account`. + * + * Account-grain routes — input doesn't declare `acting` and auth + * doesn't require permits (`role` / `keeper`) — skip resolution + * entirely; their `RequestContext.actor` is `null` and the audit + * envelope's `actor_id` stays null. + */ +export const ActingActor = Uuid.optional().meta({ + description: + 'Actor on the authenticated account that this request acts as. Omit on single-actor accounts; required on multi-actor.', +}); +export type ActingActor = z.infer; + // Types /** Account — authentication identity. You log in as an account. */ diff --git a/src/lib/auth/admin_action_specs.ts b/src/lib/auth/admin_action_specs.ts index 90a22a48..a4d4f15e 100644 --- a/src/lib/auth/admin_action_specs.ts +++ b/src/lib/auth/admin_action_specs.ts @@ -21,7 +21,7 @@ import {Uuid} from '@fuzdev/fuz_util/id.js'; import type {RequestResponseActionSpec} from '../actions/action_spec.js'; import {ROLE_ADMIN, RoleName} from './role_schema.js'; -import {AdminAccountEntryJson, Email, Username} from './account_schema.js'; +import {ActingActor, AdminAccountEntryJson, Email, Username} from './account_schema.js'; import { AdminSessionJson, AUDIT_LOG_DEFAULT_LIMIT, @@ -38,8 +38,10 @@ export const AUDIT_LOG_LIST_LIMIT_MAX = 200; // -- Input/output schemas --------------------------------------------------- -/** Input for `admin_account_list`. No parameters — the caller is the subject. */ -export const AdminAccountListInput = z.void(); +/** Input for `admin_account_list`. */ +export const AdminAccountListInput = z.strictObject({ + acting: ActingActor, +}); export type AdminAccountListInput = z.infer; /** Output for `admin_account_list`. */ @@ -49,8 +51,10 @@ export const AdminAccountListOutput = z.strictObject({ }); export type AdminAccountListOutput = z.infer; -/** Input for `admin_session_list`. No parameters — reads every active session. */ -export const AdminSessionListInput = z.void(); +/** Input for `admin_session_list`. */ +export const AdminSessionListInput = z.strictObject({ + acting: ActingActor, +}); export type AdminSessionListInput = z.infer; /** Output for `admin_session_list`. Cross-account listing; fan-out already scoped by role auth. */ @@ -62,6 +66,7 @@ export type AdminSessionListOutput = z.infer; /** Input for `admin_session_revoke_all`. */ export const AdminSessionRevokeAllInput = z.strictObject({ account_id: Uuid.meta({description: 'Account whose sessions to revoke.'}), + acting: ActingActor, }); export type AdminSessionRevokeAllInput = z.infer; @@ -75,6 +80,7 @@ export type AdminSessionRevokeAllOutput = z.infer; @@ -113,6 +119,7 @@ export const AuditLogListInput = z.strictObject({ since_seq: z.number().int().min(0).nullish().meta({ description: 'Gap-fill from this seq forward. Used for SSE reconnection.', }), + acting: ActingActor, }); export type AuditLogListInput = z.infer; @@ -134,6 +141,7 @@ export const AuditLogPermitHistoryInput = z.strictObject({ description: `Max rows to return (default ${AUDIT_LOG_DEFAULT_LIMIT}, max ${AUDIT_LOG_LIST_LIMIT_MAX}).`, }), offset: z.number().int().min(0).nullish().meta({description: 'Pagination offset.'}), + acting: ActingActor, }); export type AuditLogPermitHistoryInput = z.infer; @@ -147,6 +155,7 @@ export type AuditLogPermitHistoryOutput = z.infer; @@ -158,7 +167,9 @@ export const InviteCreateOutput = z.strictObject({ export type InviteCreateOutput = z.infer; /** Input for `invite_list`. */ -export const InviteListInput = z.void(); +export const InviteListInput = z.strictObject({ + acting: ActingActor, +}); export type InviteListInput = z.infer; /** Output for `invite_list`. Uses the enriched row including creator/claimer usernames. */ @@ -170,6 +181,7 @@ export type InviteListOutput = z.infer; /** Input for `invite_delete`. */ export const InviteDeleteInput = z.strictObject({ invite_id: Uuid.meta({description: 'Invite to delete. Must be unclaimed.'}), + acting: ActingActor, }); export type InviteDeleteInput = z.infer; @@ -179,8 +191,10 @@ export const InviteDeleteOutput = z.strictObject({ }); export type InviteDeleteOutput = z.infer; -/** Input for `app_settings_get`. No parameters. */ -export const AppSettingsGetInput = z.void(); +/** Input for `app_settings_get`. */ +export const AppSettingsGetInput = z.strictObject({ + acting: ActingActor, +}); export type AppSettingsGetInput = z.infer; /** Output for `app_settings_get`. */ @@ -192,6 +206,7 @@ export type AppSettingsGetOutput = z.infer; /** Input for `app_settings_update`. */ export const AppSettingsUpdateInput = z.strictObject({ open_signup: z.boolean().meta({description: 'New value for the open signup toggle.'}), + acting: ActingActor, }); export type AppSettingsUpdateInput = z.infer; diff --git a/src/lib/auth/admin_actions.ts b/src/lib/auth/admin_actions.ts index 3a4c7a92..ad8f10bd 100644 --- a/src/lib/auth/admin_actions.ts +++ b/src/lib/auth/admin_actions.ts @@ -28,7 +28,7 @@ * @module */ -import {rpc_action, type ActionContext, type RpcAction} from '../actions/action_rpc.js'; +import {rpc_actor_action, type ActionActorContext, type RpcAction} from '../actions/action_rpc.js'; import {jsonrpc_errors} from '../http/jsonrpc_errors.js'; import {BUILTIN_ROLE_OPTIONS, type RoleSchemaResult} from './role_schema.js'; import { @@ -58,7 +58,7 @@ import { query_app_settings_load_with_username, query_app_settings_update, } from './app_settings_queries.js'; -import type {RouteFactoryDeps} from './deps.js'; +import type {AuditEmitDeps} from './deps.js'; import {is_pg_unique_violation} from '../db/pg_error.js'; import { ERROR_ACCOUNT_NOT_FOUND, @@ -126,13 +126,14 @@ export interface AdminActionOptions { /** * Dependencies for `create_admin_actions`. * - * Shares shape with `PermitOfferActionDeps` so consumers can pass the same - * deps to both factories. `log` drives RPC-internal error logging; - * `on_audit_event` is wired by the two revoke-all mutations so SSE fan-out - * mirrors the former REST-route behavior. `audit_log_config` flows from - * `AppDeps` and is consumed by `audit_log_fire_and_forget`. + * Aliases the shared `AuditEmitDeps` (the `log` / `on_audit_event` / + * optional `audit_log_config` slice every audit-emitting site picks). + * `log` drives RPC-internal error logging; `on_audit_event` is wired by + * the two revoke-all mutations so SSE fan-out mirrors the former + * REST-route behavior; `audit_log_config` is consumed by + * `audit_log_fire_and_forget`. */ -export type AdminActionDeps = Pick; +export type AdminActionDeps = AuditEmitDeps; /** * Create the admin-only RPC actions. @@ -154,7 +155,7 @@ export const create_admin_actions = ( const account_list_handler = async ( _input: AdminAccountListInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { const accounts = await query_admin_account_list(ctx); return {accounts, grantable_roles}; @@ -162,7 +163,7 @@ export const create_admin_actions = ( const session_list_handler = async ( _input: AdminSessionListInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { const sessions = await query_session_list_all_active(ctx); return {sessions}; @@ -170,9 +171,9 @@ export const create_admin_actions = ( const session_revoke_all_handler = async ( input: AdminSessionRevokeAllInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = ctx.auth!; + const auth = ctx.auth; const account = await query_account_by_id(ctx, input.account_id); if (!account) { void audit_log_fire_and_forget( @@ -180,7 +181,6 @@ export const create_admin_actions = ( { event_type: 'session_revoke_all', outcome: 'failure', - actor_id: auth.actor.id, account_id: auth.account.id, // `target_account_id` is null: the FK to `account` would reject // a probe for a non-existent id. The probed value is preserved @@ -201,7 +201,6 @@ export const create_admin_actions = ( ctx, { event_type: 'session_revoke_all', - actor_id: auth.actor.id, account_id: auth.account.id, target_account_id: input.account_id, ip: ctx.client_ip, @@ -214,9 +213,9 @@ export const create_admin_actions = ( const token_revoke_all_handler = async ( input: AdminTokenRevokeAllInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = ctx.auth!; + const auth = ctx.auth; const account = await query_account_by_id(ctx, input.account_id); if (!account) { void audit_log_fire_and_forget( @@ -224,7 +223,6 @@ export const create_admin_actions = ( { event_type: 'token_revoke_all', outcome: 'failure', - actor_id: auth.actor.id, account_id: auth.account.id, // See `session_revoke_all_handler` — FK forces null here; the // probed id lives under `metadata.attempted_account_id`. @@ -244,7 +242,6 @@ export const create_admin_actions = ( ctx, { event_type: 'token_revoke_all', - actor_id: auth.actor.id, account_id: auth.account.id, target_account_id: input.account_id, ip: ctx.client_ip, @@ -257,7 +254,7 @@ export const create_admin_actions = ( const audit_log_list_handler = async ( input: AuditLogListInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { const events = await query_audit_log_list_with_usernames(ctx, { event_type: input.event_type ?? undefined, @@ -272,7 +269,7 @@ export const create_admin_actions = ( const audit_log_permit_history_handler = async ( input: AuditLogPermitHistoryInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { const events = await query_audit_log_list_permit_history( ctx, @@ -284,9 +281,9 @@ export const create_admin_actions = ( const invite_create_handler = async ( input: InviteCreateInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = ctx.auth!; + const auth = ctx.auth; const email = input.email ?? null; const username = input.username ?? null; @@ -333,7 +330,6 @@ export const create_admin_actions = ( ctx, { event_type: 'invite_create', - actor_id: auth.actor.id, account_id: auth.account.id, ip: ctx.client_ip, metadata: {invite_id: invite.id, email, username}, @@ -345,7 +341,7 @@ export const create_admin_actions = ( const invite_list_handler = async ( _input: InviteListInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { const invites = await query_invite_list_all_with_usernames(ctx); return {invites}; @@ -353,9 +349,9 @@ export const create_admin_actions = ( const invite_delete_handler = async ( input: InviteDeleteInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = ctx.auth!; + const auth = ctx.auth; const deleted = await query_invite_delete_unclaimed(ctx, input.invite_id); if (!deleted) { throw jsonrpc_errors.not_found('invite', {reason: ERROR_INVITE_NOT_FOUND}); @@ -364,7 +360,6 @@ export const create_admin_actions = ( ctx, { event_type: 'invite_delete', - actor_id: auth.actor.id, account_id: auth.account.id, ip: ctx.client_ip, metadata: {invite_id: input.invite_id}, @@ -375,22 +370,22 @@ export const create_admin_actions = ( }; const actions: Array = [ - rpc_action(admin_account_list_action_spec, account_list_handler), - rpc_action(admin_session_list_action_spec, session_list_handler), - rpc_action(admin_session_revoke_all_action_spec, session_revoke_all_handler), - rpc_action(admin_token_revoke_all_action_spec, token_revoke_all_handler), - rpc_action(audit_log_list_action_spec, audit_log_list_handler), - rpc_action(audit_log_permit_history_action_spec, audit_log_permit_history_handler), - rpc_action(invite_create_action_spec, invite_create_handler), - rpc_action(invite_list_action_spec, invite_list_handler), - rpc_action(invite_delete_action_spec, invite_delete_handler), + rpc_actor_action(admin_account_list_action_spec, account_list_handler), + rpc_actor_action(admin_session_list_action_spec, session_list_handler), + rpc_actor_action(admin_session_revoke_all_action_spec, session_revoke_all_handler), + rpc_actor_action(admin_token_revoke_all_action_spec, token_revoke_all_handler), + rpc_actor_action(audit_log_list_action_spec, audit_log_list_handler), + rpc_actor_action(audit_log_permit_history_action_spec, audit_log_permit_history_handler), + rpc_actor_action(invite_create_action_spec, invite_create_handler), + rpc_actor_action(invite_list_action_spec, invite_list_handler), + rpc_actor_action(invite_delete_action_spec, invite_delete_handler), ]; const {app_settings} = options; if (app_settings) { const app_settings_get_handler = async ( _input: AppSettingsGetInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { const settings = await query_app_settings_load_with_username(ctx); return {settings}; @@ -398,9 +393,9 @@ export const create_admin_actions = ( const app_settings_update_handler = async ( input: AppSettingsUpdateInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = ctx.auth!; + const auth = ctx.auth; const old_value = app_settings.open_signup; const updated = await query_app_settings_update(ctx, input.open_signup, auth.actor.id); @@ -414,7 +409,6 @@ export const create_admin_actions = ( ctx, { event_type: 'app_settings_update', - actor_id: auth.actor.id, account_id: auth.account.id, ip: ctx.client_ip, metadata: { @@ -430,8 +424,8 @@ export const create_admin_actions = ( }; actions.push( - rpc_action(app_settings_get_action_spec, app_settings_get_handler), - rpc_action(app_settings_update_action_spec, app_settings_update_handler), + rpc_actor_action(app_settings_get_action_spec, app_settings_get_handler), + rpc_actor_action(app_settings_update_action_spec, app_settings_update_handler), ); } diff --git a/src/lib/auth/audit_log_queries.ts b/src/lib/auth/audit_log_queries.ts index cbd6036d..b76b17dc 100644 --- a/src/lib/auth/audit_log_queries.ts +++ b/src/lib/auth/audit_log_queries.ts @@ -15,7 +15,9 @@ import type {QueryDeps} from '../db/query_deps.js'; import {assert_row} from '../db/assert_row.js'; import type {RouteContext} from '../http/route_spec.js'; -import type {AppDeps} from './deps.js'; +import type {Uuid} from '@fuzdev/fuz_util/id.js'; +import type {AuditEmitDeps} from './deps.js'; +import type {RequestActorContext} from './request_context.js'; import { AUDIT_LOG_DEFAULT_LIMIT, BUILTIN_AUDIT_LOG_CONFIG, @@ -107,8 +109,8 @@ export const query_audit_log = async ( } } const rows = await deps.db.query( - `INSERT INTO audit_log (event_type, outcome, actor_id, account_id, target_account_id, ip, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO audit_log (event_type, outcome, actor_id, account_id, target_account_id, target_actor_id, ip, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ input.event_type, @@ -116,6 +118,7 @@ export const query_audit_log = async ( input.actor_id ?? null, input.account_id ?? null, input.target_account_id ?? null, + input.target_actor_id ?? null, input.ip ?? null, input.metadata ? JSON.stringify(input.metadata) : null, ], @@ -298,22 +301,6 @@ export const query_audit_log_cleanup_before = async ( return rows.length; }; -/** - * Capabilities required by `audit_log_fire_and_forget`. - * - * Defined as a slice of `AppDeps` so call sites can pass the surrounding deps - * bundle directly without a structural-compatibility coincidence. The bundled - * shape replaces the prior `(log, on_audit_event, config?)` positional args - * — consumers that forgot the trailing `config` would silently fall back to - * `BUILTIN_AUDIT_LOG_CONFIG` and skip metadata validation for their own - * event types. `audit_log_config` is optional on `AppDeps` and defaults to - * `BUILTIN_AUDIT_LOG_CONFIG` inside `audit_log_fire_and_forget` when absent. - */ -export type AuditLogFireAndForgetDeps = Pick< - AppDeps, - 'log' | 'on_audit_event' | 'audit_log_config' ->; - /** * Log an audit event without blocking the caller. * @@ -321,6 +308,13 @@ export type AuditLogFireAndForgetDeps = Pick< * `background_db` so entries persist even when the request transaction * rolls back. Write and `on_audit_event` callback failures are logged separately. * + * `deps` is the shared `AuditEmitDeps` bundle (`log`, `on_audit_event`, + * optional `audit_log_config`) so call sites pass the surrounding deps + * object directly. The bundled shape replaces the prior `(log, + * on_audit_event, config?)` positional args — consumers that forgot the + * trailing `config` would silently fall back to `BUILTIN_AUDIT_LOG_CONFIG` + * and skip metadata validation for their own event types. + * * @param route - `background_db` and `pending_effects` from the route context * @param input - the audit event to record * @param deps - logger, `on_audit_event` callback, and optional `audit_log_config` @@ -331,7 +325,7 @@ export type AuditLogFireAndForgetDeps = Pick< export const audit_log_fire_and_forget = ( route: Pick, input: AuditLogInput, - deps: AuditLogFireAndForgetDeps, + deps: AuditEmitDeps, ): Promise => { const {log, on_audit_event, audit_log_config = BUILTIN_AUDIT_LOG_CONFIG} = deps; const p = query_audit_log({db: route.background_db}, input, audit_log_config) @@ -348,3 +342,69 @@ export const audit_log_fire_and_forget = ( route.pending_effects.push(p); return p; }; + +/** + * Per-request context required by `emit_permit_target_event` — + * `RouteContext` plus the resolved `client_ip` (lives on `ActionContext` + * for RPC handlers and on the route's Hono context for REST). Declared + * locally rather than reaching into `actions/action_rpc.ts` so the helper + * stays usable from REST handlers that haven't promoted to RPC yet. + */ +export type EmitPermitTargetEventContext = Pick< + RouteContext, + 'background_db' | 'pending_effects' +> & { + client_ip: string; +}; + +/** + * Stamp a permit-shape audit event with both `target_account_id` (drives + * SSE/WS socket-close — sessions are account-grain) and `target_actor_id` + * (the actor-grain forensic field). Both target fields nullable so emit + * sites without a recipient binding (e.g. `permit_revoke` on a missing + * account, offer-shape events with no `to_actor_id`) can call through + * uniformly. + * + * Lifts the six-site `{actor_id: auth.actor.id, account_id: auth.account.id, + * ip: ctx.client_ip, ...}` boilerplate around `audit_log_fire_and_forget` + * so callers thread auth + ctx + deps once and the event metadata once, + * without re-derivable plumbing. + * + * Outcome defaults to `'success'`; pass `'failure'` for denial-shape + * events. Other audit envelope shapes (target_*-by-actor-id-only events, + * non-permit-shape events) should call `audit_log_fire_and_forget` + * directly — this helper deliberately narrows to the permit-target shape. + * + * @param ctx - request context with `background_db`, `pending_effects`, `client_ip` + * @param auth - the resolved `RequestActorContext` for the current handler — actor invariant captured in the type so the helper stops needing `auth.actor!` + * @param deps - `log`, `on_audit_event`, optional `audit_log_config` + * @param input - event type, target columns, metadata, optional outcome + * @returns the settled promise (callers may ignore it) + * @mutates `audit_log` table - inserts a row via `background_db` + */ +export const emit_permit_target_event = ( + ctx: EmitPermitTargetEventContext, + auth: RequestActorContext, + deps: AuditEmitDeps, + input: { + event_type: T; + target_account_id: Uuid | null; + target_actor_id: Uuid | null; + metadata: AuditLogInput['metadata']; + outcome?: 'success' | 'failure'; + }, +): Promise => + audit_log_fire_and_forget( + ctx, + { + event_type: input.event_type, + actor_id: auth.actor.id, + account_id: auth.account.id, + outcome: input.outcome, + target_account_id: input.target_account_id, + target_actor_id: input.target_actor_id, + ip: ctx.client_ip, + metadata: input.metadata, + }, + deps, + ); diff --git a/src/lib/auth/audit_log_schema.ts b/src/lib/auth/audit_log_schema.ts index 0cf36a5b..7380f892 100644 --- a/src/lib/auth/audit_log_schema.ts +++ b/src/lib/auth/audit_log_schema.ts @@ -9,8 +9,10 @@ import {z} from 'zod'; import {Uuid} from '@fuzdev/fuz_util/id.js'; +import {Blake3Hash} from '@fuzdev/fuz_util/hash_blake3.js'; import {AuthSessionJson} from './account_schema.js'; +import {ApiTokenId} from './api_token.js'; /** * All tracked auth event types. Frozen to convert accidental in-process @@ -100,7 +102,7 @@ export const AUDIT_METADATA_SCHEMAS = Object.freeze({ }) .nullable(), session_revoke: z.looseObject({ - session_id: z.string().meta({description: 'Blake3 hash identifying the revoked session row.'}), + session_id: Blake3Hash.meta({description: 'Blake3 hash identifying the revoked session row.'}), }), session_revoke_all: z.looseObject({ // Omitted on `outcome='failure'` (no revocation attempted — e.g. target @@ -121,11 +123,11 @@ export const AUDIT_METADATA_SCHEMAS = Object.freeze({ }), }), token_create: z.looseObject({ - token_id: z.string().meta({description: 'Public id of the created API token (`tok_…`).'}), + token_id: ApiTokenId.meta({description: 'Public id of the created API token (`tok_…`).'}), name: z.string().meta({description: 'Operator-supplied label for the token.'}), }), token_revoke: z.looseObject({ - token_id: z.string().meta({description: 'Public id of the revoked API token (`tok_…`).'}), + token_id: ApiTokenId.meta({description: 'Public id of the revoked API token (`tok_…`).'}), }), token_revoke_all: z.looseObject({ // Same shape as `session_revoke_all` for failures. @@ -270,9 +272,59 @@ export interface AuditLogEvent { seq: number; event_type: AuditEventTypeName; outcome: AuditOutcome; + /** + * Operator (the actor that initiated the event) — populated when the + * request resolved an acting actor. + * + * Resolution is driven per-request by the route-spec wrapper / RPC + * dispatcher; a route gets an acting actor when its input schema + * declares `acting?: ActingActor` or its auth requires permits + * (`role` / `keeper`). Account-grain operations declare neither, + * so no actor is resolved and `actor_id` is null: login (also + * pre-credential), logout, signup, bootstrap, password_change, + * session/token revoke, app_settings_update, invite events. + * Permit events, admin actions, and actor-targeted offers + * populate this with the initiator's actor. + */ actor_id: Uuid | null; account_id: Uuid | null; target_account_id: Uuid | null; + /** + * Actor-grain target — populated when the event subject is bound to + * a specific actor. + * + * Concretely: + * - Always populated: `permit_revoke` and `permit_grant` + * (admin direct-grant, self-service toggle, and in-tx + * `permit_offer_accept` all populate both target columns — the + * permit's grantee is the actor-grain subject regardless of who + * initiated the grant), `permit_offer_accept` on accept (the + * accept binds the actor deterministically), `permit_offer_decline` + * (the grantor actor — decline is *to* the offering actor). + * - Conditionally populated: offer-shape events + * (`permit_offer_create`, `_expire`, `_retract`, `_supersede`) + * carry the actor when the offer was actor-targeted at create time + * (`permit_offer.to_actor_id` set), null when the offer was + * account-grain (any actor on `to_account_id` may accept). + * - Not populated: admin actions, account-shape events (login, + * logout, signup, bootstrap, password_change, session/token + * revoke, app_settings_update, invite events) — subject is the + * account or no specific resource, not an actor-bound permit. + * - Not populated: events whose principal isn't an actor-bound + * resource (e.g. consumer events that name a non-actor scope in + * metadata). + * + * Multi-actor invariants this column relies on: when both + * `target_actor_id` and `target_account_id` are populated they refer + * to the same account (`actor.account_id`-derivable). The invariant + * holds uniformly across every populated event including decline + * (the grantor's account is joined into the decline RETURNING) and + * the supersede cascade (the recipient account is known on + * `permit_offer.to_account_id`). `target_account_id` stays the + * SSE/WS socket-close key because sessions remain account-grain + * after multi-actor lands. + */ + target_actor_id: Uuid | null; ip: string | null; created_at: string; metadata: Record | null; @@ -296,6 +348,7 @@ export interface AuditLogInput { actor_id?: Uuid | null; account_id?: Uuid | null; target_account_id?: Uuid | null; + target_actor_id?: Uuid | null; ip?: string | null; /** * Per-event-type metadata. Builtin `T` narrows to `AuditMetadataMap[T]`; @@ -433,6 +486,7 @@ export const AuditLogEventJson = z.strictObject({ actor_id: Uuid.nullable(), account_id: Uuid.nullable(), target_account_id: Uuid.nullable(), + target_actor_id: Uuid.nullable(), ip: z.string().nullable(), created_at: z.string(), metadata: z.record(z.string(), z.unknown()).nullable(), @@ -460,6 +514,17 @@ export const AdminSessionJson = AuthSessionJson.extend({ export type AdminSessionJson = z.infer; // Schema DDL +// +// Multi-actor invariants the envelope columns assume: +// - `actor_id` + `account_id`, when both populated, refer to the same +// account (derivable via `actor.account_id`). Denormalized for +// indexed audit queries; do not let them disagree. +// - `target_actor_id` + `target_account_id`, same rule when both populated. +// - `target_account_id` is the SSE/WS socket-close key — sessions stay +// account-grain after multi-actor lands, so this column carries +// the routing identity even on actor-bound events. +// - `target_actor_id` is populated iff the event subject is actor-bound +// (see `AuditLogEvent.target_actor_id` doc-comment for the rule). export const AUDIT_LOG_SCHEMA = ` CREATE TABLE IF NOT EXISTS audit_log ( @@ -470,6 +535,7 @@ CREATE TABLE IF NOT EXISTS audit_log ( actor_id UUID REFERENCES actor(id) ON DELETE SET NULL, account_id UUID REFERENCES account(id) ON DELETE SET NULL, target_account_id UUID REFERENCES account(id) ON DELETE SET NULL, + target_actor_id UUID REFERENCES actor(id) ON DELETE SET NULL, ip TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), metadata JSONB @@ -480,4 +546,5 @@ export const AUDIT_LOG_INDEXES = [ `CREATE INDEX IF NOT EXISTS idx_audit_log_account ON audit_log(account_id)`, `CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON audit_log(event_type)`, `CREATE INDEX IF NOT EXISTS idx_audit_log_target_account ON audit_log(target_account_id)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_target_actor ON audit_log(target_actor_id)`, ]; diff --git a/src/lib/auth/bearer_auth.ts b/src/lib/auth/bearer_auth.ts index e7fa9b4a..4932db4f 100644 --- a/src/lib/auth/bearer_auth.ts +++ b/src/lib/auth/bearer_auth.ts @@ -14,8 +14,7 @@ import type {MiddlewareHandler} from 'hono'; import type {Logger} from '@fuzdev/fuz_util/log.js'; -import {REQUEST_CONTEXT_KEY, build_request_context} from './request_context.js'; -import {AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY} from '../hono_context.js'; +import {AUTH_API_TOKEN_ID_KEY, ACCOUNT_ID_KEY, CREDENTIAL_TYPE_KEY} from '../hono_context.js'; import {query_validate_api_token} from './api_token_queries.js'; import type {QueryDeps} from '../db/query_deps.js'; import {get_client_ip} from '../http/proxy.js'; @@ -25,18 +24,20 @@ import {rate_limit_exceeded_response, type RateLimiter} from '../rate_limiter.js * Create middleware that authenticates via bearer token. * * Soft-fails for invalid, expired, or empty tokens — calls `next()` without - * setting a request context, letting downstream auth enforcement (per-action - * `check_action_auth` or `require_auth`) return a consistent JSON-RPC or - * route-level error. This avoids leaking token-specific diagnostics + * setting account identity, letting downstream auth enforcement (the RPC + * dispatcher's pre-validation / post-authorization auth gates or + * `require_auth`) return a consistent JSON-RPC or route-level error. This + * avoids leaking token-specific diagnostics * (`invalid_token`, `account_not_found`) that could aid enumeration attacks, * and ensures public actions are not blocked by bad credentials. * * Rejects bearer tokens when an `Origin` or `Referer` header is present — * browsers must use cookie auth to reduce attack surface. * Auth scheme matching is case-insensitive per RFC 7235. - * On success, builds the request context (`{ account, actor, permits }`) - * and sets it on the Hono context. Skips if a request context is already set - * (e.g. by session middleware). + * On success, sets `c.var.auth_account_id`, `CREDENTIAL_TYPE_KEY = 'api_token'`, + * and `AUTH_API_TOKEN_ID_KEY`. Skips when an account is already authenticated + * (e.g. by session middleware). Acting-actor resolution + `RequestContext` + * construction are deferred to the dispatcher's authorization phase. * * Rate limiting (429) is the only hard-fail — it's a throttling concern * independent of auth identity. @@ -44,7 +45,7 @@ import {rate_limit_exceeded_response, type RateLimiter} from '../rate_limiter.js * @param deps - query dependencies (pool-level db for middleware) * @param ip_rate_limiter - per-IP rate limiter for bearer token attempts (null to disable) * @param log - the logger instance - * @mutates Hono context - sets `REQUEST_CONTEXT_KEY`, `CREDENTIAL_TYPE_KEY`, and `AUTH_API_TOKEN_ID_KEY` on success + * @mutates Hono context - sets `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, and `AUTH_API_TOKEN_ID_KEY` on success * @mutates `ip_rate_limiter` - records on attempt; resets on a valid token */ export const create_bearer_auth_middleware = ( @@ -53,8 +54,8 @@ export const create_bearer_auth_middleware = ( log: Logger, ): MiddlewareHandler => { return async (c, next): Promise => { - // Skip if already authenticated via session - if (c.get(REQUEST_CONTEXT_KEY)) { + // Skip if an account is already authenticated (e.g. by session middleware) + if (c.get(ACCOUNT_ID_KEY) != null) { await next(); return; } @@ -121,17 +122,7 @@ export const create_bearer_auth_middleware = ( // Valid token — reset rate limit counter if (ip_rate_limiter) ip_rate_limiter.reset(ip); - // Build request context from the token's account - const ctx = await build_request_context(deps, api_token.account_id); - if (!ctx) { - // Token exists but account/actor missing — soft-fail to avoid - // leaking account lifecycle information. - log.debug('bearer auth soft-fail: account or actor not found for token'); - await next(); - return; - } - - c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(ACCOUNT_ID_KEY, api_token.account_id); c.set(CREDENTIAL_TYPE_KEY, 'api_token'); c.set(AUTH_API_TOKEN_ID_KEY, api_token.id); diff --git a/src/lib/auth/cleanup.ts b/src/lib/auth/cleanup.ts index 877ea572..bfc67c7e 100644 --- a/src/lib/auth/cleanup.ts +++ b/src/lib/auth/cleanup.ts @@ -71,12 +71,17 @@ export const cleanup_expired_permit_offers = async (deps: AuthCleanupDeps): Prom const {on_audit_event, audit_log_config} = deps; for (const offer of expired) { try { + // `permit_offer_expire` populates `target_actor_id` only when the + // offer was actor-targeted (`to_actor_id` set at create time). + // Account-grain offers (no `to_actor_id`) never bound to a + // specific actor and leave the field null. const event = await query_audit_log( deps, { event_type: 'permit_offer_expire', actor_id: offer.from_actor_id, target_account_id: offer.to_account_id, + target_actor_id: offer.to_actor_id, ip: null, metadata: { offer_id: offer.id, diff --git a/src/lib/auth/daemon_token_middleware.ts b/src/lib/auth/daemon_token_middleware.ts index 617428a9..3bc7510f 100644 --- a/src/lib/auth/daemon_token_middleware.ts +++ b/src/lib/auth/daemon_token_middleware.ts @@ -16,12 +16,10 @@ import type {Logger} from '@fuzdev/fuz_util/log.js'; import {type FsWriteDeps, type FsRemoveDeps, type EnvDeps} from '../runtime/deps.js'; import {write_file_atomic} from '../runtime/fs.js'; import {get_app_dir} from '../cli/config.js'; -import {REQUEST_CONTEXT_KEY, build_request_context} from './request_context.js'; -import {AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY} from '../hono_context.js'; +import {ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY} from '../hono_context.js'; import { ERROR_INVALID_DAEMON_TOKEN, ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED, - ERROR_KEEPER_ACCOUNT_NOT_FOUND, } from '../http/error_schemas.js'; import {query_permit_find_account_id_for_role} from './permit_queries.js'; import type {QueryDeps} from '../db/query_deps.js'; @@ -81,10 +79,16 @@ export const write_daemon_token = async ( }; /** - * Resolve the keeper account ID by querying for the account with an active keeper permit. + * Resolve the keeper account ID by querying for the account with an active + * keeper permit. * - * There is exactly one keeper account (the bootstrap account). Runs once at - * server startup — the result is cached in `DaemonTokenState.keeper_account_id`. + * There is exactly one keeper account (the bootstrap account). Runs once + * at server startup — the result is cached in + * `DaemonTokenState.keeper_account_id`. The acting actor is resolved + * per-request by the dispatcher's authorization phase (which runs + * `resolve_acting_actor` against this account id), so multi-actor keeper + * accounts surface `actor_required` if a daemon caller doesn't pass an + * explicit `acting`. * * @param deps - query dependencies * @returns the keeper account ID, or `null` if no keeper exists yet (pre-bootstrap) @@ -190,19 +194,25 @@ export const start_daemon_token_rotation = async ( * Create middleware that authenticates via daemon token. * * Checks the `X-Daemon-Token` header. Behavior: - * - No header: pass through (don't touch existing context) - * - Header present + valid: build `RequestContext` from keeper account, - * set `credential_type: 'daemon_token'` (overrides any existing session/bearer context) - * - Header present + invalid: return 401 (fail-closed, no downgrade) - * - Header present + valid but `keeper_account_id` is null: return 503 + * - No header: pass through (don't touch existing context). + * - Header present + Zod-invalid: return 401 (fail-closed). + * - Header present + invalid value: return 401 (fail-closed, no downgrade). + * - Header present + valid + `keeper_account_id` null: return 503. + * - Header present + valid + ok: set `c.var.auth_account_id = + * state.keeper_account_id`, `CREDENTIAL_TYPE_KEY = 'daemon_token'` + * (overrides any existing session / bearer identity). + * + * Acting-actor resolution + `RequestContext` construction are deferred + * to the dispatcher's authorization phase. Multi-actor keeper accounts + * surface `actor_required` from there if a daemon caller doesn't pass + * an explicit `acting` value. * * @param state - the daemon token runtime state - * @param deps - query dependencies (pool-level db for middleware) - * @mutates Hono context - sets `REQUEST_CONTEXT_KEY`, `CREDENTIAL_TYPE_KEY`, and `AUTH_API_TOKEN_ID_KEY` on a valid token + * @mutates Hono context - sets `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, and `AUTH_API_TOKEN_ID_KEY` on a valid token */ export const create_daemon_token_middleware = ( state: DaemonTokenState, - deps: QueryDeps, + _deps: QueryDeps, ): MiddlewareHandler => { return async (c, next): Promise => { const token_header = c.req.header(DAEMON_TOKEN_HEADER); @@ -228,13 +238,7 @@ export const create_daemon_token_middleware = ( return c.json({error: ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED}, 503); } - // build request context from the keeper account (overrides any existing session/bearer context) - const ctx = await build_request_context(deps, state.keeper_account_id); - if (!ctx) { - return c.json({error: ERROR_KEEPER_ACCOUNT_NOT_FOUND}, 500); - } - - c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(ACCOUNT_ID_KEY, state.keeper_account_id); c.set(CREDENTIAL_TYPE_KEY, 'daemon_token'); c.set(AUTH_API_TOKEN_ID_KEY, null); diff --git a/src/lib/auth/deps.ts b/src/lib/auth/deps.ts index 80654aeb..0d9cf9d2 100644 --- a/src/lib/auth/deps.ts +++ b/src/lib/auth/deps.ts @@ -65,3 +65,18 @@ export interface AppDeps { * via `RouteContext`, so factories don't capture a pool-level `Db`. */ export type RouteFactoryDeps = Omit; + +/** + * Capabilities required by anything that emits audit events. + * + * The slice every audit-emitting site needs: `log` for sibling failure + * reporting, `on_audit_event` for SSE/WS fan-out, and the optional + * `audit_log_config` for consumer-extended event-type validation. Used + * by `audit_log_fire_and_forget` / `emit_permit_target_event` (the + * primitives) and by every action-factory deps type in `auth/` + * (`AdminActionDeps`, `AccountActionDeps`, `PermitOfferActionDeps`, + * `SelfServiceRoleActionDeps`) that runs through them. Lifted here so + * the five factory deps stop spelling the same `Pick` independently. + */ +export type AuditEmitDeps = Pick; diff --git a/src/lib/auth/middleware.ts b/src/lib/auth/middleware.ts index c2b1cfb3..238b260f 100644 --- a/src/lib/auth/middleware.ts +++ b/src/lib/auth/middleware.ts @@ -92,8 +92,10 @@ export const create_auth_middleware_specs = async ( handler: bearer_auth_middleware, // Bearer middleware soft-fails for invalid/expired tokens (calls next() // without setting context). Only 429 is a hard-fail from this layer. - // Auth enforcement (401/403) happens downstream via check_action_auth - // or require_auth, producing consistent JSON-RPC or route-level errors. + // Auth enforcement (401/403) happens downstream — the RPC dispatcher's + // pre-validation / post-authorization auth gates, or `require_auth` / + // `require_role` on REST — producing consistent JSON-RPC or + // route-level errors. errors: {429: RateLimitError}, }, ]; diff --git a/src/lib/auth/migrations.ts b/src/lib/auth/migrations.ts index 90b2eba5..0c7d64b4 100644 --- a/src/lib/auth/migrations.ts +++ b/src/lib/auth/migrations.ts @@ -4,14 +4,22 @@ * Ordered list of `{name, up}` migrations for the fuz identity system tables. * Consumed by `run_migrations` with namespace `'fuz_auth'`. * - * **Append-only after first publish.** Once a fuz_app version containing a - * given migration is published (`npm publish` / `jsr publish`), that - * migration's name and position are frozen. Never edit, rename, or reorder — - * append only. Pre-publish, anything goes; the cliff is the publish event. - * Body edits to a published migration slip past the runner (no content - * hashing) and are caught by schema-snapshot tests in consumers. + * **Schema is not stabilized yet — append-only is NOT the rule.** While + * fuz_app is pre-stable, migration bodies, names, and positions can change + * freely between versions; consumers upgrading across a schema change are + * expected to drop and re-bootstrap their dev/test databases (production + * deployments are not yet a supported use case). Once the schema is + * declared stable a hard append-only-after-publish rule will apply and the + * cliff will be called out in the release notes for that version. Until + * then: edit, rename, reorder, or replace migrations as needed; bias toward + * collapsing work into the existing v0/v1 entries rather than appending v2 + * patch migrations. * - * To add a migration, append a new entry to `AUTH_MIGRATIONS`: + * To add a migration in the pre-stable phase, prefer extending an existing + * entry's body (consumers will re-bootstrap on upgrade). If you do append + * a new entry to `AUTH_MIGRATIONS`, the runner will apply it on existing + * tracker rows — the same shape that will become mandatory once the + * schema stabilizes: * * ```ts * // v2: add display_name to account diff --git a/src/lib/auth/permit_offer_action_specs.ts b/src/lib/auth/permit_offer_action_specs.ts index 870c4e8e..e33de782 100644 --- a/src/lib/auth/permit_offer_action_specs.ts +++ b/src/lib/auth/permit_offer_action_specs.ts @@ -11,8 +11,9 @@ * policy checks (e.g. `permit_offer_list`/`_history` elevate to admin only * when inspecting another account — an input-dependent check that can't be * expressed at the spec level). `permit_revoke` declares - * `auth: {role: 'admin'}` — the RPC dispatcher's per-spec `check_action_auth` - * gates it before the handler runs even though the endpoint hosts non-admin + * `auth: {role: 'admin'}` — the RPC dispatcher's per-spec post-authorization + * auth gate (`check_action_auth_post_authorization`) rejects non-admin + * callers before the handler runs even though the endpoint hosts non-admin * methods alongside. * * @module @@ -22,14 +23,10 @@ import {z} from 'zod'; import {Uuid} from '@fuzdev/fuz_util/id.js'; import type {RequestResponseActionSpec} from '../actions/action_spec.js'; -import { - ERROR_ACCOUNT_NOT_FOUND, - ERROR_PERMIT_NOT_FOUND, - ERROR_ROLE_NOT_WEB_GRANTABLE, -} from '../http/error_schemas.js'; +import {ERROR_PERMIT_NOT_FOUND, ERROR_ROLE_NOT_WEB_GRANTABLE} from '../http/error_schemas.js'; import {RoleName} from './role_schema.js'; import {PERMIT_OFFER_MESSAGE_LENGTH_MAX, PermitOfferJson} from './permit_offer_schema.js'; -import {PERMIT_REVOKED_REASON_LENGTH_MAX} from './account_schema.js'; +import {ActingActor, PERMIT_REVOKED_REASON_LENGTH_MAX} from './account_schema.js'; /** Error reason — caller tried to offer themselves a permit. */ export const ERROR_OFFER_SELF_TARGET = 'offer_self_target' as const; @@ -43,12 +40,30 @@ export const ERROR_OFFER_NOT_FOUND = 'offer_not_found' as const; export const ERROR_OFFER_ROLE_NOT_GRANTABLE = 'offer_role_not_grantable' as const; /** Error reason — caller is not authorized to offer this role (default policy: caller lacks the role; consumer `authorize` callback may add further policy). */ export const ERROR_OFFER_NOT_AUTHORIZED = 'offer_not_authorized' as const; +/** Error reason — actor-targeted offer was accepted by an actor other than `to_actor_id`. */ +export const ERROR_OFFER_ACTOR_MISMATCH = 'offer_actor_mismatch' as const; +/** Error reason — `permit_offer_create` was called with a `to_actor_id` that does not belong to `to_account_id`. */ +export const ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH = 'offer_actor_account_mismatch' as const; // -- Input/output schemas --------------------------------------------------- -/** Input for `permit_offer_create`. */ +/** + * Input for `permit_offer_create`. + * + * `to_actor_id` (optional) narrows the offer to a specific actor on the + * recipient account. When supplied, `permit_offer_accept` will only admit + * the named actor — wrong-actor accepts reject with + * `offer_actor_mismatch`. The audit envelope's `target_actor_id` is + * stamped from this column on the create / supersede / expire / retract + * events. Omit (or pass null) for the account-grain default — any actor + * on `to_account_id` may accept. + */ export const PermitOfferCreateInput = z.strictObject({ to_account_id: Uuid.meta({description: 'Account id of the recipient.'}), + to_actor_id: Uuid.nullish().meta({ + description: + 'Optional actor-grain target on the recipient account. When set, only this actor may accept and the audit envelope carries it on offer-shape events. Must belong to `to_account_id`.', + }), role: RoleName.meta({description: 'Role being offered.'}), scope_id: Uuid.nullish().meta({ description: 'Scope id for resource-scoped grants (e.g. classroom id). `null` for global.', @@ -58,12 +73,14 @@ export const PermitOfferCreateInput = z.strictObject({ .max(PERMIT_OFFER_MESSAGE_LENGTH_MAX) .nullish() .meta({description: 'Optional free-form note from the grantor.'}), + acting: ActingActor, }); export type PermitOfferCreateInput = z.infer; /** Input for `permit_offer_accept`. */ export const PermitOfferAcceptInput = z.strictObject({ offer_id: Uuid.meta({description: 'The offer to accept.'}), + acting: ActingActor, }); export type PermitOfferAcceptInput = z.infer; @@ -75,12 +92,14 @@ export const PermitOfferDeclineInput = z.strictObject({ .max(PERMIT_OFFER_MESSAGE_LENGTH_MAX) .nullish() .meta({description: 'Optional free-form reason given on decline.'}), + acting: ActingActor, }); export type PermitOfferDeclineInput = z.infer; /** Input for `permit_offer_retract`. */ export const PermitOfferRetractInput = z.strictObject({ offer_id: Uuid.meta({description: 'The offer to retract.'}), + acting: ActingActor, }); export type PermitOfferRetractInput = z.infer; @@ -89,6 +108,7 @@ export const PermitOfferListInput = z.strictObject({ account_id: Uuid.nullish().meta({ description: 'Admin-only — list offers for another account. Defaults to the caller.', }), + acting: ActingActor, }); export type PermitOfferListInput = z.infer; @@ -106,6 +126,7 @@ export const PermitRevokeInput = z.strictObject({ description: 'Optional free-form reason; stamped on `permit.revoked_reason` and surfaced on the revokee WS notification.', }), + acting: ActingActor, }); export type PermitRevokeInput = z.infer; @@ -124,6 +145,7 @@ export const PermitOfferHistoryInput = z.strictObject({ offset: z.number().int().min(0).nullish().meta({ description: 'Pagination offset (default 0).', }), + acting: ActingActor, }); export type PermitOfferHistoryInput = z.infer; @@ -177,6 +199,7 @@ export const permit_offer_create_action_spec = { ERROR_OFFER_SELF_TARGET, ERROR_OFFER_ROLE_NOT_GRANTABLE, ERROR_OFFER_NOT_AUTHORIZED, + ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH, ], } satisfies RequestResponseActionSpec; @@ -191,7 +214,12 @@ export const permit_offer_accept_action_spec = { async: true, description: 'Accept an offer. Atomically marks the offer accepted, inserts the permit, and supersedes sibling pending offers for the same (account, role, scope).', - error_reasons: [ERROR_OFFER_NOT_FOUND, ERROR_OFFER_TERMINAL, ERROR_OFFER_EXPIRED], + error_reasons: [ + ERROR_OFFER_NOT_FOUND, + ERROR_OFFER_TERMINAL, + ERROR_OFFER_EXPIRED, + ERROR_OFFER_ACTOR_MISMATCH, + ], } satisfies RequestResponseActionSpec; export const permit_offer_decline_action_spec = { @@ -257,7 +285,7 @@ export const permit_revoke_action_spec = { async: true, description: 'Revoke an active permit on a target actor. Admin-only. Supersedes any pending offers for the same (account, role, scope). Fires permit_revoke + permit_offer_supersede notifications.', - error_reasons: [ERROR_PERMIT_NOT_FOUND, ERROR_ACCOUNT_NOT_FOUND, ERROR_ROLE_NOT_WEB_GRANTABLE], + error_reasons: [ERROR_PERMIT_NOT_FOUND, ERROR_ROLE_NOT_WEB_GRANTABLE], rate_limit: 'account', } satisfies RequestResponseActionSpec; diff --git a/src/lib/auth/permit_offer_actions.ts b/src/lib/auth/permit_offer_actions.ts index a47734b4..9172b37d 100644 --- a/src/lib/auth/permit_offer_actions.ts +++ b/src/lib/auth/permit_offer_actions.ts @@ -37,7 +37,12 @@ * @module */ -import {rpc_action, type ActionContext, type RpcAction} from '../actions/action_rpc.js'; +import { + rpc_actor_action, + type ActionActorContext, + type ActionContext, + type RpcAction, +} from '../actions/action_rpc.js'; import {jsonrpc_errors} from '../http/jsonrpc_errors.js'; import {emit_after_commit} from '../http/pending_effects.js'; import {BUILTIN_ROLE_OPTIONS, ROLE_ADMIN, type RoleSchemaResult} from './role_schema.js'; @@ -49,6 +54,8 @@ import { query_permit_offer_list, query_permit_offer_history_for_account, query_accept_offer, + PermitOfferActorAccountMismatchError, + PermitOfferActorMismatchError, PermitOfferAlreadyTerminalError, PermitOfferExpiredError, PermitOfferNotFoundError, @@ -56,10 +63,15 @@ import { } from './permit_offer_queries.js'; import {query_permit_find_active_role_for_actor, query_revoke_permit} from './permit_queries.js'; import {query_actor_by_id} from './account_queries.js'; -import {audit_log_fire_and_forget} from './audit_log_queries.js'; +import {emit_permit_target_event} from './audit_log_queries.js'; import type {AuditLogEvent} from './audit_log_schema.js'; -import {has_role, has_scoped_role, type RequestContext} from './request_context.js'; -import type {RouteFactoryDeps} from './deps.js'; +import { + has_role, + has_scoped_role, + type RequestActorContext, + type RequestContext, +} from './request_context.js'; +import type {AuditEmitDeps, RouteFactoryDeps} from './deps.js'; import { build_permit_offer_accepted_notification, build_permit_offer_declined_notification, @@ -69,12 +81,10 @@ import { build_permit_revoke_notification, type NotificationSender, } from './permit_offer_notifications.js'; +import {ERROR_PERMIT_NOT_FOUND, ERROR_ROLE_NOT_WEB_GRANTABLE} from '../http/error_schemas.js'; import { - ERROR_ACCOUNT_NOT_FOUND, - ERROR_PERMIT_NOT_FOUND, - ERROR_ROLE_NOT_WEB_GRANTABLE, -} from '../http/error_schemas.js'; -import { + ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH, + ERROR_OFFER_ACTOR_MISMATCH, ERROR_OFFER_EXPIRED, ERROR_OFFER_NOT_AUTHORIZED, ERROR_OFFER_NOT_FOUND, @@ -187,16 +197,6 @@ export const authorize_admin_or_holder: PermitOfferCreateAuthorize = async ( return has_scoped_role(auth, input.role, null); }; -/** - * Narrow `ctx.auth` to non-null. The RPC dispatcher has already enforced - * `auth: 'authenticated'` before the handler runs — this is a type narrow, - * not a runtime check that would otherwise fail. - */ -const require_request_auth = (auth: RequestContext | null): RequestContext => { - if (!auth) throw new Error('unreachable: action auth guard did not enforce authentication'); - return auth; -}; - // -- Action factory --------------------------------------------------------- /** @@ -207,10 +207,7 @@ const require_request_auth = (auth: RequestContext | null): RequestContext => { * directly (the transport's `send_to_account` signature accepts the broader * `JsonrpcMessageFromServerToClient`, which is contravariantly compatible). */ -export interface PermitOfferActionDeps extends Pick< - RouteFactoryDeps, - 'log' | 'on_audit_event' | 'audit_log_config' -> { +export interface PermitOfferActionDeps extends AuditEmitDeps { /** Optional WS fan-out primitive. `null` or absent → notifications skipped. */ notification_sender?: NotificationSender | null; } @@ -232,30 +229,27 @@ export const create_permit_offer_actions = ( const default_ttl_ms = options.default_ttl_ms ?? PERMIT_OFFER_DEFAULT_TTL_MS; const authorize = options.authorize ?? default_authorize; - // Three denial paths (web_grantable, authorize, self-target) all emit the - // same failure-outcome audit event. Local closure over `log` + `on_audit_event`. + // Four denial paths (web_grantable, authorize, self-target, + // actor-account mismatch) all emit the same failure-outcome audit + // event. `target_actor_id` is populated when the caller supplied a + // `to_actor_id` so failure rows match the success-shape envelope of + // actor-targeted offers. const emit_create_failure_audit = ( ctx: ActionContext, - auth: RequestContext, - input: Pick, + auth: RequestActorContext, + input: Pick, ): void => { - void audit_log_fire_and_forget( - ctx, - { - event_type: 'permit_offer_create', - outcome: 'failure', - actor_id: auth.actor.id, - account_id: auth.account.id, - target_account_id: input.to_account_id, - ip: ctx.client_ip, - metadata: { - role: input.role, - scope_id: input.scope_id ?? null, - to_account_id: input.to_account_id, - }, + void emit_permit_target_event(ctx, auth, deps, { + event_type: 'permit_offer_create', + outcome: 'failure', + target_account_id: input.to_account_id, + target_actor_id: input.to_actor_id ?? null, + metadata: { + role: input.role, + scope_id: input.scope_id ?? null, + to_account_id: input.to_account_id, }, - deps, - ); + }); }; // Returns {offer} only — no auto-accept. Recipient must call @@ -263,9 +257,9 @@ export const create_permit_offer_actions = ( // query_accept_offer (see testing/admin_integration.ts `offer_and_accept`). const create_handler = async ( input: PermitOfferCreateInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = require_request_auth(ctx.auth); + const auth = ctx.auth; // Role must be web_grantable — same gate as admin direct-grant. const rc = role_options.get(input.role); @@ -298,6 +292,7 @@ export const create_permit_offer_actions = ( offer = await query_permit_offer_create(ctx, { from_actor_id: auth.actor.id, to_account_id: input.to_account_id, + to_actor_id: input.to_actor_id ?? null, role: input.role, scope_id: input.scope_id ?? null, message: input.message ?? null, @@ -310,26 +305,30 @@ export const create_permit_offer_actions = ( reason: ERROR_OFFER_SELF_TARGET, }); } + if (err instanceof PermitOfferActorAccountMismatchError) { + emit_create_failure_audit(ctx, auth, input); + throw jsonrpc_errors.invalid_params('to_actor_id does not belong to to_account_id', { + reason: ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH, + }); + } throw err; } - void audit_log_fire_and_forget( - ctx, - { - event_type: 'permit_offer_create', - actor_id: auth.actor.id, - account_id: auth.account.id, - target_account_id: input.to_account_id, - ip: ctx.client_ip, - metadata: { - offer_id: offer.id, - role: offer.role, - scope_id: offer.scope_id, - to_account_id: offer.to_account_id, - }, + // `target_actor_id` is populated when the offer is actor-targeted + // (per the offer's `to_actor_id`), null for account-grain offers + // — closes the audit hole where offer-shape events used to leave + // actor-grain forensics blank even when the binding was known. + void emit_permit_target_event(ctx, auth, deps, { + event_type: 'permit_offer_create', + target_account_id: input.to_account_id, + target_actor_id: offer.to_actor_id, + metadata: { + offer_id: offer.id, + role: offer.role, + scope_id: offer.scope_id, + to_account_id: offer.to_account_id, }, - deps, - ); + }); const offer_json = to_permit_offer_json(offer); if (notification_sender) { @@ -346,14 +345,15 @@ export const create_permit_offer_actions = ( const accept_handler = async ( input: PermitOfferAcceptInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = require_request_auth(ctx.auth); + const auth = ctx.auth; let result; try { result = await query_accept_offer(ctx, { offer_id: input.offer_id, to_account_id: auth.account.id, + actor_id: auth.actor.id, ip: ctx.client_ip, }); } catch (err) { @@ -366,6 +366,11 @@ export const create_permit_offer_actions = ( if (err instanceof PermitOfferExpiredError) { throw jsonrpc_errors.invalid_request({reason: ERROR_OFFER_EXPIRED}); } + if (err instanceof PermitOfferActorMismatchError) { + throw jsonrpc_errors.forbidden('offer is targeted to a different actor', { + reason: ERROR_OFFER_ACTOR_MISMATCH, + }); + } throw err; } @@ -420,9 +425,9 @@ export const create_permit_offer_actions = ( const decline_handler = async ( input: PermitOfferDeclineInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = require_request_auth(ctx.auth); + const auth = ctx.auth; let declined; try { declined = await query_permit_offer_decline( @@ -441,38 +446,35 @@ export const create_permit_offer_actions = ( throw jsonrpc_errors.not_found('offer', {reason: ERROR_OFFER_NOT_FOUND}); } - void audit_log_fire_and_forget( - ctx, - { - event_type: 'permit_offer_decline', - actor_id: auth.actor.id, - account_id: auth.account.id, - ip: ctx.client_ip, - metadata: { - offer_id: declined.id, - role: declined.role, - scope_id: declined.scope_id, - reason: input.reason ?? undefined, - }, + // `permit_offer_decline` is *to* the offering actor — populate both + // `target_actor_id` (the grantor actor) and `target_account_id` + // (the grantor account, joined in the decline RETURNING via CTE). + // The "both populated → same account" invariant holds: the + // grantor's actor↔account binding is 1:1 by definition of `actor`. + void emit_permit_target_event(ctx, auth, deps, { + event_type: 'permit_offer_decline', + target_account_id: declined.from_account_id, + target_actor_id: declined.from_actor_id, + metadata: { + offer_id: declined.id, + role: declined.role, + scope_id: declined.scope_id, + reason: input.reason ?? undefined, }, - deps, - ); + }); if (notification_sender) { - // Look up the grantor's account (SELECT by PK, same tx) for the - // notification target. The decline reason rides along on - // `offer.decline_reason` — the DB set it in the RETURNING above. - const grantor_actor = await query_actor_by_id(ctx, declined.from_actor_id); - const grantor_account_id = grantor_actor?.account_id ?? null; - if (grantor_account_id) { - const offer_json = to_permit_offer_json(declined); - emit_after_commit(ctx, () => { - notification_sender.send_to_account( - grantor_account_id, - build_permit_offer_declined_notification({offer: offer_json}), - ); - }); - } + // Grantor's account_id rides on `declined.from_account_id` from + // the decline RETURNING — no second SELECT needed. The decline + // reason rides along on `offer.decline_reason` — the DB set it + // in the RETURNING above. + const offer_json = to_permit_offer_json(declined); + emit_after_commit(ctx, () => { + notification_sender.send_to_account( + declined.from_account_id, + build_permit_offer_declined_notification({offer: offer_json}), + ); + }); } return {ok: true}; @@ -480,9 +482,9 @@ export const create_permit_offer_actions = ( const retract_handler = async ( input: PermitOfferRetractInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = require_request_auth(ctx.auth); + const auth = ctx.auth; let retracted; try { retracted = await query_permit_offer_retract(ctx, input.offer_id, auth.actor.id); @@ -496,21 +498,20 @@ export const create_permit_offer_actions = ( throw jsonrpc_errors.not_found('offer', {reason: ERROR_OFFER_NOT_FOUND}); } - void audit_log_fire_and_forget( - ctx, - { - event_type: 'permit_offer_retract', - actor_id: auth.actor.id, - account_id: auth.account.id, - ip: ctx.client_ip, - metadata: { - offer_id: retracted.id, - role: retracted.role, - scope_id: retracted.scope_id, - }, + // `permit_offer_retract` is *from* the recipient inbox — + // `target_account_id` is the recipient account; `target_actor_id` + // inherits the offer's `to_actor_id` (set on actor-targeted + // offers, null on account-grain offers). + void emit_permit_target_event(ctx, auth, deps, { + event_type: 'permit_offer_retract', + target_account_id: retracted.to_account_id, + target_actor_id: retracted.to_actor_id, + metadata: { + offer_id: retracted.id, + role: retracted.role, + scope_id: retracted.scope_id, }, - deps, - ); + }); if (notification_sender) { const offer_json = to_permit_offer_json(retracted); @@ -527,9 +528,9 @@ export const create_permit_offer_actions = ( const list_handler = async ( input: PermitOfferListInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = require_request_auth(ctx.auth); + const auth = ctx.auth; const target = input.account_id ?? auth.account.id; if (target !== auth.account.id && !has_role(auth, ROLE_ADMIN)) { throw jsonrpc_errors.forbidden('admin required to inspect another account'); @@ -540,9 +541,9 @@ export const create_permit_offer_actions = ( const history_handler = async ( input: PermitOfferHistoryInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = require_request_auth(ctx.auth); + const auth = ctx.auth; const target = input.account_id ?? auth.account.id; if (target !== auth.account.id && !has_role(auth, ROLE_ADMIN)) { throw jsonrpc_errors.forbidden('admin required to inspect another account'); @@ -558,12 +559,19 @@ export const create_permit_offer_actions = ( const revoke_handler = async ( input: PermitRevokeInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = require_request_auth(ctx.auth); - - // IDOR guard + role lookup. One SELECT — returns null when the - // permit is revoked, missing, or belongs to a different actor. + const auth = ctx.auth; + + // IDOR guard + role lookup + actor → account JOIN. One SELECT — + // returns null when the permit is revoked, missing, or belongs + // to a different actor. The JOIN supplies `account_id` for the + // audit envelope's `target_account_id` and the post-commit + // SSE/WS socket-close fan-out target. `permit_revoke` is the + // canonical actor-bound-subject event: `target_actor_id` is the + // permit's grantee (input.actor_id); `target_account_id` is the + // account hosting that actor (sessions remain account-grain + // after multi-actor lands). const permit_row = await query_permit_find_active_role_for_actor( ctx, input.permit_id, @@ -572,34 +580,19 @@ export const create_permit_offer_actions = ( if (!permit_row) { throw jsonrpc_errors.not_found('permit', {reason: ERROR_PERMIT_NOT_FOUND}); } - - // Resolve the target actor's account once — drives both the audit - // `target_account_id` and the post-commit notification target. - const target_actor = await query_actor_by_id(ctx, input.actor_id); - if (!target_actor) { - // The IDOR guard above already matched, so a missing actor here - // indicates a race (account deleted between the two SELECTs). - // Treat as account-not-found for the caller. - throw jsonrpc_errors.not_found('account', {reason: ERROR_ACCOUNT_NOT_FOUND}); - } - const target_account_id = target_actor.account_id; + const target_account_id = permit_row.account_id; + const target_actor_id = input.actor_id; // web_grantable gate — keeper/daemon-scoped roles stay CLI-only. const rc = role_options.get(permit_row.role); if (!rc?.web_grantable) { - void audit_log_fire_and_forget( - ctx, - { - event_type: 'permit_revoke', - outcome: 'failure', - actor_id: auth.actor.id, - account_id: auth.account.id, - target_account_id, - ip: ctx.client_ip, - metadata: {role: permit_row.role, permit_id: input.permit_id}, - }, - deps, - ); + void emit_permit_target_event(ctx, auth, deps, { + event_type: 'permit_revoke', + outcome: 'failure', + target_account_id, + target_actor_id, + metadata: {role: permit_row.role, permit_id: input.permit_id}, + }); throw jsonrpc_errors.forbidden('role not web-grantable', { reason: ERROR_ROLE_NOT_WEB_GRANTABLE, }); @@ -618,41 +611,34 @@ export const create_permit_offer_actions = ( throw jsonrpc_errors.not_found('permit', {reason: ERROR_PERMIT_NOT_FOUND}); } - void audit_log_fire_and_forget( - ctx, - { - event_type: 'permit_revoke', - actor_id: auth.actor.id, - account_id: auth.account.id, - target_account_id, - ip: ctx.client_ip, - metadata: { - role: result.role, - permit_id: result.id, - scope_id: result.scope_id, - reason: input.reason ?? undefined, - }, + void emit_permit_target_event(ctx, auth, deps, { + event_type: 'permit_revoke', + target_account_id, + target_actor_id, + metadata: { + role: result.role, + permit_id: result.id, + scope_id: result.scope_id, + reason: input.reason ?? undefined, }, - deps, - ); + }); + // Supersede cascade — the recipient is known (`offer.to_account_id`), + // so populate `target_account_id` rather than leaving it null; + // `target_actor_id` inherits the offer's `to_actor_id` (actor-grain + // when the superseded offer was actor-targeted, null otherwise). for (const offer of result.superseded_offers) { - void audit_log_fire_and_forget( - ctx, - { - event_type: 'permit_offer_supersede', - actor_id: auth.actor.id, - account_id: offer.to_account_id, - ip: ctx.client_ip, - metadata: { - offer_id: offer.id, - role: offer.role, - scope_id: offer.scope_id, - reason: 'permit_revoked', - cause_id: result.id, - }, + void emit_permit_target_event(ctx, auth, deps, { + event_type: 'permit_offer_supersede', + target_account_id: offer.to_account_id, + target_actor_id: offer.to_actor_id, + metadata: { + offer_id: offer.id, + role: offer.role, + scope_id: offer.scope_id, + reason: 'permit_revoked', + cause_id: result.id, }, - deps, - ); + }); } if (notification_sender) { @@ -689,12 +675,12 @@ export const create_permit_offer_actions = ( }; return [ - rpc_action(permit_offer_create_action_spec, create_handler), - rpc_action(permit_offer_accept_action_spec, accept_handler), - rpc_action(permit_offer_decline_action_spec, decline_handler), - rpc_action(permit_offer_retract_action_spec, retract_handler), - rpc_action(permit_offer_list_action_spec, list_handler), - rpc_action(permit_offer_history_action_spec, history_handler), - rpc_action(permit_revoke_action_spec, revoke_handler), + rpc_actor_action(permit_offer_create_action_spec, create_handler), + rpc_actor_action(permit_offer_accept_action_spec, accept_handler), + rpc_actor_action(permit_offer_decline_action_spec, decline_handler), + rpc_actor_action(permit_offer_retract_action_spec, retract_handler), + rpc_actor_action(permit_offer_list_action_spec, list_handler), + rpc_actor_action(permit_offer_history_action_spec, history_handler), + rpc_actor_action(permit_revoke_action_spec, revoke_handler), ]; }; diff --git a/src/lib/auth/permit_offer_queries.ts b/src/lib/auth/permit_offer_queries.ts index 6350afdb..0d4af836 100644 --- a/src/lib/auth/permit_offer_queries.ts +++ b/src/lib/auth/permit_offer_queries.ts @@ -17,7 +17,6 @@ import type {Uuid} from '@fuzdev/fuz_util/id.js'; import type {QueryDeps} from '../db/query_deps.js'; import {assert_row} from '../db/assert_row.js'; import type {Permit} from './account_schema.js'; -import {query_actor_by_account} from './account_queries.js'; import { PERMIT_OFFER_SCOPE_SENTINEL_UUID, type CreatePermitOfferInput, @@ -69,9 +68,11 @@ export class PermitOfferNotFoundError extends Error { /** * Error thrown when a grantor attempts to offer a permit to their own account. * - * Enforced here (rather than via a CHECK constraint) so the constraint can - * be expressed as a cross-row JOIN on `actor.account_id` without requiring - * denormalized columns. + * Enforced via a single SELECT on the grantor's `actor.account_id` (rather + * than via a CHECK constraint or a denormalized column). Resolving from the + * grantor side keeps the check multi-actor-correct: under multi-actor the + * recipient account may host many actors, but the grantor → account binding + * remains 1:1 by definition of `actor`. */ export class PermitOfferSelfTargetError extends Error { constructor() { @@ -80,44 +81,104 @@ export class PermitOfferSelfTargetError extends Error { } } +/** + * Error thrown when an actor-targeted offer is being accepted by an actor + * other than `offer.to_actor_id`. Distinct from `PermitOfferNotFoundError` + * (the IDOR mask): once an offer has been resolved to the recipient account, + * a wrong-actor accept on a same-account actor is a contract violation, not + * a privacy boundary — surface a specific error so the client UI can + * distinguish "this offer isn't for you" from "no such offer". + */ +export class PermitOfferActorMismatchError extends Error { + constructor(offer_id: string) { + super(`Offer ${offer_id} is targeted to a different actor on this account`); + this.name = 'PermitOfferActorMismatchError'; + } +} + +/** + * Error thrown when `query_permit_offer_create` is called with a + * `to_actor_id` that does not exist or does not belong to `to_account_id`. + * Surfaces the actor↔account binding mismatch at the boundary instead of + * letting the FK silently disagree with the recipient field. + */ +export class PermitOfferActorAccountMismatchError extends Error { + constructor() { + super('to_actor_id does not belong to to_account_id'); + this.name = 'PermitOfferActorAccountMismatchError'; + } +} + /** * Create a new permit offer, or refresh an existing pending offer for the * same `(to_account_id, role, scope_id, from_actor_id)` tuple. * * Re-offer semantics: a second call by the same grantor with the same * `(to_account, role, scope)` while pending upserts the existing row, - * refreshing `message` and `expires_at`. A different grantor offering the - * same `(to_account, role, scope)` creates a distinct row — multiple - * pending grantors coexist. After a terminal state, a re-offer is a fresh - * INSERT. + * refreshing `message` and `expires_at` (and `to_actor_id` — supplying + * a different `to_actor_id` on re-offer narrows the existing row to the + * named actor; supplying null widens it back to account-grain). A + * different grantor offering the same `(to_account, role, scope)` creates + * a distinct row — multiple pending grantors coexist. After a terminal + * state, a re-offer is a fresh INSERT. * * Self-offer rejection: throws `PermitOfferSelfTargetError` if the offering * actor belongs to the recipient account. * + * Actor-targeted offers: when `to_actor_id` is supplied, + * `query_accept_offer` rejects any actor other than the named one. Closes + * the audit hole where offer-shape events would otherwise leave + * `target_actor_id` null even when the recipient binding is known at + * offer time. The actor↔account binding is verified here in one SELECT. + * * @mutates `permit_offer` table - inserts a new offer or upserts the matching pending row * @throws PermitOfferSelfTargetError if the offering actor belongs to `to_account_id` + * @throws PermitOfferActorAccountMismatchError if `to_actor_id` is set but does not belong to `to_account_id` */ export const query_permit_offer_create = async ( deps: QueryDeps, input: CreatePermitOfferInput, ): Promise => { - const actor = await query_actor_by_account(deps, input.to_account_id); - if (actor && actor.id === input.from_actor_id) { + // Self-target check resolves the **grantor** actor's account and + // compares against to_account_id. This is multi-actor-correct: + // a single account may host many actors, and self-target means + // "the offering actor's account == the recipient account", + // regardless of how many other actors live on either account. + // (The earlier shape — "look up an actor on to_account_id, compare + // to from_actor_id" — silently picked one actor on a multi-actor + // recipient account, missing the self-target case when the picked + // actor wasn't the offering one.) + const grantor = await deps.db.query_one<{account_id: Uuid}>( + `SELECT account_id FROM actor WHERE id = $1`, + [input.from_actor_id], + ); + if (grantor && grantor.account_id === input.to_account_id) { throw new PermitOfferSelfTargetError(); } + if (input.to_actor_id != null) { + const target = await deps.db.query_one<{account_id: Uuid}>( + `SELECT account_id FROM actor WHERE id = $1`, + [input.to_actor_id], + ); + if (!target || target.account_id !== input.to_account_id) { + throw new PermitOfferActorAccountMismatchError(); + } + } const row = await deps.db.query_one( `INSERT INTO permit_offer - (from_actor_id, to_account_id, role, scope_id, message, expires_at) - VALUES ($1, $2, $3, $4, $5, $6) + (from_actor_id, to_account_id, to_actor_id, role, scope_id, message, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (to_account_id, role, COALESCE(scope_id, '${PERMIT_OFFER_SCOPE_SENTINEL_UUID}'::uuid), from_actor_id) WHERE accepted_at IS NULL AND declined_at IS NULL AND retracted_at IS NULL AND superseded_at IS NULL DO UPDATE SET + to_actor_id = EXCLUDED.to_actor_id, message = EXCLUDED.message, expires_at = EXCLUDED.expires_at RETURNING *`, [ input.from_actor_id, input.to_account_id, + input.to_actor_id ?? null, input.role, input.scope_id ?? null, input.message ?? null, @@ -127,6 +188,17 @@ export const query_permit_offer_create = async ( return assert_row(row, 'INSERT INTO permit_offer'); }; +/** Result of `query_permit_offer_decline` — the declined offer plus the grantor's `account_id`. */ +export interface DeclinedOffer extends PermitOffer { + /** + * Grantor's `account_id`, resolved via a join on `actor` so the audit + * envelope's `target_account_id` (decline is *to* the grantor) and the + * post-commit notification target are both addressable without a + * second round-trip. + */ + from_account_id: Uuid; +} + /** * Mark an offer declined. * @@ -135,6 +207,12 @@ export const query_permit_offer_create = async ( * `PermitOfferAlreadyTerminalError` if the offer exists for the caller but * is already in a terminal state. * + * Returns the declined offer with the grantor's `from_account_id` joined + * in via CTE — the decline audit envelope populates **both** + * `target_actor_id` (the grantor actor) and `target_account_id` (the + * grantor account), satisfying the "both populated → same account" + * invariant the audit-log column comments describe. + * * @mutates `permit_offer` row - sets `declined_at` and `decline_reason` * @throws PermitOfferAlreadyTerminalError if the offer is already accepted, declined, retracted, or superseded */ @@ -143,17 +221,22 @@ export const query_permit_offer_decline = async ( offer_id: string, to_account_id: string, reason: string | null, -): Promise => { - const updated = await deps.db.query_one( - `UPDATE permit_offer - SET declined_at = NOW(), decline_reason = $3 - WHERE id = $1 - AND to_account_id = $2 - AND accepted_at IS NULL - AND declined_at IS NULL - AND retracted_at IS NULL - AND superseded_at IS NULL - RETURNING *`, +): Promise => { + const updated = await deps.db.query_one( + `WITH updated AS ( + UPDATE permit_offer + SET declined_at = NOW(), decline_reason = $3 + WHERE id = $1 + AND to_account_id = $2 + AND accepted_at IS NULL + AND declined_at IS NULL + AND retracted_at IS NULL + AND superseded_at IS NULL + RETURNING * + ) + SELECT u.*, grantor.account_id AS from_account_id + FROM updated u + JOIN actor grantor ON grantor.id = u.from_actor_id`, [offer_id, to_account_id, reason ?? null], ); if (updated) return updated; @@ -313,6 +396,18 @@ export interface AcceptOfferInput { offer_id: Uuid; /** Account of the accepting recipient — IDOR guard against another account accepting the offer. */ to_account_id: Uuid; + /** + * Accepting actor — the actor that will hold the resulting permit. + * Must belong to `to_account_id`; the query verifies and throws if not + * (defense-in-depth — the action handler passes `auth.actor.id` which + * is session-bound, but the query enforces the invariant for all + * callers including tests and future direct consumers). + * + * Required because under multi-actor an account may host many actors; + * the resulting permit must bind to the actor that actually accepted, + * not "an" actor on the account picked by query order. + */ + actor_id: Uuid; /** Optional IP to stamp on the audit events. */ ip?: string | null; } @@ -364,13 +459,13 @@ export interface AcceptOfferResult { * @throws PermitOfferNotFoundError if the offer is missing or belongs to another recipient * @throws PermitOfferAlreadyTerminalError if the offer is declined, retracted, or superseded * @throws PermitOfferExpiredError if the offer is pending but past `expires_at` - * @throws Error if the accepting account has no actor (1:1 invariant) or invariant assertions fail + * @throws Error if the accepting `actor_id` does not belong to `to_account_id`, or invariant assertions fail */ export const query_accept_offer = async ( deps: QueryDeps, input: AcceptOfferInput, ): Promise => { - const {offer_id, to_account_id, ip} = input; + const {offer_id, to_account_id, actor_id, ip} = input; // Claim the offer with a row-level lock. Subsequent concurrent callers // block on the lock until this transaction commits/rolls back; after commit @@ -392,11 +487,22 @@ export const query_accept_offer = async ( if (locked.accepted_at) { // Race winner already committed; return the pre-existing permit. // `permit_offer_permit_iff_accepted` CHECK guarantees resulting_permit_id is non-null. - const permit = await deps.db.query_one(`SELECT * FROM permit WHERE id = $1`, [ - locked.resulting_permit_id!, - ]); + const permit = assert_row( + await deps.db.query_one(`SELECT * FROM permit WHERE id = $1`, [ + locked.resulting_permit_id!, + ]), + 'resulting_permit lookup', + ); + // Multi-actor guard: two actors on the same recipient account may + // both race an account-grain offer — the loser must not silently + // receive the winner's permit (which would tell them "you got it" + // while the actor on the permit is someone else). Treat the offer + // as terminal for the loser. + if (permit.actor_id !== actor_id) { + throw new PermitOfferAlreadyTerminalError(offer_id); + } return { - permit: assert_row(permit, 'resulting_permit lookup'), + permit, offer: locked, created: false, superseded_offers: [], @@ -415,10 +521,39 @@ export const query_accept_offer = async ( throw new PermitOfferExpiredError(offer_id); } - // Resolve the accepting actor (1:1 account→actor in v1). - const actor = await query_actor_by_account(deps, to_account_id); - if (!actor) { - throw new Error(`No actor for account ${to_account_id} accepting offer ${offer_id}`); + // Actor-targeted offer gate. When the offer is account-grain + // (`to_actor_id IS NULL`) any actor on `to_account_id` may accept and + // the existing actor↔account check below applies. When actor-grain + // (`to_actor_id IS NOT NULL`) the accepting actor must match — + // reject otherwise, even when the actor is on the same account, so + // teacher-A's offer cannot be claimed by teacher-B's actor. + // + // Ordering contract: this check fires *before* the cross-account + // `actor_check` SELECT below. A wrong-actor accept on an actor-grain + // offer surfaces as `PermitOfferActorMismatchError` regardless of + // whether the supplied `actor_id` belongs to `to_account_id` — the + // actor-grain binding is the tighter constraint and dominates. The + // cross-account `Error` only fires for account-grain offers (or + // matching actor-grain offers where `to_actor_id === actor_id` but + // the actor turns out not to be on the account, which is unreachable + // under the FK invariant but stays as defense-in-depth). + if (locked.to_actor_id != null && locked.to_actor_id !== actor_id) { + throw new PermitOfferActorMismatchError(offer_id); + } + + // Verify the accepting actor belongs to the recipient account. + // Defense-in-depth: the action handler passes `auth.actor.id` which is + // already session-bound, but enforcing the invariant here protects + // direct callers (tests, future consumers) from cross-account binding + // bugs that would silently grant a permit to the wrong actor. + const actor_check = await deps.db.query_one<{id: Uuid}>( + `SELECT id FROM actor WHERE id = $1 AND account_id = $2`, + [actor_id, to_account_id], + ); + if (!actor_check) { + throw new Error( + `Accepting actor ${actor_id} does not belong to account ${to_account_id} (offer ${offer_id})`, + ); } // Insert the permit. Uses the normal grant idempotency — if another @@ -430,7 +565,7 @@ export const query_accept_offer = async ( WHERE revoked_at IS NULL DO NOTHING RETURNING *`, - [actor.id, locked.role, locked.scope_id, locked.from_actor_id, locked.id], + [actor_id, locked.role, locked.scope_id, locked.from_actor_id, locked.id], ); let permit: Permit; if (granted_permit) { @@ -442,7 +577,7 @@ export const query_accept_offer = async ( AND role = $2 AND scope_id IS NOT DISTINCT FROM $3 AND revoked_at IS NULL`, - [actor.id, locked.role, locked.scope_id], + [actor_id, locked.role, locked.scope_id], ); permit = assert_row(existing, 'query_accept_offer idempotent permit lookup'); } @@ -484,10 +619,15 @@ export const query_accept_offer = async ( // Emit audit events in-transaction (atomic with the permit insert). // `RETURNING *` after the SET guarantees `offer.resulting_permit_id === permit.id`. + // Accept binds the actor deterministically — populate both target + // columns to mirror `permit_grant` (the in-tx pair) so forensic + // queries don't have to split between the two events. const offer_accept_event = await query_audit_log(deps, { event_type: 'permit_offer_accept', - actor_id: actor.id, + actor_id, account_id: to_account_id, + target_account_id: to_account_id, + target_actor_id: actor_id, ip: ip ?? null, metadata: { offer_id: offer.id, @@ -496,10 +636,17 @@ export const query_accept_offer = async ( scope_id: offer.scope_id, }, }); + // `permit_grant` is the canonical actor-bound-subject event — the + // permit just bound to this actor. On self-accept the actor and the + // target are the same identity; on admin direct-grant (separate code + // path) they differ. Either way `target_actor_id` carries the + // grantee for actor-grain forensics. const permit_grant_event = await query_audit_log(deps, { event_type: 'permit_grant', - actor_id: actor.id, + actor_id, account_id: to_account_id, + target_account_id: to_account_id, + target_actor_id: actor_id, ip: ip ?? null, metadata: { role: offer.role, @@ -510,11 +657,16 @@ export const query_accept_offer = async ( }); const supersede_events: Array = []; for (const sibling of superseded) { + // Supersede inherits the sibling's actor-grain target — actor-grain + // when the sibling was actor-targeted, account-grain (null) when it + // was account-level. supersede_events.push( await query_audit_log(deps, { event_type: 'permit_offer_supersede', - actor_id: actor.id, + actor_id, account_id: to_account_id, + target_account_id: to_account_id, + target_actor_id: sibling.to_actor_id, ip: ip ?? null, metadata: { offer_id: sibling.id, diff --git a/src/lib/auth/permit_offer_schema.ts b/src/lib/auth/permit_offer_schema.ts index e2e873e3..e0b793b6 100644 --- a/src/lib/auth/permit_offer_schema.ts +++ b/src/lib/auth/permit_offer_schema.ts @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS permit_offer ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), from_actor_id UUID NOT NULL REFERENCES actor(id) ON DELETE CASCADE, to_account_id UUID NOT NULL REFERENCES account(id) ON DELETE CASCADE, + to_actor_id UUID NULL REFERENCES actor(id) ON DELETE CASCADE, role TEXT NOT NULL, scope_id UUID NULL, message TEXT NULL, @@ -88,11 +89,30 @@ CREATE INDEX IF NOT EXISTS permit_offer_inbox AND retracted_at IS NULL AND superseded_at IS NULL`; +// **Deferred**: a `permit_offer_to_actor` partial index belongs here once +// an actor-side inbox query (`query_permit_offer_list_for_actor`) lands — +// no current consumer filters on `to_actor_id`, and adding the index +// before the query is paying write-amp for nothing. Land the index in +// the same slice as the query. + /** Permit offer row as returned by the database. */ export interface PermitOffer { id: Uuid; from_actor_id: Uuid; to_account_id: Uuid; + /** + * Optional actor-grain target on the recipient account. When set, accept + * is gated to this specific actor — `query_accept_offer` rejects any + * other actor with `permit_offer_actor_mismatch` even when they belong + * to `to_account_id`. When null the offer is account-grain and any + * actor on `to_account_id` may accept (the v1 default). + * + * Drives the audit envelope's `target_actor_id` on offer-shape events + * (`permit_offer_create` / `_expire` / `_retract` / `_supersede`) — when + * set, the actor-grain forensic field carries the named actor; when + * null the offer-shape events leave it null by design. + */ + to_actor_id: Uuid | null; role: string; scope_id: Uuid | null; message: string | null; @@ -133,6 +153,14 @@ export interface SupersededOffer extends PermitOffer { export interface CreatePermitOfferInput { from_actor_id: Uuid; to_account_id: Uuid; + /** + * Optional actor-grain target on the recipient account. When set, + * `query_permit_offer_create` validates that the actor belongs to + * `to_account_id` and stamps the column; accept then matches against + * this specific actor. Omit (or pass null) for the account-grain + * default — any actor on `to_account_id` may accept. + */ + to_actor_id?: Uuid | null; role: string; scope_id?: Uuid | null; message?: string | null; @@ -145,6 +173,10 @@ export const PermitOfferJson = z id: Uuid.meta({description: 'Offer id.'}), from_actor_id: Uuid.meta({description: 'Actor that issued the offer.'}), to_account_id: Uuid.meta({description: 'Account the offer is directed to.'}), + to_actor_id: Uuid.nullable().meta({ + description: + 'Optional actor-grain target on the recipient account. When set, only this actor may accept; when null any actor on `to_account_id` may accept.', + }), role: RoleName.meta({description: 'Role being offered.'}), scope_id: Uuid.nullable().meta({ description: @@ -192,6 +224,7 @@ export const to_permit_offer_json = (offer: PermitOffer): PermitOfferJson => ({ id: offer.id, from_actor_id: offer.from_actor_id, to_account_id: offer.to_account_id, + to_actor_id: offer.to_actor_id, role: offer.role, scope_id: offer.scope_id, message: offer.message, diff --git a/src/lib/auth/permit_queries.ts b/src/lib/auth/permit_queries.ts index f39ac95a..ee595664 100644 --- a/src/lib/auth/permit_queries.ts +++ b/src/lib/auth/permit_queries.ts @@ -64,29 +64,39 @@ export const query_grant_permit = async ( }; /** - * Look up the role of an active permit, constrained to a specific actor. + * Look up the role of an active permit (constrained to a specific + * actor) plus the actor's `account_id`. * * Used by admin routes to inspect the permit's role before acting * (e.g., enforcing `web_grantable` on revoke). The actor constraint * mirrors `query_revoke_permit` so IDOR protection is consistent: * a caller can only see permits belonging to the target actor. * + * The JOIN to `actor` collapses what used to be a second + * `query_actor_by_id` round-trip in the revoke handler into one read, + * which closes the small TOCTOU window where the actor row could be + * deleted between the IDOR check and the actor lookup. The `account_id` + * is needed by the audit envelope's `target_account_id` field and the + * SSE/WS socket-close fan-out targeting. + * * Returns `null` if the permit is not found, already revoked, or * belongs to a different actor. * * @param deps - query dependencies * @param permit_id - the permit id to look up * @param actor_id - the actor that must own the permit - * @returns `{role}` on a match, or `null` + * @returns `{role, account_id}` on a match, or `null` */ export const query_permit_find_active_role_for_actor = async ( deps: QueryDeps, permit_id: string, actor_id: string, -): Promise<{role: string} | null> => { - const row = await deps.db.query_one<{role: string}>( - `SELECT role FROM permit - WHERE id = $1 AND actor_id = $2 AND revoked_at IS NULL`, +): Promise<{role: string; account_id: Uuid} | null> => { + const row = await deps.db.query_one<{role: string; account_id: Uuid}>( + `SELECT permit.role, actor.account_id + FROM permit + JOIN actor ON actor.id = permit.actor_id + WHERE permit.id = $1 AND permit.actor_id = $2 AND permit.revoked_at IS NULL`, [permit_id, actor_id], ); return row ?? null; @@ -266,11 +276,19 @@ export const query_permit_find_account_id_for_role = async ( /** Result of `query_permit_revoke_for_scope` — every permit revoked plus every pending offer superseded by the scope-wide cascade. */ export interface RevokeForScopeResult { /** - * One entry per permit revoked by this call. Carries the revokee's - * `account_id` so callers can fan out a `permit_revoke` notification per - * permit. Empty array means no active permit was bound to the scope. + * One entry per permit revoked by this call. Carries both the revokee's + * `actor_id` (the permit's grantee — drives `target_actor_id` audit + * envelopes) and `account_id` (the actor's account — drives + * `target_account_id` for SSE/WS socket-close fan-out). Empty array + * means no active permit was bound to the scope. */ - revoked: Array<{permit_id: Uuid; role: string; scope_id: Uuid; account_id: Uuid}>; + revoked: Array<{ + permit_id: Uuid; + role: string; + scope_id: Uuid; + actor_id: Uuid; + account_id: Uuid; + }>; /** * Every pending offer at the scope — tuple-matched and orphan, undifferentiated * — superseded in the same cascade. Each entry carries its grantor's @@ -315,13 +333,15 @@ export const query_permit_revoke_for_scope = async ( revoked_by: Uuid | null, reason?: string | null, ): Promise => { - // Revoke every active permit at the scope. CTE pulls `account_id` via a - // join on `actor` so callers fan out `permit_revoke` notifications without - // an extra round-trip. + // Revoke every active permit at the scope. CTE returns `actor_id` directly + // from the permit row (drives `target_actor_id` audit envelopes); a join + // against `actor` resolves `account_id` for `target_account_id` + // + WS/SSE socket-close fan-out, all in one round-trip. const revoked = await deps.db.query<{ permit_id: Uuid; role: string; scope_id: Uuid; + actor_id: Uuid; account_id: Uuid; }>( `WITH updated AS ( @@ -330,7 +350,7 @@ export const query_permit_revoke_for_scope = async ( WHERE scope_id = $1 AND revoked_at IS NULL RETURNING id, role, scope_id, actor_id ) - SELECT u.id AS permit_id, u.role, u.scope_id, a.account_id + SELECT u.id AS permit_id, u.role, u.scope_id, u.actor_id, a.account_id FROM updated u JOIN actor a ON a.id = u.actor_id`, [scope_id, revoked_by ?? null, reason ?? null], diff --git a/src/lib/auth/request_context.ts b/src/lib/auth/request_context.ts index fb525455..6b7da68d 100644 --- a/src/lib/auth/request_context.ts +++ b/src/lib/auth/request_context.ts @@ -1,39 +1,99 @@ /** * Request context middleware and permit checking helpers. * - * Builds `{ account, actor, permits }` from a session cookie - * for every authenticated request. Downstream handlers check - * permits, never flags. + * Two-phase identity resolution: * - * `build_request_context` is the shared helper used by session, - * bearer, and daemon token middleware to resolve account → actor → permits. - * `refresh_permits` reloads permits on an existing context. + * 1. **Authentication (middleware)** — `create_request_context_middleware`, + * `bearer_auth`, and `daemon_token_middleware` validate the credential + * (session cookie, bearer token, daemon token) and set `c.var.account_id` + * + `c.var.credential_type` on the Hono context. They do not resolve + * an acting actor or load permits; `REQUEST_CONTEXT_KEY` stays null at + * this stage, so account-grain identity is the only thing known. + * 2. **Authorization (route-spec wrapper / RPC dispatcher)** — after input + * validation, the per-route layer inspects the route. If the input + * schema declared `acting?: ActingActor` (reference equality with the + * canonical `ActingActor` schema) or the auth requires permits + * (`role` / `keeper`), `apply_authorization_phase` resolves the actor + * against `c.var.account_id` plus the validated `acting` value via + * `resolve_acting_actor`, builds the `{account, actor, permits}` + * context via `build_request_context`, and sets it on + * `REQUEST_CONTEXT_KEY` before auth guards fire. Authenticated routes + * that don't need an actor still get an account-only context via + * `build_account_context` so handler signatures stay uniform. + * + * Account-grain operations (logout, password_change, account_verify, + * etc.) declare neither `acting` nor permit-requiring auth, so no actor + * is resolved and their handlers see a `RequestContext` with + * `actor: null` + empty `permits`. They never trigger `actor_required`, + * which is what makes multi-actor logout work without first picking a + * persona. + * + * `build_request_context` loads `account → actor → permits` and verifies + * the `actor.account_id === account.id` binding. `refresh_permits` + * reloads permits on an existing context. * * @module */ import type {Context, MiddlewareHandler} from 'hono'; +import {z} from 'zod'; import type {Logger} from '@fuzdev/fuz_util/log.js'; +import {zod_unwrap_to_object} from '@fuzdev/fuz_util/zod.js'; -import {type Account, type Actor, is_permit_active, type Permit} from './account_schema.js'; +import { + ActingActor, + type Account, + type Actor, + is_permit_active, + type Permit, +} from './account_schema.js'; import { hash_session_token, session_touch_fire_and_forget, query_session_get_valid, } from './session_queries.js'; -import {query_actor_by_account, query_account_by_id} from './account_queries.js'; +import { + query_account_by_id, + query_actor_by_id, + query_actors_by_account, +} from './account_queries.js'; import {query_permit_find_active_for_actor} from './permit_queries.js'; import type {QueryDeps} from '../db/query_deps.js'; -import {AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY} from '../hono_context.js'; +import { + ACCOUNT_ID_KEY, + AUTH_API_TOKEN_ID_KEY, + CACHED_REQUEST_BODY_KEY, + CREDENTIAL_TYPE_KEY, + TEST_CONTEXT_PRESET_KEY, +} from '../hono_context.js'; +import type {ActionAuth} from '../actions/action_spec.js'; +import type {RouteAuth, RouteSpec} from '../http/route_spec.js'; import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, + ERROR_ACTOR_REQUIRED, + ERROR_ACTOR_NOT_ON_ACCOUNT, + ERROR_NO_ACTORS_ON_ACCOUNT, + ERROR_ACCOUNT_VANISHED, } from '../http/error_schemas.js'; -/** The resolved identity context for an authenticated request. */ +/** + * The resolved identity context for an authenticated request. + * + * `actor` is null on account-grain routes (no `acting` field on input, + * no `role` / `keeper` auth) — those handlers don't trigger actor + * resolution. `permits` is empty in that case. Permit checks + * (`has_role`, `has_scoped_role`, `has_any_scoped_role`) are + * null-tolerant on `RequestContext | null`; they additionally treat + * `actor: null` as "no permits" so callers don't have to narrow. + * + * Multi-actor invariant: when populated, `actor.account_id === account.id`. + * `build_request_context` enforces this; the dispatcher's authorization + * phase rejects with `actor_not_on_account` before reaching the handler. + */ export interface RequestContext { account: Account; - actor: Actor; + actor: Actor | null; permits: Array; } @@ -64,22 +124,75 @@ export const get_request_context = (c: Context): RequestContext | null => { /** * Get the request context, throwing if unauthenticated. * - * Use in route handlers where auth middleware guarantees a context exists - * (i.e., routes with `auth: {type: 'authenticated'}` or stricter). - * Prefer this over `get_request_context(c)!` for explicit error handling. + * Use in route handlers where the dispatcher's authorization phase guarantees + * a context exists (i.e., routes with `auth: {type: 'authenticated'}` or + * stricter). Prefer this over `get_request_context(c)!` for explicit error + * handling. * * @param c - the Hono context * @returns the request context (never null) - * @throws Error if no request context is set (middleware misconfiguration) + * @throws Error if no request context is set (dispatcher misconfiguration) */ export const require_request_context = (c: Context): RequestContext => { const ctx = get_request_context(c); if (!ctx) { - throw new Error('require_request_context: no request context — is auth middleware applied?'); + throw new Error( + 'require_request_context: no request context — is the dispatcher authorization phase wired?', + ); } return ctx; }; +/** + * Request context narrowed to a resolved acting actor. + * + * Returned by `require_request_actor` for handlers whose route resolves + * an actor — actions with `auth: 'keeper' | {role}` or with input that + * declares `acting?: ActingActor`. Lets handlers drop the `auth.actor!` + * non-null assertion that was masking the dispatcher invariant. + */ +export interface RequestActorContext extends RequestContext { + actor: Actor; +} + +/** + * Narrow `RequestContext | null` to a non-null context (auth invariant). + * + * Use in RPC action handlers whose spec is non-public — the dispatcher's + * pre-validation auth gate has already short-circuited unauthenticated + * callers, so `ctx.auth` is non-null by the time the handler runs. + * + * @throws Error when called from a public-auth handler (programmer error) + */ +export const require_request_auth = (auth: RequestContext | null): RequestContext => { + if (!auth) { + throw new Error( + 'require_request_auth: no auth — is this handler bound to a non-public action spec?', + ); + } + return auth; +}; + +/** + * Narrow `RequestContext | null` to `RequestActorContext` (actor invariant). + * + * Use in RPC action handlers whose spec declares `auth: 'keeper' | {role}` + * or whose input declares `acting?: ActingActor` — the dispatcher's + * authorization phase resolves an actor before the handler runs. Replaces + * the `ctx.auth!.actor!.id` chain that the type system can't otherwise see. + * + * @throws Error when the handler runs without actor resolution (programmer error) + */ +export const require_request_actor = (auth: RequestContext | null): RequestActorContext => { + const ctx = require_request_auth(auth); + if (!ctx.actor) { + throw new Error( + 'require_request_actor: no actor — is this handler bound to an actor-implying spec (keeper/role) or one whose input declares `acting`?', + ); + } + return ctx as RequestActorContext; +}; + /** * Check if a request context has an active permit for a given role. * @@ -104,16 +217,19 @@ export const has_role = ( * Whether the request context holds an active permit for `role` at `scope_id`. * * Walks the in-memory `ctx.permits` snapshot loaded once per request by - * `create_request_context_middleware`; zero DB roundtrip per check. The - * "freshness" framing of a SQL re-query is illusory because the race window - * is between predicate and the actual mutation, not predicate and middleware - * load. Closing that race needs a transactional re-check inside the - * UPDATE/INSERT, which neither style provides. + * the route-spec / RPC dispatcher's authorization phase (when the route + * declares `acting?: ActingActor` or has permit-requiring auth); zero DB + * roundtrip per check. The "freshness" framing of a SQL re-query is + * illusory because the race window is between predicate and the actual + * mutation, not predicate and authorization load. Closing that race needs + * a transactional re-check inside the UPDATE/INSERT, which neither style + * provides. * - * Null-tolerant — `null` ctx (unauthenticated) returns `false`. Same + * Null-tolerant — `null` ctx (unauthenticated) and account-grain + * contexts (`actor: null`, empty `permits`) both return `false`. Same * convention as `has_role`; lets the helper drop into `auth: 'public'` - * handlers without a manual narrow. See `cell_authorize` for the - * resource-side analog. + * or account-grain handlers without a manual narrow. See `cell_authorize` + * for the resource-side analog. * * `scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so * JS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly: @@ -165,64 +281,104 @@ export const has_any_scoped_role = ( }; /** - * Create middleware that builds the request context from a session cookie. + * Result of `resolve_acting_actor` — either an actor id or a structured + * error the caller maps to an HTTP response. + */ +export type ResolveActingActorResult = + | {ok: true; actor_id: string} + | {ok: false; reason: 'no_actors'} + | {ok: false; reason: 'actor_required'; available: Array<{id: string; name: string}>} + | {ok: false; reason: 'actor_not_on_account'}; + +/** + * Resolve the acting actor for an authenticated request. + * + * Called from the route-spec / RPC dispatcher's authorization phase + * with the authenticated account id and the validated `acting` value + * (from the request payload). Applies the uniform resolution rules: + * + * - `acting_actor_id` omitted + 1 actor → use it. + * - `acting_actor_id` omitted + 0 actors → `no_actors` (defensive — + * signup / bootstrap always create an actor in the same tx, so this + * is a server error). + * - `acting_actor_id` omitted + multiple actors → `actor_required` with + * the available list so the client can prompt; never pick silently. + * - `acting_actor_id` present + matches an actor on the account → use it. + * - `acting_actor_id` present + does not match → `actor_not_on_account`. + * The available list is intentionally not echoed in this branch (treat + * as opaque rejection). + * + * @param deps - query dependencies + * @param account_id - the authenticated account + * @param acting_actor_id - the requested acting actor id, or `undefined` + */ +export const resolve_acting_actor = async ( + deps: QueryDeps, + account_id: string, + acting_actor_id: string | undefined, +): Promise => { + const actors = await query_actors_by_account(deps, account_id); + if (actors.length === 0) return {ok: false, reason: 'no_actors'}; + if (acting_actor_id == null) { + if (actors.length === 1) return {ok: true, actor_id: actors[0]!.id}; + return { + ok: false, + reason: 'actor_required', + available: actors.map((a) => ({id: a.id, name: a.name})), + }; + } + const match = actors.find((a) => a.id === acting_actor_id); + if (!match) return {ok: false, reason: 'actor_not_on_account'}; + return {ok: true, actor_id: match.id}; +}; + +/** + * Create middleware that authenticates the account from a session cookie. * - * Reads the session identity (set by session middleware), looks up - * the `auth_session`, loads account + actor + active permits, and - * sets the `RequestContext` on the Hono context. + * Reads the session identity (set by session middleware), looks up the + * `auth_session`, and on a valid session sets `c.var.auth_account_id`, + * `CREDENTIAL_TYPE_KEY = 'session'`, and `AUTH_SESSION_TOKEN_HASH_KEY`. + * Touches the session (fire-and-forget). Does not load actor or permits; + * `REQUEST_CONTEXT_KEY` is left null — the route-spec / RPC dispatcher + * authorization phase resolves the acting actor and builds the full + * `RequestContext` when the route needs one. * - * If the session is invalid or the account is not found, the context - * is set to `null` (unauthenticated). No 401 is returned — use - * `require_role` or `require_auth` for enforcement. + * Invalid / missing session leaves all keys null and calls `next()` — + * `require_auth` / `require_role` enforce. * * @param deps - query dependencies (pool-level db for middleware) * @param log - the logger instance * @param session_context_key - the Hono context key where session middleware stored the session token - * @mutates Hono context - sets `REQUEST_CONTEXT_KEY`, `CREDENTIAL_TYPE_KEY`, `AUTH_SESSION_TOKEN_HASH_KEY`, and `AUTH_API_TOKEN_ID_KEY` + * @mutates Hono context - sets `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, `AUTH_SESSION_TOKEN_HASH_KEY`, and `AUTH_API_TOKEN_ID_KEY` */ export const create_request_context_middleware = ( deps: QueryDeps, log: Logger, session_context_key = 'auth_session_id', ): MiddlewareHandler => { - return async (c, next) => { - const session_token: string | null = c.get(session_context_key) ?? null; + return async (c, next): Promise => { + c.set(REQUEST_CONTEXT_KEY, null); + c.set(ACCOUNT_ID_KEY, null); + c.set(CREDENTIAL_TYPE_KEY, null); + c.set(AUTH_SESSION_TOKEN_HASH_KEY, null); + c.set(AUTH_API_TOKEN_ID_KEY, null); + const session_token: string | null = c.get(session_context_key) ?? null; if (!session_token) { - c.set(REQUEST_CONTEXT_KEY, null); - c.set(CREDENTIAL_TYPE_KEY, null); - c.set(AUTH_SESSION_TOKEN_HASH_KEY, null); - c.set(AUTH_API_TOKEN_ID_KEY, null); await next(); return; } const token_hash = hash_session_token(session_token); const session = await query_session_get_valid(deps, token_hash); - if (!session) { - c.set(REQUEST_CONTEXT_KEY, null); - c.set(CREDENTIAL_TYPE_KEY, null); - c.set(AUTH_SESSION_TOKEN_HASH_KEY, null); - c.set(AUTH_API_TOKEN_ID_KEY, null); - await next(); - return; - } - - const ctx = await build_request_context(deps, session.account_id); - if (!ctx) { - c.set(REQUEST_CONTEXT_KEY, null); - c.set(CREDENTIAL_TYPE_KEY, null); - c.set(AUTH_SESSION_TOKEN_HASH_KEY, null); - c.set(AUTH_API_TOKEN_ID_KEY, null); await next(); return; } - c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(ACCOUNT_ID_KEY, session.account_id); c.set(CREDENTIAL_TYPE_KEY, 'session'); c.set(AUTH_SESSION_TOKEN_HASH_KEY, token_hash); - c.set(AUTH_API_TOKEN_ID_KEY, null); // Touch session (fire-and-forget, don't block the request) void session_touch_fire_and_forget(deps, token_hash, c.var.pending_effects, log); @@ -234,11 +390,10 @@ export const create_request_context_middleware = ( /** * Middleware that requires authentication. * - * Returns 401 if no request context is set. + * Returns 401 if the auth middleware did not set `c.var.auth_account_id`. */ export const require_auth: MiddlewareHandler = async (c, next): Promise => { - const ctx = get_request_context(c); - if (!ctx) { + if (c.get(ACCOUNT_ID_KEY) == null) { return c.json({error: ERROR_AUTHENTICATION_REQUIRED}, 401); } await next(); @@ -247,17 +402,20 @@ export const require_auth: MiddlewareHandler = async (c, next): Promise { return async (c, next): Promise => { - const ctx = get_request_context(c); - if (!ctx) { + if (c.get(ACCOUNT_ID_KEY) == null) { return c.json({error: ERROR_AUTHENTICATION_REQUIRED}, 401); } - if (!has_role(ctx, role)) { + const ctx = get_request_context(c); + if (!ctx || !has_role(ctx, role)) { return c.json({error: ERROR_INSUFFICIENT_PERMISSIONS, required_role: role}, 403); } await next(); @@ -272,42 +430,310 @@ export const require_role = (role: string): MiddlewareHandler => { * or after receiving a revocation signal. * * Returns a new `RequestContext` with updated permits — the original - * context is not mutated, making concurrent calls safe. + * context is not mutated, making concurrent calls safe. Throws when + * `ctx.actor` is null; account-grain contexts have no permits to refresh. * * @param ctx - the request context to refresh * @param deps - query dependencies * @returns a new `RequestContext` with fresh permits + * @throws Error when called on an account-grain context (`actor: null`) */ export const refresh_permits = async ( ctx: RequestContext, deps: QueryDeps, ): Promise => { + if (!ctx.actor) { + throw new Error('refresh_permits: account-grain context has no actor / permits to refresh'); + } const permits = await query_permit_find_active_for_actor(deps, ctx.actor.id); return {...ctx, permits}; }; /** - * Build a full `RequestContext` from an account id. + * Build a full `RequestContext` from an account id and an explicit + * actor id (already resolved via `resolve_acting_actor`). * - * Shared helper used by session, bearer, and daemon token middleware, - * as well as WebSocket upgrade handlers. Does the account → actor → permits - * lookup pipeline and returns the composed context, or `null` if - * the account or actor is not found. + * Loads `account` + the named `actor` + the actor's active permits. + * Verifies the `actor.account_id === account.id` binding so downstream + * handlers can trust `ctx.actor.account_id === ctx.account.id`. Returns + * `null` when the account is missing, the actor is missing, or the + * actor doesn't belong to the supplied account. + * + * Called by the route-spec / RPC dispatcher's authorization phase for + * routes that need an acting actor; account-grain routes use + * `build_account_context` instead. * * @param deps - query dependencies * @param account_id - the account to build context for - * @returns a request context, or `null` if account/actor not found + * @param actor_id - the actor this request acts as + * @returns a request context, or `null` if account/actor not found or mismatched */ export const build_request_context = async ( deps: QueryDeps, account_id: string, -): Promise => { + actor_id: string, +): Promise => { const account = await query_account_by_id(deps, account_id); if (!account) return null; - const actor = await query_actor_by_account(deps, account.id); + const actor = await query_actor_by_id(deps, actor_id); if (!actor) return null; + if (actor.account_id !== account.id) return null; const permits = await query_permit_find_active_for_actor(deps, actor.id); return {account, actor, permits}; }; + +/** + * Build an account-only `RequestContext` (no actor, no permits) from + * an account id. + * + * Used by the dispatcher's authorization phase for authenticated routes + * that don't need an acting actor — account-grain operations (logout, + * password change, account self-service). Lets handlers read + * `auth.account.id` / `auth.account.username` uniformly with permit-bound + * routes; the cost is one extra `query_account_by_id` per request. + * + * Returns `null` when the account row is missing (e.g. deleted between + * the auth middleware's session lookup and the dispatcher) — caller + * surfaces that as a 500 since it represents a torn read. + * + * @param deps - query dependencies + * @param account_id - the account to build context for + * @returns an account-only request context, or `null` if the account is missing + */ +export const build_account_context = async ( + deps: QueryDeps, + account_id: string, +): Promise => { + const account = await query_account_by_id(deps, account_id); + if (!account) return null; + return {account, actor: null, permits: []}; +}; + +/** + * Whether the supplied auth descriptor implies an acting actor must be + * resolved (i.e., permit-requiring auth: `'role'` or `'keeper'`). + * + * The dispatcher's authorization phase uses this to decide whether to + * walk the actor list when the input schema doesn't already declare + * `acting?: ActingActor`. Accepts either auth shape — the route-spec + * `RouteAuth` (`{type: 'role' | 'keeper' | ...}`) or the action-spec + * `ActionAuth` (`'keeper' | {role}`) — so HTTP and RPC dispatchers share + * one source of truth for the "permit-bound" rule. + */ +export const is_actor_implying_auth = (auth: RouteAuth | ActionAuth): boolean => { + if (typeof auth === 'string') return auth === 'keeper'; + if ('type' in auth) return auth.type === 'role' || auth.type === 'keeper'; + return 'role' in auth; +}; + +/** + * Whether an input schema declares the canonical `acting?: ActingActor` + * field. Reference-equality on the exported `ActingActor` schema — + * consumer schemas with unrelated `acting` fields don't trip this check. + * + * Peels through Zod wrappers (`optional`, `nullable`, `default`, + * `transform`, `pipe`, `prefault`) via `zod_unwrap_to_object` so a spec + * authored as `z.optional(z.strictObject({acting: ActingActor}))` or + * `z.strictObject({acting: ActingActor}).default({})` still trips the + * predicate. The wrapper-tolerant lookup is defense-in-depth — the + * canonical shape is the un-wrapped `z.strictObject({acting: ActingActor})`, + * but variant B in `~/dev/grimoire/lore/fuz_app/TODO_PUBLIC_AUTH_PHASE.md` + * makes this predicate authorization-correctness load-bearing for + * `auth: 'public'` actions, so missing a wrapper-bound declaration + * would silently skip actor resolution. The reference-equality check + * on `ActingActor` keeps consumer schemas with unrelated `acting` + * fields from tripping the predicate even after the wrapper peel. + * + * The dispatcher's authorization phase uses this to decide whether to + * pull the actor id from validated input (so multi-actor users can pick + * a persona on actor-needing routes). + */ +export const input_schema_declares_acting = (schema: z.ZodType): boolean => { + const obj = zod_unwrap_to_object(schema); + if (!obj) return false; + return (obj.shape as Record).acting === ActingActor; +}; + +/** + * Resolution-failure shape returned by `apply_authorization_phase`. Each + * transport binds this to the appropriate wire shape — REST emits the body + * directly via `c.json(body, status)`; the RPC dispatcher folds it into a + * JSON-RPC error envelope `{jsonrpc, id, error: {code, message, data}}`. + * + * The auth phase deliberately stops short of constructing a `Response` so + * the same failure flows through every transport without the auth-domain + * code knowing about JSON-RPC. See `fuz_app/CLAUDE.md` § Cleanest + * architecture takes priority for the rationale. + */ +export type AuthorizationFailureBody = + | {error: typeof ERROR_ACTOR_REQUIRED; available: Array<{id: string; name: string}>} + | {error: typeof ERROR_ACTOR_NOT_ON_ACCOUNT} + | {error: typeof ERROR_NO_ACTORS_ON_ACCOUNT} + | {error: typeof ERROR_ACCOUNT_VANISHED}; + +/** + * A `(status, body)` pair the caller binds to a transport-shaped response. + * `status` is narrowed to the two values the auth phase emits — Hono's + * `c.json` status overload accepts the literals directly, and downstream + * binders avoid casts they would otherwise need against a `number`. + */ +export interface AuthorizationFailure { + status: 400 | 500; + body: AuthorizationFailureBody; +} + +/** + * Apply the dispatcher's authorization phase. Shared by the route-spec + * wrapper and the RPC dispatcher. + * + * - When `c.var.auth_account_id` is `null`, returns `void` so the + * downstream auth guard can fire 401 (less-helpful than `actor_required` + * for the unauthenticated case). + * - When `needs_actor` is true, resolves the actor against the account + * plus the supplied `acting` value, then builds the full + * `{account, actor, permits}` context. + * - When `needs_actor` is false, builds an account-only context so + * handler signatures stay uniform across the surface. + * + * On resolution failure returns an `AuthorizationFailure` (`{status, body}`) + * the caller wraps in a transport-appropriate response. Three 500 branches + * are kept distinct so the wire shape names what actually went wrong: + * + * - 500 `ERROR_NO_ACTORS_ON_ACCOUNT` — `resolve_acting_actor` returned + * `no_actors`. The actor enumeration succeeded and came back empty; + * signup / bootstrap should have created one in the same transaction, + * so this is a real corruption signal. + * - 500 `ERROR_ACCOUNT_VANISHED` — `build_request_context` / + * `build_account_context` returned null after a successful + * `resolve_acting_actor`. The account or actor row was deleted between + * the credential check and authorization (torn read race), or — in + * the `build_request_context` actor↔account mismatch sub-branch — the + * binding flipped under us. Reachability of the mismatch sub-branch in + * production is essentially zero (`resolve_acting_actor` already + * verified the actor was on this account, and `actor.account_id` only + * changes via row-level edits no production path makes), so collapsing + * that case into the torn-read shape costs nothing. + * + * Other failure paths: 400 `ERROR_ACTOR_REQUIRED` / `ERROR_ACTOR_NOT_ON_ACCOUNT`. + * Returns `undefined` on success. + * + * @mutates Hono context - sets `REQUEST_CONTEXT_KEY` on success + */ +export const apply_authorization_phase = async ( + deps: QueryDeps, + c: Context, + needs_actor: boolean, + acting_value: string | undefined, +): Promise => { + // Test escape hatch: when a harness pre-populates `REQUEST_CONTEXT_KEY` + // it must also flag `TEST_CONTEXT_PRESET_KEY = true` (set by + // `create_test_app_from_specs` / `create_fake_hono_context` / per-test + // middleware). Production middleware never sets this flag, so future + // production code that consults `REQUEST_CONTEXT_KEY` cannot silently + // bypass the live build the way an implicit presence probe would. + if (c.get(TEST_CONTEXT_PRESET_KEY)) return; + const account_id: string | null = c.get(ACCOUNT_ID_KEY) ?? null; + if (account_id == null) return; // auth guard handles 401 + + if (needs_actor) { + const acting = await resolve_acting_actor(deps, account_id, acting_value); + if (!acting.ok) { + if (acting.reason === 'actor_required') { + return { + status: 400, + body: {error: ERROR_ACTOR_REQUIRED, available: acting.available}, + }; + } + if (acting.reason === 'actor_not_on_account') { + return {status: 400, body: {error: ERROR_ACTOR_NOT_ON_ACCOUNT}}; + } + return {status: 500, body: {error: ERROR_NO_ACTORS_ON_ACCOUNT}}; + } + const ctx = await build_request_context(deps, account_id, acting.actor_id); + if (!ctx) return {status: 500, body: {error: ERROR_ACCOUNT_VANISHED}}; + c.set(REQUEST_CONTEXT_KEY, ctx); + return; + } + + const ctx = await build_account_context(deps, account_id); + if (!ctx) return {status: 500, body: {error: ERROR_ACCOUNT_VANISHED}}; + c.set(REQUEST_CONTEXT_KEY, ctx); +}; + +/** + * Create the route-spec authorization handler used by `apply_route_specs`. + * + * Decides whether the route needs actor resolution from `spec.auth` plus + * `spec.input` introspection, extracts the raw `acting` value (string + * typeguard, no schema validation), and delegates to + * `apply_authorization_phase`. Public routes (`auth.type === 'none'`) skip + * the phase entirely; their handlers see no `RequestContext`. + * + * Authorization runs before input validation (matches the RPC dispatcher's + * order). For GET routes `acting` comes from the URL query string; for + * mutating methods it comes from a pre-parse of the JSON body. The pre- + * parse result lands on `c.var.cached_request_body` so the subsequent + * `create_input_validation` step reads the parsed value from there + * without re-running `JSON.parse` — explicit cache, independent of + * Hono's internal `bodyCache` behavior. A malformed body fails the + * pre-parse silently (`acting` treated as undefined, cache flagged + * `{ok: false}`) and is then rejected with `ERROR_INVALID_JSON_BODY` + * by the input-validation step that reads the failure flag — producing + * the same final response as if the validation step had parsed first. + */ +export const create_fuz_authorization_handler = ( + deps: QueryDeps, +): ((c: Context, spec: RouteSpec) => Promise) => { + return async (c, spec) => { + if (spec.auth.type === 'none') return; + const declares_acting = input_schema_declares_acting(spec.input); + const needs_actor = is_actor_implying_auth(spec.auth) || declares_acting; + let acting_value: string | undefined; + if (declares_acting) { + const raw_acting = await read_raw_acting(c, spec.method); + acting_value = typeof raw_acting === 'string' ? raw_acting : undefined; + } + const failure = await apply_authorization_phase(deps, c, needs_actor, acting_value); + if (!failure) return; + return c.json(failure.body, failure.status); + }; +}; + +/** + * Extract the raw `acting` value from a request before input validation + * has run. Returns `undefined` on parse failure or non-object body; the + * downstream input-validation step then rejects malformed bodies with + * `ERROR_INVALID_JSON_BODY`. + * + * Writes the parse result to `c.var.cached_request_body` so the + * input-validation step does not re-run `JSON.parse` on the same Hono- + * cached body text. Hono's internal `bodyCache` keeps the body text + * alive across multiple `c.req.json()` calls, but each call still + * re-parses — caching the parsed value here decouples our pipeline + * from that undocumented detail (and saves the second parse). + * + * Three cache states: + * + * - GET (early return) — no cache write; the input-validation step is + * a no-op for GET so nothing reads the cache anyway. + * - Successful parse (any JSON value) — `{ok: true, body}`. The + * input-validation step reads `body` and runs the non-object check + * itself. + * - Parse failure — `{ok: false}`. The input-validation step short- + * circuits with `ERROR_INVALID_JSON_BODY` without re-parsing. + */ +const read_raw_acting = async (c: Context, method: string): Promise => { + if (method === 'GET') return c.req.query('acting'); + try { + const body = await c.req.json(); + c.set(CACHED_REQUEST_BODY_KEY, {ok: true, body}); + if (typeof body === 'object' && body !== null && !Array.isArray(body)) { + return (body as {acting?: unknown}).acting; + } + } catch { + c.set(CACHED_REQUEST_BODY_KEY, {ok: false}); + } + return undefined; +}; diff --git a/src/lib/auth/route_guards.ts b/src/lib/auth/route_guards.ts index b52fb2e1..9ba9b773 100644 --- a/src/lib/auth/route_guards.ts +++ b/src/lib/auth/route_guards.ts @@ -1,7 +1,13 @@ /** * Auth guard resolver for the route spec system. * - * Maps `RouteAuth` discriminants to auth middleware handlers. + * Maps `RouteAuth` discriminants to two-phase auth middleware sets. + * `pre_validation` carries the 401 check (`require_auth`) so + * unauthenticated callers never see route-shape information from input + * parse failures. `post_authorization` carries the 403 role / keeper + * checks because they read the `RequestContext` populated by the + * dispatcher's authorization phase. + * * Injected into `apply_route_specs` to decouple the generic HTTP * framework (`http/route_spec.ts`) from auth-specific middleware. * @@ -17,19 +23,19 @@ import type {AuthGuardResolver} from '../http/route_spec.js'; * * Maps `RouteAuth` to middleware: * - `none` → no guards - * - `authenticated` → `require_auth` - * - `role` → `require_role(role)` - * - `keeper` → `require_keeper` + * - `authenticated` → pre-validation `require_auth` + * - `role` → pre-validation `require_auth` + post-authorization `require_role(role)` + * - `keeper` → pre-validation `require_auth` + post-authorization `require_keeper` */ export const fuz_auth_guard_resolver: AuthGuardResolver = (auth) => { switch (auth.type) { case 'none': - return []; + return {pre_validation: [], post_authorization: []}; case 'authenticated': - return [require_auth]; + return {pre_validation: [require_auth], post_authorization: []}; case 'role': - return [require_role(auth.role)]; + return {pre_validation: [require_auth], post_authorization: [require_role(auth.role)]}; case 'keeper': - return [require_keeper]; + return {pre_validation: [require_auth], post_authorization: [require_keeper]}; } }; diff --git a/src/lib/auth/self_service_role_action_specs.ts b/src/lib/auth/self_service_role_action_specs.ts index 47a22b44..48967f2e 100644 --- a/src/lib/auth/self_service_role_action_specs.ts +++ b/src/lib/auth/self_service_role_action_specs.ts @@ -12,6 +12,7 @@ import {z} from 'zod'; import type {RequestResponseActionSpec} from '../actions/action_spec.js'; import {RoleName} from './role_schema.js'; +import {ActingActor} from './account_schema.js'; /** Error reason — caller asked to self-toggle a role outside the configured allowlist. */ export const ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE = 'role_not_self_service_eligible' as const; @@ -23,6 +24,7 @@ export const SelfServiceRoleSetInput = z.strictObject({ description: 'Desired post-call state. `true` grants if not held; `false` revokes if held. Idempotent in both directions.', }), + acting: ActingActor, }); export type SelfServiceRoleSetInput = z.infer; diff --git a/src/lib/auth/self_service_role_actions.ts b/src/lib/auth/self_service_role_actions.ts index 2f619875..7db2f6c4 100644 --- a/src/lib/auth/self_service_role_actions.ts +++ b/src/lib/auth/self_service_role_actions.ts @@ -32,14 +32,14 @@ * @module */ -import {rpc_action, type ActionContext, type RpcAction} from '../actions/action_rpc.js'; +import {rpc_actor_action, type ActionActorContext, type RpcAction} from '../actions/action_rpc.js'; import {jsonrpc_errors} from '../http/jsonrpc_errors.js'; import type {RoleSchemaResult} from './role_schema.js'; -import type {RouteFactoryDeps} from './deps.js'; +import type {AuditEmitDeps} from './deps.js'; import {query_grant_permit, query_revoke_permit} from './permit_queries.js'; import {audit_log_fire_and_forget} from './audit_log_queries.js'; import {is_permit_active} from './account_schema.js'; -import {has_scoped_role, type RequestContext} from './request_context.js'; +import {has_scoped_role} from './request_context.js'; import { ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE, self_service_role_set_action_spec, @@ -64,20 +64,13 @@ export interface SelfServiceRoleActionsOptions { } /** - * Dependencies for `create_self_service_role_actions`. Same shape as the - * peer factories so consumers thread one deps object through all three. - * `audit_log_config` flows from `AppDeps` and is consumed by + * Dependencies for `create_self_service_role_actions`. + * + * Aliases the shared `AuditEmitDeps` so consumers thread one deps object + * through every action factory. `audit_log_config` is consumed by * `audit_log_fire_and_forget`. */ -export type SelfServiceRoleActionDeps = Pick< - RouteFactoryDeps, - 'log' | 'on_audit_event' | 'audit_log_config' ->; - -const require_request_auth = (auth: RequestContext | null): RequestContext => { - if (!auth) throw new Error('unreachable: action auth guard did not enforce authentication'); - return auth; -}; +export type SelfServiceRoleActionDeps = AuditEmitDeps; /** * Build the unified self-service role toggle RPC action. @@ -114,9 +107,9 @@ export const create_self_service_role_actions = ( const handler = async ( input: SelfServiceRoleSetInput, - ctx: ActionContext, + ctx: ActionActorContext, ): Promise => { - const auth = require_request_auth(ctx.auth); + const auth = ctx.auth; reject_if_ineligible(input.role); if (input.enabled) { @@ -141,12 +134,21 @@ export const create_self_service_role_actions = ( granted_by: auth.actor.id, }); + // `permit_grant` is the canonical actor-bound-subject event — + // populate both target columns even on self-service so the + // "always populated for permit_grant" rule holds uniformly + // regardless of who initiated the grant. On self-service the + // grantor and grantee are the same identity; admin direct-grant + // (separate code path) populates the same columns with the + // grantee actor. void audit_log_fire_and_forget( ctx, { event_type: 'permit_grant', actor_id: auth.actor.id, account_id: auth.account.id, + target_account_id: auth.account.id, + target_actor_id: auth.actor.id, ip: ctx.client_ip, metadata: { role: permit.role, @@ -180,12 +182,18 @@ export const create_self_service_role_actions = ( return {ok: true, enabled: false, changed: false}; } + // Same actor-bound rule as the grant branch — `permit_revoke` + // always populates both target columns even on self-service so + // forensic queries that filter on `target_actor_id IS NOT NULL` + // don't silently miss self-toggled permits. void audit_log_fire_and_forget( ctx, { event_type: 'permit_revoke', actor_id: auth.actor.id, account_id: auth.account.id, + target_account_id: auth.account.id, + target_actor_id: auth.actor.id, ip: ctx.client_ip, metadata: { role: result.role, @@ -200,5 +208,5 @@ export const create_self_service_role_actions = ( return {ok: true, enabled: false, changed: true}; }; - return [rpc_action(self_service_role_set_action_spec, handler)]; + return [rpc_actor_action(self_service_role_set_action_spec, handler)]; }; diff --git a/src/lib/db/migrate.ts b/src/lib/db/migrate.ts index 867e91ff..52fade11 100644 --- a/src/lib/db/migrate.ts +++ b/src/lib/db/migrate.ts @@ -6,12 +6,15 @@ * `(namespace, name, sequence, applied_at)` — and the runner verifies the * applied list is a name-prefix of the code's migration array at boot. * - * **Append-only after first publish**: once a fuz_app version containing a - * given migration is published (`npm publish` / `jsr publish`), that - * migration's name and position are frozen. Never edit, rename, or reorder - * after publish — append only. Pre-publish, anything goes; the cliff is the - * publish event. Edits to a published migration's body slip past the runner - * (no content hashing) and are caught by schema-snapshot tests in consumers. + * **Schema is not stabilized yet — append-only is NOT the rule.** While + * fuz_app is pre-stable, migration bodies, names, and positions can change + * freely between versions; consumers upgrading across a schema change are + * expected to drop and re-bootstrap their dev/test databases (production + * deployments are not yet a supported use case). Once the schema is + * declared stable a hard append-only-after-publish rule will apply and the + * cliff will be called out in that release's notes; until then, body edits + * to a published migration slip past the runner (no content hashing) by + * design — they're the recommended way to evolve the schema. * * **Chain-level transactions**: All pending migrations in a namespace run in * a single transaction. Any failure rolls back every migration in that run — @@ -56,7 +59,8 @@ export interface Migration { /** * A named group of ordered migrations. * - * Array index = position in the chain. Append-only after publish. + * Array index = position in the chain. Pre-stable: bodies, names, and + * positions can change between versions (consumers re-bootstrap on upgrade). */ export interface MigrationNamespace { namespace: string; diff --git a/src/lib/dev/setup.ts b/src/lib/dev/setup.ts index d7f36dd6..08e393e6 100644 --- a/src/lib/dev/setup.ts +++ b/src/lib/dev/setup.ts @@ -20,7 +20,7 @@ import type { import type {QueryDeps} from '../db/query_deps.js'; import { query_account_by_username, - query_actor_by_account, + query_actors_by_account, query_create_account_with_actor, } from '../auth/account_queries.js'; import {query_grant_permit} from '../auth/permit_queries.js'; @@ -463,11 +463,13 @@ export const seed_dev_account = async ( const existing = await query_account_by_username(query_deps, input.username); if (existing) { - const actor = await query_actor_by_account(query_deps, existing.id); - if (!actor) { + const actors = await query_actors_by_account(query_deps, existing.id); + if (actors.length === 0) { log.error(`dev account '${input.username}' exists but has no actor`); throw new Error(`dev account '${input.username}' has no actor`); } + // Dev seed is single-actor by construction; pick the first. + const actor = actors[0]!; for (const role of input.roles ?? []) { await query_grant_permit(query_deps, { actor_id: actor.id, diff --git a/src/lib/hono_context.ts b/src/lib/hono_context.ts index 737efe14..2d06eef3 100644 --- a/src/lib/hono_context.ts +++ b/src/lib/hono_context.ts @@ -28,6 +28,62 @@ export const CREDENTIAL_TYPE_KEY = 'credential_type'; /** Hono context variable name for the authenticated API token id. */ export const AUTH_API_TOKEN_ID_KEY = 'auth_api_token_id'; +/** + * Hono context variable name for the authenticated account id. + * + * Set by the auth middleware (session, bearer, or daemon token) on a valid + * credential. `null` for unauthenticated requests. The route-spec wrapper / + * RPC dispatcher's authorization phase reads this when resolving the acting + * actor; account-grain auth guards (`require_auth`) and account-grain handlers + * read it directly. + */ +export const ACCOUNT_ID_KEY = 'auth_account_id'; + +/** + * Hono context variable name for the test-harness pre-baked context flag. + * + * Test harnesses (`create_test_app_from_specs`, `create_fake_hono_context`, + * the WS round-trip `connect()` helper, plus per-test middleware that + * pre-populates `REQUEST_CONTEXT_KEY`) set this to `true` so + * `apply_authorization_phase` skips its DB-backed actor resolution and + * trusts the supplied `RequestContext`. Production middleware never sets + * this key — only test code does. The flag is the explicit escape hatch + * that replaced the implicit "is `REQUEST_CONTEXT_KEY` already set?" probe, + * so that future production code consulting `REQUEST_CONTEXT_KEY` cannot + * silently bypass the live build. + */ +export const TEST_CONTEXT_PRESET_KEY = 'test_context_preset'; + +/** + * Cached parsed JSON request body, keyed by `'cached_request_body'`. + * + * Written by `read_raw_acting` (in the dispatcher's authorization + * phase) when it pre-parses the body to extract the `acting` field; + * read by `create_input_validation` so the input-validation step does + * not pay for a second `JSON.parse` on the same Hono-cached body text. + * + * Decouples our pipeline from Hono's internal `bodyCache` shape: Hono + * caches the body *text* (so a second `c.req.json()` call doesn't + * re-read the request stream), but each call still re-runs + * `JSON.parse(text)`. Storing the parsed value here saves the second + * parse and keeps fuz_app from depending on undocumented Hono + * implementation details. + * + * Three states: + * + * - Key absent — body has not been pre-parsed yet (the route had no + * `acting` to extract, or the request is GET). + * - `{ok: true, body: unknown}` — pre-parse succeeded; the parsed + * value (object, primitive, or array) is in `body`. + * - `{ok: false}` — pre-parse threw (malformed JSON). The downstream + * input-validation step short-circuits with `ERROR_INVALID_JSON_BODY` + * instead of re-parsing. + */ +export const CACHED_REQUEST_BODY_KEY = 'cached_request_body'; + +/** The shape stored under `CACHED_REQUEST_BODY_KEY`. */ +export type CachedRequestBody = {ok: true; body: unknown} | {ok: false}; + declare module 'hono' { interface ContextVariableMap { /** Resolved client IP, set by the trusted proxy middleware. */ @@ -39,6 +95,13 @@ declare module 'hono' { validated_query: unknown; /** How the request was authenticated (`'session'`, `'api_token'`, or `'daemon_token'`). */ credential_type: CredentialType | null; + /** + * Authenticated account id. Set by the session / bearer / daemon-token + * middleware on a valid credential; `null` for unauthenticated requests. + * The dispatcher's authorization phase resolves the acting actor against + * this id; `require_auth` 401s when it is `null`. + */ + auth_account_id: string | null; /** * blake3 hash of the authenticated session token, or `null` for non-session * credentials. Set by `create_request_context_middleware`. Used to scope @@ -60,5 +123,18 @@ declare module 'hono' { * all effects are awaited before the response returns. */ pending_effects: Array>; + /** + * Set to `true` by test harnesses that pre-populate `request_context` + * to bypass the dispatcher's DB-backed actor resolution. Read by + * `apply_authorization_phase`. Production middleware never sets this. + */ + test_context_preset: boolean; + /** + * Pre-parsed JSON request body cache. Written by `read_raw_acting` + * (the dispatcher's `acting` extractor) and read by + * `create_input_validation` so the same body is not parsed twice. + * See `CACHED_REQUEST_BODY_KEY` for state semantics. + */ + cached_request_body: CachedRequestBody; } } diff --git a/src/lib/http/CLAUDE.md b/src/lib/http/CLAUDE.md index 951c7e74..be8c8946 100644 --- a/src/lib/http/CLAUDE.md +++ b/src/lib/http/CLAUDE.md @@ -91,21 +91,66 @@ wrapper). See `../auth/signup_routes.ts`. `apply_route_specs` assembles the following middleware chain per spec: -1. **Auth guards** — `resolve_auth_guards(spec.auth)`, injected via - `AuthGuardResolver` (use `fuz_auth_guard_resolver` from `../auth/route_guards.ts`) -2. **Params validation** — `spec.params` → `validated_params` context var; - mismatch returns 400 `ERROR_INVALID_ROUTE_PARAMS` with Zod `issues` -3. **Query validation** — `spec.query` → `validated_query`; mismatch returns - 400 `ERROR_INVALID_QUERY_PARAMS` -4. **Input validation** — JSON body parsed + validated; mismatch returns 400 - `ERROR_INVALID_JSON_BODY` (not JSON) or `ERROR_INVALID_REQUEST_BODY` - (schema failure with `issues`). Skipped on GET and `z.null()` inputs -5. **Handler** — wrapped in transaction when `use_transaction` (see above), - receives `RouteContext` -6. **DEV-only output + error validation** — wraps the handler (see below) -7. **Error catch** — catches `ThrownJsonrpcError` → maps to HTTP status + - JSON-RPC error body; catches generic `Error` → 500 `internal_error` - (message only included in DEV) +1. **Params validation** — `spec.params` → `validated_params` context + var; mismatch returns 400 `ERROR_INVALID_ROUTE_PARAMS` with Zod + `issues` +2. **Query validation** — `spec.query` → `validated_query`; mismatch + returns 400 `ERROR_INVALID_QUERY_PARAMS` +3. **Pre-validation auth guards** — `require_auth` (401 + `ERROR_AUTHENTICATION_REQUIRED`) for any non-public route. Fires + before any body parsing so unauthenticated callers never see + route-shape information from input parse failures. The + `AuthGuardResolver` (e.g. `fuz_auth_guard_resolver` from + `../auth/route_guards.ts`) returns this set as + `pre_validation: Array`. +4. **Authorization phase** — when the route's input schema declares + `acting?: ActingActor` or `spec.auth.type` is `'role'` / `'keeper'`, + resolves the acting actor against `c.var.account_id` (set by the + auth middleware) plus the raw `acting` value extracted from query + (GET) or pre-parsed JSON body (mutating methods), builds + `RequestContext` via `build_request_context`, and sets + `REQUEST_CONTEXT_KEY`. Resolution failures return 400 + `ERROR_ACTOR_REQUIRED` (with `available[]`) or + `ERROR_ACTOR_NOT_ON_ACCOUNT` (or 500 `ERROR_NO_ACTORS_ON_ACCOUNT` + when the actor enumeration came back empty, 500 `ERROR_ACCOUNT_VANISHED` + on torn account/actor reads after a successful resolve) before the + handler runs. Account-grain + routes skip this phase; their handlers see no `RequestContext` (or + one with `actor: null`, depending on the helper). The pre-parsed body + lands on `c.var.cached_request_body` (see step 6) so the subsequent + input-validation step reads from there instead of re-parsing. +5. **Post-authorization auth guards** — `require_role(role)` / + `require_keeper` (403 `ERROR_INSUFFICIENT_PERMISSIONS` / + `ERROR_KEEPER_REQUIRES_DAEMON_TOKEN`). Reads `REQUEST_CONTEXT_KEY` + populated by step 4. The resolver returns this set as + `post_authorization: Array`. +6. **Input validation** — JSON body parsed + validated; mismatch returns + 400 `ERROR_INVALID_JSON_BODY` (not JSON) or `ERROR_INVALID_REQUEST_BODY` + (schema failure with `issues`). Skipped on GET and `z.null()` inputs. + On mutating methods, the parse result is shared with the authorization + phase's pre-parse via `c.var.cached_request_body` + (`CACHED_REQUEST_BODY_KEY` from `hono_context.ts`) — the cache is + fuz_app-owned, not Hono's internal `bodyCache`, so a future Hono + internals refactor can't break our second-parse-avoidance contract. +7. **Handler** — wrapped in transaction when `use_transaction` (see + above), receives `RouteContext` +8. **DEV-only output + error validation** — wraps the handler (see below) +9. **Error catch** — catches `ThrownJsonrpcError` → maps to HTTP status + + the flat REST `ApiError` body (`{error: , message?, ...rest_data}`); + catches generic `Error` → 500 `{error: 'internal_error', message?}` + (message only included in DEV). The reason string comes from + `err.data.reason` when set (consumer-supplied canonical reason + override) or from `jsonrpc_error_code_to_name(err.code)` (e.g. + `-32003 → 'not_found'`). The flat shape matches what middleware + and direct handlers emit (`c.json({error: ERROR_FOO}, status)`, + `c.json(failure.body, status)` from the dispatcher's authorization + phase) — REST callers see one envelope across every emit site, while + the JSON-RPC dispatcher keeps its own `{jsonrpc, id, error: {code, +message, data}}` envelope on the RPC mount + +The auth-before-validation order matches the RPC dispatcher +(`actions/action_rpc.ts`) so HTTP RPC and REST surface failures with +the same priority: 401 → 403 → 400 → handler. Duplicate `method path` pairs throw at registration. @@ -162,21 +207,39 @@ Pair every schema with the `z.infer` type export (`export type ApiError = z.infe ### Three-layer error-schema merge -`merge_error_schemas(spec, middleware_errors?)` (in `schema_helpers.ts`) +`merge_error_schemas(spec, middleware_errors?, acting_aware?)` (in `schema_helpers.ts`) merges three layers, later overrides earlier at the same status code: -1. **Derived** — from `derive_error_schemas(auth, has_input, has_params, has_query, rate_limit)`: +1. **Derived** — from `derive_error_schemas({auth, has_input?, has_params?, has_query?, rate_limit?, acting_aware?})`: - `has_input || has_params || has_query` → 400 `ValidationError` - `auth.type === 'authenticated'` → 401 `ApiError` - `auth.type === 'role'` → 401 `ApiError` + 403 `PermissionError` - `auth.type === 'keeper'` → 401 `ApiError` + 403 `KeeperError` - `rate_limit` → 429 `RateLimitError` + - `acting_aware` → widens 400 to a union with `ActorRequiredError` / + `ActorNotOnAccountError` and adds 500 union of `NoActorsOnAccountError` + / `AccountVanishedError`. Mirrors what the dispatcher's authorization + phase actually emits on routes whose input declares `acting?: ActingActor` + or whose auth requires permits — so DEV-mode error-schema validation in + `wrap_output_validation` doesn't reject the auth phase's body. 2. **Middleware** — from `MiddlewareSpec.errors` that apply to the route's path (via `middleware_applies`) 3. **Explicit** — `RouteSpec.errors` — always wins Routes typically only need `errors` for handler-specific codes (404, 409, 422). +`acting_aware` is computed at the call site (`apply_route_specs` / +`generate_app_surface`) via the optional `is_acting_aware?: (spec) => boolean` +callback. Computation lives in the consumer because the canonical +"input declares `acting?: ActingActor`" check uses reference equality with +the canonical `ActingActor` Zod schema in `auth/account_schema.ts`, and +`http/` stays auth-agnostic. fuz_app's `create_app_server` wires +`(spec) => is_actor_implying_auth(spec.auth) || input_schema_declares_acting(spec.input)` +— consumers building on raw `apply_route_specs` opt in by passing the +same predicate (or a narrower one). When the callback is omitted the +flag defaults to false so frameworks not using fuz_app's auth phase don't +get fuz_app-specific shapes on their derived surface. + ### `ERROR_*` constants by category - **Validation**: `ERROR_INVALID_REQUEST_BODY`, `ERROR_INVALID_JSON_BODY`, diff --git a/src/lib/http/error_schemas.ts b/src/lib/http/error_schemas.ts index cab9e3e1..7d02a571 100644 --- a/src/lib/http/error_schemas.ts +++ b/src/lib/http/error_schemas.ts @@ -64,6 +64,43 @@ export const ERROR_INVALID_TOKEN = 'invalid_token' as const; /** Token references a deleted account. */ export const ERROR_ACCOUNT_NOT_FOUND = 'account_not_found' as const; +/** + * Multi-actor account requires the request to carry an explicit `acting` + * field naming the actor the request is acting as, so the dispatcher's + * authorization phase doesn't pick a default actor silently. Returned + * with the available actors so the client can prompt. + */ +export const ERROR_ACTOR_REQUIRED = 'actor_required' as const; + +/** + * Supplied `acting` field does not name an actor on the authenticated + * account. + */ +export const ERROR_ACTOR_NOT_ON_ACCOUNT = 'actor_not_on_account' as const; + +/** + * Authenticated account exists but has no actors. Server invariant + * violation — signup / bootstrap always create an actor in the same + * transaction. Surfaced from the dispatcher's authorization phase as a + * 500 so the operator sees the corruption signal rather than a confusing + * 4xx. Distinct from `ERROR_ACCOUNT_VANISHED`: the actor list was + * enumerated successfully and came back empty. + */ +export const ERROR_NO_ACTORS_ON_ACCOUNT = 'no_actors_on_account' as const; + +/** + * Authentication validated an account, but a follow-up read in the + * authorization phase came back null — the account or its named actor + * row was deleted between the credential check and the dispatcher's + * `build_request_context` / `build_account_context` step. Torn read, + * not a missing-actor invariant violation. Surfaced as 500 so the + * operator sees the race signal; clients can retry. Distinct from + * `ERROR_ACCOUNT_NOT_FOUND` (stale token referencing a long-deleted + * account, raised at credential validation) and + * `ERROR_NO_ACTORS_ON_ACCOUNT` (the actor list enumerated empty). + */ +export const ERROR_ACCOUNT_VANISHED = 'account_vanished' as const; + // --- Keeper / daemon token --- /** Keeper routes require daemon_token credential type. */ @@ -197,6 +234,45 @@ export const ForeignKeyError = z.looseObject({ }); export type ForeignKeyError = z.infer; +/** + * Authorization-phase failure shapes. Surfaced when the dispatcher's + * `apply_authorization_phase` rejects a request before the handler runs — + * the route is acting-aware (input declares `acting?: ActingActor` or + * auth requires permits), but actor resolution failed. + * + * 400: `actor_required` (with `available[]`) for unspecified-actor on + * a multi-actor account; `actor_not_on_account` for a supplied actor + * id that doesn't belong to the authenticated account. + * + * 500: `no_actors_on_account` for a signup-invariant violation (the + * actor list enumerated empty); `account_vanished` for a torn-read + * race (account/actor row deleted between credential validation and + * the dispatcher's follow-up read). + * + * Used by `derive_error_schemas` when `acting_aware` is true so the + * merged error surface matches what the dispatcher actually emits. + */ +export const ActorRequiredError = z.looseObject({ + error: z.literal(ERROR_ACTOR_REQUIRED), + available: z.array(z.looseObject({id: z.string(), name: z.string()})), +}); +export type ActorRequiredError = z.infer; + +export const ActorNotOnAccountError = z.looseObject({ + error: z.literal(ERROR_ACTOR_NOT_ON_ACCOUNT), +}); +export type ActorNotOnAccountError = z.infer; + +export const NoActorsOnAccountError = z.looseObject({ + error: z.literal(ERROR_NO_ACTORS_ON_ACCOUNT), +}); +export type NoActorsOnAccountError = z.infer; + +export const AccountVanishedError = z.looseObject({ + error: z.literal(ERROR_ACCOUNT_VANISHED), +}); +export type AccountVanishedError = z.infer; + /** * Error schema map — maps HTTP status codes to Zod schemas. * @@ -230,17 +306,45 @@ export type RateLimitKey = z.infer; * - **auth: role**: 401 + 403 (with `required_role`) * - **auth: keeper**: 401 + 403 (keeper-specific) * - **rate_limit**: 429 (rate limit exceeded with `retry_after`) + * - **acting_aware**: extends 400 with `ActorRequiredError` / `ActorNotOnAccountError` + * and adds 500 union of `NoActorsOnAccountError` / `AccountVanishedError`. The + * dispatcher's authorization phase emits these on routes whose input declares + * `acting?: ActingActor` or whose auth requires permits (`role` / `keeper`); the + * route's surface must reflect them so DEV-mode error-schema validation in + * `wrap_output_validation` doesn't fail when the auth phase fires before the + * handler. See `http/CLAUDE.md` § Three-layer error-schema merge. + * + * `acting_aware` is computed at the merge call site (it requires inspecting + * the input schema for `acting?: ActingActor`, which lives in `auth/`). This + * keeps `http/` auth-agnostic — the per-route flag flows in via the optional + * `is_acting_aware` callback on `apply_route_specs` / `generate_app_surface`. */ -export const derive_error_schemas = ( - auth: RouteAuth, - has_input: boolean, +export interface DeriveErrorSchemasOptions { + auth: RouteAuth; + has_input?: boolean; + has_params?: boolean; + has_query?: boolean; + rate_limit?: RateLimitKey; + acting_aware?: boolean; +} + +export const derive_error_schemas = ({ + auth, + has_input = false, has_params = false, has_query = false, - rate_limit?: RateLimitKey, -): RouteErrorSchemas => { + rate_limit, + acting_aware = false, +}: DeriveErrorSchemasOptions): RouteErrorSchemas => { const errors: RouteErrorSchemas = {}; - if (has_input || has_params || has_query) { + const has_validation = has_input || has_params || has_query; + if (acting_aware) { + errors[400] = has_validation + ? z.union([ValidationError, ActorRequiredError, ActorNotOnAccountError]) + : z.union([ActorRequiredError, ActorNotOnAccountError]); + errors[500] = z.union([NoActorsOnAccountError, AccountVanishedError]); + } else if (has_validation) { errors[400] = ValidationError; } diff --git a/src/lib/http/jsonrpc_errors.ts b/src/lib/http/jsonrpc_errors.ts index 1764ab54..e0a61209 100644 --- a/src/lib/http/jsonrpc_errors.ts +++ b/src/lib/http/jsonrpc_errors.ts @@ -17,6 +17,8 @@ * @module */ +import type {ContentfulStatusCode} from 'hono/utils/http-status'; + import { JSONRPC_PARSE_ERROR, JSONRPC_INVALID_REQUEST, @@ -311,10 +313,14 @@ export const HTTP_STATUS_TO_JSONRPC_ERROR_CODE: Record * Map a JSON-RPC error code to an HTTP status code. * * Returns 500 for unrecognized codes (consumer-defined codes - * without a mapping default to internal server error). + * without a mapping default to internal server error). The return + * is narrowed to Hono's `ContentfulStatusCode` so call sites can + * pass the result to `c.json(body, status)` without `as any` — + * 499 (nginx "client closed request") is non-standard and gets + * absorbed by the cast here rather than at every dispatcher branch. */ -export const jsonrpc_error_code_to_http_status = (code: JsonrpcErrorCode): number => - JSONRPC_ERROR_CODE_TO_HTTP_STATUS[code as number] ?? 500; +export const jsonrpc_error_code_to_http_status = (code: JsonrpcErrorCode): ContentfulStatusCode => + (JSONRPC_ERROR_CODE_TO_HTTP_STATUS[code as number] ?? 500) as ContentfulStatusCode; /** * Map an HTTP status code to a JSON-RPC error code. @@ -323,3 +329,32 @@ export const jsonrpc_error_code_to_http_status = (code: JsonrpcErrorCode): numbe */ export const http_status_to_jsonrpc_error_code = (status: number): JsonrpcErrorCode => HTTP_STATUS_TO_JSONRPC_ERROR_CODE[status] ?? JSONRPC_ERROR_CODES.internal_error; + +/** + * Reverse map of `JSONRPC_ERROR_CODES` — JSON-RPC error code → name. + * + * Used by REST emitters that need a stable string identifier for the + * code in their flat-shape error body (`{error: '', ...}`) + * without inventing a separate vocabulary. Built once at module load + * from the canonical `JSONRPC_ERROR_CODES` map so the two cannot drift. + * + * Consumer-defined codes outside the standard taxonomy are not present; + * `jsonrpc_error_code_to_name` falls back to `'internal_error'` so the + * REST shape always carries some reason rather than `undefined`. + */ +export const JSONRPC_ERROR_CODE_TO_NAME: Readonly> = Object.freeze( + Object.fromEntries( + (Object.entries(JSONRPC_ERROR_CODES) as Array<[JsonrpcErrorName, JsonrpcErrorCode]>).map( + ([name, code]) => [code as number, name], + ), + ), +); + +/** + * Map a JSON-RPC error code to its canonical name (`'not_found'`, + * `'forbidden'`, etc.). Falls back to `'internal_error'` for codes + * outside the standard taxonomy so REST emitters that read this for + * their `error` field always have a stable string to emit. + */ +export const jsonrpc_error_code_to_name = (code: JsonrpcErrorCode): JsonrpcErrorName => + JSONRPC_ERROR_CODE_TO_NAME[code as number] ?? 'internal_error'; diff --git a/src/lib/http/route_spec.ts b/src/lib/http/route_spec.ts index 3a2ce500..5bc1fcd4 100644 --- a/src/lib/http/route_spec.ts +++ b/src/lib/http/route_spec.ts @@ -27,12 +27,12 @@ import { ERROR_INVALID_ROUTE_PARAMS, ERROR_INVALID_QUERY_PARAMS, } from './error_schemas.js'; -import type {JsonrpcErrorObject} from './jsonrpc.js'; import { ThrownJsonrpcError, - JSONRPC_ERROR_CODES, jsonrpc_error_code_to_http_status, + jsonrpc_error_code_to_name, } from './jsonrpc_errors.js'; +import {CACHED_REQUEST_BODY_KEY, type CachedRequestBody} from '../hono_context.js'; import {is_null_schema, merge_error_schemas} from './schema_helpers.js'; import type {MiddlewareSpec} from './middleware_spec.js'; @@ -48,6 +48,20 @@ export type RouteAuth = | {type: 'role'; role: string} | {type: 'keeper'}; +/** + * Two-phase auth guard set returned by `AuthGuardResolver`. + * + * `pre_validation` runs before input validation — 401 checks live here + * so unauthenticated callers never see route-shape information from + * input parsing failures. `post_authorization` runs after the + * authorization phase has populated `RequestContext` — role / keeper + * checks live here because they read `c.var.request_context.permits`. + */ +export interface AuthGuards { + pre_validation: Array; + post_authorization: Array; +} + /** * Resolves a `RouteAuth` to middleware guard handlers. * @@ -55,7 +69,39 @@ export type RouteAuth = * from auth-specific middleware. See `fuz_auth_guard_resolver` in * `auth/route_guards.ts` for the standard implementation. */ -export type AuthGuardResolver = (auth: RouteAuth) => Array; +export type AuthGuardResolver = (auth: RouteAuth) => AuthGuards; + +/** + * Per-route authorization phase. Runs after the pre-validation auth guards + * and before input validation; resolves the acting actor (when the route's + * input declares `acting?: ActingActor` or auth requires permits) and sets + * the request context on the Hono context. Per-route order in + * `apply_route_specs`: params → query → pre-validation auth (401) → + * authorization → post-authorization auth (403) → input validation → + * handler. + * + * Returns a `Response` to short-circuit (resolution failure → 400 / 500), + * or `void` to continue. The http framework stays auth-agnostic — fuz_app + * provides the implementation via `create_fuz_authorization_handler` in + * `auth/request_context.ts`. + */ +export type AuthorizationHandler = (c: Context, spec: RouteSpec) => Promise; + +/** + * Predicate that decides whether a route is "acting-aware" — i.e. whether + * the dispatcher's authorization phase may emit `actor_required` / + * `actor_not_on_account` (400) or `no_actors_on_account` / + * `account_vanished` (500) on this spec. When the predicate returns true + * the merged error schema is widened to accept those shapes so DEV-mode + * `wrap_output_validation` doesn't reject them. + * + * Computed at the call site because the canonical "input declares + * `acting?: ActingActor`" check lives in `auth/request_context.ts` (it + * uses reference equality with the canonical `ActingActor` schema). The + * `http/` framework receives the predicate via this callback so it stays + * auth-agnostic. See `http/CLAUDE.md` § Three-layer error-schema merge. + */ +export type IsActingAware = (spec: Pick) => boolean; /** HTTP methods supported by route specs. */ export type RouteMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -189,11 +235,27 @@ const create_input_validation = ( if (is_null_schema(input_schema)) return []; const validate: MiddlewareHandler = async (c, next): Promise => { + // Prefer the cached parse result written by `read_raw_acting` + // (the dispatcher's `acting` extractor). The cache decouples + // us from Hono's internal `bodyCache` — Hono keeps the body + // text alive across multiple `c.req.json()` calls but still + // re-runs `JSON.parse` each time, so caching the parsed value + // saves work and pins behavior to fuz_app code rather than to + // undocumented Hono internals. + // Hono's `c.get()` types this as the variable-map entry, but at + // runtime it returns `undefined` when no setter has run for this + // request. Narrow defensively. + const cached = c.get(CACHED_REQUEST_BODY_KEY) as CachedRequestBody | undefined; let body: unknown; - try { - body = await c.req.json(); - } catch { - return c.json({error: ERROR_INVALID_JSON_BODY}, 400); + if (cached !== undefined) { + if (!cached.ok) return c.json({error: ERROR_INVALID_JSON_BODY}, 400); + body = cached.body; + } else { + try { + body = await c.req.json(); + } catch { + return c.json({error: ERROR_INVALID_JSON_BODY}, 400); + } } if (typeof body !== 'object' || body === null || Array.isArray(body)) { return c.json({error: ERROR_INVALID_JSON_BODY}, 400); @@ -324,10 +386,29 @@ export const apply_middleware_specs = (app: Hono, specs: Array): /** * Wrap a handler with error catch logic. * - * Catches `ThrownJsonrpcError` and maps to HTTP status + JSON-RPC error response. - * Catches generic `Error` and maps to `internal_error` (-32603 → 500), including - * the error message in DEV only. Existing handlers that return `c.json()` directly - * are unaffected — the catch layer only activates when something is thrown. + * Catches `ThrownJsonrpcError` and maps it to a flat REST `ApiError` body + * (`{error: , message?, ...rest_data}`) at the matching HTTP status. + * Catches generic `Error` and maps to `{error: 'internal_error', message?}` + * at 500 (`message` populated only in DEV). Existing handlers that return + * `c.json()` directly are unaffected — the catch layer only activates when + * something is thrown. + * + * The flat shape matches what middleware and direct handler emissions + * produce (e.g. `c.json({error: ERROR_FOO}, status)`, + * `c.json(failure.body, status)` from the dispatcher's authorization phase), + * so REST callers see one error envelope across every emit site. The + * `` string comes from `err.data.reason` when set (consumer-supplied + * canonical reason code) or from `jsonrpc_error_code_to_name(err.code)` + * (the JSON-RPC error name — `'not_found'`, `'forbidden'`, etc.). Other + * `data` fields flatten alongside `error` so diagnostic data is visible + * to clients without descending an envelope. + * + * The JSON-RPC code is intentionally **not** carried on the REST body — + * REST callers key on HTTP status + `error` reason, and the JSON-RPC code + * is recoverable via `http_status_to_jsonrpc_error_code(status)` on the + * rare consumer that wants it. Keeping the shape transport-shaped (REST + * emits ApiError; JSON-RPC dispatcher emits the JSON-RPC envelope) avoids + * a hybrid envelope that has to be normalized on the way out. */ const wrap_error_catch = (handler: Handler, log: Logger): Handler => { return async (c, next) => { @@ -336,28 +417,73 @@ const wrap_error_catch = (handler: Handler, log: Logger): Handler => { } catch (err) { if (err instanceof ThrownJsonrpcError) { const status = jsonrpc_error_code_to_http_status(err.code); - const error: JsonrpcErrorObject = {code: err.code, message: err.message}; - if (err.data !== undefined) error.data = err.data; - return c.json({error}, status as any); + return c.json(build_rest_error_body(err), status); } // generic error — internal_error log.error('Unhandled handler error', err); - const message = DEV && err instanceof Error ? err.message : 'internal server error'; - return c.json( - {error: {code: JSONRPC_ERROR_CODES.internal_error, message} satisfies JsonrpcErrorObject}, - 500, - ); + const body: Record = {error: 'internal_error'}; + if (DEV && err instanceof Error) body.message = err.message; + return c.json(body, 500); } }; }; +/** + * Build the REST body for a thrown `ThrownJsonrpcError`. Splits out + * for unit-test directness and keeps the catch handler readable. + * + * Reason resolution order: + * 1. `err.data.reason` (consumer-supplied canonical reason — overrides code-derived name) + * 2. `jsonrpc_error_code_to_name(err.code)` (e.g. -32003 → `'not_found'`) + * + * Remaining `err.data` fields (everything except `reason`) flatten under + * the body. Non-object `data` is dropped — we don't want a primitive + * `data` to overwrite the structured shape. + */ +const build_rest_error_body = (err: ThrownJsonrpcError): Record => { + let reason: string; + const rest: Record = {}; + if ( + err.data !== null && + typeof err.data === 'object' && + !Array.isArray(err.data) && + typeof (err.data as {reason?: unknown}).reason === 'string' + ) { + const {reason: data_reason, ...other} = err.data as Record & {reason: string}; + reason = data_reason; + Object.assign(rest, other); + } else { + reason = jsonrpc_error_code_to_name(err.code); + if (err.data !== null && typeof err.data === 'object' && !Array.isArray(err.data)) { + Object.assign(rest, err.data); + } + } + const body: Record = {error: reason, ...rest}; + if (err.message && err.message !== reason) body.message = err.message; + return body; +}; + /** * Apply route specs to a Hono app. * * For each spec: resolves auth to guards via the provided resolver, * adds input validation middleware (for routes with non-null input schemas), - * wraps handler with DEV-only output and error validation, wraps with error - * catch layer (catches `ThrownJsonrpcError` and generic errors), and registers the route. + * runs the optional authorization phase to resolve the acting actor + build + * the request context, wraps handler with DEV-only output and error + * validation, wraps with error catch layer (catches `ThrownJsonrpcError` + * and generic errors), and registers the route. + * + * Per-route middleware order: params → query → pre-validation auth + * guards (401) → authorization phase → post-authorization auth guards + * (403) → input validation → handler. The 401 check runs before any + * body parsing so unauthenticated callers never see route-shape + * information from parse failures. The authorization phase runs before + * input validation (matches the RPC dispatcher's order) so role / + * keeper denials surface 403 before 400 invalid_params; it extracts + * `acting` from raw query (GET) or pre-parsed JSON body (POST/PUT/...) + * — Hono caches the parsed body internally so the subsequent input- + * validation step does not re-parse. The role / keeper guards consume + * the `RequestContext` populated by the authorization phase. * * Each handler receives a `RouteContext` with: * - `db`: transaction-scoped (for non-GET) or pool-level (for GET) @@ -365,6 +491,7 @@ const wrap_error_catch = (handler: Handler, log: Logger): Handler => { * - `pending_effects`: fire-and-forget effect queue * * @param resolve_auth_guards - maps `RouteAuth` to middleware — use `fuz_auth_guard_resolver` from `auth/route_guards.ts` + * @param authorize - optional authorization phase; runs between guards and input validation * @param db - used for transaction wrapping and `RouteContext` * @mutates `app` * @throws Error if two specs share the same `method` + `path` (each combination must be unique) @@ -375,6 +502,8 @@ export const apply_route_specs = ( resolve_auth_guards: AuthGuardResolver, log: Logger, db: Db, + authorize?: AuthorizationHandler, + is_acting_aware?: IsActingAware, ): void => { const registered = new Set(); for (const spec of specs) { @@ -385,11 +514,21 @@ export const apply_route_specs = ( ); } registered.add(route_key); - const guards = resolve_auth_guards(spec.auth); + const {pre_validation: pre_validation_guards, post_authorization: post_authorization_guards} = + resolve_auth_guards(spec.auth); const params_validation = create_params_validation(spec.params); const query_validation = create_query_validation(spec.query); const input_validation = create_input_validation(spec.input, spec.method); - const merged_errors = merge_error_schemas(spec); + const merged_errors = merge_error_schemas(spec, null, is_acting_aware?.(spec) ?? false); + const authorization: Array = authorize + ? [ + async (c, next): Promise => { + const response = await authorize(c, spec); + if (response) return response; + await next(); + }, + ] + : []; // Step 1: adapt RouteHandler → Handler (construct RouteContext, call spec.handler) const use_transaction = spec.transaction ?? spec.method !== 'GET'; const inner = spec.handler; @@ -406,9 +545,11 @@ export const apply_route_specs = ( app.on( spec.method, [spec.path], - ...guards, ...params_validation, ...query_validation, + ...pre_validation_guards, + ...authorization, + ...post_authorization_guards, ...input_validation, handler, ); diff --git a/src/lib/http/schema_helpers.ts b/src/lib/http/schema_helpers.ts index db26caf2..ff38b0a6 100644 --- a/src/lib/http/schema_helpers.ts +++ b/src/lib/http/schema_helpers.ts @@ -98,7 +98,19 @@ export const middleware_applies = (mw_path: string, route_path: string): boolean * Merge order: derived -> middleware -> explicit route errors. * Later layers override earlier ones for the same status code. * + * `acting_aware` flows through to `derive_error_schemas` so routes whose + * input declares `acting?: ActingActor` (or whose auth requires permits) + * pick up the actor-failure error shapes the dispatcher's authorization + * phase may emit. The flag is computed at the call site rather than here + * because the `acting`-detection helper lives in `auth/` (it depends on + * the canonical `ActingActor` schema for reference equality, and `http/` + * stays auth-agnostic). See `http/CLAUDE.md` § Three-layer error-schema + * merge. + * * @param spec - the route spec (needs `auth`, `input`, `params`, `rate_limit`, `errors`) + * @param middleware_errors - errors contributed by middleware whose path matches the route + * @param acting_aware - whether the dispatcher's authorization phase may emit + * actor-failure errors on this route * @returns merged error schemas, or `null` if empty */ export const merge_error_schemas = ( @@ -111,14 +123,16 @@ export const merge_error_schemas = ( errors?: RouteErrorSchemas; }, middleware_errors?: RouteErrorSchemas | null, + acting_aware = false, ): RouteErrorSchemas | null => { - const derived = derive_error_schemas( - spec.auth, - !is_null_schema(spec.input), - !!spec.params, - !!spec.query, - spec.rate_limit, - ); + const derived = derive_error_schemas({ + auth: spec.auth, + has_input: !is_null_schema(spec.input), + has_params: !!spec.params, + has_query: !!spec.query, + rate_limit: spec.rate_limit, + acting_aware, + }); const merged = {...derived, ...middleware_errors, ...spec.errors}; return Object.keys(merged).length > 0 ? merged : null; }; diff --git a/src/lib/http/surface.ts b/src/lib/http/surface.ts index f8269ca7..39ebb670 100644 --- a/src/lib/http/surface.ts +++ b/src/lib/http/surface.ts @@ -11,7 +11,7 @@ import {z} from 'zod'; import type {EventSpec} from '../realtime/sse.js'; import type {MiddlewareSpec} from './middleware_spec.js'; -import type {RouteAuth, RouteSpec} from './route_spec.js'; +import type {IsActingAware, RouteAuth, RouteSpec} from './route_spec.js'; import type {RateLimitKey, RouteErrorSchemas} from './error_schemas.js'; import type {RpcAction} from '../actions/action_rpc.js'; import {map_action_auth} from '../actions/action_bridge.js'; @@ -142,6 +142,15 @@ export interface GenerateAppSurfaceOptions { env_schema?: z.ZodObject; event_specs?: Array; rpc_endpoints?: Array; + /** + * Per-route predicate that decides whether the dispatcher's authorization + * phase may emit `actor_required` / `actor_not_on_account` (400) or + * `no_actors_on_account` / `account_vanished` (500) on this spec. Mirrors + * the parameter on `apply_route_specs` so the surface exposes the same + * error shapes the live framework would emit. See `http/CLAUDE.md` § + * Three-layer error-schema merge. + */ + is_acting_aware?: IsActingAware; } // --- Surface generation --- @@ -203,7 +212,8 @@ export const events_to_surface = (event_specs: Array): Array { - const {route_specs, middleware_specs, env_schema, event_specs, rpc_endpoints} = options; + const {route_specs, middleware_specs, env_schema, event_specs, rpc_endpoints, is_acting_aware} = + options; const diagnostics: Array = []; // Spec-level diagnostics: check for non-strict input schemas @@ -243,7 +253,7 @@ export const generate_app_surface = (options: GenerateAppSurfaceOptions): AppSur // Merge auto-derived + middleware + explicit error schemas const mw_errors = collect_middleware_errors(middleware_specs, r.path); - const merged_errors = merge_error_schemas(r, mw_errors); + const merged_errors = merge_error_schemas(r, mw_errors, is_acting_aware?.(r) ?? false); let error_schemas: Record | null = null; if (merged_errors) { const schemas: Record = {}; diff --git a/src/lib/server/app_server.ts b/src/lib/server/app_server.ts index a5571e60..fc59a7a9 100644 --- a/src/lib/server/app_server.ts +++ b/src/lib/server/app_server.ts @@ -54,6 +54,7 @@ import { apply_middleware_specs, apply_route_specs, prefix_route_specs, + type IsActingAware, type RouteSpec, } from '../http/route_spec.js'; import type {MiddlewareSpec} from '../http/middleware_spec.js'; @@ -65,6 +66,11 @@ import { import {create_surface_route_spec, type SurfaceRouteOptions} from '../http/common_routes.js'; import {create_auth_middleware_specs} from '../auth/middleware.js'; import {fuz_auth_guard_resolver} from '../auth/route_guards.js'; +import { + create_fuz_authorization_handler, + input_schema_declares_acting, + is_actor_implying_auth, +} from '../auth/request_context.js'; import {ERROR_PAYLOAD_TOO_LARGE} from '../http/error_schemas.js'; import {create_rpc_endpoint} from '../actions/action_rpc.js'; @@ -435,12 +441,22 @@ export const create_app_server = async (options: AppServerOptions): Promise + is_actor_implying_auth(spec.auth) || input_schema_declares_acting(spec.input); + const surface_spec = create_app_surface_spec({ middleware_specs: surface_middleware, route_specs, env_schema: options.env_schema, event_specs: all_event_specs, rpc_endpoints: resolved_rpc_endpoints, + is_acting_aware: fuz_is_acting_aware, }); // Config-level diagnostics (concatenated after spec-level from generate_app_surface) @@ -540,7 +556,16 @@ export const create_app_server = async (options: AppServerOptions): Promise; to_account_id: Uuid; + to_actor_id: Uuid; role: string; }): Promise<{offer_id: Uuid; permit_id: Uuid}> => { const res = await rpc_call_for_spec({ @@ -261,7 +261,12 @@ export const describe_standard_admin_integration_tests = ( const accept_result = await get_db().transaction(async (tx) => query_accept_offer( {db: tx}, - {offer_id: offer.id, to_account_id: args.to_account_id, ip: null}, + { + offer_id: offer.id, + to_account_id: args.to_account_id, + actor_id: args.to_actor_id, + ip: null, + }, ), ); return {offer_id: offer.id, permit_id: accept_result.permit.id}; @@ -278,7 +283,7 @@ export const describe_standard_admin_integration_tests = ( app: test_app.app, path: rpc_path, spec: admin_account_list_action_spec, - params: undefined, + params: {}, headers: test_app.create_session_headers(), }); @@ -302,7 +307,7 @@ export const describe_standard_admin_integration_tests = ( app: test_app.app, path: rpc_path, spec: admin_account_list_action_spec, - params: undefined, + params: {}, headers: test_app.create_session_headers(), }); @@ -331,7 +336,7 @@ export const describe_standard_admin_integration_tests = ( app: test_app.app, path: rpc_path, spec: admin_session_list_action_spec, - params: undefined, + params: {}, headers: test_app.create_session_headers(), }); @@ -512,6 +517,7 @@ export const describe_standard_admin_integration_tests = ( app: test_app.app, admin_headers: test_app.create_session_headers(), to_account_id: user_two.account.id, + to_actor_id: user_two.actor.id, role: grantable_role, }); @@ -537,12 +543,10 @@ export const describe_standard_admin_integration_tests = ( const test_app = await create_test_app(build_admin_test_app_options(options, get_db())); const user_two = await test_app.create_account({username: 'user_two'}); - const target_actor = await query_actor_by_account({db: get_db()}, user_two.account.id); - assert.ok(target_actor); const permit = await query_grant_permit( {db: get_db()}, { - actor_id: target_actor.id, + actor_id: user_two.actor.id, role: grantable_role, granted_by: test_app.backend.actor.id, }, @@ -553,7 +557,7 @@ export const describe_standard_admin_integration_tests = ( app: test_app.app, path: rpc_path, spec: permit_revoke_action_spec, - params: {actor_id: target_actor.id, permit_id: permit.id}, + params: {actor_id: user_two.actor.id, permit_id: permit.id}, headers: test_app.create_session_headers(), }); assert.ok( @@ -764,17 +768,16 @@ export const describe_standard_admin_integration_tests = ( app: test_app.app, admin_headers: test_app.create_session_headers(), to_account_id: user_two.account.id, + to_actor_id: user_two.actor.id, role: grantable_role, }); // 4. revoke permit (RPC) - const target_actor = await query_actor_by_account({db: get_db()}, user_two.account.id); - assert.ok(target_actor); const revoke_res = await rpc_call_for_spec({ app: test_app.app, path: rpc_path, spec: permit_revoke_action_spec, - params: {actor_id: target_actor.id, permit_id}, + params: {actor_id: user_two.actor.id, permit_id}, headers: test_app.create_session_headers(), }); assert.ok( @@ -981,7 +984,7 @@ export const describe_standard_admin_integration_tests = ( app: test_app.app, path: rpc_path, spec: admin_account_list_action_spec, - params: undefined, + params: {}, headers: create_headers(regular_user.session_cookie), }); assert.ok(!res.ok, 'Expected admin_account_list to fail for non-admin'); diff --git a/src/lib/testing/adversarial_headers.ts b/src/lib/testing/adversarial_headers.ts index ed366b4b..dc52d4a7 100644 --- a/src/lib/testing/adversarial_headers.ts +++ b/src/lib/testing/adversarial_headers.ts @@ -146,7 +146,7 @@ export const describe_standard_adversarial_headers = ( } if (tc.expected_status === 200) { assert.strictEqual(body.ok, true, 'expected ok to be true for 200 response'); - assert.strictEqual(body.has_context, false, 'expected has_context to be false (no auth)'); + assert.strictEqual(body.account_id, null, 'expected account_id to be null (no auth)'); } if (tc.validate_expectation === 'not_called') { assert.strictEqual( diff --git a/src/lib/testing/app_server.ts b/src/lib/testing/app_server.ts index 988b8a2e..bfa5b74f 100644 --- a/src/lib/testing/app_server.ts +++ b/src/lib/testing/app_server.ts @@ -130,11 +130,11 @@ export const bootstrap_test_account = async ( await query_grant_permit(deps, {actor_id: actor.id, role, granted_by: null}); } - // Create API token + // Create API token (account-scoped — acting actor is per-request) const {token: api_token, id: token_id, token_hash} = generate_api_token(); await query_create_api_token(deps, token_id, account.id, 'test-cli', token_hash); - // Create session + cookie + // Create session (account-scoped — acting actor is per-request) const session_token = generate_session_token(); const session_hash = hash_session_token(session_token); const expires_at = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); diff --git a/src/lib/testing/audit_completeness.ts b/src/lib/testing/audit_completeness.ts index 846bfe5e..6d267bbd 100644 --- a/src/lib/testing/audit_completeness.ts +++ b/src/lib/testing/audit_completeness.ts @@ -63,7 +63,6 @@ import { account_token_list_action_spec, account_token_revoke_action_spec, } from '../auth/account_action_specs.js'; -import {query_actor_by_account} from '../auth/account_queries.js'; /** * Configuration for `describe_audit_completeness_tests`. @@ -390,7 +389,12 @@ export const describe_audit_completeness_tests = (options: AuditCompletenessTest await get_db().transaction(async (tx) => { await query_accept_offer( {db: tx}, - {offer_id: offer.id, to_account_id: target.account.id, ip: null}, + { + offer_id: offer.id, + to_account_id: target.account.id, + actor_id: target.actor.id, + ip: null, + }, ); }); @@ -398,12 +402,10 @@ export const describe_audit_completeness_tests = (options: AuditCompletenessTest assert_has_event(events_after_accept, 'permit_grant', 'offer accept'); }); - test('permit revoke (RPC) produces permit_revoke event', async () => { + test('permit revoke (RPC) produces permit_revoke event with both target columns', async () => { const test_app = await create_test_app(build_options(options, get_db())); const target = await test_app.create_account({username: 'audit_revoke_target'}); - const target_actor = await query_actor_by_account({db: get_db()}, target.account.id); - assert.ok(target_actor); // Offer + accept to materialize a permit we can revoke. const offer_res = await rpc_call_for_spec({ @@ -421,7 +423,12 @@ export const describe_audit_completeness_tests = (options: AuditCompletenessTest const accept_result = await get_db().transaction(async (tx) => { return query_accept_offer( {db: tx}, - {offer_id: offer.id, to_account_id: target.account.id, ip: null}, + { + offer_id: offer.id, + to_account_id: target.account.id, + actor_id: target.actor.id, + ip: null, + }, ); }); @@ -430,7 +437,7 @@ export const describe_audit_completeness_tests = (options: AuditCompletenessTest app: test_app.app, path: rpc_path, spec: permit_revoke_action_spec, - params: {actor_id: target_actor.id, permit_id: accept_result.permit.id}, + params: {actor_id: target.actor.id, permit_id: accept_result.permit.id}, headers: test_app.create_session_headers(), }); assert.ok( @@ -440,6 +447,19 @@ export const describe_audit_completeness_tests = (options: AuditCompletenessTest const events = await query_audit_events(test_app.backend.deps.db); assert_has_event(events, 'permit_revoke', 'permit_revoke RPC'); + + // Audit envelope must populate both target columns — + // `permit_revoke` is the canonical actor-bound-subject event. + const revoke_rows = await test_app.backend.deps.db.query<{ + target_account_id: string | null; + target_actor_id: string | null; + }>( + `SELECT target_account_id, target_actor_id FROM audit_log + WHERE event_type = 'permit_revoke' ORDER BY seq DESC LIMIT 1`, + ); + const row = revoke_rows[0]!; + assert.strictEqual(row.target_account_id, target.account.id); + assert.strictEqual(row.target_actor_id, target.actor.id); }); test('admin session revoke-all produces session_revoke_all event', async () => { diff --git a/src/lib/testing/auth_apps.ts b/src/lib/testing/auth_apps.ts index 062ff912..523e94f0 100644 --- a/src/lib/testing/auth_apps.ts +++ b/src/lib/testing/auth_apps.ts @@ -14,8 +14,17 @@ import {Logger} from '@fuzdev/fuz_util/log.js'; import {apply_route_specs, type RouteSpec, type RouteAuth} from '../http/route_spec.js'; import {fuz_auth_guard_resolver} from '../auth/route_guards.js'; -import {REQUEST_CONTEXT_KEY, type RequestContext} from '../auth/request_context.js'; -import {CREDENTIAL_TYPE_KEY, type CredentialType} from '../hono_context.js'; +import { + REQUEST_CONTEXT_KEY, + create_fuz_authorization_handler, + type RequestContext, +} from '../auth/request_context.js'; +import { + ACCOUNT_ID_KEY, + CREDENTIAL_TYPE_KEY, + TEST_CONTEXT_PRESET_KEY, + type CredentialType, +} from '../hono_context.js'; import {create_stub_db} from './stubs.js'; import {create_test_account, create_test_actor, create_test_permit} from './entities.js'; @@ -41,11 +50,14 @@ export const create_test_app_from_specs = ( credential_type?: CredentialType, ): Hono => { const app = new Hono(); + const db = create_stub_db(); app.use('/*', async (c, next) => { c.set('pending_effects', []); if (auth_ctx) { - (c as any).set(REQUEST_CONTEXT_KEY, auth_ctx); - (c as any).set(CREDENTIAL_TYPE_KEY, credential_type ?? 'session'); + c.set(ACCOUNT_ID_KEY, auth_ctx.account.id); + c.set(REQUEST_CONTEXT_KEY, auth_ctx); + c.set(CREDENTIAL_TYPE_KEY, credential_type ?? 'session'); + c.set(TEST_CONTEXT_PRESET_KEY, true); } await next(); }); @@ -54,7 +66,8 @@ export const create_test_app_from_specs = ( route_specs, fuz_auth_guard_resolver, new Logger('test', {level: 'off'}), - create_stub_db(), + db, + create_fuz_authorization_handler({db}), ); return app; }; diff --git a/src/lib/testing/entities.ts b/src/lib/testing/entities.ts index 0226eab1..c4cc2a8e 100644 --- a/src/lib/testing/entities.ts +++ b/src/lib/testing/entities.ts @@ -97,12 +97,13 @@ export const create_test_context = ( /** Override type for `create_test_audit_event` — id-like fields accept plain `string`. */ export type TestAuditEventOverrides = Partial< - Omit + Omit > & { id?: string; actor_id?: string | null; account_id?: string | null; target_account_id?: string | null; + target_actor_id?: string | null; }; /** Create a test `AuditLogEvent` with sensible defaults. */ @@ -114,6 +115,7 @@ export const create_test_audit_event = (overrides?: TestAuditEventOverrides): Au actor_id: 'actor-test' as Uuid, account_id: 'acct-test' as Uuid, target_account_id: null, + target_actor_id: null, ip: '127.0.0.1', created_at: '2024-01-01T00:00:00Z', metadata: null, diff --git a/src/lib/testing/integration.ts b/src/lib/testing/integration.ts index 099e0f9e..3b636935 100644 --- a/src/lib/testing/integration.ts +++ b/src/lib/testing/integration.ts @@ -1272,7 +1272,7 @@ export const describe_standard_integration_tests = ( // handler-authored shape, but `message` and any sibling fields should // equally be free of stack traces, file paths, or other internals. assert_no_error_info_leakage( - res.error as unknown as Record, + res.error, `RPC ${account_verify_action_spec.method} 401 error envelope`, ); }); diff --git a/src/lib/testing/integration_helpers.ts b/src/lib/testing/integration_helpers.ts index 0c469f92..9ac6fcb4 100644 --- a/src/lib/testing/integration_helpers.ts +++ b/src/lib/testing/integration_helpers.ts @@ -212,22 +212,23 @@ export const check_error_response_fields = (body: Record): Arra * Assert that an error response contains no leaky field values. * * Checks both field names and string values for patterns indicating - * stack traces, SQL, or internal paths. + * stack traces, SQL, or internal paths. Accepts `unknown` so callers + * pass response bodies / nested envelope fields directly without + * intermediate `as` casts; non-object bodies skip the field-name check. * * @param context - description for error messages */ -export const assert_no_error_info_leakage = ( - body: Record, - context: string, -): void => { +export const assert_no_error_info_leakage = (body: unknown, context: string): void => { const body_str = JSON.stringify(body); - for (const pattern of LEAKY_FIELD_PATTERNS) { - // check field names (not values — 'error' legitimately contains error codes) - for (const key of Object.keys(body)) { - assert.ok( - !key.toLowerCase().includes(pattern), - `${context}: error response field '${key}' matches leaky pattern '${pattern}'`, - ); + if (body !== null && typeof body === 'object' && !Array.isArray(body)) { + for (const pattern of LEAKY_FIELD_PATTERNS) { + // check field names (not values — 'error' legitimately contains error codes) + for (const key of Object.keys(body)) { + assert.ok( + !key.toLowerCase().includes(pattern), + `${context}: error response field '${key}' matches leaky pattern '${pattern}'`, + ); + } } } // check for stack traces and file paths in values diff --git a/src/lib/testing/middleware.ts b/src/lib/testing/middleware.ts index 23f9f534..fe91edf7 100644 --- a/src/lib/testing/middleware.ts +++ b/src/lib/testing/middleware.ts @@ -17,14 +17,23 @@ import {Logger} from '@fuzdev/fuz_util/log.js'; import {create_bearer_auth_middleware} from '../auth/bearer_auth.js'; import {query_validate_api_token} from '../auth/api_token_queries.js'; -import {query_account_by_id, query_actor_by_account} from '../auth/account_queries.js'; +import { + query_account_by_id, + query_actor_by_id, + query_actors_by_account, +} from '../auth/account_queries.js'; import {query_permit_find_active_for_actor} from '../auth/permit_queries.js'; import type {QueryDeps} from '../db/query_deps.js'; import {create_proxy_middleware, get_client_ip} from '../http/proxy.js'; import {verify_request_source, parse_allowed_origins} from '../http/origin.js'; import type {RateLimiter} from '../rate_limiter.js'; import {REQUEST_CONTEXT_KEY, type RequestContext} from '../auth/request_context.js'; -import {AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY} from '../hono_context.js'; +import { + ACCOUNT_ID_KEY, + AUTH_API_TOKEN_ID_KEY, + CREDENTIAL_TYPE_KEY, + TEST_CONTEXT_PRESET_KEY, +} from '../hono_context.js'; import {ApiError} from '../http/error_schemas.js'; // Mock the query modules so test cases can control return values. @@ -35,7 +44,8 @@ vi.mock('../auth/api_token_queries.js', () => ({ vi.mock('../auth/account_queries.js', () => ({ query_account_by_id: vi.fn(), - query_actor_by_account: vi.fn(), + query_actor_by_id: vi.fn(), + query_actors_by_account: vi.fn(), })); vi.mock('../auth/permit_queries.js', () => ({ @@ -56,8 +66,8 @@ export interface BearerAuthTestOptions { mock_validate_result?: unknown; /** What `query_account_by_id()` returns. */ mock_find_by_id_result?: unknown; - /** What `query_actor_by_account()` returns. */ - mock_find_by_account_result?: unknown; + /** What `query_actor_by_id()` returns. */ + mock_find_actor_by_id_result?: unknown; /** What `query_permit_find_active_for_actor()` returns. */ mock_permits_result?: unknown; /** Expected HTTP status, or `'next'` if the middleware should call `next()`. */ @@ -72,11 +82,13 @@ export interface BearerAuthTestOptions { export interface BearerAuthTestCase extends BearerAuthTestOptions { /** Whether the request should reach token validation or be short-circuited. */ validate_expectation: 'called' | 'not_called'; - /** If true, assert `REQUEST_CONTEXT_KEY` and `CREDENTIAL_TYPE_KEY` were set to api_token values. */ - assert_context_set?: boolean; + /** If true, assert `ACCOUNT_ID_KEY` was set and `CREDENTIAL_TYPE_KEY` is `'api_token'`. */ + assert_account_set?: boolean; + /** Expected `ACCOUNT_ID_KEY` value when `assert_account_set` is true. */ + expected_account_id?: string; /** If set, assert `AUTH_API_TOKEN_ID_KEY` was set to this value after a successful bearer auth. */ expected_api_token_id?: string; - /** If true, assert the pre-existing session context and credential type are preserved. */ + /** If true, assert the pre-existing session `ACCOUNT_ID_KEY` and credential type are preserved. */ assert_context_preserved?: boolean; /** Optional callback for custom spy assertions on the mocks bundle. */ assert_mocks?: (mocks: BearerAuthMocks) => void; @@ -88,7 +100,8 @@ export interface BearerAuthTestCase extends BearerAuthTestOptions { export interface BearerAuthMocks { mock_validate: ReturnType; mock_find_by_id: ReturnType; - mock_find_by_account: ReturnType; + mock_find_actor_by_id: ReturnType; + mock_find_actors_by_account: ReturnType; mock_find_active_for_actor: ReturnType; } @@ -99,7 +112,7 @@ const STUB_DEPS: QueryDeps = {db: {} as any}; * Create mock dependencies for `create_bearer_auth_middleware`, configured per test case. * * Configures the module-level mocks for `query_validate_api_token`, - * `query_account_by_id`, `query_actor_by_account`, and `query_permit_find_active_for_actor` + * `query_account_by_id`, `query_actor_by_id`, and `query_permit_find_active_for_actor` * so each test case controls return values independently. * * @returns mocks bundle with spy references @@ -110,7 +123,8 @@ const STUB_DEPS: QueryDeps = {db: {} as any}; export const create_bearer_auth_mocks = (tc: BearerAuthTestOptions): BearerAuthMocks => { const mock_validate = vi.mocked(query_validate_api_token); const mock_find_by_id = vi.mocked(query_account_by_id); - const mock_find_by_account = vi.mocked(query_actor_by_account); + const mock_find_actor_by_id = vi.mocked(query_actor_by_id); + const mock_find_actors_by_account = vi.mocked(query_actors_by_account); const mock_find_active_for_actor = vi.mocked(query_permit_find_active_for_actor); mock_validate @@ -119,14 +133,29 @@ export const create_bearer_auth_mocks = (tc: BearerAuthTestOptions): BearerAuthM mock_find_by_id .mockReset() .mockImplementation(() => Promise.resolve(tc.mock_find_by_id_result) as any); - mock_find_by_account + mock_find_actor_by_id .mockReset() - .mockImplementation(() => Promise.resolve(tc.mock_find_by_account_result) as any); + .mockImplementation(() => Promise.resolve(tc.mock_find_actor_by_id_result) as any); + // `resolve_acting_actor` enumerates actors. Default: wrap the + // `mock_find_actor_by_id_result` in a single-element array so + // single-actor account scenarios resolve transparently; empty when + // the actor mock is undefined/null. Multi-actor scenarios should + // override this directly via `mockResolvedValue`. + mock_find_actors_by_account.mockReset().mockImplementation(() => { + const actor = tc.mock_find_actor_by_id_result; + return Promise.resolve(actor ? [actor] : []) as any; + }); mock_find_active_for_actor .mockReset() .mockImplementation(() => Promise.resolve(tc.mock_permits_result ?? []) as any); - return {mock_validate, mock_find_by_id, mock_find_by_account, mock_find_active_for_actor}; + return { + mock_validate, + mock_find_by_id, + mock_find_actor_by_id, + mock_find_actors_by_account, + mock_find_active_for_actor, + }; }; /** Default client IP set by the proxy stub in test apps. */ @@ -152,11 +181,19 @@ export const create_bearer_auth_test_app = ( const app = new Hono(); - // inject pre-existing request context if the test case specifies one + // inject pre-existing session identity if the test case specifies one. + // `pre_context` simulates the session middleware having authenticated + // the caller — sets `ACCOUNT_ID_KEY` (the account-grain identity bearer + // auth checks) and the legacy `REQUEST_CONTEXT_KEY` (preserved through + // the bearer middleware unchanged so consumer expectations on the full + // context shape stay testable). if (tc.pre_context) { + const pre_context = tc.pre_context; app.use('*', async (c, next) => { - c.set(REQUEST_CONTEXT_KEY, tc.pre_context!); + c.set(ACCOUNT_ID_KEY, pre_context.account.id); + c.set(REQUEST_CONTEXT_KEY, pre_context); c.set(CREDENTIAL_TYPE_KEY, 'session'); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); } @@ -169,19 +206,22 @@ export const create_bearer_auth_test_app = ( app.use('/api/*', bearer_middleware); - // route handler echoes full context state for assertions + // route handler echoes the account-grain identity the middleware writes + // (bearer auth sets `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY` + + // `AUTH_API_TOKEN_ID_KEY`; it never builds the full request context — + // that is the dispatcher's authorization phase). `request_context_set` + // is only true when a test pre-populates it via `pre_context`. app.get('/api/test', (c) => { - const ctx = c.get(REQUEST_CONTEXT_KEY); + const account_id = c.get(ACCOUNT_ID_KEY); const cred = c.get(CREDENTIAL_TYPE_KEY); const api_token_id = c.get(AUTH_API_TOKEN_ID_KEY); + const ctx = c.get(REQUEST_CONTEXT_KEY); return c.json({ ok: true, - has_context: ctx != null, + account_id: account_id ?? null, credential_type: cred ?? null, - account_id: ctx?.account.id ?? null, - actor_id: ctx?.actor.id ?? null, - permit_count: ctx?.permits.length ?? 0, api_token_id: api_token_id ?? null, + request_context_set: ctx != null, }); }); @@ -233,8 +273,15 @@ export const describe_bearer_auth_cases = ( assert.ok(mocks.mock_validate.mock.calls.length > 0, 'validate should have been called'); } - if (tc.assert_context_set) { - assert.strictEqual(body.has_context, true, 'REQUEST_CONTEXT_KEY should be set'); + if (tc.assert_account_set) { + assert.ok(body.account_id, 'ACCOUNT_ID_KEY should be set'); + if (tc.expected_account_id !== undefined) { + assert.strictEqual( + body.account_id, + tc.expected_account_id, + 'ACCOUNT_ID_KEY should match the validated api_token.account_id', + ); + } assert.strictEqual( body.credential_type, 'api_token', @@ -251,7 +298,7 @@ export const describe_bearer_auth_cases = ( } if (tc.assert_context_preserved) { - assert.strictEqual(body.has_context, true, 'original context should be preserved'); + assert.ok(body.account_id, 'pre-existing account_id should be preserved'); assert.strictEqual( body.credential_type, 'session', @@ -289,7 +336,8 @@ export interface TestMiddlewareStackApp { app: Hono; mock_validate: ReturnType; mock_find_by_id: ReturnType; - mock_find_by_account: ReturnType; + mock_find_actor_by_id: ReturnType; + mock_find_actors_by_account: ReturnType; mock_find_active_for_actor: ReturnType; } @@ -311,12 +359,14 @@ export const create_test_middleware_stack_app = ( const mock_validate = vi.mocked(query_validate_api_token); const mock_find_by_id = vi.mocked(query_account_by_id); - const mock_find_by_account = vi.mocked(query_actor_by_account); + const mock_find_actor_by_id = vi.mocked(query_actor_by_id); + const mock_find_actors_by_account = vi.mocked(query_actors_by_account); const mock_find_active_for_actor = vi.mocked(query_permit_find_active_for_actor); mock_validate.mockReset().mockImplementation(() => Promise.resolve(undefined) as any); mock_find_by_id.mockReset().mockImplementation(() => Promise.resolve(undefined) as any); - mock_find_by_account.mockReset().mockImplementation(() => Promise.resolve(undefined) as any); + mock_find_actor_by_id.mockReset().mockImplementation(() => Promise.resolve(undefined) as any); + mock_find_actors_by_account.mockReset().mockImplementation(() => Promise.resolve([]) as any); mock_find_active_for_actor.mockReset().mockImplementation(() => Promise.resolve([]) as any); const get_connection_ip = @@ -343,15 +393,24 @@ export const create_test_middleware_stack_app = ( app.use('/api/*', origin_mw); app.use('/api/*', bearer_mw); - // echo route for assertions + // echo route for assertions — exposes the account-grain identity bearer + // auth writes (`ACCOUNT_ID_KEY`); the full request context is the + // dispatcher's authorization phase concern, not middleware. app.get(TEST_MIDDLEWARE_PATH, (c) => { - const ctx = c.get(REQUEST_CONTEXT_KEY); + const account_id = c.get(ACCOUNT_ID_KEY); return c.json({ ok: true, client_ip: get_client_ip(c), - has_context: ctx != null, + account_id: account_id ?? null, }); }); - return {app, mock_validate, mock_find_by_id, mock_find_by_account, mock_find_active_for_actor}; + return { + app, + mock_validate, + mock_find_by_id, + mock_find_actor_by_id, + mock_find_actors_by_account, + mock_find_active_for_actor, + }; }; diff --git a/src/lib/testing/rpc_helpers.ts b/src/lib/testing/rpc_helpers.ts index 920d36a3..b0ca03ff 100644 --- a/src/lib/testing/rpc_helpers.ts +++ b/src/lib/testing/rpc_helpers.ts @@ -172,10 +172,11 @@ export const assert_jsonrpc_success_response = (body: unknown, output_schema?: z assert.ok(result.success, `not a valid JSON-RPC success response: ${JSON.stringify(body)}`); if (output_schema) { const output_result = output_schema.safeParse(result.data.result); - assert.ok( - output_result.success, - `JSON-RPC result does not match output schema: ${JSON.stringify((output_result as any).error?.issues)}`, - ); + if (!output_result.success) { + assert.fail( + `JSON-RPC result does not match output schema: ${JSON.stringify(output_result.error.issues)}`, + ); + } } }; diff --git a/src/lib/testing/ws_round_trip.ts b/src/lib/testing/ws_round_trip.ts index 47821213..9472e54b 100644 --- a/src/lib/testing/ws_round_trip.ts +++ b/src/lib/testing/ws_round_trip.ts @@ -61,7 +61,13 @@ import { import {BackendWebsocketTransport} from '../actions/transports_ws_backend.js'; import {REQUEST_CONTEXT_KEY, type RequestContext} from '../auth/request_context.js'; import {ROLE_KEEPER} from '../auth/role_schema.js'; -import {AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, type CredentialType} from '../hono_context.js'; +import { + ACCOUNT_ID_KEY, + AUTH_API_TOKEN_ID_KEY, + CREDENTIAL_TYPE_KEY, + TEST_CONTEXT_PRESET_KEY, + type CredentialType, +} from '../hono_context.js'; import {JSONRPC_VERSION} from '../http/jsonrpc.js'; import { create_jsonrpc_request, @@ -127,10 +133,12 @@ export interface FakeHonoContextOptions { export const create_fake_hono_context = (opts: FakeHonoContextOptions): Context => { const request_context = opts.request_context ?? build_simple_request_context(opts.role); const vars: Record = { + [ACCOUNT_ID_KEY]: request_context.account.id, [REQUEST_CONTEXT_KEY]: request_context, [CREDENTIAL_TYPE_KEY]: opts.credential_type, auth_session_id: opts.auth_session_id ?? (opts.credential_type === 'session' ? 's1' : null), [AUTH_API_TOKEN_ID_KEY]: opts.api_token_id ?? null, + [TEST_CONTEXT_PRESET_KEY]: true, }; return { get: (key: string) => vars[key], @@ -486,10 +494,12 @@ export const create_ws_test_harness = ( const roles = identity.roles ?? []; const ctx_store = new Map([ + [ACCOUNT_ID_KEY, account_id], [REQUEST_CONTEXT_KEY, build_multi_role_request_context(account_id, roles)], [CREDENTIAL_TYPE_KEY, credential_type], ['auth_session_id', session_id], [AUTH_API_TOKEN_ID_KEY, api_token_id], + [TEST_CONTEXT_PRESET_KEY, true], ]); const fake_c = { get: (key: string) => ctx_store.get(key), diff --git a/src/lib/ui/CLAUDE.md b/src/lib/ui/CLAUDE.md index 7cb7c6e1..2bf974bf 100644 --- a/src/lib/ui/CLAUDE.md +++ b/src/lib/ui/CLAUDE.md @@ -169,12 +169,16 @@ destructive actions. Accept is a `PendingButton`; decline is a `ConfirmButton` whose popover contains a textarea (max `PERMIT_OFFER_MESSAGE_LENGTH_MAX`). - `PermitOfferForm.svelte` — grantor-side create form. Props: - `to_account_id`, `roles: Array` (pre-filtered upstream by - `web_grantable`), `scope_id = null`, `on_created?`, `format_role?`. - Surfaces three reason codes with friendly copy: - `ERROR_OFFER_SELF_TARGET`, `ERROR_OFFER_ROLE_NOT_GRANTABLE`, - `ERROR_OFFER_NOT_AUTHORIZED` — imported from `../auth/permit_offer_action_specs.js` - (see `../auth/CLAUDE.md` for `permit_offer_action_specs.ts` + `permit_offer_actions.ts`). + `to_account_id`, `to_actor_id = null` (optional — narrows the offer + to a specific actor on the recipient account; default account-grain), + `roles: Array` (pre-filtered upstream by `web_grantable`), + `scope_id = null`, `on_created?`, `format_role?`. Surfaces five + reason codes with friendly copy: `ERROR_OFFER_SELF_TARGET`, + `ERROR_OFFER_ROLE_NOT_GRANTABLE`, `ERROR_OFFER_NOT_AUTHORIZED`, + `ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH`, `ERROR_OFFER_ACTOR_MISMATCH` + — imported from `../auth/permit_offer_action_specs.js` (see + `../auth/CLAUDE.md` for `permit_offer_action_specs.ts` + + `permit_offer_actions.ts`). - `PermitOfferHistory.svelte` — both-directions history (recipient + grantor, including terminal). Props: `current_actor_id: string | null` (classifies row as "sent" vs "received"), `format_actor?`, @@ -247,10 +251,12 @@ destructive actions. `revoke_permit`, `retract_offer`, `session_revoke_all`, `token_revoke_all` — the last two are also reused by `AdminSessionsState`). `SvelteSet`s for in-flight tracking: - `granting_keys` (`${account_id}:${role}`), `revoking_ids` - (permit id), `retracting_ids` (offer id). `revoke_permit` keys on - `actor_id` (permits are actor-scoped — matches `row.actor.id` - straight from the listing) with optional `reason`. + `granting_keys` (`${account_id}:${role}` for the account-grain + default; `${account_id}:${role}:${to_actor_id}` when `grant_permit` + is called with an actor-targeted offer), `revoking_ids` (permit id), + `retracting_ids` (offer id). `revoke_permit` keys on `actor_id` + (permits are actor-scoped — matches `row.actor.id` straight from the + listing) with optional `reason`. - `admin_invites_state.svelte.ts` — `AdminInvitesState` extends `Loadable` + `admin_invites_rpc_context` + narrow `AdminInvitesRpc` (`list`, `create`, `delete`). Fields: diff --git a/src/lib/ui/PermitOfferForm.svelte b/src/lib/ui/PermitOfferForm.svelte index 1487462c..6c940ede 100644 --- a/src/lib/ui/PermitOfferForm.svelte +++ b/src/lib/ui/PermitOfferForm.svelte @@ -19,6 +19,8 @@ type PermitOfferJson, } from '../auth/permit_offer_schema.js'; import { + ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH, + ERROR_OFFER_ACTOR_MISMATCH, ERROR_OFFER_NOT_AUTHORIZED, ERROR_OFFER_ROLE_NOT_GRANTABLE, ERROR_OFFER_SELF_TARGET, @@ -26,12 +28,19 @@ const { to_account_id, + to_actor_id = null, roles, scope_id = null, on_created, format_role = (role: string) => role, }: { to_account_id: string; + /** + * Narrow the offer to a specific actor on `to_account_id`. Omit + * (or `null`, the default) for the account-grain default — any + * actor on the recipient account may accept. + */ + to_actor_id?: string | null; /** Roles the caller may offer — caller filters by `web_grantable` upstream. */ roles: Array; /** Resource scope for the offer; `null` (default) yields a global offer. */ @@ -58,6 +67,10 @@ return 'That role cannot be offered through this form.'; case ERROR_OFFER_NOT_AUTHORIZED: return 'You are not authorized to offer that role.'; + case ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH: + return 'That actor is not on the recipient account.'; + case ERROR_OFFER_ACTOR_MISMATCH: + return 'This offer is for a different actor on the recipient account.'; default: return null; } @@ -72,6 +85,7 @@ } const offer = await permit_offers.create({ to_account_id, + to_actor_id, role: selected_role, scope_id, message: message.trim() || null, diff --git a/src/lib/ui/admin_accounts_state.svelte.ts b/src/lib/ui/admin_accounts_state.svelte.ts index f7cf7ea9..39e75676 100644 --- a/src/lib/ui/admin_accounts_state.svelte.ts +++ b/src/lib/ui/admin_accounts_state.svelte.ts @@ -128,19 +128,34 @@ export class AdminAccountsState extends Loadable { * refreshes the existing pending row — the returned offer id is stable * across those calls. * + * `to_actor_id` (optional) narrows the offer to a specific actor on + * `account_id`; the in-flight `granting_keys` entry stays at + * `account_id:role` for the account-grain default (so existing + * consumers reading the 2-segment key keep working) and becomes + * `account_id:role:to_actor_id` when actor-targeted, so the two + * variants can be in flight without colliding on the per-row spinner. + * * No-op when the rpc adapter is absent; `error` is set to a descriptive * message so the UI surfaces the misconfiguration. */ - async grant_permit(account_id: Uuid, role: RoleName): Promise { + async grant_permit( + account_id: Uuid, + role: RoleName, + to_actor_id?: Uuid | null, + ): Promise { const rpc = this.#get_rpc(); if (!rpc) { this.error = 'rpc adapter not wired'; return undefined; } - const key = `${account_id}:${role}`; + const key = to_actor_id ? `${account_id}:${role}:${to_actor_id}` : `${account_id}:${role}`; this.granting_keys.add(key); try { - const {offer} = await rpc.grant_permit({to_account_id: account_id, role}); + const {offer} = await rpc.grant_permit({ + to_account_id: account_id, + role, + ...(to_actor_id ? {to_actor_id} : {}), + }); this.error = null; await this.fetch(); return offer; diff --git a/src/lib/ui/permit_offers_state.svelte.ts b/src/lib/ui/permit_offers_state.svelte.ts index b56b0799..b6ecd88d 100644 --- a/src/lib/ui/permit_offers_state.svelte.ts +++ b/src/lib/ui/permit_offers_state.svelte.ts @@ -50,6 +50,7 @@ export interface PermitOffersRpc { }) => Promise<{offers: Array}>; create: (params: { to_account_id: string; + to_actor_id?: string | null; role: string; scope_id?: string | null; message?: string | null; @@ -162,9 +163,16 @@ export class PermitOffersState extends Loadable { }); } - /** Issue a new offer; merges the returned offer into the cache on success. */ + /** + * Issue a new offer; merges the returned offer into the cache on success. + * + * `to_actor_id` (optional) narrows the offer to a specific actor on + * `to_account_id`; omit / null for the account-grain default (any actor + * on the recipient account may accept). + */ async create(params: { to_account_id: string; + to_actor_id?: string | null; role: string; scope_id?: string | null; message?: string | null; diff --git a/src/routes/library.json b/src/routes/library.json index 721d319d..cbf072a8 100644 --- a/src/routes/library.json +++ b/src/routes/library.json @@ -580,8 +580,8 @@ { "name": "generate_actions_api_method_signature", "kind": "function", - "doc_comment": "Generates one method line of the typed `FrontendActionsApi` interface for a\nsingle spec. Encapsulates the input/options/return-type signature shape so\nthe surface evolves in one place when fields like `signal` or\n`transport_name` are added to per-call options.\n\nAsync methods (`request_response`, `remote_notification`, async\n`local_call`) get an optional second `options?: RpcClientCallOptions` arg\n(`{signal?, transport_name?, queue?}`) and a `Promise>` return\ntype. Sync `local_call` methods omit the options arg — `signal` can't\ncooperatively interrupt a synchronous handler and there's no transport to\nselect. `remote_notification` is async because\n`create_remote_notification_method` returns a Promise that resolves to a\n`Result<{value: void}>` (success) or `Result<{error}>` (transport send\nfailure). Earlier emit shapes declared notifications as `=> void` —\nregenerate consumer typed clients to pick up the corrected return.\n\nRegisters exactly the imports the emitted line references on `imports`:\n`ActionInputs` (when the spec has input), `ActionOutputs` (always),\n`RpcClientCallOptions` (async only), and `Result` + `JsonrpcErrorObject`\n(any return shape that wraps the value in `Result<{value}, {error}>` —\nevery async method, plus sync `local_call` when `sync_returns_value:\nfalse`). Mirrors the leaf-level pattern `get_handler_return_type` already\nfollows so wrappers no longer pre-register imports a per-spec emit might\nnot actually use.", - "source_line": 409, + "doc_comment": "Generates one method line of the typed `FrontendActionsApi` interface for a\nsingle spec. Encapsulates the input/options/return-type signature shape so\nthe surface evolves in one place when fields like `signal` or\n`transport_name` are added to per-call options.\n\nAsync methods (`request_response`, `remote_notification`, async\n`local_call`) get an optional second `options?: RpcClientCallOptions` arg\n(`{signal?, transport_name?, queue?}`) and a `Promise>` return\ntype. Sync `local_call` methods omit the options arg — `signal` can't\ncooperatively interrupt a synchronous handler and there's no transport to\nselect. `remote_notification` is async because\n`create_remote_notification_method` returns a Promise that resolves to a\n`Result<{value: void}>` (success) or `Result<{error}>` (transport send\nfailure). Earlier emit shapes declared notifications as `=> void` —\nregenerate consumer typed clients to pick up the corrected return.\n\nRegisters exactly the imports the emitted line references on `imports`:\n`ActionInputs` (when the spec has input), `ActionOutputs` (always),\n`RpcClientCallOptions` (async only), and `Result` + `JsonrpcErrorObject`\n(any return shape that wraps the value in `Result<{value}, {error}>` —\nevery async method, plus sync `local_call` when `sync_returns_value:\nfalse`). Mirrors the leaf-level pattern `get_handler_return_type` already\nfollows so wrappers no longer pre-register imports a per-spec emit might\nnot actually use.\n\n**Optional-input detection.** The emitted parameter is `input?:` (caller\nmay omit the argument) when either (a) the schema accepts `undefined` —\n`z.optional(z.strictObject(...))` and similar wrappers — or (b) the\nschema accepts the empty object `{}` — `z.strictObject({acting:\nActingActor})` and other all-optional-fields strict objects. The second\nprobe mirrors the dispatcher's HTTP convention (`raw_params ?? {}` for\nnon-`z.void()` schemas in `actions/action_rpc.ts` / `http/route_spec.ts`):\nif a request with no params reaches the handler, this is the value the\nschema is asked to validate. A schema with required fields fails both\nprobes and stays `input:` (required at the typed surface). Refinements\nand transforms run as part of `safeParse`, so their accept/reject\ndecisions feed into the optional/required choice naturally.", + "source_line": 422, "type_signature": "(spec: { method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; }, imports: ImportBuilder, options?: { ...; } | undefined): string", "return_type": "string", "return_description": "one line like `foo: (input: ActionInputs['foo'], options?: RpcClientCallOptions) => Promise>;`", @@ -605,21 +605,21 @@ "name": "ActionMethodEnumKind", "kind": "type", "doc_comment": "Discriminator for `generate_action_method_enums` — which method-set enums to emit.", - "source_line": 487, + "source_line": 502, "type_signature": "ActionMethodEnumKind" }, { "name": "ACTION_METHOD_ENUM_KINDS_ALL", "kind": "variable", "doc_comment": "Default emit set — every enum kind.", - "source_line": 499, + "source_line": 514, "type_signature": "ReadonlySet" }, { "name": "generate_action_method_enums", "kind": "function", "doc_comment": "Emit one or more `z.enum([...])` declarations for action method names —\n`ActionMethod`, `RequestResponseActionMethod`, `RemoteNotificationActionMethod`,\n`LocalCallActionMethod`, `FrontendActionMethod`, `BackendActionMethod`,\n`FrontendRequestResponseMethod`, `BackendRequestResponseMethod`,\n`BroadcastActionMethod`. Pairs each runtime const with a `z.infer` type\nalias under the same identifier.\n\nProtocol-action methods (`heartbeat`, `cancel`) are filtered out by\ndefault — pass `include_protocol_actions: true` if a consumer genuinely\nwants them on their typed surface. Empty kinds are skipped so the helper\nnever emits `z.enum([])` (zod runtime-throws on that).\n\nAdds `import {z} from 'zod';` to `imports` only when at least one block\nis emitted (idempotent).\n\nFor genuinely cross-product enums the discriminator doesn't cover, use\n`generate_action_method_enum_block` — caller owns the predicate, name,\nand jsdoc.", - "source_line": 571, + "source_line": 586, "type_signature": "(specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[], imports: ImportBuilder, options?: { ...; } | undefined): string", "return_type": "string", "parameters": [ @@ -642,7 +642,7 @@ "name": "generate_action_method_enum_block", "kind": "function", "doc_comment": "Emit a single named `z.enum([...])` + `z.infer` block for an arbitrary\nspec subset. Lower-level escape hatch from `generate_action_method_enums` —\nfor cross-product or domain-specific enums the built-in discriminator\ndoesn't cover.\n\nMirrors the built-in helper's contract: protocol actions filtered by\ndefault, empty subsets return `''` (skip rather than emit `z.enum([])`),\n`zod` import registered idempotently only when at least one method\nqualifies.\n\nThe cross-product space is open-ended; rather than grow the\n`ActionMethodEnumKind` discriminator one cross-product at a time, callers\nown the subset shape — name, jsdoc, predicate.", - "source_line": 673, + "source_line": 688, "type_signature": "(specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[], imports: ImportBuilder, options: { ...; }): string", "return_type": "string", "parameters": [ @@ -664,7 +664,7 @@ "name": "generate_typed_action_event_alias", "kind": "function", "doc_comment": "Emit the fixed-shape `TypedActionEvent` alias used by `FrontendActionHandlers`\nto narrow `ActionEvent.data` against the consumer's generated `ActionEventDatas`\nmap. Registers the four fuz_app type imports it needs (`ActionEvent`,\n`ActionEventPhase`, `ActionEventStep`, `ActionEventDatas`) plus the\n`ActionMethod` type import — sourced from `collections_path` and\n`metatypes_path` respectively.\n\nPair with `generate_action_method_enums` (emits `ActionMethod` into\n`metatypes_path`) and `generate_action_event_datas` (emits\n`ActionEventDatas` into `collections_path`).", - "source_line": 702, + "source_line": 717, "type_signature": "(imports: ImportBuilder, options?: { collections_path?: string | undefined; metatypes_path?: string | undefined; } | undefined): string", "return_type": "string", "parameters": [ @@ -683,7 +683,7 @@ "name": "generate_action_specs_record", "kind": "function", "doc_comment": "Emit the `ActionSpecs` runtime const + interface + the `action_specs:\nArray` value bundling every spec. Adds the `* as specs`\nnamespace import + the `ActionSpecUnion` type import.", - "source_line": 733, + "source_line": 748, "type_signature": "(specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[], imports: ImportBuilder, options?: { ...; } | undefined): string", "return_type": "string", "parameters": [ @@ -706,7 +706,7 @@ "name": "generate_action_inputs_outputs", "kind": "function", "doc_comment": "Emit `ActionInputs` + `ActionOutputs` runtime consts and matching interfaces.\nThe runtime consts reference `specs.{method}_action_spec.input` /\n`.output`; the interfaces use `z.infer`.\n\nAdds `import {z} from 'zod';` and the `* as specs` namespace import.", - "source_line": 790, + "source_line": 805, "type_signature": "(specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[], imports: ImportBuilder, options?: { ...; } | undefined): string", "return_type": "string", "parameters": [ @@ -729,7 +729,7 @@ "name": "generate_action_event_datas", "kind": "function", "doc_comment": "Emit the `ActionEventDatas` interface — one `ActionEvent*Data` variant per\nmethod, parameterized by the spec's kind:\n- `request_response` → `ActionEventRequestResponseData`\n- `remote_notification` → `ActionEventRemoteNotificationData`\n- `local_call` → `ActionEventLocalCallData`\n\nAdds the per-kind data type imports (only the kinds that appear in `specs`).", - "source_line": 878, + "source_line": 893, "type_signature": "(specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[], imports: ImportBuilder, options?: { ...; } | undefined): string", "return_type": "string", "parameters": [ @@ -752,7 +752,7 @@ "name": "generate_frontend_actions_api", "kind": "function", "doc_comment": "Emit the `FrontendActionsApi` interface — one method signature per spec via\n`generate_actions_api_method_signature`. Optionally filter the spec set\n(e.g. omit additional methods alongside the default protocol-action\nfilter) via `method_filter`.\n\nImports are registered by the leaf `generate_actions_api_method_signature`\nper emitted line — only what the spec set actually references shows up on\n`imports`. A spec set with no async methods skips `RpcClientCallOptions`;\none with no inputs skips `ActionInputs`; sync `local_call` methods with\n`sync_returns_value: true` (the default) skip `Result` / `JsonrpcErrorObject`.\n\nThe interface name is fixed at `FrontendActionsApi` — the symmetric counterpart\nof `BackendActionsApi`. Earlier consumer-named variants (`MyActionsApi`,\n`VisionesActionsApi`) were retired in API review III to make the side-of-the-wire\nintent visible at every call site. If a consumer needs a different name they\nhand-roll the interface (the helper's job is the standard symmetric shape).", - "source_line": 944, + "source_line": 959, "type_signature": "(specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[], imports: ImportBuilder, options?: { ...; } | undefined): string", "return_type": "string", "parameters": [ @@ -775,7 +775,7 @@ "name": "generate_frontend_action_handlers", "kind": "function", "doc_comment": "Emit the `FrontendActionHandlers` interface — wraps `generate_phase_handlers`\nwith the `TypedActionEvent` action-event type and standard 1-tab per-method\nindentation. Pairs with `generate_typed_action_event_alias` (emits the\nmatching `TypedActionEvent` alias) — call both in the same gen producer.", - "source_line": 996, + "source_line": 1011, "type_signature": "(specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[], imports: ImportBuilder, options?: { ...; } | undefined): string", "return_type": "string", "parameters": [ @@ -798,7 +798,7 @@ "name": "generate_backend_actions_api", "kind": "function", "doc_comment": "Emit BOTH the typed `BackendActionsApi` interface AND the\n`broadcast_action_specs` runtime array. The interface is shaped for\n`create_broadcast_api`: backend-initiated `remote_notification` methods,\neach `(input) => Promise`. The array bundles the matching specs as a\n`ReadonlyArray`.\n\nFilter: `kind === 'remote_notification' && initiator !== 'frontend'`,\nadditionally excluding methods that are the target of another spec's\n`streams` field. Streams targets (e.g. `completion_progress`,\n`ollama_progress`) are request-scoped notifications invoked via\n`ctx.notify` inside their parent handler — they're never callable through\nthe broadcast API. The discriminator is `ActionSpec.streams`, not a manual\nexclusion list.\n\nAdds the `* as specs` namespace import (from `specs_module`), the\n`ActionInputs` type import (from `collections_path`), and the\n`ActionSpecUnion` type import.\n\nMethod signature shape today is `(input) => Promise` — matches the\nfire-and-forget runtime of `create_broadcast_api`. Generalizing per-kind\nvia `generate_actions_api_method_signature` is deferred until a second\nbackend runtime constructor lands.", - "source_line": 1064, + "source_line": 1079, "type_signature": "(specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[], imports: ImportBuilder, options?: { ...; } | undefined): string", "return_type": "string", "parameters": [ @@ -821,7 +821,7 @@ "name": "generate_backend_action_handlers_map", "kind": "function", "doc_comment": "Emit the `BackendActionHandlers` mapped type — one entry per\n`BackendRequestResponseMethod`, each `(input, ctx) => output | Promise`.\nReplaces the hand-maintained `Exclude<>` + parallel mapped-type pattern\n(zzz had this at `zzz/src/lib/server/zzz_action_handlers.ts:42-66`).\n\nThe context type is consumer-defined (e.g. zzz's `ZzzHandlerContext`). Pass\n`context_type` to name it; the helper assumes it's importable or defined\nin the emitted module's scope (consumer's responsibility).\n\nAdds `ActionInputs` / `ActionOutputs` type imports from `collections_path`\nand the `BackendRequestResponseMethod` import from `metatypes_path`.", - "source_line": 1136, + "source_line": 1151, "type_signature": "(imports: ImportBuilder, options?: { type_name?: string | undefined; method_enum_name?: string | undefined; context_type?: string | undefined; collections_path?: string | undefined; metatypes_path?: string | undefined; } | undefined): string", "return_type": "string", "parameters": [ @@ -840,7 +840,7 @@ "name": "SpecSource", "kind": "type", "doc_comment": "One source in a multi-source consumer's namespace map. `ns` is the local\nalias used inside the generated file; `module` is the import path; `specs`\nis the runtime spec array. `create_namespace_qualifier` consumes a list of\nthese.", - "source_line": 1178, + "source_line": 1193, "type_signature": "SpecSource", "properties": [ { @@ -873,7 +873,7 @@ "description": "if two sources contain the same method name (same-method" } ], - "source_line": 1222, + "source_line": 1237, "type_signature": "(sources: readonly SpecSource[], imports: ImportBuilder): { qualify_spec: (spec: { method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; ... 8 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; }) => string; all_specs: readonly ({ ...; } | ... 1 more ... | { ...; })[]; }", "return_type": "{ qualify_spec: (spec: { method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType<...>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; }) => string; all_specs: rea...", "parameters": [ @@ -894,7 +894,7 @@ "examples": [ "```ts\nexport const gen: Gen = ({origin_path}) => {\n const imports = new ImportBuilder();\n return compose_gen_file({\n origin_path,\n imports,\n blocks: [\n generate_action_specs_record(all_action_specs, imports),\n generate_action_inputs_outputs(all_action_specs, imports),\n generate_action_event_datas(all_action_specs, imports),\n ],\n });\n};\n```\n\nEmpty blocks (`''`) are filtered out so helpers that short-circuit on\nempty spec sets don't introduce stray double blank lines." ], - "source_line": 1285, + "source_line": 1300, "type_signature": "(input: { origin_path: string; imports: ImportBuilder; blocks: readonly string[]; }): string", "return_type": "string", "parameters": [ @@ -1975,7 +1975,7 @@ "name": "ActionContext", "kind": "type", "doc_comment": "Per-request context provided to RPC action handlers.\n\nExtends `RouteContext` with auth identity and logger.\n`auth` is `RequestContext | null` — handlers for authenticated\nactions can narrow via the auth middleware guarantee.", - "source_line": 52, + "source_line": 61, "type_signature": "ActionContext", "properties": [ { @@ -2038,7 +2038,7 @@ "name": "ActionHandler", "kind": "type", "doc_comment": "Handler function for an RPC action.\n\nReceives validated input and an `ActionContext` with per-request deps.\nReturns the output value (serialized to JSON by the wrapper).", - "source_line": 98, + "source_line": 107, "type_signature": "ActionHandler", "generic_params": [ { @@ -2051,11 +2051,43 @@ } ] }, + { + "name": "ActionActorContext", + "kind": "type", + "doc_comment": "`ActionContext` narrowed to a resolved acting actor.\n\nReturned to handlers bound via `rpc_actor_action` — the dispatcher's\nauthorization phase has already run for actor-implying auth or\n`acting`-declaring inputs, so `ctx.auth.actor` is non-null and the\nhandler skips the `require_request_actor(ctx.auth)` narrowing call.", + "source_line": 120, + "type_signature": "ActionActorContext", + "extends": ["Omit"], + "properties": [ + { + "name": "auth", + "kind": "variable", + "type_signature": "RequestActorContext" + } + ] + }, + { + "name": "ActorActionHandler", + "kind": "type", + "doc_comment": "Handler function for an RPC action whose dispatcher always resolves an\nacting actor (`auth: 'keeper' | {role}` or input declaring\n`acting?: ActingActor`). Mirrors `ActionHandler` but tightens the\n`ctx.auth` slot to the non-null `RequestActorContext`.", + "source_line": 130, + "type_signature": "ActorActionHandler", + "generic_params": [ + { + "name": "TInput", + "default_type": "any" + }, + { + "name": "TOutput", + "default_type": "any" + } + ] + }, { "name": "RpcAction", "kind": "type", "doc_comment": "An RPC action — combines an action spec with its handler.\n\nThe spec defines the contract (method, auth, schemas, side effects).\nThe handler implements the behavior.", - "source_line": 109, + "source_line": 141, "type_signature": "RpcAction", "properties": [ { @@ -2074,7 +2106,7 @@ "name": "rpc_action", "kind": "function", "doc_comment": "Pair a spec with a handler while preserving per-method input/output types.\n\nConstructing `{spec, handler}` literals widens `handler` to\n`ActionHandler`, so spec/handler drift (renamed Zod schema,\noutput field removal, input shape change) slips past the typechecker.\n`rpc_action(spec, handler)` binds the handler signature to\n`(input: z.infer, ctx) => z.infer` via the\ngeneric spec parameter — drift surfaces at the call site.\n\nFits fuz_app's factory-closure pattern (handlers close over\n`grantable_roles`, `app_settings` ref, `notification_sender`, etc.).\nzzz uses a different shape — a codegen-keyed `Record`\nmap typed via generated `ActionInputs`/`ActionOutputs` — which works when\nhandlers are pure (no closure state) and specs are codegen-enumerated.\nfuz_app's admin + permit-offer actions have neither, so per-pair typing\nat the registration site is the right fit.", - "source_line": 132, + "source_line": 164, "type_signature": "(spec: TSpec, handler: ActionHandler, output>): RpcAction", "return_type": "RpcAction", "parameters": [ @@ -2088,11 +2120,32 @@ } ] }, + { + "name": "rpc_actor_action", + "kind": "function", + "doc_comment": "Variant of `rpc_action` for handlers whose spec always resolves an\nacting actor — actions with `auth: 'keeper' | {role}` or inputs that\ndeclare `acting?: ActingActor`. The dispatcher's authorization phase\nruns before the handler, populates `ctx.auth` with a non-null\n`RequestActorContext`, and `rpc_actor_action` reflects that\nguarantee in the handler signature so the handler body skips the\n`require_request_actor(ctx.auth)` narrowing call (and the bug class\nwhere forgetting that call fails open against a `null` actor).\n\nThe runtime binding is identical to `rpc_action` — both register the\nsame `RpcAction` shape on the action map. Only the compile-time\nhandler signature differs.", + "examples": [ + "```ts\nrpc_actor_action(permit_revoke_action_spec, async (input, ctx) => {\n // ctx.auth is RequestActorContext — no require_request_actor() needed.\n const revoker_id = ctx.auth.actor.id;\n // ...\n});\n```" + ], + "source_line": 195, + "type_signature": "(spec: TSpec, handler: ActorActionHandler, output>): RpcAction", + "return_type": "RpcAction", + "parameters": [ + { + "name": "spec", + "type": "TSpec" + }, + { + "name": "handler", + "type": "ActorActionHandler, output>" + } + ] + }, { "name": "CreateRpcEndpointOptions", "kind": "type", "doc_comment": "Options for `create_rpc_endpoint`.", - "source_line": 141, + "source_line": 204, "type_signature": "CreateRpcEndpointOptions", "properties": [ { @@ -2123,21 +2176,21 @@ "name": "action_account_rate_limiter", "kind": "variable", "type_signature": "RateLimiter | null", - "doc_comment": "Per-actor rate limiter consulted for actions whose spec declares\n`rate_limit: 'account'` or `'both'`. Keyed on\n`request_context.actor.id`. `null` disables the account check.\nSame limiter is shared with the WebSocket action dispatcher." + "doc_comment": "Per-account rate limiter consulted for actions whose spec declares\n`rate_limit: 'account'` or `'both'`. Keyed on\n`request_context.account.id` (account-grain — billed to the\nauthenticated account regardless of which actor was resolved).\n`null` disables the account check. Same limiter is shared with the\nWebSocket action dispatcher." } ] }, { "name": "create_rpc_endpoint", "kind": "function", - "doc_comment": "Single JSON-RPC 2.0 endpoint — the canonical RPC transport binding.\n\nReturns two `RouteSpec` entries (GET + POST on the same path) for\n`apply_route_specs`. The internal dispatcher handles:\n\n1. **Parse envelope** — POST: JSON body as `JsonrpcRequest`. GET: `method`\n and `params` from query string.\n2. **Lookup method** — find the `RpcAction` by method name.\n3. **Auth check** — verify identity against the action's `auth` requirement.\n4. **Validate params** — parse input against the action's `input` schema.\n5. **Dispatch** — acquire DB handle (transaction for mutations, pool for reads),\n construct `ActionContext`, call handler, return JSON-RPC response.\n\nGET is restricted to `side_effects: false` actions (cacheable reads).\nAll errors use JSON-RPC format: `{jsonrpc, id, error: {code, message, data?}}`.\n\nThe RouteSpecs use `auth: {type: 'none'}` because auth is checked per-action\ninside the dispatcher, and `transaction: false` because transaction scope\nis per-action (mutations get a transaction, reads get pool).", + "doc_comment": "Single JSON-RPC 2.0 endpoint — the canonical RPC transport binding.\n\nReturns two `RouteSpec` entries (GET + POST on the same path) for\n`apply_route_specs`. The internal dispatcher handles:\n\n1. **Parse envelope** — POST: JSON body as `JsonrpcRequest`. GET: `method`\n and `params` from query string.\n2. **Lookup method** — find the `RpcAction` by method name.\n3. **Pre-validation auth** — short-circuit `unauthenticated` when no\n account is on the request, before input validation runs.\n4. **Authorization phase** — resolve the acting actor (when the action's\n auth requires permits or its input declares `acting?: ActingActor`)\n and build the request context. Runs before input validation so\n permit-grain auth checks return 403 before 400 invalid_params;\n `acting` is read from raw params via a string typeguard.\n5. **Post-authorization auth** — enforce role / keeper requirements\n against the request context.\n6. **Validate params** — parse input against the action's `input` schema.\n7. **Rate limit** — per-action IP / account throttling.\n8. **Dispatch** — acquire DB handle (transaction for mutations, pool for reads),\n construct `ActionContext`, call handler, return JSON-RPC response.\n\nGET is restricted to `side_effects: false` actions (cacheable reads).\nAll errors use JSON-RPC format: `{jsonrpc, id, error: {code, message, data?}}`.\n\nThe RouteSpecs use `auth: {type: 'none'}` because auth is checked per-action\ninside the dispatcher, and `transaction: false` because transaction scope\nis per-action (mutations get a transaction, reads get pool).", "throws": [ { "type": "Error", "description": "if two actions share the same `spec.method` (registration-time" } ], - "source_line": 241, + "source_line": 338, "type_signature": "(options: CreateRpcEndpointOptions): RouteSpec[]", "return_type": "RouteSpec[]", "return_description": "route specs (GET + POST) ready for `apply_route_specs`", @@ -2790,7 +2843,7 @@ "name": "action_account_rate_limiter", "kind": "variable", "type_signature": "RateLimiter | null", - "doc_comment": "Per-actor rate limiter consulted for actions whose spec declares\n`rate_limit: 'account'` or `'both'`. Keyed on\n`request_context.actor.id`. `null` (or omitted) disables the\naccount check. Same limiter is shared with the HTTP RPC dispatcher." + "doc_comment": "Per-account rate limiter consulted for actions whose spec declares\n`rate_limit: 'account'` or `'both'`. Keyed on\n`request_context.account.id`. `null` (or omitted) disables the\naccount check. Same limiter is shared with the HTTP RPC dispatcher." } ] }, @@ -2825,7 +2878,7 @@ ] } ], - "module_comment": "WebSocket JSON-RPC dispatch — the low-level WS transport binding.\n\nMost consumers should mount WS endpoints via `register_ws_endpoint`\n(`actions/register_ws_endpoint.ts`), which wraps this function with the standard\nupgrade stack (origin check + auth + optional role). This module stays\nexported as the lower-level entry point for tests that drive the\ndispatcher directly via `create_ws_test_harness`.\n\nSymmetric to `create_rpc_endpoint` (from `actions/action_rpc.ts`):\nconsumer supplies action specs + a handler map, the dispatcher parses the\nenvelope, checks per-action auth, validates input, invokes the handler with\na per-request context, and writes the response.\n\nExtracted from zzz's `register_websocket_actions` to converge pattern drift\nacross consumers (zzz, tx, undying). Broadcast-style notifications remain\ndomain-shaped today — this module only covers per-request dispatch + the\nsocket-scoped `ctx.notify` + per-socket `ctx.signal`. See\n`BackendWebsocketTransport.send` for broadcast.\n\n## Auth expectations\n\nThe consumer is responsible for rejecting unauthenticated upgrades *before*\nrouting to this handler (fuz_app's `require_auth` middleware, or\n`register_ws_endpoint` which wires it for you). Inside the dispatcher,\n`get_request_context(c)` is treated as guaranteed non-null and per-action\nauth is enforced on each message.", + "module_comment": "WebSocket JSON-RPC dispatch — the low-level WS transport binding.\n\nMost consumers should mount WS endpoints via `register_ws_endpoint`\n(`actions/register_ws_endpoint.ts`), which wraps this function with the standard\nupgrade stack (origin check + auth + optional role). This module stays\nexported as the lower-level entry point for tests that drive the\ndispatcher directly via `create_ws_test_harness`.\n\nSymmetric to `create_rpc_endpoint` (from `actions/action_rpc.ts`):\nconsumer supplies action specs + a handler map, the dispatcher parses the\nenvelope, checks per-action auth, validates input, invokes the handler with\na per-request context, and writes the response.\n\nExtracted from zzz's `register_websocket_actions` to converge pattern drift\nacross consumers (zzz, tx, undying). Broadcast-style notifications remain\ndomain-shaped today — this module only covers per-request dispatch + the\nsocket-scoped `ctx.notify` + per-socket `ctx.signal`. See\n`BackendWebsocketTransport.send` for broadcast.\n\n## Auth expectations\n\nThe consumer is responsible for rejecting unauthenticated upgrades *before*\nrouting to this handler (fuz_app's `require_auth` middleware, or\n`register_ws_endpoint` which wires it for you). Inside the dispatcher,\n`require_request_context(c)` enforces the dispatcher invariant and\nper-action auth is enforced on each message.", "dependencies": [ "actions/cancel.ts", "actions/transports.ts", @@ -2848,7 +2901,7 @@ "name": "RegisterWsEndpointOptions", "kind": "type", "doc_comment": "Options for `register_ws_endpoint`.", - "source_line": 32, + "source_line": 39, "type_signature": "RegisterWsEndpointOptions", "generic_params": [ { @@ -2864,19 +2917,25 @@ "type_signature": "Array", "doc_comment": "Origin allowlist regexes — typically parsed from the `ALLOWED_ORIGINS`\nenv var via `parse_allowed_origins`. Passed straight to\n`verify_request_source`." }, + { + "name": "db", + "kind": "variable", + "type_signature": "Db", + "doc_comment": "Pool-level database used for upgrade-time actor resolution + permit\nload. Ran once per connection, then the result is reused for every\nmessage on the socket." + }, { "name": "required_role", "kind": "variable", "type_signature": "RoleName", - "doc_comment": "Role required to upgrade. Omit for any authenticated account (`require_auth`\nalone); set to e.g. `ROLE_ADMIN` to gate the endpoint behind a role. The\nper-action `auth` in each spec still applies at dispatch time — this is\na coarse upgrade-time gate." + "doc_comment": "Role required to upgrade. Omit for any authenticated account\n(`require_auth` + actor resolution alone); set to e.g. `ROLE_ADMIN`\nto gate the endpoint behind a role. The per-action `auth` in each\nspec still applies at dispatch time — this is a coarse upgrade-time\ngate." } ] }, { "name": "register_ws_endpoint", "kind": "function", - "doc_comment": "Mount a WebSocket endpoint with the standard upgrade stack (origin check\n+ auth + optional role) and JSON-RPC dispatch.\n\nReturns the `BackendWebsocketTransport` (supplied or freshly\ncreated), same as `register_action_ws` — retain it to wire\n`create_ws_auth_guard` on `on_audit_event` or to broadcast.", - "source_line": 61, + "doc_comment": "Mount a WebSocket endpoint with the standard upgrade stack (origin check\n+ auth + actor resolution + optional role) and JSON-RPC dispatch.\n\nReturns the `BackendWebsocketTransport` (supplied or freshly\ncreated), same as `register_action_ws` — retain it to wire\n`create_ws_auth_guard` on `on_audit_event` or to broadcast.", + "source_line": 94, "type_signature": "(options: RegisterWsEndpointOptions): RegisterActionWsResult", "return_type": "RegisterActionWsResult", "parameters": [ @@ -2887,7 +2946,7 @@ ] } ], - "module_comment": "Composed WebSocket endpoint registration — the idiomatic consumer entry\npoint for mounting a fuz_app WS endpoint.\n\nWraps the standard upgrade stack every consumer writes by hand:\n\n1. `verify_request_source(allowed_origins)` — reject disallowed origins\n before the upgrade handshake runs.\n2. `require_auth` — reject unauthenticated upgrades.\n3. Optional `require_role(required_role)` — for endpoints gated to a\n specific role.\n\nThen delegates to `register_action_ws` for per-message JSON-RPC\ndispatch.", + "module_comment": "Composed WebSocket endpoint registration — the idiomatic consumer entry\npoint for mounting a fuz_app WS endpoint.\n\nWraps the standard upgrade stack every consumer writes by hand:\n\n1. `verify_request_source(allowed_origins)` — reject disallowed origins\n before the upgrade handshake runs.\n2. `require_auth` — reject unauthenticated upgrades.\n3. **Authorization phase** — resolve the acting actor against the\n authenticated account plus an optional `?acting=` query string,\n and build the `RequestContext` that per-message dispatch reads.\n Multi-actor accounts must supply `?acting` to pick a persona;\n single-actor accounts work without it.\n4. Optional `require_role(required_role)` — for endpoints gated to a\n specific role.\n\nThen delegates to `register_action_ws` for per-message JSON-RPC\ndispatch.", "dependencies": [ "actions/register_action_ws.ts", "auth/request_context.ts", @@ -4517,7 +4576,7 @@ "name": "AccountActionOptions", "kind": "type", "doc_comment": "Options for `create_account_actions`.", - "source_line": 66, + "source_line": 67, "type_signature": "AccountActionOptions", "properties": [ { @@ -4531,22 +4590,22 @@ { "name": "AccountActionDeps", "kind": "type", - "doc_comment": "Dependencies for `create_account_actions`.\n\nShares shape with `AdminActionDeps` / `PermitOfferActionDeps` so consumers\ncan pass the same deps to every action factory. `audit_log_config` is\ncarried through `AppDeps` and consumed by `audit_log_fire_and_forget`;\nabsent → defaults to `BUILTIN_AUDIT_LOG_CONFIG`.", - "source_line": 84, - "type_signature": "AccountActionDeps" + "doc_comment": "Dependencies for `create_account_actions`.\n\nAliases the shared `AuditEmitDeps` (the `log` / `on_audit_event` /\noptional `audit_log_config` slice every audit-emitting site picks).\n`audit_log_config` is consumed by `audit_log_fire_and_forget`; absent →\ndefaults to `BUILTIN_AUDIT_LOG_CONFIG`.", + "source_line": 85, + "type_signature": "AuditEmitDeps" }, { "name": "create_account_actions", "kind": "function", "doc_comment": "Create the self-service account RPC actions.", - "source_line": 96, - "type_signature": "(deps: AccountActionDeps, options?: AccountActionOptions): RpcAction[]", + "source_line": 94, + "type_signature": "(deps: AuditEmitDeps, options?: AccountActionOptions): RpcAction[]", "return_type": "RpcAction[]", "return_description": "the `RpcAction` array to spread into a `create_rpc_endpoint` call", "parameters": [ { "name": "deps", - "type": "AccountActionDeps", + "type": "AuditEmitDeps", "description": "`AccountActionDeps` slice of `AppDeps` (`log`, `on_audit_event`, optional `audit_log_config`)" }, { @@ -4567,6 +4626,7 @@ "auth/api_token.ts", "auth/api_token_queries.ts", "auth/audit_log_queries.ts", + "auth/request_context.ts", "auth/session_queries.ts" ], "dependents": ["auth/standard_rpc_actions.ts"] @@ -4755,12 +4815,12 @@ ] }, { - "name": "query_actor_by_account", + "name": "query_actors_by_account", "kind": "function", - "doc_comment": "Find the actor for an account.\n\nFor v1, each account has exactly one actor.", - "source_line": 167, - "type_signature": "(deps: QueryDeps, account_id: string): Promise", - "return_type": "Promise", + "doc_comment": "List every actor on an account, ordered by `created_at`.\n\nUsed by `resolve_acting_actor` to resolve the acting actor for a\nrequest: 1 actor picks transparently, multiple require an explicit\n`acting` field on the request payload. For lookups by id, use\n`query_actor_by_id` instead.", + "source_line": 170, + "type_signature": "(deps: QueryDeps, account_id: string): Promise", + "return_type": "Promise", "parameters": [ { "name": "deps", @@ -4776,7 +4836,7 @@ "name": "query_actor_by_id", "kind": "function", "doc_comment": "Find an actor by id.", - "source_line": 177, + "source_line": 183, "type_signature": "(deps: QueryDeps, id: string): Promise", "return_type": "Promise", "parameters": [ @@ -4794,7 +4854,7 @@ "name": "query_create_account_with_actor", "kind": "function", "doc_comment": "Create an account and its actor in a single operation.\n\nFor v1, every account gets exactly one actor with the same name as the username.", - "source_line": 194, + "source_line": 200, "type_signature": "(deps: QueryDeps, input: CreateAccountInput): Promise<{ account: Account; actor: Actor; }>", "return_type": "Promise<{ account: Account; actor: Actor; }>", "return_description": "the created account and actor", @@ -4815,7 +4875,7 @@ "name": "query_admin_account_list", "kind": "function", "doc_comment": "List all accounts with their actors, active permits, and pending inbound\npermit offers for admin display.\n\nUses 4 flat queries instead of N+1 per-account loops. Pending offers surface\nthe \"offer pending — awaiting acceptance\" UX without a second round-trip;\n`message` is intentionally excluded (cross-admin visibility of grantor notes\nwould expand beyond what the audit log discloses).", - "source_line": 238, + "source_line": 244, "type_signature": "(deps: QueryDeps): Promise<{ account: { id: string & $brand<\"Uuid\">; username: string; email: string | null; email_verified: boolean; created_at: string; updated_at: string; updated_by: (string & $brand<...>) | null; }; actor: { ...; } | null; permits: { ...; }[]; pending_offers: { ...; }[]; }[]>", "return_type": "Promise<{ account: { id: string & $brand<\"Uuid\">; username: string; email: string | null; email_verified: boolean; created_at: string; updated_at: string; updated_by: (string & $brand<\"Uuid\">) | null; }; actor: { ...; } | null; permits: { ...; }[]; pending_offers: { ...; }[]; }[]>", "return_description": "admin account entries sorted by creation date", @@ -4835,13 +4895,10 @@ "auth/admin_actions.ts", "auth/bootstrap_account.ts", "auth/permit_offer_actions.ts", - "auth/permit_offer_queries.ts", "auth/request_context.ts", "auth/signup_routes.ts", "dev/setup.ts", - "testing/admin_integration.ts", "testing/app_server.ts", - "testing/audit_completeness.ts", "testing/middleware.ts" ] }, @@ -4852,28 +4909,28 @@ "name": "AccountStatusInput", "kind": "type", "doc_comment": "Input for `GET /api/account/status`. No parameters — caller is the subject.", - "source_line": 62, + "source_line": 69, "type_signature": "ZodNull" }, { "name": "AccountStatusOutput", "kind": "type", - "doc_comment": "Output for `GET /api/account/status` on the authenticated path.\n\n`account` and `actor` are the caller's own identity entities (v1 is 1:1\naccount/actor, but `actor` is first-class so consumers don't have to\nderive `actor_id` from the permit list). Permits are already\nactive-filtered by `build_request_context` via\n`query_permit_find_active_for_actor` — `revoked_at` / `revoked_by` /\n`revoked_reason` are never populated here, so `PermitSummaryJson`\ncarries the fields a client actually needs (including `scope_id` for\nper-scope auth decisions).", - "source_line": 77, - "type_signature": "ZodObject<{ account: ZodObject<{ id: $ZodBranded; username: ZodString; email: ZodNullable; email_verified: ZodBoolean; created_at: ZodString; }, $strict>; actor: ZodObject<...>; permits: ZodArray<...>; }, $strict>" + "doc_comment": "Output for `GET /api/account/status` on the authenticated path.\n\n`account` is always populated for authenticated callers. `actor` and\n`permits` are populated when the caller's account has a unique actor or\nthe request supplies `?acting=`; on multi-actor accounts\nwithout an `acting` query, `actor` is `null` and `permits` is empty so\nthe frontend can show a persona picker without a separate roundtrip.", + "source_line": 81, + "type_signature": "ZodObject<{ account: ZodObject<{ id: $ZodBranded; username: ZodString; email: ZodNullable; email_verified: ZodBoolean; created_at: ZodString; }, $strict>; actor: ZodNullable<...>; permits: ZodArray<...>; }, $strict>" }, { "name": "AccountStatusUnauthenticatedError", "kind": "type", "doc_comment": "Error body for `GET /api/account/status` on the unauthenticated path.", - "source_line": 85, + "source_line": 89, "type_signature": "ZodObject<{ error: ZodLiteral<\"authentication_required\">; bootstrap_available: ZodOptional; }, $loose>" }, { "name": "create_account_status_route_spec", "kind": "function", "doc_comment": "Create the account status route spec.\n\nHandles both authenticated and unauthenticated requests:\n- Authenticated: returns `{account}` with 200\n- Unauthenticated: returns 401 with optional `bootstrap_available` flag\n\nThis eliminates the need for a separate `/health` fetch on page load —\nthe frontend gets both session state and bootstrap availability in one request.", - "source_line": 104, + "source_line": 108, "type_signature": "(options?: AccountStatusOptions | undefined): RouteSpec", "return_type": "RouteSpec", "return_description": "a single account status route spec", @@ -4890,7 +4947,7 @@ "name": "AccountStatusOptions", "kind": "type", "doc_comment": "Options for the account status route spec.", - "source_line": 142, + "source_line": 192, "type_signature": "AccountStatusOptions", "properties": [ { @@ -4911,35 +4968,35 @@ "name": "DEFAULT_MAX_SESSIONS", "kind": "variable", "doc_comment": "Default maximum sessions per account.", - "source_line": 150, + "source_line": 200, "type_signature": "5" }, { "name": "DEFAULT_MAX_TOKENS", "kind": "variable", "doc_comment": "Default maximum API tokens per account.", - "source_line": 153, + "source_line": 203, "type_signature": "10" }, { "name": "DEFAULT_LOGIN_FAIL_FLOOR_MS", "kind": "variable", "doc_comment": "Default minimum wall-clock time (ms) for a login failure (401) response.\n\nPicked to exceed the p99 of every 401 code path (Argon2id dominates at\n~100ms, plus DB + overhead). The handler races failure work against\n`sleep(floor + jitter)` via `await`, so observed response time = max(work,\ndelay). Found-vs-not-found and rate-limit-skipped-vs-not paths converge.\nOnly 401 is padded — 429 stays fast by design to keep rate-limit DoS\nhandling cheap.", - "source_line": 165, + "source_line": 215, "type_signature": "250" }, { "name": "DEFAULT_LOGIN_FAIL_JITTER_MS", "kind": "variable", "doc_comment": "Default uniform jitter window (±ms) layered on the floor.\n\nRandom jitter prevents a stable clamp point from leaking whenever a path\noccasionally exceeds the floor. `Math.random` is sufficient — we only need\nunpredictability of the exact delay, not cryptographic guarantees.", - "source_line": 174, + "source_line": 224, "type_signature": "25" }, { "name": "AuthSessionRouteOptions", "kind": "type", "doc_comment": "Shared options for route factories that create sessions and rate-limit by IP.\n\nExtended by `AccountRouteOptions` and `SignupRouteOptions`.\nConsumers can destructure these from `AppServerContext` once and spread into multiple factories.", - "source_line": 188, + "source_line": 238, "type_signature": "AuthSessionRouteOptions", "properties": [ { @@ -4959,7 +5016,7 @@ "name": "AccountRouteOptions", "kind": "type", "doc_comment": "Per-factory configuration for account route specs.", - "source_line": 197, + "source_line": 247, "type_signature": "AccountRouteOptions", "extends": ["AuthSessionRouteOptions"], "properties": [ @@ -4993,49 +5050,49 @@ "name": "LoginInput", "kind": "type", "doc_comment": "Input for `POST /login`. Accepts a username or email in the `username` field.", - "source_line": 218, + "source_line": 268, "type_signature": "ZodObject<{ username: ZodString; password: ZodString; }, $strict>" }, { "name": "LoginOutput", "kind": "type", "doc_comment": "Output for `POST /login`. The signed session cookie is the operative side effect.", - "source_line": 225, + "source_line": 275, "type_signature": "ZodObject<{ ok: ZodLiteral; }, $strict>" }, { "name": "LogoutInput", "kind": "type", "doc_comment": "Input for `POST /logout`. Session identity flows through the cookie.", - "source_line": 231, + "source_line": 281, "type_signature": "ZodNull" }, { "name": "LogoutOutput", "kind": "type", "doc_comment": "Output for `POST /logout`. Includes the revoked account's username for UI redraw.", - "source_line": 235, + "source_line": 285, "type_signature": "ZodObject<{ ok: ZodLiteral; username: ZodString; }, $strict>" }, { "name": "PasswordChangeInput", "kind": "type", "doc_comment": "Input for `POST /password`. `current_password` is minimally validated; `new_password` enforces the full policy.", - "source_line": 242, + "source_line": 292, "type_signature": "ZodObject<{ current_password: ZodString; new_password: ZodString; }, $strict>" }, { "name": "PasswordChangeOutput", "kind": "type", "doc_comment": "Output for `POST /password`. Counts are returned so the UI can summarize the revoke-all cascade.", - "source_line": 249, + "source_line": 299, "type_signature": "ZodObject<{ ok: ZodLiteral; sessions_revoked: ZodNumber; tokens_revoked: ZodNumber; }, $strict>" }, { "name": "create_account_route_specs", "kind": "function", "doc_comment": "Create account route specs for session-based auth.\n\nThe returned specs cover the three flows that stay REST after the RPC\nmigration (login, logout, password change). Self-service session/token\nmanagement and verify are on `auth/account_actions.ts`.", - "source_line": 267, + "source_line": 317, "type_signature": "(deps: RouteFactoryDeps, options: AccountRouteOptions): RouteSpec[]", "return_type": "RouteSpec[]", "return_description": "route specs (not yet applied to Hono)", @@ -5064,6 +5121,7 @@ "auth/session_lifecycle.ts", "auth/session_middleware.ts", "auth/session_queries.ts", + "hono_context.ts", "http/error_schemas.ts", "http/proxy.ts", "http/route_spec.ts", @@ -5116,11 +5174,18 @@ "source_line": 40, "type_signature": "ZodEmail" }, + { + "name": "ActingActor", + "kind": "type", + "doc_comment": "`acting` field shared by every action input that needs the caller's\nacting actor. Declaring `acting: ActingActor` on an action's input\nis the signal to the RPC dispatcher / route-spec wrapper to resolve\nan actor against the authenticated account: the authorization phase\nruns `resolve_acting_actor`, builds the actor-bound `RequestContext`,\nand loads permits before auth guards fire.\n\nResolution rules: omitted + 1 actor → use it; omitted + multiple\nactors → `actor_required` with the available list; supplied + on\nthe account → use it; supplied + foreign actor → `actor_not_on_account`.\n\nAccount-grain routes — input doesn't declare `acting` and auth\ndoesn't require permits (`role` / `keeper`) — skip resolution\nentirely; their `RequestContext.actor` is `null` and the audit\nenvelope's `actor_id` stays null.", + "source_line": 60, + "type_signature": "ZodOptional<$ZodBranded>" + }, { "name": "Account", "kind": "type", "doc_comment": "Account — authentication identity. You log in as an account.", - "source_line": 46, + "source_line": 69, "type_signature": "Account", "properties": [ { @@ -5174,7 +5239,7 @@ "name": "SessionAccount", "kind": "type", "doc_comment": "Account without sensitive fields, scoped to the authenticated user's own session.", - "source_line": 59, + "source_line": 82, "type_signature": "SessionAccount", "properties": [ { @@ -5208,7 +5273,7 @@ "name": "Actor", "kind": "type", "doc_comment": "Actor — the entity that acts. Owns cells, holds permits, appears in audit trails.", - "source_line": 68, + "source_line": 91, "type_signature": "Actor", "properties": [ { @@ -5247,14 +5312,14 @@ "name": "PERMIT_REVOKED_REASON_LENGTH_MAX", "kind": "variable", "doc_comment": "Maximum length of the optional free-form `revoked_reason` attached to a\nrevoked permit. Bounds the value at the schema layer so both the admin\ninput (when the route surfaces a reason field) and the revokee-facing\n`permit_revoke` WS notification validate against the same ceiling.", - "source_line": 83, + "source_line": 106, "type_signature": "500" }, { "name": "Permit", "kind": "type", "doc_comment": "Permit — time-bounded, revocable grant of a role to an actor.", - "source_line": 86, + "source_line": 109, "type_signature": "Permit", "properties": [ { @@ -5320,7 +5385,7 @@ { "name": "is_permit_active", "kind": "function", - "source_line": 103, + "source_line": 126, "type_signature": "(p: { revoked_at?: string | null | undefined; expires_at: string | null; }, now?: Date): boolean", "return_type": "boolean", "parameters": [ @@ -5339,7 +5404,7 @@ "name": "AuthSession", "kind": "type", "doc_comment": "Server-side auth session, keyed by blake3 hash of session token.", - "source_line": 109, + "source_line": 132, "type_signature": "AuthSession", "properties": [ { @@ -5373,7 +5438,7 @@ "name": "ApiToken", "kind": "type", "doc_comment": "API token for CLI/programmatic access.", - "source_line": 118, + "source_line": 141, "type_signature": "ApiToken", "properties": [ { @@ -5422,62 +5487,62 @@ "name": "SessionAccountJson", "kind": "type", "doc_comment": "Zod schema for `SessionAccount` — account without sensitive fields.", - "source_line": 132, + "source_line": 155, "type_signature": "ZodObject<{ id: $ZodBranded; username: ZodString; email: ZodNullable; email_verified: ZodBoolean; created_at: ZodString; }, $strict>" }, { "name": "AuthSessionJson", "kind": "type", "doc_comment": "Zod schema for `AuthSession` — id is the blake3 hash, safe for client.", - "source_line": 142, + "source_line": 165, "type_signature": "ZodObject<{ id: ZodString; account_id: $ZodBranded; created_at: ZodString; expires_at: ZodString; last_seen_at: ZodString; }, $strict>" }, { "name": "ClientApiTokenJson", "kind": "type", "doc_comment": "Zod schema for client-safe API token listing (excludes `token_hash`).", - "source_line": 152, + "source_line": 175, "type_signature": "ZodObject<{ id: ZodString; account_id: $ZodBranded; name: ZodString; expires_at: ZodNullable; last_used_at: ZodNullable<...>; last_used_ip: ZodNullable<...>; created_at: ZodString; }, $strict>" }, { "name": "PermitSummaryJson", "kind": "type", "doc_comment": "Zod schema for the permit summary returned in admin account listings.", - "source_line": 164, + "source_line": 187, "type_signature": "ZodObject<{ id: $ZodBranded; role: ZodString; scope_id: ZodNullable<$ZodBranded>; created_at: ZodString; expires_at: ZodNullable<...>; granted_by: ZodNullable<...>; }, $strict>" }, { "name": "ActorSummaryJson", "kind": "type", "doc_comment": "Zod schema for the actor summary returned in admin account listings.", - "source_line": 175, + "source_line": 198, "type_signature": "ZodObject<{ id: $ZodBranded; name: ZodString; }, $strict>" }, { "name": "AdminAccountJson", "kind": "type", "doc_comment": "Zod schema for admin-facing account data — extends `SessionAccountJson` with audit fields.", - "source_line": 182, + "source_line": 205, "type_signature": "ZodObject<{ id: $ZodBranded; username: ZodString; email: ZodNullable; email_verified: ZodBoolean; created_at: ZodString; updated_at: ZodString; updated_by: ZodNullable<...>; }, $strict>" }, { "name": "PendingOfferSummaryJson", "kind": "type", "doc_comment": "Zod schema for a pending permit offer surfaced in admin account listings.\n\nDeliberately narrower than `PermitOfferJson`: omits `message` and\n`decline_reason` so cross-admin visibility of the listing does not expose\ngrantor-authored text that the audit log also withholds. Full offer\npayloads remain available through the offer-specific RPC surface and the\naudit log when admins need them.\n\n`from_username` is resolved server-side so multi-admin deployments can see\nat a glance whose pending offer is blocking a \"+ {role}\" button; the\nresolution runs inside the listing query's parallel batch.", - "source_line": 201, + "source_line": 224, "type_signature": "ZodObject<{ id: $ZodBranded; role: ZodString; scope_id: ZodNullable<$ZodBranded>; from_actor_id: $ZodBranded<...>; from_username: ZodString; created_at: ZodString; expires_at: ZodString; }, $strict>" }, { "name": "AdminAccountEntryJson", "kind": "type", "doc_comment": "Zod schema for an admin account listing entry (account + actor + permits + pending offers).", - "source_line": 213, + "source_line": 236, "type_signature": "ZodObject<{ account: ZodObject<{ id: $ZodBranded; username: ZodString; email: ZodNullable; email_verified: ZodBoolean; created_at: ZodString; updated_at: ZodString; updated_by: ZodNullable<...>; }, $strict>; actor: ZodNullable<...>; permits: ZodArray<...>; pending_offers: ZodArray<...." }, { "name": "CreateAccountInput", "kind": "type", - "source_line": 223, + "source_line": 246, "type_signature": "CreateAccountInput", "properties": [ { @@ -5500,7 +5565,7 @@ { "name": "GrantPermitInput", "kind": "type", - "source_line": 229, + "source_line": 252, "type_signature": "GrantPermitInput", "properties": [ { @@ -5541,7 +5606,7 @@ "name": "to_session_account", "kind": "function", "doc_comment": "Convert an `Account` to a `SessionAccount` by stripping sensitive fields.", - "source_line": 246, + "source_line": 269, "type_signature": "(account: Account): SessionAccount", "return_type": "SessionAccount", "return_description": "the client-safe account", @@ -5557,7 +5622,7 @@ "name": "to_admin_account", "kind": "function", "doc_comment": "Convert an `Account` to an `AdminAccountJson` for admin listings.", - "source_line": 260, + "source_line": 283, "type_signature": "(account: Account): { id: string & $brand<\"Uuid\">; username: string; email: string | null; email_verified: boolean; created_at: string; updated_at: string; updated_by: (string & $brand<...>) | null; }", "return_type": "{ id: string & $brand<\"Uuid\">; username: string; email: string | null; email_verified: boolean; created_at: string; updated_at: string; updated_by: (string & $brand<\"Uuid\">) | null; }", "return_description": "the admin-safe account with audit fields", @@ -5583,6 +5648,7 @@ "auth/permit_offer_action_specs.ts", "auth/permit_offer_notifications.ts", "auth/request_context.ts", + "auth/self_service_role_action_specs.ts", "auth/self_service_role_actions.ts", "auth/signup_routes.ts", "ui/BootstrapForm.svelte", @@ -5603,228 +5669,228 @@ { "name": "AdminAccountListInput", "kind": "type", - "doc_comment": "Input for `admin_account_list`. No parameters — the caller is the subject.", + "doc_comment": "Input for `admin_account_list`.", "source_line": 42, - "type_signature": "ZodVoid" + "type_signature": "ZodObject<{ acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "AdminAccountListOutput", "kind": "type", "doc_comment": "Output for `admin_account_list`.", - "source_line": 46, + "source_line": 48, "type_signature": "ZodObject<{ accounts: ZodArray; username: ZodString; email: ZodNullable; email_verified: ZodBoolean; created_at: ZodString; updated_at: ZodString; updated_by: ZodNullable<...>; }, $strict>; actor: ZodNullable<...>; permits: ZodArray<...." }, { "name": "AdminSessionListInput", "kind": "type", - "doc_comment": "Input for `admin_session_list`. No parameters — reads every active session.", - "source_line": 53, - "type_signature": "ZodVoid" + "doc_comment": "Input for `admin_session_list`.", + "source_line": 55, + "type_signature": "ZodObject<{ acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "AdminSessionListOutput", "kind": "type", "doc_comment": "Output for `admin_session_list`. Cross-account listing; fan-out already scoped by role auth.", - "source_line": 57, + "source_line": 61, "type_signature": "ZodObject<{ sessions: ZodArray; created_at: ZodString; expires_at: ZodString; last_seen_at: ZodString; username: ZodString; }, $strict>>; }, $strict>" }, { "name": "AdminSessionRevokeAllInput", "kind": "type", "doc_comment": "Input for `admin_session_revoke_all`.", - "source_line": 63, - "type_signature": "ZodObject<{ account_id: $ZodBranded; }, $strict>" + "source_line": 67, + "type_signature": "ZodObject<{ account_id: $ZodBranded; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "AdminSessionRevokeAllOutput", "kind": "type", "doc_comment": "Output for `admin_session_revoke_all`.", - "source_line": 69, + "source_line": 74, "type_signature": "ZodObject<{ ok: ZodLiteral; count: ZodNumber; }, $strict>" }, { "name": "AdminTokenRevokeAllInput", "kind": "type", "doc_comment": "Input for `admin_token_revoke_all`.", - "source_line": 76, - "type_signature": "ZodObject<{ account_id: $ZodBranded; }, $strict>" + "source_line": 81, + "type_signature": "ZodObject<{ account_id: $ZodBranded; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "AdminTokenRevokeAllOutput", "kind": "type", "doc_comment": "Output for `admin_token_revoke_all`.", - "source_line": 82, + "source_line": 88, "type_signature": "ZodObject<{ ok: ZodLiteral; count: ZodNumber; }, $strict>" }, { "name": "AuditLogListInput", "kind": "type", "doc_comment": "Input for `audit_log_list`. All filter fields are optional — omit for the\ndefault newest-first page. `since_seq` exists for SSE reconnection gap\nfill (caller supplies the highest seq seen; server returns everything\nafter).", - "source_line": 94, - "type_signature": "ZodObject<{ event_type: ZodOptional>; outcome: ZodOptional>>; account_id: ZodOptional<...>; limit: ZodOptional<...>; offset: ZodOptional<...>; since_seq: ZodOptional<...>; }, $strict>" + "source_line": 100, + "type_signature": "ZodObject<{ event_type: ZodOptional>; outcome: ZodOptional>>; ... 4 more ...; acting: ZodOptional<...>; }, $strict>" }, { "name": "AuditLogListOutput", "kind": "type", "doc_comment": "Output for `audit_log_list`.", - "source_line": 120, - "type_signature": "ZodObject<{ events: ZodArray; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 7 more ...; target_username: ZodNullable<...>; }, $strict>>; }, $strict>" + "source_line": 127, + "type_signature": "ZodObject<{ events: ZodArray; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 8 more ...; target_username: ZodNullable<...>; }, $strict>>; }, $strict>" }, { "name": "AuditLogPermitHistoryInput", "kind": "type", "doc_comment": "Input for `audit_log_permit_history`.", - "source_line": 126, - "type_signature": "ZodObject<{ limit: ZodOptional>; offset: ZodOptional>; }, $strict>" + "source_line": 133, + "type_signature": "ZodObject<{ limit: ZodOptional>; offset: ZodOptional>; acting: ZodOptional<...>; }, $strict>" }, { "name": "AuditLogPermitHistoryOutput", "kind": "type", "doc_comment": "Output for `audit_log_permit_history`.", - "source_line": 141, - "type_signature": "ZodObject<{ events: ZodArray; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 7 more ...; target_username: ZodNullable<...>; }, $strict>>; }, $strict>" + "source_line": 149, + "type_signature": "ZodObject<{ events: ZodArray; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 8 more ...; target_username: ZodNullable<...>; }, $strict>>; }, $strict>" }, { "name": "InviteCreateInput", "kind": "type", "doc_comment": "Input for `invite_create`. At least one of `email` / `username` must be provided.", - "source_line": 147, - "type_signature": "ZodObject<{ email: ZodOptional>; username: ZodOptional>; }, $strict>" + "source_line": 155, + "type_signature": "ZodObject<{ email: ZodOptional>; username: ZodOptional>; acting: ZodOptional<...>; }, $strict>" }, { "name": "InviteCreateOutput", "kind": "type", "doc_comment": "Output for `invite_create`.", - "source_line": 154, + "source_line": 163, "type_signature": "ZodObject<{ ok: ZodLiteral; invite: ZodObject<{ id: $ZodBranded; email: ZodNullable; username: ZodNullable<...>; claimed_by: ZodNullable<...>; claimed_at: ZodNullable<...>; created_at: ZodString; created_by: ZodNullable<...>; }, $strict>; }, $strict>" }, { "name": "InviteListInput", "kind": "type", "doc_comment": "Input for `invite_list`.", - "source_line": 161, - "type_signature": "ZodVoid" + "source_line": 170, + "type_signature": "ZodObject<{ acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "InviteListOutput", "kind": "type", "doc_comment": "Output for `invite_list`. Uses the enriched row including creator/claimer usernames.", - "source_line": 165, + "source_line": 176, "type_signature": "ZodObject<{ invites: ZodArray; email: ZodNullable; username: ZodNullable; ... 5 more ...; claimed_by_username: ZodNullable<...>; }, $strict>>; }, $strict>" }, { "name": "InviteDeleteInput", "kind": "type", "doc_comment": "Input for `invite_delete`.", - "source_line": 171, - "type_signature": "ZodObject<{ invite_id: $ZodBranded; }, $strict>" + "source_line": 182, + "type_signature": "ZodObject<{ invite_id: $ZodBranded; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "InviteDeleteOutput", "kind": "type", "doc_comment": "Output for `invite_delete`.", - "source_line": 177, + "source_line": 189, "type_signature": "ZodObject<{ ok: ZodLiteral; }, $strict>" }, { "name": "AppSettingsGetInput", "kind": "type", - "doc_comment": "Input for `app_settings_get`. No parameters.", - "source_line": 183, - "type_signature": "ZodVoid" + "doc_comment": "Input for `app_settings_get`.", + "source_line": 195, + "type_signature": "ZodObject<{ acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "AppSettingsGetOutput", "kind": "type", "doc_comment": "Output for `app_settings_get`.", - "source_line": 187, + "source_line": 201, "type_signature": "ZodObject<{ settings: ZodObject<{ open_signup: ZodBoolean; updated_at: ZodNullable; updated_by: ZodNullable<$ZodBranded>; updated_by_username: ZodNullable<...>; }, $strict>; }, $strict>" }, { "name": "AppSettingsUpdateInput", "kind": "type", "doc_comment": "Input for `app_settings_update`.", - "source_line": 193, - "type_signature": "ZodObject<{ open_signup: ZodBoolean; }, $strict>" + "source_line": 207, + "type_signature": "ZodObject<{ open_signup: ZodBoolean; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "AppSettingsUpdateOutput", "kind": "type", "doc_comment": "Output for `app_settings_update`.", - "source_line": 199, + "source_line": 214, "type_signature": "ZodObject<{ ok: ZodLiteral; settings: ZodObject<{ open_signup: ZodBoolean; updated_at: ZodNullable; updated_by: ZodNullable<$ZodBranded>; updated_by_username: ZodNullable<...>; }, $strict>; }, $strict>" }, { "name": "admin_account_list_action_spec", "kind": "variable", - "source_line": 207, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodVoid; output: ZodObject<{ accounts: ZodArray; ... 5 more ...; updated_by: ZodNullable<...>; }, $strict>; actor: ZodNullab..." + "source_line": 222, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodObject<{ acting: ZodOptional<$ZodBranded>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" }, { "name": "admin_session_list_action_spec", "kind": "variable", - "source_line": 219, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodVoid; output: ZodObject<{ sessions: ZodArray; created_at: ZodString; expires_at: ZodString; last_seen_at: ZodString; username: ZodString; ..." + "source_line": 234, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodObject<{ acting: ZodOptional<$ZodBranded>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" }, { "name": "admin_session_revoke_all_action_spec", "kind": "variable", - "source_line": 231, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ account_id: $ZodBranded; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" + "source_line": 246, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ account_id: $ZodBranded; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" }, { "name": "admin_token_revoke_all_action_spec", "kind": "variable", - "source_line": 244, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ account_id: $ZodBranded; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" + "source_line": 259, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ account_id: $ZodBranded; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" }, { "name": "audit_log_list_action_spec", "kind": "variable", - "source_line": 257, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodObject<{ event_type: ZodOptional>; ... 4 more ...; since_seq: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" + "source_line": 272, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodObject<{ event_type: ZodOptional>; ... 5 more ...; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" }, { "name": "audit_log_permit_history_action_spec", "kind": "variable", - "source_line": 269, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodObject<{ limit: ZodOptional>; offset: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" + "source_line": 284, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodObject<{ limit: ZodOptional>; offset: ZodOptional<...>; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" }, { "name": "invite_create_action_spec", "kind": "variable", - "source_line": 281, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ email: ZodOptional>; username: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" + "source_line": 296, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ email: ZodOptional>; username: ZodOptional<...>; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"accou..." }, { "name": "invite_list_action_spec", "kind": "variable", - "source_line": 294, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodVoid; output: ZodObject<{ invites: ZodArray; ... 7 more ...; claimed_by_username: ZodNullable<...>; }, $strict>>; }, $strict>; async: true; de..." + "source_line": 309, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodObject<{ acting: ZodOptional<$ZodBranded>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" }, { "name": "invite_delete_action_spec", "kind": "variable", - "source_line": 306, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ invite_id: $ZodBranded; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" + "source_line": 321, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ invite_id: $ZodBranded; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" }, { "name": "app_settings_get_action_spec", "kind": "variable", - "source_line": 319, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodVoid; output: ZodObject<{ settings: ZodObject<{ open_signup: ZodBoolean; updated_at: ZodNullable<...>; updated_by: ZodNullable<...>; updated_by_username: ZodNullable<...>; }, $strict>; }, $stric..." + "source_line": 334, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: false; input: ZodObject<{ acting: ZodOptional<$ZodBranded>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" }, { "name": "app_settings_update_action_spec", "kind": "variable", - "source_line": 331, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ open_signup: ZodBoolean; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" + "source_line": 346, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ open_signup: ZodBoolean; acting: ZodOptional<$ZodBranded>; }, $strict>; output: ZodObject<...>; async: true; description: string; rate_limit: \"account\"; }" }, { "name": "all_admin_action_specs", "kind": "variable", "doc_comment": "All admin action specs — a codegen-ready registry. Consumers spread this\ninto their own action-spec array to include admin methods in a typed\nclient surface. Always includes the two app-settings specs; the runtime\nfactory only wires their handlers when `AdminActionOptions.app_settings`\nis provided.", - "source_line": 351, + "source_line": 366, "type_signature": "{ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; }[]" } ], @@ -5871,22 +5937,22 @@ { "name": "AdminActionDeps", "kind": "type", - "doc_comment": "Dependencies for `create_admin_actions`.\n\nShares shape with `PermitOfferActionDeps` so consumers can pass the same\ndeps to both factories. `log` drives RPC-internal error logging;\n`on_audit_event` is wired by the two revoke-all mutations so SSE fan-out\nmirrors the former REST-route behavior. `audit_log_config` flows from\n`AppDeps` and is consumed by `audit_log_fire_and_forget`.", - "source_line": 135, - "type_signature": "AdminActionDeps" + "doc_comment": "Dependencies for `create_admin_actions`.\n\nAliases the shared `AuditEmitDeps` (the `log` / `on_audit_event` /\noptional `audit_log_config` slice every audit-emitting site picks).\n`log` drives RPC-internal error logging; `on_audit_event` is wired by\nthe two revoke-all mutations so SSE fan-out mirrors the former\nREST-route behavior; `audit_log_config` is consumed by\n`audit_log_fire_and_forget`.", + "source_line": 136, + "type_signature": "AuditEmitDeps" }, { "name": "create_admin_actions", "kind": "function", "doc_comment": "Create the admin-only RPC actions.", - "source_line": 145, - "type_signature": "(deps: AdminActionDeps, options?: AdminActionOptions): RpcAction[]", + "source_line": 146, + "type_signature": "(deps: AuditEmitDeps, options?: AdminActionOptions): RpcAction[]", "return_type": "RpcAction[]", "return_description": "the `RpcAction` array to spread into a `create_rpc_endpoint` call", "parameters": [ { "name": "deps", - "type": "AdminActionDeps", + "type": "AuditEmitDeps", "description": "`AdminActionDeps` slice of `AppDeps` (`log`, `on_audit_event`, optional `audit_log_config`)" }, { @@ -6167,6 +6233,7 @@ "auth/account_action_specs.ts", "auth/account_actions.ts", "auth/api_token_queries.ts", + "auth/audit_log_schema.ts", "testing/app_server.ts" ] }, @@ -6312,7 +6379,7 @@ "name": "get_audit_metadata_validation_failures", "kind": "function", "doc_comment": "Number of audit metadata validation failures observed since process start.", - "source_line": 42, + "source_line": 44, "type_signature": "(): number", "return_type": "number", "parameters": [] @@ -6321,7 +6388,7 @@ "name": "reset_audit_metadata_validation_failures", "kind": "function", "doc_comment": "Reset the counter — for tests only.", - "source_line": 46, + "source_line": 48, "type_signature": "(): void", "return_type": "void", "parameters": [] @@ -6330,7 +6397,7 @@ "name": "get_audit_unknown_event_type_failures", "kind": "function", "doc_comment": "Number of audit unknown-event-type failures observed since process start.", - "source_line": 61, + "source_line": 63, "type_signature": "(): number", "return_type": "number", "parameters": [] @@ -6339,7 +6406,7 @@ "name": "reset_audit_unknown_event_type_failures", "kind": "function", "doc_comment": "Reset the counter — for tests only.", - "source_line": 65, + "source_line": 67, "type_signature": "(): void", "return_type": "void", "parameters": [] @@ -6348,7 +6415,7 @@ "name": "query_audit_log", "kind": "function", "doc_comment": "Insert an audit log entry.\n\n`RETURNING *` so callers receive DB-assigned fields (`id`, `seq`,\n`created_at`). Validates `metadata` against `config.metadata_schemas`;\nunknown `event_type` and metadata mismatches log + bump their counters\nbut write the row anyway. Consumers extend the recognized set via\n`create_audit_log_config({extra_events})`.", - "source_line": 85, + "source_line": 87, "type_signature": "(deps: QueryDeps, input: AuditLogInput, config?: AuditLogConfig): Promise", "return_type": "Promise", "return_description": "the inserted audit log row", @@ -6375,7 +6442,7 @@ "name": "query_audit_log_list", "kind": "function", "doc_comment": "List audit log entries, newest first.", - "source_line": 133, + "source_line": 136, "type_signature": "(deps: QueryDeps, options?: AuditLogListOptions | undefined): Promise", "return_type": "Promise", "return_description": "matching audit log entries", @@ -6397,9 +6464,9 @@ "name": "query_audit_log_list_with_usernames", "kind": "function", "doc_comment": "List audit log entries with resolved usernames, newest first.", - "source_line": 185, - "type_signature": "(deps: QueryDeps, options?: AuditLogListOptions | undefined): Promise<{ id: string & $brand<\"Uuid\">; seq: number; event_type: string; outcome: \"success\" | \"failure\"; ... 7 more ...; target_username: string | null; }[]>", - "return_type": "Promise<{ id: string & $brand<\"Uuid\">; seq: number; event_type: string; outcome: \"success\" | \"failure\"; actor_id: (string & $brand<\"Uuid\">) | null; account_id: (string & $brand<...>) | null; ... 5 more ...; target_username: string | null; }[]>", + "source_line": 188, + "type_signature": "(deps: QueryDeps, options?: AuditLogListOptions | undefined): Promise<{ id: string & $brand<\"Uuid\">; seq: number; event_type: string; outcome: \"success\" | \"failure\"; ... 8 more ...; target_username: string | null; }[]>", + "return_type": "Promise<{ id: string & $brand<\"Uuid\">; seq: number; event_type: string; outcome: \"success\" | \"failure\"; actor_id: (string & $brand<\"Uuid\">) | null; account_id: (string & $brand<...>) | null; ... 6 more ...; target_username: string | null; }[]>", "return_description": "matching audit log entries with `username` and `target_username`", "parameters": [ { @@ -6419,7 +6486,7 @@ "name": "query_audit_log_list_for_account", "kind": "function", "doc_comment": "List audit log entries related to an account (as actor or target).", - "source_line": 243, + "source_line": 246, "type_signature": "(deps: QueryDeps, account_id: string, limit?: number): Promise", "return_type": "Promise", "parameters": [ @@ -6445,9 +6512,9 @@ "name": "query_audit_log_list_permit_history", "kind": "function", "doc_comment": "List permit grant/revoke events with resolved usernames.", - "source_line": 264, - "type_signature": "(deps: QueryDeps, limit?: number, offset?: number): Promise<{ id: string & $brand<\"Uuid\">; seq: number; event_type: string; outcome: \"success\" | \"failure\"; actor_id: (string & $brand<...>) | null; ... 6 more ...; target_username: string | null; }[]>", - "return_type": "Promise<{ id: string & $brand<\"Uuid\">; seq: number; event_type: string; outcome: \"success\" | \"failure\"; actor_id: (string & $brand<\"Uuid\">) | null; account_id: (string & $brand<...>) | null; ... 5 more ...; target_username: string | null; }[]>", + "source_line": 267, + "type_signature": "(deps: QueryDeps, limit?: number, offset?: number): Promise<{ id: string & $brand<\"Uuid\">; seq: number; event_type: string; outcome: \"success\" | \"failure\"; actor_id: (string & $brand<...>) | null; ... 7 more ...; target_username: string | null; }[]>", + "return_type": "Promise<{ id: string & $brand<\"Uuid\">; seq: number; event_type: string; outcome: \"success\" | \"failure\"; actor_id: (string & $brand<\"Uuid\">) | null; account_id: (string & $brand<...>) | null; ... 6 more ...; target_username: string | null; }[]>", "return_description": "permit history events with `username` and `target_username`", "parameters": [ { @@ -6473,7 +6540,7 @@ "name": "query_audit_log_cleanup_before", "kind": "function", "doc_comment": "Delete audit log entries older than the given date.", - "source_line": 290, + "source_line": 293, "type_signature": "(deps: QueryDeps, before: Date): Promise", "return_type": "Promise", "return_description": "the number of entries deleted", @@ -6490,19 +6557,12 @@ } ] }, - { - "name": "AuditLogFireAndForgetDeps", - "kind": "type", - "doc_comment": "Capabilities required by `audit_log_fire_and_forget`.\n\nDefined as a slice of `AppDeps` so call sites can pass the surrounding deps\nbundle directly without a structural-compatibility coincidence. The bundled\nshape replaces the prior `(log, on_audit_event, config?)` positional args\n— consumers that forgot the trailing `config` would silently fall back to\n`BUILTIN_AUDIT_LOG_CONFIG` and skip metadata validation for their own\nevent types. `audit_log_config` is optional on `AppDeps` and defaults to\n`BUILTIN_AUDIT_LOG_CONFIG` inside `audit_log_fire_and_forget` when absent.", - "source_line": 312, - "type_signature": "AuditLogFireAndForgetDeps" - }, { "name": "audit_log_fire_and_forget", "kind": "function", - "doc_comment": "Log an audit event without blocking the caller.\n\nErrors are logged — audit logging never breaks auth flows. Uses\n`background_db` so entries persist even when the request transaction\nrolls back. Write and `on_audit_event` callback failures are logged separately.", - "source_line": 331, - "type_signature": "(route: Pick, input: AuditLogInput, deps: AuditLogFireAndForgetDeps): Promise<...>", + "doc_comment": "Log an audit event without blocking the caller.\n\nErrors are logged — audit logging never breaks auth flows. Uses\n`background_db` so entries persist even when the request transaction\nrolls back. Write and `on_audit_event` callback failures are logged separately.\n\n`deps` is the shared `AuditEmitDeps` bundle (`log`, `on_audit_event`,\noptional `audit_log_config`) so call sites pass the surrounding deps\nobject directly. The bundled shape replaces the prior `(log,\non_audit_event, config?)` positional args — consumers that forgot the\ntrailing `config` would silently fall back to `BUILTIN_AUDIT_LOG_CONFIG`\nand skip metadata validation for their own event types.", + "source_line": 325, + "type_signature": "(route: Pick, input: AuditLogInput, deps: AuditEmitDeps): Promise<...>", "return_type": "Promise", "return_description": "the settled promise (callers may ignore it)", "parameters": [ @@ -6518,20 +6578,58 @@ }, { "name": "deps", - "type": "AuditLogFireAndForgetDeps", + "type": "AuditEmitDeps", "description": "logger, `on_audit_event` callback, and optional `audit_log_config`" } ] - } - ], - "module_comment": "Audit log database queries.\n\nRecords and retrieves auth mutation events for security monitoring.\nAll write operations should use `audit_log_fire_and_forget` to\nensure audit logging never blocks or breaks auth flows.\n\nRollback resilience: `audit_log_fire_and_forget` writes to `background_db`\n(pool-level), not the handler's transaction-scoped `db`, so audit entries\npersist even when the request transaction rolls back.", - "dependencies": ["auth/audit_log_schema.ts", "db/assert_row.ts"], - "dependents": [ - "auth/account_actions.ts", - "auth/account_routes.ts", - "auth/admin_actions.ts", - "auth/bootstrap_routes.ts", - "auth/cleanup.ts", + }, + { + "name": "EmitPermitTargetEventContext", + "kind": "type", + "doc_comment": "Per-request context required by `emit_permit_target_event` —\n`RouteContext` plus the resolved `client_ip` (lives on `ActionContext`\nfor RPC handlers and on the route's Hono context for REST). Declared\nlocally rather than reaching into `actions/action_rpc.ts` so the helper\nstays usable from REST handlers that haven't promoted to RPC yet.", + "source_line": 353, + "type_signature": "EmitPermitTargetEventContext" + }, + { + "name": "emit_permit_target_event", + "kind": "function", + "doc_comment": "Stamp a permit-shape audit event with both `target_account_id` (drives\nSSE/WS socket-close — sessions are account-grain) and `target_actor_id`\n(the actor-grain forensic field). Both target fields nullable so emit\nsites without a recipient binding (e.g. `permit_revoke` on a missing\naccount, offer-shape events with no `to_actor_id`) can call through\nuniformly.\n\nLifts the six-site `{actor_id: auth.actor.id, account_id: auth.account.id,\nip: ctx.client_ip, ...}` boilerplate around `audit_log_fire_and_forget`\nso callers thread auth + ctx + deps once and the event metadata once,\nwithout re-derivable plumbing.\n\nOutcome defaults to `'success'`; pass `'failure'` for denial-shape\nevents. Other audit envelope shapes (target_*-by-actor-id-only events,\nnon-permit-shape events) should call `audit_log_fire_and_forget`\ndirectly — this helper deliberately narrows to the permit-target shape.", + "source_line": 385, + "type_signature": "(ctx: EmitPermitTargetEventContext, auth: RequestActorContext, deps: AuditEmitDeps, input: { event_type: T; target_account_id: (string & $brand<...>) | null; target_actor_id: (string & $brand<...>) | null; metadata: (T extends \"invite_create\" | ... 19 more ... | \"permit_offer_supersede\" ? (AuditMetadataMap[T] & Record<...>) | null : Record<...> | null) | undefined; outcome?: \"success\" | ... 1 more ... | undefined; }): Promise<...>", + "return_type": "Promise", + "return_description": "the settled promise (callers may ignore it)", + "parameters": [ + { + "name": "ctx", + "type": "EmitPermitTargetEventContext", + "description": "request context with `background_db`, `pending_effects`, `client_ip`" + }, + { + "name": "auth", + "type": "RequestActorContext", + "description": "the resolved `RequestActorContext` for the current handler — actor invariant captured in the type so the helper stops needing `auth.actor!`" + }, + { + "name": "deps", + "type": "AuditEmitDeps", + "description": "`log`, `on_audit_event`, optional `audit_log_config`" + }, + { + "name": "input", + "type": "{ event_type: T; target_account_id: (string & $brand<\"Uuid\">) | null; target_actor_id: (string & $brand<\"Uuid\">) | null; metadata: (T extends \"invite_create\" | ... 19 more ... | \"permit_offer_supersede\" ? (AuditMetadataMap[T] & Record<...>) | null : Record<...> | null) | undefined; outcome?: \"success\" | ... 1 more ....", + "description": "event type, target columns, metadata, optional outcome" + } + ] + } + ], + "module_comment": "Audit log database queries.\n\nRecords and retrieves auth mutation events for security monitoring.\nAll write operations should use `audit_log_fire_and_forget` to\nensure audit logging never blocks or breaks auth flows.\n\nRollback resilience: `audit_log_fire_and_forget` writes to `background_db`\n(pool-level), not the handler's transaction-scoped `db`, so audit entries\npersist even when the request transaction rolls back.", + "dependencies": ["auth/audit_log_schema.ts", "db/assert_row.ts"], + "dependents": [ + "auth/account_actions.ts", + "auth/account_routes.ts", + "auth/admin_actions.ts", + "auth/bootstrap_routes.ts", + "auth/cleanup.ts", "auth/permit_offer_actions.ts", "auth/permit_offer_queries.ts", "auth/self_service_role_actions.ts", @@ -6590,56 +6688,56 @@ "name": "AUDIT_EVENT_TYPES", "kind": "variable", "doc_comment": "All tracked auth event types. Frozen to convert accidental in-process\nmutation (test cross-contamination, cast escapes) into loud TypeErrors.\nNot a security boundary — in-process code has many other paths to subvert\naudit logging.", - "source_line": 21, + "source_line": 23, "type_signature": "readonly [\"login\", \"logout\", \"bootstrap\", \"signup\", \"password_change\", \"session_revoke\", \"session_revoke_all\", \"token_create\", \"token_revoke\", \"token_revoke_all\", \"permit_grant\", ... 9 more ..., \"app_settings_update\"]" }, { "name": "AuditEventType", "kind": "type", "doc_comment": "Zod schema for audit event types.", - "source_line": 46, + "source_line": 48, "type_signature": "ZodEnum<{ invite_create: \"invite_create\"; invite_delete: \"invite_delete\"; app_settings_update: \"app_settings_update\"; login: \"login\"; logout: \"logout\"; bootstrap: \"bootstrap\"; signup: \"signup\"; ... 13 more ...; permit_offer_supersede: \"permit_offer_supersede\"; }>" }, { "name": "AUDIT_EVENT_TYPE_NAME_REGEX", "kind": "variable", "doc_comment": "Letter start, then letters, digits, `_`, `.`, `/`, `-`. Accepts snake_case,\ndotted, and namespaced consumer conventions; rejects empty strings, leading\nseparators, whitespace, and control characters.", - "source_line": 54, + "source_line": 56, "type_signature": "RegExp" }, { "name": "AuditEventTypeName", "kind": "type", "doc_comment": "Zod schema for valid audit event-type name strings.", - "source_line": 57, + "source_line": 59, "type_signature": "ZodString" }, { "name": "AuditOutcome", "kind": "type", "doc_comment": "Zod schema for audit event outcomes.", - "source_line": 63, + "source_line": 65, "type_signature": "ZodEnum<{ success: \"success\"; failure: \"failure\"; }>" }, { "name": "AUDIT_METADATA_SCHEMAS", "kind": "variable", "doc_comment": "Per-event-type metadata Zod schemas. `z.looseObject` so consumers can\nadd fields while known ones are validated. The record is frozen to\ncatch mutation bugs at the key level (e.g. tests that try to swap in a\nstub schema); the Zod schemas themselves are reachable and mutable —\nfreeze isn't a security boundary.", - "source_line": 73, + "source_line": 75, "type_signature": "Readonly<{ login: ZodNullable>; logout: ZodNull; bootstrap: ZodNullable>; ... 17 more ...; app_settings_update: ZodObject<...>; }>" }, { "name": "AuditMetadataMap", "kind": "type", "doc_comment": "Mapped type of metadata shapes per event type, derived from Zod schemas.", - "source_line": 263, + "source_line": 265, "type_signature": "AuditMetadataMap" }, { "name": "AuditLogEvent", "kind": "type", "doc_comment": "Audit log row from the database. See `AuditLogEventJson` for `event_type` widening rationale.", - "source_line": 268, + "source_line": 270, "type_signature": "AuditLogEvent", "properties": [ { @@ -6665,7 +6763,8 @@ { "name": "actor_id", "kind": "variable", - "type_signature": "Uuid | null" + "type_signature": "Uuid | null", + "doc_comment": "Operator (the actor that initiated the event) — populated when the\nrequest resolved an acting actor.\n\nResolution is driven per-request by the route-spec wrapper / RPC\ndispatcher; a route gets an acting actor when its input schema\ndeclares `acting?: ActingActor` or its auth requires permits\n(`role` / `keeper`). Account-grain operations declare neither,\nso no actor is resolved and `actor_id` is null: login (also\npre-credential), logout, signup, bootstrap, password_change,\nsession/token revoke, app_settings_update, invite events.\nPermit events, admin actions, and actor-targeted offers\npopulate this with the initiator's actor." }, { "name": "account_id", @@ -6677,6 +6776,12 @@ "kind": "variable", "type_signature": "Uuid | null" }, + { + "name": "target_actor_id", + "kind": "variable", + "type_signature": "Uuid | null", + "doc_comment": "Actor-grain target — populated when the event subject is bound to\na specific actor.\n\nConcretely:\n- Always populated: `permit_revoke` and `permit_grant`\n (admin direct-grant, self-service toggle, and in-tx\n `permit_offer_accept` all populate both target columns — the\n permit's grantee is the actor-grain subject regardless of who\n initiated the grant), `permit_offer_accept` on accept (the\n accept binds the actor deterministically), `permit_offer_decline`\n (the grantor actor — decline is *to* the offering actor).\n- Conditionally populated: offer-shape events\n (`permit_offer_create`, `_expire`, `_retract`, `_supersede`)\n carry the actor when the offer was actor-targeted at create time\n (`permit_offer.to_actor_id` set), null when the offer was\n account-grain (any actor on `to_account_id` may accept).\n- Not populated: admin actions, account-shape events (login,\n logout, signup, bootstrap, password_change, session/token\n revoke, app_settings_update, invite events) — subject is the\n account or no specific resource, not an actor-bound permit.\n- Not populated: events whose principal isn't an actor-bound\n resource (e.g. consumer events that name a non-actor scope in\n metadata).\n\nMulti-actor invariants this column relies on: when both\n`target_actor_id` and `target_account_id` are populated they refer\nto the same account (`actor.account_id`-derivable). The invariant\nholds uniformly across every populated event including decline\n(the grantor's account is joined into the decline RETURNING) and\nthe supersede cascade (the recipient account is known on\n`permit_offer.to_account_id`). `target_account_id` stays the\nSSE/WS socket-close key because sessions remain account-grain\nafter multi-actor lands." + }, { "name": "ip", "kind": "variable", @@ -6698,7 +6803,7 @@ "name": "get_audit_metadata", "kind": "function", "doc_comment": "Narrow metadata type for a known event type.\n\nUse after checking `event_type` to get typed metadata access.", - "source_line": 286, + "source_line": 338, "type_signature": "(event: AuditLogEvent & { event_type: T; }): AuditMetadataMap[T] | null", "return_type": "AuditMetadataMap[T] | null", "parameters": [ @@ -6712,7 +6817,7 @@ "name": "AuditLogInput", "kind": "type", "doc_comment": "Input for creating an audit log entry.", - "source_line": 293, + "source_line": 345, "type_signature": "AuditLogInput", "generic_params": [ { @@ -6747,6 +6852,11 @@ "kind": "variable", "type_signature": "Uuid | null" }, + { + "name": "target_actor_id", + "kind": "variable", + "type_signature": "Uuid | null" + }, { "name": "ip", "kind": "variable", @@ -6764,7 +6874,7 @@ "name": "AuditLogConfig", "kind": "type", "doc_comment": "Configuration bundle for audit-log event types and metadata schemas.\n\nLets consumers extend the closed `AUDIT_EVENT_TYPES` enum with their own\nevent strings (and metadata Zod schemas) without forking. Pass to\n`audit_log_fire_and_forget` / `query_audit_log` as the optional `config`\nargument; both default to `BUILTIN_AUDIT_LOG_CONFIG`.\n\nThe DB column is `TEXT NOT NULL` and never enforced an enum, so consumer\nevent types round-trip through `query_audit_log_list` and SSE identically\nto builtins.\n\nConstructed configs are deep-frozen (wrapper, `event_types`,\n`metadata_schemas`) to catch accidental mutation bugs early. Not a\nsecurity boundary against in-process code, which can subvert audit\nlogging through other paths.", - "source_line": 327, + "source_line": 380, "type_signature": "AuditLogConfig", "properties": [ { @@ -6787,14 +6897,14 @@ "name": "BUILTIN_AUDIT_LOG_CONFIG", "kind": "variable", "doc_comment": "Builtin fuz_app audit-log config — every existing event type and its metadata schema.", - "source_line": 338, + "source_line": 391, "type_signature": "AuditLogConfig" }, { "name": "CreateAuditLogConfigOptions", "kind": "type", "doc_comment": "Options for `create_audit_log_config`.", - "source_line": 344, + "source_line": 397, "type_signature": "CreateAuditLogConfigOptions", "properties": [ { @@ -6815,7 +6925,7 @@ "description": "when an `extra_events` key collides with a builtin event type or fails `AuditEventTypeName` format validation" } ], - "source_line": 369, + "source_line": 422, "type_signature": "(options?: CreateAuditLogConfigOptions | undefined): AuditLogConfig", "return_type": "AuditLogConfig", "parameters": [ @@ -6830,14 +6940,14 @@ "name": "AUDIT_LOG_DEFAULT_LIMIT", "kind": "variable", "doc_comment": "Default page size for audit log listings.", - "source_line": 399, + "source_line": 452, "type_signature": "50" }, { "name": "AuditLogListOptions", "kind": "type", "doc_comment": "Options for listing audit log entries.", - "source_line": 402, + "source_line": 455, "type_signature": "AuditLogListOptions", "properties": [ { @@ -6883,45 +6993,45 @@ "name": "AuditLogEventJson", "kind": "type", "doc_comment": "Zod schema for client-safe audit log event.\n\n`event_type` is `AuditEventTypeName` (regex-validated string) — matches\nthe `AuditLogEvent` row and the DB's `TEXT NOT NULL` column. Consumer\ntypes registered via `create_audit_log_config({extra_events})` round-trip\nthrough queries, `on_audit_event` callbacks, and JSON-RPC responses\nidentically to builtins. `AuditLogInput` stays parameterized on the\nwrite side so `AuditMetadataMap` narrowing via `get_audit_metadata` works.", - "source_line": 428, - "type_signature": "ZodObject<{ id: $ZodBranded; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 5 more ...; metadata: ZodNullable<...>; }, $strict>" + "source_line": 481, + "type_signature": "ZodObject<{ id: $ZodBranded; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 6 more ...; metadata: ZodNullable<...>; }, $strict>" }, { "name": "AuditLogEventWithUsernamesJson", "kind": "type", "doc_comment": "Zod schema for audit log events with resolved usernames.", - "source_line": 443, - "type_signature": "ZodObject<{ id: $ZodBranded; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 7 more ...; target_username: ZodNullable<...>; }, $strict>" + "source_line": 497, + "type_signature": "ZodObject<{ id: $ZodBranded; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 8 more ...; target_username: ZodNullable<...>; }, $strict>" }, { "name": "PermitHistoryEventJson", "kind": "type", "doc_comment": "Zod schema for permit history events with resolved usernames.", - "source_line": 450, - "type_signature": "ZodObject<{ id: $ZodBranded; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 7 more ...; target_username: ZodNullable<...>; }, $strict>" + "source_line": 504, + "type_signature": "ZodObject<{ id: $ZodBranded; seq: ZodNumber; event_type: ZodString; outcome: ZodEnum<{ success: \"success\"; failure: \"failure\"; }>; ... 8 more ...; target_username: ZodNullable<...>; }, $strict>" }, { "name": "AdminSessionJson", "kind": "type", "doc_comment": "Zod schema for admin session listing (session + username).", - "source_line": 457, + "source_line": 511, "type_signature": "ZodObject<{ id: ZodString; account_id: $ZodBranded; created_at: ZodString; expires_at: ZodString; last_seen_at: ZodString; username: ZodString; }, $strict>" }, { "name": "AUDIT_LOG_SCHEMA", "kind": "variable", - "source_line": 464, + "source_line": 529, "type_signature": "\"\\nCREATE TABLE IF NOT EXISTS audit_log (\\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\\n seq SERIAL NOT NULL,\\n event_type TEXT NOT NULL,\\n outcome TEXT NOT NULL DEFAULT 'success',\\n actor_id UUID REFERENCES actor(id) ON DELETE SET NULL,\\n account_id UUID REFERENCES account(id) ON DELETE SET NULL,\\n targe..." }, { "name": "AUDIT_LOG_INDEXES", "kind": "variable", - "source_line": 478, + "source_line": 544, "type_signature": "string[]" } ], "module_comment": "Audit log database schema and types.\n\nRecords auth mutations (login, logout, grant, revoke, etc.) for\nsecurity monitoring and operational visibility.", - "dependencies": ["auth/account_schema.ts"], + "dependencies": ["auth/account_schema.ts", "auth/api_token.ts"], "dependents": [ "auth/admin_action_specs.ts", "auth/admin_actions.ts", @@ -6938,8 +7048,8 @@ { "name": "create_bearer_auth_middleware", "kind": "function", - "doc_comment": "Create middleware that authenticates via bearer token.\n\nSoft-fails for invalid, expired, or empty tokens — calls `next()` without\nsetting a request context, letting downstream auth enforcement (per-action\n`check_action_auth` or `require_auth`) return a consistent JSON-RPC or\nroute-level error. This avoids leaking token-specific diagnostics\n(`invalid_token`, `account_not_found`) that could aid enumeration attacks,\nand ensures public actions are not blocked by bad credentials.\n\nRejects bearer tokens when an `Origin` or `Referer` header is present —\nbrowsers must use cookie auth to reduce attack surface.\nAuth scheme matching is case-insensitive per RFC 7235.\nOn success, builds the request context (`{ account, actor, permits }`)\nand sets it on the Hono context. Skips if a request context is already set\n(e.g. by session middleware).\n\nRate limiting (429) is the only hard-fail — it's a throttling concern\nindependent of auth identity.", - "source_line": 50, + "doc_comment": "Create middleware that authenticates via bearer token.\n\nSoft-fails for invalid, expired, or empty tokens — calls `next()` without\nsetting account identity, letting downstream auth enforcement (the RPC\ndispatcher's pre-validation / post-authorization auth gates or\n`require_auth`) return a consistent JSON-RPC or route-level error. This\navoids leaking token-specific diagnostics\n(`invalid_token`, `account_not_found`) that could aid enumeration attacks,\nand ensures public actions are not blocked by bad credentials.\n\nRejects bearer tokens when an `Origin` or `Referer` header is present —\nbrowsers must use cookie auth to reduce attack surface.\nAuth scheme matching is case-insensitive per RFC 7235.\nOn success, sets `c.var.auth_account_id`, `CREDENTIAL_TYPE_KEY = 'api_token'`,\nand `AUTH_API_TOKEN_ID_KEY`. Skips when an account is already authenticated\n(e.g. by session middleware). Acting-actor resolution + `RequestContext`\nconstruction are deferred to the dispatcher's authorization phase.\n\nRate limiting (429) is the only hard-fail — it's a throttling concern\nindependent of auth identity.", + "source_line": 51, "type_signature": "(deps: QueryDeps, ip_rate_limiter: RateLimiter | null, log: Logger): MiddlewareHandler", "return_type": "MiddlewareHandler", "parameters": [ @@ -6964,7 +7074,6 @@ "module_comment": "Bearer auth middleware for API token authentication.\n\nBearer tokens are rejected when `Origin` or `Referer` headers are present —\nbrowsers must use cookie auth. This reduces attack surface: a stolen token\ncannot be replayed from a browser context (the browser adds `Origin`\nautomatically).\n\nToken generation and hashing utilities live in `auth/api_token.ts`.", "dependencies": [ "auth/api_token_queries.ts", - "auth/request_context.ts", "hono_context.ts", "http/proxy.ts", "rate_limiter.ts" @@ -7346,7 +7455,7 @@ "description": "re-thrown from any sweep that fails (no per-sweep isolation here)" } ], - "source_line": 118, + "source_line": 123, "type_signature": "(deps: AuthCleanupDeps): Promise", "return_type": "Promise", "parameters": [ @@ -7371,21 +7480,21 @@ "name": "DEFAULT_ROTATION_INTERVAL_MS", "kind": "variable", "doc_comment": "Default rotation interval in milliseconds (30 seconds).", - "source_line": 38, + "source_line": 36, "type_signature": "30000" }, { "name": "DaemonTokenWriteDeps", "kind": "type", "doc_comment": "Deps for writing the daemon token to disk.", - "source_line": 41, + "source_line": 39, "type_signature": "DaemonTokenWriteDeps" }, { "name": "get_daemon_token_path", "kind": "function", "doc_comment": "Get the daemon token file path (`~/.{name}/run/daemon_token`).", - "source_line": 54, + "source_line": 52, "type_signature": "(runtime: Pick, name: string): string | null", "return_type": "string | null", "return_description": "path to `daemon_token`, or `null` if `$HOME` is not set", @@ -7406,7 +7515,7 @@ "name": "write_daemon_token", "kind": "function", "doc_comment": "Write the current token to disk atomically.\n\nUses `write_file_atomic` (temp file + rename) and optionally sets mode 0600.", - "source_line": 72, + "source_line": 70, "type_signature": "(runtime: DaemonTokenWriteDeps, token_path: string, token: string): Promise", "return_type": "Promise", "parameters": [ @@ -7430,8 +7539,8 @@ { "name": "resolve_keeper_account_id", "kind": "function", - "doc_comment": "Resolve the keeper account ID by querying for the account with an active keeper permit.\n\nThere is exactly one keeper account (the bootstrap account). Runs once at\nserver startup — the result is cached in `DaemonTokenState.keeper_account_id`.", - "source_line": 92, + "doc_comment": "Resolve the keeper account ID by querying for the account with an active\nkeeper permit.\n\nThere is exactly one keeper account (the bootstrap account). Runs once\nat server startup — the result is cached in\n`DaemonTokenState.keeper_account_id`. The acting actor is resolved\nper-request by the dispatcher's authorization phase (which runs\n`resolve_acting_actor` against this account id), so multi-actor keeper\naccounts surface `actor_required` if a daemon caller doesn't pass an\nexplicit `acting`.", + "source_line": 96, "type_signature": "(deps: QueryDeps): Promise", "return_type": "Promise", "return_description": "the keeper account ID, or `null` if no keeper exists yet (pre-bootstrap)", @@ -7447,7 +7556,7 @@ "name": "DaemonTokenRotationOptions", "kind": "type", "doc_comment": "Options for daemon token rotation.", - "source_line": 97, + "source_line": 101, "type_signature": "DaemonTokenRotationOptions", "properties": [ { @@ -7468,7 +7577,7 @@ "name": "DaemonTokenRotation", "kind": "type", "doc_comment": "Result of starting daemon token rotation.", - "source_line": 105, + "source_line": 109, "type_signature": "DaemonTokenRotation", "properties": [ { @@ -7495,7 +7604,7 @@ "description": "if `$HOME` is not set so the daemon token path cannot be resolved" } ], - "source_line": 126, + "source_line": 130, "type_signature": "(runtime: Pick & Pick & { chmod?: ((path: string, mode: number) => Promise<...>) | undefined; } & FsRemoveDeps, deps: QueryDeps, options: DaemonTokenRotationOptions, log: Logger): Promise<...>", "return_type": "Promise", "return_description": "rotation state and stop function", @@ -7525,9 +7634,9 @@ { "name": "create_daemon_token_middleware", "kind": "function", - "doc_comment": "Create middleware that authenticates via daemon token.\n\nChecks the `X-Daemon-Token` header. Behavior:\n- No header: pass through (don't touch existing context)\n- Header present + valid: build `RequestContext` from keeper account,\n set `credential_type: 'daemon_token'` (overrides any existing session/bearer context)\n- Header present + invalid: return 401 (fail-closed, no downgrade)\n- Header present + valid but `keeper_account_id` is null: return 503", - "source_line": 203, - "type_signature": "(state: DaemonTokenState, deps: QueryDeps): MiddlewareHandler", + "doc_comment": "Create middleware that authenticates via daemon token.\n\nChecks the `X-Daemon-Token` header. Behavior:\n- No header: pass through (don't touch existing context).\n- Header present + Zod-invalid: return 401 (fail-closed).\n- Header present + invalid value: return 401 (fail-closed, no downgrade).\n- Header present + valid + `keeper_account_id` null: return 503.\n- Header present + valid + ok: set `c.var.auth_account_id =\n state.keeper_account_id`, `CREDENTIAL_TYPE_KEY = 'daemon_token'`\n (overrides any existing session / bearer identity).\n\nActing-actor resolution + `RequestContext` construction are deferred\nto the dispatcher's authorization phase. Multi-actor keeper accounts\nsurface `actor_required` from there if a daemon caller doesn't pass\nan explicit `acting` value.", + "source_line": 213, + "type_signature": "(state: DaemonTokenState, _deps: QueryDeps): MiddlewareHandler", "return_type": "MiddlewareHandler", "parameters": [ { @@ -7536,9 +7645,8 @@ "description": "the daemon token runtime state" }, { - "name": "deps", - "type": "QueryDeps", - "description": "query dependencies (pool-level db for middleware)" + "name": "_deps", + "type": "QueryDeps" } ] } @@ -7547,7 +7655,6 @@ "dependencies": [ "auth/daemon_token.ts", "auth/permit_queries.ts", - "auth/request_context.ts", "auth/role_schema.ts", "cli/config.ts", "hono_context.ts", @@ -7825,6 +7932,13 @@ "doc_comment": "Capabilities for route spec factories.\n\n`AppDeps` without `db` — route handlers receive database connections\nvia `RouteContext`, so factories don't capture a pool-level `Db`.", "source_line": 67, "type_signature": "RouteFactoryDeps" + }, + { + "name": "AuditEmitDeps", + "kind": "type", + "doc_comment": "Capabilities required by anything that emits audit events.\n\nThe slice every audit-emitting site needs: `log` for sibling failure\nreporting, `on_audit_event` for SSE/WS fan-out, and the optional\n`audit_log_config` for consumer-extended event-type validation. Used\nby `audit_log_fire_and_forget` / `emit_permit_target_event` (the\nprimitives) and by every action-factory deps type in `auth/`\n(`AdminActionDeps`, `AccountActionDeps`, `PermitOfferActionDeps`,\n`SelfServiceRoleActionDeps`) that runs through them. Lifted here so\nthe five factory deps stop spelling the same `Pick` independently.", + "source_line": 82, + "type_signature": "AuditEmitDeps" } ], "module_comment": "Stateless capabilities bundle for fuz_app backends.\n\n`AppDeps` is the central dependency injection type — injectable and swappable\nper environment (production vs test). Does not contain config (static values)\nor runtime state (mutable refs)." @@ -8250,25 +8364,25 @@ "name": "AUTH_MIGRATION_NAMESPACE", "kind": "variable", "doc_comment": "Namespace identifier for fuz_app auth migrations.", - "source_line": 62, + "source_line": 70, "type_signature": "\"fuz_auth\"" }, { "name": "AUTH_MIGRATIONS", "kind": "variable", "doc_comment": "Auth schema migrations in order.\n\n- v0: Full auth schema — account (with email_verified), actor, permit,\n auth_session, api_token, audit_log (with seq), bootstrap_lock, invite,\n app_settings, plus all indexes and seeds.\n- v1: `permit_offer` table for consentful grants; adds `scope_id` /\n `source_offer_id` / `revoked_reason` to `permit` and swaps the\n `(actor_id, role)` partial unique index for a scope-aware variant using\n the all-zeros sentinel UUID. The `permit_offer` table carries a\n `superseded_at` terminal state; its partial unique index is scoped by\n `(to_account, role, scope, from_actor)` so multiple grantors may coexist.", - "source_line": 77, + "source_line": 85, "type_signature": "Migration[]" }, { "name": "AUTH_MIGRATION_NS", "kind": "variable", "doc_comment": "Pre-composed migration namespace for auth tables.", - "source_line": 141, + "source_line": 149, "type_signature": "MigrationNamespace" } ], - "module_comment": "Auth schema migrations.\n\nOrdered list of `{name, up}` migrations for the fuz identity system tables.\nConsumed by `run_migrations` with namespace `'fuz_auth'`.\n\n**Append-only after first publish.** Once a fuz_app version containing a\ngiven migration is published (`npm publish` / `jsr publish`), that\nmigration's name and position are frozen. Never edit, rename, or reorder —\nappend only. Pre-publish, anything goes; the cliff is the publish event.\nBody edits to a published migration slip past the runner (no content\nhashing) and are caught by schema-snapshot tests in consumers.\n\nTo add a migration, append a new entry to `AUTH_MIGRATIONS`:\n\n```ts\n// v2: add display_name to account\n{\n name: 'account_display_name',\n up: async (db) => {\n await db.query('ALTER TABLE account ADD COLUMN display_name TEXT');\n },\n},\n```\n\nMigrations are forward-only (no down). Use `IF NOT EXISTS` / `IF EXISTS`\nfor DDL safety. The `name` appears in error messages on failure.", + "module_comment": "Auth schema migrations.\n\nOrdered list of `{name, up}` migrations for the fuz identity system tables.\nConsumed by `run_migrations` with namespace `'fuz_auth'`.\n\n**Schema is not stabilized yet — append-only is NOT the rule.** While\nfuz_app is pre-stable, migration bodies, names, and positions can change\nfreely between versions; consumers upgrading across a schema change are\nexpected to drop and re-bootstrap their dev/test databases (production\ndeployments are not yet a supported use case). Once the schema is\ndeclared stable a hard append-only-after-publish rule will apply and the\ncliff will be called out in the release notes for that version. Until\nthen: edit, rename, reorder, or replace migrations as needed; bias toward\ncollapsing work into the existing v0/v1 entries rather than appending v2\npatch migrations.\n\nTo add a migration in the pre-stable phase, prefer extending an existing\nentry's body (consumers will re-bootstrap on upgrade). If you do append\na new entry to `AUTH_MIGRATIONS`, the runner will apply it on existing\ntracker rows — the same shape that will become mandatory once the\nschema stabilizes:\n\n```ts\n// v2: add display_name to account\n{\n name: 'account_display_name',\n up: async (db) => {\n await db.query('ALTER TABLE account ADD COLUMN display_name TEXT');\n },\n},\n```\n\nMigrations are forward-only (no down). Use `IF NOT EXISTS` / `IF EXISTS`\nfor DDL safety. The `name` appears in error messages on failure.", "dependencies": ["auth/audit_log_schema.ts", "auth/ddl.ts", "auth/permit_offer_schema.ts"], "dependents": [ "server/app_backend.ts", @@ -8424,186 +8538,200 @@ "name": "ERROR_OFFER_SELF_TARGET", "kind": "variable", "doc_comment": "Error reason — caller tried to offer themselves a permit.", - "source_line": 35, + "source_line": 32, "type_signature": "\"offer_self_target\"" }, { "name": "ERROR_OFFER_TERMINAL", "kind": "variable", "doc_comment": "Error reason — offer is declined, retracted, or superseded.", - "source_line": 37, + "source_line": 34, "type_signature": "\"offer_terminal\"" }, { "name": "ERROR_OFFER_EXPIRED", "kind": "variable", "doc_comment": "Error reason — offer's `expires_at` has passed.", - "source_line": 39, + "source_line": 36, "type_signature": "\"offer_expired\"" }, { "name": "ERROR_OFFER_NOT_FOUND", "kind": "variable", "doc_comment": "Error reason — offer does not exist or belongs to a different recipient (404-over-403 IDOR mask).", - "source_line": 41, + "source_line": 38, "type_signature": "\"offer_not_found\"" }, { "name": "ERROR_OFFER_ROLE_NOT_GRANTABLE", "kind": "variable", "doc_comment": "Error reason — the offered role is not `web_grantable` (nobody may offer it via this surface).", - "source_line": 43, + "source_line": 40, "type_signature": "\"offer_role_not_grantable\"" }, { "name": "ERROR_OFFER_NOT_AUTHORIZED", "kind": "variable", "doc_comment": "Error reason — caller is not authorized to offer this role (default policy: caller lacks the role; consumer `authorize` callback may add further policy).", - "source_line": 45, + "source_line": 42, "type_signature": "\"offer_not_authorized\"" }, + { + "name": "ERROR_OFFER_ACTOR_MISMATCH", + "kind": "variable", + "doc_comment": "Error reason — actor-targeted offer was accepted by an actor other than `to_actor_id`.", + "source_line": 44, + "type_signature": "\"offer_actor_mismatch\"" + }, + { + "name": "ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH", + "kind": "variable", + "doc_comment": "Error reason — `permit_offer_create` was called with a `to_actor_id` that does not belong to `to_account_id`.", + "source_line": 46, + "type_signature": "\"offer_actor_account_mismatch\"" + }, { "name": "PermitOfferCreateInput", "kind": "type", - "doc_comment": "Input for `permit_offer_create`.", - "source_line": 50, - "type_signature": "ZodObject<{ to_account_id: $ZodBranded; role: ZodString; scope_id: ZodOptional>>; message: ZodOptional<...>; }, $strict>" + "doc_comment": "Input for `permit_offer_create`.\n\n`to_actor_id` (optional) narrows the offer to a specific actor on the\nrecipient account. When supplied, `permit_offer_accept` will only admit\nthe named actor — wrong-actor accepts reject with\n`offer_actor_mismatch`. The audit envelope's `target_actor_id` is\nstamped from this column on the create / supersede / expire / retract\nevents. Omit (or pass null) for the account-grain default — any actor\non `to_account_id` may accept.", + "source_line": 61, + "type_signature": "ZodObject<{ to_account_id: $ZodBranded; to_actor_id: ZodOptional>>; role: ZodString; scope_id: ZodOptional<...>; message: ZodOptional<...>; acting: ZodOptional<...>; }, $strict>" }, { "name": "PermitOfferAcceptInput", "kind": "type", "doc_comment": "Input for `permit_offer_accept`.", - "source_line": 65, - "type_signature": "ZodObject<{ offer_id: $ZodBranded; }, $strict>" + "source_line": 81, + "type_signature": "ZodObject<{ offer_id: $ZodBranded; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "PermitOfferDeclineInput", "kind": "type", "doc_comment": "Input for `permit_offer_decline`.", - "source_line": 71, - "type_signature": "ZodObject<{ offer_id: $ZodBranded; reason: ZodOptional>; }, $strict>" + "source_line": 88, + "type_signature": "ZodObject<{ offer_id: $ZodBranded; reason: ZodOptional>; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "PermitOfferRetractInput", "kind": "type", "doc_comment": "Input for `permit_offer_retract`.", - "source_line": 82, - "type_signature": "ZodObject<{ offer_id: $ZodBranded; }, $strict>" + "source_line": 100, + "type_signature": "ZodObject<{ offer_id: $ZodBranded; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "PermitOfferListInput", "kind": "type", "doc_comment": "Input for `permit_offer_list`. `account_id` is admin-only (inspect another account's inbox).", - "source_line": 88, - "type_signature": "ZodObject<{ account_id: ZodOptional>>; }, $strict>" + "source_line": 107, + "type_signature": "ZodObject<{ account_id: ZodOptional>>; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "PermitRevokeInput", "kind": "type", "doc_comment": "Input for `permit_revoke`. Admin-only mutation that revokes an active\npermit on a target actor. `actor_id` is the natural key — permits are\nactor-scoped, and the admin UI reads `row.actor.id` straight from the\nlisting. Deriving `actor_id` from `account_id` would collapse under\nmulti-actor accounts.", - "source_line": 102, - "type_signature": "ZodObject<{ actor_id: $ZodBranded; permit_id: $ZodBranded; reason: ZodOptional>; }, $strict>" + "source_line": 122, + "type_signature": "ZodObject<{ actor_id: $ZodBranded; permit_id: $ZodBranded; reason: ZodOptional>; acting: ZodOptional<...>; }, $strict>" }, { "name": "PermitOfferHistoryInput", "kind": "type", "doc_comment": "Input for `permit_offer_history`. Returns every offer involving the account\nin either direction (recipient or grantor), including terminal rows, newest\nfirst. `account_id` is admin-only.", - "source_line": 117, - "type_signature": "ZodObject<{ account_id: ZodOptional>>; limit: ZodOptional>; offset: ZodOptional<...>; }, $strict>" + "source_line": 138, + "type_signature": "ZodObject<{ account_id: ZodOptional>>; limit: ZodOptional>; offset: ZodOptional<...>; acting: ZodOptional<...>; }, $strict>" }, { "name": "PermitOfferCreateOutput", "kind": "type", "doc_comment": "Output for `permit_offer_create`.", - "source_line": 131, - "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" + "source_line": 153, + "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" }, { "name": "PermitOfferAcceptOutput", "kind": "type", "doc_comment": "Output for `permit_offer_accept`.", - "source_line": 137, - "type_signature": "ZodObject<{ permit_id: $ZodBranded; offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; superseded_offer_ids: ZodArray<...>; }, $strict>" + "source_line": 159, + "type_signature": "ZodObject<{ permit_id: $ZodBranded; offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; ... 12 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; superseded_offer_ids: ZodArray<...>; }, $strict>" }, { "name": "PermitOfferOkOutput", "kind": "type", "doc_comment": "Output for `permit_offer_decline` / `permit_offer_retract`.", - "source_line": 145, + "source_line": 167, "type_signature": "ZodObject<{ ok: ZodLiteral; }, $strict>" }, { "name": "PermitOfferListOutput", "kind": "type", "doc_comment": "Output for `permit_offer_list`.", - "source_line": 149, - "type_signature": "ZodObject<{ offers: ZodArray; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>>; }, $strict>" + "source_line": 171, + "type_signature": "ZodObject<{ offers: ZodArray; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>>; }, $strict>" }, { "name": "PermitOfferHistoryOutput", "kind": "type", "doc_comment": "Output for `permit_offer_history`.", - "source_line": 153, - "type_signature": "ZodObject<{ offers: ZodArray; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>>; }, $strict>" + "source_line": 175, + "type_signature": "ZodObject<{ offers: ZodArray; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>>; }, $strict>" }, { "name": "PermitRevokeOutput", "kind": "type", "doc_comment": "Output for `permit_revoke`.", - "source_line": 157, + "source_line": 179, "type_signature": "ZodObject<{ ok: ZodLiteral; revoked: ZodLiteral; }, $strict>" }, { "name": "permit_offer_create_action_spec", "kind": "variable", - "source_line": 165, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ to_account_id: $ZodBranded; role: ZodString; scope_id: ZodOptional<...>; message: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: s..." + "source_line": 187, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ to_account_id: $ZodBranded; ... 4 more ...; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; error_reasons: (\"offer_..." }, { "name": "permit_offer_accept_action_spec", "kind": "variable", - "source_line": 183, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ offer_id: $ZodBranded; }, $strict>; output: ZodObject<...>; async: true; description: string; error_reasons: (\"offer_terminal\" | ... 1 more ... | \"offer_not_found\")..." + "source_line": 206, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ offer_id: $ZodBranded; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; error_reasons: (\"offer_terminal\" | ... 2 mor..." }, { "name": "permit_offer_decline_action_spec", "kind": "variable", - "source_line": 197, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ offer_id: $ZodBranded; reason: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; error_reasons: (\"offer_terminal\" | \"offer_no..." + "source_line": 225, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ offer_id: $ZodBranded; reason: ZodOptional<...>; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; error_reasons: (\"o..." }, { "name": "permit_offer_retract_action_spec", "kind": "variable", - "source_line": 210, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ offer_id: $ZodBranded; }, $strict>; output: ZodObject<...>; async: true; description: string; error_reasons: (\"offer_terminal\" | \"offer_not_found\")[]; }" + "source_line": 238, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ offer_id: $ZodBranded; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; error_reasons: (\"offer_terminal\" | \"offer_no..." }, { "name": "permit_offer_list_action_spec", "kind": "variable", - "source_line": 223, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: false; input: ZodObject<{ account_id: ZodOptional>>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" + "source_line": 251, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: false; input: ZodObject<{ account_id: ZodOptional>>; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" }, { "name": "permit_offer_history_action_spec", "kind": "variable", - "source_line": 236, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: false; input: ZodObject<{ account_id: ZodOptional>>; limit: ZodOptional<...>; offset: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description..." + "source_line": 264, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: false; input: ZodObject<{ account_id: ZodOptional>>; limit: ZodOptional<...>; offset: ZodOptional<...>; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>..." }, { "name": "permit_revoke_action_spec", "kind": "variable", - "source_line": 249, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ actor_id: $ZodBranded; permit_id: $ZodBranded<...>; reason: ZodOptional<...>; }, $strict>; ... 4 more ...; rate_limit: \"account\"; }" + "source_line": 277, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: { role: string; }; side_effects: true; input: ZodObject<{ actor_id: $ZodBranded; permit_id: $ZodBranded<...>; reason: ZodOptional<...>; acting: ZodOptional<...>; }, $strict>; ... 4 more ...; rate_limit: \"account\"; }" }, { "name": "all_permit_offer_action_specs", "kind": "variable", "doc_comment": "All permit-offer action specs — a codegen-ready registry. Consumers spread\nthis into their own action-spec array to include offer lifecycle + revoke\nmethods in a typed client surface.", - "source_line": 269, + "source_line": 297, "type_signature": "{ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; }[]" } ], - "module_comment": "Permit offer RPC action specs — declarative contract for the\nconsentful-permits surface (offer lifecycle + admin revoke).\n\nImport this module for the specs, Input/Output schemas, `ERROR_OFFER_*`\nreason constants, and the `all_permit_offer_action_specs` registry.\nHandlers live in `auth/permit_offer_actions.ts`.\n\nAuthorization enforcement: offer-lifecycle specs declare\n`auth: 'authenticated'` and rely on `query_*` IDOR guards or in-handler\npolicy checks (e.g. `permit_offer_list`/`_history` elevate to admin only\nwhen inspecting another account — an input-dependent check that can't be\nexpressed at the spec level). `permit_revoke` declares\n`auth: {role: 'admin'}` — the RPC dispatcher's per-spec `check_action_auth`\ngates it before the handler runs even though the endpoint hosts non-admin\nmethods alongside.", + "module_comment": "Permit offer RPC action specs — declarative contract for the\nconsentful-permits surface (offer lifecycle + admin revoke).\n\nImport this module for the specs, Input/Output schemas, `ERROR_OFFER_*`\nreason constants, and the `all_permit_offer_action_specs` registry.\nHandlers live in `auth/permit_offer_actions.ts`.\n\nAuthorization enforcement: offer-lifecycle specs declare\n`auth: 'authenticated'` and rely on `query_*` IDOR guards or in-handler\npolicy checks (e.g. `permit_offer_list`/`_history` elevate to admin only\nwhen inspecting another account — an input-dependent check that can't be\nexpressed at the spec level). `permit_revoke` declares\n`auth: {role: 'admin'}` — the RPC dispatcher's per-spec post-authorization\nauth gate (`check_action_auth_post_authorization`) rejects non-admin\ncallers before the handler runs even though the endpoint hosts non-admin\nmethods alongside.", "dependencies": [ "auth/account_schema.ts", "auth/permit_offer_schema.ts", @@ -8625,14 +8753,14 @@ "name": "PermitOfferCreateAuthorize", "kind": "type", "doc_comment": "Authorization callback for `permit_offer_create`. Returns `true` to allow,\n`false` to reject (handler converts to `forbidden`).\n\nProvided with the fully-resolved request context and the parsed input\n(pre-TTL, pre-normalization). Consumers override the default to implement\npolicies like \"teacher may offer classroom_student only in classrooms they\nteach\".", - "source_line": 115, + "source_line": 125, "type_signature": "PermitOfferCreateAuthorize" }, { "name": "PermitOfferActionOptions", "kind": "type", "doc_comment": "Options for `create_permit_offer_actions`.", - "source_line": 123, + "source_line": 133, "type_signature": "PermitOfferActionOptions", "properties": [ { @@ -8659,7 +8787,7 @@ "name": "authorize_admin_or_holder", "kind": "function", "doc_comment": "Authorization callback that admits any admin and otherwise falls back to\nthe symmetric default (caller must hold the offered role globally).\n\nThe `web_grantable` filter in `create_handler` runs **before** the\n`authorize` callback, so this never sees non-web-grantable roles. Drop\ninto `create_permit_offer_actions({authorize: authorize_admin_or_holder})`\n(or any factory that forwards `authorize`, e.g. `create_standard_rpc_actions`)\nfor the common \"admins offer anything; users offer what they hold\"\npattern. Scope-aware policies (e.g. classroom_teacher offering\nclassroom_student in their own scope) wrap this and short-circuit `true`\nbefore delegating.", - "source_line": 179, + "source_line": 189, "type_signature": "(auth: RequestContext, input: { to_account_id: string; role: string; scope_id: string | null; }, deps: Pick, ctx: ActionContext): boolean | Promise<...>", "return_type": "boolean | Promise", "parameters": [ @@ -8687,9 +8815,7 @@ "doc_comment": "Dependencies for `create_permit_offer_actions`.\n\n`notification_sender` is optional — when absent, WS fan-out is silently\nskipped. Consumers wiring `BackendWebsocketTransport` assign its instance\ndirectly (the transport's `send_to_account` signature accepts the broader\n`JsonrpcMessageFromServerToClient`, which is contravariantly compatible).", "source_line": 210, "type_signature": "PermitOfferActionDeps", - "extends": [ - "Pick<\n\tRouteFactoryDeps,\n\t'log' | 'on_audit_event' | 'audit_log_config'\n>" - ], + "extends": ["AuditEmitDeps"], "properties": [ { "name": "notification_sender", @@ -8703,7 +8829,7 @@ "name": "create_permit_offer_actions", "kind": "function", "doc_comment": "Create the seven permit-offer RPC actions (six offer-lifecycle methods\nplus `permit_revoke`).", - "source_line": 226, + "source_line": 223, "type_signature": "(deps: PermitOfferActionDeps, options?: PermitOfferActionOptions): RpcAction[]", "return_type": "RpcAction[]", "return_description": "the `RpcAction` array to spread into a `create_rpc_endpoint` call", @@ -8798,35 +8924,35 @@ "kind": "type", "doc_comment": "Params for `permit_offer_received` — offer delivered to its recipient.", "source_line": 75, - "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" + "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" }, { "name": "PermitOfferRetractedParams", "kind": "type", "doc_comment": "Params for `permit_offer_retracted` — grantor-side retraction.", "source_line": 81, - "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" + "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" }, { "name": "PermitOfferAcceptedParams", "kind": "type", "doc_comment": "Params for `permit_offer_accepted` — recipient accepted the offer.", "source_line": 87, - "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" + "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" }, { "name": "PermitOfferDeclinedParams", "kind": "type", "doc_comment": "Params for `permit_offer_declined`. The decline reason (if any) rides along\ninside `offer.decline_reason` — the DB stamps it on the offer row during\ndecline, so a sibling `reason` field would just duplicate it.", "source_line": 97, - "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" + "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>" }, { "name": "PermitOfferSupersedeParams", "kind": "type", "doc_comment": "Params for `permit_offer_supersede`. Fires to the grantor's sockets when\ntheir pending offer is obsoleted — either by a sibling accept\n(`reason: 'sibling_accepted'`), by revoke of the resulting permit\n(`reason: 'permit_revoked'`), or by deletion of the parent scope row\nthe offer was bound to (`reason: 'scope_destroyed'`). `cause_id` points\nat the accepted offer id, the revoked permit id, or the destroyed scope\nrow id respectively.", "source_line": 111, - "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; reason: ZodEnum<...>; cause_id: $ZodBranded<...>; }, $strict>" + "type_signature": "ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; reason: ZodEnum<...>; cause_id: $ZodBranded<...>; }, $strict>" }, { "name": "PermitRevokeParams", @@ -8839,31 +8965,31 @@ "name": "permit_offer_received_notification_spec", "kind": "variable", "source_line": 135, - "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>; output: ZodVoid; async..." + "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 12 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>; output: ZodVoid; async..." }, { "name": "permit_offer_retracted_notification_spec", "kind": "variable", "source_line": 147, - "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>; output: ZodVoid; async..." + "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 12 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>; output: ZodVoid; async..." }, { "name": "permit_offer_accepted_notification_spec", "kind": "variable", "source_line": 159, - "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>; output: ZodVoid; async..." + "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 12 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>; output: ZodVoid; async..." }, { "name": "permit_offer_declined_notification_spec", "kind": "variable", "source_line": 171, - "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>; output: ZodVoid; async..." + "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 12 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; }, $strict>; output: ZodVoid; async..." }, { "name": "permit_offer_supersede_notification_spec", "kind": "variable", "source_line": 183, - "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; reason: ZodEnum<...>; cause_id: $Zo..." + "type_signature": "{ method: string; kind: \"remote_notification\"; initiator: \"backend\"; auth: null; side_effects: true; input: ZodObject<{ offer: ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded<...>; ... 12 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>; reason: ZodEnum<...>; cause_id: $Zo..." }, { "name": "permit_revoke_notification_spec", @@ -8882,12 +9008,12 @@ "name": "build_permit_offer_received_notification", "kind": "function", "source_line": 227, - "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }): { ...; }", + "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }): { ...; }", "return_type": "{ [x: string]: unknown; jsonrpc: \"2.0\"; method: string; params?: { [x: string]: unknown; } | undefined; }", "parameters": [ { "name": "params", - "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }" + "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }" } ] }, @@ -8895,12 +9021,12 @@ "name": "build_permit_offer_retracted_notification", "kind": "function", "source_line": 232, - "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }): { ...; }", + "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }): { ...; }", "return_type": "{ [x: string]: unknown; jsonrpc: \"2.0\"; method: string; params?: { [x: string]: unknown; } | undefined; }", "parameters": [ { "name": "params", - "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }" + "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }" } ] }, @@ -8908,12 +9034,12 @@ "name": "build_permit_offer_accepted_notification", "kind": "function", "source_line": 237, - "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }): { ...; }", + "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }): { ...; }", "return_type": "{ [x: string]: unknown; jsonrpc: \"2.0\"; method: string; params?: { [x: string]: unknown; } | undefined; }", "parameters": [ { "name": "params", - "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }" + "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }" } ] }, @@ -8921,12 +9047,12 @@ "name": "build_permit_offer_declined_notification", "kind": "function", "source_line": 242, - "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }): { ...; }", + "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }): { ...; }", "return_type": "{ [x: string]: unknown; jsonrpc: \"2.0\"; method: string; params?: { [x: string]: unknown; } | undefined; }", "parameters": [ { "name": "params", - "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }" + "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; }" } ] }, @@ -8934,12 +9060,12 @@ "name": "build_permit_offer_supersede_notification", "kind": "function", "source_line": 247, - "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; reason: \"sibling_accepted\" | ... 1 more ... | \"scope_destroyed\"; cause_id: string & $brand<...>; }): { ...; }", + "type_signature": "(params: { offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; reason: \"sibling_accepted\" | ... 1 more ... | \"scope_destroyed\"; cause_id: string & $brand<...>; }): { ...; }", "return_type": "{ [x: string]: unknown; jsonrpc: \"2.0\"; method: string; params?: { [x: string]: unknown; } | undefined; }", "parameters": [ { "name": "params", - "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }; reason: \"sibling_accepted\" | ... 1 more ... | \"scope_destroyed\"; cause_..." + "type": "{ offer: { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }; reason: \"sibling_accepted\" | ... 1 more ... | \"scope_destroyed\"; cause_id: str..." } ] }, @@ -8974,7 +9100,7 @@ "name": "PermitOfferAlreadyTerminalError", "kind": "class", "doc_comment": "Error thrown by offer-lifecycle queries when the offer is in a non-pending\nstate (accepted / declined / retracted / superseded) and therefore not\nactionable. Distinct from `PermitOfferExpiredError` — expiry has its own\nuser-facing story (\"ask the grantor to re-send\") so it travels separately.", - "source_line": 36, + "source_line": 35, "extends": ["Error"], "implements": [], "members": [ @@ -8995,7 +9121,7 @@ "name": "PermitOfferExpiredError", "kind": "class", "doc_comment": "Error thrown when an offer's `expires_at` has passed. The accept path\nenforces this independently of the sweep — a stale offer past its expiry\nmust not be accepted, even in the race window between expiry and the\nsweep stamping the audit event.", - "source_line": 49, + "source_line": 48, "extends": ["Error"], "implements": [], "members": [ @@ -9016,7 +9142,7 @@ "name": "PermitOfferNotFoundError", "kind": "class", "doc_comment": "Error thrown when an offer cannot be located for the caller. Covers both\n\"offer does not exist\" and \"offer belongs to a different recipient\"\n(IDOR guard) — the standard 404-over-403 pattern that avoids disclosing\nwhether an offer id exists.", - "source_line": 62, + "source_line": 61, "extends": ["Error"], "implements": [], "members": [ @@ -9036,8 +9162,8 @@ { "name": "PermitOfferSelfTargetError", "kind": "class", - "doc_comment": "Error thrown when a grantor attempts to offer a permit to their own account.\n\nEnforced here (rather than via a CHECK constraint) so the constraint can\nbe expressed as a cross-row JOIN on `actor.account_id` without requiring\ndenormalized columns.", - "source_line": 76, + "doc_comment": "Error thrown when a grantor attempts to offer a permit to their own account.\n\nEnforced via a single SELECT on the grantor's `actor.account_id` (rather\nthan via a CHECK constraint or a denormalized column). Resolving from the\ngrantor side keeps the check multi-actor-correct: under multi-actor the\nrecipient account may host many actors, but the grantor → account binding\nremains 1:1 by definition of `actor`.", + "source_line": 77, "extends": ["Error"], "implements": [], "members": [ @@ -9049,17 +9175,58 @@ } ] }, + { + "name": "PermitOfferActorMismatchError", + "kind": "class", + "doc_comment": "Error thrown when an actor-targeted offer is being accepted by an actor\nother than `offer.to_actor_id`. Distinct from `PermitOfferNotFoundError`\n(the IDOR mask): once an offer has been resolved to the recipient account,\na wrong-actor accept on a same-account actor is a contract violation, not\na privacy boundary — surface a specific error so the client UI can\ndistinguish \"this offer isn't for you\" from \"no such offer\".", + "source_line": 92, + "extends": ["Error"], + "implements": [], + "members": [ + { + "name": "constructor", + "kind": "constructor", + "type_signature": "(offer_id: string): PermitOfferActorMismatchError", + "parameters": [ + { + "name": "offer_id", + "type": "string" + } + ] + } + ] + }, + { + "name": "PermitOfferActorAccountMismatchError", + "kind": "class", + "doc_comment": "Error thrown when `query_permit_offer_create` is called with a\n`to_actor_id` that does not exist or does not belong to `to_account_id`.\nSurfaces the actor↔account binding mismatch at the boundary instead of\nletting the FK silently disagree with the recipient field.", + "source_line": 105, + "extends": ["Error"], + "implements": [], + "members": [ + { + "name": "constructor", + "kind": "constructor", + "type_signature": "(): PermitOfferActorAccountMismatchError", + "parameters": [] + } + ] + }, { "name": "query_permit_offer_create", "kind": "function", - "doc_comment": "Create a new permit offer, or refresh an existing pending offer for the\nsame `(to_account_id, role, scope_id, from_actor_id)` tuple.\n\nRe-offer semantics: a second call by the same grantor with the same\n`(to_account, role, scope)` while pending upserts the existing row,\nrefreshing `message` and `expires_at`. A different grantor offering the\nsame `(to_account, role, scope)` creates a distinct row — multiple\npending grantors coexist. After a terminal state, a re-offer is a fresh\nINSERT.\n\nSelf-offer rejection: throws `PermitOfferSelfTargetError` if the offering\nactor belongs to the recipient account.", + "doc_comment": "Create a new permit offer, or refresh an existing pending offer for the\nsame `(to_account_id, role, scope_id, from_actor_id)` tuple.\n\nRe-offer semantics: a second call by the same grantor with the same\n`(to_account, role, scope)` while pending upserts the existing row,\nrefreshing `message` and `expires_at` (and `to_actor_id` — supplying\na different `to_actor_id` on re-offer narrows the existing row to the\nnamed actor; supplying null widens it back to account-grain). A\ndifferent grantor offering the same `(to_account, role, scope)` creates\na distinct row — multiple pending grantors coexist. After a terminal\nstate, a re-offer is a fresh INSERT.\n\nSelf-offer rejection: throws `PermitOfferSelfTargetError` if the offering\nactor belongs to the recipient account.\n\nActor-targeted offers: when `to_actor_id` is supplied,\n`query_accept_offer` rejects any actor other than the named one. Closes\nthe audit hole where offer-shape events would otherwise leave\n`target_actor_id` null even when the recipient binding is known at\noffer time. The actor↔account binding is verified here in one SELECT.", "throws": [ { "type": "PermitOfferSelfTargetError", "description": "if the offering actor belongs to `to_account_id`" + }, + { + "type": "PermitOfferActorAccountMismatchError", + "description": "if `to_actor_id` is set but does not belong to `to_account_id`" } ], - "source_line": 100, + "source_line": 138, "type_signature": "(deps: QueryDeps, input: CreatePermitOfferInput): Promise", "return_type": "Promise", "parameters": [ @@ -9073,19 +9240,35 @@ } ] }, + { + "name": "DeclinedOffer", + "kind": "type", + "doc_comment": "Result of `query_permit_offer_decline` — the declined offer plus the grantor's `account_id`.", + "source_line": 192, + "type_signature": "DeclinedOffer", + "extends": ["PermitOffer"], + "properties": [ + { + "name": "from_account_id", + "kind": "variable", + "type_signature": "Uuid", + "doc_comment": "Grantor's `account_id`, resolved via a join on `actor` so the audit\nenvelope's `target_account_id` (decline is *to* the grantor) and the\npost-commit notification target are both addressable without a\nsecond round-trip." + } + ] + }, { "name": "query_permit_offer_decline", "kind": "function", - "doc_comment": "Mark an offer declined.\n\nGuarded by `to_account_id` (IDOR). Returns `null` if the offer does not\nexist or belongs to a different account. Throws\n`PermitOfferAlreadyTerminalError` if the offer exists for the caller but\nis already in a terminal state.", + "doc_comment": "Mark an offer declined.\n\nGuarded by `to_account_id` (IDOR). Returns `null` if the offer does not\nexist or belongs to a different account. Throws\n`PermitOfferAlreadyTerminalError` if the offer exists for the caller but\nis already in a terminal state.\n\nReturns the declined offer with the grantor's `from_account_id` joined\nin via CTE — the decline audit envelope populates **both**\n`target_actor_id` (the grantor actor) and `target_account_id` (the\ngrantor account), satisfying the \"both populated → same account\"\ninvariant the audit-log column comments describe.", "throws": [ { "type": "PermitOfferAlreadyTerminalError", "description": "if the offer is already accepted, declined, retracted, or superseded" } ], - "source_line": 141, - "type_signature": "(deps: QueryDeps, offer_id: string, to_account_id: string, reason: string | null): Promise", - "return_type": "Promise", + "source_line": 219, + "type_signature": "(deps: QueryDeps, offer_id: string, to_account_id: string, reason: string | null): Promise", + "return_type": "Promise", "parameters": [ { "name": "deps", @@ -9115,7 +9298,7 @@ "description": "if the offer is already accepted, declined, retracted, or superseded" } ], - "source_line": 174, + "source_line": 257, "type_signature": "(deps: QueryDeps, offer_id: string, from_actor_id: string): Promise", "return_type": "Promise", "parameters": [ @@ -9137,7 +9320,7 @@ "name": "query_permit_offer_list", "kind": "function", "doc_comment": "List pending, non-expired offers for an account, soonest expiry first.\n\nExpired offers are filtered server-side (`expires_at > NOW()`) so the\ninbox never surfaces a row that can no longer be accepted. The periodic\nsweep (`query_permit_offer_sweep_expired`) handles audit tombstoning.", - "source_line": 230, + "source_line": 313, "type_signature": "(deps: QueryDeps, to_account_id: string): Promise", "return_type": "Promise", "parameters": [ @@ -9155,7 +9338,7 @@ "name": "query_permit_offer_history_for_account", "kind": "function", "doc_comment": "List every offer involving an account (either direction), newest first.\n\nIncludes terminal offers — used by the grantor-side admin / history view.", - "source_line": 252, + "source_line": 335, "type_signature": "(deps: QueryDeps, account_id: string, limit?: number, offset?: number): Promise", "return_type": "Promise", "parameters": [ @@ -9183,7 +9366,7 @@ "name": "query_permit_offer_find_pending", "kind": "function", "doc_comment": "Look up a pending offer by id. Returns `null` if the offer is terminal,\nexpired (server-side filter), or missing.", - "source_line": 272, + "source_line": 355, "type_signature": "(deps: QueryDeps, offer_id: string): Promise", "return_type": "Promise", "parameters": [ @@ -9201,7 +9384,7 @@ "name": "query_permit_offer_sweep_expired", "kind": "function", "doc_comment": "Return pending offers whose `expires_at` has passed.\n\nCallers fire `permit_offer_expire` audit events for each row. The schema\ndoes not tombstone the row, so callers are responsible for their own\nidempotency (e.g. check whether a `permit_offer_expire` audit event\nalready exists for the offer id).", - "source_line": 297, + "source_line": 380, "type_signature": "(deps: QueryDeps): Promise", "return_type": "Promise", "parameters": [ @@ -9215,7 +9398,7 @@ "name": "AcceptOfferInput", "kind": "type", "doc_comment": "Input for `query_accept_offer`.", - "source_line": 312, + "source_line": 395, "type_signature": "AcceptOfferInput", "properties": [ { @@ -9229,6 +9412,12 @@ "type_signature": "Uuid", "doc_comment": "Account of the accepting recipient — IDOR guard against another account accepting the offer." }, + { + "name": "actor_id", + "kind": "variable", + "type_signature": "Uuid", + "doc_comment": "Accepting actor — the actor that will hold the resulting permit.\nMust belong to `to_account_id`; the query verifies and throws if not\n(defense-in-depth — the action handler passes `auth.actor.id` which\nis session-bound, but the query enforces the invariant for all\ncallers including tests and future direct consumers).\n\nRequired because under multi-actor an account may host many actors;\nthe resulting permit must bind to the actor that actually accepted,\nnot \"an\" actor on the account picked by query order." + }, { "name": "ip", "kind": "variable", @@ -9241,7 +9430,7 @@ "name": "AcceptOfferResult", "kind": "type", "doc_comment": "Result of `query_accept_offer` — the permit produced (new or pre-existing on race), plus the (now-accepted) offer.", - "source_line": 321, + "source_line": 416, "type_signature": "AcceptOfferResult", "properties": [ { @@ -9293,10 +9482,10 @@ }, { "type": "Error", - "description": "if the accepting account has no actor (1:1 invariant) or invariant assertions fail" + "description": "if the accepting `actor_id` does not belong to `to_account_id`, or invariant assertions fail" } ], - "source_line": 369, + "source_line": 464, "type_signature": "(deps: QueryDeps, input: AcceptOfferInput): Promise", "return_type": "Promise", "parameters": [ @@ -9313,7 +9502,6 @@ ], "module_comment": "Permit offer database queries.\n\nCovers the offer side of the consentful-permits flow: create (with\nre-offer upsert), decline, retract, list, find-pending, sweep-expired,\nand the atomic `query_accept_offer` that bridges offer → permit.\n\nIDOR guards are expressed in each helper's signature — decline/accept\nrequire the recipient's `to_account_id`, retract requires the grantor's\n`from_actor_id`.", "dependencies": [ - "auth/account_queries.ts", "auth/audit_log_queries.ts", "auth/permit_offer_schema.ts", "db/assert_row.ts" @@ -9353,27 +9541,27 @@ "name": "PERMIT_OFFER_SCHEMA", "kind": "variable", "source_line": 27, - "type_signature": "\"\\nCREATE TABLE IF NOT EXISTS permit_offer (\\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\\n from_actor_id UUID NOT NULL REFERENCES actor(id) ON DELETE CASCADE,\\n to_account_id UUID NOT NULL REFERENCES account(id) ON DELETE CASCADE,\\n role TEXT NOT NULL,\\n scope_id UUID NULL,\\n message TEXT NULL,\\n created..." + "type_signature": "\"\\nCREATE TABLE IF NOT EXISTS permit_offer (\\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\\n from_actor_id UUID NOT NULL REFERENCES actor(id) ON DELETE CASCADE,\\n to_account_id UUID NOT NULL REFERENCES account(id) ON DELETE CASCADE,\\n to_actor_id UUID NULL REFERENCES actor(id) ON DELETE CASCADE,\\n role TEXT ..." }, { "name": "PERMIT_OFFER_PENDING_UNIQUE_INDEX", "kind": "variable", "doc_comment": "At most one pending offer per (to_account, role, scope, from_actor).\n\nIncluding `from_actor_id` in the tuple lets multiple grantors coexist —\nteacher A and teacher B can each have a pending `classroom_student` offer\nfor the same student and scope. A same-grantor re-offer upserts the\nexisting pending row. `COALESCE` collapses `NULL` scopes into the\nsentinel UUID so Postgres's NULL-in-unique-index quirk does not allow\nduplicate global pending offers. The ON CONFLICT target in\n`query_permit_offer_create` must match this expression literally.", - "source_line": 69, + "source_line": 70, "type_signature": "\"\\nCREATE UNIQUE INDEX IF NOT EXISTS permit_offer_pending_unique\\n ON permit_offer (\\n to_account_id,\\n role,\\n COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'::uuid),\\n from_actor_id\\n )\\n WHERE accepted_at IS NULL\\n AND declined_at IS NULL\\n AND retracted_at IS NULL\\n AND supersed..." }, { "name": "PERMIT_OFFER_INBOX_INDEX", "kind": "variable", "doc_comment": "Inbox lookup — pending offers for an account, ordered by soonest expiry.", - "source_line": 83, + "source_line": 84, "type_signature": "\"\\nCREATE INDEX IF NOT EXISTS permit_offer_inbox\\n ON permit_offer (to_account_id, expires_at)\\n WHERE accepted_at IS NULL\\n AND declined_at IS NULL\\n AND retracted_at IS NULL\\n AND superseded_at IS NULL\"" }, { "name": "PermitOffer", "kind": "type", "doc_comment": "Permit offer row as returned by the database.", - "source_line": 92, + "source_line": 99, "type_signature": "PermitOffer", "properties": [ { @@ -9391,6 +9579,12 @@ "kind": "variable", "type_signature": "Uuid" }, + { + "name": "to_actor_id", + "kind": "variable", + "type_signature": "Uuid | null", + "doc_comment": "Optional actor-grain target on the recipient account. When set, accept\nis gated to this specific actor — `query_accept_offer` rejects any\nother actor with `permit_offer_actor_mismatch` even when they belong\nto `to_account_id`. When null the offer is account-grain and any\nactor on `to_account_id` may accept (the v1 default).\n\nDrives the audit envelope's `target_actor_id` on offer-shape events\n(`permit_offer_create` / `_expire` / `_retract` / `_supersede`) — when\nset, the actor-grain forensic field carries the named actor; when\nnull the offer-shape events leave it null by design." + }, { "name": "role", "kind": "variable", @@ -9453,7 +9647,7 @@ "name": "SupersededOffer", "kind": "type", "doc_comment": "A superseded offer row annotated with the grantor's `account_id`.\n\nCarried by `superseded_offers` in accept/revoke query results so callers\ncan fan out `permit_offer_supersede` notifications to the grantor's\nsockets without a second round-trip. Populated via a CTE join on `actor`\nin the supersede UPDATE.", - "source_line": 123, + "source_line": 143, "type_signature": "SupersededOffer", "extends": ["PermitOffer"], "properties": [ @@ -9468,7 +9662,7 @@ "name": "CreatePermitOfferInput", "kind": "type", "doc_comment": "Input for `query_permit_offer_create`.\n\n`expires_at` must be supplied — the query layer does not apply a default,\nso callers can thread their own TTL (typically `PERMIT_OFFER_DEFAULT_TTL_MS`).", - "source_line": 133, + "source_line": 153, "type_signature": "CreatePermitOfferInput", "properties": [ { @@ -9481,6 +9675,12 @@ "kind": "variable", "type_signature": "Uuid" }, + { + "name": "to_actor_id", + "kind": "variable", + "type_signature": "Uuid | null", + "doc_comment": "Optional actor-grain target on the recipient account. When set,\n`query_permit_offer_create` validates that the actor belongs to\n`to_account_id` and stamps the column; accept then matches against\nthis specific actor. Omit (or pass null) for the account-grain\ndefault — any actor on `to_account_id` may accept." + }, { "name": "role", "kind": "variable", @@ -9507,16 +9707,16 @@ "name": "PermitOfferJson", "kind": "type", "doc_comment": "Zod schema for client-safe permit offer data.", - "source_line": 143, - "type_signature": "ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 10 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>" + "source_line": 171, + "type_signature": "ZodObject<{ id: $ZodBranded; from_actor_id: $ZodBranded; to_account_id: $ZodBranded; ... 11 more ...; resulting_permit_id: ZodNullable<...>; }, $strict>" }, { "name": "to_permit_offer_json", "kind": "function", "doc_comment": "Convert a `PermitOffer` row to its JSON payload shape.", - "source_line": 191, - "type_signature": "(offer: PermitOffer): { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }", - "return_type": "{ id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<\"Uuid\">) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; }", + "source_line": 223, + "type_signature": "(offer: PermitOffer): { id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; ... 11 more ...; resulting_permit_id: (string & $brand<...>) | null; }", + "return_type": "{ id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; }", "parameters": [ { "name": "offer", @@ -9565,11 +9765,11 @@ { "name": "query_permit_find_active_role_for_actor", "kind": "function", - "doc_comment": "Look up the role of an active permit, constrained to a specific actor.\n\nUsed by admin routes to inspect the permit's role before acting\n(e.g., enforcing `web_grantable` on revoke). The actor constraint\nmirrors `query_revoke_permit` so IDOR protection is consistent:\na caller can only see permits belonging to the target actor.\n\nReturns `null` if the permit is not found, already revoked, or\nbelongs to a different actor.", - "source_line": 82, - "type_signature": "(deps: QueryDeps, permit_id: string, actor_id: string): Promise<{ role: string; } | null>", - "return_type": "Promise<{ role: string; } | null>", - "return_description": "`{role}` on a match, or `null`", + "doc_comment": "Look up the role of an active permit (constrained to a specific\nactor) plus the actor's `account_id`.\n\nUsed by admin routes to inspect the permit's role before acting\n(e.g., enforcing `web_grantable` on revoke). The actor constraint\nmirrors `query_revoke_permit` so IDOR protection is consistent:\na caller can only see permits belonging to the target actor.\n\nThe JOIN to `actor` collapses what used to be a second\n`query_actor_by_id` round-trip in the revoke handler into one read,\nwhich closes the small TOCTOU window where the actor row could be\ndeleted between the IDOR check and the actor lookup. The `account_id`\nis needed by the audit envelope's `target_account_id` field and the\nSSE/WS socket-close fan-out targeting.\n\nReturns `null` if the permit is not found, already revoked, or\nbelongs to a different actor.", + "source_line": 90, + "type_signature": "(deps: QueryDeps, permit_id: string, actor_id: string): Promise<{ role: string; account_id: string & $brand<\"Uuid\">; } | null>", + "return_type": "Promise<{ role: string; account_id: string & $brand<\"Uuid\">; } | null>", + "return_description": "`{role, account_id}` on a match, or `null`", "parameters": [ { "name": "deps", @@ -9592,7 +9792,7 @@ "name": "RevokePermitResult", "kind": "type", "doc_comment": "Result of `query_revoke_permit` — the revoked permit plus any pending offers superseded by the revoke.", - "source_line": 96, + "source_line": 106, "type_signature": "RevokePermitResult", "properties": [ { @@ -9622,7 +9822,7 @@ "name": "query_revoke_permit", "kind": "function", "doc_comment": "Revoke a permit by id, constrained to a specific actor.\n\nRequires `actor_id` to prevent cross-account revocation (IDOR guard).\nReturns `null` if the permit is not found, already revoked, or belongs\nto a different actor.\n\nSupersedes any pending offers for the revoked permit's\n`(to_account, role, scope)` in the same transaction. Prevents the\n\"accept a pre-revoke offer to bypass the revoke\" path — any stale\noffer becomes terminal at revoke time. A fresh post-revoke grant\nrequires the grantor to call `query_permit_offer_create` again.", - "source_line": 133, + "source_line": 143, "type_signature": "(deps: QueryDeps, permit_id: string & $brand<\"Uuid\">, actor_id: string & $brand<\"Uuid\">, revoked_by: (string & $brand<\"Uuid\">) | null, reason?: string | ... 1 more ... | undefined): Promise<...>", "return_type": "Promise", "parameters": [ @@ -9658,7 +9858,7 @@ "name": "query_permit_find_active_for_actor", "kind": "function", "doc_comment": "Find all active (non-revoked, non-expired) permits for an actor.", - "source_line": 182, + "source_line": 192, "type_signature": "(deps: QueryDeps, actor_id: string): Promise", "return_type": "Promise", "parameters": [ @@ -9676,7 +9876,7 @@ "name": "query_permit_has_role", "kind": "function", "doc_comment": "Check if an actor has an active permit for a given role.\n\nThe `scope_id` parameter selects between global and scoped checks:\n- Omitted or `null` — matches a global permit (`scope_id IS NULL`).\n Pre-scope callers keep their existing semantics.\n- A scope uuid — matches a permit bound to that exact scope.\n\nThe `IS NOT DISTINCT FROM` comparison handles the NULL case uniformly.", - "source_line": 206, + "source_line": 216, "type_signature": "(deps: QueryDeps, actor_id: string, role: string, scope_id?: string | null | undefined): Promise", "return_type": "Promise", "parameters": [ @@ -9703,7 +9903,7 @@ "name": "query_permit_list_for_actor", "kind": "function", "doc_comment": "List all permits for an actor (including revoked/expired).", - "source_line": 229, + "source_line": 239, "type_signature": "(deps: QueryDeps, actor_id: string): Promise", "return_type": "Promise", "parameters": [ @@ -9721,7 +9921,7 @@ "name": "query_permit_find_account_id_for_role", "kind": "function", "doc_comment": "Find the account ID of an account that holds an active permit for a given role.\n\nJoins permit → actor → account. Returns the first match, or `null` if none.", - "source_line": 248, + "source_line": 258, "type_signature": "(deps: QueryDeps, role: string): Promise", "return_type": "Promise", "return_description": "the account ID, or `null`", @@ -9742,14 +9942,14 @@ "name": "RevokeForScopeResult", "kind": "type", "doc_comment": "Result of `query_permit_revoke_for_scope` — every permit revoked plus every pending offer superseded by the scope-wide cascade.", - "source_line": 267, + "source_line": 277, "type_signature": "RevokeForScopeResult", "properties": [ { "name": "revoked", "kind": "variable", - "type_signature": "Array<{permit_id: Uuid; role: string; scope_id: Uuid; account_id: Uuid}>", - "doc_comment": "One entry per permit revoked by this call. Carries the revokee's\n`account_id` so callers can fan out a `permit_revoke` notification per\npermit. Empty array means no active permit was bound to the scope." + "type_signature": "Array<{\n\t\tpermit_id: Uuid;\n\t\trole: string;\n\t\tscope_id: Uuid;\n\t\tactor_id: Uuid;\n\t\taccount_id: Uuid;\n\t}>", + "doc_comment": "One entry per permit revoked by this call. Carries both the revokee's\n`actor_id` (the permit's grantee — drives `target_actor_id` audit\nenvelopes) and `account_id` (the actor's account — drives\n`target_account_id` for SSE/WS socket-close fan-out). Empty array\nmeans no active permit was bound to the scope." }, { "name": "superseded_offers", @@ -9763,7 +9963,7 @@ "name": "query_permit_revoke_for_scope", "kind": "function", "doc_comment": "Revoke every active permit bound to a scope and supersede every pending\noffer at the scope, in one cascade.\n\nUse this from a consumer's parent-scope delete handler (e.g., classroom\ndeletion) — `permit.scope_id` and `permit_offer.scope_id` are polymorphic\nwith no FK constraint by design, so a parent row deletion would otherwise\norphan permits and offers. The cascade is **role-agnostic**: anything\nattached to the destroyed scope is cleaned up.\n\nBoth updates run as separate statements inside the caller's transaction\n(mirrors `query_permit_revoke_role`'s shape). The two halves are\nindependent — orphan pending offers can exist at a scope with no active\npermits, so the supersede half always runs even when no permit was\nrevoked.", - "source_line": 312, + "source_line": 330, "type_signature": "(deps: QueryDeps, scope_id: string & $brand<\"Uuid\">, revoked_by: (string & $brand<\"Uuid\">) | null, reason?: string | null | undefined): Promise", "return_type": "Promise", "return_description": "the revoked permits (with `account_id` for fan-out) and superseded offers (with `from_account_id` for fan-out)", @@ -9795,7 +9995,7 @@ "name": "RevokeRoleResult", "kind": "type", "doc_comment": "Result of `query_permit_revoke_role` — every permit revoked plus the pending offers superseded by the bulk revoke.", - "source_line": 363, + "source_line": 383, "type_signature": "RevokeRoleResult", "properties": [ { @@ -9816,7 +10016,7 @@ "name": "query_permit_revoke_role", "kind": "function", "doc_comment": "Revoke every active permit an actor holds for a given role.\n\nWith scoped permits a single actor+role tuple can hold several active\npermits (one per scope), so this revokes all of them. Pass\n`query_revoke_permit(permit_id, ...)` when a single scoped permit\nis the target.\n\nAlso supersedes pending offers for the actor's account across every\nscope of this role (the actor can no longer hold the role, so any\npending offer of the same role is a bypass vector).", - "source_line": 400, + "source_line": 420, "type_signature": "(deps: QueryDeps, actor_id: string, role: string, revoked_by: string | null, reason?: string | null | undefined): Promise", "return_type": "Promise", "return_description": "the list of revoked permits (empty if none were active) and superseded pending offers", @@ -9870,8 +10070,8 @@ { "name": "RequestContext", "kind": "type", - "doc_comment": "The resolved identity context for an authenticated request.", - "source_line": 34, + "doc_comment": "The resolved identity context for an authenticated request.\n\n`actor` is null on account-grain routes (no `acting` field on input,\nno `role` / `keeper` auth) — those handlers don't trigger actor\nresolution. `permits` is empty in that case. Permit checks\n(`has_role`, `has_scoped_role`, `has_any_scoped_role`) are\nnull-tolerant on `RequestContext | null`; they additionally treat\n`actor: null` as \"no permits\" so callers don't have to narrow.\n\nMulti-actor invariant: when populated, `actor.account_id === account.id`.\n`build_request_context` enforces this; the dispatcher's authorization\nphase rejects with `actor_not_on_account` before reaching the handler.", + "source_line": 94, "type_signature": "RequestContext", "properties": [ { @@ -9882,7 +10082,7 @@ { "name": "actor", "kind": "variable", - "type_signature": "Actor" + "type_signature": "Actor | null" }, { "name": "permits", @@ -9895,21 +10095,21 @@ "name": "REQUEST_CONTEXT_KEY", "kind": "variable", "doc_comment": "Hono context variable name for the request context.", - "source_line": 41, + "source_line": 101, "type_signature": "\"request_context\"" }, { "name": "AUTH_SESSION_TOKEN_HASH_KEY", "kind": "variable", "doc_comment": "Hono context variable name for the authenticated session token hash.\n\nSet by `create_request_context_middleware` after a successful session lookup.\n`null` when the request is unauthenticated or authenticated via a non-session\ncredential (bearer token, daemon token). Exposed so handlers can scope\nper-session resources (e.g., SSE stream identity for targeted disconnection\non `session_revoke`) without re-hashing the token.", - "source_line": 52, + "source_line": 112, "type_signature": "\"auth_session_token_hash\"" }, { "name": "get_request_context", "kind": "function", "doc_comment": "Get the request context from a Hono context, or `null` if unauthenticated.", - "source_line": 60, + "source_line": 120, "type_signature": "(c: Context): RequestContext | null", "return_type": "RequestContext | null", "return_description": "the request context, or `null`", @@ -9924,14 +10124,14 @@ { "name": "require_request_context", "kind": "function", - "doc_comment": "Get the request context, throwing if unauthenticated.\n\nUse in route handlers where auth middleware guarantees a context exists\n(i.e., routes with `auth: {type: 'authenticated'}` or stricter).\nPrefer this over `get_request_context(c)!` for explicit error handling.", + "doc_comment": "Get the request context, throwing if unauthenticated.\n\nUse in route handlers where the dispatcher's authorization phase guarantees\na context exists (i.e., routes with `auth: {type: 'authenticated'}` or\nstricter). Prefer this over `get_request_context(c)!` for explicit error\nhandling.", "throws": [ { "type": "Error", - "description": "if no request context is set (middleware misconfiguration)" + "description": "if no request context is set (dispatcher misconfiguration)" } ], - "source_line": 75, + "source_line": 136, "type_signature": "(c: Context): RequestContext", "return_type": "RequestContext", "return_description": "the request context (never null)", @@ -9943,11 +10143,66 @@ } ] }, + { + "name": "RequestActorContext", + "kind": "type", + "doc_comment": "Request context narrowed to a resolved acting actor.\n\nReturned by `require_request_actor` for handlers whose route resolves\nan actor — actions with `auth: 'keeper' | {role}` or with input that\ndeclares `acting?: ActingActor`. Lets handlers drop the `auth.actor!`\nnon-null assertion that was masking the dispatcher invariant.", + "source_line": 154, + "type_signature": "RequestActorContext", + "extends": ["RequestContext"], + "properties": [ + { + "name": "actor", + "kind": "variable", + "type_signature": "Actor" + } + ] + }, + { + "name": "require_request_auth", + "kind": "function", + "doc_comment": "Narrow `RequestContext | null` to a non-null context (auth invariant).\n\nUse in RPC action handlers whose spec is non-public — the dispatcher's\npre-validation auth gate has already short-circuited unauthenticated\ncallers, so `ctx.auth` is non-null by the time the handler runs.", + "throws": [ + { + "type": "Error", + "description": "when called from a public-auth handler (programmer error)" + } + ], + "source_line": 167, + "type_signature": "(auth: RequestContext | null): RequestContext", + "return_type": "RequestContext", + "parameters": [ + { + "name": "auth", + "type": "RequestContext | null" + } + ] + }, + { + "name": "require_request_actor", + "kind": "function", + "doc_comment": "Narrow `RequestContext | null` to `RequestActorContext` (actor invariant).\n\nUse in RPC action handlers whose spec declares `auth: 'keeper' | {role}`\nor whose input declares `acting?: ActingActor` — the dispatcher's\nauthorization phase resolves an actor before the handler runs. Replaces\nthe `ctx.auth!.actor!.id` chain that the type system can't otherwise see.", + "throws": [ + { + "type": "Error", + "description": "when the handler runs without actor resolution (programmer error)" + } + ], + "source_line": 186, + "type_signature": "(auth: RequestContext | null): RequestActorContext", + "return_type": "RequestActorContext", + "parameters": [ + { + "name": "auth", + "type": "RequestContext | null" + } + ] + }, { "name": "has_role", "kind": "function", "doc_comment": "Check if a request context has an active permit for a given role.\n\nChecks the permits already loaded in the context (no DB query).\nNull-tolerant — `null` ctx (unauthenticated) returns `false`. Symmetric\nwith `has_scoped_role` / `has_any_scoped_role` so the three helpers\ncompose freely in the same predicate (e.g.\n`has_role(auth, ADMIN) || has_scoped_role(auth, role, scope)`).", - "source_line": 97, + "source_line": 210, "type_signature": "(ctx: RequestContext | null, role: string, now?: Date): boolean", "return_type": "boolean", "return_description": "`true` if the actor has an active permit for the role", @@ -9973,8 +10228,8 @@ { "name": "has_scoped_role", "kind": "function", - "doc_comment": "Whether the request context holds an active permit for `role` at `scope_id`.\n\nWalks the in-memory `ctx.permits` snapshot loaded once per request by\n`create_request_context_middleware`; zero DB roundtrip per check. The\n\"freshness\" framing of a SQL re-query is illusory because the race window\nis between predicate and the actual mutation, not predicate and middleware\nload. Closing that race needs a transactional re-check inside the\nUPDATE/INSERT, which neither style provides.\n\nNull-tolerant — `null` ctx (unauthenticated) returns `false`. Same\nconvention as `has_role`; lets the helper drop into `auth: 'public'`\nhandlers without a manual narrow. See `cell_authorize` for the\nresource-side analog.\n\n`scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so\nJS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly:\n\n- `scope_id === null` matches global permits (`scope_id IS NULL`).\n- `scope_id === ''` matches permits bound to that exact scope.", - "source_line": 130, + "doc_comment": "Whether the request context holds an active permit for `role` at `scope_id`.\n\nWalks the in-memory `ctx.permits` snapshot loaded once per request by\nthe route-spec / RPC dispatcher's authorization phase (when the route\ndeclares `acting?: ActingActor` or has permit-requiring auth); zero DB\nroundtrip per check. The \"freshness\" framing of a SQL re-query is\nillusory because the race window is between predicate and the actual\nmutation, not predicate and authorization load. Closing that race needs\na transactional re-check inside the UPDATE/INSERT, which neither style\nprovides.\n\nNull-tolerant — `null` ctx (unauthenticated) and account-grain\ncontexts (`actor: null`, empty `permits`) both return `false`. Same\nconvention as `has_role`; lets the helper drop into `auth: 'public'`\nor account-grain handlers without a manual narrow. See `cell_authorize`\nfor the resource-side analog.\n\n`scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so\nJS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly:\n\n- `scope_id === null` matches global permits (`scope_id IS NULL`).\n- `scope_id === ''` matches permits bound to that exact scope.", + "source_line": 246, "type_signature": "(ctx: RequestContext | null, role: string, scope_id: string | null, now?: Date): boolean", "return_type": "boolean", "return_description": "`true` iff the actor holds an active permit for the role at the requested scope", @@ -10006,7 +10261,7 @@ "name": "has_any_scoped_role", "kind": "function", "doc_comment": "Whether the request context holds an active permit for any role in `roles`\nat `scope_id`. Empty `roles` short-circuits to `false` — documents intent\nat the call site (\"zero roles trivially admit no-one\"). Same scope and\nnull-tolerance semantics as `has_scoped_role`.", - "source_line": 154, + "source_line": 270, "type_signature": "(ctx: RequestContext | null, roles: readonly string[], scope_id: string | null, now?: Date): boolean", "return_type": "boolean", "return_description": "`true` iff the actor holds an active permit for any role in `roles` at the requested scope", @@ -10034,11 +10289,43 @@ } ] }, + { + "name": "ResolveActingActorResult", + "kind": "type", + "doc_comment": "Result of `resolve_acting_actor` — either an actor id or a structured\nerror the caller maps to an HTTP response.", + "source_line": 287, + "type_signature": "ResolveActingActorResult" + }, + { + "name": "resolve_acting_actor", + "kind": "function", + "doc_comment": "Resolve the acting actor for an authenticated request.\n\nCalled from the route-spec / RPC dispatcher's authorization phase\nwith the authenticated account id and the validated `acting` value\n(from the request payload). Applies the uniform resolution rules:\n\n- `acting_actor_id` omitted + 1 actor → use it.\n- `acting_actor_id` omitted + 0 actors → `no_actors` (defensive —\n signup / bootstrap always create an actor in the same tx, so this\n is a server error).\n- `acting_actor_id` omitted + multiple actors → `actor_required` with\n the available list so the client can prompt; never pick silently.\n- `acting_actor_id` present + matches an actor on the account → use it.\n- `acting_actor_id` present + does not match → `actor_not_on_account`.\n The available list is intentionally not echoed in this branch (treat\n as opaque rejection).", + "source_line": 315, + "type_signature": "(deps: QueryDeps, account_id: string, acting_actor_id: string | undefined): Promise", + "return_type": "Promise", + "parameters": [ + { + "name": "deps", + "type": "QueryDeps", + "description": "query dependencies" + }, + { + "name": "account_id", + "type": "string", + "description": "the authenticated account" + }, + { + "name": "acting_actor_id", + "type": "string | undefined", + "description": "the requested acting actor id, or `undefined`" + } + ] + }, { "name": "create_request_context_middleware", "kind": "function", - "doc_comment": "Create middleware that builds the request context from a session cookie.\n\nReads the session identity (set by session middleware), looks up\nthe `auth_session`, loads account + actor + active permits, and\nsets the `RequestContext` on the Hono context.\n\nIf the session is invalid or the account is not found, the context\nis set to `null` (unauthenticated). No 401 is returned — use\n`require_role` or `require_auth` for enforcement.", - "source_line": 183, + "doc_comment": "Create middleware that authenticates the account from a session cookie.\n\nReads the session identity (set by session middleware), looks up the\n`auth_session`, and on a valid session sets `c.var.auth_account_id`,\n`CREDENTIAL_TYPE_KEY = 'session'`, and `AUTH_SESSION_TOKEN_HASH_KEY`.\nTouches the session (fire-and-forget). Does not load actor or permits;\n`REQUEST_CONTEXT_KEY` is left null — the route-spec / RPC dispatcher\nauthorization phase resolves the acting actor and builds the full\n`RequestContext` when the route needs one.\n\nInvalid / missing session leaves all keys null and calls `next()` —\n`require_auth` / `require_role` enforce.", + "source_line": 354, "type_signature": "(deps: QueryDeps, log: Logger, session_context_key?: string): MiddlewareHandler", "return_type": "MiddlewareHandler", "parameters": [ @@ -10063,8 +10350,8 @@ { "name": "require_auth", "kind": "function", - "doc_comment": "Middleware that requires authentication.\n\nReturns 401 if no request context is set.", - "source_line": 239, + "doc_comment": "Middleware that requires authentication.\n\nReturns 401 if the auth middleware did not set `c.var.auth_account_id`.", + "source_line": 395, "type_signature": "(c: Context, next: Next): Promise", "return_type": "Promise", "parameters": [ @@ -10081,8 +10368,8 @@ { "name": "require_role", "kind": "function", - "doc_comment": "Create middleware that requires a specific role.\n\nReturns 401 if unauthenticated, 403 if the role is missing.", - "source_line": 254, + "doc_comment": "Create middleware that requires a specific role.\n\nReturns 401 if unauthenticated, 403 if the role is missing. Reads\n`REQUEST_CONTEXT_KEY` because role-gated routes always run the\ndispatcher's authorization phase before this guard (the phase sets the\nactor-bound `RequestContext`).", + "source_line": 412, "type_signature": "(role: string): MiddlewareHandler", "return_type": "MiddlewareHandler", "parameters": [ @@ -10096,8 +10383,14 @@ { "name": "refresh_permits", "kind": "function", - "doc_comment": "Reload active permits from the database, returning a new request context.\n\nUseful for long-lived WebSocket connections where permits may change\n(grant or revoke) during the connection lifetime. Call periodically\nor after receiving a revocation signal.\n\nReturns a new `RequestContext` with updated permits — the original\ncontext is not mutated, making concurrent calls safe.", - "source_line": 281, + "doc_comment": "Reload active permits from the database, returning a new request context.\n\nUseful for long-lived WebSocket connections where permits may change\n(grant or revoke) during the connection lifetime. Call periodically\nor after receiving a revocation signal.\n\nReturns a new `RequestContext` with updated permits — the original\ncontext is not mutated, making concurrent calls safe. Throws when\n`ctx.actor` is null; account-grain contexts have no permits to refresh.", + "throws": [ + { + "type": "Error", + "description": "when called on an account-grain context (`actor: null`)" + } + ], + "source_line": 441, "type_signature": "(ctx: RequestContext, deps: QueryDeps): Promise", "return_type": "Promise", "return_description": "a new `RequestContext` with fresh permits", @@ -10117,11 +10410,37 @@ { "name": "build_request_context", "kind": "function", - "doc_comment": "Build a full `RequestContext` from an account id.\n\nShared helper used by session, bearer, and daemon token middleware,\nas well as WebSocket upgrade handlers. Does the account → actor → permits\nlookup pipeline and returns the composed context, or `null` if\nthe account or actor is not found.", - "source_line": 301, + "doc_comment": "Build a full `RequestContext` from an account id and an explicit\nactor id (already resolved via `resolve_acting_actor`).\n\nLoads `account` + the named `actor` + the actor's active permits.\nVerifies the `actor.account_id === account.id` binding so downstream\nhandlers can trust `ctx.actor.account_id === ctx.account.id`. Returns\n`null` when the account is missing, the actor is missing, or the\nactor doesn't belong to the supplied account.\n\nCalled by the route-spec / RPC dispatcher's authorization phase for\nroutes that need an acting actor; account-grain routes use\n`build_account_context` instead.", + "source_line": 471, + "type_signature": "(deps: QueryDeps, account_id: string, actor_id: string): Promise", + "return_type": "Promise", + "return_description": "a request context, or `null` if account/actor not found or mismatched", + "parameters": [ + { + "name": "deps", + "type": "QueryDeps", + "description": "query dependencies" + }, + { + "name": "account_id", + "type": "string", + "description": "the account to build context for" + }, + { + "name": "actor_id", + "type": "string", + "description": "the actor this request acts as" + } + ] + }, + { + "name": "build_account_context", + "kind": "function", + "doc_comment": "Build an account-only `RequestContext` (no actor, no permits) from\nan account id.\n\nUsed by the dispatcher's authorization phase for authenticated routes\nthat don't need an acting actor — account-grain operations (logout,\npassword change, account self-service). Lets handlers read\n`auth.account.id` / `auth.account.username` uniformly with permit-bound\nroutes; the cost is one extra `query_account_by_id` per request.\n\nReturns `null` when the account row is missing (e.g. deleted between\nthe auth middleware's session lookup and the dispatcher) — caller\nsurfaces that as a 500 since it represents a torn read.", + "source_line": 505, "type_signature": "(deps: QueryDeps, account_id: string): Promise", "return_type": "Promise", - "return_description": "a request context, or `null` if account/actor not found", + "return_description": "an account-only request context, or `null` if the account is missing", "parameters": [ { "name": "deps", @@ -10134,9 +10453,103 @@ "description": "the account to build context for" } ] + }, + { + "name": "is_actor_implying_auth", + "kind": "function", + "doc_comment": "Whether the supplied auth descriptor implies an acting actor must be\nresolved (i.e., permit-requiring auth: `'role'` or `'keeper'`).\n\nThe dispatcher's authorization phase uses this to decide whether to\nwalk the actor list when the input schema doesn't already declare\n`acting?: ActingActor`. Accepts either auth shape — the route-spec\n`RouteAuth` (`{type: 'role' | 'keeper' | ...}`) or the action-spec\n`ActionAuth` (`'keeper' | {role}`) — so HTTP and RPC dispatchers share\none source of truth for the \"permit-bound\" rule.", + "source_line": 525, + "type_signature": "(auth: \"public\" | \"authenticated\" | \"keeper\" | { role: string; } | RouteAuth): boolean", + "return_type": "boolean", + "parameters": [ + { + "name": "auth", + "type": "\"public\" | \"authenticated\" | \"keeper\" | { role: string; } | RouteAuth" + } + ] + }, + { + "name": "input_schema_declares_acting", + "kind": "function", + "doc_comment": "Whether an input schema declares the canonical `acting?: ActingActor`\nfield. Reference-equality on the exported `ActingActor` schema —\nconsumer schemas with unrelated `acting` fields don't trip this check.\n\nPeels through Zod wrappers (`optional`, `nullable`, `default`,\n`transform`, `pipe`, `prefault`) via `zod_unwrap_to_object` so a spec\nauthored as `z.optional(z.strictObject({acting: ActingActor}))` or\n`z.strictObject({acting: ActingActor}).default({})` still trips the\npredicate. The wrapper-tolerant lookup is defense-in-depth — the\ncanonical shape is the un-wrapped `z.strictObject({acting: ActingActor})`,\nbut variant B in `~/dev/grimoire/lore/fuz_app/TODO_PUBLIC_AUTH_PHASE.md`\nmakes this predicate authorization-correctness load-bearing for\n`auth: 'public'` actions, so missing a wrapper-bound declaration\nwould silently skip actor resolution. The reference-equality check\non `ActingActor` keeps consumer schemas with unrelated `acting`\nfields from tripping the predicate even after the wrapper peel.\n\nThe dispatcher's authorization phase uses this to decide whether to\npull the actor id from validated input (so multi-actor users can pick\na persona on actor-needing routes).", + "source_line": 553, + "type_signature": "(schema: ZodType>): boolean", + "return_type": "boolean", + "parameters": [ + { + "name": "schema", + "type": "ZodType>" + } + ] + }, + { + "name": "AuthorizationFailureBody", + "kind": "type", + "doc_comment": "Resolution-failure shape returned by `apply_authorization_phase`. Each\ntransport binds this to the appropriate wire shape — REST emits the body\ndirectly via `c.json(body, status)`; the RPC dispatcher folds it into a\nJSON-RPC error envelope `{jsonrpc, id, error: {code, message, data}}`.\n\nThe auth phase deliberately stops short of constructing a `Response` so\nthe same failure flows through every transport without the auth-domain\ncode knowing about JSON-RPC. See `fuz_app/CLAUDE.md` § Cleanest\narchitecture takes priority for the rationale.", + "source_line": 570, + "type_signature": "AuthorizationFailureBody" + }, + { + "name": "AuthorizationFailure", + "kind": "type", + "doc_comment": "A `(status, body)` pair the caller binds to a transport-shaped response.\n`status` is narrowed to the two values the auth phase emits — Hono's\n`c.json` status overload accepts the literals directly, and downstream\nbinders avoid casts they would otherwise need against a `number`.", + "source_line": 582, + "type_signature": "AuthorizationFailure", + "properties": [ + { + "name": "status", + "kind": "variable", + "type_signature": "400 | 500" + }, + { + "name": "body", + "kind": "variable", + "type_signature": "AuthorizationFailureBody" + } + ] + }, + { + "name": "apply_authorization_phase", + "kind": "function", + "doc_comment": "Apply the dispatcher's authorization phase. Shared by the route-spec\nwrapper and the RPC dispatcher.\n\n- When `c.var.auth_account_id` is `null`, returns `void` so the\n downstream auth guard can fire 401 (less-helpful than `actor_required`\n for the unauthenticated case).\n- When `needs_actor` is true, resolves the actor against the account\n plus the supplied `acting` value, then builds the full\n `{account, actor, permits}` context.\n- When `needs_actor` is false, builds an account-only context so\n handler signatures stay uniform across the surface.\n\nOn resolution failure returns an `AuthorizationFailure` (`{status, body}`)\nthe caller wraps in a transport-appropriate response. Three 500 branches\nare kept distinct so the wire shape names what actually went wrong:\n\n- 500 `ERROR_NO_ACTORS_ON_ACCOUNT` — `resolve_acting_actor` returned\n `no_actors`. The actor enumeration succeeded and came back empty;\n signup / bootstrap should have created one in the same transaction,\n so this is a real corruption signal.\n- 500 `ERROR_ACCOUNT_VANISHED` — `build_request_context` /\n `build_account_context` returned null after a successful\n `resolve_acting_actor`. The account or actor row was deleted between\n the credential check and authorization (torn read race), or — in\n the `build_request_context` actor↔account mismatch sub-branch — the\n binding flipped under us. Reachability of the mismatch sub-branch in\n production is essentially zero (`resolve_acting_actor` already\n verified the actor was on this account, and `actor.account_id` only\n changes via row-level edits no production path makes), so collapsing\n that case into the torn-read shape costs nothing.\n\nOther failure paths: 400 `ERROR_ACTOR_REQUIRED` / `ERROR_ACTOR_NOT_ON_ACCOUNT`.\nReturns `undefined` on success.", + "source_line": 624, + "type_signature": "(deps: QueryDeps, c: Context, needs_actor: boolean, acting_value: string | undefined): Promise", + "return_type": "Promise", + "parameters": [ + { + "name": "deps", + "type": "QueryDeps" + }, + { + "name": "c", + "type": "Context" + }, + { + "name": "needs_actor", + "type": "boolean" + }, + { + "name": "acting_value", + "type": "string | undefined" + } + ] + }, + { + "name": "create_fuz_authorization_handler", + "kind": "function", + "doc_comment": "Create the route-spec authorization handler used by `apply_route_specs`.\n\nDecides whether the route needs actor resolution from `spec.auth` plus\n`spec.input` introspection, extracts the raw `acting` value (string\ntypeguard, no schema validation), and delegates to\n`apply_authorization_phase`. Public routes (`auth.type === 'none'`) skip\nthe phase entirely; their handlers see no `RequestContext`.\n\nAuthorization runs before input validation (matches the RPC dispatcher's\norder). For GET routes `acting` comes from the URL query string; for\nmutating methods it comes from a pre-parse of the JSON body. The pre-\nparse result lands on `c.var.cached_request_body` so the subsequent\n`create_input_validation` step reads the parsed value from there\nwithout re-running `JSON.parse` — explicit cache, independent of\nHono's internal `bodyCache` behavior. A malformed body fails the\npre-parse silently (`acting` treated as undefined, cache flagged\n`{ok: false}`) and is then rejected with `ERROR_INVALID_JSON_BODY`\nby the input-validation step that reads the failure flag — producing\nthe same final response as if the validation step had parsed first.", + "source_line": 686, + "type_signature": "(deps: QueryDeps): (c: Context, spec: RouteSpec) => Promise", + "return_type": "(c: Context, spec: RouteSpec) => Promise", + "parameters": [ + { + "name": "deps", + "type": "QueryDeps" + } + ] } ], - "module_comment": "Request context middleware and permit checking helpers.\n\nBuilds `{ account, actor, permits }` from a session cookie\nfor every authenticated request. Downstream handlers check\npermits, never flags.\n\n`build_request_context` is the shared helper used by session,\nbearer, and daemon token middleware to resolve account → actor → permits.\n`refresh_permits` reloads permits on an existing context.", + "module_comment": "Request context middleware and permit checking helpers.\n\nTwo-phase identity resolution:\n\n1. **Authentication (middleware)** — `create_request_context_middleware`,\n `bearer_auth`, and `daemon_token_middleware` validate the credential\n (session cookie, bearer token, daemon token) and set `c.var.account_id`\n + `c.var.credential_type` on the Hono context. They do not resolve\n an acting actor or load permits; `REQUEST_CONTEXT_KEY` stays null at\n this stage, so account-grain identity is the only thing known.\n2. **Authorization (route-spec wrapper / RPC dispatcher)** — after input\n validation, the per-route layer inspects the route. If the input\n schema declared `acting?: ActingActor` (reference equality with the\n canonical `ActingActor` schema) or the auth requires permits\n (`role` / `keeper`), `apply_authorization_phase` resolves the actor\n against `c.var.account_id` plus the validated `acting` value via\n `resolve_acting_actor`, builds the `{account, actor, permits}`\n context via `build_request_context`, and sets it on\n `REQUEST_CONTEXT_KEY` before auth guards fire. Authenticated routes\n that don't need an actor still get an account-only context via\n `build_account_context` so handler signatures stay uniform.\n\nAccount-grain operations (logout, password_change, account_verify,\netc.) declare neither `acting` nor permit-requiring auth, so no actor\nis resolved and their handlers see a `RequestContext` with\n`actor: null` + empty `permits`. They never trigger `actor_required`,\nwhich is what makes multi-actor logout work without first picking a\npersona.\n\n`build_request_context` loads `account → actor → permits` and verifies\nthe `actor.account_id === account.id` binding. `refresh_permits`\nreloads permits on an existing context.", "dependencies": [ "auth/account_queries.ts", "auth/account_schema.ts", @@ -10149,15 +10562,15 @@ "actions/action_rpc.ts", "actions/register_action_ws.ts", "actions/register_ws_endpoint.ts", + "auth/account_actions.ts", "auth/account_routes.ts", "auth/audit_log_routes.ts", - "auth/bearer_auth.ts", - "auth/daemon_token_middleware.ts", "auth/middleware.ts", "auth/permit_offer_actions.ts", "auth/require_keeper.ts", "auth/route_guards.ts", "auth/self_service_role_actions.ts", + "server/app_server.ts", "testing/auth_apps.ts", "testing/middleware.ts", "testing/ws_round_trip.ts" @@ -10337,10 +10750,10 @@ { "name": "fuz_auth_guard_resolver", "kind": "function", - "doc_comment": "Standard auth guard resolver for fuz_app.\n\nMaps `RouteAuth` to middleware:\n- `none` → no guards\n- `authenticated` → `require_auth`\n- `role` → `require_role(role)`\n- `keeper` → `require_keeper`", - "source_line": 24, - "type_signature": "(auth: RouteAuth): MiddlewareHandler[]", - "return_type": "MiddlewareHandler[]", + "doc_comment": "Standard auth guard resolver for fuz_app.\n\nMaps `RouteAuth` to middleware:\n- `none` → no guards\n- `authenticated` → pre-validation `require_auth`\n- `role` → pre-validation `require_auth` + post-authorization `require_role(role)`\n- `keeper` → pre-validation `require_auth` + post-authorization `require_keeper`", + "source_line": 30, + "type_signature": "(auth: RouteAuth): AuthGuards", + "return_type": "AuthGuards", "parameters": [ { "name": "auth", @@ -10349,7 +10762,7 @@ ] } ], - "module_comment": "Auth guard resolver for the route spec system.\n\nMaps `RouteAuth` discriminants to auth middleware handlers.\nInjected into `apply_route_specs` to decouple the generic HTTP\nframework (`http/route_spec.ts`) from auth-specific middleware.", + "module_comment": "Auth guard resolver for the route spec system.\n\nMaps `RouteAuth` discriminants to two-phase auth middleware sets.\n`pre_validation` carries the 401 check (`require_auth`) so\nunauthenticated callers never see route-shape information from input\nparse failures. `post_authorization` carries the 403 role / keeper\nchecks because they read the `RequestContext` populated by the\ndispatcher's authorization phase.\n\nInjected into `apply_route_specs` to decouple the generic HTTP\nframework (`http/route_spec.ts`) from auth-specific middleware.", "dependencies": ["auth/request_context.ts", "auth/require_keeper.ts"], "dependents": ["server/app_server.ts", "testing/auth_apps.ts"] }, @@ -10360,39 +10773,39 @@ "name": "ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE", "kind": "variable", "doc_comment": "Error reason — caller asked to self-toggle a role outside the configured allowlist.", - "source_line": 17, + "source_line": 18, "type_signature": "\"role_not_self_service_eligible\"" }, { "name": "SelfServiceRoleSetInput", "kind": "type", "doc_comment": "Input for `self_service_role_set`.", - "source_line": 20, - "type_signature": "ZodObject<{ role: ZodString; enabled: ZodBoolean; }, $strict>" + "source_line": 21, + "type_signature": "ZodObject<{ role: ZodString; enabled: ZodBoolean; acting: ZodOptional<$ZodBranded>; }, $strict>" }, { "name": "SelfServiceRoleSetOutput", "kind": "type", "doc_comment": "Output for `self_service_role_set`. `enabled` echoes the post-call state\n(always equals the input `enabled` on success). `changed` is `true` only\nwhen the call mutated — re-grants / re-revokes return `false`.", - "source_line": 34, + "source_line": 36, "type_signature": "ZodObject<{ ok: ZodLiteral; enabled: ZodBoolean; changed: ZodBoolean; }, $strict>" }, { "name": "self_service_role_set_action_spec", "kind": "variable", - "source_line": 41, - "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ role: ZodString; enabled: ZodBoolean; }, $strict>; output: ZodObject<...>; async: true; description: string; }" + "source_line": 43, + "type_signature": "{ method: string; kind: \"request_response\"; initiator: \"frontend\"; auth: \"authenticated\"; side_effects: true; input: ZodObject<{ role: ZodString; enabled: ZodBoolean; acting: ZodOptional<...>; }, $strict>; output: ZodObject<...>; async: true; description: string; }" }, { "name": "all_self_service_role_action_specs", "kind": "variable", "doc_comment": "All self-service role action specs — a codegen-ready registry. Single-element\npost-unification, kept for symmetry with the other `all_*_action_specs`\nexports so codegen and frontend bundles import the same shape.", - "source_line": 59, + "source_line": 61, "type_signature": "readonly { method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; output: ZodType>; ... 6 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; }[]" } ], "module_comment": "Unified self-service role toggle action spec — schemas, error reasons,\nand the codegen-ready registry.\n\nClient-safe: no query-layer or audit-write imports. Handler factory\nlives in `auth/self_service_role_actions.ts`.", - "dependencies": ["auth/role_schema.ts"], + "dependencies": ["auth/account_schema.ts", "auth/role_schema.ts"], "dependents": ["auth/self_service_role_actions.ts"] }, { @@ -10422,9 +10835,9 @@ { "name": "SelfServiceRoleActionDeps", "kind": "type", - "doc_comment": "Dependencies for `create_self_service_role_actions`. Same shape as the\npeer factories so consumers thread one deps object through all three.\n`audit_log_config` flows from `AppDeps` and is consumed by\n`audit_log_fire_and_forget`.", - "source_line": 72, - "type_signature": "SelfServiceRoleActionDeps" + "doc_comment": "Dependencies for `create_self_service_role_actions`.\n\nAliases the shared `AuditEmitDeps` so consumers thread one deps object\nthrough every action factory. `audit_log_config` is consumed by\n`audit_log_fire_and_forget`.", + "source_line": 73, + "type_signature": "AuditEmitDeps" }, { "name": "create_self_service_role_actions", @@ -10436,14 +10849,14 @@ "description": "at factory time if any `eligible_roles` entry is missing from `options.roles.role_options`" } ], - "source_line": 90, - "type_signature": "(deps: SelfServiceRoleActionDeps, options: SelfServiceRoleActionsOptions): RpcAction[]", + "source_line": 83, + "type_signature": "(deps: AuditEmitDeps, options: SelfServiceRoleActionsOptions): RpcAction[]", "return_type": "RpcAction[]", "return_description": "the `RpcAction` array to spread into a `create_rpc_endpoint` call", "parameters": [ { "name": "deps", - "type": "SelfServiceRoleActionDeps", + "type": "AuditEmitDeps", "description": "`SelfServiceRoleActionDeps` slice of `AppDeps` (`log`, `on_audit_event`, optional `audit_log_config`)" }, { @@ -12458,7 +12871,7 @@ "name": "Migration", "kind": "type", "doc_comment": "A single migration: a name + an `up` function applied inside a transaction.\n\nThrow from `up` to roll back the entire chain.", - "source_line": 51, + "source_line": 54, "type_signature": "Migration", "properties": [ { @@ -12476,8 +12889,8 @@ { "name": "MigrationNamespace", "kind": "type", - "doc_comment": "A named group of ordered migrations.\n\nArray index = position in the chain. Append-only after publish.", - "source_line": 61, + "doc_comment": "A named group of ordered migrations.\n\nArray index = position in the chain. Pre-stable: bodies, names, and\npositions can change between versions (consumers re-bootstrap on upgrade).", + "source_line": 65, "type_signature": "MigrationNamespace", "properties": [ { @@ -12496,7 +12909,7 @@ "name": "MigrationResult", "kind": "type", "doc_comment": "Result of running migrations for a single namespace.", - "source_line": 67, + "source_line": 71, "type_signature": "MigrationResult", "properties": [ { @@ -12516,14 +12929,14 @@ "name": "MigrationErrorKind", "kind": "type", "doc_comment": "Tagged error vocabulary for `run_migrations` and `baseline`.\n\nCallers branch on `.kind` rather than matching error messages — message\ntext is for operators, not control flow.", - "source_line": 79, + "source_line": 83, "type_signature": "MigrationErrorKind" }, { "name": "MigrationErrorContext", "kind": "type", "doc_comment": "Structured context passed alongside a `MigrationError`.", - "source_line": 89, + "source_line": 93, "type_signature": "MigrationErrorContext", "properties": [ { @@ -12552,7 +12965,7 @@ "name": "MigrationError", "kind": "class", "doc_comment": "Tagged error thrown by `run_migrations` and `baseline`.\n\nBranch on `.kind`; the message carries an operator-facing remediation hint.", - "source_line": 101, + "source_line": 105, "extends": ["Error"], "implements": [], "members": [ @@ -12612,7 +13025,7 @@ "description": "with `kind` of `binary-older-than-db`," } ], - "source_line": 225, + "source_line": 229, "type_signature": "(db: Db, namespaces: MigrationNamespace[]): Promise", "return_type": "Promise", "return_description": "one result per namespace where work happened (already-up-to-date\nnamespaces are omitted)", @@ -12639,7 +13052,7 @@ "description": "with `kind` of `old-tracker-shape`," } ], - "source_line": 353, + "source_line": 357, "type_signature": "(db: Db, ns: MigrationNamespace, names: readonly string[]): Promise", "return_type": "Promise", "parameters": [ @@ -12661,7 +13074,7 @@ ] } ], - "module_comment": "Identity-tracked database migration runner.\n\nMigrations are named `{name, up}` objects in ordered arrays, grouped by\nnamespace. A `schema_version` table records one row per applied migration —\n`(namespace, name, sequence, applied_at)` — and the runner verifies the\napplied list is a name-prefix of the code's migration array at boot.\n\n**Append-only after first publish**: once a fuz_app version containing a\ngiven migration is published (`npm publish` / `jsr publish`), that\nmigration's name and position are frozen. Never edit, rename, or reorder\nafter publish — append only. Pre-publish, anything goes; the cliff is the\npublish event. Edits to a published migration's body slip past the runner\n(no content hashing) and are caught by schema-snapshot tests in consumers.\n\n**Chain-level transactions**: All pending migrations in a namespace run in\na single transaction. Any failure rolls back every migration in that run —\nno partial-state recovery. This rules out non-transactional DDL (e.g.,\n`CREATE INDEX CONCURRENTLY`); run those out of band.\n\n**Chain idempotency, not migration idempotency**: the chain-tx wraps every\nmigration replayed in a single boot, so an individual migration may\ntemporarily produce intermediate state that a later migration reverses\n(e.g. v0's `PERMIT_INDEXES` recreates an index that v1 drops; chain-tx\nhides this from observers). What matters is that the *committed end state*\nmatches; the in-tx steps may not be individually idempotent against an\narbitrary mid-chain target.\n\n**Forward-only**: No down-migrations. Schema changes are additive.\n\n**Advisory locking**: Per-namespace `pg_advisory_lock` reduces contention\nin multi-instance deployments — best-effort, not load-bearing. The locks\nare session-scoped, but `Db.query` runs against a pool that may check out\na different backend per call, so two concurrent boots can both \"hold\"\nthe lock on different sessions. The real serialization comes from chain-\ntx atomicity + the `(namespace, name)` PK on `schema_version`: the\nloser's INSERT hits a PK violation, the chain-tx rolls back, and the\nnext boot reads the committed state and proceeds cleanly. Environments\nwithout `pg_advisory_lock` (some PGlite versions) silently fall through.", + "module_comment": "Identity-tracked database migration runner.\n\nMigrations are named `{name, up}` objects in ordered arrays, grouped by\nnamespace. A `schema_version` table records one row per applied migration —\n`(namespace, name, sequence, applied_at)` — and the runner verifies the\napplied list is a name-prefix of the code's migration array at boot.\n\n**Schema is not stabilized yet — append-only is NOT the rule.** While\nfuz_app is pre-stable, migration bodies, names, and positions can change\nfreely between versions; consumers upgrading across a schema change are\nexpected to drop and re-bootstrap their dev/test databases (production\ndeployments are not yet a supported use case). Once the schema is\ndeclared stable a hard append-only-after-publish rule will apply and the\ncliff will be called out in that release's notes; until then, body edits\nto a published migration slip past the runner (no content hashing) by\ndesign — they're the recommended way to evolve the schema.\n\n**Chain-level transactions**: All pending migrations in a namespace run in\na single transaction. Any failure rolls back every migration in that run —\nno partial-state recovery. This rules out non-transactional DDL (e.g.,\n`CREATE INDEX CONCURRENTLY`); run those out of band.\n\n**Chain idempotency, not migration idempotency**: the chain-tx wraps every\nmigration replayed in a single boot, so an individual migration may\ntemporarily produce intermediate state that a later migration reverses\n(e.g. v0's `PERMIT_INDEXES` recreates an index that v1 drops; chain-tx\nhides this from observers). What matters is that the *committed end state*\nmatches; the in-tx steps may not be individually idempotent against an\narbitrary mid-chain target.\n\n**Forward-only**: No down-migrations. Schema changes are additive.\n\n**Advisory locking**: Per-namespace `pg_advisory_lock` reduces contention\nin multi-instance deployments — best-effort, not load-bearing. The locks\nare session-scoped, but `Db.query` runs against a pool that may check out\na different backend per call, so two concurrent boots can both \"hold\"\nthe lock on different sessions. The real serialization comes from chain-\ntx atomicity + the `(namespace, name)` PK on `schema_version`: the\nloser's INSERT hits a PK violation, the chain-tx rolls back, and the\nnext boot reads the committed state and proceeds cleanly. Environments\nwithout `pg_advisory_lock` (some PGlite versions) silently fall through.", "dependents": [ "server/app_backend.ts", "testing/admin_integration.ts", @@ -13906,16 +14319,46 @@ "doc_comment": "Hono context variable name for the authenticated API token id.", "source_line": 29, "type_signature": "\"auth_api_token_id\"" + }, + { + "name": "ACCOUNT_ID_KEY", + "kind": "variable", + "doc_comment": "Hono context variable name for the authenticated account id.\n\nSet by the auth middleware (session, bearer, or daemon token) on a valid\ncredential. `null` for unauthenticated requests. The route-spec wrapper /\nRPC dispatcher's authorization phase reads this when resolving the acting\nactor; account-grain auth guards (`require_auth`) and account-grain handlers\nread it directly.", + "source_line": 40, + "type_signature": "\"auth_account_id\"" + }, + { + "name": "TEST_CONTEXT_PRESET_KEY", + "kind": "variable", + "doc_comment": "Hono context variable name for the test-harness pre-baked context flag.\n\nTest harnesses (`create_test_app_from_specs`, `create_fake_hono_context`,\nthe WS round-trip `connect()` helper, plus per-test middleware that\npre-populates `REQUEST_CONTEXT_KEY`) set this to `true` so\n`apply_authorization_phase` skips its DB-backed actor resolution and\ntrusts the supplied `RequestContext`. Production middleware never sets\nthis key — only test code does. The flag is the explicit escape hatch\nthat replaced the implicit \"is `REQUEST_CONTEXT_KEY` already set?\" probe,\nso that future production code consulting `REQUEST_CONTEXT_KEY` cannot\nsilently bypass the live build.", + "source_line": 55, + "type_signature": "\"test_context_preset\"" + }, + { + "name": "CACHED_REQUEST_BODY_KEY", + "kind": "variable", + "doc_comment": "Cached parsed JSON request body, keyed by `'cached_request_body'`.\n\nWritten by `read_raw_acting` (in the dispatcher's authorization\nphase) when it pre-parses the body to extract the `acting` field;\nread by `create_input_validation` so the input-validation step does\nnot pay for a second `JSON.parse` on the same Hono-cached body text.\n\nDecouples our pipeline from Hono's internal `bodyCache` shape: Hono\ncaches the body *text* (so a second `c.req.json()` call doesn't\nre-read the request stream), but each call still re-runs\n`JSON.parse(text)`. Storing the parsed value here saves the second\nparse and keeps fuz_app from depending on undocumented Hono\nimplementation details.\n\nThree states:\n\n- Key absent — body has not been pre-parsed yet (the route had no\n `acting` to extract, or the request is GET).\n- `{ok: true, body: unknown}` — pre-parse succeeded; the parsed\n value (object, primitive, or array) is in `body`.\n- `{ok: false}` — pre-parse threw (malformed JSON). The downstream\n input-validation step short-circuits with `ERROR_INVALID_JSON_BODY`\n instead of re-parsing.", + "source_line": 82, + "type_signature": "\"cached_request_body\"" + }, + { + "name": "CachedRequestBody", + "kind": "type", + "doc_comment": "The shape stored under `CACHED_REQUEST_BODY_KEY`.", + "source_line": 85, + "type_signature": "CachedRequestBody" } ], "module_comment": "Hono context variable augmentation for fuz_app.\n\nCross-cutting shared vocabulary — defines the Hono `ContextVariableMap`\nvariables used by auth, http, server, and testing modules.\n\nAuto-loaded by `server/app_server.ts` (side-effect import) and\ntransitively by auth middleware modules that import `CREDENTIAL_TYPE_KEY`.\nConsumers don't need a manual import unless bypassing the standard server assembly.", "dependents": [ "actions/action_rpc.ts", "actions/register_action_ws.ts", + "auth/account_routes.ts", "auth/bearer_auth.ts", "auth/daemon_token_middleware.ts", "auth/request_context.ts", "auth/require_keeper.ts", + "http/route_spec.ts", "server/app_server.ts", "testing/auth_apps.ts", "testing/middleware.ts", @@ -14226,253 +14669,325 @@ "source_line": 65, "type_signature": "\"account_not_found\"" }, + { + "name": "ERROR_ACTOR_REQUIRED", + "kind": "variable", + "doc_comment": "Multi-actor account requires the request to carry an explicit `acting`\nfield naming the actor the request is acting as, so the dispatcher's\nauthorization phase doesn't pick a default actor silently. Returned\nwith the available actors so the client can prompt.", + "source_line": 73, + "type_signature": "\"actor_required\"" + }, + { + "name": "ERROR_ACTOR_NOT_ON_ACCOUNT", + "kind": "variable", + "doc_comment": "Supplied `acting` field does not name an actor on the authenticated\naccount.", + "source_line": 79, + "type_signature": "\"actor_not_on_account\"" + }, + { + "name": "ERROR_NO_ACTORS_ON_ACCOUNT", + "kind": "variable", + "doc_comment": "Authenticated account exists but has no actors. Server invariant\nviolation — signup / bootstrap always create an actor in the same\ntransaction. Surfaced from the dispatcher's authorization phase as a\n500 so the operator sees the corruption signal rather than a confusing\n4xx. Distinct from `ERROR_ACCOUNT_VANISHED`: the actor list was\nenumerated successfully and came back empty.", + "source_line": 89, + "type_signature": "\"no_actors_on_account\"" + }, + { + "name": "ERROR_ACCOUNT_VANISHED", + "kind": "variable", + "doc_comment": "Authentication validated an account, but a follow-up read in the\nauthorization phase came back null — the account or its named actor\nrow was deleted between the credential check and the dispatcher's\n`build_request_context` / `build_account_context` step. Torn read,\nnot a missing-actor invariant violation. Surfaced as 500 so the\noperator sees the race signal; clients can retry. Distinct from\n`ERROR_ACCOUNT_NOT_FOUND` (stale token referencing a long-deleted\naccount, raised at credential validation) and\n`ERROR_NO_ACTORS_ON_ACCOUNT` (the actor list enumerated empty).", + "source_line": 102, + "type_signature": "\"account_vanished\"" + }, { "name": "ERROR_KEEPER_REQUIRES_DAEMON_TOKEN", "kind": "variable", "doc_comment": "Keeper routes require daemon_token credential type.", - "source_line": 70, + "source_line": 107, "type_signature": "\"keeper_requires_daemon_token\"" }, { "name": "ERROR_INVALID_DAEMON_TOKEN", "kind": "variable", "doc_comment": "Daemon token header present but malformed or not matching current/previous token.", - "source_line": 73, + "source_line": 110, "type_signature": "\"invalid_daemon_token\"" }, { "name": "ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED", "kind": "variable", "doc_comment": "Daemon token valid but keeper account not yet resolved (pre-bootstrap).", - "source_line": 76, + "source_line": 113, "type_signature": "\"keeper_account_not_configured\"" }, { "name": "ERROR_KEEPER_ACCOUNT_NOT_FOUND", "kind": "variable", "doc_comment": "Keeper account ID set but account row not found.", - "source_line": 79, + "source_line": 116, "type_signature": "\"keeper_account_not_found\"" }, { "name": "ERROR_ALREADY_BOOTSTRAPPED", "kind": "variable", "doc_comment": "Bootstrap lock already acquired — system already bootstrapped.", - "source_line": 84, + "source_line": 121, "type_signature": "\"already_bootstrapped\"" }, { "name": "ERROR_TOKEN_FILE_MISSING", "kind": "variable", "doc_comment": "Bootstrap token file not found on disk.", - "source_line": 87, + "source_line": 124, "type_signature": "\"token_file_missing\"" }, { "name": "ERROR_BOOTSTRAP_NOT_CONFIGURED", "kind": "variable", "doc_comment": "Bootstrap endpoint called but no token path configured.", - "source_line": 90, + "source_line": 127, "type_signature": "\"bootstrap_not_configured\"" }, { "name": "ERROR_NO_MATCHING_INVITE", "kind": "variable", "doc_comment": "No unclaimed invite matches the signup credentials.", - "source_line": 95, + "source_line": 132, "type_signature": "\"no_matching_invite\"" }, { "name": "ERROR_SIGNUP_CONFLICT", "kind": "variable", "doc_comment": "Signup conflict — username or email already taken (intentionally vague for enumeration prevention).", - "source_line": 98, + "source_line": 135, "type_signature": "\"signup_conflict\"" }, { "name": "ERROR_INVITE_NOT_FOUND", "kind": "variable", "doc_comment": "Invite not found (for delete operations).", - "source_line": 101, + "source_line": 138, "type_signature": "\"invite_not_found\"" }, { "name": "ERROR_INVITE_MISSING_IDENTIFIER", "kind": "variable", "doc_comment": "Invite must have at least an email or username.", - "source_line": 104, + "source_line": 141, "type_signature": "\"invite_missing_identifier\"" }, { "name": "ERROR_INVITE_DUPLICATE", "kind": "variable", "doc_comment": "An unclaimed invite already exists for this email or username.", - "source_line": 107, + "source_line": 144, "type_signature": "\"invite_duplicate\"" }, { "name": "ERROR_INVITE_ACCOUNT_EXISTS_USERNAME", "kind": "variable", "doc_comment": "An account already exists with this invite's username.", - "source_line": 110, + "source_line": 147, "type_signature": "\"invite_account_exists_username\"" }, { "name": "ERROR_INVITE_ACCOUNT_EXISTS_EMAIL", "kind": "variable", "doc_comment": "An account already exists with this invite's email.", - "source_line": 113, + "source_line": 150, "type_signature": "\"invite_account_exists_email\"" }, { "name": "ERROR_ROLE_NOT_WEB_GRANTABLE", "kind": "variable", "doc_comment": "Admin tried to grant a role that is not web-grantable.", - "source_line": 118, + "source_line": 155, "type_signature": "\"role_not_web_grantable\"" }, { "name": "ERROR_PERMIT_NOT_FOUND", "kind": "variable", "doc_comment": "Permit ID not found or not owned by the target actor.", - "source_line": 121, + "source_line": 158, "type_signature": "\"permit_not_found\"" }, { "name": "ERROR_INVALID_EVENT_TYPE", "kind": "variable", "doc_comment": "Query parameter `event_type` is not a valid audit event type.", - "source_line": 124, + "source_line": 161, "type_signature": "\"invalid_event_type\"" }, { "name": "ERROR_FOREIGN_KEY_VIOLATION", "kind": "variable", "doc_comment": "DELETE blocked by a foreign key constraint.", - "source_line": 129, + "source_line": 166, "type_signature": "\"foreign_key_violation\"" }, { "name": "ERROR_TABLE_NOT_FOUND", "kind": "variable", "doc_comment": "Table name not found in `information_schema`.", - "source_line": 132, + "source_line": 169, "type_signature": "\"table_not_found\"" }, { "name": "ERROR_TABLE_NO_PRIMARY_KEY", "kind": "variable", "doc_comment": "Table has no primary key constraint (cannot delete by PK).", - "source_line": 135, + "source_line": 172, "type_signature": "\"table_no_primary_key\"" }, { "name": "ERROR_ROW_NOT_FOUND", "kind": "variable", "doc_comment": "Row with the given PK value not found.", - "source_line": 138, + "source_line": 175, "type_signature": "\"row_not_found\"" }, { "name": "ERROR_DATABASE_CONNECTION_FAILED", "kind": "variable", "doc_comment": "Database health-check query failed (connectivity or query error).", - "source_line": 141, + "source_line": 178, "type_signature": "\"database_connection_failed\"" }, { "name": "ApiError", "kind": "type", "doc_comment": "Base API error — all JSON error responses have at least `{error: string}`.", - "source_line": 147, + "source_line": 184, "type_signature": "ZodObject<{ error: ZodString; }, $loose>" }, { "name": "ValidationError", "kind": "type", "doc_comment": "Input validation error — returned when the request body fails Zod parsing.\n\n`issues` contains the Zod validation issues for diagnostic display.", - "source_line": 155, + "source_line": 192, "type_signature": "ZodObject<{ error: ZodString; issues: ZodArray>; }, $loose>>; }, $loose>" }, { "name": "PermissionError", "kind": "type", "doc_comment": "Permission error — returned by `require_role()` when the required role is missing.", - "source_line": 168, + "source_line": 205, "type_signature": "ZodObject<{ error: ZodLiteral<\"insufficient_permissions\">; required_role: ZodString; }, $loose>" }, { "name": "KeeperError", "kind": "type", "doc_comment": "Keeper credential error — returned by `require_keeper` when credential type is wrong.", - "source_line": 175, + "source_line": 212, "type_signature": "ZodObject<{ error: ZodLiteral<\"keeper_requires_daemon_token\">; credential_type: ZodString; }, $loose>" }, { "name": "RateLimitError", "kind": "type", "doc_comment": "Rate limit error — returned when a rate limiter rejects the request.", - "source_line": 182, + "source_line": 219, "type_signature": "ZodObject<{ error: ZodLiteral<\"rate_limit_exceeded\">; retry_after: ZodNumber; }, $loose>" }, { "name": "PayloadTooLargeError", "kind": "type", "doc_comment": "Payload too large error — returned when the request body exceeds the size limit.", - "source_line": 189, + "source_line": 226, "type_signature": "ZodObject<{ error: ZodLiteral<\"payload_too_large\">; }, $loose>" }, { "name": "ForeignKeyError", "kind": "type", "doc_comment": "Foreign key violation error — returned when a delete is blocked by references.", - "source_line": 195, + "source_line": 232, "type_signature": "ZodObject<{ error: ZodLiteral<\"foreign_key_violation\">; }, $loose>" }, + { + "name": "ActorRequiredError", + "kind": "type", + "doc_comment": "Authorization-phase failure shapes. Surfaced when the dispatcher's\n`apply_authorization_phase` rejects a request before the handler runs —\nthe route is acting-aware (input declares `acting?: ActingActor` or\nauth requires permits), but actor resolution failed.\n\n400: `actor_required` (with `available[]`) for unspecified-actor on\na multi-actor account; `actor_not_on_account` for a supplied actor\nid that doesn't belong to the authenticated account.\n\n500: `no_actors_on_account` for a signup-invariant violation (the\nactor list enumerated empty); `account_vanished` for a torn-read\nrace (account/actor row deleted between credential validation and\nthe dispatcher's follow-up read).\n\nUsed by `derive_error_schemas` when `acting_aware` is true so the\nmerged error surface matches what the dispatcher actually emits.", + "source_line": 255, + "type_signature": "ZodObject<{ error: ZodLiteral<\"actor_required\">; available: ZodArray>; }, $loose>" + }, + { + "name": "ActorNotOnAccountError", + "kind": "type", + "source_line": 261, + "type_signature": "ZodObject<{ error: ZodLiteral<\"actor_not_on_account\">; }, $loose>" + }, + { + "name": "NoActorsOnAccountError", + "kind": "type", + "source_line": 266, + "type_signature": "ZodObject<{ error: ZodLiteral<\"no_actors_on_account\">; }, $loose>" + }, + { + "name": "AccountVanishedError", + "kind": "type", + "source_line": 271, + "type_signature": "ZodObject<{ error: ZodLiteral<\"account_vanished\">; }, $loose>" + }, { "name": "RouteErrorSchemas", "kind": "type", "doc_comment": "Error schema map — maps HTTP status codes to Zod schemas.\n\nUsed on `RouteSpec.errors` and internally by `derive_error_schemas`.", - "source_line": 205, + "source_line": 281, "type_signature": "Partial>>>" }, { "name": "RateLimitKey", "kind": "type", "doc_comment": "Rate limit key type — declares what a route or RPC action's rate limiter\nis keyed on.\n\n- `'ip'` — per-IP rate limiting (bootstrap, password change, bearer auth)\n- `'account'` — per-account rate limiting. On REST auth routes the key is\n the submitted identifier (login). On RPC actions (post-auth) the key is\n the resolved actor id (`request_context.actor.id`) — separate namespace.\n- `'both'` — both keys.", - "source_line": 217, + "source_line": 293, "type_signature": "ZodEnum<{ both: \"both\"; ip: \"ip\"; account: \"account\"; }>" }, { - "name": "derive_error_schemas", - "kind": "function", - "doc_comment": "Derive error schemas from a route's auth requirement, input schema, and rate limit config.\n\nReturns the error schemas that middleware will auto-produce for this route.\nRoute handlers can declare additional error schemas via `RouteSpec.errors`;\nexplicit entries override auto-derived ones for the same status code.\n\nDerivation rules:\n- **Has input schema** (non-null) or **has params schema** or **has query schema**: 400 (validation error with issues)\n- **auth: authenticated**: 401\n- **auth: role**: 401 + 403 (with `required_role`)\n- **auth: keeper**: 401 + 403 (keeper-specific)\n- **rate_limit**: 429 (rate limit exceeded with `retry_after`)", - "source_line": 234, - "type_signature": "(auth: RouteAuth, has_input: boolean, has_params?: boolean, has_query?: boolean, rate_limit?: \"both\" | \"ip\" | \"account\" | undefined): Partial>>>", - "return_type": "Partial>>>", - "parameters": [ + "name": "DeriveErrorSchemasOptions", + "kind": "type", + "doc_comment": "Derive error schemas from a route's auth requirement, input schema, and rate limit config.\n\nReturns the error schemas that middleware will auto-produce for this route.\nRoute handlers can declare additional error schemas via `RouteSpec.errors`;\nexplicit entries override auto-derived ones for the same status code.\n\nDerivation rules:\n- **Has input schema** (non-null) or **has params schema** or **has query schema**: 400 (validation error with issues)\n- **auth: authenticated**: 401\n- **auth: role**: 401 + 403 (with `required_role`)\n- **auth: keeper**: 401 + 403 (keeper-specific)\n- **rate_limit**: 429 (rate limit exceeded with `retry_after`)\n- **acting_aware**: extends 400 with `ActorRequiredError` / `ActorNotOnAccountError`\n and adds 500 union of `NoActorsOnAccountError` / `AccountVanishedError`. The\n dispatcher's authorization phase emits these on routes whose input declares\n `acting?: ActingActor` or whose auth requires permits (`role` / `keeper`); the\n route's surface must reflect them so DEV-mode error-schema validation in\n `wrap_output_validation` doesn't fail when the auth phase fires before the\n handler. See `http/CLAUDE.md` § Three-layer error-schema merge.\n\n`acting_aware` is computed at the merge call site (it requires inspecting\nthe input schema for `acting?: ActingActor`, which lives in `auth/`). This\nkeeps `http/` auth-agnostic — the per-route flag flows in via the optional\n`is_acting_aware` callback on `apply_route_specs` / `generate_app_surface`.", + "source_line": 322, + "type_signature": "DeriveErrorSchemasOptions", + "properties": [ { "name": "auth", - "type": "RouteAuth" + "kind": "variable", + "type_signature": "RouteAuth" }, { "name": "has_input", - "type": "boolean" + "kind": "variable", + "type_signature": "boolean" }, { "name": "has_params", - "type": "boolean", - "default_value": "false" + "kind": "variable", + "type_signature": "boolean" + }, + { + "name": "has_query", + "kind": "variable", + "type_signature": "boolean" }, { - "name": "has_query", - "type": "boolean", - "default_value": "false" + "name": "rate_limit", + "kind": "variable", + "type_signature": "RateLimitKey" }, { - "name": "rate_limit", - "type": "\"both\" | \"ip\" | \"account\" | undefined", - "optional": true + "name": "acting_aware", + "kind": "variable", + "type_signature": "boolean" + } + ] + }, + { + "name": "derive_error_schemas", + "kind": "function", + "source_line": 331, + "type_signature": "({ auth, has_input, has_params, has_query, rate_limit, acting_aware, }: DeriveErrorSchemasOptions): Partial>>>", + "return_type": "Partial>>>", + "parameters": [ + { + "name": "__0", + "type": "DeriveErrorSchemasOptions" } ] } @@ -14515,35 +15030,35 @@ "name": "UNKNOWN_ERROR_MESSAGE", "kind": "variable", "doc_comment": "Default message for unknown errors.", - "source_line": 31, + "source_line": 33, "type_signature": "\"unknown error\"" }, { "name": "JsonrpcErrorName", "kind": "type", "doc_comment": "Names of standard and general application JSON-RPC error codes.", - "source_line": 34, + "source_line": 36, "type_signature": "JsonrpcErrorName" }, { "name": "JSONRPC_ERROR_CODES", "kind": "variable", "doc_comment": "Standard JSON-RPC error codes (5) plus general application codes (10).\n\nExtensible — consumers add domain-specific codes to their own objects\nby casting `as JsonrpcErrorCode`. Application codes use the -32000 to\n-32099 range reserved by the JSON-RPC spec.\n\nFrozen with `Object.freeze` to convert accidental mutation (test\ncross-contamination, cast escapes) into loud TypeErrors. Spread into\na fresh object to extend.", - "source_line": 62, + "source_line": 64, "type_signature": "Readonly)>>" }, { "name": "jsonrpc_error_messages", "kind": "variable", "doc_comment": "Named constructors for `JsonrpcErrorObject` values.\n\nEach function creates a JSON-RPC error object with the correct\ncode and a sensible default message. Used by the catch layer in\n`apply_route_specs` to build response bodies.\n\nFrozen so tests must compose new objects rather than monkey-patch.", - "source_line": 115, + "source_line": 117, "type_signature": "Readonly { [x: string]: unknown; code: -32700 | -32600 | -32601 | -32602 | -32603 | (number & $brand<\"JsonrpcServerErrorCode\">); message: string; data?: unknown; }>>" }, { "name": "ThrownJsonrpcError", "kind": "class", "doc_comment": "Error class carrying a JSON-RPC error code — thrown by handlers,\ncaught by `apply_route_specs` and mapped to HTTP status + JSON-RPC error response.\n\nNamed for what it is: an error with a JSON-RPC error code that gets thrown.", - "source_line": 222, + "source_line": 224, "extends": ["Error"], "implements": [], "members": [ @@ -14588,30 +15103,30 @@ "name": "jsonrpc_errors", "kind": "variable", "doc_comment": "Named constructors for `ThrownJsonrpcError`.\n\nUsage: `throw jsonrpc_errors.not_found('user')` or `throw jsonrpc_errors.forbidden()`.", - "source_line": 247, + "source_line": 249, "type_signature": "{ readonly parse_error: (...args: any[]) => ThrownJsonrpcError; readonly invalid_request: (...args: any[]) => ThrownJsonrpcError; readonly method_not_found: (...args: any[]) => ThrownJsonrpcError; ... 11 more ...; readonly request_cancelled: (...args: any[]) => ThrownJsonrpcError; }" }, { "name": "JSONRPC_ERROR_CODE_TO_HTTP_STATUS", "kind": "variable", "doc_comment": "Maps JSON-RPC error codes to HTTP status codes.\n\nExtensible — consumers with domain-specific error codes assign directly\n(`JSONRPC_ERROR_CODE_TO_HTTP_STATUS[-32020] = 502`) at module load. The\nlookup function reads at call time, so mutation is the supported\nextension mechanism.", - "source_line": 275, + "source_line": 277, "type_signature": "Record" }, { "name": "HTTP_STATUS_TO_JSONRPC_ERROR_CODE", "kind": "variable", "doc_comment": "Maps HTTP status codes to JSON-RPC error codes (reverse mapping).\n\nWhen multiple error codes map to the same HTTP status (e.g. parse_error\nand invalid_request both map to 400), the last one wins. Use for\nbest-effort HTTP → JSON-RPC translation.", - "source_line": 302, + "source_line": 304, "type_signature": "Record)>" }, { "name": "jsonrpc_error_code_to_http_status", "kind": "function", - "doc_comment": "Map a JSON-RPC error code to an HTTP status code.\n\nReturns 500 for unrecognized codes (consumer-defined codes\nwithout a mapping default to internal server error).", - "source_line": 316, - "type_signature": "(code: -32700 | -32600 | -32601 | -32602 | -32603 | (number & $brand<\"JsonrpcServerErrorCode\">)): number", - "return_type": "number", + "doc_comment": "Map a JSON-RPC error code to an HTTP status code.\n\nReturns 500 for unrecognized codes (consumer-defined codes\nwithout a mapping default to internal server error). The return\nis narrowed to Hono's `ContentfulStatusCode` so call sites can\npass the result to `c.json(body, status)` without `as any` —\n499 (nginx \"client closed request\") is non-standard and gets\nabsorbed by the cast here rather than at every dispatcher branch.", + "source_line": 322, + "type_signature": "(code: -32700 | -32600 | -32601 | -32602 | -32603 | (number & $brand<\"JsonrpcServerErrorCode\">)): ContentfulStatusCode", + "return_type": "ContentfulStatusCode", "parameters": [ { "name": "code", @@ -14623,7 +15138,7 @@ "name": "http_status_to_jsonrpc_error_code", "kind": "function", "doc_comment": "Map an HTTP status code to a JSON-RPC error code.\n\nReturns `internal_error` (-32603) for unrecognized status codes.", - "source_line": 324, + "source_line": 330, "type_signature": "(status: number): -32700 | -32600 | -32601 | -32602 | -32603 | (number & $brand<\"JsonrpcServerErrorCode\">)", "return_type": "-32700 | -32600 | -32601 | -32602 | -32603 | (number & $brand<\"JsonrpcServerErrorCode\">)", "parameters": [ @@ -14632,6 +15147,27 @@ "type": "number" } ] + }, + { + "name": "JSONRPC_ERROR_CODE_TO_NAME", + "kind": "variable", + "doc_comment": "Reverse map of `JSONRPC_ERROR_CODES` — JSON-RPC error code → name.\n\nUsed by REST emitters that need a stable string identifier for the\ncode in their flat-shape error body (`{error: '', ...}`)\nwithout inventing a separate vocabulary. Built once at module load\nfrom the canonical `JSONRPC_ERROR_CODES` map so the two cannot drift.\n\nConsumer-defined codes outside the standard taxonomy are not present;\n`jsonrpc_error_code_to_name` falls back to `'internal_error'` so the\nREST shape always carries some reason rather than `undefined`.", + "source_line": 345, + "type_signature": "Readonly>" + }, + { + "name": "jsonrpc_error_code_to_name", + "kind": "function", + "doc_comment": "Map a JSON-RPC error code to its canonical name (`'not_found'`,\n`'forbidden'`, etc.). Falls back to `'internal_error'` for codes\noutside the standard taxonomy so REST emitters that read this for\ntheir `error` field always have a stable string to emit.", + "source_line": 359, + "type_signature": "(code: -32700 | -32600 | -32601 | -32602 | -32603 | (number & $brand<\"JsonrpcServerErrorCode\">)): JsonrpcErrorName", + "return_type": "JsonrpcErrorName", + "parameters": [ + { + "name": "code", + "type": "-32700 | -32600 | -32601 | -32602 | -32603 | (number & $brand<\"JsonrpcServerErrorCode\">)" + } + ] } ], "module_comment": "JSON-RPC error infrastructure for fuz_app routes.\n\nProvides error types, named constructors, and HTTP status mapping\nfor the throw/catch error pattern used by `apply_route_specs`.\nCore error codes (5 standard + 10 general application). Domain-specific\ncodes stay in consumers — add by casting `as JsonrpcErrorCode`.\n\n`JsonrpcErrorCode` and `JsonrpcErrorObject` types are Zod-inferred\nfrom `http/jsonrpc.ts` — this module re-uses those as the single source\nof truth.\n\nComplementary to `http/error_schemas.ts`: that module is declarative\n(Zod schemas for surface introspection), this one is runtime\n(throw + catch + map).", @@ -15441,25 +15977,58 @@ "source_line": 45, "type_signature": "RouteAuth" }, + { + "name": "AuthGuards", + "kind": "type", + "doc_comment": "Two-phase auth guard set returned by `AuthGuardResolver`.\n\n`pre_validation` runs before input validation — 401 checks live here\nso unauthenticated callers never see route-shape information from\ninput parsing failures. `post_authorization` runs after the\nauthorization phase has populated `RequestContext` — role / keeper\nchecks live here because they read `c.var.request_context.permits`.", + "source_line": 60, + "type_signature": "AuthGuards", + "properties": [ + { + "name": "pre_validation", + "kind": "variable", + "type_signature": "Array" + }, + { + "name": "post_authorization", + "kind": "variable", + "type_signature": "Array" + } + ] + }, { "name": "AuthGuardResolver", "kind": "type", "doc_comment": "Resolves a `RouteAuth` to middleware guard handlers.\n\nInjected into `apply_route_specs` to decouple route registration\nfrom auth-specific middleware. See `fuz_auth_guard_resolver` in\n`auth/route_guards.ts` for the standard implementation.", - "source_line": 58, + "source_line": 72, "type_signature": "AuthGuardResolver" }, + { + "name": "AuthorizationHandler", + "kind": "type", + "doc_comment": "Per-route authorization phase. Runs after the pre-validation auth guards\nand before input validation; resolves the acting actor (when the route's\ninput declares `acting?: ActingActor` or auth requires permits) and sets\nthe request context on the Hono context. Per-route order in\n`apply_route_specs`: params → query → pre-validation auth (401) →\nauthorization → post-authorization auth (403) → input validation →\nhandler.\n\nReturns a `Response` to short-circuit (resolution failure → 400 / 500),\nor `void` to continue. The http framework stays auth-agnostic — fuz_app\nprovides the implementation via `create_fuz_authorization_handler` in\n`auth/request_context.ts`.", + "source_line": 88, + "type_signature": "AuthorizationHandler" + }, + { + "name": "IsActingAware", + "kind": "type", + "doc_comment": "Predicate that decides whether a route is \"acting-aware\" — i.e. whether\nthe dispatcher's authorization phase may emit `actor_required` /\n`actor_not_on_account` (400) or `no_actors_on_account` /\n`account_vanished` (500) on this spec. When the predicate returns true\nthe merged error schema is widened to accept those shapes so DEV-mode\n`wrap_output_validation` doesn't reject them.\n\nComputed at the call site because the canonical \"input declares\n`acting?: ActingActor`\" check lives in `auth/request_context.ts` (it\nuses reference equality with the canonical `ActingActor` schema). The\n`http/` framework receives the predicate via this callback so it stays\nauth-agnostic. See `http/CLAUDE.md` § Three-layer error-schema merge.", + "source_line": 104, + "type_signature": "IsActingAware" + }, { "name": "RouteMethod", "kind": "type", "doc_comment": "HTTP methods supported by route specs.", - "source_line": 61, + "source_line": 107, "type_signature": "RouteMethod" }, { "name": "RouteContext", "kind": "type", "doc_comment": "Per-request deps provided by the framework to route handlers.", - "source_line": 66, + "source_line": 112, "type_signature": "RouteContext", "properties": [ { @@ -15486,14 +16055,14 @@ "name": "RouteHandler", "kind": "type", "doc_comment": "Route handler function — receives the Hono context and a `RouteContext`\nwith per-request deps (db, background_db, pending_effects).\n\nTypeScript allows fewer params, so handlers that don't need `route`\ncan use `(c) => ...` without changes.", - "source_line": 82, + "source_line": 128, "type_signature": "RouteHandler" }, { "name": "RouteSpec", "kind": "type", "doc_comment": "A single route definition — the unit of the surface map.\n\n`input` and `output` schemas align with SAES `ActionSpec` naming.\nUse `z.null()` for routes with no request body (GET, DELETE without body).", - "source_line": 90, + "source_line": 136, "type_signature": "RouteSpec", "properties": [ { @@ -15569,7 +16138,7 @@ "name": "get_route_input", "kind": "function", "doc_comment": "Get validated input from the Hono context.\n\nCall after the input validation middleware has run. The type parameter\nshould match the route's `input` schema.", - "source_line": 146, + "source_line": 192, "type_signature": "(c: Context): T", "return_type": "T", "parameters": [ @@ -15583,7 +16152,7 @@ "name": "get_route_params", "kind": "function", "doc_comment": "Get validated URL path params from the Hono context.\n\nCall after the params validation middleware has run. The type parameter\nshould match the route's `params` schema.\n\nTODO derive `T` from the route spec so the type parameter isn't manually\nspecified — same applies to `get_route_input` / `get_route_query`.", - "source_line": 159, + "source_line": 205, "type_signature": "(c: Context): T", "return_type": "T", "parameters": [ @@ -15597,7 +16166,7 @@ "name": "get_route_query", "kind": "function", "doc_comment": "Get validated URL query params from the Hono context.\n\nCall after the query validation middleware has run. The type parameter\nshould match the route's `query` schema.", - "source_line": 169, + "source_line": 215, "type_signature": "(c: Context): T", "return_type": "T", "parameters": [ @@ -15611,7 +16180,7 @@ "name": "apply_middleware_specs", "kind": "function", "doc_comment": "Apply named middleware specs to a Hono app.", - "source_line": 318, + "source_line": 380, "type_signature": "(app: Hono, specs: MiddlewareSpec[]): void", "return_type": "void", "parameters": [ @@ -15628,15 +16197,15 @@ { "name": "apply_route_specs", "kind": "function", - "doc_comment": "Apply route specs to a Hono app.\n\nFor each spec: resolves auth to guards via the provided resolver,\nadds input validation middleware (for routes with non-null input schemas),\nwraps handler with DEV-only output and error validation, wraps with error\ncatch layer (catches `ThrownJsonrpcError` and generic errors), and registers the route.\n\nEach handler receives a `RouteContext` with:\n- `db`: transaction-scoped (for non-GET) or pool-level (for GET)\n- `background_db`: always pool-level\n- `pending_effects`: fire-and-forget effect queue", + "doc_comment": "Apply route specs to a Hono app.\n\nFor each spec: resolves auth to guards via the provided resolver,\nadds input validation middleware (for routes with non-null input schemas),\nruns the optional authorization phase to resolve the acting actor + build\nthe request context, wraps handler with DEV-only output and error\nvalidation, wraps with error catch layer (catches `ThrownJsonrpcError`\nand generic errors), and registers the route.\n\nPer-route middleware order: params → query → pre-validation auth\nguards (401) → authorization phase → post-authorization auth guards\n(403) → input validation → handler. The 401 check runs before any\nbody parsing so unauthenticated callers never see route-shape\ninformation from parse failures. The authorization phase runs before\ninput validation (matches the RPC dispatcher's order) so role /\nkeeper denials surface 403 before 400 invalid_params; it extracts\n`acting` from raw query (GET) or pre-parsed JSON body (POST/PUT/...)\n— Hono caches the parsed body internally so the subsequent input-\nvalidation step does not re-parse. The role / keeper guards consume\nthe `RequestContext` populated by the authorization phase.\n\nEach handler receives a `RouteContext` with:\n- `db`: transaction-scoped (for non-GET) or pool-level (for GET)\n- `background_db`: always pool-level\n- `pending_effects`: fire-and-forget effect queue", "throws": [ { "type": "Error", "description": "if two specs share the same `method` + `path` (each combination must be unique)" } ], - "source_line": 372, - "type_signature": "(app: Hono, specs: RouteSpec[], resolve_auth_guards: AuthGuardResolver, log: Logger, db: Db): void", + "source_line": 499, + "type_signature": "(app: Hono, specs: RouteSpec[], resolve_auth_guards: AuthGuardResolver, log: Logger, db: Db, authorize?: AuthorizationHandler | undefined, is_acting_aware?: IsActingAware | undefined): void", "return_type": "void", "parameters": [ { @@ -15660,6 +16229,17 @@ "name": "db", "type": "Db", "description": "used for transaction wrapping and `RouteContext`" + }, + { + "name": "authorize", + "type": "AuthorizationHandler | undefined", + "optional": true, + "description": "optional authorization phase; runs between guards and input validation" + }, + { + "name": "is_acting_aware", + "type": "IsActingAware | undefined", + "optional": true } ] }, @@ -15667,7 +16247,7 @@ "name": "prefix_route_specs", "kind": "function", "doc_comment": "Prepend a prefix to all route spec paths.", - "source_line": 424, + "source_line": 565, "type_signature": "(prefix: string, specs: RouteSpec[]): RouteSpec[]", "return_type": "RouteSpec[]", "return_description": "a new array — the input specs are not mutated", @@ -15686,6 +16266,7 @@ ], "module_comment": "Introspectable route spec system for Hono apps.\n\nRoutes are defined as data (method, path, auth, input/output schemas, handler),\nthen applied to Hono. The attack surface is generated from the specs —\nalways accurate, always complete.\n\nInput/output schemas align with SAES `ActionSpec` conventions:\n- `input`: Zod schema for the request body (`z.null()` for no body)\n- `output`: Zod schema for the success response body\n- `z.strictObject()` for inputs (reject unknown keys)", "dependencies": [ + "hono_context.ts", "http/error_schemas.ts", "http/jsonrpc_errors.ts", "http/schema_helpers.ts" @@ -15780,9 +16361,9 @@ { "name": "merge_error_schemas", "kind": "function", - "doc_comment": "Merge auto-derived, middleware, and explicit error schemas for a route spec.\n\nMerge order: derived -> middleware -> explicit route errors.\nLater layers override earlier ones for the same status code.", - "source_line": 104, - "type_signature": "(spec: { auth: RouteAuth; input: ZodType>; params?: ZodObject<$ZodLooseShape, $strip> | undefined; query?: ZodObject<...> | undefined; rate_limit?: \"both\" | ... 2 more ... | undefined; errors?: Partial<...> | undefined; }, middleware_errors?: Partial<...> | ... 1 more ... | undefined): Partial<...> | null", + "doc_comment": "Merge auto-derived, middleware, and explicit error schemas for a route spec.\n\nMerge order: derived -> middleware -> explicit route errors.\nLater layers override earlier ones for the same status code.\n\n`acting_aware` flows through to `derive_error_schemas` so routes whose\ninput declares `acting?: ActingActor` (or whose auth requires permits)\npick up the actor-failure error shapes the dispatcher's authorization\nphase may emit. The flag is computed at the call site rather than here\nbecause the `acting`-detection helper lives in `auth/` (it depends on\nthe canonical `ActingActor` schema for reference equality, and `http/`\nstays auth-agnostic). See `http/CLAUDE.md` § Three-layer error-schema\nmerge.", + "source_line": 116, + "type_signature": "(spec: { auth: RouteAuth; input: ZodType>; params?: ZodObject<$ZodLooseShape, $strip> | undefined; query?: ZodObject<...> | undefined; rate_limit?: \"both\" | ... 2 more ... | undefined; errors?: Partial<...> | undefined; }, middleware_errors?: Partial<...> | ... 1 more ... | undefined, acting_aware?: boolean): Partial<...> | null", "return_type": "Partial>>> | null", "return_description": "merged error schemas, or `null` if empty", "parameters": [ @@ -15794,7 +16375,14 @@ { "name": "middleware_errors", "type": "Partial>>> | null | undefined", - "optional": true + "optional": true, + "description": "errors contributed by middleware whose path matches the route" + }, + { + "name": "acting_aware", + "type": "boolean", + "description": "whether the dispatcher's authorization phase may emit\nactor-failure errors on this route", + "default_value": "false" } ] } @@ -16434,6 +17022,12 @@ "name": "rpc_endpoints", "kind": "variable", "type_signature": "Array" + }, + { + "name": "is_acting_aware", + "kind": "variable", + "type_signature": "IsActingAware", + "doc_comment": "Per-route predicate that decides whether the dispatcher's authorization\nphase may emit `actor_required` / `actor_not_on_account` (400) or\n`no_actors_on_account` / `account_vanished` (500) on this spec. Mirrors\nthe parameter on `apply_route_specs` so the surface exposes the same\nerror shapes the live framework would emit. See `http/CLAUDE.md` §\nThree-layer error-schema merge." } ] }, @@ -16441,7 +17035,7 @@ "name": "collect_middleware_errors", "kind": "function", "doc_comment": "Collect error schemas from all middleware that applies to a route path.", - "source_line": 154, + "source_line": 163, "type_signature": "(middleware: MiddlewareSpec[], route_path: string): Partial>>> | null", "return_type": "Partial>>> | null", "return_description": "merged middleware error schemas, or `null` if none", @@ -16460,7 +17054,7 @@ "name": "env_schema_to_surface", "kind": "function", "doc_comment": "Convert env schema to surface entries using `.meta()` metadata.", - "source_line": 172, + "source_line": 181, "type_signature": "(schema: ZodObject<$ZodLooseShape, $strip>): AppSurfaceEnv[]", "return_type": "AppSurfaceEnv[]", "parameters": [ @@ -16475,7 +17069,7 @@ "name": "events_to_surface", "kind": "function", "doc_comment": "Convert SSE event specs to surface entries.", - "source_line": 192, + "source_line": 201, "type_signature": "(event_specs: EventSpec[]): AppSurfaceEvent[]", "return_type": "AppSurfaceEvent[]", "parameters": [ @@ -16489,7 +17083,7 @@ "name": "generate_app_surface", "kind": "function", "doc_comment": "Generate a JSON-serializable attack surface from middleware, route specs,\nand optional env/event metadata.", - "source_line": 205, + "source_line": 214, "type_signature": "(options: GenerateAppSurfaceOptions): AppSurface", "return_type": "AppSurface", "parameters": [ @@ -16503,7 +17097,7 @@ "name": "create_app_surface_spec", "kind": "function", "doc_comment": "Create an `AppSurfaceSpec` — the surface bundled with its source specs.", - "source_line": 299, + "source_line": 309, "type_signature": "(options: GenerateAppSurfaceOptions): AppSurfaceSpec", "return_type": "AppSurfaceSpec", "parameters": [ @@ -17976,7 +18570,7 @@ "name": "EffectErrorContext", "kind": "type", "doc_comment": "Context passed to `on_effect_error` when a pending effect rejects.", - "source_line": 74, + "source_line": 80, "type_signature": "EffectErrorContext", "properties": [ { @@ -17997,7 +18591,7 @@ "name": "AppServerOptions", "kind": "type", "doc_comment": "Configuration for `create_app_server()`.\n\nRequires a pre-initialized `AppBackend` from `create_app_backend()`.\nTwo explicit steps: init backend then assemble server.", - "source_line": 87, + "source_line": 93, "type_signature": "AppServerOptions", "properties": [ { @@ -18156,7 +18750,7 @@ "name": "AppServerContext", "kind": "type", "doc_comment": "Context passed to `create_route_specs`.", - "source_line": 245, + "source_line": 251, "type_signature": "AppServerContext", "properties": [ { @@ -18227,7 +18821,7 @@ "name": "AppServer", "kind": "type", "doc_comment": "Result of `create_app_server()`.", - "source_line": 267, + "source_line": 273, "type_signature": "AppServer", "properties": [ { @@ -18276,14 +18870,14 @@ "name": "DEFAULT_MAX_BODY_SIZE", "kind": "variable", "doc_comment": "Default maximum request body size: 1 MiB.", - "source_line": 283, + "source_line": 289, "type_signature": "number" }, { "name": "create_app_server", "kind": "function", "doc_comment": "Create a fully assembled Hono app with auth, middleware, and routes.\n\nHandles the assembly lifecycle: proxy middleware → auth middleware →\nbootstrap status → route specs → surface generation → Hono app assembly →\nstatic serving. Database migrations belong to the backend lifecycle —\npass `migration_namespaces` to `create_app_backend`.\n\nWhen `audit_log_sse` is set, shallow-copies `backend.deps` with a composed\n`on_audit_event` that fans out to the SSE registry and the original\ncallback — `backend.deps` itself is not mutated.", - "source_line": 299, + "source_line": 305, "type_signature": "(options: AppServerOptions): Promise", "return_type": "Promise", "return_description": "assembled Hono app, backend, surface build, and bootstrap status", @@ -18301,6 +18895,7 @@ "auth/app_settings_queries.ts", "auth/bootstrap_routes.ts", "auth/middleware.ts", + "auth/request_context.ts", "auth/route_guards.ts", "auth/session_cookie.ts", "hono_context.ts", @@ -18551,7 +19146,7 @@ "name": "StandardAdminIntegrationTestOptions", "kind": "type", "doc_comment": "Configuration for `describe_standard_admin_integration_tests`.", - "source_line": 83, + "source_line": 82, "type_signature": "StandardAdminIntegrationTestOptions", "properties": [ { @@ -18608,7 +19203,7 @@ "description": "at setup time when `options.rpc_endpoints` is empty — admin" } ], - "source_line": 165, + "source_line": 164, "type_signature": "(options: StandardAdminIntegrationTestOptions): void", "return_type": "void", "parameters": [ @@ -18622,7 +19217,6 @@ "module_comment": "Standard admin integration test suite for fuz_app admin routes.\n\n`describe_standard_admin_integration_tests` creates a composable test suite\nthat exercises admin account listing, permit grant/revoke (via the RPC\nsurface — see `permit_offer_create` / `permit_revoke`), session/token\nmanagement, and audit log routes against a real PGlite database.\n\nConsumers call it with their route factory, session config, role schema,\nand RPC endpoint specs — all admin route tests come for free.\n\nScope: admin *semantics* — cross-admin isolation, permit grant/revoke\nflow, session/token revoke-all, audit writes. Output-schema conformance\nfor admin methods is **not** the concern of this suite; it lives in:\n\n- `describe_rpc_round_trip_tests` — every RPC method (admin methods\n included) is hit with a spec-generated valid body and the 2xx result\n is validated against `spec.output`.\n- `describe_round_trip_validation` — every REST route is hit and\n validated against its declared `output` / error schemas (SSE routes\n skipped via `Content-Type: text/event-stream`).\n- `describe_sse_route_tests` — SSE frames validated against their\n declared `EventSpec`.", "dependencies": [ "auth/account_action_specs.ts", - "auth/account_queries.ts", "auth/admin_action_specs.ts", "auth/migrations.ts", "auth/permit_offer_action_specs.ts", @@ -19595,7 +20189,7 @@ "name": "AuditCompletenessTestOptions", "kind": "type", "doc_comment": "Configuration for `describe_audit_completeness_tests`.", - "source_line": 71, + "source_line": 70, "type_signature": "AuditCompletenessTestOptions", "properties": [ { @@ -19640,7 +20234,7 @@ "description": "at setup time when `options.rpc_endpoints` is empty — the" } ], - "source_line": 157, + "source_line": 156, "type_signature": "(options: AuditCompletenessTestOptions): void", "return_type": "void", "parameters": [ @@ -19654,7 +20248,6 @@ "module_comment": "Composable audit log completeness test suite.\n\nVerifies that every auth mutation route produces the expected audit log\nevent. Uses the real middleware stack and database — audit events are\nverified by querying the `audit_log` table after each request.\n\nBootstrap is excluded because it requires filesystem token state that\n`create_test_app` does not provide. Bootstrap audit logging is tested\nseparately in `bootstrap_account.db.test.ts`.", "dependencies": [ "auth/account_action_specs.ts", - "auth/account_queries.ts", "auth/admin_action_specs.ts", "auth/audit_log_schema.ts", "auth/migrations.ts", @@ -19676,7 +20269,7 @@ "name": "create_test_request_context", "kind": "function", "doc_comment": "Create a mock `RequestContext` with optional role permit.", - "source_line": 25, + "source_line": 34, "type_signature": "(role?: string | undefined): RequestContext", "return_type": "RequestContext", "parameters": [ @@ -19691,7 +20284,7 @@ "name": "create_test_app_from_specs", "kind": "function", "doc_comment": "Create a Hono test app from route specs with optional auth context.", - "source_line": 38, + "source_line": 47, "type_signature": "(route_specs: RouteSpec[], auth_ctx?: RequestContext | undefined, credential_type?: \"session\" | \"api_token\" | \"daemon_token\" | undefined): Hono", "return_type": "Hono", "parameters": [ @@ -19718,7 +20311,7 @@ "name": "AuthTestApps", "kind": "type", "doc_comment": "Pre-built Hono apps for each auth level, shared across adversarial test suites.", - "source_line": 63, + "source_line": 76, "type_signature": "AuthTestApps", "properties": [ { @@ -19747,7 +20340,7 @@ "name": "create_auth_test_apps", "kind": "function", "doc_comment": "Create one Hono test app per auth level.", - "source_line": 76, + "source_line": 89, "type_signature": "(route_specs: RouteSpec[], roles: string[]): AuthTestApps", "return_type": "AuthTestApps", "parameters": [ @@ -19773,7 +20366,7 @@ "description": "if `auth.type === 'role'` and `auth.role` is not present in" } ], - "source_line": 103, + "source_line": 116, "type_signature": "(apps: AuthTestApps, auth: RouteAuth): Hono", "return_type": "Hono", "parameters": [ @@ -19791,7 +20384,7 @@ "name": "resolve_test_path", "kind": "function", "doc_comment": "Replace Hono route params (`:foo`) with dummy values for HTTP testing.", - "source_line": 120, + "source_line": 133, "type_signature": "(path: string): string", "return_type": "string", "parameters": [ @@ -20238,7 +20831,7 @@ "name": "create_test_audit_event", "kind": "function", "doc_comment": "Create a test `AuditLogEvent` with sensible defaults.", - "source_line": 109, + "source_line": 110, "type_signature": "(overrides?: TestAuditEventOverrides | undefined): AuditLogEvent", "return_type": "AuditLogEvent", "parameters": [ @@ -20620,14 +21213,14 @@ { "name": "assert_no_error_info_leakage", "kind": "function", - "doc_comment": "Assert that an error response contains no leaky field values.\n\nChecks both field names and string values for patterns indicating\nstack traces, SQL, or internal paths.", - "source_line": 219, - "type_signature": "(body: Record, context: string): void", + "doc_comment": "Assert that an error response contains no leaky field values.\n\nChecks both field names and string values for patterns indicating\nstack traces, SQL, or internal paths. Accepts `unknown` so callers\npass response bodies / nested envelope fields directly without\nintermediate `as` casts; non-object bodies skip the field-name check.", + "source_line": 221, + "type_signature": "(body: unknown, context: string): void", "return_type": "void", "parameters": [ { "name": "body", - "type": "Record" + "type": "unknown" }, { "name": "context", @@ -20640,7 +21233,7 @@ "name": "assert_rate_limit_retry_after_header", "kind": "function", "doc_comment": "Assert that a 429 response includes a valid `Retry-After` header\nmatching the JSON body's `retry_after` field.", - "source_line": 246, + "source_line": 247, "type_signature": "(response: Response, body: { retry_after: number; }): void", "return_type": "void", "parameters": [ @@ -20658,21 +21251,21 @@ "name": "SENSITIVE_FIELD_BLOCKLIST", "kind": "variable", "doc_comment": "Field names that must never appear in any HTTP response body.", - "source_line": 264, + "source_line": 265, "type_signature": "readonly string[]" }, { "name": "ADMIN_ONLY_FIELD_BLOCKLIST", "kind": "variable", "doc_comment": "Field names that must not appear in non-admin HTTP response bodies.", - "source_line": 267, + "source_line": 268, "type_signature": "readonly string[]" }, { "name": "collect_json_keys_recursive", "kind": "function", "doc_comment": "Recursively collect all key names from a parsed JSON value.\n\nWalks objects and arrays to find every property name at any nesting depth.", - "source_line": 274, + "source_line": 275, "type_signature": "(value: unknown): Set", "return_type": "Set", "parameters": [ @@ -20686,7 +21279,7 @@ "name": "assert_no_sensitive_fields_in_json", "kind": "function", "doc_comment": "Assert that a parsed JSON body contains no fields from the given blocklist.", - "source_line": 296, + "source_line": 297, "type_signature": "(body: unknown, blocklist: readonly string[], context: string): void", "return_type": "void", "parameters": [ @@ -20709,7 +21302,7 @@ "name": "pick_auth_headers", "kind": "function", "doc_comment": "Pick request headers matching a route spec's auth requirement.\n\nMaps `RouteAuth` onto a test account's credentials:\n- `none` — origin headers only\n- `authenticated` — the authed account's session cookie\n- `role: admin` — the admin account's session cookie\n- `role: ` — the test app's bootstrapped keeper session\n- `keeper` — the test app's daemon token", - "source_line": 317, + "source_line": 318, "type_signature": "(spec: RouteSpec, test_app: TestApp, authed_account: TestAccount, admin_account: TestAccount): Record", "return_type": "Record", "parameters": [ @@ -20837,7 +21430,7 @@ "name": "BearerAuthTestOptions", "kind": "type", "doc_comment": "Mock configuration for bearer auth middleware test setup.", - "source_line": 48, + "source_line": 58, "type_signature": "BearerAuthTestOptions", "properties": [ { @@ -20871,10 +21464,10 @@ "doc_comment": "What `query_account_by_id()` returns." }, { - "name": "mock_find_by_account_result", + "name": "mock_find_actor_by_id_result", "kind": "variable", "type_signature": "unknown", - "doc_comment": "What `query_actor_by_account()` returns." + "doc_comment": "What `query_actor_by_id()` returns." }, { "name": "mock_permits_result", @@ -20906,7 +21499,7 @@ "name": "BearerAuthTestCase", "kind": "type", "doc_comment": "A full test case for the table-driven bearer auth runner.", - "source_line": 72, + "source_line": 82, "type_signature": "BearerAuthTestCase", "extends": ["BearerAuthTestOptions"], "properties": [ @@ -20917,10 +21510,16 @@ "doc_comment": "Whether the request should reach token validation or be short-circuited." }, { - "name": "assert_context_set", + "name": "assert_account_set", "kind": "variable", "type_signature": "boolean", - "doc_comment": "If true, assert `REQUEST_CONTEXT_KEY` and `CREDENTIAL_TYPE_KEY` were set to api_token values." + "doc_comment": "If true, assert `ACCOUNT_ID_KEY` was set and `CREDENTIAL_TYPE_KEY` is `'api_token'`." + }, + { + "name": "expected_account_id", + "kind": "variable", + "type_signature": "string", + "doc_comment": "Expected `ACCOUNT_ID_KEY` value when `assert_account_set` is true." }, { "name": "expected_api_token_id", @@ -20932,7 +21531,7 @@ "name": "assert_context_preserved", "kind": "variable", "type_signature": "boolean", - "doc_comment": "If true, assert the pre-existing session context and credential type are preserved." + "doc_comment": "If true, assert the pre-existing session `ACCOUNT_ID_KEY` and credential type are preserved." }, { "name": "assert_mocks", @@ -20946,7 +21545,7 @@ "name": "BearerAuthMocks", "kind": "type", "doc_comment": "Mocks bundle returned by `create_bearer_auth_mocks`.", - "source_line": 88, + "source_line": 100, "type_signature": "BearerAuthMocks", "properties": [ { @@ -20960,7 +21559,12 @@ "type_signature": "ReturnType" }, { - "name": "mock_find_by_account", + "name": "mock_find_actor_by_id", + "kind": "variable", + "type_signature": "ReturnType" + }, + { + "name": "mock_find_actors_by_account", "kind": "variable", "type_signature": "ReturnType" }, @@ -20974,8 +21578,8 @@ { "name": "create_bearer_auth_mocks", "kind": "function", - "doc_comment": "Create mock dependencies for `create_bearer_auth_middleware`, configured per test case.\n\nConfigures the module-level mocks for `query_validate_api_token`,\n`query_account_by_id`, `query_actor_by_account`, and `query_permit_find_active_for_actor`\nso each test case controls return values independently.", - "source_line": 110, + "doc_comment": "Create mock dependencies for `create_bearer_auth_middleware`, configured per test case.\n\nConfigures the module-level mocks for `query_validate_api_token`,\n`query_account_by_id`, `query_actor_by_id`, and `query_permit_find_active_for_actor`\nso each test case controls return values independently.", + "source_line": 123, "type_signature": "(tc: BearerAuthTestOptions): BearerAuthMocks", "return_type": "BearerAuthMocks", "return_description": "mocks bundle with spy references", @@ -20990,14 +21594,14 @@ "name": "TEST_CLIENT_IP", "kind": "variable", "doc_comment": "Default client IP set by the proxy stub in test apps.", - "source_line": 133, + "source_line": 162, "type_signature": "\"127.0.0.1\"" }, { "name": "create_bearer_auth_test_app", "kind": "function", "doc_comment": "Create a Hono app wired with `create_bearer_auth_middleware` using mocked deps.\n\nThe route handler at `/api/test` returns the resolved context in the response body,\nenabling assertions on `REQUEST_CONTEXT_KEY` and `CREDENTIAL_TYPE_KEY`.", - "source_line": 141, + "source_line": 170, "type_signature": "(tc: BearerAuthTestOptions, ip_rate_limiter?: RateLimiter | null): { app: Hono; mocks: BearerAuthMocks; }", "return_type": "{ app: Hono; mocks: BearerAuthMocks; }", "parameters": [ @@ -21016,7 +21620,7 @@ "name": "describe_bearer_auth_cases", "kind": "function", "doc_comment": "Run a table of bearer auth middleware test cases.\n\nGenerates one `test()` per case inside a `describe()` block.", - "source_line": 198, + "source_line": 238, "type_signature": "(suite_name: string, cases: BearerAuthTestCase[], ip_rate_limiter?: RateLimiter | null): void", "return_type": "void", "parameters": [ @@ -21039,14 +21643,14 @@ "name": "TEST_MIDDLEWARE_PATH", "kind": "variable", "doc_comment": "Path used by the echo route in `create_test_middleware_stack_app`.", - "source_line": 273, + "source_line": 320, "type_signature": "\"/api/test\"" }, { "name": "TestMiddlewareStackOptions", "kind": "type", "doc_comment": "Options for `create_test_middleware_stack_app`.", - "source_line": 276, + "source_line": 323, "type_signature": "TestMiddlewareStackOptions", "properties": [ { @@ -21079,7 +21683,7 @@ "name": "TestMiddlewareStackApp", "kind": "type", "doc_comment": "Return type of `create_test_middleware_stack_app`.", - "source_line": 288, + "source_line": 335, "type_signature": "TestMiddlewareStackApp", "properties": [ { @@ -21098,7 +21702,12 @@ "type_signature": "ReturnType" }, { - "name": "mock_find_by_account", + "name": "mock_find_actor_by_id", + "kind": "variable", + "type_signature": "ReturnType" + }, + { + "name": "mock_find_actors_by_account", "kind": "variable", "type_signature": "ReturnType" }, @@ -21113,7 +21722,7 @@ "name": "create_test_middleware_stack_app", "kind": "function", "doc_comment": "Create a Hono app with real proxy + origin + bearer middleware for integration testing.\n\nAll DB queries return undefined (no real database needed).\nThe echo route at `TEST_MIDDLEWARE_PATH` returns `{ok, client_ip, has_context}`.", - "source_line": 306, + "source_line": 354, "type_signature": "(options?: TestMiddlewareStackOptions | undefined): TestMiddlewareStackApp", "return_type": "TestMiddlewareStackApp", "return_description": "the app and mock spies (reconfigure via `mockImplementation` for valid-token paths)", @@ -21529,14 +22138,14 @@ "name": "RpcTestTransport", "kind": "type", "doc_comment": "Minimal transport surface — the duck type `Hono.request` already satisfies.\nExtracted so test setups that want an in-process / WS / mock path can plug\na different dispatcher without changing call sites.", - "source_line": 189, + "source_line": 190, "type_signature": "RpcTestTransport" }, { "name": "http_transport", "kind": "function", "doc_comment": "Adapt a `Hono`-style app into an `RpcTestTransport`.", - "source_line": 192, + "source_line": 193, "type_signature": "(app: { request: (input: string, init: RequestInit) => Response | Promise; }): RpcTestTransport", "return_type": "RpcTestTransport", "parameters": [ @@ -21550,14 +22159,14 @@ "name": "RpcCallResult", "kind": "type", "doc_comment": "Discriminated return from `rpc_call`. `status` is the HTTP status.", - "source_line": 200, + "source_line": 201, "type_signature": "RpcCallResult" }, { "name": "RpcCallArgs", "kind": "type", "doc_comment": "Arguments for `rpc_call`.", - "source_line": 209, + "source_line": 210, "type_signature": "RpcCallArgs", "properties": [ { @@ -21620,7 +22229,7 @@ "description": "if the response body is neither a valid `JsonrpcResponse`" } ], - "source_line": 262, + "source_line": 263, "type_signature": "(args: RpcCallArgs): Promise", "return_type": "Promise", "parameters": [ @@ -21634,7 +22243,7 @@ "name": "rpc_call_non_browser", "kind": "function", "doc_comment": "Same as `rpc_call` but without the default `origin` header. Use for\nbearer-auth probes: `bearer_auth` discards the token when Origin or\nReferer is present (browser context), so a bearer probe via `rpc_call`\nwould short-circuit to 401 before the token is ever validated.\n\nEquivalent to `rpc_call({...args, suppress_default_origin: true})`.", - "source_line": 330, + "source_line": 331, "type_signature": "(args: Omit): Promise", "return_type": "Promise", "parameters": [ @@ -21648,7 +22257,7 @@ "name": "RpcCallResultForSpec", "kind": "type", "doc_comment": "Typed discriminated result returned by `rpc_call_for_spec`. The success\nbranch's `result` is inferred from `TSpec['output']`. The error branch\nstays untyped because JSON-RPC `error.data` shapes vary per error and\nare asserted per call site.", - "source_line": 340, + "source_line": 341, "type_signature": "RpcCallResultForSpec", "generic_params": [ { @@ -21661,7 +22270,7 @@ "name": "RpcCallForSpecArgs", "kind": "type", "doc_comment": "Arguments for `rpc_call_for_spec`. `spec` replaces the loose `method` field.", - "source_line": 345, + "source_line": 346, "type_signature": "RpcCallForSpecArgs", "generic_params": [ { @@ -21680,7 +22289,7 @@ "description": "if the success `result` does not parse against `spec.output`," } ], - "source_line": 369, + "source_line": 370, "type_signature": "(args: RpcCallForSpecArgs): Promise>", "return_type": "Promise>", "parameters": [ @@ -21700,7 +22309,7 @@ "description": "if the response is a JSON-RPC error, if `rpc_call` throws" } ], - "source_line": 395, + "source_line": 396, "type_signature": "(args: RpcCallArgs, output_schema: ZodType>): Promise", "return_type": "Promise", "parameters": [ @@ -21718,7 +22327,7 @@ "name": "find_rpc_action", "kind": "function", "doc_comment": "Find the `RpcAction` for a method within a set of RPC endpoint specs.\nReturns both the endpoint path and the matched action. `undefined` when\nthe method is not registered.", - "source_line": 421, + "source_line": 422, "type_signature": "(rpc_endpoints: readonly RpcEndpointSpec[], method: string): { path: string; action: RpcAction; } | undefined", "return_type": "{ path: string; action: RpcAction; } | undefined", "parameters": [ @@ -21736,7 +22345,7 @@ "name": "find_rpc_method", "kind": "function", "doc_comment": "Find the generated surface entry for a method — the shape returned by\n`generate_app_surface` (JSON-serializable, useful for schema assertions\nat the boundary of a consumer test).", - "source_line": 438, + "source_line": 439, "type_signature": "(rpc_endpoints: readonly AppSurfaceRpcEndpoint[], method: string): { path: string; method_spec: AppSurfaceRpcMethod; } | undefined", "return_type": "{ path: string; method_spec: AppSurfaceRpcMethod; } | undefined", "parameters": [ @@ -21760,7 +22369,7 @@ "description": "if `rpc_endpoints` is empty (hard-fail; see the suite options" } ], - "source_line": 464, + "source_line": 465, "type_signature": "(rpc_endpoints: readonly RpcEndpointSpec[]): string", "return_type": "string", "parameters": [ @@ -22801,7 +23410,7 @@ "name": "FakeWs", "kind": "type", "doc_comment": "A `WSContext` paired with capture arrays. Use `sends` to assert on\noutgoing frames; use `closes` to assert on revocation / close.", - "source_line": 82, + "source_line": 88, "type_signature": "FakeWs", "properties": [ { @@ -22825,7 +23434,7 @@ "name": "create_fake_ws", "kind": "function", "doc_comment": "Build a real `WSContext` backed by in-memory `send`/`close` capture.\nParsing of outgoing frames is left to the caller — `sends` holds the\nraw strings as the dispatcher wrote them.", - "source_line": 93, + "source_line": 99, "type_signature": "(): FakeWs", "return_type": "FakeWs", "parameters": [] @@ -22834,7 +23443,7 @@ "name": "FakeHonoContextOptions", "kind": "type", "doc_comment": "Options for `create_fake_hono_context`.", - "source_line": 109, + "source_line": 115, "type_signature": "FakeHonoContextOptions", "properties": [ { @@ -22870,7 +23479,7 @@ "name": "create_fake_hono_context", "kind": "function", "doc_comment": "Build a fake Hono `Context` exposing the auth keys the dispatcher\nreads via `c.get(...)`. Only `.get()` is populated — no other Hono\ncontext surface is simulated.", - "source_line": 127, + "source_line": 133, "type_signature": "(opts: FakeHonoContextOptions): Context", "return_type": "Context", "parameters": [ @@ -22884,7 +23493,7 @@ "name": "StubUpgrade", "kind": "type", "doc_comment": "The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory capture.", - "source_line": 141, + "source_line": 149, "type_signature": "StubUpgrade", "properties": [ { @@ -22903,7 +23512,7 @@ "name": "create_stub_upgrade", "kind": "function", "doc_comment": "Build a fake `upgradeWebSocket` that captures the `createEvents`\ncallback. The returned middleware is inert — tests drive\n`createEvents` directly.", - "source_line": 151, + "source_line": 159, "type_signature": "(): StubUpgrade", "return_type": "StubUpgrade", "parameters": [] @@ -22912,7 +23521,7 @@ "name": "MinimalActionEnvironment", "kind": "class", "doc_comment": "Minimal `ActionEventEnvironment` for tests that instantiate an\n`ActionPeer` without pulling in the full runtime. Pre-loads a\nspec map from the supplied list.", - "source_line": 171, + "source_line": 179, "extends": [], "implements": ["ActionEventEnvironment"], "members": [ @@ -22957,7 +23566,7 @@ "name": "dispatch_ws_message", "kind": "function", "doc_comment": "Hono types `WSEvents.onMessage` as `() => void | Promise`.\nAwaits only the Promise branch so tests observe full dispatch\n(auth, validation, handler, send).", - "source_line": 190, + "source_line": 198, "type_signature": "(on_message: (evt: MessageEvent, ws: WSContext) => void, event: MessageEvent, ws: WSContext): Promise<...>", "return_type": "Promise", "parameters": [ @@ -22979,7 +23588,7 @@ "name": "WsConnectIdentity", "kind": "type", "doc_comment": "Auth identity for a mock connection.", - "source_line": 205, + "source_line": 213, "type_signature": "WsConnectIdentity", "properties": [ { @@ -23018,7 +23627,7 @@ "name": "MockWsClient", "kind": "type", "doc_comment": "A mock WS client: send requests, inspect/await incoming messages.", - "source_line": 219, + "source_line": 227, "type_signature": "MockWsClient", "properties": [ { @@ -23057,7 +23666,7 @@ { "name": "JsonrpcNotificationFrame", "kind": "type", - "source_line": 289, + "source_line": 297, "type_signature": "JsonrpcNotificationFrame

", "generic_params": [ { @@ -23086,7 +23695,7 @@ { "name": "JsonrpcSuccessResponseFrame", "kind": "type", - "source_line": 295, + "source_line": 303, "type_signature": "JsonrpcSuccessResponseFrame", "generic_params": [ { @@ -23115,7 +23724,7 @@ { "name": "JsonrpcErrorResponseFrame", "kind": "type", - "source_line": 301, + "source_line": 309, "type_signature": "JsonrpcErrorResponseFrame", "generic_params": [ { @@ -23145,7 +23754,7 @@ "name": "is_notification", "kind": "function", "doc_comment": "Predicate matching a JSON-RPC notification with the given method name.", - "source_line": 308, + "source_line": 316, "type_signature": "(method: string): (msg: unknown) => boolean", "return_type": "(msg: unknown) => boolean", "parameters": [ @@ -23159,7 +23768,7 @@ "name": "is_notification_with", "kind": "function", "doc_comment": "Type-guard combinator: match a notification whose typed `params` satisfies\n`match`. Collapses the common test pattern of casting `msg` to\n`JsonrpcNotificationFrame

` in every predicate body.\n\n```ts\nconst match_roster_for = (id: Uuid) =>\n is_notification_with(\n WORLD_METHODS.roster_changed,\n (params) => params.character_id === id && !params.removed,\n );\nconst roster = await client.wait_for(match_roster_for(char_id));\n```", - "source_line": 327, + "source_line": 335, "type_signature": "

(method: string, match: (params: P) => boolean): (msg: unknown) => msg is JsonrpcNotificationFrame

", "return_type": "(msg: unknown) => msg is JsonrpcNotificationFrame

", "parameters": [ @@ -23177,7 +23786,7 @@ "name": "is_response_for", "kind": "function", "doc_comment": "Predicate matching a JSON-RPC response frame (success or error) for the given request id.", - "source_line": 335, + "source_line": 343, "type_signature": "(id: string | number): (msg: unknown) => boolean", "return_type": "(msg: unknown) => boolean", "parameters": [ @@ -23191,7 +23800,7 @@ "name": "CreateWsTestHarnessOptions", "kind": "type", "doc_comment": "Options for `create_ws_test_harness`.", - "source_line": 341, + "source_line": 349, "type_signature": "CreateWsTestHarnessOptions", "generic_params": [ { @@ -23247,7 +23856,7 @@ "name": "WsTestHarness", "kind": "type", "doc_comment": "A harness instance — transport handle + connection factory.", - "source_line": 367, + "source_line": 375, "type_signature": "WsTestHarness", "properties": [ { @@ -23267,7 +23876,7 @@ "name": "create_ws_test_harness", "kind": "function", "doc_comment": "Create a WebSocket test harness for the given specs + handlers.\n\nRegisters against a throwaway Hono app with a fake\n`upgradeWebSocket`; the captured events factory is invoked per\n`connect()` with a synthesized Hono context carrying the requested\nauth identity. Returned clients drive the real\n`onOpen`/`onMessage`/`onClose` path against a real `WSContext`.", - "source_line": 448, + "source_line": 456, "type_signature": "(options: CreateWsTestHarnessOptions): WsTestHarness", "return_type": "WsTestHarness", "parameters": [ @@ -23281,7 +23890,7 @@ "name": "keeper_identity", "kind": "function", "doc_comment": "Convenience: default identity for keeper-authenticated connections.", - "source_line": 626, + "source_line": 636, "type_signature": "(): WsConnectIdentity", "return_type": "WsConnectIdentity", "parameters": [] @@ -23290,7 +23899,7 @@ "name": "build_broadcast_api", "kind": "function", "doc_comment": "Wire a typed broadcast API against the harness's transport, matching\nhow a consumer's real backend composes the stack. Returns the typed\nAPI so tests can call `.tx_run_created(...)` / `.workspace_changed(...)`\netc. directly.\n\n```ts\nconst harness = create_ws_test_harness({specs, handlers});\nconst broadcast = build_broadcast_api({\n harness,\n specs: my_broadcast_action_specs,\n});\nconst client = await harness.connect(keeper_identity());\nawait broadcast.tx_run_created({run_id: '...', ...});\nawait client.wait_for(is_notification('tx_run_created'));\n```", - "source_line": 658, + "source_line": 668, "type_signature": "(options: { harness: WsTestHarness; specs: readonly ({ method: string; initiator: \"frontend\" | \"backend\" | \"both\"; side_effects: boolean; input: ZodType>; ... 7 more ...; rate_limit?: \"both\" | ... 2 more ... | undefined; } | { ...; } | { ...; })[]; }): TApi", "return_type": "TApi", "parameters": [ @@ -23572,9 +24181,9 @@ { "name": "grant_permit", "kind": "function", - "doc_comment": "Offer the role to the recipient via the `permit_offer_create` RPC.\nServer returns the pending offer; the recipient must accept before\nthe permit materializes. Returns the offer payload on success so\ncallers can drive follow-up UX (e.g. seed `PermitOffersState.outgoing`).\n\nA re-offer from the same admin to the same `(account, role)`\nrefreshes the existing pending row — the returned offer id is stable\nacross those calls.\n\nNo-op when the rpc adapter is absent; `error` is set to a descriptive\nmessage so the UI surfaces the misconfiguration.", - "type_signature": "(account_id: string & $brand<\"Uuid\">, role: string): Promise<{ id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<...>; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; } | undefined>", - "return_type": "Promise<{ id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; } | undefined>", + "doc_comment": "Offer the role to the recipient via the `permit_offer_create` RPC.\nServer returns the pending offer; the recipient must accept before\nthe permit materializes. Returns the offer payload on success so\ncallers can drive follow-up UX (e.g. seed `PermitOffersState.outgoing`).\n\nA re-offer from the same admin to the same `(account, role)`\nrefreshes the existing pending row — the returned offer id is stable\nacross those calls.\n\n`to_actor_id` (optional) narrows the offer to a specific actor on\n`account_id`; the in-flight `granting_keys` entry stays at\n`account_id:role` for the account-grain default (so existing\nconsumers reading the 2-segment key keep working) and becomes\n`account_id:role:to_actor_id` when actor-targeted, so the two\nvariants can be in flight without colliding on the per-row spinner.\n\nNo-op when the rpc adapter is absent; `error` is set to a descriptive\nmessage so the UI surfaces the misconfiguration.", + "type_signature": "(account_id: string & $brand<\"Uuid\">, role: string, to_actor_id?: (string & $brand<\"Uuid\">) | null | undefined): Promise<{ id: string & $brand<\"Uuid\">; ... 13 more ...; resulting_permit_id: (string & $brand<...>) | null; } | undefined>", + "return_type": "Promise<{ id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; } | undefined>", "parameters": [ { "name": "account_id", @@ -23583,6 +24192,11 @@ { "name": "role", "type": "string" + }, + { + "name": "to_actor_id", + "type": "(string & $brand<\"Uuid\">) | null | undefined", + "optional": true } ] }, @@ -24420,12 +25034,12 @@ { "name": "fetch", "kind": "function", - "type_signature": "(options?: { event_type?: string | null | undefined; outcome?: \"success\" | \"failure\" | null | undefined; account_id?: (string & $brand<\"Uuid\">) | null | undefined; limit?: number | null | undefined; offset?: number | ... 1 more ... | undefined; since_seq?: number | ... 1 more ... | undefined; } | undefined): Promise<...>", + "type_signature": "(options?: { event_type?: string | null | undefined; outcome?: \"success\" | \"failure\" | null | undefined; account_id?: (string & $brand<\"Uuid\">) | null | undefined; limit?: number | null | undefined; offset?: number | ... 1 more ... | undefined; since_seq?: number | ... 1 more ... | undefined; acting?: (string & $brand<...>) | undefined; } | undefined): Promise<...>", "return_type": "Promise", "parameters": [ { "name": "options", - "type": "{ event_type?: string | null | undefined; outcome?: \"success\" | \"failure\" | null | undefined; account_id?: (string & $brand<\"Uuid\">) | null | undefined; limit?: number | null | undefined; offset?: number | ... 1 more ... | undefined; since_seq?: number | ... 1 more ... | undefined; } | undefined", + "type": "{ event_type?: string | null | undefined; outcome?: \"success\" | \"failure\" | null | undefined; account_id?: (string & $brand<\"Uuid\">) | null | undefined; limit?: number | null | undefined; offset?: number | ... 1 more ... | undefined; since_seq?: number | ... 1 more ... | undefined; acting?: (string & $brand<...>) | ...", "optional": true } ] @@ -25189,7 +25803,7 @@ { "name": "create", "kind": "variable", - "type_signature": "(params: {\n\t\tto_account_id: string;\n\t\trole: string;\n\t\tscope_id?: string | null;\n\t\tmessage?: string | null;\n\t}) => Promise<{offer: PermitOfferJson}>" + "type_signature": "(params: {\n\t\tto_account_id: string;\n\t\tto_actor_id?: string | null;\n\t\trole: string;\n\t\tscope_id?: string | null;\n\t\tmessage?: string | null;\n\t}) => Promise<{offer: PermitOfferJson}>" }, { "name": "accept", @@ -25212,7 +25826,7 @@ "name": "PermitOfferNotification", "kind": "type", "doc_comment": "Narrow WS notification envelope — method + params, matching `JsonrpcNotification`.", - "source_line": 67, + "source_line": 68, "type_signature": "PermitOfferNotification", "properties": [ { @@ -25231,13 +25845,13 @@ "name": "PermitOfferSubscribe", "kind": "type", "doc_comment": "Subscription primitive — consumer wires their WS receiver; returns a disposer.", - "source_line": 73, + "source_line": 74, "type_signature": "PermitOfferSubscribe" }, { "name": "PermitOffersStateOptions", "kind": "type", - "source_line": 77, + "source_line": 78, "type_signature": "PermitOffersStateOptions", "properties": [ { @@ -25262,7 +25876,7 @@ { "name": "PermitOffersState", "kind": "class", - "source_line": 94, + "source_line": 95, "extends": ["Loadable"], "implements": [], "members": [ @@ -25329,13 +25943,13 @@ { "name": "create", "kind": "function", - "doc_comment": "Issue a new offer; merges the returned offer into the cache on success.", - "type_signature": "(params: { to_account_id: string; role: string; scope_id?: string | null | undefined; message?: string | null | undefined; }): Promise<{ id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<...>; ... 11 more ...; resulting_permit_id: (string & $brand<...>) | null; } | undefined>", - "return_type": "Promise<{ id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; role: string; scope_id: (string & $brand<...>) | null; ... 8 more ...; resulting_permit_id: (string & $brand<...>) | null; } | undefined>", + "doc_comment": "Issue a new offer; merges the returned offer into the cache on success.\n\n`to_actor_id` (optional) narrows the offer to a specific actor on\n`to_account_id`; omit / null for the account-grain default (any actor\non the recipient account may accept).", + "type_signature": "(params: { to_account_id: string; to_actor_id?: string | null | undefined; role: string; scope_id?: string | null | undefined; message?: string | null | undefined; }): Promise<{ id: string & $brand<...>; ... 13 more ...; resulting_permit_id: (string & $brand<...>) | null; } | undefined>", + "return_type": "Promise<{ id: string & $brand<\"Uuid\">; from_actor_id: string & $brand<\"Uuid\">; to_account_id: string & $brand<\"Uuid\">; to_actor_id: (string & $brand<\"Uuid\">) | null; ... 10 more ...; resulting_permit_id: (string & $brand<...>) | null; } | undefined>", "parameters": [ { "name": "params", - "type": "{ to_account_id: string; role: string; scope_id?: string | null | undefined; message?: string | null | undefined; }" + "type": "{ to_account_id: string; to_actor_id?: string | null | undefined; role: string; scope_id?: string | null | undefined; message?: string | null | undefined; }" } ] }, @@ -25437,6 +26051,12 @@ "name": "to_account_id", "type": "string" }, + { + "name": "to_actor_id", + "type": "string | null", + "optional": true, + "description": "Narrow the offer to a specific actor on `to_account_id`. Omit\n(or `null`, the default) for the account-grain default — any\nactor on the recipient account may accept." + }, { "name": "roles", "type": "Array", diff --git a/src/test/actions/action_codegen.test.ts b/src/test/actions/action_codegen.test.ts index 1ec32609..40e7bc90 100644 --- a/src/test/actions/action_codegen.test.ts +++ b/src/test/actions/action_codegen.test.ts @@ -320,6 +320,123 @@ describe('generate_actions_api_method_signature', () => { assert.ok(!built.includes('ActionInputs')); }); + // --- optional-input detection --- + // + // Probes both `safeParse(undefined)` (catches `z.optional(...)` wrappers) + // and `safeParse({})` (catches all-optional-fields strict objects, the + // canonical `z.strictObject({acting: ActingActor})` audit-actor shape). + // Either succeeding flips the emitted parameter to `input?:` so the + // caller can omit the argument at the typed surface. Mirrors the + // dispatcher's HTTP convention (`raw_params ?? {}` for non-`z.void()` + // schemas), so the codegen tracks runtime semantics. + + test('all-optional-fields strict object emits input?: (acting-only audit-actor shape)', () => { + // Regression case for the audit-actor migration. Listing-style action + // specs replaced `input: z.void()` with `z.strictObject({acting: + // ActingActor})` — `acting` is `Uuid.optional()`, so the strict object + // itself accepts `{}` but not `undefined`. The earlier + // `safeParse(undefined)`-only probe missed this and emitted `input:` + // (required), breaking nullary call sites in tx's `AdminRpcApi`. + const imports = new ImportBuilder(); + const spec: ActionSpecUnion = { + method: 'admin_account_list', + kind: 'request_response', + initiator: 'frontend', + auth: 'authenticated', + side_effects: false, + input: z.strictObject({acting: z.string().optional()}), + output: z.strictObject({accounts: z.array(z.string())}), + async: true, + description: 'List all accounts.', + }; + const sig = generate_actions_api_method_signature(spec, imports); + assert.ok( + sig.includes("input?: ActionInputs['admin_account_list']"), + `expected optional input, got: ${sig}`, + ); + }); + + test('all-nullish-fields strict object emits input?: (audit-log filter shape)', () => { + // `z.strictObject({a: z.string().nullish(), b: z.number().nullish()})` + // is the audit-log / permit-history filter shape. Every field is + // nullish, so `{}` parses cleanly. Same ergonomic flip as the + // acting-only case above. + const imports = new ImportBuilder(); + const spec: ActionSpecUnion = { + method: 'audit_log_list', + kind: 'request_response', + initiator: 'frontend', + auth: 'authenticated', + side_effects: false, + input: z.strictObject({ + event_type: z.string().nullish(), + limit: z.number().nullish(), + }), + output: z.strictObject({events: z.array(z.string())}), + async: true, + description: 'List audit log events.', + }; + const sig = generate_actions_api_method_signature(spec, imports); + assert.ok( + sig.includes("input?: ActionInputs['audit_log_list']"), + `expected optional input, got: ${sig}`, + ); + }); + + test('strict object with required field stays required (mixed required + optional)', () => { + // Regression guard: the relaxation must not flip schemas that require + // at least one field. `z.strictObject({account_id: ..., acting: ...})` + // is the admin_session_revoke_all shape — `account_id` is required, so + // `{}` does not parse, and the typed surface must keep `input:`. + const imports = new ImportBuilder(); + const spec: ActionSpecUnion = { + method: 'admin_session_revoke_all', + kind: 'request_response', + initiator: 'frontend', + auth: 'authenticated', + side_effects: true, + input: z.strictObject({ + account_id: z.string(), + acting: z.string().optional(), + }), + output: z.strictObject({ok: z.literal(true)}), + async: true, + description: 'Revoke all sessions for an account.', + }; + const sig = generate_actions_api_method_signature(spec, imports); + assert.ok( + sig.includes("input: ActionInputs['admin_session_revoke_all']"), + `expected required input, got: ${sig}`, + ); + // Belt-and-suspenders — must NOT emit the optional shape. + assert.ok( + !sig.includes("input?: ActionInputs['admin_session_revoke_all']"), + `expected required input, got optional: ${sig}`, + ); + }); + + test('z.optional(z.strictObject(...)) wrapper still emits input?: (existing probe)', () => { + // The pre-relaxation probe (`safeParse(undefined)` only) caught wrapper + // shapes; this test pins that the OR-extended probe still does. + const imports = new ImportBuilder(); + const spec: ActionSpecUnion = { + method: 'wrapped_input', + kind: 'request_response', + initiator: 'frontend', + auth: 'authenticated', + side_effects: false, + input: z.optional(z.strictObject({account_id: z.string()})), + output: z.strictObject({ok: z.literal(true)}), + async: true, + description: 'Wrapped optional input.', + }; + const sig = generate_actions_api_method_signature(spec, imports); + assert.ok( + sig.includes("input?: ActionInputs['wrapped_input']"), + `expected optional input, got: ${sig}`, + ); + }); + test('custom collections_path threads through', () => { const imports = new ImportBuilder(); generate_actions_api_method_signature(create_rr('frontend'), imports, { @@ -1048,9 +1165,13 @@ describe('generate_frontend_actions_api', () => { const result = generate_frontend_actions_api(fixture_specs, imports, { include_protocol_actions: true, }); + // heartbeat's input is `z.strictObject({})` (empty object) — `safeParse({})` + // succeeds, so the emitted parameter is `input?:`. The protocol-action + // fixture is the canonical "no meaningful input" case, exactly the kind + // of all-optional shape the probe relaxation targets. assert.ok( result.includes( - "heartbeat: (input: ActionInputs['heartbeat'], options?: RpcClientCallOptions)", + "heartbeat: (input?: ActionInputs['heartbeat'], options?: RpcClientCallOptions)", ), ); assert.ok(result.includes('cancel:')); diff --git a/src/test/actions/action_rpc.test.ts b/src/test/actions/action_rpc.test.ts index 7471a050..4970a6a4 100644 --- a/src/test/actions/action_rpc.test.ts +++ b/src/test/actions/action_rpc.test.ts @@ -16,11 +16,17 @@ import {fuz_auth_guard_resolver} from '$lib/auth/route_guards.js'; import {generate_app_surface} from '$lib/http/surface.js'; import {create_stub_db} from '$lib/testing/stubs.js'; import {REQUEST_CONTEXT_KEY} from '$lib/auth/request_context.js'; -import {CREDENTIAL_TYPE_KEY, type CredentialType} from '$lib/hono_context.js'; +import { + ACCOUNT_ID_KEY, + CREDENTIAL_TYPE_KEY, + TEST_CONTEXT_PRESET_KEY, + type CredentialType, +} from '$lib/hono_context.js'; import {create_test_request_context} from '$lib/testing/auth_apps.js'; import {create_test_actor} from '$lib/testing/entities.js'; import {jsonrpc_errors, JSONRPC_ERROR_CODES} from '$lib/http/jsonrpc_errors.js'; import {RateLimiter} from '$lib/rate_limiter.js'; +import type {Uuid} from '@fuzdev/fuz_util/id.js'; const log = new Logger('test', {level: 'off'}); const db = create_stub_db(); @@ -94,7 +100,9 @@ const create_test_app = ( const app = new Hono(); if (auth_context) { app.use('/*', async (c, next) => { + (c as any).set(ACCOUNT_ID_KEY, auth_context.account.id); (c as any).set(REQUEST_CONTEXT_KEY, auth_context); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); if (credential_type) { (c as any).set(CREDENTIAL_TYPE_KEY, credential_type); } @@ -1110,7 +1118,9 @@ describe('rate limit', () => { const auth_context = create_test_request_context(); const app = new Hono(); app.use('/*', async (c, next) => { + (c as any).set(ACCOUNT_ID_KEY, auth_context.account.id); (c as any).set(REQUEST_CONTEXT_KEY, auth_context); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); (c as any).set(CREDENTIAL_TYPE_KEY, 'session' as CredentialType); await next(); }); @@ -1142,22 +1152,25 @@ describe('rate limit', () => { assert.strictEqual(typeof third.error?.data?.retry_after, 'number'); }); - test('per-actor isolation — separate budgets', async () => { + test('per-account isolation — separate budgets', async () => { const limiter = make_limiter(1); - // `create_test_request_context()` returns a shared default actor id — - // build a second context with `create_test_actor` so the two contexts - // hash to distinct buckets. + // Account-keyed rate limiting hashes on `account.id`. `create_test_request_context()` + // returns the shared default account; build a second context with a + // distinct account so the two contexts hash to different buckets. const actor_a = create_test_request_context(); const base_b = create_test_request_context(); const actor_b = { ...base_b, + account: {...base_b.account, id: 'acc_2' as Uuid, username: 'beta'}, actor: create_test_actor({id: 'act_2', account_id: 'acc_2'}), }; const make_app = (auth_context: ReturnType) => { const app = new Hono(); app.use('/*', async (c, next) => { + (c as any).set(ACCOUNT_ID_KEY, auth_context.account.id); (c as any).set(REQUEST_CONTEXT_KEY, auth_context); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); (c as any).set(CREDENTIAL_TYPE_KEY, 'session' as CredentialType); await next(); }); @@ -1191,10 +1204,10 @@ describe('rate limit', () => { }), ); - // actor a consumes their entire budget + // account a consumes their entire budget assert.strictEqual((await (await send_a()).json()).result?.ok, true); assert.strictEqual((await send_a()).status, 429); - // actor b has not started — still allowed + // account b has not started — still allowed assert.strictEqual((await (await send_b()).json()).result?.ok, true); }); @@ -1203,7 +1216,9 @@ describe('rate limit', () => { const auth_context = create_test_request_context(); const app = new Hono(); app.use('/*', async (c, next) => { + (c as any).set(ACCOUNT_ID_KEY, auth_context.account.id); (c as any).set(REQUEST_CONTEXT_KEY, auth_context); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); (c as any).set(CREDENTIAL_TYPE_KEY, 'session' as CredentialType); await next(); }); @@ -1234,7 +1249,9 @@ describe('rate limit', () => { const auth_context = create_test_request_context(); const app = new Hono(); app.use('/*', async (c, next) => { + (c as any).set(ACCOUNT_ID_KEY, auth_context.account.id); (c as any).set(REQUEST_CONTEXT_KEY, auth_context); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); (c as any).set(CREDENTIAL_TYPE_KEY, 'session' as CredentialType); await next(); }); diff --git a/src/test/actions/register_ws_endpoint.test.ts b/src/test/actions/register_ws_endpoint.test.ts index 75954e0b..83121bb3 100644 --- a/src/test/actions/register_ws_endpoint.test.ts +++ b/src/test/actions/register_ws_endpoint.test.ts @@ -21,8 +21,9 @@ import {heartbeat_action} from '$lib/actions/heartbeat.js'; import {parse_allowed_origins} from '$lib/http/origin.js'; import {REQUEST_CONTEXT_KEY} from '$lib/auth/request_context.js'; import {ROLE_ADMIN, type RoleName} from '$lib/auth/role_schema.js'; -import {CREDENTIAL_TYPE_KEY} from '$lib/hono_context.js'; +import {ACCOUNT_ID_KEY, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; import {create_stub_upgrade} from '$lib/testing/ws_round_trip.js'; +import {create_stub_db} from '$lib/testing/stubs.js'; import {create_test_request_context} from '$lib/testing/auth_apps.js'; const log = new Logger('test', {level: 'off'}); @@ -47,8 +48,11 @@ const build_app = (opts: BuildOptions = {}) => { app.use('*', async (c, next) => { if (authenticated) { - c.set(REQUEST_CONTEXT_KEY, create_test_request_context(role)); + const ctx = create_test_request_context(role); + c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(ACCOUNT_ID_KEY, ctx.account.id); c.set(CREDENTIAL_TYPE_KEY, 'session'); + c.set(TEST_CONTEXT_PRESET_KEY, true); } await next(); }); @@ -62,6 +66,7 @@ const build_app = (opts: BuildOptions = {}) => { actions: [heartbeat_action], extend_context: (base) => base, allowed_origins: parse_allowed_origins(ALLOWED_ORIGIN), + db: create_stub_db(), required_role, log, }); @@ -169,6 +174,7 @@ describe('composition', () => { actions: [heartbeat_action], extend_context: (base) => base, allowed_origins: parse_allowed_origins(ALLOWED_ORIGIN), + db: create_stub_db(), log, }); diff --git a/src/test/actions/transports_ws_auth_guard.test.ts b/src/test/actions/transports_ws_auth_guard.test.ts index 27d3a7f0..a095f254 100644 --- a/src/test/actions/transports_ws_auth_guard.test.ts +++ b/src/test/actions/transports_ws_auth_guard.test.ts @@ -48,6 +48,7 @@ const create_audit_event = (overrides: Partial): AuditLogEvent => actor_id: null, account_id: null, target_account_id: null, + target_actor_id: null, ip: null, created_at: new Date().toISOString(), metadata: null, diff --git a/src/test/auth/account_queries.db.test.ts b/src/test/auth/account_queries.db.test.ts index e740e7e5..4e8ff16e 100644 --- a/src/test/auth/account_queries.db.test.ts +++ b/src/test/auth/account_queries.db.test.ts @@ -17,7 +17,6 @@ import { query_delete_account, query_account_has_any, query_create_actor, - query_actor_by_account, query_actor_by_id, query_create_account_with_actor, } from '$lib/auth/account_queries.js'; @@ -279,23 +278,6 @@ describe_db('account queries', (get_db) => { assert.strictEqual(actor.name, 'alice'); }); - test('find_by_account returns the actor', async () => { - const db = get_db(); - const deps = {db}; - const account = await query_create_account(deps, {username: 'bob', password_hash: 'hash'}); - await query_create_actor(deps, account.id, 'bob'); - const found = await query_actor_by_account(deps, account.id); - assert.ok(found); - assert.strictEqual(found.name, 'bob'); - }); - - test('find_by_account returns undefined for missing account', async () => { - const db = get_db(); - const deps = {db}; - const found = await query_actor_by_account(deps, '00000000-0000-0000-0000-000000000099'); - assert.strictEqual(found, undefined); - }); - test('find_by_id returns the actor', async () => { const db = get_db(); const deps = {db}; diff --git a/src/test/auth/account_status.test.ts b/src/test/auth/account_status.test.ts index 647a00c0..0f7056de 100644 --- a/src/test/auth/account_status.test.ts +++ b/src/test/auth/account_status.test.ts @@ -15,6 +15,7 @@ import {create_account_status_route_spec} from '$lib/auth/account_routes.js'; import {apply_route_specs} from '$lib/http/route_spec.js'; import {fuz_auth_guard_resolver} from '$lib/auth/route_guards.js'; import {REQUEST_CONTEXT_KEY, type RequestContext} from '$lib/auth/request_context.js'; +import {ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; import type {Uuid} from '@fuzdev/fuz_util/id.js'; import {create_stub_db} from '$lib/testing/stubs.js'; @@ -53,7 +54,9 @@ const create_test_app = ( const app = new Hono(); if (auth_ctx) { app.use('/*', async (c, next) => { + (c as any).set(ACCOUNT_ID_KEY, auth_ctx.account.id); (c as any).set(REQUEST_CONTEXT_KEY, auth_ctx); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); } diff --git a/src/test/auth/api_token_queries.db.test.ts b/src/test/auth/api_token_queries.db.test.ts index b85a8ec8..ecc37050 100644 --- a/src/test/auth/api_token_queries.db.test.ts +++ b/src/test/auth/api_token_queries.db.test.ts @@ -22,19 +22,21 @@ import {describe_db} from '../db_fixture.js'; const log = new Logger('test', {level: 'off'}); -/** Create a test account and return its id. */ -const setup_account = async (get_db: () => import('$lib/db/db.js').Db): Promise => { +/** Create a test account + actor and return both ids. */ +const setup_account = async ( + get_db: () => import('$lib/db/db.js').Db, +): Promise<{account_id: string; actor_id: string}> => { const db = get_db(); const deps = {db}; const account = await query_create_account(deps, {username: 'token_user', password_hash: 'hash'}); - await query_create_actor(deps, account.id, 'token_user'); - return account.id; + const actor = await query_create_actor(deps, account.id, 'token_user'); + return {account_id: account.id, actor_id: actor.id}; }; describe_db('ApiTokenQueries', (get_db) => { describe('create', () => { test('stores a token and returns the record', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token_hash} = generate_api_token(); @@ -50,7 +52,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('stores a token with expiration', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token_hash} = generate_api_token(); @@ -71,7 +73,7 @@ describe_db('ApiTokenQueries', (get_db) => { describe('validate', () => { test('returns the token for a valid raw token', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token, token_hash} = generate_api_token(); @@ -98,7 +100,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('returns undefined for expired token', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token, token_hash} = generate_api_token(); @@ -111,7 +113,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('tracks usage in pending_effects', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token, token_hash} = generate_api_token(); @@ -132,7 +134,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('updates last_used_at on validate', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token, token_hash} = generate_api_token(); @@ -152,7 +154,7 @@ describe_db('ApiTokenQueries', (get_db) => { describe('revoke', () => { test('deletes an existing token', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token_hash} = generate_api_token(); @@ -164,7 +166,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('returns false for non-existent token', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; @@ -174,7 +176,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('revoked token cannot be validated', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token, token_hash} = generate_api_token(); @@ -189,7 +191,7 @@ describe_db('ApiTokenQueries', (get_db) => { describe('revoke_all_for_account', () => { test('revokes all tokens for the account', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; for (let i = 0; i < 3; i++) { @@ -203,7 +205,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('returns 0 for account with no tokens', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; @@ -215,7 +217,7 @@ describe_db('ApiTokenQueries', (get_db) => { describe('revoke_for_account', () => { test('revokes token belonging to the account', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token_hash} = generate_api_token(); @@ -227,7 +229,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('rejects revocation for wrong account (IDOR guard)', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token_hash} = generate_api_token(); @@ -249,7 +251,7 @@ describe_db('ApiTokenQueries', (get_db) => { describe('list_for_account', () => { test('lists tokens without token_hash', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token_hash} = generate_api_token(); @@ -264,7 +266,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('returns empty array for account with no tokens', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; @@ -274,7 +276,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('returns multiple tokens', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; for (let i = 0; i < 3; i++) { @@ -292,7 +294,7 @@ describe_db('ApiTokenQueries', (get_db) => { describe('validate with ip and usage tracking', () => { test('sets last_used_ip and last_used_at after validation', async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; const {id, token, token_hash} = generate_api_token(); @@ -338,7 +340,7 @@ describe_db('ApiTokenQueries', (get_db) => { for (const {token_count, limit, expected_evictions, name} of limit_cases) { test(`enforce_token_limit matrix: ${name}`, async () => { - const account_id = await setup_account(get_db); + const {account_id} = await setup_account(get_db); const db = get_db(); const deps = {db}; diff --git a/src/test/auth/app_settings_actions.db.test.ts b/src/test/auth/app_settings_actions.db.test.ts index 48d2bb96..416eaa7a 100644 --- a/src/test/auth/app_settings_actions.db.test.ts +++ b/src/test/auth/app_settings_actions.db.test.ts @@ -52,7 +52,7 @@ describe_db('app settings RPC actions', (get_db) => { app: test_app.app, path: RPC_PATH, spec: app_settings_get_action_spec, - params: undefined, + params: {}, headers: test_app.create_session_headers(), }); assert.ok(r.ok); @@ -70,7 +70,7 @@ describe_db('app settings RPC actions', (get_db) => { app: test_app.app, path: RPC_PATH, spec: app_settings_get_action_spec, - params: undefined, + params: {}, headers: non_admin.create_session_headers(), }); assert.ok(!r.ok); @@ -87,7 +87,7 @@ describe_db('app settings RPC actions', (get_db) => { app: test_app.app, path: RPC_PATH, spec: app_settings_get_action_spec, - params: undefined, + params: {}, headers: { host: 'localhost', origin: 'http://localhost:5173', @@ -147,7 +147,7 @@ describe_db('app settings RPC actions', (get_db) => { app: test_app.app, path: RPC_PATH, spec: app_settings_get_action_spec, - params: undefined, + params: {}, headers: test_app.create_session_headers(), }); assert.ok(get_r.ok); diff --git a/src/test/auth/audit_log.test.ts b/src/test/auth/audit_log.test.ts index 21bcde64..3026edc4 100644 --- a/src/test/auth/audit_log.test.ts +++ b/src/test/auth/audit_log.test.ts @@ -11,6 +11,7 @@ import {describe, test, assert, vi, afterEach, beforeEach} from 'vitest'; import {Hono} from 'hono'; import {REQUEST_CONTEXT_KEY, type RequestContext} from '$lib/auth/request_context.js'; +import {ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; import {create_account_route_specs} from '$lib/auth/account_routes.js'; import {apply_route_specs} from '$lib/http/route_spec.js'; import {fuz_auth_guard_resolver} from '$lib/auth/route_guards.js'; @@ -231,7 +232,9 @@ describe('account route audit logging', () => { app.use('*', test_proxy_middleware); if (options?.inject_ctx) { app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, options.inject_ctx!.account.id); c.set(REQUEST_CONTEXT_KEY, options.inject_ctx!); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); } @@ -304,7 +307,8 @@ describe('account route audit logging', () => { assert.strictEqual(audit_log_calls.length, 1); assert.strictEqual(audit_log_calls[0]!.event_type, 'logout'); assert.strictEqual(audit_log_calls[0]!.outcome, undefined); // defaults to 'success' - assert.strictEqual(audit_log_calls[0]!.actor_id, ACT_TEST); + // account-grain — see `AuditLogEvent.actor_id` doc-comment + assert.strictEqual(audit_log_calls[0]!.actor_id, undefined); assert.strictEqual(audit_log_calls[0]!.account_id, ACC_TEST); assert.strictEqual(audit_log_calls[0]!.ip, TEST_CONNECTION_IP); }); @@ -330,7 +334,8 @@ describe('account route audit logging', () => { assert.strictEqual(audit_log_calls.length, 1); assert.strictEqual(audit_log_calls[0]!.event_type, 'password_change'); assert.strictEqual(audit_log_calls[0]!.outcome, undefined); // defaults to 'success' - assert.strictEqual(audit_log_calls[0]!.actor_id, ACT_TEST); + // account-grain — see `AuditLogEvent.actor_id` doc-comment + assert.strictEqual(audit_log_calls[0]!.actor_id, undefined); assert.strictEqual(audit_log_calls[0]!.account_id, ACC_TEST); assert.strictEqual((audit_log_calls[0]!.metadata as any).sessions_revoked, 2); }); @@ -350,7 +355,8 @@ describe('account route audit logging', () => { assert.strictEqual(audit_log_calls.length, 1); assert.strictEqual(audit_log_calls[0]!.event_type, 'password_change'); assert.strictEqual(audit_log_calls[0]!.outcome, 'failure'); - assert.strictEqual(audit_log_calls[0]!.actor_id, ACT_TEST); + // account-grain — see `AuditLogEvent.actor_id` doc-comment + assert.strictEqual(audit_log_calls[0]!.actor_id, undefined); assert.strictEqual(audit_log_calls[0]!.account_id, ACC_TEST); }); diff --git a/src/test/auth/audit_log_fire_and_forget.test.ts b/src/test/auth/audit_log_fire_and_forget.test.ts index 378045d2..915b36e2 100644 --- a/src/test/auth/audit_log_fire_and_forget.test.ts +++ b/src/test/auth/audit_log_fire_and_forget.test.ts @@ -46,6 +46,7 @@ const FAKE_EVENT: AuditLogEvent = { actor_id: null, account_id: 'acct-1' as Uuid, target_account_id: null, + target_actor_id: null, ip: null, created_at: '2025-01-01T00:00:00.000Z', metadata: null, diff --git a/src/test/auth/auth_attack_surface.test.ts b/src/test/auth/auth_attack_surface.test.ts index b863605a..c4d5ac6c 100644 --- a/src/test/auth/auth_attack_surface.test.ts +++ b/src/test/auth/auth_attack_surface.test.ts @@ -22,6 +22,7 @@ import { require_role, type RequestContext, } from '$lib/auth/request_context.js'; +import {ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; import {SESSION_COOKIE_OPTIONS} from '$lib/auth/session_cookie.js'; import {API_TOKEN_PREFIX} from '$lib/auth/api_token.js'; import {PASSWORD_LENGTH_MIN} from '$lib/auth/password.js'; @@ -81,7 +82,9 @@ const create_test_app = (specs: Array, auth_ctx?: RequestContext): Ho // Simulate request context middleware — sets context if provided if (auth_ctx) { app.use('/*', async (c, next) => { + (c as any).set(ACCOUNT_ID_KEY, auth_ctx.account.id); (c as any).set(REQUEST_CONTEXT_KEY, auth_ctx); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); } @@ -262,7 +265,10 @@ describe('targeted adversarial tests', () => { test('require_role returns 403 with role info', async () => { const app = new Hono(); app.use('/*', async (c, next) => { - (c as any).set(REQUEST_CONTEXT_KEY, create_test_ctx('viewer')); + const ctx = create_test_ctx('viewer'); + (c as any).set(ACCOUNT_ID_KEY, ctx.account.id); + (c as any).set(REQUEST_CONTEXT_KEY, ctx); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); app.get('/test', require_role('admin'), (c) => c.json({ok: true})); diff --git a/src/test/auth/bearer_actor_deleted.db.test.ts b/src/test/auth/bearer_actor_deleted.db.test.ts new file mode 100644 index 00000000..124d0ad2 --- /dev/null +++ b/src/test/auth/bearer_actor_deleted.db.test.ts @@ -0,0 +1,84 @@ +/** + * Bearer auth + dispatcher authorization phase: empty actor list. + * + * The bearer middleware validates the token and sets `ACCOUNT_ID_KEY` / + * `CREDENTIAL_TYPE_KEY` only — actor + permit resolution lives in the + * dispatcher's authorization phase. When the actor list is empty the + * authorization phase surfaces `ERROR_NO_ACTORS_ON_ACCOUNT` (500). + * + * The other 500 reason `apply_authorization_phase` can emit — + * `ERROR_ACCOUNT_VANISHED` (torn read race where + * `query_account_by_id` / `query_actor_by_id` returns null after a + * successful `resolve_acting_actor`) — is exercised at the unit level + * in `request_context.authorization_phase.test.ts`. Reaching it via + * a real DB requires deleting the `account` row mid-request, which + * cascades to `api_token` / `auth_session` and tears down the + * credential before the dispatcher ever runs. + * + * Companion to `permit_offer.multi_actor.*.db.test.ts` which exercises + * the `actor_not_on_account` / `actor_required` (400) branches via the + * `acting` parameter. + * + * @module + */ + +import {test, assert} from 'vitest'; + +import {admin_session_revoke_all_action_spec} from '$lib/auth/admin_action_specs.js'; +import {create_test_app} from '$lib/testing/app_server.js'; +import {ERROR_NO_ACTORS_ON_ACCOUNT} from '$lib/http/error_schemas.js'; +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import {rpc_call_for_spec} from '$lib/testing/rpc_helpers.js'; +import { + RPC_PATH, + create_admin_route_specs, + describe_db, + session_options, +} from './admin_rpc_test_helpers.js'; + +describe_db('bearer auth + dispatcher authorization phase — empty actor list', (get_db) => { + test('all actors deleted → 500 no_actors_on_account (resolve_acting_actor empty list)', async () => { + const test_app = await create_test_app({ + session_options, + create_route_specs: create_admin_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + + // Delete every actor on the bootstrap account directly. The bearer + // token still validates — the `api_token` row is intact and the + // account row is intact — so this isolates the dispatcher's + // authorization phase as the only code that walks the actor list. + await test_app.backend.deps.db.query('DELETE FROM actor WHERE account_id = $1', [ + test_app.backend.account.id, + ]); + + // Hit a role-gated RPC method (`auth: {role: 'admin'}`) over the + // bearer transport. `suppress_default_origin: true` drops the + // default Origin header so `bearer_auth` doesn't discard the token + // under browser-context rules. + const res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: admin_session_revoke_all_action_spec, + params: {account_id: test_app.backend.account.id}, + headers: test_app.create_bearer_headers(), + suppress_default_origin: true, + }); + + // The dispatcher folds the auth-phase failure into a JSON-RPC + // envelope: 500 status, internal_error code, reason on + // `error.data.reason`. `rpc_call_for_spec` rejects non-envelope + // bodies, so reaching this assertion is itself the regression + // guard for the wrap. + assert.ok(!res.ok); + assert.strictEqual(res.status, 500); + assert.strictEqual(res.error.message, ERROR_NO_ACTORS_ON_ACCOUNT); + assert.strictEqual( + (res.error.data as {reason?: string} | undefined)?.reason, + ERROR_NO_ACTORS_ON_ACCOUNT, + ); + + await test_app.cleanup(); + }); +}); diff --git a/src/test/auth/bearer_api_token.db.test.ts b/src/test/auth/bearer_api_token.db.test.ts index 899f43c9..97cebae8 100644 --- a/src/test/auth/bearer_api_token.db.test.ts +++ b/src/test/auth/bearer_api_token.db.test.ts @@ -8,7 +8,7 @@ import {describe, assert, test, vi, afterEach} from 'vitest'; import {Logger} from '@fuzdev/fuz_util/log.js'; import {wait} from '@fuzdev/fuz_util/async.js'; -import {query_create_account, query_delete_account} from '$lib/auth/account_queries.js'; +import {query_create_account_with_actor, query_delete_account} from '$lib/auth/account_queries.js'; import { query_create_api_token, query_validate_api_token, @@ -27,10 +27,16 @@ afterEach(() => { vi.restoreAllMocks(); }); -const create_test_account = async (database: Db, username: string): Promise => { +const create_test_account = async ( + database: Db, + username: string, +): Promise<{account_id: string; actor_id: string}> => { const deps = {db: database}; - const account = await query_create_account(deps, {username, password_hash: 'hash'}); - return account.id; + const {account, actor} = await query_create_account_with_actor(deps, { + username, + password_hash: 'hash', + }); + return {account_id: account.id, actor_id: actor.id}; }; describe('generate_api_token', () => { @@ -78,7 +84,7 @@ describe('hash_api_token', () => { describe_db('ApiTokenQueries', (get_db) => { test('create stores a token record', async () => { - const account_id = await create_test_account(get_db(), 'alice'); + const {account_id} = await create_test_account(get_db(), 'alice'); const deps = {db: get_db()}; const {id, token_hash} = generate_api_token(); const record = await query_create_api_token(deps, id, account_id, 'CLI token', token_hash); @@ -88,7 +94,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('create with expiration', async () => { - const account_id = await create_test_account(get_db(), 'bob'); + const {account_id} = await create_test_account(get_db(), 'bob'); const deps = {db: get_db()}; const {id, token_hash} = generate_api_token(); const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); @@ -104,7 +110,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('validate returns the token for a valid raw token', async () => { - const account_id = await create_test_account(get_db(), 'charlie'); + const {account_id} = await create_test_account(get_db(), 'charlie'); const db = get_db(); const deps = {db}; const {token, id, token_hash} = generate_api_token(); @@ -127,7 +133,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('validate returns undefined for expired token', async () => { - const account_id = await create_test_account(get_db(), 'dave'); + const {account_id} = await create_test_account(get_db(), 'dave'); const db = get_db(); const deps = {db}; const {token, id, token_hash} = generate_api_token(); @@ -139,7 +145,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('revoke deletes the token', async () => { - const account_id = await create_test_account(get_db(), 'eve'); + const {account_id} = await create_test_account(get_db(), 'eve'); const db = get_db(); const deps = {db}; const {token, id, token_hash} = generate_api_token(); @@ -151,7 +157,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('revoke returns false for missing token', async () => { - const account_id = await create_test_account(get_db(), 'eve_missing'); + const {account_id} = await create_test_account(get_db(), 'eve_missing'); const deps = {db: get_db()}; assert.strictEqual( await query_revoke_api_token_for_account(deps, 'tok_nonexistent', account_id), @@ -160,7 +166,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('list_for_account returns tokens without hashes', async () => { - const account_id = await create_test_account(get_db(), 'frank'); + const {account_id} = await create_test_account(get_db(), 'frank'); const deps = {db: get_db()}; const t1 = generate_api_token(); const t2 = generate_api_token(); @@ -175,7 +181,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('revoke_for_account succeeds for own token', async () => { - const account_id = await create_test_account(get_db(), 'heidi'); + const {account_id} = await create_test_account(get_db(), 'heidi'); const db = get_db(); const deps = {db}; const {token, id, token_hash} = generate_api_token(); @@ -189,8 +195,8 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('revoke_for_account fails for other account token', async () => { - const alice_id = await create_test_account(get_db(), 'alice_rfa'); - const bob_id = await create_test_account(get_db(), 'bob_rfa'); + const {account_id: alice_id} = await create_test_account(get_db(), 'alice_rfa'); + const {account_id: bob_id} = await create_test_account(get_db(), 'bob_rfa'); const db = get_db(); const deps = {db}; const {token, id, token_hash} = generate_api_token(); @@ -206,7 +212,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('tokens cascade delete with account', async () => { - const account_id = await create_test_account(get_db(), 'grace'); + const {account_id} = await create_test_account(get_db(), 'grace'); const db = get_db(); const deps = {db}; const {id, token_hash} = generate_api_token(); @@ -219,7 +225,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('enforce_token_limit returns 0 when under limit', async () => { - const account_id = await create_test_account(get_db(), 'limit_under'); + const {account_id} = await create_test_account(get_db(), 'limit_under'); const deps = {db: get_db()}; const t1 = generate_api_token(); const t2 = generate_api_token(); @@ -234,7 +240,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('enforce_token_limit evicts oldest when over limit', async () => { - const account_id = await create_test_account(get_db(), 'limit_over'); + const {account_id} = await create_test_account(get_db(), 'limit_over'); const db = get_db(); const deps = {db}; const base = Date.now(); @@ -264,7 +270,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('enforce_token_limit returns 0 at exact limit', async () => { - const account_id = await create_test_account(get_db(), 'limit_exact'); + const {account_id} = await create_test_account(get_db(), 'limit_exact'); const deps = {db: get_db()}; const t1 = generate_api_token(); const t2 = generate_api_token(); @@ -281,7 +287,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('enforce_token_limit with max 1 keeps only newest', async () => { - const account_id = await create_test_account(get_db(), 'limit_one'); + const {account_id} = await create_test_account(get_db(), 'limit_one'); const db = get_db(); const deps = {db}; const base = Date.now(); @@ -306,7 +312,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('enforce_token_limit with max 0 evicts all tokens', async () => { - const account_id = await create_test_account(get_db(), 'limit_zero'); + const {account_id} = await create_test_account(get_db(), 'limit_zero'); const deps = {db: get_db()}; const t1 = generate_api_token(); const t2 = generate_api_token(); @@ -321,8 +327,8 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('enforce_token_limit does not affect other accounts', async () => { - const alice_id = await create_test_account(get_db(), 'limit_alice'); - const bob_id = await create_test_account(get_db(), 'limit_bob'); + const {account_id: alice_id} = await create_test_account(get_db(), 'limit_alice'); + const {account_id: bob_id} = await create_test_account(get_db(), 'limit_bob'); const deps = {db: get_db()}; const a1 = generate_api_token(); const a2 = generate_api_token(); @@ -342,7 +348,7 @@ describe_db('ApiTokenQueries', (get_db) => { }); test('validate logs error when usage tracking update fails', async () => { - const account_id = await create_test_account(get_db(), 'ivan'); + const {account_id} = await create_test_account(get_db(), 'ivan'); const db = get_db(); const deps = {db}; const {token, id, token_hash} = generate_api_token(); diff --git a/src/test/auth/bearer_auth_middleware.test.ts b/src/test/auth/bearer_auth_middleware.test.ts index 71aeefe9..d9f4fe0c 100644 --- a/src/test/auth/bearer_auth_middleware.test.ts +++ b/src/test/auth/bearer_auth_middleware.test.ts @@ -22,41 +22,7 @@ import {RateLimitError, ERROR_RATE_LIMIT_EXCEEDED} from '$lib/http/error_schemas // --- Test data --- -const MOCK_ACCOUNT = { - id: 'acc_1', - username: 'tokenuser', - password_hash: 'hash', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - created_by: null, - updated_by: null, - email: null, - email_verified: false, -}; - -const MOCK_ACTOR = { - id: 'act_1', - account_id: 'acc_1', - name: 'tokenuser', - created_at: new Date().toISOString(), - updated_at: null, - updated_by: null, -}; - -const MOCK_PERMITS = [ - { - id: 'perm_1', - actor_id: 'act_1', - role: 'admin', - created_at: new Date().toISOString(), - expires_at: null, - revoked_at: null, - revoked_by: null, - granted_by: null, - }, -]; - -const MOCK_API_TOKEN = {account_id: 'acc_1', id: 'tok_1'}; +const MOCK_API_TOKEN = {account_id: 'acc_1', actor_id: 'act_1', id: 'tok_1'}; // --- Test case table --- @@ -181,60 +147,27 @@ const bearer_auth_cases: Array = [ assert.strictEqual(mocks.mock_validate.mock.calls[0]![2], TEST_CLIENT_IP); }, }, + // success path — bearer auth sets the account-grain identity from the + // validated token and stops. Account / actor / permit lookups belong to + // the dispatcher's authorization phase, which only runs when a route's + // auth requires permits or its input declares `acting?: ActingActor`. { - name: 'valid token but account deleted — soft-fails (no info leakage)', + name: 'valid token — sets account_id, credential_type, and api_token_id', headers: {Authorization: 'Bearer secret_fuz_token_good'}, mock_validate_result: MOCK_API_TOKEN, - mock_find_by_id_result: undefined, expected_status: 'next', validate_expectation: 'called', - assert_mocks: (mocks) => { - // find_by_id was called with (deps, account_id) - assert.strictEqual(mocks.mock_find_by_id.mock.calls.length, 1); - assert.strictEqual(mocks.mock_find_by_id.mock.calls[0]![1], 'acc_1'); - // find_by_account should NOT have been called - assert.strictEqual(mocks.mock_find_by_account.mock.calls.length, 0); - }, - }, - { - name: 'valid token but actor missing — soft-fails (no info leakage)', - headers: {Authorization: 'Bearer secret_fuz_token_good'}, - mock_validate_result: MOCK_API_TOKEN, - mock_find_by_id_result: MOCK_ACCOUNT, - mock_find_by_account_result: undefined, - expected_status: 'next', - validate_expectation: 'called', - assert_mocks: (mocks) => { - // find_by_account was called with (deps, account_id) - assert.strictEqual(mocks.mock_find_by_account.mock.calls.length, 1); - assert.strictEqual(mocks.mock_find_by_account.mock.calls[0]![1], 'acc_1'); - // permits should NOT have been loaded - assert.strictEqual(mocks.mock_find_active_for_actor.mock.calls.length, 0); - }, - }, - - // success path - { - name: 'full success — sets request context and credential type', - headers: {Authorization: 'Bearer secret_fuz_token_good'}, - mock_validate_result: MOCK_API_TOKEN, - mock_find_by_id_result: MOCK_ACCOUNT, - mock_find_by_account_result: MOCK_ACTOR, - mock_permits_result: MOCK_PERMITS, - expected_status: 'next', - validate_expectation: 'called', - assert_context_set: true, + assert_account_set: true, + expected_account_id: 'acc_1', expected_api_token_id: 'tok_1', assert_mocks: (mocks) => { // validate called with (deps, raw_token, ip, pending_effects) assert.strictEqual(mocks.mock_validate.mock.calls[0]![1], 'secret_fuz_token_good'); assert.strictEqual(mocks.mock_validate.mock.calls[0]![2], TEST_CLIENT_IP); - // full chain was called - assert.strictEqual(mocks.mock_find_by_id.mock.calls.length, 1); - assert.strictEqual(mocks.mock_find_by_account.mock.calls.length, 1); - assert.strictEqual(mocks.mock_find_active_for_actor.mock.calls.length, 1); - // permits queried with (deps, actor_id) - assert.strictEqual(mocks.mock_find_active_for_actor.mock.calls[0]![1], 'act_1'); + // account/actor/permit queries are not the bearer middleware's concern + assert.strictEqual(mocks.mock_find_by_id.mock.calls.length, 0); + assert.strictEqual(mocks.mock_find_actor_by_id.mock.calls.length, 0); + assert.strictEqual(mocks.mock_find_active_for_actor.mock.calls.length, 0); }, }, ]; @@ -280,9 +213,6 @@ describe('bearer auth rate limiter side effects', () => { name: '', headers: {Authorization: 'Bearer secret_fuz_token_good'}, mock_validate_result: MOCK_API_TOKEN, - mock_find_by_id_result: MOCK_ACCOUNT, - mock_find_by_account_result: MOCK_ACTOR, - mock_permits_result: MOCK_PERMITS, expected_status: 'next', }; const {app} = create_bearer_auth_test_app(tc, mock_limiter as any); diff --git a/src/test/auth/cleanup.db.test.ts b/src/test/auth/cleanup.db.test.ts index fa5cf9b2..de160982 100644 --- a/src/test/auth/cleanup.db.test.ts +++ b/src/test/auth/cleanup.db.test.ts @@ -38,6 +38,7 @@ const future = (ms_from_now: number): Date => new Date(Date.now() + ms_from_now) interface TestAccounts { grantor_actor_id: Uuid; recipient_account_id: Uuid; + recipient_actor_id: Uuid; recipient_account_id_2: Uuid; } @@ -46,10 +47,11 @@ const seed_accounts = async (db: Db): Promise => { {db}, {username: 'cleanup_grantor', password_hash: 'hash'}, ); - const {account: recipient_account} = await query_create_account_with_actor( - {db}, - {username: 'cleanup_recipient', password_hash: 'hash'}, - ); + const {account: recipient_account, actor: recipient_actor} = + await query_create_account_with_actor( + {db}, + {username: 'cleanup_recipient', password_hash: 'hash'}, + ); const {account: recipient_account_2} = await query_create_account_with_actor( {db}, {username: 'cleanup_recipient_2', password_hash: 'hash'}, @@ -57,6 +59,7 @@ const seed_accounts = async (db: Db): Promise => { return { grantor_actor_id: grantor_actor.id, recipient_account_id: recipient_account.id, + recipient_actor_id: recipient_actor.id, recipient_account_id_2: recipient_account_2.id, }; }; diff --git a/src/test/auth/daemon_token_middleware.test.ts b/src/test/auth/daemon_token_middleware.test.ts index 163c0275..28f786e1 100644 --- a/src/test/auth/daemon_token_middleware.test.ts +++ b/src/test/auth/daemon_token_middleware.test.ts @@ -18,12 +18,10 @@ import { create_daemon_token_middleware, resolve_keeper_account_id, } from '$lib/auth/daemon_token_middleware.js'; -import {REQUEST_CONTEXT_KEY} from '$lib/auth/request_context.js'; -import {AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY} from '$lib/hono_context.js'; +import {ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY} from '$lib/hono_context.js'; import { ERROR_INVALID_DAEMON_TOKEN, ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED, - ERROR_KEEPER_ACCOUNT_NOT_FOUND, } from '$lib/http/error_schemas.js'; import {ROLE_KEEPER} from '$lib/auth/role_schema.js'; import type {QueryDeps} from '$lib/db/query_deps.js'; @@ -33,19 +31,22 @@ import {create_test_account, create_test_actor, create_test_permit} from '$lib/t // Mock module-level query functions used by daemon_token_middleware const { mock_query_account_by_id, - mock_query_actor_by_account, + mock_query_actor_by_id, + mock_query_actors_by_account, mock_query_permit_find_active_for_actor, mock_query_permit_find_account_id_for_role, } = vi.hoisted(() => ({ mock_query_account_by_id: vi.fn(), - mock_query_actor_by_account: vi.fn(), + mock_query_actor_by_id: vi.fn(), + mock_query_actors_by_account: vi.fn(), mock_query_permit_find_active_for_actor: vi.fn(), mock_query_permit_find_account_id_for_role: vi.fn(), })); vi.mock('$lib/auth/account_queries.js', () => ({ query_account_by_id: mock_query_account_by_id, - query_actor_by_account: mock_query_actor_by_account, + query_actor_by_id: mock_query_actor_by_id, + query_actors_by_account: mock_query_actors_by_account, })); vi.mock('$lib/auth/permit_queries.js', () => ({ @@ -78,13 +79,15 @@ const setup_default_mocks = () => { }), ]; mock_query_account_by_id.mockImplementation(async () => account); - mock_query_actor_by_account.mockImplementation(async () => actor); + mock_query_actor_by_id.mockImplementation(async () => actor); + mock_query_actors_by_account.mockImplementation(async () => [actor]); mock_query_permit_find_active_for_actor.mockImplementation(async () => permits); }; beforeEach(() => { mock_query_account_by_id.mockReset(); - mock_query_actor_by_account.mockReset(); + mock_query_actor_by_id.mockReset(); + mock_query_actors_by_account.mockReset(); mock_query_permit_find_active_for_actor.mockReset(); mock_query_permit_find_account_id_for_role.mockReset(); setup_default_mocks(); @@ -95,11 +98,11 @@ const create_daemon_app = (state: DaemonTokenState): Hono => { const app = new Hono(); app.use('/*', create_daemon_token_middleware(state, mock_deps)); app.get('/test', (c) => { - const ctx = c.get(REQUEST_CONTEXT_KEY); + const account_id = c.get(ACCOUNT_ID_KEY); const credential_type = c.get(CREDENTIAL_TYPE_KEY); const api_token_id = c.get(AUTH_API_TOKEN_ID_KEY); return c.json({ - context: ctx ? {account_id: ctx.account.id, actor_id: ctx.actor.id} : null, + context: account_id ? {account_id, actor_id: null} : null, credential_type: credential_type ?? null, api_token_id: api_token_id ?? null, }); @@ -219,7 +222,7 @@ describe('create_daemon_token_middleware', () => { assert.strictEqual(body.credential_type, null); }); - test('valid current token sets request context and credential_type', async () => { + test('valid current token sets account_id and credential_type', async () => { const state = create_state(); const app = create_daemon_app(state); @@ -230,12 +233,14 @@ describe('create_daemon_token_middleware', () => { const body = await res.json(); assert.ok(body.context); assert.strictEqual(body.context.account_id, 'acct-keeper'); - assert.strictEqual(body.context.actor_id, 'actor-keeper'); + // Middleware sets only the account-grain identity. Actor resolution + // happens in the dispatcher's authorization phase when the route's + // auth requires permits or its input declares `acting`. assert.strictEqual(body.credential_type, 'daemon_token'); assert.strictEqual(body.api_token_id, null); }); - test('valid previous token sets request context and credential_type', async () => { + test('valid previous token sets account_id and credential_type', async () => { const previous = generate_daemon_token(); const state = create_state({previous_token: previous}); const app = create_daemon_app(state); @@ -246,6 +251,7 @@ describe('create_daemon_token_middleware', () => { assert.strictEqual(res.status, 200); const body = await res.json(); assert.ok(body.context); + assert.strictEqual(body.context.account_id, 'acct-keeper'); assert.strictEqual(body.credential_type, 'daemon_token'); }); @@ -285,51 +291,21 @@ describe('create_daemon_token_middleware', () => { assert.strictEqual(body.error, ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED); }); - test('returns 500 when keeper account not found in database', async () => { - const state = create_state(); - mock_query_account_by_id.mockImplementation(async () => undefined); - const app = create_daemon_app(state); - - const res = await app.request('/test', { - headers: {[DAEMON_TOKEN_HEADER]: state.current_token}, - }); - assert.strictEqual(res.status, 500); - const body = await res.json(); - assert.strictEqual(body.error, ERROR_KEEPER_ACCOUNT_NOT_FOUND); - }); - - test('returns 500 when keeper actor not found in database', async () => { - const state = create_state(); - mock_query_actor_by_account.mockImplementation(async () => undefined); - const app = create_daemon_app(state); - - const res = await app.request('/test', { - headers: {[DAEMON_TOKEN_HEADER]: state.current_token}, - }); - assert.strictEqual(res.status, 500); - const body = await res.json(); - assert.strictEqual(body.error, ERROR_KEEPER_ACCOUNT_NOT_FOUND); - }); - test('overrides existing session context when daemon token header present', async () => { const state = create_state(); const app = new Hono(); - // simulate session middleware setting context first + // simulate session middleware setting account-only context first app.use('/*', async (c, next) => { - c.set(REQUEST_CONTEXT_KEY, { - account: create_test_account({id: 'acct-session-user' as Uuid}), - actor: create_test_actor({id: 'actor-session-user' as Uuid}), - permits: [], - }); + c.set(ACCOUNT_ID_KEY, 'acct-session-user'); c.set(CREDENTIAL_TYPE_KEY, 'session'); await next(); }); app.use('/*', create_daemon_token_middleware(state, mock_deps)); app.get('/test', (c) => { - const ctx = c.get(REQUEST_CONTEXT_KEY); + const account_id = c.get(ACCOUNT_ID_KEY); const credential_type = c.get(CREDENTIAL_TYPE_KEY); return c.json({ - account_id: ctx?.account.id ?? null, + account_id: account_id ?? null, credential_type: credential_type ?? null, }); }); @@ -339,7 +315,7 @@ describe('create_daemon_token_middleware', () => { }); assert.strictEqual(res.status, 200); const body = await res.json(); - // daemon token overrides the session context + // daemon token overrides the session-derived account id assert.strictEqual(body.account_id, 'acct-keeper'); assert.strictEqual(body.credential_type, 'daemon_token'); }); diff --git a/src/test/auth/invite_signup.integration.db.test.ts b/src/test/auth/invite_signup.integration.db.test.ts index 2d534ec7..e7475648 100644 --- a/src/test/auth/invite_signup.integration.db.test.ts +++ b/src/test/auth/invite_signup.integration.db.test.ts @@ -244,7 +244,7 @@ describe_db('invite + signup integration', (get_db) => { app: test_app.app, path: RPC_PATH, spec: invite_list_action_spec, - params: undefined, + params: {}, headers: test_app.create_session_headers(), }); assert.ok(r.ok); @@ -281,7 +281,7 @@ describe_db('invite + signup integration', (get_db) => { app: test_app.app, path: RPC_PATH, spec: invite_list_action_spec, - params: undefined, + params: {}, headers: test_app.create_session_headers(), }); assert.ok(list_r.ok); @@ -663,7 +663,7 @@ describe_db('invite + signup integration', (get_db) => { app: test_app.app, path: RPC_PATH, spec: invite_list_action_spec, - params: undefined, + params: {}, headers: test_app.create_session_headers(), }); assert.ok(list_r.ok); diff --git a/src/test/auth/middleware_stack.test.ts b/src/test/auth/middleware_stack.test.ts index 6ea60ca0..86edcd1c 100644 --- a/src/test/auth/middleware_stack.test.ts +++ b/src/test/auth/middleware_stack.test.ts @@ -80,20 +80,13 @@ describe('Host header spoofing', () => { }); test('spoofed Host with valid bearer token still authenticates', async () => { - const {app, mock_validate, mock_find_by_id, mock_find_by_account} = - create_test_middleware_stack_app({connection_ip: TRUSTED_PROXY}); + const {app, mock_validate} = create_test_middleware_stack_app({connection_ip: TRUSTED_PROXY}); mock_validate.mockResolvedValueOnce({ id: 'tok-1', account_id: 'acct-1', name: 'test', token_hash: 'h', }); - mock_find_by_id.mockResolvedValueOnce({id: 'acct-1', username: 'test'}); - mock_find_by_account.mockResolvedValueOnce({ - id: 'actor-1', - account_id: 'acct-1', - name: 'test', - }); const res = await app.request(TEST_MIDDLEWARE_PATH, { headers: { Host: 'evil.attacker.com:666', @@ -102,7 +95,7 @@ describe('Host header spoofing', () => { }); assert.strictEqual(res.status, 200); const body = await res.json(); - assert.strictEqual(body.has_context, true); + assert.strictEqual(body.account_id, 'acct-1'); }); }); @@ -205,22 +198,26 @@ describe('rate limiting keys on resolved client IP', () => { }); const VALID_TOKEN = 'valid_token_xyz'; - const {app, mock_validate, mock_find_by_id, mock_find_by_account} = - create_test_middleware_stack_app({ - connection_ip: TRUSTED_PROXY, - ip_rate_limiter: limiter, - }); + const {app, mock_validate} = create_test_middleware_stack_app({ + connection_ip: TRUSTED_PROXY, + ip_rate_limiter: limiter, + }); - // configure mocks for a valid token path + // configure mocks for a valid token path — bearer auth only consumes + // `query_validate_api_token`; account / actor / permit lookups are the + // dispatcher's authorization phase concern, not middleware. mock_validate.mockImplementation((_deps: any, raw_token: string) => Promise.resolve( raw_token === VALID_TOKEN - ? {id: 'tok-1', account_id: 'acct-1', name: 'test', token_hash: 'h'} + ? { + id: 'tok-1', + account_id: 'acct-1', + name: 'test', + token_hash: 'h', + } : undefined, ), ); - mock_find_by_id.mockResolvedValue({id: 'acct-1', username: 'test'}); - mock_find_by_account.mockResolvedValue({id: 'actor-1', account_id: 'acct-1', name: 'test'}); // exhaust rate limit for 5.5.5.5 with invalid tokens (soft-fail 200, but record() still fires) for (let i = 0; i < 2; i++) { @@ -249,7 +246,7 @@ describe('rate limiting keys on resolved client IP', () => { }); assert.strictEqual(allowed.status, 200); const body = await allowed.json(); - assert.strictEqual(body.has_context, true, 'valid token should build request context'); + assert.strictEqual(body.account_id, 'acct-1', 'valid token should set ACCOUNT_ID_KEY'); assert.strictEqual(body.client_ip, '6.6.6.6', 'XFF should resolve to client IP'); // 6.6.6.6 with invalid token soft-fails to 200 (not rate-limited — valid token reset its counter) diff --git a/src/test/auth/password_change.test.ts b/src/test/auth/password_change.test.ts index 5162e85d..6eff0360 100644 --- a/src/test/auth/password_change.test.ts +++ b/src/test/auth/password_change.test.ts @@ -15,6 +15,7 @@ import {RateLimiter} from '$lib/rate_limiter.js'; import {create_proxy_middleware} from '$lib/http/proxy.js'; import type {Uuid} from '@fuzdev/fuz_util/id.js'; import {REQUEST_CONTEXT_KEY, type RequestContext} from '$lib/auth/request_context.js'; +import {ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; import {create_account_route_specs} from '$lib/auth/account_routes.js'; import {apply_route_specs} from '$lib/http/route_spec.js'; import {fuz_auth_guard_resolver} from '$lib/auth/route_guards.js'; @@ -38,7 +39,8 @@ vi.mock('$lib/auth/account_queries.js', () => ({ query_account_by_username_or_email: vi.fn(), query_update_account_password: mock_update_password, query_account_by_id: vi.fn(), - query_actor_by_account: vi.fn(), + query_actor_by_id: vi.fn(), + query_actors_by_account: vi.fn(() => Promise.resolve([])), })); vi.mock('$lib/auth/session_queries.js', async (importOriginal) => { @@ -174,7 +176,9 @@ const create_password_change_app = ( // inject authenticated request context before route guards app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, fake_ctx.account.id); c.set(REQUEST_CONTEXT_KEY, fake_ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); @@ -234,11 +238,15 @@ describe('password change handler', () => { assert.strictEqual(mock_hash_password.mock.calls[0]![0], valid_new_password); // account was updated with (deps, account_id, password_hash, updated_by) + // `password_change` is account-grain — `updated_by` stays null per the + // audit-actor rule (the operation is performed by the account; the + // actor resolved by middleware is incidental under v1 1:1 and is not + // required at all under multi-actor). assert.strictEqual(mock_update_password.mock.calls.length, 1); const [_deps, account_id, hash, updated_by] = mock_update_password.mock.calls[0]!; assert.strictEqual(account_id, 'acc_test'); assert.strictEqual(hash, 'new_hashed_password'); - assert.strictEqual(updated_by, 'act_test'); + assert.strictEqual(updated_by, null); // all sessions revoked with (deps, account_id) assert.strictEqual(mock_revoke_all.mock.calls.length, 1); diff --git a/src/test/auth/permit_offer.multi_actor.account_grain.db.test.ts b/src/test/auth/permit_offer.multi_actor.account_grain.db.test.ts new file mode 100644 index 00000000..039e9481 --- /dev/null +++ b/src/test/auth/permit_offer.multi_actor.account_grain.db.test.ts @@ -0,0 +1,81 @@ +/** + * Multi-actor coverage — account-grain offers (`to_actor_id` null). + * + * Any actor on the recipient account may accept; the audit envelope + * leaves `target_actor_id` null on the offer-shape events because the + * offer itself is not yet bound to a specific actor. + * + * @module + */ + +import {assert, describe, test} from 'vitest'; + +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import {permit_offer_create_action_spec} from '$lib/auth/permit_offer_action_specs.js'; +import {query_accept_offer} from '$lib/auth/permit_offer_queries.js'; +import type {AuditLogEvent} from '$lib/auth/audit_log_schema.js'; +import {rpc_call_for_spec} from '$lib/testing/rpc_helpers.js'; + +import {RPC_PATH, describe_db} from './permit_offer_test_helpers.js'; +import {create_multi_actor_helpers} from './permit_offer.multi_actor.fixtures.js'; + +describe_db('permit_offer.multi_actor — account_grain', (get_db) => { + const {build_app_with_audit, add_second_actor} = create_multi_actor_helpers(get_db); + + describe('account-grain offers (`to_actor_id` null)', () => { + test('any actor on the recipient account may accept', async () => { + const events: Array = []; + const test_app = await build_app_with_audit(events); + const recipient = await test_app.create_account({username: 'multi_acct_recipient'}); + const second_actor_id = await add_second_actor(recipient.account.id, 'second'); + + const create_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: {to_account_id: recipient.account.id, role: ROLE_ADMIN}, + headers: test_app.create_session_headers(), + }); + assert.ok(create_res.ok); + assert.strictEqual(create_res.result.offer.to_actor_id, null); + + // Direct query call exercises the `to_actor_id IS NULL` branch + // where any actor on `to_account_id` may accept. + const accepted = await get_db().transaction(async (tx) => + query_accept_offer( + {db: tx}, + { + offer_id: create_res.result.offer.id, + to_account_id: recipient.account.id, + actor_id: second_actor_id, + ip: null, + }, + ), + ); + assert.strictEqual(accepted.permit.actor_id, second_actor_id); + }); + + test('audit envelope leaves target_actor_id null on offer-shape events', async () => { + const events: Array = []; + const test_app = await build_app_with_audit(events); + const recipient = await test_app.create_account({username: 'multi_acct_envelope'}); + + const res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: {to_account_id: recipient.account.id, role: ROLE_ADMIN}, + headers: test_app.create_session_headers(), + }); + assert.ok(res.ok); + const create_event = events.find( + (e) => + e.event_type === 'permit_offer_create' && + (e.metadata as {offer_id?: string}).offer_id === res.result.offer.id, + ); + assert.ok(create_event); + assert.strictEqual(create_event.target_account_id, recipient.account.id); + assert.strictEqual(create_event.target_actor_id, null); + }); + }); +}); diff --git a/src/test/auth/permit_offer.multi_actor.actor_grain.db.test.ts b/src/test/auth/permit_offer.multi_actor.actor_grain.db.test.ts new file mode 100644 index 00000000..12163433 --- /dev/null +++ b/src/test/auth/permit_offer.multi_actor.actor_grain.db.test.ts @@ -0,0 +1,301 @@ +/** + * Multi-actor coverage — actor-grain offers (`to_actor_id` set). + * + * Only the named actor may accept; sibling actors on the same account + * reject with `permit_offer_actor_mismatch`. Offers targeted at an + * actor that doesn't belong to `to_account_id` reject up-front with + * `offer_actor_account_mismatch`. Self-target check still fires when + * the grantor's account has multiple actors. + * + * @module + */ + +import {assert, describe, test} from 'vitest'; +import {assert_rejects} from '@fuzdev/fuz_util/testing.js'; + +import {create_test_app} from '$lib/testing/app_server.js'; +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import { + permit_offer_create_action_spec, + permit_offer_accept_action_spec, + ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH, + ERROR_OFFER_ACTOR_MISMATCH, +} from '$lib/auth/permit_offer_action_specs.js'; +import { + query_accept_offer, + query_permit_offer_create, + PermitOfferActorAccountMismatchError, + PermitOfferActorMismatchError, +} from '$lib/auth/permit_offer_queries.js'; +import type {AuditLogEvent} from '$lib/auth/audit_log_schema.js'; +import {rpc_call_for_spec} from '$lib/testing/rpc_helpers.js'; + +import { + RPC_PATH, + create_route_specs, + describe_db, + session_options, +} from './permit_offer_test_helpers.js'; +import {create_multi_actor_helpers} from './permit_offer.multi_actor.fixtures.js'; + +describe_db('permit_offer.multi_actor — actor_grain', (get_db) => { + const {build_app_with_audit, add_second_actor} = create_multi_actor_helpers(get_db); + + describe('actor-grain offers (`to_actor_id` set)', () => { + test('only the named actor may accept; wrong-actor rejects with permit_offer_actor_mismatch', async () => { + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({username: 'multi_actor_target'}); + const second_actor_id = await add_second_actor(recipient.account.id, 'second'); + + const create_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: recipient.account.id, + to_actor_id: recipient.actor.id, + role: ROLE_ADMIN, + }, + headers: test_app.create_session_headers(), + }); + assert.ok(create_res.ok); + assert.strictEqual(create_res.result.offer.to_actor_id, recipient.actor.id); + + // Wrong actor (sibling on the same account) — must reject. + const wrong_err = await assert_rejects(() => + get_db().transaction(async (tx) => + query_accept_offer( + {db: tx}, + { + offer_id: create_res.result.offer.id, + to_account_id: recipient.account.id, + actor_id: second_actor_id, + ip: null, + }, + ), + ), + ); + assert.ok(wrong_err instanceof PermitOfferActorMismatchError); + + // Correct actor — succeeds. + const accepted = await get_db().transaction(async (tx) => + query_accept_offer( + {db: tx}, + { + offer_id: create_res.result.offer.id, + to_account_id: recipient.account.id, + actor_id: recipient.actor.id, + ip: null, + }, + ), + ); + assert.strictEqual(accepted.permit.actor_id, recipient.actor.id); + }); + + test('action-level accept succeeds when the caller passes acting: actor_b', async () => { + // Sessions are account-grain (no actor binding); the per-request + // `acting` field on the RPC params is what picks the acting actor. + // With the dispatcher wired, the same recipient session can pass + // `acting: actor_a` (rejected — wrong actor) or `acting: actor_b` + // (accepted). Single account, two actors, one session. + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({username: 'multi_actor_b_session'}); + const second_actor_id = await add_second_actor(recipient.account.id, 'recipient_b'); + + // Offer targeted at actor B. + const create_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: recipient.account.id, + to_actor_id: second_actor_id, + role: ROLE_ADMIN, + }, + headers: test_app.create_session_headers(), + }); + assert.ok(create_res.ok); + + // Recipient passes `acting: recipient.actor.id` (the wrong actor — + // the offer is targeted at actor B). Rejected with the action-level + // wrong-actor reason. + const wrong_actor_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_accept_action_spec, + params: {offer_id: create_res.result.offer.id, acting: recipient.actor.id}, + headers: recipient.create_session_headers(), + }); + assert.ok(!wrong_actor_res.ok); + assert.strictEqual(wrong_actor_res.status, 403); + assert.strictEqual( + (wrong_actor_res.error.data as {reason: string} | undefined)?.reason, + ERROR_OFFER_ACTOR_MISMATCH, + ); + + // Recipient passes `acting: actor_b` and retries — succeeds. + const accept_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_accept_action_spec, + params: {offer_id: create_res.result.offer.id, acting: second_actor_id}, + headers: recipient.create_session_headers(), + }); + assert.ok(accept_res.ok); + assert.strictEqual(accept_res.result.offer.to_actor_id, second_actor_id); + }); + + test('action-level wrong-actor accept maps PermitOfferActorMismatchError to ERROR_OFFER_ACTOR_MISMATCH', async () => { + // Single account, two actors. The offer is targeted at the second + // actor; the recipient passes `acting: recipient.actor.id` (the + // first actor on the account — the wrong actor for this offer). + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({username: 'multi_actor_action_wrong'}); + const second_actor_id = await add_second_actor(recipient.account.id, 'second_wrong'); + + // Offer targeted at the second actor. + const create_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: recipient.account.id, + to_actor_id: second_actor_id, + role: ROLE_ADMIN, + }, + headers: test_app.create_session_headers(), + }); + assert.ok(create_res.ok); + + const accept_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_accept_action_spec, + params: {offer_id: create_res.result.offer.id, acting: recipient.actor.id}, + headers: recipient.create_session_headers(), + }); + assert.ok(!accept_res.ok); + assert.strictEqual(accept_res.status, 403); + assert.strictEqual( + (accept_res.error.data as {reason: string} | undefined)?.reason, + ERROR_OFFER_ACTOR_MISMATCH, + ); + }); + + test('create envelope carries the target actor on actor-grain offers', async () => { + const events: Array = []; + const test_app = await build_app_with_audit(events); + const recipient = await test_app.create_account({username: 'multi_actor_envelope'}); + + const res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: recipient.account.id, + to_actor_id: recipient.actor.id, + role: ROLE_ADMIN, + }, + headers: test_app.create_session_headers(), + }); + assert.ok(res.ok); + const create_event = events.find( + (e) => + e.event_type === 'permit_offer_create' && + (e.metadata as {offer_id?: string}).offer_id === res.result.offer.id, + ); + assert.ok(create_event); + assert.strictEqual(create_event.target_account_id, recipient.account.id); + assert.strictEqual(create_event.target_actor_id, recipient.actor.id); + }); + + test('to_actor_id from a different account rejects with offer_actor_account_mismatch', async () => { + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({username: 'multi_actor_xacct_recipient'}); + const stranger = await test_app.create_account({username: 'multi_actor_xacct_stranger'}); + + // Direct query: throws. + const err = await assert_rejects(() => + query_permit_offer_create( + {db: get_db()}, + { + from_actor_id: test_app.backend.actor.id, + to_account_id: recipient.account.id, + to_actor_id: stranger.actor.id, + role: ROLE_ADMIN, + expires_at: new Date(Date.now() + 60 * 60 * 1000), + }, + ), + ); + assert.ok(err instanceof PermitOfferActorAccountMismatchError); + + // Action-level: maps to invalid_params with the new reason. + const res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: recipient.account.id, + to_actor_id: stranger.actor.id, + role: ROLE_ADMIN, + }, + headers: test_app.create_session_headers(), + }); + assert.ok(!res.ok); + assert.strictEqual(res.status, 400); + assert.strictEqual( + (res.error.data as {reason: string} | undefined)?.reason, + ERROR_OFFER_ACTOR_ACCOUNT_MISMATCH, + ); + }); + + test('grantor-side self-target check still fires across multiple grantor actors', async () => { + // Two actors on the grantor's account: the self-target check + // resolves the offering actor's account, not the recipient's. + // Adding a sibling actor on the grantor must not unblock a + // self-targeted offer when the grantor picks a specific actor + // via `acting`. + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + await add_second_actor(test_app.backend.account.id, 'admin_second'); + + const res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: test_app.backend.account.id, + role: ROLE_ADMIN, + acting: test_app.backend.actor.id, + }, + headers: test_app.create_session_headers(), + }); + assert.ok(!res.ok); + assert.strictEqual(res.status, 400); + }); + }); +}); diff --git a/src/test/auth/permit_offer.multi_actor.cascade.db.test.ts b/src/test/auth/permit_offer.multi_actor.cascade.db.test.ts new file mode 100644 index 00000000..def13af2 --- /dev/null +++ b/src/test/auth/permit_offer.multi_actor.cascade.db.test.ts @@ -0,0 +1,211 @@ +/** + * Multi-actor coverage — cascade inheritance. + * + * Audit envelopes for `permit_offer_retract`, `permit_offer_decline`, + * `permit_offer_expire`, and the in-tx `permit_offer_supersede` event + * inherit `to_actor_id` from the offer being terminated. Decline is the + * exception — it routes back to the grantor, so both target columns + * carry the grantor side regardless of `to_actor_id`. + * + * @module + */ + +import {assert, describe, test} from 'vitest'; +import type {Uuid} from '@fuzdev/fuz_util/id.js'; +import {Logger} from '@fuzdev/fuz_util/log.js'; + +import {create_test_app} from '$lib/testing/app_server.js'; +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import { + permit_offer_create_action_spec, + permit_offer_decline_action_spec, + permit_offer_retract_action_spec, +} from '$lib/auth/permit_offer_action_specs.js'; +import {query_accept_offer} from '$lib/auth/permit_offer_queries.js'; +import {cleanup_expired_permit_offers} from '$lib/auth/cleanup.js'; +import type {AuditLogEvent} from '$lib/auth/audit_log_schema.js'; +import {rpc_call_for_spec} from '$lib/testing/rpc_helpers.js'; + +import { + RPC_PATH, + create_route_specs, + describe_db, + session_options, +} from './permit_offer_test_helpers.js'; +import {create_multi_actor_helpers} from './permit_offer.multi_actor.fixtures.js'; + +describe_db('permit_offer.multi_actor — cascade', (get_db) => { + const {build_app_with_audit} = create_multi_actor_helpers(get_db); + + describe('cascade inheritance', () => { + test('actor-targeted retract carries the actor on the audit envelope', async () => { + const events: Array = []; + const test_app = await build_app_with_audit(events); + const recipient = await test_app.create_account({username: 'multi_actor_retract'}); + + const create_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: recipient.account.id, + to_actor_id: recipient.actor.id, + role: ROLE_ADMIN, + }, + headers: test_app.create_session_headers(), + }); + assert.ok(create_res.ok); + + events.length = 0; + const retract_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_retract_action_spec, + params: {offer_id: create_res.result.offer.id}, + headers: test_app.create_session_headers(), + }); + assert.ok(retract_res.ok); + + const retract_event = events.find((e) => e.event_type === 'permit_offer_retract'); + assert.ok(retract_event); + assert.strictEqual(retract_event.target_account_id, recipient.account.id); + assert.strictEqual(retract_event.target_actor_id, recipient.actor.id); + }); + + test('actor-targeted decline still puts the grantor in both target columns', async () => { + const events: Array = []; + const test_app = await build_app_with_audit(events); + const recipient = await test_app.create_account({username: 'multi_actor_decline'}); + + const create_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: recipient.account.id, + to_actor_id: recipient.actor.id, + role: ROLE_ADMIN, + }, + headers: test_app.create_session_headers(), + }); + assert.ok(create_res.ok); + + events.length = 0; + const decline_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_decline_action_spec, + params: {offer_id: create_res.result.offer.id}, + headers: recipient.create_session_headers(), + }); + assert.ok(decline_res.ok); + + const decline_event = events.find((e) => e.event_type === 'permit_offer_decline'); + assert.ok(decline_event); + // Decline is *to* the offering actor — both target columns + // carry the grantor side, regardless of `to_actor_id` semantics. + assert.strictEqual(decline_event.target_account_id, test_app.backend.account.id); + assert.strictEqual(decline_event.target_actor_id, test_app.backend.actor.id); + }); + + test('expired actor-targeted offer carries the actor on the permit_offer_expire envelope', async () => { + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({username: 'multi_actor_expire'}); + + // Insert an already-past actor-targeted offer directly — the + // create helper rejects past `expires_at` indirectly through + // the inbox sweep semantics; bypass via raw insert is the + // existing pattern for expiry tests. + const rows = await get_db().query<{id: Uuid}>( + `INSERT INTO permit_offer (from_actor_id, to_account_id, to_actor_id, role, expires_at) + VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 minute') + RETURNING id`, + [test_app.backend.actor.id, recipient.account.id, recipient.actor.id, ROLE_ADMIN], + ); + const offer_id = rows[0]!.id; + + const captured: Array = []; + const count = await cleanup_expired_permit_offers({ + db: get_db(), + log: new Logger('test_expire', {level: 'off'}), + on_audit_event: (event) => { + captured.push(event); + }, + }); + assert.ok(count >= 1); + const expire_event = captured.find( + (e) => + e.event_type === 'permit_offer_expire' && + (e.metadata as {offer_id?: string}).offer_id === offer_id, + ); + assert.ok(expire_event); + assert.strictEqual(expire_event.target_account_id, recipient.account.id); + assert.strictEqual(expire_event.target_actor_id, recipient.actor.id); + }); + + test('supersede cascade inherits to_actor_id when the sibling was actor-targeted', async () => { + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({username: 'multi_actor_supersede'}); + const grantor_b = await test_app.create_account({ + username: 'multi_actor_supersede_b', + roles: [ROLE_ADMIN], + }); + + // Offer A — account-grain (no `to_actor_id`). + const offer_a_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: {to_account_id: recipient.account.id, role: ROLE_ADMIN}, + headers: test_app.create_session_headers(), + }); + assert.ok(offer_a_res.ok); + // Offer B — actor-targeted at the recipient's actor; from a + // different grantor so the partial unique index allows both + // to coexist. + const offer_b_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: { + to_account_id: recipient.account.id, + to_actor_id: recipient.actor.id, + role: ROLE_ADMIN, + }, + headers: grantor_b.create_session_headers(), + }); + assert.ok(offer_b_res.ok); + + // Accept A — supersedes B in-tx. Audit emission is in-tx, + // not via fire-and-forget; assert against the DB. + const accept_result = await get_db().transaction(async (tx) => + query_accept_offer( + {db: tx}, + { + offer_id: offer_a_res.result.offer.id, + to_account_id: recipient.account.id, + actor_id: recipient.actor.id, + ip: null, + }, + ), + ); + assert.strictEqual(accept_result.superseded_offers.length, 1); + const supersede_event = accept_result.audit_events.find( + (e) => e.event_type === 'permit_offer_supersede', + ); + assert.ok(supersede_event); + assert.strictEqual(supersede_event.target_account_id, recipient.account.id); + assert.strictEqual(supersede_event.target_actor_id, recipient.actor.id); + }); + }); +}); diff --git a/src/test/auth/permit_offer.multi_actor.dispatcher_400.db.test.ts b/src/test/auth/permit_offer.multi_actor.dispatcher_400.db.test.ts new file mode 100644 index 00000000..475d65c8 --- /dev/null +++ b/src/test/auth/permit_offer.multi_actor.dispatcher_400.db.test.ts @@ -0,0 +1,159 @@ +/** + * Multi-actor coverage — dispatcher-level 400 `actor_required`. + * + * Authenticated requests on a multi-actor account must hit the + * dispatcher's authorization phase and surface + * `400 actor_required` (with the available actor list) before the + * handler runs — never silently pick. Single-actor accounts must + * still resolve transparently. The third test asserts the full + * JSON-RPC envelope wrap (regression guard for the dispatcher fold). + * + * @module + */ + +import {assert, describe, test} from 'vitest'; + +import {create_test_app} from '$lib/testing/app_server.js'; +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import {permit_offer_list_action_spec} from '$lib/auth/permit_offer_action_specs.js'; +import {ERROR_ACTOR_REQUIRED} from '$lib/http/error_schemas.js'; +import {rpc_call_for_spec} from '$lib/testing/rpc_helpers.js'; + +import { + RPC_PATH, + create_route_specs, + describe_db, + session_options, +} from './permit_offer_test_helpers.js'; +import {create_multi_actor_helpers} from './permit_offer.multi_actor.fixtures.js'; + +describe_db('permit_offer.multi_actor — dispatcher_400', (get_db) => { + const {add_second_actor} = create_multi_actor_helpers(get_db); + + describe('dispatcher-level multi-actor 400', () => { + test('authenticated request with multi-actor account hits 400 actor_required envelope before the handler runs', async () => { + // The dispatcher's authorization phase enforces the multi-actor + // contract: when the account has 2+ actors and the request + // doesn't supply `acting`, surface 400 `actor_required` with + // the available actor list before handler dispatch — never + // silently pick. Single-actor accounts still resolve + // transparently via `resolve_acting_actor` (regression guard + // in the sibling test). `rpc_call_for_spec` rejects non- + // envelope bodies, so reaching the data assertions is itself + // the regression guard for the dispatcher's envelope wrap. + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({ + username: 'multi_actor_middleware_400', + }); + await add_second_actor(recipient.account.id, 'middleware_second'); + + // `permit_offer_list` is `side_effects: false` so it exercises + // the dispatcher's authorization-phase path without depending + // on handler logic. + const res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_list_action_spec, + params: {}, + headers: recipient.create_session_headers(), + }); + assert.ok(!res.ok); + assert.strictEqual(res.status, 400); + assert.strictEqual(res.error.message, ERROR_ACTOR_REQUIRED); + const data = res.error.data as + | {reason?: string; available?: Array<{id: string; name: string}>} + | undefined; + assert.strictEqual(data?.reason, ERROR_ACTOR_REQUIRED); + assert.ok(Array.isArray(data?.available)); + assert.strictEqual(data.available.length, 2); + const ids = new Set(data.available.map((a) => a.id)); + assert.ok(ids.has(recipient.actor.id)); + }); + + test('authenticated single-actor account passes middleware (no false positive)', async () => { + // Regression guard for the v1 1:1 default — middleware must + // transparently pick the unique actor and not 400. + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({ + username: 'multi_actor_single_passes', + }); + + const list_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_list_action_spec, + params: {}, + headers: recipient.create_session_headers(), + }); + assert.ok(list_res.ok); + }); + + test('actor_required body is wrapped in a full JSON-RPC envelope (regression for the dispatcher fold)', async () => { + // Hits the dispatcher with an `acting`-declaring method on a + // multi-actor account and asserts every envelope field by + // hand. Pre-fold the response body was the plain + // `{error, available}` shape `apply_authorization_phase` + // produces — this test is the regression guard ensuring the + // dispatcher's wrap continues to populate `jsonrpc`, `id`, + // `error.code`, `error.message`, `error.data.reason`, and + // `error.data.available`. + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({ + username: 'multi_actor_envelope_regression', + }); + await add_second_actor(recipient.account.id, 'envelope_second'); + + const post_init = { + method: 'POST' as const, + headers: { + ...recipient.create_session_headers(), + host: 'localhost', + origin: 'http://localhost:5173', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'envelope_regression_id', + method: permit_offer_list_action_spec.method, + params: {}, + }), + }; + const res = await test_app.app.request(RPC_PATH, post_init); + assert.strictEqual(res.status, 400); + const body = (await res.json()) as { + jsonrpc?: string; + id?: string; + error?: { + code?: number; + message?: string; + data?: {reason?: string; available?: Array<{id: string; name: string}>}; + }; + }; + assert.strictEqual(body.jsonrpc, '2.0'); + assert.strictEqual(body.id, 'envelope_regression_id'); + // 400 maps to `invalid_params` (-32602) via http_status_to_jsonrpc_error_code. + assert.strictEqual(body.error?.code, -32602); + assert.strictEqual(body.error?.message, ERROR_ACTOR_REQUIRED); + assert.strictEqual(body.error?.data?.reason, ERROR_ACTOR_REQUIRED); + assert.ok(Array.isArray(body.error?.data?.available)); + assert.strictEqual(body.error.data.available.length, 2); + const ids = new Set(body.error.data.available.map((a) => a.id)); + assert.ok(ids.has(recipient.actor.id)); + }); + }); +}); diff --git a/src/test/auth/permit_offer.multi_actor.fixtures.ts b/src/test/auth/permit_offer.multi_actor.fixtures.ts new file mode 100644 index 00000000..e9b0ae12 --- /dev/null +++ b/src/test/auth/permit_offer.multi_actor.fixtures.ts @@ -0,0 +1,58 @@ +/** + * Shared scaffolding for the `permit_offer.multi_actor.*.db.test.ts` + * sibling suites. + * + * Returns a `{build_app_with_audit, add_second_actor}` pair bound to the + * caller's `describe_db` `get_db` callback. The original monolithic + * `permit_offer.multi_actor.db.test.ts` declared both as outer-scope + * closures over `get_db`; lifting them here lets each per-aspect sibling + * file pull the same scaffolding without re-declaring the closures. + * + * Not itself a test file — no `.test.` infix means vitest does not pick + * it up. Mirrors `./permit_offer_test_helpers.ts` for the rest of the + * permit-offer integration scaffolding. + * + * @module + */ + +import {create_test_app} from '$lib/testing/app_server.js'; +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import {query_create_actor} from '$lib/auth/account_queries.js'; +import type {AuditLogEvent} from '$lib/auth/audit_log_schema.js'; +import type {Db} from '$lib/db/db.js'; +import type {Uuid} from '@fuzdev/fuz_util/id.js'; + +import {create_route_specs, session_options} from './permit_offer_test_helpers.js'; + +/** + * Build the multi-actor scaffolding bound to a `describe_db` callback's + * `get_db`. Returns the two closures every per-aspect sibling needs: + * + * - `build_app_with_audit(events)` — `create_test_app` with an + * `on_audit_event` that pushes into the supplied array. + * - `add_second_actor(account_id, name)` — `query_create_actor` that + * returns just the new actor's id (the only field the call sites use). + * + * @param get_db - the `describe_db` callback's `() => Db` accessor + */ +export const create_multi_actor_helpers = ( + get_db: () => Db, +): { + build_app_with_audit: (events: Array) => ReturnType; + add_second_actor: (account_id: Uuid, name: string) => Promise; +} => ({ + build_app_with_audit: (events) => + create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + on_audit_event: (event) => { + events.push(event); + }, + }), + add_second_actor: async (account_id, name) => { + const actor = await query_create_actor({db: get_db()}, account_id, name); + return actor.id; + }, +}); diff --git a/src/test/auth/permit_offer.multi_actor.race_mismatch.db.test.ts b/src/test/auth/permit_offer.multi_actor.race_mismatch.db.test.ts new file mode 100644 index 00000000..75f62fd7 --- /dev/null +++ b/src/test/auth/permit_offer.multi_actor.race_mismatch.db.test.ts @@ -0,0 +1,149 @@ +/** + * Multi-actor coverage — account-grain accept race-loser actor mismatch. + * + * Two actors on the same recipient account both attempt to accept the + * same account-grain offer. The race winner binds the permit to actor + * A; the loser must hit `PermitOfferAlreadyTerminalError` rather than + * silently receive actor A's permit. Same-actor retry on an already- + * accepted offer must continue to return the existing permit + * idempotently. + * + * @module + */ + +import {assert, describe, test} from 'vitest'; +import {assert_rejects} from '@fuzdev/fuz_util/testing.js'; + +import {create_test_app} from '$lib/testing/app_server.js'; +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import {permit_offer_create_action_spec} from '$lib/auth/permit_offer_action_specs.js'; +import { + query_accept_offer, + PermitOfferAlreadyTerminalError, +} from '$lib/auth/permit_offer_queries.js'; +import {rpc_call_for_spec} from '$lib/testing/rpc_helpers.js'; + +import { + RPC_PATH, + create_route_specs, + describe_db, + session_options, +} from './permit_offer_test_helpers.js'; +import {create_multi_actor_helpers} from './permit_offer.multi_actor.fixtures.js'; + +describe_db('permit_offer.multi_actor — race_mismatch', (get_db) => { + const {add_second_actor} = create_multi_actor_helpers(get_db); + + describe('account-grain accept race-loser actor mismatch', () => { + test("losing actor on the same account gets PermitOfferAlreadyTerminalError, not someone else's permit", async () => { + // Two actors on the recipient account both attempt to accept + // the same account-grain offer. The race winner binds the + // permit to actor_A; the loser must not silently receive + // "you got the permit" with actor_A's permit row attached. + // Under v1 1:1 this branch is unreachable; under multi-actor + // it's the difference between truthful "offer is terminal" + // and misleading "permit obtained" UI. + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({username: 'race_loser_recipient'}); + const second_actor_id = await add_second_actor(recipient.account.id, 'race_loser_b'); + + const create_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: {to_account_id: recipient.account.id, role: ROLE_ADMIN}, + headers: test_app.create_session_headers(), + }); + assert.ok(create_res.ok); + + // Actor A wins the race. + const winner = await get_db().transaction(async (tx) => + query_accept_offer( + {db: tx}, + { + offer_id: create_res.result.offer.id, + to_account_id: recipient.account.id, + actor_id: recipient.actor.id, + ip: null, + }, + ), + ); + assert.strictEqual(winner.permit.actor_id, recipient.actor.id); + + // Actor B (the loser) tries to accept the same offer. The + // offer is now accepted; the locked.accepted_at branch fires. + // permit.actor_id !== actor_id → terminal error. + const err = await assert_rejects(() => + get_db().transaction(async (tx) => + query_accept_offer( + {db: tx}, + { + offer_id: create_res.result.offer.id, + to_account_id: recipient.account.id, + actor_id: second_actor_id, + ip: null, + }, + ), + ), + ); + assert.ok( + err instanceof PermitOfferAlreadyTerminalError, + `expected PermitOfferAlreadyTerminalError, got ${err.constructor.name}: ${err.message}`, + ); + }); + + test('same-actor retry on accepted offer still returns idempotent permit (no spurious terminal)', async () => { + // Retry path — same actor attempts twice, second call observes + // the already-accepted offer and returns the existing permit. + // Must not be broken by the loser-mismatch guard: actor matches. + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const recipient = await test_app.create_account({username: 'race_idempotent_retry'}); + + const create_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_offer_create_action_spec, + params: {to_account_id: recipient.account.id, role: ROLE_ADMIN}, + headers: test_app.create_session_headers(), + }); + assert.ok(create_res.ok); + + const first = await get_db().transaction(async (tx) => + query_accept_offer( + {db: tx}, + { + offer_id: create_res.result.offer.id, + to_account_id: recipient.account.id, + actor_id: recipient.actor.id, + ip: null, + }, + ), + ); + const second = await get_db().transaction(async (tx) => + query_accept_offer( + {db: tx}, + { + offer_id: create_res.result.offer.id, + to_account_id: recipient.account.id, + actor_id: recipient.actor.id, + ip: null, + }, + ), + ); + assert.strictEqual(first.created, true); + assert.strictEqual(second.created, false); + assert.strictEqual(second.permit.id, first.permit.id); + assert.strictEqual(second.permit.actor_id, recipient.actor.id); + }); + }); +}); diff --git a/src/test/auth/permit_offer.multi_actor.revoke_envelope.db.test.ts b/src/test/auth/permit_offer.multi_actor.revoke_envelope.db.test.ts new file mode 100644 index 00000000..e3d1b71d --- /dev/null +++ b/src/test/auth/permit_offer.multi_actor.revoke_envelope.db.test.ts @@ -0,0 +1,62 @@ +/** + * Multi-actor coverage — `permit_revoke` envelope on multi-actor accounts. + * + * The revoke audit event must name the actor the permit was actually + * granted to, not whichever actor the index returns first when scanning + * by `account_id`. Sibling actors on the recipient account exist but + * hold no permits. + * + * @module + */ + +import {assert, describe, test} from 'vitest'; + +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import {permit_revoke_action_spec} from '$lib/auth/permit_offer_action_specs.js'; +import {query_grant_permit} from '$lib/auth/permit_queries.js'; +import type {AuditLogEvent} from '$lib/auth/audit_log_schema.js'; +import {rpc_call_for_spec} from '$lib/testing/rpc_helpers.js'; + +import {RPC_PATH, describe_db} from './permit_offer_test_helpers.js'; +import {create_multi_actor_helpers} from './permit_offer.multi_actor.fixtures.js'; + +describe_db('permit_offer.multi_actor — revoke_envelope', (get_db) => { + const {build_app_with_audit, add_second_actor} = create_multi_actor_helpers(get_db); + + describe('permit_revoke envelope on multi-actor accounts', () => { + test('target_actor_id names the granted actor, not whichever the index returns first', async () => { + const events: Array = []; + const test_app = await build_app_with_audit(events); + const recipient = await test_app.create_account({username: 'multi_actor_revoke'}); + // Insert a second actor on the recipient before granting the + // permit to the first one. A naive `first_actor_by_account` + // lookup would now race between the two; the revoke audit + // must still name the actually-bound actor. + await add_second_actor(recipient.account.id, 'unbound_sibling'); + + const permit = await query_grant_permit( + {db: get_db()}, + { + actor_id: recipient.actor.id, + role: ROLE_ADMIN, + granted_by: test_app.backend.actor.id, + }, + ); + + events.length = 0; + const revoke_res = await rpc_call_for_spec({ + app: test_app.app, + path: RPC_PATH, + spec: permit_revoke_action_spec, + params: {actor_id: recipient.actor.id, permit_id: permit.id}, + headers: test_app.create_session_headers(), + }); + assert.ok(revoke_res.ok); + + const revoke_event = events.find((e) => e.event_type === 'permit_revoke'); + assert.ok(revoke_event); + assert.strictEqual(revoke_event.target_account_id, recipient.account.id); + assert.strictEqual(revoke_event.target_actor_id, recipient.actor.id); + }); + }); +}); diff --git a/src/test/auth/permit_offer.multi_actor.scope_revoke.db.test.ts b/src/test/auth/permit_offer.multi_actor.scope_revoke.db.test.ts new file mode 100644 index 00000000..b987ebb4 --- /dev/null +++ b/src/test/auth/permit_offer.multi_actor.scope_revoke.db.test.ts @@ -0,0 +1,62 @@ +/** + * Multi-actor coverage — `query_permit_revoke_for_scope` returns one + * row per revoked permit with the right actor + account on each. + * + * Scope-cascade revocation produces an audit event per revoked permit, + * keyed by `(actor_id, account_id)`. This test seeds two accounts with + * a permit each on the same scope and asserts the cascade returns both + * rows with the correct identity columns. + * + * @module + */ + +import {assert, describe, test} from 'vitest'; +import type {Uuid} from '@fuzdev/fuz_util/id.js'; + +import {create_test_app} from '$lib/testing/app_server.js'; +import {ROLE_ADMIN} from '$lib/auth/role_schema.js'; +import {query_permit_revoke_for_scope, query_grant_permit} from '$lib/auth/permit_queries.js'; + +import {create_route_specs, describe_db, session_options} from './permit_offer_test_helpers.js'; + +describe_db('permit_offer.multi_actor — scope_revoke', (get_db) => { + describe('query_permit_revoke_for_scope returns actor + account per revoked permit', () => { + test('cascade returns one entry per revoked permit with correct actor + account', async () => { + const test_app = await create_test_app({ + session_options, + create_route_specs, + db: get_db(), + roles: [ROLE_ADMIN], + }); + const a = await test_app.create_account({username: 'scope_revoke_a'}); + const b = await test_app.create_account({username: 'scope_revoke_b'}); + + const scope: Uuid = '11111111-1111-4111-8111-111111111111' as Uuid; + + await query_grant_permit( + {db: get_db()}, + {actor_id: a.actor.id, role: 'classroom_student', scope_id: scope, granted_by: null}, + ); + await query_grant_permit( + {db: get_db()}, + {actor_id: b.actor.id, role: 'classroom_student', scope_id: scope, granted_by: null}, + ); + + const result = await get_db().transaction(async (tx) => + query_permit_revoke_for_scope({db: tx}, scope, null, 'scope_destroyed'), + ); + + assert.strictEqual(result.revoked.length, 2); + const by_actor = new Map(); + for (const row of result.revoked) by_actor.set(row.actor_id, row); + const a_row = by_actor.get(a.actor.id); + const b_row = by_actor.get(b.actor.id); + assert.ok(a_row); + assert.ok(b_row); + assert.strictEqual(a_row.account_id, a.account.id); + assert.strictEqual(b_row.account_id, b.account.id); + assert.strictEqual(a_row.scope_id, scope); + assert.strictEqual(b_row.role, 'classroom_student'); + }); + }); +}); diff --git a/src/test/auth/permit_offer_actions.audit.db.test.ts b/src/test/auth/permit_offer_actions.audit.db.test.ts index bf76d832..b35b657a 100644 --- a/src/test/auth/permit_offer_actions.audit.db.test.ts +++ b/src/test/auth/permit_offer_actions.audit.db.test.ts @@ -71,6 +71,12 @@ describe_db('permit_offer_actions.audit', (get_db) => { (match.metadata as {to_account_id?: string}).to_account_id, recipient.account.id, ); + // `permit_offer_create` is account-grain — the offer routes to + // the account inbox; any actor on the account may accept. + // `target_account_id` is set; `target_actor_id` stays null + // (per audit_log_schema rule). + assert.strictEqual(match.target_account_id, recipient.account.id); + assert.strictEqual(match.target_actor_id, null); }); test('web_grantable=false emits failure-outcome create event', async () => { @@ -218,6 +224,13 @@ describe_db('permit_offer_actions.audit', (get_db) => { assert.ok(match, 'expected permit_offer_decline event'); assert.strictEqual((match.metadata as {offer_id?: string}).offer_id, offer_id); assert.strictEqual((match.metadata as {reason?: string}).reason, 'nah'); + // `permit_offer_decline` carries the original grantor in BOTH + // target columns — `target_actor_id` is the grantor actor and + // `target_account_id` is the grantor's account (joined into + // the decline RETURNING). The "both populated → same account" + // invariant holds (grantor's actor↔account binding is 1:1). + assert.strictEqual(match.target_actor_id, test_app.backend.actor.id); + assert.strictEqual(match.target_account_id, test_app.backend.account.id); }); test('retract emits permit_offer_retract', async () => { @@ -244,6 +257,11 @@ describe_db('permit_offer_actions.audit', (get_db) => { const match = events.find((e) => e.event_type === 'permit_offer_retract'); assert.ok(match, 'expected permit_offer_retract event'); assert.strictEqual((match.metadata as {offer_id?: string}).offer_id, offer_id); + // `permit_offer_retract` carries the recipient account as + // `target_account_id` per the audit_log_schema rule. + // `target_actor_id` stays null (no actor binding yet). + assert.strictEqual(match.target_account_id, recipient.account.id); + assert.strictEqual(match.target_actor_id, null); }); }); }); diff --git a/src/test/auth/permit_offer_actions.error_reasons.test.ts b/src/test/auth/permit_offer_actions.error_reasons.test.ts index 9b9e690b..c08ea13e 100644 --- a/src/test/auth/permit_offer_actions.error_reasons.test.ts +++ b/src/test/auth/permit_offer_actions.error_reasons.test.ts @@ -35,8 +35,12 @@ for (const [name, value] of Object.entries({...permit_offer_specs, ...error_sche if (name.startsWith('ERROR_') && typeof value === 'string') error_value_by_name[name] = value; } +// Match either `rpc_action(...)` or `rpc_actor_action(...)` — the binder +// kind is irrelevant to the source-scan; both pair the same spec/handler +// shape. Permit-offer handlers all use the actor-narrowing variant after +// the audit-actor branch's `rpc_actor_action` migration. const spec_to_handler: ReadonlyArray = [ - ...handler_source.matchAll(/rpc_action\((\w+_action_spec),\s*(\w+_handler)\)/g), + ...handler_source.matchAll(/\brpc_(?:actor_)?action\((\w+_action_spec),\s*(\w+_handler)\)/g), ].map((m) => [m[1]!, m[2]!] as const); const get_handler_body = (handler_name: string): string => { diff --git a/src/test/auth/permit_offer_notifications.test.ts b/src/test/auth/permit_offer_notifications.test.ts index 283dbbc1..ff1a7557 100644 --- a/src/test/auth/permit_offer_notifications.test.ts +++ b/src/test/auth/permit_offer_notifications.test.ts @@ -41,6 +41,7 @@ const fake_offer = (): PermitOfferJson => { id: create_uuid(), from_actor_id: create_uuid(), to_account_id: create_uuid(), + to_actor_id: null, role: 'admin' as PermitOfferJson['role'], scope_id: null, message: 'hi', diff --git a/src/test/auth/permit_offer_queries.accept.db.test.ts b/src/test/auth/permit_offer_queries.accept.db.test.ts new file mode 100644 index 00000000..6e78a517 --- /dev/null +++ b/src/test/auth/permit_offer_queries.accept.db.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for `permit_offer_queries.ts` — `query_accept_offer` core paths. + * + * Covers the happy-path accept (permit insert + audit fan-out + has_role), + * idempotent re-accept on race, the `already_terminal` rejection on + * declined / retracted offers, and the recipient-mismatch IDOR guard + * (404-over-403 with zero column mutation). Supersede semantics on accept + * live in `permit_offer_queries.supersede.db.test.ts`. + * + * @module + */ + +import {assert, test} from 'vitest'; +import {assert_rejects} from '@fuzdev/fuz_util/testing.js'; + +import { + query_permit_offer_decline, + query_permit_offer_find_pending, + query_accept_offer, + PermitOfferAlreadyTerminalError, + PermitOfferNotFoundError, +} from '$lib/auth/permit_offer_queries.js'; +import {query_permit_has_role} from '$lib/auth/permit_queries.js'; + +import {describe_db} from '../db_fixture.js'; +import {make_account, create_pending_offer} from './permit_offer_queries.fixtures.js'; + +describe_db('permit_offer_queries.accept', (get_db) => { + test('accept inserts permit + stamps resulting_permit_id + emits audit events', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_accept'); + const recipient = await make_account(db, 'recipient_accept'); + + const offer = await create_pending_offer(db, grantor, recipient); + + const result = await query_accept_offer(deps, { + offer_id: offer.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }); + + assert.strictEqual(result.created, true); + assert.strictEqual(result.permit.actor_id, recipient.actor_id); + assert.strictEqual(result.permit.role, 'teacher'); + assert.strictEqual(result.permit.source_offer_id, offer.id); + assert.strictEqual(result.offer.resulting_permit_id, result.permit.id); + assert.ok(result.offer.accepted_at); + assert.strictEqual(result.audit_events.length, 2); + // Order is part of the contract: accept binds the actor first, then + // the permit grant references the resulting permit. Pin index, not + // just multiset. + const [accept_event, grant_event] = result.audit_events; + assert.ok(accept_event); + assert.ok(grant_event); + assert.strictEqual(accept_event.event_type, 'permit_offer_accept'); + assert.strictEqual(grant_event.event_type, 'permit_grant'); + // Both target columns populated on accept (the in-tx pair) — see + // `auth/CLAUDE.md` audit_log_schema rule. + assert.strictEqual(accept_event.target_account_id, recipient.account_id); + assert.strictEqual(accept_event.target_actor_id, recipient.actor_id); + assert.strictEqual(grant_event.target_account_id, recipient.account_id); + assert.strictEqual(grant_event.target_actor_id, recipient.actor_id); + const grant_metadata = grant_event.metadata as { + source_offer_id?: string; + permit_id?: string; + role?: string; + }; + assert.strictEqual(grant_metadata.source_offer_id, offer.id); + assert.strictEqual(grant_metadata.permit_id, result.permit.id); + assert.strictEqual(grant_metadata.role, 'teacher'); + + // permit is active via has_role check. + assert.strictEqual(await query_permit_has_role(deps, recipient.actor_id, 'teacher'), true); + }); + + test('accept is idempotent on race — second call returns already-created permit', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_race'); + const recipient = await make_account(db, 'recipient_race'); + + const offer = await create_pending_offer(db, grantor, recipient); + + const first = await query_accept_offer(deps, { + offer_id: offer.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }); + // Second call simulates the losing side of a race — the offer is now + // accepted and has a resulting_permit_id; the helper should return that + // permit rather than throwing. + const second = await query_accept_offer(deps, { + offer_id: offer.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }); + assert.strictEqual(first.created, true); + assert.strictEqual(second.created, false); + assert.strictEqual(second.permit.id, first.permit.id); + assert.strictEqual(second.audit_events.length, 0); + // Doc: "empty on the race-loser path". Pin so a refactor that + // re-emits the supersede side on retry surfaces here. + assert.strictEqual(second.superseded_offers.length, 0); + }); + + test('accept throws already_terminal for declined / retracted offers', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_terminal'); + const recipient = await make_account(db, 'recipient_terminal'); + + const declined = await create_pending_offer(db, grantor, recipient); + await query_permit_offer_decline(deps, declined.id, recipient.account_id, null); + + const err = await assert_rejects(() => + query_accept_offer(deps, { + offer_id: declined.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }), + ); + assert.ok(err instanceof PermitOfferAlreadyTerminalError); + }); + + test('accept rejects when to_account_id does not match the offer', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_idor_accept'); + const recipient = await make_account(db, 'recipient_idor_accept'); + const attacker = await make_account(db, 'attacker_idor_accept'); + + const offer = await create_pending_offer(db, grantor, recipient); + + const err = await assert_rejects(() => + query_accept_offer(deps, { + offer_id: offer.id, + to_account_id: attacker.account_id, + actor_id: attacker.actor_id, + }), + ); + assert.ok(err instanceof PermitOfferNotFoundError); + // offer is still pending — the wrong-recipient call must not accept it. + const still_pending = await query_permit_offer_find_pending(deps, offer.id); + assert.ok(still_pending); + + // Defense-in-depth for the 404-over-403 contract: zero columns mutated. + const rows = await db.query<{ + accepted_at: string | null; + declined_at: string | null; + retracted_at: string | null; + superseded_at: string | null; + resulting_permit_id: string | null; + }>( + `SELECT accepted_at, declined_at, retracted_at, superseded_at, resulting_permit_id + FROM permit_offer WHERE id = $1`, + [offer.id], + ); + const r = rows[0]!; + assert.strictEqual(r.accepted_at, null); + assert.strictEqual(r.declined_at, null); + assert.strictEqual(r.retracted_at, null); + assert.strictEqual(r.superseded_at, null); + assert.strictEqual(r.resulting_permit_id, null); + }); + + test('accept rejects when actor_id does not belong to to_account_id (defense-in-depth)', async () => { + // `query_accept_offer` re-checks the actor↔account binding with a SELECT + // after the FOR UPDATE lock and the actor-grain gate. The path is + // reachable only via direct calls (the dispatcher resolves acting actor + // upstream), but the source comment explicitly promises the invariant + // "for all callers including tests and future direct consumers" — so + // pin the contract here. + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_actor_check'); + const recipient = await make_account(db, 'recipient_actor_check'); + const stranger = await make_account(db, 'stranger_actor_check'); + + const offer = await create_pending_offer(db, grantor, recipient); + + const err = await assert_rejects(() => + query_accept_offer(deps, { + offer_id: offer.id, + to_account_id: recipient.account_id, + actor_id: stranger.actor_id, + }), + ); + // Plain Error (no dedicated subclass — direct callers are expected to + // be rare). Match on the documented message shape so a refactor to a + // subclass surfaces here rather than silently passing. + assert.ok( + err.message.includes(`does not belong to account ${recipient.account_id}`), + `unexpected message: ${err.message}`, + ); + assert.ok(err.message.includes(stranger.actor_id)); + assert.ok(err.message.includes(offer.id)); + + // Offer must remain pending and untouched. + const still_pending = await query_permit_offer_find_pending(deps, offer.id); + assert.ok(still_pending); + assert.strictEqual(still_pending.accepted_at, null); + assert.strictEqual(still_pending.resulting_permit_id, null); + }); +}); diff --git a/src/test/auth/permit_offer_queries.concurrent.db.test.ts b/src/test/auth/permit_offer_queries.concurrent.db.test.ts index dbdda02d..2798bf58 100644 --- a/src/test/auth/permit_offer_queries.concurrent.db.test.ts +++ b/src/test/auth/permit_offer_queries.concurrent.db.test.ts @@ -12,46 +12,21 @@ import {assert, test} from 'vitest'; -import {query_create_account_with_actor} from '$lib/auth/account_queries.js'; -import {query_permit_offer_create, query_accept_offer} from '$lib/auth/permit_offer_queries.js'; +import {query_accept_offer} from '$lib/auth/permit_offer_queries.js'; import {create_describe_db, AUTH_INTEGRATION_TRUNCATE_TABLES} from '$lib/testing/db.js'; -import type {Uuid} from '@fuzdev/fuz_util/id.js'; -import type {Db} from '$lib/db/db.js'; import {pg_factory} from '../db_fixture.js'; - -interface TestAccount { - account_id: Uuid; - actor_id: Uuid; -} - -const make_account = async (db: Db, username: string): Promise => { - const deps = {db}; - const {account, actor} = await query_create_account_with_actor(deps, { - username, - password_hash: 'hash', - }); - return {account_id: account.id, actor_id: actor.id}; -}; - -const future = (ms_from_now: number): Date => new Date(Date.now() + ms_from_now); -const hour = 60 * 60 * 1000; +import {make_account, create_pending_offer} from './permit_offer_queries.fixtures.js'; const describe_pg = create_describe_db(pg_factory, AUTH_INTEGRATION_TRUNCATE_TABLES); -describe_pg('PermitOfferQueries concurrent accept', (get_db) => { +describe_pg('permit_offer_queries.concurrent', (get_db) => { test('two concurrent accepts serialize — one inserts, one returns existing', async () => { const db = get_db(); - const deps = {db}; const grantor = await make_account(db, 'grantor_concurrent'); const recipient = await make_account(db, 'recipient_concurrent'); - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); + const offer = await create_pending_offer(db, grantor, recipient); // Launch both transactions before awaiting either — each `db.transaction` // acquires a dedicated pool client, so the two run on separate connections. @@ -59,10 +34,16 @@ describe_pg('PermitOfferQueries concurrent accept', (get_db) => { // blocks until commit, then reads the already-accepted state. const [first, second] = await Promise.all([ db.transaction((tx) => - query_accept_offer({db: tx}, {offer_id: offer.id, to_account_id: recipient.account_id}), + query_accept_offer( + {db: tx}, + {offer_id: offer.id, to_account_id: recipient.account_id, actor_id: recipient.actor_id}, + ), ), db.transaction((tx) => - query_accept_offer({db: tx}, {offer_id: offer.id, to_account_id: recipient.account_id}), + query_accept_offer( + {db: tx}, + {offer_id: offer.id, to_account_id: recipient.account_id, actor_id: recipient.actor_id}, + ), ), ]); @@ -74,7 +55,17 @@ describe_pg('PermitOfferQueries concurrent accept', (get_db) => { // Loser emitted no audit events; winner emitted two. const winner = first.created ? first : second; const loser = first.created ? second : first; + assert.strictEqual(winner.created, true); + assert.strictEqual(loser.created, false); assert.strictEqual(winner.audit_events.length, 2); assert.strictEqual(loser.audit_events.length, 0); + // Loser must observe the same actor on the permit it returns — + // same-actor idempotent path through `locked.accepted_at` (the + // multi-actor mismatch guard at `permit_offer_queries.ts:501-503` + // would throw if it bound to a different actor). + assert.strictEqual(winner.permit.actor_id, recipient.actor_id); + assert.strictEqual(loser.permit.actor_id, recipient.actor_id); + // Loser path must not double-emit supersede side effects. + assert.strictEqual(loser.superseded_offers.length, 0); }); }); diff --git a/src/test/auth/permit_offer_queries.create.db.test.ts b/src/test/auth/permit_offer_queries.create.db.test.ts new file mode 100644 index 00000000..4f77b4db --- /dev/null +++ b/src/test/auth/permit_offer_queries.create.db.test.ts @@ -0,0 +1,174 @@ +/** + * Tests for `permit_offer_queries.ts` — offer creation paths. + * + * Covers the create insert, same-(to_account, role, scope) re-offer upsert, + * scope-distinguished offers under the partial unique index, and the + * self-target rejection. + * + * @module + */ + +import {assert, test} from 'vitest'; +import {assert_rejects} from '@fuzdev/fuz_util/testing.js'; + +import { + query_permit_offer_create, + PermitOfferSelfTargetError, +} from '$lib/auth/permit_offer_queries.js'; +import {create_uuid} from '@fuzdev/fuz_util/id.js'; + +import {describe_db} from '../db_fixture.js'; +import {make_account, future, hour} from './permit_offer_queries.fixtures.js'; + +describe_db('permit_offer_queries.create', (get_db) => { + test('create inserts a pending offer', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_create'); + const recipient = await make_account(db, 'recipient_create'); + const offer = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + role: 'teacher', + message: 'welcome', + expires_at: future(hour), + }); + assert.match(offer.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + assert.strictEqual(offer.role, 'teacher'); + assert.strictEqual(offer.from_actor_id, grantor.actor_id); + assert.strictEqual(offer.to_account_id, recipient.account_id); + assert.strictEqual(offer.scope_id, null); + assert.strictEqual(offer.message, 'welcome'); + assert.strictEqual(offer.accepted_at, null); + assert.strictEqual(offer.declined_at, null); + assert.strictEqual(offer.retracted_at, null); + assert.strictEqual(offer.resulting_permit_id, null); + }); + + test('re-offer while pending upserts the same row (refreshes message + expires_at)', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_upsert'); + const recipient = await make_account(db, 'recipient_upsert'); + const first = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + role: 'teacher', + message: 'first', + expires_at: future(hour), + }); + const later_expiry = future(hour * 2); + const second = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + role: 'teacher', + message: 'second', + expires_at: later_expiry, + }); + assert.strictEqual(second.id, first.id); + assert.strictEqual(second.message, 'second'); + // Compare timestamps numerically — `Date >` works on Date objects but + // not consistently on bare ISO strings depending on driver shape. + assert.ok(new Date(second.expires_at).getTime() > new Date(first.expires_at).getTime()); + // still a single row in the table for this recipient/role. + const rows = await db.query<{c: number}>( + `SELECT COUNT(*)::int AS c FROM permit_offer WHERE to_account_id = $1 AND role = $2`, + [recipient.account_id, 'teacher'], + ); + assert.strictEqual(rows[0]!.c, 1); + }); + + test('different scope produces a distinct offer (partial unique index covers scope)', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_scope'); + const recipient = await make_account(db, 'recipient_scope'); + const classroom_a = create_uuid(); + const classroom_b = create_uuid(); + const offer_a = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + role: 'classroom_student', + scope_id: classroom_a, + expires_at: future(hour), + }); + const offer_b = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + role: 'classroom_student', + scope_id: classroom_b, + expires_at: future(hour), + }); + assert.notStrictEqual(offer_a.id, offer_b.id); + }); + + test('self-offer rejected (from_actor belongs to to_account)', async () => { + const db = get_db(); + const deps = {db}; + const self = await make_account(db, 'self_offer'); + const err = await assert_rejects(() => + query_permit_offer_create(deps, { + from_actor_id: self.actor_id, + to_account_id: self.account_id, + role: 'teacher', + expires_at: future(hour), + }), + ); + assert.ok(err instanceof PermitOfferSelfTargetError); + }); + + test('re-offer narrows account-grain row to a specific actor (to_actor_id null → set)', async () => { + // Source contract: "supplying a different `to_actor_id` on re-offer + // narrows the existing row to the named actor". Pins the + // `EXCLUDED.to_actor_id` upsert behavior — without it the second call + // would silently ignore the actor binding while still claiming the + // re-offer landed. + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_narrow'); + const recipient = await make_account(db, 'recipient_narrow'); + const account_grain = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + role: 'teacher', + expires_at: future(hour), + }); + assert.strictEqual(account_grain.to_actor_id, null); + const narrowed = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + to_actor_id: recipient.actor_id, + role: 'teacher', + expires_at: future(hour), + }); + assert.strictEqual(narrowed.id, account_grain.id); + assert.strictEqual(narrowed.to_actor_id, recipient.actor_id); + }); + + test('re-offer widens actor-grain row back to account-grain (to_actor_id set → null)', async () => { + // Companion to the narrow case: re-offer with `to_actor_id: null` + // must reset the column. Closes the asymmetric path where an + // actor-grain offer accidentally stays bound after a wider re-offer. + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_widen'); + const recipient = await make_account(db, 'recipient_widen'); + const actor_grain = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + to_actor_id: recipient.actor_id, + role: 'teacher', + expires_at: future(hour), + }); + assert.strictEqual(actor_grain.to_actor_id, recipient.actor_id); + const widened = await query_permit_offer_create(deps, { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + to_actor_id: null, + role: 'teacher', + expires_at: future(hour), + }); + assert.strictEqual(widened.id, actor_grain.id); + assert.strictEqual(widened.to_actor_id, null); + }); +}); diff --git a/src/test/auth/permit_offer_queries.db.test.ts b/src/test/auth/permit_offer_queries.db.test.ts deleted file mode 100644 index 37263223..00000000 --- a/src/test/auth/permit_offer_queries.db.test.ts +++ /dev/null @@ -1,1037 +0,0 @@ -/** - * Tests for permit_offer_queries.ts — offer lifecycle queries + atomic accept. - * - * @module - */ - -import {assert, test} from 'vitest'; -import {assert_rejects} from '@fuzdev/fuz_util/testing.js'; - -import {query_create_account_with_actor} from '$lib/auth/account_queries.js'; -import { - query_permit_offer_create, - query_permit_offer_decline, - query_permit_offer_retract, - query_permit_offer_list, - query_permit_offer_find_pending, - query_permit_offer_history_for_account, - query_permit_offer_sweep_expired, - query_accept_offer, - PermitOfferAlreadyTerminalError, - PermitOfferExpiredError, - PermitOfferNotFoundError, - PermitOfferSelfTargetError, -} from '$lib/auth/permit_offer_queries.js'; -import { - query_permit_has_role, - query_grant_permit, - query_revoke_permit, -} from '$lib/auth/permit_queries.js'; -import {query_audit_log, query_audit_log_list_for_account} from '$lib/auth/audit_log_queries.js'; -import {create_uuid, type Uuid} from '@fuzdev/fuz_util/id.js'; -import type {Db} from '$lib/db/db.js'; - -import {describe_db} from '../db_fixture.js'; - -interface TestAccount { - account_id: Uuid; - actor_id: Uuid; -} - -const make_account = async (db: Db, username: string): Promise => { - const deps = {db}; - const {account, actor} = await query_create_account_with_actor(deps, { - username, - password_hash: 'hash', - }); - return {account_id: account.id, actor_id: actor.id}; -}; - -const future = (ms_from_now: number): Date => new Date(Date.now() + ms_from_now); -const hour = 60 * 60 * 1000; - -interface CreatePendingOfferOptions { - role?: string; - scope_id?: Uuid | null; - message?: string | null; - expires_at?: Date; -} - -/** Test helper — create a pending offer with sensible defaults. */ -const create_pending_offer = ( - db: Db, - grantor: TestAccount, - recipient: TestAccount, - options: CreatePendingOfferOptions = {}, -) => - query_permit_offer_create( - {db}, - { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: options.role ?? 'teacher', - scope_id: options.scope_id ?? null, - message: options.message ?? null, - expires_at: options.expires_at ?? future(hour), - }, - ); - -describe_db('PermitOfferQueries', (get_db) => { - test('create inserts a pending offer', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_create'); - const recipient = await make_account(db, 'recipient_create'); - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - message: 'welcome', - expires_at: future(hour), - }); - assert.ok(offer.id); - assert.strictEqual(offer.role, 'teacher'); - assert.strictEqual(offer.from_actor_id, grantor.actor_id); - assert.strictEqual(offer.to_account_id, recipient.account_id); - assert.strictEqual(offer.scope_id, null); - assert.strictEqual(offer.message, 'welcome'); - assert.strictEqual(offer.accepted_at, null); - assert.strictEqual(offer.declined_at, null); - assert.strictEqual(offer.retracted_at, null); - assert.strictEqual(offer.resulting_permit_id, null); - }); - - test('re-offer while pending upserts the same row (refreshes message + expires_at)', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_upsert'); - const recipient = await make_account(db, 'recipient_upsert'); - const first = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - message: 'first', - expires_at: future(hour), - }); - const later_expiry = future(hour * 2); - const second = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - message: 'second', - expires_at: later_expiry, - }); - assert.strictEqual(second.id, first.id); - assert.strictEqual(second.message, 'second'); - assert.ok(new Date(second.expires_at) > new Date(first.expires_at)); - // still a single row in the table for this recipient/role. - const rows = await db.query<{c: number}>( - `SELECT COUNT(*)::int AS c FROM permit_offer WHERE to_account_id = $1 AND role = $2`, - [recipient.account_id, 'teacher'], - ); - assert.strictEqual(rows[0]!.c, 1); - }); - - test('different scope produces a distinct offer (partial unique index covers scope)', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_scope'); - const recipient = await make_account(db, 'recipient_scope'); - const classroom_a = create_uuid(); - const classroom_b = create_uuid(); - const offer_a = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom_a, - expires_at: future(hour), - }); - const offer_b = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom_b, - expires_at: future(hour), - }); - assert.notStrictEqual(offer_a.id, offer_b.id); - }); - - test('self-offer rejected (from_actor belongs to to_account)', async () => { - const db = get_db(); - const deps = {db}; - const self = await make_account(db, 'self_offer'); - const err = await assert_rejects(() => - query_permit_offer_create(deps, { - from_actor_id: self.actor_id, - to_account_id: self.account_id, - role: 'teacher', - expires_at: future(hour), - }), - ); - assert.ok(err instanceof PermitOfferSelfTargetError); - }); - - test('decline marks offer terminal', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_decline'); - const recipient = await make_account(db, 'recipient_decline'); - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - const declined = await query_permit_offer_decline( - deps, - offer.id, - recipient.account_id, - 'no thanks', - ); - assert.ok(declined); - assert.ok(declined.declined_at); - assert.strictEqual(declined.decline_reason, 'no thanks'); - }); - - test('decline on terminal offer throws already_terminal', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_decline_terminal'); - const recipient = await make_account(db, 'recipient_decline_terminal'); - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - await query_permit_offer_decline(deps, offer.id, recipient.account_id, null); - const err = await assert_rejects(() => - query_permit_offer_decline(deps, offer.id, recipient.account_id, null), - ); - assert.ok(err instanceof PermitOfferAlreadyTerminalError); - }); - - test('decline with wrong recipient returns null (IDOR guard)', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_idor'); - const recipient = await make_account(db, 'recipient_idor'); - const attacker = await make_account(db, 'attacker_idor'); - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - const result = await query_permit_offer_decline(deps, offer.id, attacker.account_id, null); - assert.strictEqual(result, null); - }); - - test('retract marks offer terminal; retract on terminal throws', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_retract'); - const recipient = await make_account(db, 'recipient_retract'); - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - const retracted = await query_permit_offer_retract(deps, offer.id, grantor.actor_id); - assert.ok(retracted); - assert.ok(retracted.retracted_at); - const err = await assert_rejects(() => - query_permit_offer_retract(deps, offer.id, grantor.actor_id), - ); - assert.ok(err instanceof PermitOfferAlreadyTerminalError); - }); - - test('retract with wrong grantor returns null', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'retract_guard_grantor'); - const other = await make_account(db, 'retract_guard_other'); - const recipient = await make_account(db, 'retract_guard_recipient'); - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - const result = await query_permit_offer_retract(deps, offer.id, other.actor_id); - assert.strictEqual(result, null); - }); - - test('list filters out terminal and expired offers', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_list'); - const recipient = await make_account(db, 'recipient_list'); - - // pending, in-window — should appear - const pending = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - - // declined — terminal, should not appear - const declinable = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: create_uuid(), - expires_at: future(hour), - }); - await query_permit_offer_decline(deps, declinable.id, recipient.account_id, null); - - // expired — pending but past expires_at, should not appear - await db.query( - `INSERT INTO permit_offer (from_actor_id, to_account_id, role, scope_id, expires_at) - VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour')`, - [grantor.actor_id, recipient.account_id, 'classroom_student', create_uuid()], - ); - - const list = await query_permit_offer_list(deps, recipient.account_id); - assert.strictEqual(list.length, 1); - assert.strictEqual(list[0]!.id, pending.id); - }); - - test('find_pending returns null for expired or terminal offers', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_find'); - const recipient = await make_account(db, 'recipient_find'); - - const pending = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - assert.ok(await query_permit_offer_find_pending(deps, pending.id)); - - await query_permit_offer_decline(deps, pending.id, recipient.account_id, null); - assert.strictEqual(await query_permit_offer_find_pending(deps, pending.id), null); - }); - - test('sweep returns only expired pending offers', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_sweep'); - const recipient = await make_account(db, 'recipient_sweep'); - - const fresh = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - - const expired_rows = await db.query<{id: string}>( - `INSERT INTO permit_offer (from_actor_id, to_account_id, role, scope_id, expires_at) - VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour') - RETURNING id`, - [grantor.actor_id, recipient.account_id, 'classroom_student', create_uuid()], - ); - const expired_id = expired_rows[0]!.id; - - const swept = await query_permit_offer_sweep_expired(deps); - const swept_ids = swept.map((o) => o.id); - assert.include(swept_ids, expired_id); - assert.notInclude(swept_ids, fresh.id); - }); - - // -- query_accept_offer ----------------------------------------------------- - - test('accept inserts permit + stamps resulting_permit_id + emits audit events', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_accept'); - const recipient = await make_account(db, 'recipient_accept'); - - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - - const result = await query_accept_offer(deps, { - offer_id: offer.id, - to_account_id: recipient.account_id, - }); - - assert.strictEqual(result.created, true); - assert.strictEqual(result.permit.actor_id, recipient.actor_id); - assert.strictEqual(result.permit.role, 'teacher'); - assert.strictEqual(result.permit.source_offer_id, offer.id); - assert.strictEqual(result.offer.resulting_permit_id, result.permit.id); - assert.ok(result.offer.accepted_at); - assert.strictEqual(result.audit_events.length, 2); - const event_types = result.audit_events.map((e) => e.event_type).sort(); - assert.deepStrictEqual(event_types, ['permit_grant', 'permit_offer_accept']); - const permit_grant_event = result.audit_events.find((e) => e.event_type === 'permit_grant'); - assert.ok(permit_grant_event); - assert.strictEqual( - (permit_grant_event.metadata as {source_offer_id?: string}).source_offer_id, - offer.id, - ); - - // permit is active via has_role check. - assert.strictEqual(await query_permit_has_role(deps, recipient.actor_id, 'teacher'), true); - }); - - test('accept is idempotent on race — second call returns already-created permit', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_race'); - const recipient = await make_account(db, 'recipient_race'); - - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - - const first = await query_accept_offer(deps, { - offer_id: offer.id, - to_account_id: recipient.account_id, - }); - // Second call simulates the losing side of a race — the offer is now - // accepted and has a resulting_permit_id; the helper should return that - // permit rather than throwing. - const second = await query_accept_offer(deps, { - offer_id: offer.id, - to_account_id: recipient.account_id, - }); - assert.strictEqual(first.created, true); - assert.strictEqual(second.created, false); - assert.strictEqual(second.permit.id, first.permit.id); - assert.strictEqual(second.audit_events.length, 0); - }); - - test('accept throws already_terminal for declined / retracted offers', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_terminal'); - const recipient = await make_account(db, 'recipient_terminal'); - - const declined = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - await query_permit_offer_decline(deps, declined.id, recipient.account_id, null); - - const err = await assert_rejects(() => - query_accept_offer(deps, { - offer_id: declined.id, - to_account_id: recipient.account_id, - }), - ); - assert.ok(err instanceof PermitOfferAlreadyTerminalError); - }); - - test('accept rejects when to_account_id does not match the offer', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_idor_accept'); - const recipient = await make_account(db, 'recipient_idor_accept'); - const attacker = await make_account(db, 'attacker_idor_accept'); - - const offer = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - - const err = await assert_rejects(() => - query_accept_offer(deps, { - offer_id: offer.id, - to_account_id: attacker.account_id, - }), - ); - assert.ok(err instanceof PermitOfferNotFoundError); - // offer is still pending — the wrong-recipient call must not accept it. - const still_pending = await query_permit_offer_find_pending(deps, offer.id); - assert.ok(still_pending); - - // Defense-in-depth for the 404-over-403 contract: zero columns mutated. - const rows = await db.query<{ - accepted_at: string | null; - declined_at: string | null; - retracted_at: string | null; - superseded_at: string | null; - resulting_permit_id: string | null; - }>( - `SELECT accepted_at, declined_at, retracted_at, superseded_at, resulting_permit_id - FROM permit_offer WHERE id = $1`, - [offer.id], - ); - const r = rows[0]!; - assert.strictEqual(r.accepted_at, null); - assert.strictEqual(r.declined_at, null); - assert.strictEqual(r.retracted_at, null); - assert.strictEqual(r.superseded_at, null); - assert.strictEqual(r.resulting_permit_id, null); - }); - - // -- scoped permit grant semantics ----------------------------------------- - - test('query_grant_permit: global permit (scope_id NULL) idempotent on sentinel', async () => { - const db = get_db(); - const deps = {db}; - const grantee = await make_account(db, 'global_grantee'); - const first = await query_grant_permit(deps, { - actor_id: grantee.actor_id, - role: 'admin', - granted_by: null, - }); - const second = await query_grant_permit(deps, { - actor_id: grantee.actor_id, - role: 'admin', - granted_by: null, - }); - assert.strictEqual(first.id, second.id); - }); - - test('query_grant_permit: different scopes produce distinct permits', async () => { - const db = get_db(); - const deps = {db}; - const grantee = await make_account(db, 'scoped_grantee'); - const classroom_a = create_uuid(); - const classroom_b = create_uuid(); - const a = await query_grant_permit(deps, { - actor_id: grantee.actor_id, - role: 'classroom_student', - scope_id: classroom_a, - granted_by: null, - }); - const b = await query_grant_permit(deps, { - actor_id: grantee.actor_id, - role: 'classroom_student', - scope_id: classroom_b, - granted_by: null, - }); - assert.notStrictEqual(a.id, b.id); - assert.strictEqual(a.scope_id, classroom_a); - assert.strictEqual(b.scope_id, classroom_b); - }); - - test('query_grant_permit: same scope is idempotent', async () => { - const db = get_db(); - const deps = {db}; - const grantee = await make_account(db, 'idem_scope_grantee'); - const classroom = create_uuid(); - const a = await query_grant_permit(deps, { - actor_id: grantee.actor_id, - role: 'classroom_student', - scope_id: classroom, - granted_by: null, - }); - const b = await query_grant_permit(deps, { - actor_id: grantee.actor_id, - role: 'classroom_student', - scope_id: classroom, - granted_by: null, - }); - assert.strictEqual(a.id, b.id); - }); - - test('query_permit_has_role: scope_id distinguishes grants', async () => { - const db = get_db(); - const deps = {db}; - const grantee = await make_account(db, 'scope_check_grantee'); - const classroom_a = create_uuid(); - const classroom_b = create_uuid(); - await query_grant_permit(deps, { - actor_id: grantee.actor_id, - role: 'classroom_student', - scope_id: classroom_a, - granted_by: null, - }); - assert.strictEqual( - await query_permit_has_role(deps, grantee.actor_id, 'classroom_student', classroom_a), - true, - ); - assert.strictEqual( - await query_permit_has_role(deps, grantee.actor_id, 'classroom_student', classroom_b), - false, - ); - // No scope_id argument means "global (NULL) scope" — the scoped grant must not match. - assert.strictEqual( - await query_permit_has_role(deps, grantee.actor_id, 'classroom_student'), - false, - ); - }); - - test('query_permit_has_role: global grant does not match a scoped check', async () => { - const db = get_db(); - const deps = {db}; - const grantee = await make_account(db, 'global_vs_scoped'); - await query_grant_permit(deps, { - actor_id: grantee.actor_id, - role: 'admin', - granted_by: null, - }); - assert.strictEqual(await query_permit_has_role(deps, grantee.actor_id, 'admin'), true); - assert.strictEqual( - await query_permit_has_role(deps, grantee.actor_id, 'admin', create_uuid()), - false, - ); - }); - - // -- coexistence, superseding, and distinct errors ------------------------- - - test('two grantors produce distinct pending offers for same (to_account, role, scope)', async () => { - const db = get_db(); - const deps = {db}; - const grantor_a = await make_account(db, 'coexist_a'); - const grantor_b = await make_account(db, 'coexist_b'); - const recipient = await make_account(db, 'coexist_recipient'); - const classroom = create_uuid(); - - const offer_a = await query_permit_offer_create(deps, { - from_actor_id: grantor_a.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom, - message: 'from A', - expires_at: future(hour), - }); - const offer_b = await query_permit_offer_create(deps, { - from_actor_id: grantor_b.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom, - message: 'from B', - expires_at: future(hour), - }); - assert.notStrictEqual(offer_a.id, offer_b.id); - assert.strictEqual(offer_a.from_actor_id, grantor_a.actor_id); - assert.strictEqual(offer_b.from_actor_id, grantor_b.actor_id); - const list = await query_permit_offer_list(deps, recipient.account_id); - assert.strictEqual(list.length, 2); - }); - - test('same-grantor re-offer still upserts the pending row', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'reoffer_same_a'); - const recipient = await make_account(db, 'reoffer_same_recipient'); - const classroom = create_uuid(); - - const first = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom, - message: 'first', - expires_at: future(hour), - }); - const second = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom, - message: 'second', - expires_at: future(hour * 2), - }); - assert.strictEqual(second.id, first.id); - assert.strictEqual(second.message, 'second'); - }); - - test('accept on expired pending offer throws PermitOfferExpiredError', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'grantor_expired_accept'); - const recipient = await make_account(db, 'recipient_expired_accept'); - - // Insert an already-past offer directly; the create helper would - // reject on CHECK constraint checks around expiry vs created_at ordering - // if we tried to build it via query_permit_offer_create. - const rows = await db.query<{id: Uuid}>( - `INSERT INTO permit_offer (from_actor_id, to_account_id, role, expires_at) - VALUES ($1, $2, 'teacher', NOW() - INTERVAL '1 minute') - RETURNING id`, - [grantor.actor_id, recipient.account_id], - ); - const expired_offer_id = rows[0]!.id; - - const err = await assert_rejects(() => - query_accept_offer(deps, { - offer_id: expired_offer_id, - to_account_id: recipient.account_id, - }), - ); - assert.ok(err instanceof PermitOfferExpiredError); - - // Row must be untouched — the throw happens before any state mutation. - const check_rows = await db.query<{ - accepted_at: string | null; - resulting_permit_id: string | null; - superseded_at: string | null; - declined_at: string | null; - retracted_at: string | null; - }>( - `SELECT accepted_at, resulting_permit_id, superseded_at, declined_at, retracted_at - FROM permit_offer WHERE id = $1`, - [expired_offer_id], - ); - const r = check_rows[0]!; - assert.strictEqual(r.accepted_at, null); - assert.strictEqual(r.resulting_permit_id, null); - assert.strictEqual(r.superseded_at, null); - assert.strictEqual(r.declined_at, null); - assert.strictEqual(r.retracted_at, null); - }); - - test('accept supersedes sibling pending offers and emits audit events', async () => { - const db = get_db(); - const deps = {db}; - const grantor_a = await make_account(db, 'sibling_a'); - const grantor_b = await make_account(db, 'sibling_b'); - const grantor_c = await make_account(db, 'sibling_c'); - const recipient = await make_account(db, 'sibling_recipient'); - const classroom = create_uuid(); - - const offer_a = await query_permit_offer_create(deps, { - from_actor_id: grantor_a.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom, - expires_at: future(hour), - }); - const offer_b = await query_permit_offer_create(deps, { - from_actor_id: grantor_b.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom, - expires_at: future(hour), - }); - const offer_c = await query_permit_offer_create(deps, { - from_actor_id: grantor_c.actor_id, - to_account_id: recipient.account_id, - role: 'classroom_student', - scope_id: classroom, - expires_at: future(hour), - }); - - const result = await query_accept_offer(deps, { - offer_id: offer_a.id, - to_account_id: recipient.account_id, - }); - - assert.strictEqual(result.superseded_offers.length, 2); - const superseded_ids = result.superseded_offers.map((o) => o.id).sort(); - assert.deepStrictEqual(superseded_ids, [offer_b.id, offer_c.id].sort()); - for (const sibling of result.superseded_offers) { - assert.ok(sibling.superseded_at); - } - - // from_account_id is populated via CTE join on `actor` — each sibling's - // entry must carry its own grantor account, never a cross-contamination. - // Direct guard so a broken join fails here before any notification test. - const grantor_b_account = await db.query<{account_id: string}>( - `SELECT account_id FROM actor WHERE id = $1`, - [grantor_b.actor_id], - ); - const grantor_c_account = await db.query<{account_id: string}>( - `SELECT account_id FROM actor WHERE id = $1`, - [grantor_c.actor_id], - ); - const expected_accounts: Record = { - [offer_b.id]: grantor_b_account[0]!.account_id, - [offer_c.id]: grantor_c_account[0]!.account_id, - }; - for (const sibling of result.superseded_offers) { - assert.strictEqual(sibling.from_account_id, expected_accounts[sibling.id]); - } - - // On-disk: exactly one terminal column set per superseded sibling. - // Locks in single-terminal invariant (permit_offer_single_terminal CHECK). - const sibling_rows = await db.query<{ - accepted_at: string | null; - declined_at: string | null; - retracted_at: string | null; - superseded_at: string | null; - }>( - `SELECT accepted_at, declined_at, retracted_at, superseded_at - FROM permit_offer WHERE id = ANY($1)`, - [[offer_b.id, offer_c.id]], - ); - assert.strictEqual(sibling_rows.length, 2); - for (const row of sibling_rows) { - assert.ok(row.superseded_at); - assert.strictEqual(row.accepted_at, null); - assert.strictEqual(row.declined_at, null); - assert.strictEqual(row.retracted_at, null); - } - - // audit events: permit_offer_accept + permit_grant + 2x permit_offer_supersede - const event_types = result.audit_events.map((e) => e.event_type).sort(); - assert.deepStrictEqual(event_types, [ - 'permit_grant', - 'permit_offer_accept', - 'permit_offer_supersede', - 'permit_offer_supersede', - ]); - for (const e of result.audit_events.filter((e) => e.event_type === 'permit_offer_supersede')) { - const md = e.metadata as {reason?: string; cause_id?: string}; - assert.strictEqual(md.reason, 'sibling_accepted'); - assert.strictEqual(md.cause_id, offer_a.id); - } - - // list is now empty for the recipient — all three offers terminal. - const list = await query_permit_offer_list(deps, recipient.account_id); - assert.strictEqual(list.length, 0); - - // attempting to accept a superseded sibling throws already-terminal. - const err = await assert_rejects(() => - query_accept_offer(deps, { - offer_id: offer_b.id, - to_account_id: recipient.account_id, - }), - ); - assert.ok(err instanceof PermitOfferAlreadyTerminalError); - }); - - test('history_for_account returns offers in both directions', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'history_grantor'); - const recipient = await make_account(db, 'history_recipient'); - const outsider = await make_account(db, 'history_outsider'); - - const outgoing = await query_permit_offer_create(deps, { - from_actor_id: grantor.actor_id, - to_account_id: recipient.account_id, - role: 'teacher', - expires_at: future(hour), - }); - const incoming = await query_permit_offer_create(deps, { - from_actor_id: outsider.actor_id, - to_account_id: grantor.account_id, - role: 'teacher', - expires_at: future(hour), - }); - - const for_grantor = await query_permit_offer_history_for_account(deps, grantor.account_id); - const ids = for_grantor.map((o) => o.id).sort(); - assert.deepStrictEqual(ids, [outgoing.id, incoming.id].sort()); - }); - - // -- decline/retract with multi-grantor coexistence ------------------------ - - test('decline on A does not affect B from a different grantor', async () => { - const db = get_db(); - const deps = {db}; - const grantor_a = await make_account(db, 'decline_coexist_a'); - const grantor_b = await make_account(db, 'decline_coexist_b'); - const recipient = await make_account(db, 'decline_coexist_recipient'); - const classroom = create_uuid(); - - const offer_a = await create_pending_offer(db, grantor_a, recipient, { - role: 'classroom_student', - scope_id: classroom, - }); - const offer_b = await create_pending_offer(db, grantor_b, recipient, { - role: 'classroom_student', - scope_id: classroom, - }); - - const declined = await query_permit_offer_decline(deps, offer_a.id, recipient.account_id, null); - assert.ok(declined?.declined_at); - - // B is still pending. - const still = await query_permit_offer_find_pending(deps, offer_b.id); - assert.ok(still); - assert.strictEqual(still.accepted_at, null); - assert.strictEqual(still.declined_at, null); - assert.strictEqual(still.retracted_at, null); - assert.strictEqual(still.superseded_at, null); - }); - - test('retract on A does not affect B from a different grantor', async () => { - const db = get_db(); - const deps = {db}; - const grantor_a = await make_account(db, 'retract_coexist_a'); - const grantor_b = await make_account(db, 'retract_coexist_b'); - const recipient = await make_account(db, 'retract_coexist_recipient'); - const classroom = create_uuid(); - - const offer_a = await create_pending_offer(db, grantor_a, recipient, { - role: 'classroom_student', - scope_id: classroom, - }); - const offer_b = await create_pending_offer(db, grantor_b, recipient, { - role: 'classroom_student', - scope_id: classroom, - }); - - const retracted = await query_permit_offer_retract(deps, offer_a.id, grantor_a.actor_id); - assert.ok(retracted?.retracted_at); - - const still = await query_permit_offer_find_pending(deps, offer_b.id); - assert.ok(still); - assert.strictEqual(still.retracted_at, null); - }); - - test('decline and retract on a superseded offer both throw already_terminal', async () => { - const db = get_db(); - const deps = {db}; - const grantor_a = await make_account(db, 'superseded_grantor_a'); - const grantor_b = await make_account(db, 'superseded_grantor_b'); - const recipient = await make_account(db, 'superseded_recipient'); - const classroom = create_uuid(); - - const offer_a = await create_pending_offer(db, grantor_a, recipient, { - role: 'classroom_student', - scope_id: classroom, - }); - const offer_b = await create_pending_offer(db, grantor_b, recipient, { - role: 'classroom_student', - scope_id: classroom, - }); - - // Accept A → B becomes superseded. - const result = await query_accept_offer(deps, { - offer_id: offer_a.id, - to_account_id: recipient.account_id, - }); - assert.strictEqual(result.superseded_offers.length, 1); - assert.strictEqual(result.superseded_offers[0]!.id, offer_b.id); - - // Decline on B must throw already_terminal — exercises the superseded_at - // branch in resolve_terminal_or_missing. - const decline_err = await assert_rejects(() => - query_permit_offer_decline(deps, offer_b.id, recipient.account_id, null), - ); - assert.ok(decline_err instanceof PermitOfferAlreadyTerminalError); - - // Retract on B by the original grantor — also terminal. - const retract_err = await assert_rejects(() => - query_permit_offer_retract(deps, offer_b.id, grantor_b.actor_id), - ); - assert.ok(retract_err instanceof PermitOfferAlreadyTerminalError); - }); - - test('sweep_expired does not return expired superseded offers', async () => { - const db = get_db(); - const deps = {db}; - const grantor = await make_account(db, 'sweep_superseded_grantor'); - const recipient = await make_account(db, 'sweep_superseded_recipient'); - - // Defense-in-depth: an expired pending offer that is *also* superseded - // must not appear in the sweep (the sweep must gate on non-terminal). - const rows = await db.query<{id: string}>( - `INSERT INTO permit_offer (from_actor_id, to_account_id, role, expires_at, superseded_at) - VALUES ($1, $2, 'teacher', NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes') - RETURNING id`, - [grantor.actor_id, recipient.account_id], - ); - const superseded_expired_id = rows[0]!.id; - - const swept = await query_permit_offer_sweep_expired(deps); - const ids = swept.map((o) => o.id); - assert.notInclude(ids, superseded_expired_id); - }); - - // -- end-to-end revoke-bypass regression ----------------------------------- - - test('revoke-bypass regression: accept A → revoke → cannot accept superseded B', async () => { - const db = get_db(); - const deps = {db}; - const grantor_a = await make_account(db, 'bypass_grantor_a'); - const grantor_b = await make_account(db, 'bypass_grantor_b'); - const recipient = await make_account(db, 'bypass_recipient'); - const classroom = create_uuid(); - - const offer_a = await create_pending_offer(db, grantor_a, recipient, { - role: 'classroom_student', - scope_id: classroom, - }); - const offer_b = await create_pending_offer(db, grantor_b, recipient, { - role: 'classroom_student', - scope_id: classroom, - }); - - // Recipient accepts A — B is superseded in the same transaction. - const accept = await query_accept_offer(deps, { - offer_id: offer_a.id, - to_account_id: recipient.account_id, - }); - assert.strictEqual(accept.superseded_offers.length, 1); - assert.strictEqual(accept.superseded_offers[0]!.id, offer_b.id); - - // Admin revokes the resulting permit. - const revoke = await query_revoke_permit( - deps, - accept.permit.id, - recipient.actor_id, - null, - 'ended', - ); - assert.ok(revoke); - assert.strictEqual(revoke.id, accept.permit.id); - - // Emit the permit_revoke audit event like the route layer does, so the - // audit chain is inspectable. - await query_audit_log(deps, { - event_type: 'permit_revoke', - actor_id: recipient.actor_id, - account_id: recipient.account_id, - metadata: { - role: revoke.role, - permit_id: revoke.id, - scope_id: revoke.scope_id, - reason: 'ended', - }, - }); - - // Attempting to accept the stale B offer must throw already_terminal — - // closed by the accept-time sibling supersede. - const err = await assert_rejects(() => - query_accept_offer(deps, { - offer_id: offer_b.id, - to_account_id: recipient.account_id, - }), - ); - assert.ok(err instanceof PermitOfferAlreadyTerminalError); - - // Audit chain: permit_offer_accept(A) → permit_grant(source_offer_id=A) - // → permit_offer_supersede(B, reason sibling_accepted, cause_id=A) - // → permit_revoke. - const events = await query_audit_log_list_for_account(deps, recipient.account_id); - const by_type = new Map(); - for (const e of events) { - const list = by_type.get(e.event_type) ?? []; - list.push(e); - by_type.set(e.event_type, list); - } - assert.strictEqual(by_type.get('permit_offer_accept')?.length, 1); - const permit_grants = by_type.get('permit_grant') ?? []; - assert.strictEqual(permit_grants.length, 1); - assert.strictEqual( - (permit_grants[0]!.metadata as {source_offer_id?: string}).source_offer_id, - offer_a.id, - ); - const supersedes = by_type.get('permit_offer_supersede') ?? []; - assert.strictEqual(supersedes.length, 1); - const supersede_md = supersedes[0]!.metadata as { - reason?: string; - cause_id?: string; - offer_id?: string; - }; - assert.strictEqual(supersede_md.reason, 'sibling_accepted'); - assert.strictEqual(supersede_md.cause_id, offer_a.id); - assert.strictEqual(supersede_md.offer_id, offer_b.id); - assert.strictEqual(by_type.get('permit_revoke')?.length, 1); - }); -}); diff --git a/src/test/auth/permit_offer_queries.decline.db.test.ts b/src/test/auth/permit_offer_queries.decline.db.test.ts new file mode 100644 index 00000000..254db5d0 --- /dev/null +++ b/src/test/auth/permit_offer_queries.decline.db.test.ts @@ -0,0 +1,68 @@ +/** + * Tests for `permit_offer_queries.ts` — decline lifecycle. + * + * Covers the happy-path decline, the already-terminal rejection, and the + * IDOR guard (wrong recipient → null, no row mutation). + * + * @module + */ + +import {assert, test} from 'vitest'; +import {assert_rejects} from '@fuzdev/fuz_util/testing.js'; + +import { + query_permit_offer_decline, + PermitOfferAlreadyTerminalError, +} from '$lib/auth/permit_offer_queries.js'; + +import {describe_db} from '../db_fixture.js'; +import {make_account, create_pending_offer} from './permit_offer_queries.fixtures.js'; + +describe_db('permit_offer_queries.decline', (get_db) => { + test('decline marks offer terminal and joins grantor account_id', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_decline'); + const recipient = await make_account(db, 'recipient_decline'); + const offer = await create_pending_offer(db, grantor, recipient); + const declined = await query_permit_offer_decline( + deps, + offer.id, + recipient.account_id, + 'no thanks', + ); + assert.ok(declined); + assert.ok(declined.declined_at); + assert.ok(new Date(declined.declined_at).getTime() > 0); + assert.strictEqual(declined.decline_reason, 'no thanks'); + // `DeclinedOffer.from_account_id` is the CTE join contract — the + // audit envelope's `target_account_id` and the post-commit + // `permit_offer_declined` notification both depend on it. Pin so a + // refactor that drops the join surfaces here. + assert.strictEqual(declined.from_account_id, grantor.account_id); + }); + + test('decline on terminal offer throws already_terminal', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_decline_terminal'); + const recipient = await make_account(db, 'recipient_decline_terminal'); + const offer = await create_pending_offer(db, grantor, recipient); + await query_permit_offer_decline(deps, offer.id, recipient.account_id, null); + const err = await assert_rejects(() => + query_permit_offer_decline(deps, offer.id, recipient.account_id, null), + ); + assert.ok(err instanceof PermitOfferAlreadyTerminalError); + }); + + test('decline with wrong recipient returns null (IDOR guard)', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_idor'); + const recipient = await make_account(db, 'recipient_idor'); + const attacker = await make_account(db, 'attacker_idor'); + const offer = await create_pending_offer(db, grantor, recipient); + const result = await query_permit_offer_decline(deps, offer.id, attacker.account_id, null); + assert.strictEqual(result, null); + }); +}); diff --git a/src/test/auth/permit_offer_queries.fixtures.ts b/src/test/auth/permit_offer_queries.fixtures.ts new file mode 100644 index 00000000..180cdb6b --- /dev/null +++ b/src/test/auth/permit_offer_queries.fixtures.ts @@ -0,0 +1,108 @@ +/** + * Shared scaffolding for the `permit_offer_queries..db.test.ts` + * sibling suites. + * + * Lifts the per-test-file boilerplate out of the per-aspect files: + * `make_account` / `future` / `hour` / `TestAccount` for the account + * setup boilerplate, plus `create_pending_offer` for the + * default-pending-offer shape used by `supersede` and `concurrent`. + * Mirrors the `permit_offer.multi_actor.fixtures.ts` pattern on the + * actions side. + * + * Not itself a test file — no `.test.` infix means vitest does not pick + * it up. + * + * @module + */ + +import {query_create_account_with_actor} from '$lib/auth/account_queries.js'; +import {query_permit_offer_create} from '$lib/auth/permit_offer_queries.js'; +import type {PermitOffer} from '$lib/auth/permit_offer_schema.js'; +import type {Db} from '$lib/db/db.js'; +import type {Uuid} from '@fuzdev/fuz_util/id.js'; + +export interface TestAccount { + account_id: Uuid; + actor_id: Uuid; +} + +export const make_account = async (db: Db, username: string): Promise => { + const deps = {db}; + const {account, actor} = await query_create_account_with_actor(deps, { + username, + password_hash: 'hash', + }); + return {account_id: account.id, actor_id: actor.id}; +}; + +export const future = (ms_from_now: number): Date => new Date(Date.now() + ms_from_now); +export const hour = 60 * 60 * 1000; + +export interface CreatePendingOfferOptions { + role?: string; + scope_id?: Uuid | null; + message?: string | null; + expires_at?: Date; +} + +/** Test helper — create a pending offer with sensible defaults. */ +export const create_pending_offer = ( + db: Db, + grantor: TestAccount, + recipient: TestAccount, + options: CreatePendingOfferOptions = {}, +): Promise => + query_permit_offer_create( + {db}, + { + from_actor_id: grantor.actor_id, + to_account_id: recipient.account_id, + role: options.role ?? 'teacher', + scope_id: options.scope_id ?? null, + message: options.message ?? null, + expires_at: options.expires_at ?? future(hour), + }, + ); + +export interface InsertSupersededOfferOptions { + role?: string; + scope_id?: Uuid | null; + /** Defaults to `future(hour)` — set to a past Date to also expire the row. */ + expires_at?: Date; + /** When the offer was superseded — defaults to "1 minute ago". */ + superseded_at?: Date; +} + +/** + * Test helper — raw INSERT a superseded `permit_offer` row. + * + * No public API sets `superseded_at` directly (callers go through accept or + * permit revoke). Tests for the list/find_pending/sweep predicates need + * already-superseded rows in isolation, so this helper is the documented + * raw-SQL escape hatch. + * + * @returns the inserted row's id + */ +export const insert_superseded_offer = async ( + db: Db, + grantor: TestAccount, + recipient: TestAccount, + options: InsertSupersededOfferOptions = {}, +): Promise => { + const expires_at = options.expires_at ?? future(hour); + const superseded_at = options.superseded_at ?? new Date(Date.now() - 60_000); + const rows = await db.query<{id: Uuid}>( + `INSERT INTO permit_offer (from_actor_id, to_account_id, role, scope_id, expires_at, superseded_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [ + grantor.actor_id, + recipient.account_id, + options.role ?? 'classroom_student', + options.scope_id ?? null, + expires_at.toISOString(), + superseded_at.toISOString(), + ], + ); + return rows[0]!.id; +}; diff --git a/src/test/auth/permit_offer_queries.list.db.test.ts b/src/test/auth/permit_offer_queries.list.db.test.ts new file mode 100644 index 00000000..5a9b5a57 --- /dev/null +++ b/src/test/auth/permit_offer_queries.list.db.test.ts @@ -0,0 +1,234 @@ +/** + * Tests for `permit_offer_queries.ts` — list / find_pending / sweep readers. + * + * Covers the active-offer filter on `query_permit_offer_list`, the + * `find_pending` lookup short-circuit on terminal/expired offers, and the + * expired-only `sweep_expired` reader. The supersede × sweep interaction + * lives in `permit_offer_queries.supersede.db.test.ts`. + * + * @module + */ + +import {assert, test} from 'vitest'; + +import { + query_accept_offer, + query_permit_offer_decline, + query_permit_offer_retract, + query_permit_offer_list, + query_permit_offer_find_pending, + query_permit_offer_sweep_expired, +} from '$lib/auth/permit_offer_queries.js'; +import {create_uuid} from '@fuzdev/fuz_util/id.js'; + +import {describe_db} from '../db_fixture.js'; +import { + make_account, + create_pending_offer, + insert_superseded_offer, + future, + hour, +} from './permit_offer_queries.fixtures.js'; + +describe_db('permit_offer_queries.list', (get_db) => { + test('list filters out every terminal state plus expired-pending', async () => { + // Each terminal column (accepted_at / declined_at / retracted_at / + // superseded_at) is an independent gate in the WHERE clause; the + // expired-pending case is a separate `expires_at > NOW()` gate. Cover + // each path so a refactor that drops one of the IS NULL checks fails + // here rather than leaking terminal rows into a recipient's inbox. + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_list'); + const recipient = await make_account(db, 'recipient_list'); + + // pending, in-window — should appear + const pending = await create_pending_offer(db, grantor, recipient); + + // accepted — terminal + const acceptable = await create_pending_offer(db, grantor, recipient, { + role: 'admin', + scope_id: create_uuid(), + }); + await db.transaction((tx) => + query_accept_offer( + {db: tx}, + { + offer_id: acceptable.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }, + ), + ); + + // declined — terminal + const declinable = await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: create_uuid(), + }); + await query_permit_offer_decline(deps, declinable.id, recipient.account_id, null); + + // retracted — terminal + const retractable = await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: create_uuid(), + }); + await query_permit_offer_retract(deps, retractable.id, grantor.actor_id); + + // superseded — terminal (no public API sets `superseded_at` outside the + // accept / revoke supersede CTEs, so the fixture raw-INSERTs). + await insert_superseded_offer(db, grantor, recipient, {scope_id: create_uuid()}); + + // expired-pending + await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: create_uuid(), + expires_at: future(-hour), + }); + + const list = await query_permit_offer_list(deps, recipient.account_id); + assert.strictEqual(list.length, 1); + assert.strictEqual(list[0]!.id, pending.id); + }); + + test('find_pending returns null for every terminal state plus expired-pending', async () => { + // Same exhaustive coverage as the list test. find_pending shares the + // same predicate structure — a missing IS NULL would silently break + // the supersede revoke-bypass forecloser. + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_find'); + const recipient = await make_account(db, 'recipient_find'); + + // pending baseline + const pending = await create_pending_offer(db, grantor, recipient); + assert.ok(await query_permit_offer_find_pending(deps, pending.id)); + + // accepted + const acceptable = await create_pending_offer(db, grantor, recipient, { + role: 'admin', + scope_id: create_uuid(), + }); + await db.transaction((tx) => + query_accept_offer( + {db: tx}, + { + offer_id: acceptable.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }, + ), + ); + assert.strictEqual(await query_permit_offer_find_pending(deps, acceptable.id), null); + + // declined + const declinable = await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: create_uuid(), + }); + await query_permit_offer_decline(deps, declinable.id, recipient.account_id, null); + assert.strictEqual(await query_permit_offer_find_pending(deps, declinable.id), null); + + // retracted + const retractable = await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: create_uuid(), + }); + await query_permit_offer_retract(deps, retractable.id, grantor.actor_id); + assert.strictEqual(await query_permit_offer_find_pending(deps, retractable.id), null); + + // superseded + const superseded_id = await insert_superseded_offer(db, grantor, recipient, { + scope_id: create_uuid(), + }); + assert.strictEqual(await query_permit_offer_find_pending(deps, superseded_id), null); + + // expired-pending + const expired = await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: create_uuid(), + expires_at: future(-hour), + }); + assert.strictEqual(await query_permit_offer_find_pending(deps, expired.id), null); + + // missing + assert.strictEqual(await query_permit_offer_find_pending(deps, create_uuid()), null); + }); + + test('sweep returns only expired pending offers', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_sweep'); + const recipient = await make_account(db, 'recipient_sweep'); + + const fresh = await create_pending_offer(db, grantor, recipient); + const expired = await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: create_uuid(), + expires_at: future(-hour), + }); + + const swept = await query_permit_offer_sweep_expired(deps); + const swept_ids = swept.map((o) => o.id); + assert.include(swept_ids, expired.id); + assert.notInclude(swept_ids, fresh.id); + }); + + test('list orders by expires_at ASC (soonest first)', async () => { + // `query_permit_offer_list` ORDER BY expires_at ASC is part of the + // inbox contract — closest deadline first so users act on the most + // urgent offer. Three rows with distinct deadlines pin both order + // and stability. + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_list_order'); + const recipient = await make_account(db, 'recipient_list_order'); + // Insert in reverse expiry order so the result depends on ORDER BY, + // not insertion order. + const late = await create_pending_offer(db, grantor, recipient, { + role: 'a', + scope_id: create_uuid(), + expires_at: future(hour * 3), + }); + const middle = await create_pending_offer(db, grantor, recipient, { + role: 'b', + scope_id: create_uuid(), + expires_at: future(hour * 2), + }); + const soon = await create_pending_offer(db, grantor, recipient, { + role: 'c', + scope_id: create_uuid(), + expires_at: future(hour), + }); + const list = await query_permit_offer_list(deps, recipient.account_id); + assert.deepStrictEqual( + list.map((o) => o.id), + [soon.id, middle.id, late.id], + ); + }); + + test('sweep_expired orders by expires_at ASC (oldest expiry first)', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_sweep_order'); + const recipient = await make_account(db, 'recipient_sweep_order'); + const newer = await create_pending_offer(db, grantor, recipient, { + role: 'a', + scope_id: create_uuid(), + expires_at: future(-5 * 60_000), + }); + const oldest = await create_pending_offer(db, grantor, recipient, { + role: 'b', + scope_id: create_uuid(), + expires_at: future(-hour * 3), + }); + const middle = await create_pending_offer(db, grantor, recipient, { + role: 'c', + scope_id: create_uuid(), + expires_at: future(-hour), + }); + const swept = await query_permit_offer_sweep_expired(deps); + const ids = swept.map((o) => o.id); + assert.deepStrictEqual(ids, [oldest.id, middle.id, newer.id]); + }); +}); diff --git a/src/test/auth/permit_offer_queries.retract.db.test.ts b/src/test/auth/permit_offer_queries.retract.db.test.ts new file mode 100644 index 00000000..ba566ee8 --- /dev/null +++ b/src/test/auth/permit_offer_queries.retract.db.test.ts @@ -0,0 +1,48 @@ +/** + * Tests for `permit_offer_queries.ts` — retract lifecycle. + * + * Covers the happy-path retract + already-terminal rejection, and the + * wrong-grantor guard (returns null without mutating). + * + * @module + */ + +import {assert, test} from 'vitest'; +import {assert_rejects} from '@fuzdev/fuz_util/testing.js'; + +import { + query_permit_offer_retract, + PermitOfferAlreadyTerminalError, +} from '$lib/auth/permit_offer_queries.js'; + +import {describe_db} from '../db_fixture.js'; +import {make_account, create_pending_offer} from './permit_offer_queries.fixtures.js'; + +describe_db('permit_offer_queries.retract', (get_db) => { + test('retract marks offer terminal; retract on terminal throws', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_retract'); + const recipient = await make_account(db, 'recipient_retract'); + const offer = await create_pending_offer(db, grantor, recipient); + const retracted = await query_permit_offer_retract(deps, offer.id, grantor.actor_id); + assert.ok(retracted); + assert.ok(retracted.retracted_at); + assert.ok(new Date(retracted.retracted_at).getTime() > 0); + const err = await assert_rejects(() => + query_permit_offer_retract(deps, offer.id, grantor.actor_id), + ); + assert.ok(err instanceof PermitOfferAlreadyTerminalError); + }); + + test('retract with wrong grantor returns null', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'retract_guard_grantor'); + const other = await make_account(db, 'retract_guard_other'); + const recipient = await make_account(db, 'retract_guard_recipient'); + const offer = await create_pending_offer(db, grantor, recipient); + const result = await query_permit_offer_retract(deps, offer.id, other.actor_id); + assert.strictEqual(result, null); + }); +}); diff --git a/src/test/auth/permit_offer_queries.supersede.db.test.ts b/src/test/auth/permit_offer_queries.supersede.db.test.ts new file mode 100644 index 00000000..eaa23768 --- /dev/null +++ b/src/test/auth/permit_offer_queries.supersede.db.test.ts @@ -0,0 +1,477 @@ +/** + * Tests for `permit_offer_queries.ts` — multi-grantor coexistence + supersede. + * + * Covers distinct pending offers across grantors for the same + * `(to_account, role, scope)`, same-grantor pending upsert under that + * coexistence, accept-on-expired rejection, the supersede cascade on accept + * (audit fan-out + on-disk single-terminal invariant + cross-grantor join + * sanity), `history_for_account` symmetry, decline/retract isolation across + * grantors, the already-terminal branches that fire for superseded siblings, + * the sweep × superseded interaction, and the end-to-end revoke-bypass + * regression for the accept-time supersede path. + * + * @module + */ + +import {assert, test} from 'vitest'; +import {assert_rejects} from '@fuzdev/fuz_util/testing.js'; + +import { + query_permit_offer_decline, + query_permit_offer_retract, + query_permit_offer_list, + query_permit_offer_find_pending, + query_permit_offer_history_for_account, + query_permit_offer_sweep_expired, + query_accept_offer, + PermitOfferAlreadyTerminalError, + PermitOfferExpiredError, +} from '$lib/auth/permit_offer_queries.js'; +import {query_revoke_permit} from '$lib/auth/permit_queries.js'; +import {query_audit_log, query_audit_log_list_for_account} from '$lib/auth/audit_log_queries.js'; +import {create_uuid} from '@fuzdev/fuz_util/id.js'; + +import {describe_db} from '../db_fixture.js'; +import { + make_account, + future, + hour, + create_pending_offer, + insert_superseded_offer, +} from './permit_offer_queries.fixtures.js'; + +describe_db('permit_offer_queries.supersede', (get_db) => { + test('two grantors produce distinct pending offers for same (to_account, role, scope)', async () => { + const db = get_db(); + const deps = {db}; + const grantor_a = await make_account(db, 'coexist_a'); + const grantor_b = await make_account(db, 'coexist_b'); + const recipient = await make_account(db, 'coexist_recipient'); + const classroom = create_uuid(); + + const offer_a = await create_pending_offer(db, grantor_a, recipient, { + role: 'classroom_student', + scope_id: classroom, + message: 'from A', + }); + const offer_b = await create_pending_offer(db, grantor_b, recipient, { + role: 'classroom_student', + scope_id: classroom, + message: 'from B', + }); + assert.notStrictEqual(offer_a.id, offer_b.id); + assert.strictEqual(offer_a.from_actor_id, grantor_a.actor_id); + assert.strictEqual(offer_b.from_actor_id, grantor_b.actor_id); + const list = await query_permit_offer_list(deps, recipient.account_id); + assert.strictEqual(list.length, 2); + }); + + test('same-grantor re-offer still upserts the pending row', async () => { + const db = get_db(); + const grantor = await make_account(db, 'reoffer_same_a'); + const recipient = await make_account(db, 'reoffer_same_recipient'); + const classroom = create_uuid(); + + const first = await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: classroom, + message: 'first', + }); + const second = await create_pending_offer(db, grantor, recipient, { + role: 'classroom_student', + scope_id: classroom, + message: 'second', + expires_at: future(hour * 2), + }); + assert.strictEqual(second.id, first.id); + assert.strictEqual(second.message, 'second'); + }); + + test('accept on expired pending offer throws PermitOfferExpiredError', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'grantor_expired_accept'); + const recipient = await make_account(db, 'recipient_expired_accept'); + + // `expires_at` has no past-vs-created_at CHECK constraint — the + // create helper accepts a past Date and stores it verbatim. Use the + // public path so the test exercises the real upsert. + const expired_offer = await create_pending_offer(db, grantor, recipient, { + expires_at: future(-60_000), + }); + + const err = await assert_rejects(() => + query_accept_offer(deps, { + offer_id: expired_offer.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }), + ); + assert.ok(err instanceof PermitOfferExpiredError); + + // Row must be untouched — the throw happens before any state mutation. + const check_rows = await db.query<{ + accepted_at: string | null; + resulting_permit_id: string | null; + superseded_at: string | null; + declined_at: string | null; + retracted_at: string | null; + }>( + `SELECT accepted_at, resulting_permit_id, superseded_at, declined_at, retracted_at + FROM permit_offer WHERE id = $1`, + [expired_offer.id], + ); + const r = check_rows[0]!; + assert.strictEqual(r.accepted_at, null); + assert.strictEqual(r.resulting_permit_id, null); + assert.strictEqual(r.superseded_at, null); + assert.strictEqual(r.declined_at, null); + assert.strictEqual(r.retracted_at, null); + }); + + test('accept supersedes sibling pending offers and emits audit events', async () => { + const db = get_db(); + const deps = {db}; + const grantor_a = await make_account(db, 'sibling_a'); + const grantor_b = await make_account(db, 'sibling_b'); + const grantor_c = await make_account(db, 'sibling_c'); + const recipient = await make_account(db, 'sibling_recipient'); + const classroom = create_uuid(); + + const offer_a = await create_pending_offer(db, grantor_a, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + const offer_b = await create_pending_offer(db, grantor_b, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + const offer_c = await create_pending_offer(db, grantor_c, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + + const result = await query_accept_offer(deps, { + offer_id: offer_a.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }); + + assert.strictEqual(result.superseded_offers.length, 2); + const superseded_ids = result.superseded_offers.map((o) => o.id).sort(); + assert.deepStrictEqual(superseded_ids, [offer_b.id, offer_c.id].sort()); + for (const sibling of result.superseded_offers) { + assert.ok(sibling.superseded_at); + } + + // from_account_id is populated via CTE join on `actor` — each sibling's + // entry must carry its own grantor account, never a cross-contamination. + // Direct guard so a broken join fails here before any notification test. + const grantor_b_account = await db.query<{account_id: string}>( + `SELECT account_id FROM actor WHERE id = $1`, + [grantor_b.actor_id], + ); + const grantor_c_account = await db.query<{account_id: string}>( + `SELECT account_id FROM actor WHERE id = $1`, + [grantor_c.actor_id], + ); + const expected_accounts: Record = { + [offer_b.id]: grantor_b_account[0]!.account_id, + [offer_c.id]: grantor_c_account[0]!.account_id, + }; + for (const sibling of result.superseded_offers) { + assert.strictEqual(sibling.from_account_id, expected_accounts[sibling.id]); + } + + // On-disk: exactly one terminal column set per superseded sibling. + // Locks in single-terminal invariant (permit_offer_single_terminal CHECK). + const sibling_rows = await db.query<{ + accepted_at: string | null; + declined_at: string | null; + retracted_at: string | null; + superseded_at: string | null; + }>( + `SELECT accepted_at, declined_at, retracted_at, superseded_at + FROM permit_offer WHERE id = ANY($1)`, + [[offer_b.id, offer_c.id]], + ); + assert.strictEqual(sibling_rows.length, 2); + for (const row of sibling_rows) { + assert.ok(row.superseded_at); + assert.strictEqual(row.accepted_at, null); + assert.strictEqual(row.declined_at, null); + assert.strictEqual(row.retracted_at, null); + } + + // audit events: permit_offer_accept → permit_grant → 2× permit_offer_supersede + // Pin order — accept fires first (offer side), grant second (permit + // side), then the per-sibling supersedes. Multiset-only checks would + // silently pass even if a refactor reordered the in-tx emits. + assert.strictEqual(result.audit_events.length, 4); + assert.strictEqual(result.audit_events[0]?.event_type, 'permit_offer_accept'); + assert.strictEqual(result.audit_events[1]?.event_type, 'permit_grant'); + for (const e of result.audit_events.slice(2)) { + assert.strictEqual(e.event_type, 'permit_offer_supersede'); + const md = e.metadata as {reason?: string; cause_id?: string}; + assert.strictEqual(md.reason, 'sibling_accepted'); + assert.strictEqual(md.cause_id, offer_a.id); + } + + // list is now empty for the recipient — all three offers terminal. + const list = await query_permit_offer_list(deps, recipient.account_id); + assert.strictEqual(list.length, 0); + + // attempting to accept a superseded sibling throws already-terminal. + const err = await assert_rejects(() => + query_accept_offer(deps, { + offer_id: offer_b.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }), + ); + assert.ok(err instanceof PermitOfferAlreadyTerminalError); + }); + + test('history_for_account returns offers in both directions, newest first, with pagination', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'history_grantor'); + const recipient = await make_account(db, 'history_recipient'); + const outsider = await make_account(db, 'history_outsider'); + + // Two offers — `outgoing` first (older `created_at`), then `incoming`. + // `query_permit_offer_history_for_account` is documented as + // `ORDER BY created_at DESC`, so the second insert appears first. + const outgoing = await create_pending_offer(db, grantor, recipient); + // Defeat any same-clock-tick ordering ambiguity in PGlite by stamping + // a deterministic gap between the two `created_at` values. + await db.query(`UPDATE permit_offer SET created_at = NOW() - INTERVAL '1 hour' WHERE id = $1`, [ + outgoing.id, + ]); + const incoming = await create_pending_offer(db, outsider, grantor); + + const for_grantor = await query_permit_offer_history_for_account(deps, grantor.account_id); + // Newest-first ordering — pin index, not multiset. + assert.deepStrictEqual( + for_grantor.map((o) => o.id), + [incoming.id, outgoing.id], + ); + + // limit + offset paginate the same ordering. + const page1 = await query_permit_offer_history_for_account(deps, grantor.account_id, 1, 0); + assert.strictEqual(page1.length, 1); + assert.strictEqual(page1[0]!.id, incoming.id); + const page2 = await query_permit_offer_history_for_account(deps, grantor.account_id, 1, 1); + assert.strictEqual(page2.length, 1); + assert.strictEqual(page2[0]!.id, outgoing.id); + }); + + // -- decline/retract with multi-grantor coexistence ------------------------ + + test('decline on A does not affect B from a different grantor', async () => { + const db = get_db(); + const deps = {db}; + const grantor_a = await make_account(db, 'decline_coexist_a'); + const grantor_b = await make_account(db, 'decline_coexist_b'); + const recipient = await make_account(db, 'decline_coexist_recipient'); + const classroom = create_uuid(); + + const offer_a = await create_pending_offer(db, grantor_a, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + const offer_b = await create_pending_offer(db, grantor_b, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + + const declined = await query_permit_offer_decline(deps, offer_a.id, recipient.account_id, null); + assert.ok(declined?.declined_at); + + // B is still pending. + const still = await query_permit_offer_find_pending(deps, offer_b.id); + assert.ok(still); + assert.strictEqual(still.accepted_at, null); + assert.strictEqual(still.declined_at, null); + assert.strictEqual(still.retracted_at, null); + assert.strictEqual(still.superseded_at, null); + }); + + test('retract on A does not affect B from a different grantor', async () => { + const db = get_db(); + const deps = {db}; + const grantor_a = await make_account(db, 'retract_coexist_a'); + const grantor_b = await make_account(db, 'retract_coexist_b'); + const recipient = await make_account(db, 'retract_coexist_recipient'); + const classroom = create_uuid(); + + const offer_a = await create_pending_offer(db, grantor_a, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + const offer_b = await create_pending_offer(db, grantor_b, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + + const retracted = await query_permit_offer_retract(deps, offer_a.id, grantor_a.actor_id); + assert.ok(retracted?.retracted_at); + + const still = await query_permit_offer_find_pending(deps, offer_b.id); + assert.ok(still); + assert.strictEqual(still.retracted_at, null); + }); + + test('decline and retract on a superseded offer both throw already_terminal', async () => { + const db = get_db(); + const deps = {db}; + const grantor_a = await make_account(db, 'superseded_grantor_a'); + const grantor_b = await make_account(db, 'superseded_grantor_b'); + const recipient = await make_account(db, 'superseded_recipient'); + const classroom = create_uuid(); + + const offer_a = await create_pending_offer(db, grantor_a, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + const offer_b = await create_pending_offer(db, grantor_b, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + + // Accept A → B becomes superseded. + const result = await query_accept_offer(deps, { + offer_id: offer_a.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }); + assert.strictEqual(result.superseded_offers.length, 1); + assert.strictEqual(result.superseded_offers[0]!.id, offer_b.id); + + // Decline on B must throw already_terminal — exercises the superseded_at + // branch in resolve_terminal_or_missing. + const decline_err = await assert_rejects(() => + query_permit_offer_decline(deps, offer_b.id, recipient.account_id, null), + ); + assert.ok(decline_err instanceof PermitOfferAlreadyTerminalError); + + // Retract on B by the original grantor — also terminal. + const retract_err = await assert_rejects(() => + query_permit_offer_retract(deps, offer_b.id, grantor_b.actor_id), + ); + assert.ok(retract_err instanceof PermitOfferAlreadyTerminalError); + }); + + test('sweep_expired does not return expired superseded offers', async () => { + const db = get_db(); + const deps = {db}; + const grantor = await make_account(db, 'sweep_superseded_grantor'); + const recipient = await make_account(db, 'sweep_superseded_recipient'); + + // Defense-in-depth: an expired pending offer that is *also* superseded + // must not appear in the sweep (the sweep must gate on non-terminal). + const superseded_expired_id = await insert_superseded_offer(db, grantor, recipient, { + role: 'teacher', + expires_at: future(-hour), + superseded_at: new Date(Date.now() - 30 * 60_000), + }); + + const swept = await query_permit_offer_sweep_expired(deps); + const ids = swept.map((o) => o.id); + assert.notInclude(ids, superseded_expired_id); + }); + + // -- end-to-end revoke-bypass regression ----------------------------------- + + test('revoke-bypass regression: accept A → revoke → cannot accept superseded B', async () => { + const db = get_db(); + const deps = {db}; + const grantor_a = await make_account(db, 'bypass_grantor_a'); + const grantor_b = await make_account(db, 'bypass_grantor_b'); + const recipient = await make_account(db, 'bypass_recipient'); + const classroom = create_uuid(); + + const offer_a = await create_pending_offer(db, grantor_a, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + const offer_b = await create_pending_offer(db, grantor_b, recipient, { + role: 'classroom_student', + scope_id: classroom, + }); + + // Recipient accepts A — B is superseded in the same transaction. + const accept = await query_accept_offer(deps, { + offer_id: offer_a.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }); + assert.strictEqual(accept.superseded_offers.length, 1); + assert.strictEqual(accept.superseded_offers[0]!.id, offer_b.id); + + // Admin revokes the resulting permit. + const revoke = await query_revoke_permit( + deps, + accept.permit.id, + recipient.actor_id, + null, + 'ended', + ); + assert.ok(revoke); + assert.strictEqual(revoke.id, accept.permit.id); + + // Emit the permit_revoke audit event like the route layer does, so the + // audit chain is inspectable. + await query_audit_log(deps, { + event_type: 'permit_revoke', + actor_id: recipient.actor_id, + account_id: recipient.account_id, + metadata: { + role: revoke.role, + permit_id: revoke.id, + scope_id: revoke.scope_id, + reason: 'ended', + }, + }); + + // Attempting to accept the stale B offer must throw already_terminal — + // closed by the accept-time sibling supersede. + const err = await assert_rejects(() => + query_accept_offer(deps, { + offer_id: offer_b.id, + to_account_id: recipient.account_id, + actor_id: recipient.actor_id, + }), + ); + assert.ok(err instanceof PermitOfferAlreadyTerminalError); + + // Audit chain: permit_offer_accept(A) → permit_grant(source_offer_id=A) + // → permit_offer_supersede(B, reason sibling_accepted, cause_id=A) + // → permit_revoke. + const events = await query_audit_log_list_for_account(deps, recipient.account_id); + const by_type = new Map(); + for (const e of events) { + const list = by_type.get(e.event_type) ?? []; + list.push(e); + by_type.set(e.event_type, list); + } + assert.strictEqual(by_type.get('permit_offer_accept')?.length, 1); + const permit_grants = by_type.get('permit_grant') ?? []; + assert.strictEqual(permit_grants.length, 1); + assert.strictEqual( + (permit_grants[0]!.metadata as {source_offer_id?: string}).source_offer_id, + offer_a.id, + ); + const supersedes = by_type.get('permit_offer_supersede') ?? []; + assert.strictEqual(supersedes.length, 1); + const supersede_md = supersedes[0]!.metadata as { + reason?: string; + cause_id?: string; + offer_id?: string; + }; + assert.strictEqual(supersede_md.reason, 'sibling_accepted'); + assert.strictEqual(supersede_md.cause_id, offer_a.id); + assert.strictEqual(supersede_md.offer_id, offer_b.id); + assert.strictEqual(by_type.get('permit_revoke')?.length, 1); + }); +}); diff --git a/src/test/auth/permit_queries.scope.db.test.ts b/src/test/auth/permit_queries.scope.db.test.ts new file mode 100644 index 00000000..e55ab87b --- /dev/null +++ b/src/test/auth/permit_queries.scope.db.test.ts @@ -0,0 +1,123 @@ +/** + * Tests for `permit_queries.ts` — `query_grant_permit` + `query_permit_has_role` + * scope semantics. + * + * Covers global-permit idempotence on the NULL-scope sentinel, scope-keyed + * distinct rows, same-scope idempotence, and `has_role` matching rules + * (scoped vs global, no cross-match either direction). + * + * @module + */ + +import {assert, test} from 'vitest'; + +import {query_grant_permit, query_permit_has_role} from '$lib/auth/permit_queries.js'; +import {create_uuid} from '@fuzdev/fuz_util/id.js'; + +import {describe_db} from '../db_fixture.js'; +import {make_account} from './permit_offer_queries.fixtures.js'; + +describe_db('permit_queries.scope', (get_db) => { + test('query_grant_permit: global permit (scope_id NULL) idempotent on sentinel', async () => { + const db = get_db(); + const deps = {db}; + const grantee = await make_account(db, 'global_grantee'); + const first = await query_grant_permit(deps, { + actor_id: grantee.actor_id, + role: 'admin', + granted_by: null, + }); + const second = await query_grant_permit(deps, { + actor_id: grantee.actor_id, + role: 'admin', + granted_by: null, + }); + assert.strictEqual(first.id, second.id); + }); + + test('query_grant_permit: different scopes produce distinct permits', async () => { + const db = get_db(); + const deps = {db}; + const grantee = await make_account(db, 'scoped_grantee'); + const classroom_a = create_uuid(); + const classroom_b = create_uuid(); + const a = await query_grant_permit(deps, { + actor_id: grantee.actor_id, + role: 'classroom_student', + scope_id: classroom_a, + granted_by: null, + }); + const b = await query_grant_permit(deps, { + actor_id: grantee.actor_id, + role: 'classroom_student', + scope_id: classroom_b, + granted_by: null, + }); + assert.notStrictEqual(a.id, b.id); + assert.strictEqual(a.scope_id, classroom_a); + assert.strictEqual(b.scope_id, classroom_b); + }); + + test('query_grant_permit: same scope is idempotent', async () => { + const db = get_db(); + const deps = {db}; + const grantee = await make_account(db, 'idem_scope_grantee'); + const classroom = create_uuid(); + const a = await query_grant_permit(deps, { + actor_id: grantee.actor_id, + role: 'classroom_student', + scope_id: classroom, + granted_by: null, + }); + const b = await query_grant_permit(deps, { + actor_id: grantee.actor_id, + role: 'classroom_student', + scope_id: classroom, + granted_by: null, + }); + assert.strictEqual(a.id, b.id); + }); + + test('query_permit_has_role: scope_id distinguishes grants', async () => { + const db = get_db(); + const deps = {db}; + const grantee = await make_account(db, 'scope_check_grantee'); + const classroom_a = create_uuid(); + const classroom_b = create_uuid(); + await query_grant_permit(deps, { + actor_id: grantee.actor_id, + role: 'classroom_student', + scope_id: classroom_a, + granted_by: null, + }); + assert.strictEqual( + await query_permit_has_role(deps, grantee.actor_id, 'classroom_student', classroom_a), + true, + ); + assert.strictEqual( + await query_permit_has_role(deps, grantee.actor_id, 'classroom_student', classroom_b), + false, + ); + // No scope_id argument means "global (NULL) scope" — the scoped grant must not match. + assert.strictEqual( + await query_permit_has_role(deps, grantee.actor_id, 'classroom_student'), + false, + ); + }); + + test('query_permit_has_role: global grant does not match a scoped check', async () => { + const db = get_db(); + const deps = {db}; + const grantee = await make_account(db, 'global_vs_scoped'); + await query_grant_permit(deps, { + actor_id: grantee.actor_id, + role: 'admin', + granted_by: null, + }); + assert.strictEqual(await query_permit_has_role(deps, grantee.actor_id, 'admin'), true); + assert.strictEqual( + await query_permit_has_role(deps, grantee.actor_id, 'admin', create_uuid()), + false, + ); + }); +}); diff --git a/src/test/auth/request_context.authorization_phase.test.ts b/src/test/auth/request_context.authorization_phase.test.ts new file mode 100644 index 00000000..588bb63b --- /dev/null +++ b/src/test/auth/request_context.authorization_phase.test.ts @@ -0,0 +1,254 @@ +/** + * Unit tests for `apply_authorization_phase`. + * + * Covers the four failure shapes the dispatcher's authorization phase + * can return — both 400 reasons (`actor_required`, `actor_not_on_account`) + * and both 500 reasons (`no_actors_on_account` for the + * empty-actor-list invariant; `account_vanished` for the torn-read + * race where `build_request_context` / `build_account_context` return + * null after `resolve_acting_actor` succeeded). + * + * The torn-read 500 is unreachable from an integration test that + * deletes the `account` row — the `ON DELETE CASCADE` chain tears down + * `api_token` / `auth_session` first, so bearer auth fails before the + * dispatcher runs. Mocking `query_account_by_id` / `query_actor_by_id` + * to return null is the only way to deterministically exercise the + * branch. The companion `bearer_actor_deleted.db.test.ts` covers the + * `no_actors_on_account` arm at the integration level. + * + * @module + */ + +import {describe, test, assert, vi, afterEach} from 'vitest'; +import type {Context} from 'hono'; + +import {apply_authorization_phase, REQUEST_CONTEXT_KEY} from '$lib/auth/request_context.js'; +import { + query_account_by_id, + query_actor_by_id, + query_actors_by_account, +} from '$lib/auth/account_queries.js'; +import {query_permit_find_active_for_actor} from '$lib/auth/permit_queries.js'; +import {ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; +import { + ERROR_ACTOR_REQUIRED, + ERROR_ACTOR_NOT_ON_ACCOUNT, + ERROR_NO_ACTORS_ON_ACCOUNT, + ERROR_ACCOUNT_VANISHED, +} from '$lib/http/error_schemas.js'; +import {create_test_account, create_test_actor, create_test_permit} from '$lib/testing/entities.js'; +import type {QueryDeps} from '$lib/db/query_deps.js'; + +const mock_deps: QueryDeps = {db: {} as any}; + +vi.mock('$lib/auth/account_queries.js', () => ({ + query_account_by_id: vi.fn(), + query_actor_by_id: vi.fn(), + query_actors_by_account: vi.fn(), +})); + +vi.mock('$lib/auth/permit_queries.js', () => ({ + query_permit_find_active_for_actor: vi.fn(), +})); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** + * Build a minimal fake Hono `Context` exposing just `.get()` / `.set()` + * over an in-memory store. The function under test only touches those + * two methods. + */ +const create_fake_context = (initial_vars: Record = {}): Context => { + const vars: Record = {...initial_vars}; + return { + get: (key: string) => vars[key], + set: (key: string, value: unknown) => { + vars[key] = value; + }, + } as unknown as Context; +}; + +const ACCOUNT_ID = 'acct-1'; +const ACTOR_ID = 'actor-1'; +const SECOND_ACTOR_ID = 'actor-2'; + +const account = create_test_account({id: ACCOUNT_ID, username: 'alice'}); +const actor = create_test_actor({id: ACTOR_ID, account_id: ACCOUNT_ID, name: 'alice'}); +const second_actor = create_test_actor({ + id: SECOND_ACTOR_ID, + account_id: ACCOUNT_ID, + name: 'alice-pro', +}); +const permits = [create_test_permit({id: 'permit-1', actor_id: ACTOR_ID, role: 'admin'})]; + +describe('apply_authorization_phase — short-circuit paths', () => { + test('returns void when TEST_CONTEXT_PRESET_KEY is set (test escape hatch)', async () => { + const c = create_fake_context({ + [TEST_CONTEXT_PRESET_KEY]: true, + [ACCOUNT_ID_KEY]: ACCOUNT_ID, + }); + + const result = await apply_authorization_phase(mock_deps, c, true, undefined); + + assert.strictEqual(result, undefined); + // The escape hatch trusts whatever the harness pre-populated. + assert.strictEqual(vi.mocked(query_actors_by_account).mock.calls.length, 0); + }); + + test('returns void when account_id is null (downstream auth guard handles 401)', async () => { + const c = create_fake_context({[ACCOUNT_ID_KEY]: null}); + + const result = await apply_authorization_phase(mock_deps, c, true, undefined); + + assert.strictEqual(result, undefined); + assert.strictEqual(vi.mocked(query_actors_by_account).mock.calls.length, 0); + }); +}); + +describe('apply_authorization_phase — needs_actor: false (account-grain)', () => { + test('builds account-only context on success (actor: null, empty permits)', async () => { + vi.mocked(query_account_by_id).mockResolvedValue(account); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, false, undefined); + + assert.strictEqual(result, undefined); + const ctx = c.get(REQUEST_CONTEXT_KEY); + assert.deepStrictEqual(ctx, {account, actor: null, permits: []}); + }); + + test('returns 500 account_vanished when query_account_by_id returns null', async () => { + vi.mocked(query_account_by_id).mockResolvedValue(undefined); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, false, undefined); + + assert.deepStrictEqual(result, { + status: 500, + body: {error: ERROR_ACCOUNT_VANISHED}, + }); + assert.strictEqual(c.get(REQUEST_CONTEXT_KEY), undefined); + }); +}); + +describe('apply_authorization_phase — needs_actor: true', () => { + test('builds full context on single-actor success (no acting supplied)', async () => { + vi.mocked(query_actors_by_account).mockResolvedValue([actor]); + vi.mocked(query_account_by_id).mockResolvedValue(account); + vi.mocked(query_actor_by_id).mockResolvedValue(actor); + vi.mocked(query_permit_find_active_for_actor).mockResolvedValue(permits); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, true, undefined); + + assert.strictEqual(result, undefined); + assert.deepStrictEqual(c.get(REQUEST_CONTEXT_KEY), {account, actor, permits}); + }); + + test('returns 500 no_actors_on_account when query_actors_by_account is empty', async () => { + vi.mocked(query_actors_by_account).mockResolvedValue([]); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, true, undefined); + + assert.deepStrictEqual(result, { + status: 500, + body: {error: ERROR_NO_ACTORS_ON_ACCOUNT}, + }); + assert.strictEqual(c.get(REQUEST_CONTEXT_KEY), undefined); + }); + + test('returns 400 actor_required with available list on multi-actor + no acting', async () => { + vi.mocked(query_actors_by_account).mockResolvedValue([actor, second_actor]); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, true, undefined); + + assert.deepStrictEqual(result, { + status: 400, + body: { + error: ERROR_ACTOR_REQUIRED, + available: [ + {id: ACTOR_ID, name: 'alice'}, + {id: SECOND_ACTOR_ID, name: 'alice-pro'}, + ], + }, + }); + assert.strictEqual(c.get(REQUEST_CONTEXT_KEY), undefined); + }); + + test('returns 400 actor_not_on_account when supplied acting does not match', async () => { + vi.mocked(query_actors_by_account).mockResolvedValue([actor]); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, true, 'actor-not-here'); + + assert.deepStrictEqual(result, { + status: 400, + body: {error: ERROR_ACTOR_NOT_ON_ACCOUNT}, + }); + assert.strictEqual(c.get(REQUEST_CONTEXT_KEY), undefined); + }); + + test('returns 500 account_vanished when query_account_by_id is null after resolve (torn read)', async () => { + // `query_actors_by_account` succeeds — `resolve_acting_actor` returns + // {ok: true}. `build_request_context`'s account lookup then returns + // null (account row deleted between the two reads — production is a + // concurrent-deletion race; here we simulate it directly). + vi.mocked(query_actors_by_account).mockResolvedValue([actor]); + vi.mocked(query_account_by_id).mockResolvedValue(undefined); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, true, undefined); + + assert.deepStrictEqual(result, { + status: 500, + body: {error: ERROR_ACCOUNT_VANISHED}, + }); + assert.strictEqual(c.get(REQUEST_CONTEXT_KEY), undefined); + }); + + test('returns 500 account_vanished when query_actor_by_id is null after resolve (torn read)', async () => { + // `query_actors_by_account` returned the actor; `query_account_by_id` + // found the account; but `query_actor_by_id` returns null — the + // actor row was deleted between enumeration and lookup. + vi.mocked(query_actors_by_account).mockResolvedValue([actor]); + vi.mocked(query_account_by_id).mockResolvedValue(account); + vi.mocked(query_actor_by_id).mockResolvedValue(undefined); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, true, undefined); + + assert.deepStrictEqual(result, { + status: 500, + body: {error: ERROR_ACCOUNT_VANISHED}, + }); + assert.strictEqual(c.get(REQUEST_CONTEXT_KEY), undefined); + }); + + test('returns 500 account_vanished when actor.account_id mismatch (defense-in-depth branch)', async () => { + // Defense-in-depth: `resolve_acting_actor` already verified the actor + // belongs to the account, but `build_request_context` re-checks the + // binding. The mismatch sub-branch fires when `actor.account_id` + // flipped between the two reads — production-unreachable on paper, + // but the docstring documents that it collapses into the torn-read + // 500 shape rather than its own status code. + vi.mocked(query_actors_by_account).mockResolvedValue([actor]); + vi.mocked(query_account_by_id).mockResolvedValue(account); + vi.mocked(query_actor_by_id).mockResolvedValue({ + ...actor, + account_id: 'different-account' as typeof actor.account_id, + }); + const c = create_fake_context({[ACCOUNT_ID_KEY]: ACCOUNT_ID}); + + const result = await apply_authorization_phase(mock_deps, c, true, undefined); + + assert.deepStrictEqual(result, { + status: 500, + body: {error: ERROR_ACCOUNT_VANISHED}, + }); + assert.strictEqual(c.get(REQUEST_CONTEXT_KEY), undefined); + }); +}); diff --git a/src/test/auth/request_context.input_schema_declares_acting.test.ts b/src/test/auth/request_context.input_schema_declares_acting.test.ts new file mode 100644 index 00000000..6cac6d66 --- /dev/null +++ b/src/test/auth/request_context.input_schema_declares_acting.test.ts @@ -0,0 +1,100 @@ +/** + * Unit tests for `input_schema_declares_acting`. + * + * Reference-equality check — the predicate looks for the canonical + * `ActingActor` schema in the input's `.shape.acting` slot. Pinned here + * because the dispatcher's authorization phase keys on it + * (`actions/action_rpc.ts`, `http/route_spec.ts`, + * `server/app_server.ts`) and the `audit-actor` migration replaced + * `input: z.void()` with `z.strictObject({acting: ActingActor})` on + * every actor-aware action — the canonical shape must keep tripping + * the predicate. Variant B in + * `~/dev/grimoire/lore/fuz_app/TODO_PUBLIC_AUTH_PHASE.md` makes the + * predicate authorization-correctness load-bearing on `auth: 'public'` + * actions, so a regression here is a security regression. + * + * @module + */ + +import {describe, test, assert} from 'vitest'; +import {z} from 'zod'; + +import {input_schema_declares_acting} from '$lib/auth/request_context.js'; +import {ActingActor} from '$lib/auth/account_schema.js'; + +describe('input_schema_declares_acting', () => { + test('canonical strictObject({acting: ActingActor}) returns true', () => { + // The audit-actor migration's standard shape — every listing-style + // admin / permit-offer / account / audit spec uses this. + const schema = z.strictObject({acting: ActingActor}); + assert.strictEqual(input_schema_declares_acting(schema), true); + }); + + test('strictObject with required field plus acting returns true', () => { + // Mixed required + acting — admin_session_revoke_all, + // audit_log_permit_history, etc. The predicate fires on any + // object schema that has the canonical `acting` slot. + const schema = z.strictObject({account_id: z.string(), acting: ActingActor}); + assert.strictEqual(input_schema_declares_acting(schema), true); + }); + + test('object without acting returns false', () => { + const schema = z.strictObject({something_else: z.string()}); + assert.strictEqual(input_schema_declares_acting(schema), false); + }); + + test('object with locally-defined acting (not the canonical export) returns false', () => { + // Reference equality — a consumer schema with an unrelated `acting` + // field must not trip the predicate. The dispatcher's authorization + // phase only resolves an actor when the input declares the canonical + // `ActingActor` slot. + const schema = z.strictObject({acting: z.string().optional()}); + assert.strictEqual(input_schema_declares_acting(schema), false); + }); + + test('z.void() input returns false', () => { + assert.strictEqual(input_schema_declares_acting(z.void()), false); + }); + + test('z.null() input returns false', () => { + assert.strictEqual(input_schema_declares_acting(z.null()), false); + }); + + test('non-object schema returns false', () => { + assert.strictEqual(input_schema_declares_acting(z.string()), false); + }); + + // --- Wrapper tolerance (defense-in-depth for variant B) --- + // + // `zod_unwrap_to_object` peels `optional` / `nullable` / `default` / + // `transform` / `pipe` / `prefault` before the shape lookup. A spec that + // wraps `z.strictObject({acting: ActingActor})` for any reason still + // declares the canonical acting slot, so the dispatcher must resolve + // the actor. Variant B in `~/dev/grimoire/lore/fuz_app/TODO_PUBLIC_AUTH_PHASE.md` + // makes this load-bearing — a missed declaration on a public action + // silently skips authorization and the handler runs without `ctx.auth`. + + test('z.optional wrapper around the canonical strictObject still returns true', () => { + const schema = z.optional(z.strictObject({acting: ActingActor})); + assert.strictEqual(input_schema_declares_acting(schema), true); + }); + + test('z.nullable wrapper around the canonical strictObject still returns true', () => { + const schema = z.nullable(z.strictObject({acting: ActingActor})); + assert.strictEqual(input_schema_declares_acting(schema), true); + }); + + test('default-wrapped strictObject still returns true', () => { + const schema = z.strictObject({acting: ActingActor}).default({}); + assert.strictEqual(input_schema_declares_acting(schema), true); + }); + + test('wrapper around an unrelated-acting object still returns false (reference equality preserved)', () => { + // Reference equality on `ActingActor` is the security-critical part of + // the predicate. Wrapper peeling must not weaken it — a consumer + // schema with a locally-defined `acting` field does not trip the + // predicate even when wrapped. + const schema = z.optional(z.strictObject({acting: z.string().optional()})); + assert.strictEqual(input_schema_declares_acting(schema), false); + }); +}); diff --git a/src/test/auth/request_context.test.ts b/src/test/auth/request_context.test.ts index 63479826..20772099 100644 --- a/src/test/auth/request_context.test.ts +++ b/src/test/auth/request_context.test.ts @@ -18,7 +18,12 @@ import { create_request_context_middleware, REQUEST_CONTEXT_KEY, } from '$lib/auth/request_context.js'; -import {AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY} from '$lib/hono_context.js'; +import { + ACCOUNT_ID_KEY, + AUTH_API_TOKEN_ID_KEY, + CREDENTIAL_TYPE_KEY, + TEST_CONTEXT_PRESET_KEY, +} from '$lib/hono_context.js'; import type {Account, Actor, Permit} from '$lib/auth/account_schema.js'; import { ERROR_AUTHENTICATION_REQUIRED, @@ -32,7 +37,11 @@ import { } from '$lib/testing/entities.js'; import type {QueryDeps} from '$lib/db/query_deps.js'; import {query_session_get_valid, session_touch_fire_and_forget} from '$lib/auth/session_queries.js'; -import {query_account_by_id, query_actor_by_account} from '$lib/auth/account_queries.js'; +import { + query_account_by_id, + query_actor_by_id, + query_actors_by_account, +} from '$lib/auth/account_queries.js'; import {query_permit_find_active_for_actor} from '$lib/auth/permit_queries.js'; const log = new Logger('test', {level: 'off'}); @@ -52,7 +61,8 @@ vi.mock('$lib/auth/session_queries.js', async (import_original) => { vi.mock('$lib/auth/account_queries.js', () => ({ query_account_by_id: vi.fn(), - query_actor_by_account: vi.fn(), + query_actor_by_id: vi.fn(), + query_actors_by_account: vi.fn(), })); vi.mock('$lib/auth/permit_queries.js', () => ({ @@ -302,7 +312,9 @@ describe('require_auth', () => { const app = new Hono(); // set context before require_auth app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, ctx.account.id); c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); app.use('/*', require_auth); @@ -331,7 +343,9 @@ describe('require_role', () => { const ctx = create_test_context([{role: 'user'}]); const app = new Hono(); app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, ctx.account.id); c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); app.use('/*', require_role('admin')); @@ -348,7 +362,9 @@ describe('require_role', () => { const ctx = create_test_context([{role: 'admin'}]); const app = new Hono(); app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, ctx.account.id); c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); app.use('/*', require_role('admin')); @@ -364,7 +380,9 @@ describe('require_role', () => { const ctx = create_test_context([{role: 'user'}]); const app = new Hono(); app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, ctx.account.id); c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); app.use('/*', require_role('keeper')); @@ -382,7 +400,9 @@ describe('require_role', () => { const ctx = create_test_context([{role: 'admin', expires_at: past}]); const app = new Hono(); app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, ctx.account.id); c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); app.use('/*', require_role('admin')); @@ -399,7 +419,9 @@ describe('require_role', () => { const ctx = create_test_context([{role: 'admin', revoked_at: '2024-01-01T00:00:00Z'}]); const app = new Hono(); app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, ctx.account.id); c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); app.use('/*', require_role('admin')); @@ -471,9 +493,16 @@ describe('create_request_context_middleware', () => { vi.mocked(query_account_by_id).mockImplementation(async () => 'account' in overrides ? overrides.account : account, ); - vi.mocked(query_actor_by_account).mockImplementation(async () => + vi.mocked(query_actor_by_id).mockImplementation(async () => 'actor' in overrides ? overrides.actor : actor, ); + // `resolve_acting_actor` enumerates actors. Mirror the actor mock — + // when an actor is supplied (or default), return it as the unique + // account actor; when not, return empty. + vi.mocked(query_actors_by_account).mockImplementation(async () => { + const a = 'actor' in overrides ? overrides.actor : actor; + return a ? [a] : []; + }); vi.mocked(query_permit_find_active_for_actor).mockImplementation(async () => 'permits' in overrides ? overrides.permits! : permits, ); @@ -491,75 +520,57 @@ describe('create_request_context_middleware', () => { }); app.use('/*', create_request_context_middleware(mock_deps, log)); app.get('/test', (c) => { - const ctx = c.get(REQUEST_CONTEXT_KEY); + const account_id = c.get(ACCOUNT_ID_KEY); const credential_type = c.get(CREDENTIAL_TYPE_KEY); const api_token_id = c.get(AUTH_API_TOKEN_ID_KEY); + const context = c.get(REQUEST_CONTEXT_KEY); return c.json({ - context: ctx, + account_id: account_id ?? null, credential_type: credential_type ?? null, api_token_id: api_token_id ?? null, + context: context ?? null, }); }); return app; }; - test('no session token sets request_context to null and credential_type to null', async () => { + test('no session token leaves account_id and credential_type null', async () => { configure_mocks(); const app = create_ctx_app(null); const res = await app.request('/test'); const body = await res.json(); - assert.strictEqual(body.context, null); + assert.strictEqual(body.account_id, null); assert.strictEqual(body.credential_type, null); assert.strictEqual(body.api_token_id, null); + assert.strictEqual(body.context, null); }); - test('invalid session sets request_context to null and credential_type to null', async () => { + test('invalid session leaves account_id and credential_type null', async () => { configure_mocks({session: undefined}); const app = create_ctx_app(); const res = await app.request('/test'); const body = await res.json(); - assert.strictEqual(body.context, null); + assert.strictEqual(body.account_id, null); assert.strictEqual(body.credential_type, null); assert.strictEqual(body.api_token_id, null); + assert.strictEqual(body.context, null); }); - test('valid session builds full request context and sets credential_type to session', async () => { + test('valid session sets account_id and credential_type to session', async () => { configure_mocks(); const app = create_ctx_app(); const res = await app.request('/test'); const body = await res.json(); - assert.ok(body.context); - assert.strictEqual(body.context.account.id, 'acct-1'); - assert.strictEqual(body.context.actor.id, 'actor-1'); - assert.strictEqual(body.context.permits.length, 1); - assert.strictEqual(body.context.permits[0].role, 'admin'); + assert.strictEqual(body.account_id, 'acct-1'); assert.strictEqual(body.credential_type, 'session'); assert.strictEqual(body.api_token_id, null); - }); - - test('account not found sets request_context to null', async () => { - configure_mocks({account: undefined}); - const app = create_ctx_app(); - - const res = await app.request('/test'); - const body = await res.json(); - assert.strictEqual(body.context, null); - assert.strictEqual(body.credential_type, null); - assert.strictEqual(body.api_token_id, null); - }); - - test('actor not found sets request_context to null', async () => { - configure_mocks({actor: undefined}); - const app = create_ctx_app(); - - const res = await app.request('/test'); - const body = await res.json(); + // Middleware does not build the request context — that is the + // dispatcher's authorization phase. `REQUEST_CONTEXT_KEY` stays null + // after authentication. assert.strictEqual(body.context, null); - assert.strictEqual(body.credential_type, null); - assert.strictEqual(body.api_token_id, null); }); test('always calls next() regardless of auth state', async () => { @@ -601,16 +612,16 @@ describe('create_request_context_middleware', () => { }); app.use('/*', create_request_context_middleware(mock_deps, error_log)); app.get('/test', (c) => { - const ctx = c.get(REQUEST_CONTEXT_KEY); + const account_id = c.get(ACCOUNT_ID_KEY); const credential_type = c.get(CREDENTIAL_TYPE_KEY); - return c.json({context: ctx, credential_type: credential_type ?? null}); + return c.json({account_id: account_id ?? null, credential_type: credential_type ?? null}); }); const res = await app.request('/test'); assert.strictEqual(res.status, 200, 'request should succeed despite touch failure'); const body = await res.json(); - assert.ok(body.context, 'request context should still be set'); + assert.strictEqual(body.account_id, 'acct-1', 'account_id should still be set'); // wait for the fire-and-forget promise to settle await wait(); diff --git a/src/test/auth/request_context.test_context_preset.test.ts b/src/test/auth/request_context.test_context_preset.test.ts new file mode 100644 index 00000000..cf6c7f1b --- /dev/null +++ b/src/test/auth/request_context.test_context_preset.test.ts @@ -0,0 +1,104 @@ +/** + * Drift guard for `TEST_CONTEXT_PRESET_KEY`. + * + * The flag is the dispatcher's test-only escape hatch — when set, + * `apply_authorization_phase` short-circuits and trusts whatever the + * harness pre-populated under `REQUEST_CONTEXT_KEY`. Production + * middleware setting it would silently bypass the live actor + + * permit resolution, so we walk the source tree at test time and + * fail loud on any production-side write to that key. + * + * Allowed write sites are confined to `src/lib/testing/` — those are + * the harness helpers (`auth_apps.ts`, `middleware.ts`, the WS + * round-trip primitives) that consumers opt into by importing. + * + * @module + */ + +import {test, assert} from 'vitest'; +import {readdirSync, readFileSync, statSync} from 'node:fs'; +import {join} from 'node:path'; + +import {TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; + +/** Walk a directory tree, yielding absolute paths of every regular file. */ +const walk = (root: string): Array => { + const out: Array = []; + const visit = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const s = statSync(full); + if (s.isDirectory()) { + visit(full); + } else if (s.isFile()) { + out.push(full); + } + } + }; + visit(root); + return out; +}; + +const LIB_ROOT = new URL('../../lib/', import.meta.url).pathname; +const TESTING_PREFIX = `${LIB_ROOT}testing/`; + +/** + * Files that own the canonical declaration of the key. Allowed to + * mention the literal `'test_context_preset'` exactly once each + * because they declare the constant + its Hono context-type entry — + * checked in the second test below by counting only assignments. + */ +const DECLARATION_PATHS = new Set([`${LIB_ROOT}hono_context.ts`]); + +test('no production module under src/lib/ writes TEST_CONTEXT_PRESET_KEY', () => { + // The flag value is `'test_context_preset'` — keep this in sync if + // the constant is ever renamed (the test doubles as a rename guard). + assert.strictEqual(TEST_CONTEXT_PRESET_KEY, 'test_context_preset'); + + // Patterns that would set the key on a Hono context — both the named + // constant and the bare string literal. Reads (`c.get(...)`) and + // type-only mentions (e.g. the `ContextVariableMap` augmentation) are + // allowed; only writes are forbidden in production code. + const set_patterns: Array = [ + /c\.set\s*\(\s*TEST_CONTEXT_PRESET_KEY\b/, + /c\.set\s*\(\s*['"]test_context_preset['"]/, + // Initial-var maps (`{[TEST_CONTEXT_PRESET_KEY]: ...}` or `{test_context_preset: ...}`) + // the WS round-trip fake context uses — allowed in `testing/`, + // banned everywhere else under `src/lib/`. + /\[\s*TEST_CONTEXT_PRESET_KEY\s*\]\s*:/, + /\btest_context_preset\s*:\s*(?:true|false|[a-zA-Z_])/, + ]; + + const violations: Array<{file: string; pattern: string; line: string}> = []; + + for (const file of walk(LIB_ROOT)) { + // Skip the testing subtree — the escape-hatch setters live there. + if (file.startsWith(TESTING_PREFIX)) continue; + // Skip generated files and non-TS sources. + if (!/\.(ts|js)$/.test(file)) continue; + + const text = readFileSync(file, 'utf8'); + for (const pattern of set_patterns) { + const m = pattern.exec(text); + if (m) { + // `hono_context.ts` declares the constant + the type entry — + // the type entry matches `\btest_context_preset\s*:\s*[a-zA-Z_]` + // (the type is `boolean`) and is part of the canonical + // declaration, not a write. The grep above already excludes + // reads; this further excludes the declaration sites. + if (DECLARATION_PATHS.has(file) && pattern.source.includes('test_context_preset')) { + continue; + } + violations.push({file, pattern: pattern.source, line: m[0]}); + } + } + } + + assert.deepStrictEqual( + violations, + [], + `Production code under src/lib/ must not write TEST_CONTEXT_PRESET_KEY. ` + + `Move the write to src/lib/testing/, or remove it. Violations:\n` + + violations.map((v) => ` ${v.file}: ${v.line} (matched ${v.pattern})`).join('\n'), + ); +}); diff --git a/src/test/auth/request_context.ws.db.test.ts b/src/test/auth/request_context.ws.db.test.ts index 9be21534..a46509d7 100644 --- a/src/test/auth/request_context.ws.db.test.ts +++ b/src/test/auth/request_context.ws.db.test.ts @@ -120,7 +120,7 @@ describe_db('build_request_context', (get_db) => { }); await query_grant_permit(deps, {actor_id: actor.id, role: ROLE_ADMIN, granted_by: null}); - const ctx = await build_request_context(deps, account.id); + const ctx = await build_request_context(deps, account.id, actor.id); assert.ok(ctx !== null); assert.strictEqual(ctx.account.id, account.id); @@ -140,7 +140,7 @@ describe_db('build_request_context', (get_db) => { }); await query_grant_permit(deps, {actor_id: actor.id, role: ROLE_ADMIN, granted_by: null}); - const ctx = await build_request_context(deps, account.id); + const ctx = await build_request_context(deps, account.id, actor.id); assert.ok(ctx !== null); // raw context includes password_hash (needed for internal operations) @@ -160,7 +160,11 @@ describe_db('build_request_context', (get_db) => { test('returns null for nonexistent account', async () => { const db = get_db(); const deps = {db}; - const ctx = await build_request_context(deps, '00000000-0000-0000-0000-000000000000'); + const ctx = await build_request_context( + deps, + '00000000-0000-0000-0000-000000000000', + '00000000-0000-0000-0000-000000000001', + ); assert.strictEqual(ctx, null); }); @@ -173,7 +177,28 @@ describe_db('build_request_context', (get_db) => { password_hash: STUB_HASH, }); - const ctx = await build_request_context(deps, account.id); + const ctx = await build_request_context( + deps, + account.id, + '00000000-0000-0000-0000-000000000001', + ); + assert.strictEqual(ctx, null); + }); + + test('returns null when actor belongs to a different account', async () => { + const db = get_db(); + const deps = {db}; + const a = await query_create_account_with_actor(deps, { + username: 'wrong_actor_a', + password_hash: STUB_HASH, + }); + const b = await query_create_account_with_actor(deps, { + username: 'wrong_actor_b', + password_hash: STUB_HASH, + }); + + // Pass account A's id with actor B's id — must return null. + const ctx = await build_request_context(deps, a.account.id, b.actor.id); assert.strictEqual(ctx, null); }); @@ -185,7 +210,7 @@ describe_db('build_request_context', (get_db) => { password_hash: STUB_HASH, }); - const ctx = await build_request_context(deps, account.id); + const ctx = await build_request_context(deps, account.id, actor.id); assert.ok(ctx !== null); assert.strictEqual(ctx.account.id, account.id); diff --git a/src/test/auth/require_keeper.test.ts b/src/test/auth/require_keeper.test.ts index e77a6405..310ff816 100644 --- a/src/test/auth/require_keeper.test.ts +++ b/src/test/auth/require_keeper.test.ts @@ -9,7 +9,11 @@ import {Hono} from 'hono'; import {require_keeper} from '$lib/auth/require_keeper.js'; import {REQUEST_CONTEXT_KEY, type RequestContext} from '$lib/auth/request_context.js'; -import {CREDENTIAL_TYPE_KEY, type CredentialType} from '$lib/hono_context.js'; +import { + CREDENTIAL_TYPE_KEY, + TEST_CONTEXT_PRESET_KEY, + type CredentialType, +} from '$lib/hono_context.js'; import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, @@ -29,6 +33,7 @@ const create_keeper_app = (ctx?: RequestContext, credential_type?: CredentialTyp if (ctx) { app.use('/*', async (c, next) => { c.set(REQUEST_CONTEXT_KEY, ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); if (credential_type) { c.set(CREDENTIAL_TYPE_KEY, credential_type); } diff --git a/src/test/auth/role_escalation.db.test.ts b/src/test/auth/role_escalation.db.test.ts index dd2bb1ab..4137da7f 100644 --- a/src/test/auth/role_escalation.db.test.ts +++ b/src/test/auth/role_escalation.db.test.ts @@ -11,10 +11,7 @@ import {assert, test} from 'vitest'; import {ROLE_KEEPER, ROLE_ADMIN} from '$lib/auth/role_schema.js'; -import { - query_create_account_with_actor, - query_actor_by_account, -} from '$lib/auth/account_queries.js'; +import {query_create_account_with_actor} from '$lib/auth/account_queries.js'; import { query_grant_permit, query_permit_has_role, @@ -143,20 +140,4 @@ describe_db('RoleEscalation', (get_db) => { assert.strictEqual(other_permits.length, 1); assert.strictEqual(other_permits[0]!.role, ROLE_KEEPER); }); - - test('actor_by_account correctly isolates actors', async () => { - const db = get_db(); - const deps = {db}; - const {account_id: acct_a, actor_id: actor_a} = await create_test_actor(db, 'iso_a'); - const {account_id: acct_b, actor_id: actor_b} = await create_test_actor(db, 'iso_b'); - - const resolved_a = await query_actor_by_account(deps, acct_a); - const resolved_b = await query_actor_by_account(deps, acct_b); - - assert.ok(resolved_a); - assert.ok(resolved_b); - assert.strictEqual(resolved_a.id, actor_a); - assert.strictEqual(resolved_b.id, actor_b); - assert.notStrictEqual(resolved_a.id, resolved_b.id); - }); }); diff --git a/src/test/auth/self_service_role_actions.db.test.ts b/src/test/auth/self_service_role_actions.db.test.ts index 0e630f32..c49338f4 100644 --- a/src/test/auth/self_service_role_actions.db.test.ts +++ b/src/test/auth/self_service_role_actions.db.test.ts @@ -107,9 +107,12 @@ describe_db('self_service_role_actions', (get_db) => { const audit_rows = await get_db().query<{ event_type: string; account_id: string | null; + target_account_id: string | null; + target_actor_id: string | null; metadata: Record | null; }>( - `SELECT event_type, account_id, metadata FROM audit_log + `SELECT event_type, account_id, target_account_id, target_actor_id, metadata + FROM audit_log WHERE event_type = 'permit_grant' AND account_id = $1 ORDER BY seq DESC LIMIT 1`, [caller.account.id], @@ -118,6 +121,12 @@ describe_db('self_service_role_actions', (get_db) => { assert.strictEqual(audit_rows[0]!.metadata?.role, 'teacher'); assert.strictEqual(audit_rows[0]!.metadata?.self_service, true); assert.ok(audit_rows[0]!.metadata?.permit_id); + // Self-service `permit_grant` populates both target columns + // (== actor_id, account_id) so the audit_log_schema rule + // "permit_grant always populates both target columns" holds + // uniformly across admin, accept, and self-service paths. + assert.strictEqual(audit_rows[0]!.target_account_id, caller.account.id); + assert.strictEqual(audit_rows[0]!.target_actor_id, caller.actor.id); }); test('idempotent re-grant returns changed:false and writes no extra audit row', async () => { @@ -188,9 +197,12 @@ describe_db('self_service_role_actions', (get_db) => { const audit_rows = await get_db().query<{ event_type: string; + target_account_id: string | null; + target_actor_id: string | null; metadata: Record | null; }>( - `SELECT event_type, metadata FROM audit_log + `SELECT event_type, target_account_id, target_actor_id, metadata + FROM audit_log WHERE event_type = 'permit_revoke' AND account_id = $1 ORDER BY seq DESC LIMIT 1`, [caller.account.id], @@ -198,6 +210,10 @@ describe_db('self_service_role_actions', (get_db) => { assert.strictEqual(audit_rows.length, 1); assert.strictEqual(audit_rows[0]!.metadata?.role, 'teacher'); assert.strictEqual(audit_rows[0]!.metadata?.self_service, true); + // Same actor-bound rule as the grant branch — target columns + // populated even on self-service. + assert.strictEqual(audit_rows[0]!.target_account_id, caller.account.id); + assert.strictEqual(audit_rows[0]!.target_actor_id, caller.actor.id); }); test('revoke without prior grant returns changed:false and writes no audit row', async () => { diff --git a/src/test/auth/session_lifecycle.test.ts b/src/test/auth/session_lifecycle.test.ts index 9f2b9314..b39fc5da 100644 --- a/src/test/auth/session_lifecycle.test.ts +++ b/src/test/auth/session_lifecycle.test.ts @@ -207,6 +207,7 @@ describe('create_session_and_set_cookie', () => { deps: mock_deps, c, account_id: 'acct-1', + session_options: SESSION_OPTIONS, }); diff --git a/src/test/auth/session_queries.db.test.ts b/src/test/auth/session_queries.db.test.ts index a8f8cdd6..4925dd9f 100644 --- a/src/test/auth/session_queries.db.test.ts +++ b/src/test/auth/session_queries.db.test.ts @@ -6,7 +6,7 @@ import {describe, assert, test} from 'vitest'; -import {query_create_account, query_delete_account} from '$lib/auth/account_queries.js'; +import {query_create_account_with_actor, query_delete_account} from '$lib/auth/account_queries.js'; import { query_create_session, query_session_get_valid, @@ -26,11 +26,17 @@ import type {Db} from '$lib/db/db.js'; import {describe_db} from '../db_fixture.js'; -/** Helper to create a test account and return its id. */ -const create_test_account = async (database: Db, username: string): Promise => { +/** Helper to create a test account + actor and return both ids. */ +const create_test_account = async ( + database: Db, + username: string, +): Promise<{account_id: string; actor_id: string}> => { const deps = {db: database}; - const account = await query_create_account(deps, {username, password_hash: 'hash'}); - return account.id; + const {account, actor} = await query_create_account_with_actor(deps, { + username, + password_hash: 'hash', + }); + return {account_id: account.id, actor_id: actor.id}; }; describe('hash_session_token', () => { @@ -95,7 +101,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('create and get_valid returns the session', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'alice'); + const {account_id} = await create_test_account(db, 'alice'); const token = generate_session_token(); const token_hash = hash_session_token(token); const expires = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); @@ -110,7 +116,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('get_valid returns undefined for expired session', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'bob'); + const {account_id} = await create_test_account(db, 'bob'); const token_hash = hash_session_token('expired_token'); const past = new Date(Date.now() - 1000); await query_create_session(deps, token_hash, account_id, past); @@ -129,7 +135,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('revoke deletes the session', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'charlie'); + const {account_id} = await create_test_account(db, 'charlie'); const token_hash = hash_session_token('revoke_me'); await query_create_session( deps, @@ -149,8 +155,8 @@ describe_db('AuthSessionQueries', (get_db) => { // For user-facing revocation, use revoke_for_account which includes an IDOR guard. const db = get_db(); const deps = {db}; - const alice_id = await create_test_account(db, 'alice_unscoped'); - const bob_id = await create_test_account(db, 'bob_unscoped'); + const {account_id: alice_id} = await create_test_account(db, 'alice_unscoped'); + const {account_id: bob_id} = await create_test_account(db, 'bob_unscoped'); const alice_hash = hash_session_token('alice_trust_boundary'); const bob_hash = hash_session_token('bob_trust_boundary'); await query_create_session( @@ -190,7 +196,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('revoke_all_for_account deletes all sessions', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'dave'); + const {account_id} = await create_test_account(db, 'dave'); const expires = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); await query_create_session(deps, hash_session_token('session1'), account_id, expires); await query_create_session(deps, hash_session_token('session2'), account_id, expires); @@ -206,7 +212,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('list_for_account returns sessions newest first', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'eve'); + const {account_id} = await create_test_account(db, 'eve'); const expires = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); await query_create_session(deps, hash_session_token('first'), account_id, expires); await query_create_session(deps, hash_session_token('second'), account_id, expires); @@ -220,7 +226,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('touch updates last_seen_at', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'frank'); + const {account_id} = await create_test_account(db, 'frank'); const token_hash = hash_session_token('touch_me'); await query_create_session( deps, @@ -242,7 +248,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('cleanup_expired removes expired sessions', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'grace'); + const {account_id} = await create_test_account(db, 'grace'); const past = new Date(Date.now() - 1000); const future = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); await query_create_session(deps, hash_session_token('expired1'), account_id, past); @@ -259,7 +265,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('revoke_for_account succeeds for own session', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'iris'); + const {account_id} = await create_test_account(db, 'iris'); const token_hash = hash_session_token('own_session'); await query_create_session( deps, @@ -278,8 +284,8 @@ describe_db('AuthSessionQueries', (get_db) => { test('revoke_for_account fails for other account session', async () => { const db = get_db(); const deps = {db}; - const alice_id = await create_test_account(db, 'alice_rfa'); - const bob_id = await create_test_account(db, 'bob_rfa'); + const {account_id: alice_id} = await create_test_account(db, 'alice_rfa'); + const {account_id: bob_id} = await create_test_account(db, 'bob_rfa'); const token_hash = hash_session_token('alice_session'); await query_create_session( deps, @@ -300,7 +306,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('revoke_for_account returns false for nonexistent session', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'jack'); + const {account_id} = await create_test_account(db, 'jack'); const revoked = await query_session_revoke_for_account(deps, 'nonexistent_hash', account_id); assert.strictEqual(revoked, false); @@ -309,7 +315,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('sessions cascade delete with account', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'heidi'); + const {account_id} = await create_test_account(db, 'heidi'); await query_create_session( deps, hash_session_token('cascade_me'), @@ -326,7 +332,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('enforce_session_limit returns 0 when under limit', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'limit_under'); + const {account_id} = await create_test_account(db, 'limit_under'); const expires = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); await query_create_session(deps, hash_session_token('s1'), account_id, expires); await query_create_session(deps, hash_session_token('s2'), account_id, expires); @@ -341,7 +347,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('enforce_session_limit evicts oldest when over limit', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'limit_over'); + const {account_id} = await create_test_account(db, 'limit_over'); const expires = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS).toISOString(); // insert with explicit created_at to ensure deterministic ordering const base = Date.now(); @@ -370,7 +376,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('enforce_session_limit with max 1 keeps only the latest', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'limit_one'); + const {account_id} = await create_test_account(db, 'limit_one'); const expires = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS).toISOString(); // insert with explicit created_at to ensure deterministic ordering const base = Date.now(); @@ -396,8 +402,8 @@ describe_db('AuthSessionQueries', (get_db) => { test('enforce_session_limit does not affect other accounts', async () => { const db = get_db(); const deps = {db}; - const alice_id = await create_test_account(db, 'limit_alice'); - const bob_id = await create_test_account(db, 'limit_bob'); + const {account_id: alice_id} = await create_test_account(db, 'limit_alice'); + const {account_id: bob_id} = await create_test_account(db, 'limit_bob'); const expires = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); await query_create_session(deps, hash_session_token('alice_s1'), alice_id, expires); await query_create_session(deps, hash_session_token('alice_s2'), alice_id, expires); @@ -427,7 +433,7 @@ describe_db('AuthSessionQueries', (get_db) => { test(`enforce_session_limit matrix: ${name}`, async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, `matrix_${name.replaceAll(' ', '_')}`); + const {account_id} = await create_test_account(db, `matrix_${name.replaceAll(' ', '_')}`); const expires = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); for (let i = 0; i < session_count; i++) { @@ -454,8 +460,14 @@ describe_db('AuthSessionQueries', (get_db) => { test(`revoke_for_account IDOR: ${name}`, async () => { const db = get_db(); const deps = {db}; - const owner_id = await create_test_account(db, `idor_owner_${name.replaceAll(' ', '_')}`); - const other_id = await create_test_account(db, `idor_other_${name.replaceAll(' ', '_')}`); + const {account_id: owner_id} = await create_test_account( + db, + `idor_owner_${name.replaceAll(' ', '_')}`, + ); + const {account_id: other_id} = await create_test_account( + db, + `idor_other_${name.replaceAll(' ', '_')}`, + ); const token = generate_session_token(); const token_hash = hash_session_token(token); await query_create_session( @@ -481,7 +493,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('enforce_session_limit counts expired sessions toward the limit', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'limit_expired'); + const {account_id} = await create_test_account(db, 'limit_expired'); const past = new Date(Date.now() - 1000); const future = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); // 2 expired + 1 active = 3 total @@ -503,8 +515,8 @@ describe_db('AuthSessionQueries', (get_db) => { test('list_all_active returns active sessions with username, excludes expired', async () => { const db = get_db(); const deps = {db}; - const alice_id = await create_test_account(db, 'active_alice'); - const bob_id = await create_test_account(db, 'active_bob'); + const {account_id: alice_id} = await create_test_account(db, 'active_alice'); + const {account_id: bob_id} = await create_test_account(db, 'active_bob'); const future = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); const past = new Date(Date.now() - 1000); @@ -528,7 +540,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('touch does not extend expiry when session has plenty of time remaining', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'touch_no_extend'); + const {account_id} = await create_test_account(db, 'touch_no_extend'); const token_hash = hash_session_token('far_future'); // Expires 30 days from now — well above the 1 day threshold const far_future = new Date(Date.now() + AUTH_SESSION_LIFETIME_MS); @@ -553,7 +565,7 @@ describe_db('AuthSessionQueries', (get_db) => { test('touch extends expiry when session expires within 1 day', async () => { const db = get_db(); const deps = {db}; - const account_id = await create_test_account(db, 'touch_extend'); + const {account_id} = await create_test_account(db, 'touch_extend'); const token_hash = hash_session_token('near_expiry'); // Expires in 30 minutes — well under the 1 day threshold const near_expiry = new Date(Date.now() + 30 * 60 * 1000); diff --git a/src/test/auth/standard_rpc_actions.test.ts b/src/test/auth/standard_rpc_actions.test.ts index da0d9de5..552a1dde 100644 --- a/src/test/auth/standard_rpc_actions.test.ts +++ b/src/test/auth/standard_rpc_actions.test.ts @@ -143,7 +143,7 @@ describe('create_standard_rpc_actions', () => { const actions = create_standard_rpc_actions(deps, { app_settings: make_app_settings(), authorize: async (auth, input) => { - calls.push({actor_id: auth.actor.id, role: input.role, scope_id: input.scope_id}); + calls.push({actor_id: auth.actor!.id, role: input.role, scope_id: input.scope_id}); return false; }, }); diff --git a/src/test/http/common_routes.test.ts b/src/test/http/common_routes.test.ts index e9014a82..8d8298d4 100644 --- a/src/test/http/common_routes.test.ts +++ b/src/test/http/common_routes.test.ts @@ -20,6 +20,7 @@ import {apply_route_specs} from '$lib/http/route_spec.js'; import {fuz_auth_guard_resolver} from '$lib/auth/route_guards.js'; import type {AppSurface} from '$lib/http/surface.js'; import {REQUEST_CONTEXT_KEY, type RequestContext} from '$lib/auth/request_context.js'; +import {ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; import {create_stub_db} from '$lib/testing/stubs.js'; import {create_test_context} from '$lib/testing/entities.js'; @@ -37,7 +38,9 @@ const create_test_app = ( const app = new Hono(); if (auth_ctx) { app.use('/*', async (c, next) => { + (c as any).set(ACCOUNT_ID_KEY, auth_ctx.account.id); (c as any).set(REQUEST_CONTEXT_KEY, auth_ctx); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); } diff --git a/src/test/http/db_routes.db.test.ts b/src/test/http/db_routes.db.test.ts index 14fbc7c1..0021f28a 100644 --- a/src/test/http/db_routes.db.test.ts +++ b/src/test/http/db_routes.db.test.ts @@ -15,7 +15,7 @@ import {apply_route_specs, type RouteSpec} from '$lib/http/route_spec.js'; import {fuz_auth_guard_resolver} from '$lib/auth/route_guards.js'; import {REQUEST_CONTEXT_KEY, type RequestContext} from '$lib/auth/request_context.js'; import {create_test_context} from '$lib/testing/entities.js'; -import {CREDENTIAL_TYPE_KEY} from '$lib/hono_context.js'; +import {ACCOUNT_ID_KEY, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; import type {Db} from '$lib/db/db.js'; import {run_migrations} from '$lib/db/migrate.js'; import {AUTH_MIGRATION_NS} from '$lib/auth/migrations.js'; @@ -37,7 +37,9 @@ const keeper_ctx: RequestContext = create_test_context([{role: 'keeper'}]); const create_test_app = (specs: Array) => { const app = new Hono(); app.use('/*', async (c, next) => { + c.set(ACCOUNT_ID_KEY, keeper_ctx.account.id); c.set(REQUEST_CONTEXT_KEY, keeper_ctx); + c.set(TEST_CONTEXT_PRESET_KEY, true); c.set(CREDENTIAL_TYPE_KEY, 'daemon_token'); await next(); }); diff --git a/src/test/http/error_schemas.test.ts b/src/test/http/error_schemas.test.ts index d4c93abe..b8e63394 100644 --- a/src/test/http/error_schemas.test.ts +++ b/src/test/http/error_schemas.test.ts @@ -50,6 +50,14 @@ import { ERROR_INVITE_DUPLICATE, ERROR_INVITE_ACCOUNT_EXISTS_USERNAME, ERROR_INVITE_ACCOUNT_EXISTS_EMAIL, + ERROR_ACTOR_REQUIRED, + ERROR_ACTOR_NOT_ON_ACCOUNT, + ERROR_NO_ACTORS_ON_ACCOUNT, + ERROR_ACCOUNT_VANISHED, + ActorRequiredError, + ActorNotOnAccountError, + NoActorsOnAccountError, + AccountVanishedError, } from '$lib/http/error_schemas.js'; describe('standard error schemas', () => { @@ -129,30 +137,30 @@ describe('standard error schemas', () => { describe('derive_error_schemas', () => { test('auth none + no input derives no errors', () => { - const errors = derive_error_schemas({type: 'none'}, false); + const errors = derive_error_schemas({auth: {type: 'none'}}); assert.deepStrictEqual(errors, {}); }); test('auth none + has input derives 400', () => { - const errors = derive_error_schemas({type: 'none'}, true); + const errors = derive_error_schemas({auth: {type: 'none'}, has_input: true}); assert.ok(errors[400]); assert.strictEqual(errors[401], undefined); }); test('auth authenticated derives 401', () => { - const errors = derive_error_schemas({type: 'authenticated'}, false); + const errors = derive_error_schemas({auth: {type: 'authenticated'}}); assert.ok(errors[401]); assert.strictEqual(errors[403], undefined); }); test('auth authenticated + has input derives 400 and 401', () => { - const errors = derive_error_schemas({type: 'authenticated'}, true); + const errors = derive_error_schemas({auth: {type: 'authenticated'}, has_input: true}); assert.ok(errors[400]); assert.ok(errors[401]); }); test('auth role derives 401 and 403 with PermissionError', () => { - const errors = derive_error_schemas({type: 'role', role: 'admin'}, false); + const errors = derive_error_schemas({auth: {type: 'role', role: 'admin'}}); assert.ok(errors[401]); assert.ok(errors[403]); // Verify the 403 schema is PermissionError (accepts required_role) @@ -164,7 +172,7 @@ describe('derive_error_schemas', () => { }); test('auth keeper derives 401 and 403 with KeeperError', () => { - const errors = derive_error_schemas({type: 'keeper'}, false); + const errors = derive_error_schemas({auth: {type: 'keeper'}}); assert.ok(errors[401]); assert.ok(errors[403]); // Verify the 403 schema is KeeperError (accepts credential_type) @@ -176,33 +184,37 @@ describe('derive_error_schemas', () => { }); test('does not auto-derive 429 without rate_limit', () => { - const errors = derive_error_schemas({type: 'none'}, true); + const errors = derive_error_schemas({auth: {type: 'none'}, has_input: true}); assert.strictEqual(errors[429], undefined); }); test('rate_limit ip derives 429', () => { - const errors = derive_error_schemas({type: 'none'}, false, false, false, 'ip'); + const errors = derive_error_schemas({auth: {type: 'none'}, rate_limit: 'ip'}); assert.ok(errors[429]); }); test('rate_limit account derives 429', () => { - const errors = derive_error_schemas({type: 'none'}, false, false, false, 'account'); + const errors = derive_error_schemas({auth: {type: 'none'}, rate_limit: 'account'}); assert.ok(errors[429]); }); test('rate_limit both derives 429', () => { - const errors = derive_error_schemas({type: 'none'}, true, false, false, 'both'); + const errors = derive_error_schemas({ + auth: {type: 'none'}, + has_input: true, + rate_limit: 'both', + }); assert.ok(errors[400]); assert.ok(errors[429]); }); test('has_params derives 400', () => { - const errors = derive_error_schemas({type: 'none'}, false, true); + const errors = derive_error_schemas({auth: {type: 'none'}, has_params: true}); assert.ok(errors[400]); }); test('has_query derives 400', () => { - const errors = derive_error_schemas({type: 'none'}, false, false, true); + const errors = derive_error_schemas({auth: {type: 'none'}, has_query: true}); assert.ok(errors[400]); }); }); @@ -284,3 +296,100 @@ describe('error code constants', () => { ); }); }); + +describe('authorization-phase actor error schemas', () => { + test('ActorRequiredError accepts available[] with id+name entries', () => { + const result = ActorRequiredError.safeParse({ + error: ERROR_ACTOR_REQUIRED, + available: [{id: '00000000-0000-4000-8000-000000000001', name: 'alice'}], + }); + assert.isTrue(result.success); + }); + + test('ActorRequiredError rejects missing available', () => { + const result = ActorRequiredError.safeParse({error: ERROR_ACTOR_REQUIRED}); + assert.isFalse(result.success); + }); + + test('ActorNotOnAccountError accepts the literal-only shape', () => { + const result = ActorNotOnAccountError.safeParse({error: ERROR_ACTOR_NOT_ON_ACCOUNT}); + assert.isTrue(result.success); + }); + + test('NoActorsOnAccountError accepts the literal-only shape', () => { + const result = NoActorsOnAccountError.safeParse({error: ERROR_NO_ACTORS_ON_ACCOUNT}); + assert.isTrue(result.success); + }); + + test('AccountVanishedError accepts the literal-only shape', () => { + const result = AccountVanishedError.safeParse({error: ERROR_ACCOUNT_VANISHED}); + assert.isTrue(result.success); + }); + + test('derive_error_schemas with acting_aware folds actor errors into 400 + 500', () => { + const errors = derive_error_schemas({ + auth: {type: 'authenticated'}, + has_input: true, + acting_aware: true, + }); + // 400 union accepts ValidationError + actor 400 shapes. + assert.ok(errors[400]); + const validation_match = errors[400].safeParse({ + error: ERROR_INVALID_REQUEST_BODY, + issues: [], + }); + assert.isTrue(validation_match.success); + const actor_required_match = errors[400].safeParse({ + error: ERROR_ACTOR_REQUIRED, + available: [], + }); + assert.isTrue(actor_required_match.success); + const actor_not_on_account_match = errors[400].safeParse({ + error: ERROR_ACTOR_NOT_ON_ACCOUNT, + }); + assert.isTrue(actor_not_on_account_match.success); + // 500 union accepts both invariant + torn-read shapes. + assert.ok(errors[500]); + const no_actors_match = errors[500].safeParse({error: ERROR_NO_ACTORS_ON_ACCOUNT}); + assert.isTrue(no_actors_match.success); + const account_vanished_match = errors[500].safeParse({error: ERROR_ACCOUNT_VANISHED}); + assert.isTrue(account_vanished_match.success); + }); + + test('derive_error_schemas without acting_aware leaves 400 narrow and omits 500', () => { + const errors = derive_error_schemas({ + auth: {type: 'authenticated'}, + has_input: true, + }); + assert.ok(errors[400]); + const validation_match = errors[400].safeParse({ + error: ERROR_INVALID_REQUEST_BODY, + issues: [], + }); + assert.isTrue(validation_match.success); + const actor_required_match = errors[400].safeParse({ + error: ERROR_ACTOR_REQUIRED, + available: [], + }); + // `error` is a literal string in ValidationError, so a fresh literal + // passes — but `issues` is required, so without it the parse fails. + assert.isFalse(actor_required_match.success); + assert.strictEqual(errors[500], undefined); + }); + + test('derive_error_schemas acting_aware with no validation still emits 400 + 500', () => { + // Parameterless acting-aware route (no input/params/query) — auth phase + // can still emit actor errors before the (empty) input validation step. + const errors = derive_error_schemas({ + auth: {type: 'role', role: 'admin'}, + acting_aware: true, + }); + assert.ok(errors[400]); + const actor_required_match = errors[400].safeParse({ + error: ERROR_ACTOR_REQUIRED, + available: [], + }); + assert.isTrue(actor_required_match.success); + assert.ok(errors[500]); + }); +}); diff --git a/src/test/http/route_spec.test.ts b/src/test/http/route_spec.test.ts index 2a09ccd9..bd65643a 100644 --- a/src/test/http/route_spec.test.ts +++ b/src/test/http/route_spec.test.ts @@ -22,6 +22,7 @@ import {generate_app_surface, events_to_surface} from '$lib/http/surface.js'; import {middleware_applies, schema_to_surface} from '$lib/http/schema_helpers.js'; import type {EventSpec} from '$lib/realtime/sse.js'; import {REQUEST_CONTEXT_KEY} from '$lib/auth/request_context.js'; +import {ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY} from '$lib/hono_context.js'; import {create_test_request_context} from '$lib/testing/auth_apps.js'; import {ApiError, RateLimitError} from '$lib/http/error_schemas.js'; import {create_stub_db} from '$lib/testing/stubs.js'; @@ -113,7 +114,10 @@ describe('apply_route_specs', () => { const app = new Hono(); // Set request context before the route app.use('/*', async (c, next) => { - (c as any).set(REQUEST_CONTEXT_KEY, create_test_request_context()); + const ctx = create_test_request_context(); + (c as any).set(ACCOUNT_ID_KEY, ctx.account.id); + (c as any).set(REQUEST_CONTEXT_KEY, ctx); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); const specs: Array = [ @@ -155,7 +159,10 @@ describe('apply_route_specs', () => { test('auth role returns 403 when wrong role', async () => { const app = new Hono(); app.use('/*', async (c, next) => { - (c as any).set(REQUEST_CONTEXT_KEY, create_test_request_context('viewer')); + const ctx = create_test_request_context('viewer'); + (c as any).set(ACCOUNT_ID_KEY, ctx.account.id); + (c as any).set(REQUEST_CONTEXT_KEY, ctx); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); const specs: Array = [ @@ -178,7 +185,10 @@ describe('apply_route_specs', () => { test('auth role passes with correct role', async () => { const app = new Hono(); app.use('/*', async (c, next) => { - (c as any).set(REQUEST_CONTEXT_KEY, create_test_request_context('admin')); + const ctx = create_test_request_context('admin'); + (c as any).set(ACCOUNT_ID_KEY, ctx.account.id); + (c as any).set(REQUEST_CONTEXT_KEY, ctx); + (c as any).set(TEST_CONTEXT_PRESET_KEY, true); await next(); }); const specs: Array = [ @@ -889,6 +899,83 @@ describe('input validation', () => { `expected 400 or 415 for multipart, got ${res.status}`, ); }); + + test('cached_request_body: malformed JSON still produces ERROR_INVALID_JSON_BODY through the cached path', async () => { + // `read_raw_acting` runs before `create_input_validation` when the + // input declares `acting?: ActingActor`. It pre-parses the body + // and writes the failure flag to `c.var.cached_request_body`. The + // input-validation step then short-circuits on that flag without + // re-parsing — final response shape must be identical to the + // no-acting path. + const {ActingActor} = await import('$lib/auth/account_schema.js'); + const {create_fuz_authorization_handler} = await import('$lib/auth/request_context.js'); + const {ERROR_INVALID_JSON_BODY} = await import('$lib/http/error_schemas.js'); + + const app = new Hono(); + const specs: Array = [ + { + method: 'POST', + path: '/test', + auth: {type: 'none'}, + description: 'acting-aware test route', + input: z.strictObject({acting: ActingActor, name: z.string()}), + output: z.strictObject({ok: z.literal(true)}), + handler: async (c) => c.json({ok: true}), + }, + ]; + apply_route_specs( + app, + specs, + fuz_auth_guard_resolver, + log, + db, + create_fuz_authorization_handler({db}), + ); + + const res = await app.request('/test', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: '{this is not json', + }); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.strictEqual(body.error, ERROR_INVALID_JSON_BODY); + }); + + test('cached_request_body: valid body parses once, cached value drives input validation', async () => { + const {ActingActor} = await import('$lib/auth/account_schema.js'); + const {create_fuz_authorization_handler} = await import('$lib/auth/request_context.js'); + + const app = new Hono(); + const specs: Array = [ + { + method: 'POST', + path: '/test', + auth: {type: 'none'}, + description: 'acting-aware happy-path', + input: z.strictObject({acting: ActingActor, name: z.string()}), + output: z.strictObject({ok: z.literal(true)}), + handler: async (c) => c.json({ok: true}), + }, + ]; + apply_route_specs( + app, + specs, + fuz_auth_guard_resolver, + log, + db, + create_fuz_authorization_handler({db}), + ); + + const res = await app.request('/test', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name: 'test'}), + }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.ok, true); + }); }); describe('GET body validation guard', () => { @@ -1045,11 +1132,13 @@ describe('error catch layer', () => { const res = await app.request('/test'); assert.strictEqual(res.status, 404); const body = await res.json(); - assert.strictEqual(body.error.code, JSONRPC_ERROR_CODES.not_found as number); - assert.strictEqual(body.error.message, 'user not found'); + // REST flat shape: `error` is the reason name (from the JSON-RPC code), + // `message` carries the human message from the throw site. + assert.strictEqual(body.error, 'not_found'); + assert.strictEqual(body.message, 'user not found'); }); - test('ThrownJsonrpcError with data includes data in response', async () => { + test('ThrownJsonrpcError with data flattens data under the response body', async () => { const app = new Hono(); const specs: Array = [ { @@ -1071,12 +1160,13 @@ describe('error catch layer', () => { const res = await app.request('/test', {method: 'POST'}); assert.strictEqual(res.status, 409); const body = await res.json(); - assert.strictEqual(body.error.code, JSONRPC_ERROR_CODES.conflict as number); - assert.strictEqual(body.error.message, 'duplicate'); - assert.deepStrictEqual(body.error.data, {field: 'email'}); + assert.strictEqual(body.error, 'conflict'); + assert.strictEqual(body.message, 'duplicate'); + // Non-`reason` data fields flatten alongside `error` / `message`. + assert.strictEqual(body.field, 'email'); }); - test('ThrownJsonrpcError without data omits data from response', async () => { + test('ThrownJsonrpcError without data omits extras from response', async () => { const app = new Hono(); const specs: Array = [ { @@ -1096,8 +1186,45 @@ describe('error catch layer', () => { const res = await app.request('/test'); assert.strictEqual(res.status, 401); const body = await res.json(); - assert.strictEqual(body.error.code, JSONRPC_ERROR_CODES.unauthenticated as number); - assert.strictEqual(body.error.data, undefined); + assert.strictEqual(body.error, 'unauthenticated'); + // Default message equals the reason name, so the catch layer + // suppresses the redundant `message` field for the simple case. + assert.strictEqual(body.message, undefined); + // No data → no extras besides `error`. + assert.deepStrictEqual(Object.keys(body), ['error']); + }); + + test('data.reason overrides the code-derived reason on the REST body', async () => { + // Consumers that throw with a domain-specific reason + // (`{reason: ERROR_OFFER_TERMINAL}` etc.) should see that string + // land on `body.error` instead of the generic JSON-RPC name. + const app = new Hono(); + const specs: Array = [ + { + method: 'POST', + path: '/test', + auth: {type: 'none'}, + handler: () => { + throw jsonrpc_errors.conflict('offer already terminal', { + reason: 'offer_terminal', + offer_id: 'offer-1', + }); + }, + description: 'Test', + input: z.null(), + output: z.null(), + }, + ]; + apply_route_specs(app, specs, fuz_auth_guard_resolver, log, db); + + const res = await app.request('/test', {method: 'POST'}); + assert.strictEqual(res.status, 409); + const body = await res.json(); + assert.strictEqual(body.error, 'offer_terminal'); + assert.strictEqual(body.message, 'offer already terminal'); + assert.strictEqual(body.offer_id, 'offer-1'); + // `reason` is consumed into `error` and not duplicated. + assert.strictEqual(body.reason, undefined); }); test('generic Error maps to internal_error 500 with message in DEV', async () => { @@ -1120,9 +1247,9 @@ describe('error catch layer', () => { const res = await app.request('/test'); assert.strictEqual(res.status, 500); const body = await res.json(); - assert.strictEqual(body.error.code, JSONRPC_ERROR_CODES.internal_error as number); + assert.strictEqual(body.error, 'internal_error'); // DEV is true in test environment — error message is included - assert.strictEqual(body.error.message, 'something broke'); + assert.strictEqual(body.message, 'something broke'); }); test('handler that returns normally is unaffected by catch layer', async () => { diff --git a/src/test/rate_limiter.handlers.test.ts b/src/test/rate_limiter.handlers.test.ts index 5044f091..6d291da8 100644 --- a/src/test/rate_limiter.handlers.test.ts +++ b/src/test/rate_limiter.handlers.test.ts @@ -35,7 +35,7 @@ const { mock_session_enforce_limit, mock_validate_api_token, mock_account_by_id, - mock_actor_by_account, + mock_resolve_actor, mock_permit_find_active, mock_invite_find_unclaimed_match, mock_invite_claim, @@ -46,7 +46,7 @@ const { mock_session_enforce_limit: vi.fn((..._args: Array) => Promise.resolve(0)), mock_validate_api_token: vi.fn((..._args: Array) => Promise.resolve(undefined)), mock_account_by_id: vi.fn((..._args: Array): Promise => Promise.resolve(null)), - mock_actor_by_account: vi.fn((..._args: Array): Promise => Promise.resolve(null)), + mock_resolve_actor: vi.fn((..._args: Array): Promise => Promise.resolve(null)), mock_permit_find_active: vi.fn((..._args: Array): Promise => Promise.resolve([])), mock_invite_find_unclaimed_match: vi.fn( (..._args: Array): Promise => Promise.resolve(null), @@ -61,7 +61,11 @@ const { vi.mock('$lib/auth/account_queries.js', () => ({ query_account_by_username_or_email: (...a: Array) => mock_find_by_username_or_email(...a), query_account_by_id: (...a: Array) => mock_account_by_id(...a), - query_actor_by_account: (...a: Array) => mock_actor_by_account(...a), + query_actor_by_id: (...a: Array) => mock_resolve_actor(...a), + query_actors_by_account: async (..._a: Array) => { + const actor = await mock_resolve_actor(..._a); + return actor ? [actor] : []; + }, query_create_account_with_actor: (...a: Array) => mock_create_account_with_actor(...a), })); @@ -147,6 +151,14 @@ const create_login_app = ( mock_find_by_username_or_email.mockReset().mockImplementation(() => Promise.resolve(null)); mock_session_create.mockReset().mockImplementation(() => Promise.resolve()); mock_session_enforce_limit.mockReset().mockImplementation(() => Promise.resolve(0)); + // Login itself is account-only, but the request_context middleware that + // runs on any subsequent /api request resolves the acting actor on the + // authenticated account. Default to a valid actor so the post-login path + // works in tests that exercise it; tests of the missing-actor branch + // override per-test. + mock_resolve_actor + .mockReset() + .mockImplementation(() => Promise.resolve({id: 'actor_login_test'})); const mock_verify_password = vi.fn(() => Promise.resolve(false)); const mock_verify_dummy = vi.fn(() => Promise.resolve(false)); @@ -206,7 +218,7 @@ const create_bearer_app = (ip_rate_limiter: RateLimiter | null): BearerTestApp = const mock_validate = vi.fn(() => Promise.resolve(undefined)); mock_validate_api_token.mockReset().mockImplementation(mock_validate); mock_account_by_id.mockReset().mockImplementation(() => Promise.resolve(null)); - mock_actor_by_account.mockReset().mockImplementation(() => Promise.resolve(null)); + mock_resolve_actor.mockReset().mockImplementation(() => Promise.resolve(null)); mock_permit_find_active.mockReset().mockImplementation(() => Promise.resolve([])); const bearer_middleware = create_bearer_auth_middleware({db}, ip_rate_limiter, log); @@ -868,7 +880,9 @@ describe('bearer auth rate limiting', () => { const mock_find_by_id = vi.fn((): Promise => Promise.resolve(null)); mock_validate_api_token.mockReset().mockImplementation(mock_validate); mock_account_by_id.mockReset().mockImplementation(mock_find_by_id); - mock_actor_by_account.mockReset().mockImplementation(() => Promise.resolve({id: 'actor_1'})); + mock_resolve_actor + .mockReset() + .mockImplementation(() => Promise.resolve({id: 'actor_1', account_id: 'acc_1'})); mock_permit_find_active.mockReset().mockImplementation(() => Promise.resolve([])); const bearer_middleware = create_bearer_auth_middleware({db}, limiter, log); diff --git a/src/test/server/auth_flow.integration.db.test.ts b/src/test/server/auth_flow.integration.db.test.ts index 64e85bf0..729a1118 100644 --- a/src/test/server/auth_flow.integration.db.test.ts +++ b/src/test/server/auth_flow.integration.db.test.ts @@ -37,7 +37,7 @@ const create_authenticated_route_spec = (): RouteSpec => ({ output: z.looseObject({username: z.string(), actor_id: z.string()}), handler: (c) => { const ctx = require_request_context(c); - return c.json({username: ctx.account.username, actor_id: ctx.actor.id}); + return c.json({username: ctx.account.username, actor_id: ctx.actor?.id ?? null}); }, }); @@ -122,7 +122,9 @@ describe('auth flow integration', () => { assert.strictEqual(res.status, 200); const body = await res.json(); assert.strictEqual(body.username, test_server.account.username); - assert.strictEqual(body.actor_id, test_server.actor.id); + // Account-grain auth has no resolved actor — the route does not + // declare `acting` and its auth doesn't require permits. + assert.strictEqual(body.actor_id, null); }); // --- (c) Authenticated route without session cookie returns 401 --- @@ -230,7 +232,7 @@ describe('auth flow integration', () => { assert.strictEqual(res.status, 200); const body = await res.json(); assert.strictEqual(body.username, test_server.account.username); - assert.strictEqual(body.actor_id, test_server.actor.id); + assert.strictEqual(body.actor_id, null); }); test('authenticated route with invalid bearer token returns 401', async () => { @@ -305,7 +307,7 @@ describe('auth flow integration', () => { assert.strictEqual(res.status, 200); const body = await res.json(); assert.strictEqual(body.username, test_server.account.username); - assert.strictEqual(body.actor_id, test_server.actor.id); + assert.strictEqual(body.actor_id, null); }); // --- (i) Error response information leakage --- diff --git a/src/test/testing/test_auth_surface.test.ts b/src/test/testing/test_auth_surface.test.ts index 128cb2ef..6f691a8d 100644 --- a/src/test/testing/test_auth_surface.test.ts +++ b/src/test/testing/test_auth_surface.test.ts @@ -117,6 +117,7 @@ describe('create_test_request_context', () => { test('account and actor IDs are consistent', () => { const ctx = create_test_request_context(); + assert.ok(ctx.actor !== null); assert.strictEqual(ctx.actor.account_id, ctx.account.id); }); }); diff --git a/src/test/ui/admin_accounts_state.svelte.test.ts b/src/test/ui/admin_accounts_state.svelte.test.ts index 94ccae9d..c842817d 100644 --- a/src/test/ui/admin_accounts_state.svelte.test.ts +++ b/src/test/ui/admin_accounts_state.svelte.test.ts @@ -29,6 +29,7 @@ const make_offer = (overrides: Partial = {}): PermitOfferJson = id: 'offer-x' as PermitOfferJson['id'], from_actor_id: 'actor-admin' as PermitOfferJson['from_actor_id'], to_account_id: 'acct-1' as PermitOfferJson['to_account_id'], + to_actor_id: null, role: 'admin', scope_id: null, message: null, @@ -206,6 +207,42 @@ describe('AdminAccountsState.grant_permit', () => { assert.strictEqual(offer, undefined); assert.strictEqual(state.error, 'rpc adapter not wired'); }); + + test('forwards to_actor_id to rpc.grant_permit when supplied', async () => { + const target_actor = 'actor-target' as Uuid; + const rpc = make_rpc(); + const state = new AdminAccountsState({get_rpc: () => rpc}); + + await state.grant_permit(acct_1, 'admin', target_actor); + + assert.deepStrictEqual((rpc.grant_permit as ReturnType).mock.calls[0]![0], { + to_account_id: acct_1, + role: 'admin', + to_actor_id: target_actor, + }); + }); + + test('granting_keys uses 3-segment shape for actor-grain offers', async () => { + const target_actor = 'actor-target' as Uuid; + let resolve_fn: (v: {offer: PermitOfferJson}) => void; + const rpc = make_rpc(); + (rpc.grant_permit as ReturnType).mockReturnValueOnce( + new Promise<{offer: PermitOfferJson}>((resolve) => { + resolve_fn = resolve; + }), + ); + + const state = new AdminAccountsState({get_rpc: () => rpc}); + const grant_promise = state.grant_permit(acct_1, 'admin', target_actor); + assert.ok(state.granting_keys.has(`acct-1:admin:${target_actor}`)); + assert.ok( + !state.granting_keys.has('acct-1:admin'), + 'account-grain key must not collide with the actor-grain key', + ); + resolve_fn!({offer: make_offer()}); + await grant_promise; + assert.ok(!state.granting_keys.has(`acct-1:admin:${target_actor}`)); + }); }); describe('AdminAccountsState.revoke_permit', () => { diff --git a/src/test/ui/admin_sessions_state.svelte.test.ts b/src/test/ui/admin_sessions_state.svelte.test.ts index f16c8366..0d64720c 100644 --- a/src/test/ui/admin_sessions_state.svelte.test.ts +++ b/src/test/ui/admin_sessions_state.svelte.test.ts @@ -29,6 +29,7 @@ const make_offer = (overrides: Partial = {}): PermitOfferJson = id: 'offer-x' as PermitOfferJson['id'], from_actor_id: 'actor-admin' as PermitOfferJson['from_actor_id'], to_account_id: 'acct-1' as PermitOfferJson['to_account_id'], + to_actor_id: null, role: 'admin', scope_id: null, message: null, diff --git a/src/test/ui/permit_offers_state.svelte.test.ts b/src/test/ui/permit_offers_state.svelte.test.ts index ec99f42c..c32edbfc 100644 --- a/src/test/ui/permit_offers_state.svelte.test.ts +++ b/src/test/ui/permit_offers_state.svelte.test.ts @@ -37,6 +37,7 @@ const pending_offer = (overrides: Partial = {}): PermitOfferJso id: next_uuid() as PermitOfferJson['id'], from_actor_id: GRANTOR_ACTOR_ID as PermitOfferJson['from_actor_id'], to_account_id: RECIPIENT_ID as PermitOfferJson['to_account_id'], + to_actor_id: null, role: 'admin', scope_id: null, message: null, @@ -324,6 +325,35 @@ describe('PermitOffersState — mutations', () => { assert.strictEqual(state.outgoing[0]!.id, offer.id); }); + test('create forwards to_actor_id to the rpc and stamps the returned actor-grain offer', async () => { + const target_actor_id = next_uuid(); + const captured: {params: Parameters[0] | null} = {params: null}; + const offer = pending_offer({ + to_account_id: OTHER_RECIPIENT_ID as PermitOfferJson['to_account_id'], + to_actor_id: target_actor_id as PermitOfferJson['to_actor_id'], + }); + const state = create_state({ + create: async (params) => { + captured.params = params; + return {offer}; + }, + }); + + await state.create({ + to_account_id: OTHER_RECIPIENT_ID, + to_actor_id: target_actor_id, + role: 'admin', + }); + + assert.deepStrictEqual(captured.params, { + to_account_id: OTHER_RECIPIENT_ID, + to_actor_id: target_actor_id, + role: 'admin', + }); + assert.strictEqual(state.outgoing.length, 1); + assert.strictEqual(state.outgoing[0]!.to_actor_id, target_actor_id); + }); + test('accept eagerly drops superseded siblings', async () => { const target = pending_offer(); const sibling = pending_offer();