Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/dark-bottles-juggle.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 23 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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()`
Expand Down
52 changes: 41 additions & 11 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,
Expand All @@ -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`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: <reason>, 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
Expand Down Expand Up @@ -326,7 +356,7 @@ Per-request `Array<Promise<void>>` 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<RouteContext, 'background_db' | 'pending_effects'>`, 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<AppDeps, 'log' | 'on_audit_event' | 'audit_log_config'>` 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<RouteContext, 'background_db' | 'pending_effects'>`, 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<AppDeps, 'log' | 'on_audit_event' | 'audit_log_config'>` 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`)

Expand Down
27 changes: 18 additions & 9 deletions docs/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 8 additions & 6 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading