@fuzdev/fuz_app 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +630 -0
- package/dist/actions/action_rpc.d.ts +29 -0
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +42 -6
- package/dist/actions/action_types.d.ts +2 -2
- package/dist/actions/cancel.d.ts +12 -13
- package/dist/actions/cancel.d.ts.map +1 -1
- package/dist/actions/cancel.js +10 -13
- package/dist/actions/heartbeat.d.ts +8 -13
- package/dist/actions/heartbeat.d.ts.map +1 -1
- package/dist/actions/heartbeat.js +5 -8
- package/dist/actions/register_action_ws.d.ts +3 -3
- package/dist/actions/register_action_ws.js +2 -2
- package/dist/actions/register_ws_endpoint.d.ts +4 -4
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +3 -3
- package/dist/actions/socket.svelte.d.ts +16 -16
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +15 -15
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +923 -0
- package/dist/auth/account_action_specs.d.ts +216 -0
- package/dist/auth/account_action_specs.d.ts.map +1 -0
- package/dist/auth/account_action_specs.js +159 -0
- package/dist/auth/account_actions.d.ts +51 -0
- package/dist/auth/account_actions.d.ts.map +1 -0
- package/dist/auth/account_actions.js +119 -0
- package/dist/auth/account_queries.d.ts +6 -2
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +40 -4
- package/dist/auth/account_routes.d.ts +94 -16
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +108 -180
- package/dist/auth/account_schema.d.ts +85 -30
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +40 -8
- package/dist/auth/admin_action_specs.d.ts +674 -0
- package/dist/auth/admin_action_specs.d.ts.map +1 -0
- package/dist/auth/admin_action_specs.js +287 -0
- package/dist/auth/admin_actions.d.ts +69 -0
- package/dist/auth/admin_actions.d.ts.map +1 -0
- package/dist/auth/admin_actions.js +256 -0
- package/dist/auth/api_token.d.ts +10 -0
- package/dist/auth/api_token.d.ts.map +1 -1
- package/dist/auth/api_token.js +9 -0
- package/dist/auth/api_token_queries.d.ts +3 -3
- package/dist/auth/api_token_queries.js +3 -3
- package/dist/auth/app_settings_schema.d.ts +4 -3
- package/dist/auth/app_settings_schema.d.ts.map +1 -1
- package/dist/auth/app_settings_schema.js +2 -1
- package/dist/auth/audit_log_routes.d.ts +14 -6
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +22 -79
- package/dist/auth/audit_log_schema.d.ts +100 -29
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +83 -11
- package/dist/auth/bootstrap_routes.d.ts +14 -0
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +10 -3
- package/dist/auth/cleanup.d.ts +63 -0
- package/dist/auth/cleanup.d.ts.map +1 -0
- package/dist/auth/cleanup.js +80 -0
- package/dist/auth/invite_schema.d.ts +11 -10
- package/dist/auth/invite_schema.d.ts.map +1 -1
- package/dist/auth/invite_schema.js +4 -3
- package/dist/auth/migrations.d.ts +6 -0
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +28 -0
- package/dist/auth/permit_offer_action_specs.d.ts +364 -0
- package/dist/auth/permit_offer_action_specs.d.ts.map +1 -0
- package/dist/auth/permit_offer_action_specs.js +216 -0
- package/dist/auth/permit_offer_actions.d.ts +96 -0
- package/dist/auth/permit_offer_actions.d.ts.map +1 -0
- package/dist/auth/permit_offer_actions.js +428 -0
- package/dist/auth/permit_offer_notifications.d.ts +361 -0
- package/dist/auth/permit_offer_notifications.d.ts.map +1 -0
- package/dist/auth/permit_offer_notifications.js +179 -0
- package/dist/auth/permit_offer_queries.d.ts +165 -0
- package/dist/auth/permit_offer_queries.d.ts.map +1 -0
- package/dist/auth/permit_offer_queries.js +390 -0
- package/dist/auth/permit_offer_schema.d.ts +103 -0
- package/dist/auth/permit_offer_schema.d.ts.map +1 -0
- package/dist/auth/permit_offer_schema.js +142 -0
- package/dist/auth/permit_queries.d.ts +77 -14
- package/dist/auth/permit_queries.d.ts.map +1 -1
- package/dist/auth/permit_queries.js +119 -24
- package/dist/auth/session_queries.d.ts +4 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +4 -2
- package/dist/auth/signup_routes.d.ts +13 -0
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +14 -7
- package/dist/http/CLAUDE.md +584 -0
- package/dist/http/pending_effects.d.ts +29 -0
- package/dist/http/pending_effects.d.ts.map +1 -0
- package/dist/http/pending_effects.js +31 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +4 -3
- package/dist/rate_limiter.d.ts +30 -0
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +25 -2
- package/dist/realtime/sse_auth_guard.d.ts +2 -0
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +5 -3
- package/dist/testing/CLAUDE.md +668 -1
- package/dist/testing/admin_integration.d.ts +10 -7
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +382 -482
- package/dist/testing/app_server.d.ts +7 -6
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/attack_surface.d.ts +9 -3
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/attack_surface.js +4 -4
- package/dist/testing/audit_completeness.d.ts +6 -0
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +158 -134
- package/dist/testing/auth_apps.d.ts.map +1 -1
- package/dist/testing/auth_apps.js +4 -33
- package/dist/testing/db.d.ts +1 -1
- package/dist/testing/db.d.ts.map +1 -1
- package/dist/testing/db.js +2 -0
- package/dist/testing/entities.d.ts +35 -13
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +17 -0
- package/dist/testing/integration.d.ts +10 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +352 -340
- package/dist/testing/integration_helpers.d.ts +16 -5
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +24 -4
- package/dist/testing/rate_limiting.d.ts +7 -0
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +41 -10
- package/dist/testing/rpc_helpers.d.ts +153 -1
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +184 -8
- package/dist/testing/sse_round_trip.d.ts +8 -0
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +10 -3
- package/dist/testing/standard.d.ts +9 -1
- package/dist/testing/standard.d.ts.map +1 -1
- package/dist/testing/standard.js +6 -2
- package/dist/testing/surface_invariants.d.ts +7 -3
- package/dist/testing/surface_invariants.d.ts.map +1 -1
- package/dist/testing/surface_invariants.js +5 -4
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +9 -38
- package/dist/ui/AccountSessions.svelte +8 -4
- package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
- package/dist/ui/AdminAccounts.svelte +61 -33
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/AdminAuditLog.svelte +3 -2
- package/dist/ui/AdminAuditLog.svelte.d.ts.map +1 -1
- package/dist/ui/AdminInvites.svelte +3 -2
- package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
- package/dist/ui/AdminOverview.svelte +14 -9
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/AdminPermitHistory.svelte +3 -2
- package/dist/ui/AdminPermitHistory.svelte.d.ts.map +1 -1
- package/dist/ui/AdminSessions.svelte +29 -25
- package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +351 -0
- package/dist/ui/OpenSignupToggle.svelte +6 -3
- package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
- package/dist/ui/PermitOfferForm.svelte +141 -0
- package/dist/ui/PermitOfferForm.svelte.d.ts +14 -0
- package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -0
- package/dist/ui/PermitOfferHistory.svelte +109 -0
- package/dist/ui/PermitOfferHistory.svelte.d.ts +11 -0
- package/dist/ui/PermitOfferHistory.svelte.d.ts.map +1 -0
- package/dist/ui/PermitOfferInbox.svelte +121 -0
- package/dist/ui/PermitOfferInbox.svelte.d.ts +12 -0
- package/dist/ui/PermitOfferInbox.svelte.d.ts.map +1 -0
- package/dist/ui/account_sessions_state.svelte.d.ts +53 -3
- package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.js +39 -16
- package/dist/ui/admin_accounts_state.svelte.d.ts +118 -2
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +99 -23
- package/dist/ui/admin_invites_state.svelte.d.ts +47 -1
- package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_invites_state.svelte.js +38 -26
- package/dist/ui/admin_sessions_state.svelte.d.ts +26 -0
- package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_sessions_state.svelte.js +35 -21
- package/dist/ui/app_settings_state.svelte.d.ts +39 -0
- package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
- package/dist/ui/app_settings_state.svelte.js +34 -18
- package/dist/ui/audit_log_state.svelte.d.ts +40 -3
- package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
- package/dist/ui/audit_log_state.svelte.js +36 -42
- package/dist/ui/auth_state.svelte.d.ts +4 -3
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +4 -1
- package/dist/ui/permit_offers_state.svelte.d.ts +125 -0
- package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -0
- package/dist/ui/permit_offers_state.svelte.js +197 -0
- package/package.json +3 -3
- package/dist/auth/admin_routes.d.ts +0 -29
- package/dist/auth/admin_routes.d.ts.map +0 -1
- package/dist/auth/admin_routes.js +0 -226
- package/dist/auth/app_settings_routes.d.ts +0 -27
- package/dist/auth/app_settings_routes.d.ts.map +0 -1
- package/dist/auth/app_settings_routes.js +0 -66
- package/dist/auth/invite_routes.d.ts +0 -18
- package/dist/auth/invite_routes.d.ts.map +0 -1
- package/dist/auth/invite_routes.js +0 -129
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
# auth/
|
|
2
|
+
|
|
3
|
+
> Auth domain: identity, crypto primitives, schema + DDL, queries, middleware, routes, RPC actions, cleanup.
|
|
4
|
+
|
|
5
|
+
Forty source files, grouped below by theme. For design rationale and threat
|
|
6
|
+
model, see `../../../docs/identity.md` and `../../../docs/security.md`. For the
|
|
7
|
+
subsystem's place in server assembly and middleware ordering, see
|
|
8
|
+
`../../../docs/architecture.md` and the root `../../../CLAUDE.md`.
|
|
9
|
+
|
|
10
|
+
The DI vocabulary is the stack standard: stateless capabilities in
|
|
11
|
+
`AppDeps` / `RouteFactoryDeps`; static config in `*Options`; runtime state
|
|
12
|
+
(e.g. `DaemonTokenState`, mutable `AppSettings` ref, `BootstrapStatus`) is
|
|
13
|
+
inline, never in `deps`. All `query_*` functions take `deps: QueryDeps = {db}`
|
|
14
|
+
as their first arg.
|
|
15
|
+
|
|
16
|
+
## Crypto primitives
|
|
17
|
+
|
|
18
|
+
Pure, I/O-free operations. Framework-dependent middleware lives in later
|
|
19
|
+
sections.
|
|
20
|
+
|
|
21
|
+
| Module | Exports |
|
|
22
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| `keyring.ts` | `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring`, `ValidatedKeyringResult` |
|
|
24
|
+
| `session_cookie.ts` | `SessionOptions<T>`, `SessionCookieOptions`, `SESSION_COOKIE_OPTIONS`, `SESSION_AGE_MAX`, `ParsedSession`, `ProcessSessionResult`, `parse_session`, `create_session_cookie_value`, `process_session_cookie`, `create_session_config`, `fuz_session_config` |
|
|
25
|
+
| `password.ts` | `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300) |
|
|
26
|
+
| `password_argon2.ts` | `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps` |
|
|
27
|
+
| `api_token.ts` | `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token` |
|
|
28
|
+
| `daemon_token.ts` | `DaemonToken` (Zod), `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState` |
|
|
29
|
+
| `bootstrap_account.ts` | `bootstrap_account`, `BootstrapAccountDeps`, `BootstrapAccountInput`, `BootstrapAccountSuccess`, `BootstrapAccountFailure`, `BootstrapAccountResult` |
|
|
30
|
+
|
|
31
|
+
Design notes:
|
|
32
|
+
|
|
33
|
+
- **Keyring** encapsulates secrets — only `sign` / `verify` are exposed, keys
|
|
34
|
+
never leave the closure. `__` separator splits multiple rotation keys;
|
|
35
|
+
first key signs, all keys verify. Old keys remain valid for verification
|
|
36
|
+
indefinitely — rotating `SECRET_COOKIE_KEYS` is a security-critical deploy.
|
|
37
|
+
Minimum key length is 32 chars.
|
|
38
|
+
- **Session cookie** encodes `${identity}:${expires_at}` and HMAC-SHA256
|
|
39
|
+
signs the concatenation. Expiration is embedded in the signed value (not
|
|
40
|
+
only in the cookie `Max-Age`) for defense-in-depth. `TIdentity` is generic:
|
|
41
|
+
`string` for session-id references (server-side sessions, per-session
|
|
42
|
+
revocation), `number` for direct account-id references (no server state).
|
|
43
|
+
The canonical fuz pattern is `SessionOptions<string>` via
|
|
44
|
+
`create_session_config(name)`.
|
|
45
|
+
- **Password** has two schemas deliberately. `Password` enforces the current
|
|
46
|
+
length policy (used at account creation and password change);
|
|
47
|
+
`PasswordProvided` is minimal (`min(1)`) for login / verification so a
|
|
48
|
+
tightened policy does not lock out existing accounts. Both carry
|
|
49
|
+
`sensitivity: 'secret'` meta.
|
|
50
|
+
- **Argon2id** uses OWASP parameters (`memoryCost: 19456`, `timeCost: 2`,
|
|
51
|
+
`parallelism: 1`) via `@node-rs/argon2`. `verify_dummy` returns `false` but
|
|
52
|
+
takes the same time as a real verification — call on account-lookup miss
|
|
53
|
+
to equalize timing. The dummy hash is memoized.
|
|
54
|
+
- **API token** format is `secret_fuz_token_<base64url>`. Prefix enables
|
|
55
|
+
secret scanning (GitHub, TruffleHog, etc.); public `id` is `tok_<12 chars>`;
|
|
56
|
+
storage key is the blake3 hash. Raw token is returned exactly once.
|
|
57
|
+
- **Daemon token** is a 43-char base64url (256 bits). Validation is
|
|
58
|
+
timing-safe and accepts both `current_token` and `previous_token` during
|
|
59
|
+
the rotation race window. Pure primitives only — rotation lifecycle lives
|
|
60
|
+
in `daemon_token_middleware.ts`.
|
|
61
|
+
- **Bootstrap account** is one-shot; protected by the `bootstrap_lock` table
|
|
62
|
+
via atomic `UPDATE ... WHERE id = 1 AND bootstrapped = false RETURNING id`.
|
|
63
|
+
Token read + password hash happen outside the transaction (CPU + I/O);
|
|
64
|
+
lock acquisition + account + actor + two permits (`keeper` and `admin`)
|
|
65
|
+
happen inside. On commit, the token file is deleted — if that fails,
|
|
66
|
+
`token_file_deleted: false` is returned and the caller is expected to
|
|
67
|
+
surface an error (the `/bootstrap` handler throws so the operator gets a
|
|
68
|
+
loud signal). Provided tokens are **not** trimmed — only `expected_token`
|
|
69
|
+
is (tokens must match on disk exactly).
|
|
70
|
+
|
|
71
|
+
## Schemas, types, and DDL
|
|
72
|
+
|
|
73
|
+
| Module | What's inside |
|
|
74
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
75
|
+
| `account_schema.ts` | Runtime types + client-safe Zod schemas for identity entities |
|
|
76
|
+
| `role_schema.ts` | Role vocabulary and extensibility |
|
|
77
|
+
| `ddl.ts` | Raw `CREATE TABLE` / index / seed SQL strings |
|
|
78
|
+
| `invite_schema.ts` | `Invite`, `InviteJson`, `InviteWithUsernamesJson`, `CreateInviteInput` |
|
|
79
|
+
| `app_settings_schema.ts` | `AppSettings`, `AppSettingsJson`, `AppSettingsWithUsernameJson`, `UpdateAppSettingsInput` |
|
|
80
|
+
| `audit_log_schema.ts` | Event-type enum, per-type metadata schemas, table DDL |
|
|
81
|
+
| `permit_offer_schema.ts` | Permit offer DDL, types, and client-safe schemas |
|
|
82
|
+
| `permit_offer_notifications.ts` | WS notification specs for the consentful-permits lifecycle |
|
|
83
|
+
|
|
84
|
+
### Identity entities (`account_schema.ts`)
|
|
85
|
+
|
|
86
|
+
- `Account` (primary identity, holds `password_hash`), `Actor` (the entity
|
|
87
|
+
that acts — owns cells, holds permits, appears in audit trails; 1:1 with
|
|
88
|
+
account in v1), `Permit` (time-bounded, revocable grant of a role to an
|
|
89
|
+
actor — carries `scope_id`, `source_offer_id`, `revoked_reason`),
|
|
90
|
+
`AuthSession` (server-side, keyed by blake3), `ApiToken`.
|
|
91
|
+
- Every `id` / `*_id` field on entity interfaces, `*Json` schemas, and
|
|
92
|
+
`*Input` types is branded `Uuid` (from `../uuid.ts`), except
|
|
93
|
+
`AuthSessionJson.id` (`Blake3Hash`) and `ClientApiTokenJson.id`
|
|
94
|
+
(`ApiTokenId` — `tok_`-prefixed).
|
|
95
|
+
- `Username`: `[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]` (3–39, GitHub parity).
|
|
96
|
+
`UsernameProvided`: `min(1).max(255)` — permissive for login/lookup so
|
|
97
|
+
tightening creation rules won't lock out existing users.
|
|
98
|
+
- `Email`: `z.email()`.
|
|
99
|
+
- `PERMIT_REVOKED_REASON_LENGTH_MAX = 500` — bounds both the admin input
|
|
100
|
+
and the `permit_revoke` WS payload.
|
|
101
|
+
- Client-safe Zod schemas (every exported schema has a same-named `z.infer`
|
|
102
|
+
type export):
|
|
103
|
+
- `SessionAccountJson` — strips sensitive fields from `Account`
|
|
104
|
+
- `AuthSessionJson` — `id` is the blake3 hash (safe for client)
|
|
105
|
+
- `ClientApiTokenJson` — excludes `token_hash`
|
|
106
|
+
- `PermitSummaryJson` — the client-safe permit shape carried by
|
|
107
|
+
`GET /api/account/status` and the admin account listing; includes
|
|
108
|
+
`scope_id` so clients can make per-scope auth decisions. Excludes
|
|
109
|
+
`revoked_at` / `revoked_by` / `revoked_reason` because the callers
|
|
110
|
+
that return it already filter to active permits.
|
|
111
|
+
- `ActorSummaryJson`
|
|
112
|
+
- `AdminAccountJson` extends `SessionAccountJson` with `updated_at` / `updated_by`
|
|
113
|
+
- `PendingOfferSummaryJson` — narrower than `PermitOfferJson`; omits
|
|
114
|
+
`message` and `decline_reason` so cross-admin visibility of the listing
|
|
115
|
+
does not expose grantor-authored text beyond what the audit log
|
|
116
|
+
discloses. `from_username` is resolved server-side so admins can see
|
|
117
|
+
whose pending offer is blocking a "+ role" button.
|
|
118
|
+
- `AdminAccountEntryJson` — composes `{account, actor, permits, pending_offers}`
|
|
119
|
+
- Converters: `to_session_account(account)`, `to_admin_account(account)`,
|
|
120
|
+
`is_permit_active(p, now?)`.
|
|
121
|
+
- Input types: `CreateAccountInput`, `GrantPermitInput` (with optional
|
|
122
|
+
`scope_id`, `source_offer_id`).
|
|
123
|
+
|
|
124
|
+
### Role system (`role_schema.ts`)
|
|
125
|
+
|
|
126
|
+
- `RoleName`: lowercase letters + underscores, no leading/trailing
|
|
127
|
+
underscore.
|
|
128
|
+
- `ROLE_KEEPER = 'keeper'` (requires daemon token, not `web_grantable`).
|
|
129
|
+
- `ROLE_ADMIN = 'admin'` (web-grantable).
|
|
130
|
+
- `BUILTIN_ROLES`, `BuiltinRole` (Zod enum).
|
|
131
|
+
- `RoleOptions`: `requires_daemon_token`, `web_grantable` (defaults `false`
|
|
132
|
+
and `true`).
|
|
133
|
+
- `BUILTIN_ROLE_OPTIONS` — fixed, not overridable by consumers.
|
|
134
|
+
- `create_role_schema(app_roles)` — call once at startup; returns `{Role, role_options}`.
|
|
135
|
+
Collisions with builtin names throw at construction. Used by middleware
|
|
136
|
+
to check `requires_daemon_token` and by admin UI to filter `web_grantable`.
|
|
137
|
+
|
|
138
|
+
### Raw DDL (`ddl.ts`)
|
|
139
|
+
|
|
140
|
+
Separated from runtime types to isolate DDL concerns. Consumed by
|
|
141
|
+
`migrations.ts`:
|
|
142
|
+
|
|
143
|
+
- `ACCOUNT_SCHEMA` (plus `ACCOUNT_EMAIL_INDEX`, `ACCOUNT_USERNAME_CI_INDEX`
|
|
144
|
+
— both case-insensitive partial uniques)
|
|
145
|
+
- `ACTOR_SCHEMA`, `ACTOR_INDEX`
|
|
146
|
+
- `PERMIT_SCHEMA`, `PERMIT_INDEXES` — v0 has `permit_actor_role_active_unique`
|
|
147
|
+
which is replaced in v1 with the scope-aware `permit_actor_role_scope_active_unique`
|
|
148
|
+
- `AUTH_SESSION_SCHEMA`, `AUTH_SESSION_INDEXES`
|
|
149
|
+
- `API_TOKEN_SCHEMA`, `API_TOKEN_INDEX`
|
|
150
|
+
- `BOOTSTRAP_LOCK_SCHEMA`, `BOOTSTRAP_LOCK_SEED` — seeded as `bootstrapped`
|
|
151
|
+
iff accounts already exist (fresh install: false; restoring into a
|
|
152
|
+
bootstrapped DB: true).
|
|
153
|
+
- `INVITE_SCHEMA`, `INVITE_INDEXES` — three partial uniques covering
|
|
154
|
+
email-unclaimed, username-unclaimed, plus a `claimed_at` index.
|
|
155
|
+
- `APP_SETTINGS_SCHEMA`, `APP_SETTINGS_SEED` — single-row via
|
|
156
|
+
`CHECK (id = 1)` constraint; seed is `ON CONFLICT DO NOTHING`.
|
|
157
|
+
|
|
158
|
+
### Audit log (`audit_log_schema.ts`)
|
|
159
|
+
|
|
160
|
+
- `AUDIT_EVENT_TYPES` — 21 events covering auth + permit + offer + invite +
|
|
161
|
+
settings mutations. Offer lifecycle: `permit_offer_create` / `_accept` /
|
|
162
|
+
`_decline` / `_retract` / `_expire` / `_supersede`.
|
|
163
|
+
- `AuditEventType` (Zod enum), `AuditOutcome` (`'success' | 'failure'`).
|
|
164
|
+
- `AUDIT_METADATA_SCHEMAS` — per-type `z.looseObject`. Notable shapes:
|
|
165
|
+
- `permit_grant` metadata carries `scope_id`, optional `permit_id` (failed
|
|
166
|
+
grants omit — `web_grantable` denial never produces a row), optional
|
|
167
|
+
`source_offer_id`.
|
|
168
|
+
- `permit_revoke` metadata carries `scope_id`, optional `reason`.
|
|
169
|
+
- `permit_offer_create` metadata carries optional `offer_id` (failed
|
|
170
|
+
creates omit).
|
|
171
|
+
- `permit_offer_supersede` metadata carries
|
|
172
|
+
`reason: 'sibling_accepted' | 'permit_revoked'` + `cause_id` (accepted
|
|
173
|
+
offer id or revoked permit id).
|
|
174
|
+
- `AuditLogEvent` (row), `AuditLogInput<T>` (narrow metadata), `AuditLogListOptions`
|
|
175
|
+
(supports `since_seq` for SSE reconnection gap fill).
|
|
176
|
+
- Client-safe: `AuditLogEventJson`, `AuditLogEventWithUsernamesJson`,
|
|
177
|
+
`PermitHistoryEventJson`, `AdminSessionJson`.
|
|
178
|
+
- `get_audit_metadata(event)` type-narrows metadata after checking `event_type`.
|
|
179
|
+
- DDL: `AUDIT_LOG_SCHEMA` (includes monotonically-increasing `seq SERIAL`
|
|
180
|
+
column for cursor-based gap fill), `AUDIT_LOG_INDEXES`.
|
|
181
|
+
|
|
182
|
+
### Permit offer (`permit_offer_schema.ts`)
|
|
183
|
+
|
|
184
|
+
The consentful-permits surface. Key constants:
|
|
185
|
+
|
|
186
|
+
- `PERMIT_OFFER_SCOPE_SENTINEL_UUID = '00000000-…'` — all-zeros UUID used
|
|
187
|
+
inside `COALESCE(scope_id, sentinel)` in partial unique indexes to collapse
|
|
188
|
+
NULL scopes into a comparable value. Without this, Postgres's NULL-in-
|
|
189
|
+
unique-index quirk would allow duplicate global pending offers.
|
|
190
|
+
- `PERMIT_OFFER_MESSAGE_LENGTH_MAX = 500`.
|
|
191
|
+
- `PERMIT_OFFER_DEFAULT_TTL_MS` = 30 days (GitHub org-invite parity).
|
|
192
|
+
|
|
193
|
+
DDL:
|
|
194
|
+
|
|
195
|
+
- `PERMIT_OFFER_SCHEMA` carries four nullable terminal timestamps:
|
|
196
|
+
`accepted_at`, `declined_at`, `retracted_at`, **`superseded_at`** (fourth
|
|
197
|
+
terminal — obsoleted by sibling accept or revoke of the resulting permit).
|
|
198
|
+
Three CHECK constraints:
|
|
199
|
+
- `permit_offer_single_terminal` — at most one terminal timestamp set.
|
|
200
|
+
- `permit_offer_permit_iff_accepted` — `(accepted_at IS NOT NULL) = (resulting_permit_id IS NOT NULL)`.
|
|
201
|
+
- `permit_offer_reason_iff_declined` — `decline_reason` only on declined rows.
|
|
202
|
+
- `PERMIT_OFFER_PENDING_UNIQUE_INDEX` — partial unique on
|
|
203
|
+
`(to_account_id, role, COALESCE(scope_id, sentinel), from_actor_id)`
|
|
204
|
+
where all four terminal timestamps are null. Including `from_actor_id`
|
|
205
|
+
lets multiple grantors coexist (teacher A and B can both offer the same
|
|
206
|
+
student role). A same-grantor re-offer upserts the pending row. The
|
|
207
|
+
`ON CONFLICT` target in `query_permit_offer_create` must match this
|
|
208
|
+
expression literally.
|
|
209
|
+
- `PERMIT_OFFER_INBOX_INDEX` — `(to_account_id, expires_at)` partial on
|
|
210
|
+
pending rows, soonest-expiry first.
|
|
211
|
+
|
|
212
|
+
Types:
|
|
213
|
+
|
|
214
|
+
- `PermitOffer` (row), `SupersededOffer` (row + `from_account_id` joined
|
|
215
|
+
via `actor` — carried so callers fan out `permit_offer_supersede`
|
|
216
|
+
notifications without a second round trip).
|
|
217
|
+
- `CreatePermitOfferInput` (`expires_at` is required — query layer applies
|
|
218
|
+
no default).
|
|
219
|
+
- `PermitOfferJson` (with `.meta({description})` on every field) paired
|
|
220
|
+
with `to_permit_offer_json(offer)`.
|
|
221
|
+
|
|
222
|
+
### WS notifications (`permit_offer_notifications.ts`)
|
|
223
|
+
|
|
224
|
+
Six `RemoteNotificationActionSpec`s fan notifications to affected sockets:
|
|
225
|
+
|
|
226
|
+
| Method | Fires to | Payload |
|
|
227
|
+
| ------------------------ | ---------------------------------- | --------------------------------------------------------------------- |
|
|
228
|
+
| `permit_offer_received` | Recipient | `{offer: PermitOfferJson}` |
|
|
229
|
+
| `permit_offer_retracted` | Recipient | `{offer: PermitOfferJson}` |
|
|
230
|
+
| `permit_offer_accepted` | Grantor | `{offer: PermitOfferJson}` |
|
|
231
|
+
| `permit_offer_declined` | Grantor | `{offer: PermitOfferJson}` (decline reason on `offer.decline_reason`) |
|
|
232
|
+
| `permit_offer_supersede` | Grantor (sibling / revoked-permit) | `{offer, reason: 'sibling_accepted' \| 'permit_revoked', cause_id}` |
|
|
233
|
+
| `permit_revoke` | Revokee | `{permit_id, role, scope_id, reason?}` |
|
|
234
|
+
|
|
235
|
+
Method constants: `PERMIT_OFFER_RECEIVED_NOTIFICATION_METHOD`,
|
|
236
|
+
`_RETRACTED_`, `_ACCEPTED_`, `_DECLINED_`, `_SUPERSEDE_`,
|
|
237
|
+
`PERMIT_REVOKE_NOTIFICATION_METHOD`. Zod params schemas with inferred type
|
|
238
|
+
exports: `PermitOfferReceivedParams`, `_RetractedParams`, `_AcceptedParams`,
|
|
239
|
+
`_DeclinedParams`, `_SupersedeParams`, `PermitRevokeParams`. Notification
|
|
240
|
+
builders: `build_permit_offer_received_notification(params)` etc.
|
|
241
|
+
|
|
242
|
+
`PERMIT_OFFER_NOTIFICATION_SPECS: Array<EventSpec>` — pass to
|
|
243
|
+
`create_app_server`'s `event_specs` so the attack surface reflects them
|
|
244
|
+
and DEV-mode `create_validated_broadcaster` catches payload drift.
|
|
245
|
+
|
|
246
|
+
`NotificationSender` is the narrow structural capability:
|
|
247
|
+
`send_to_account(account_id, message): number`. `BackendWebsocketTransport`
|
|
248
|
+
structurally satisfies it (its signature accepts the broader
|
|
249
|
+
`JsonrpcMessageFromServerToClient`, contravariantly compatible). Target
|
|
250
|
+
account travels via the send argument, not the payload — `revoked_by` is
|
|
251
|
+
deliberately not in the `permit_revoke` payload (the revokee doesn't need
|
|
252
|
+
to learn the admin's identity).
|
|
253
|
+
|
|
254
|
+
## Queries
|
|
255
|
+
|
|
256
|
+
All take `deps: QueryDeps = {db}` as their first arg (except
|
|
257
|
+
`query_validate_api_token` which uses `ApiTokenQueryDeps` — adds `log`).
|
|
258
|
+
|
|
259
|
+
### `account_queries.ts`
|
|
260
|
+
|
|
261
|
+
CRUD + listing:
|
|
262
|
+
|
|
263
|
+
- `query_create_account`, `query_create_actor`, `query_create_account_with_actor`.
|
|
264
|
+
- `query_account_by_id` / `_username` / `_email` — case-insensitive via
|
|
265
|
+
`LOWER()` (relies on the `idx_account_email` / `idx_account_username_ci`
|
|
266
|
+
indexes).
|
|
267
|
+
- `query_account_by_username_or_email(deps, input)` — if `@` in input, tries
|
|
268
|
+
email first; else username first. Single login field accepting either.
|
|
269
|
+
- `query_update_account_password`, `query_delete_account` (cascades to
|
|
270
|
+
actors, permits, sessions, tokens).
|
|
271
|
+
- `query_account_has_any` — used by bootstrap for belt-and-suspenders check.
|
|
272
|
+
- `query_actor_by_account`, `query_actor_by_id`.
|
|
273
|
+
- `query_admin_account_list` — composes accounts + actors + active permits +
|
|
274
|
+
pending inbound offers with **four flat queries** instead of N+1. Pending
|
|
275
|
+
offers exclude `message` on purpose (cross-admin visibility). Returns
|
|
276
|
+
`Array<AdminAccountEntryJson>`, sorted by `created_at`.
|
|
277
|
+
|
|
278
|
+
### `permit_queries.ts`
|
|
279
|
+
|
|
280
|
+
- `query_grant_permit` — idempotent; `ON CONFLICT` target and fallback
|
|
281
|
+
`SELECT` both use `COALESCE(scope_id, sentinel)`. The fallback `SELECT`
|
|
282
|
+
uses `IS NOT DISTINCT FROM` (plain `=` would miss the NULL-scope conflict
|
|
283
|
+
case).
|
|
284
|
+
- `query_permit_find_active_role_for_actor(deps, permit_id, actor_id)` —
|
|
285
|
+
actor-scoped read, so IDOR protection is consistent with revoke. Returns
|
|
286
|
+
`{role}` or `null`.
|
|
287
|
+
- **`query_revoke_permit(deps, permit_id, actor_id, revoked_by, reason?)`** —
|
|
288
|
+
actor-scoped IDOR guard (returns `null` if the permit belongs to a
|
|
289
|
+
different actor). Supersedes pending offers for the revoked permit's
|
|
290
|
+
`(to_account, role, scope)` in the **same transaction** via a CTE that
|
|
291
|
+
joins `actor` to surface each sibling's `from_account_id`. Returns
|
|
292
|
+
`RevokePermitResult = {id, role, scope_id, superseded_offers}`. Closes the
|
|
293
|
+
"accept a pre-revoke offer to bypass the revoke" path — the stale offer
|
|
294
|
+
becomes terminal at revoke time.
|
|
295
|
+
- `query_permit_find_active_for_actor`, `query_permit_list_for_actor`.
|
|
296
|
+
- `query_permit_has_role(deps, actor_id, role, scope_id?)` — `IS NOT DISTINCT FROM`
|
|
297
|
+
handles the NULL case. Omitted scope matches `scope_id IS NULL` (pre-scope
|
|
298
|
+
callers keep semantics).
|
|
299
|
+
- `query_permit_find_account_id_for_role(deps, role)` — joins
|
|
300
|
+
permit → actor → account, returns first match. Used by daemon token
|
|
301
|
+
middleware to resolve the keeper account.
|
|
302
|
+
- `query_permit_revoke_role(deps, actor_id, role, ...)` — revokes every
|
|
303
|
+
active permit for `(actor, role)` across all scopes and supersedes all
|
|
304
|
+
matching pending offers. Returns `RevokeRoleResult = {revoked, superseded_offers}`.
|
|
305
|
+
|
|
306
|
+
### `permit_offer_queries.ts`
|
|
307
|
+
|
|
308
|
+
Error classes (all extend `Error` with stable `.name` — never use
|
|
309
|
+
`instanceof` against plain messages):
|
|
310
|
+
|
|
311
|
+
- `PermitOfferSelfTargetError` — grantor offered themselves. Enforced via
|
|
312
|
+
cross-row JOIN in `query_permit_offer_create` (rather than CHECK) to avoid
|
|
313
|
+
denormalized columns.
|
|
314
|
+
- `PermitOfferAlreadyTerminalError` — offer exists for the caller but is
|
|
315
|
+
accepted / declined / retracted / superseded.
|
|
316
|
+
- `PermitOfferExpiredError` — pending but past `expires_at` (distinct from
|
|
317
|
+
terminal; different user-facing story: "ask the grantor to re-send").
|
|
318
|
+
- `PermitOfferNotFoundError` — not found or belongs to a different recipient
|
|
319
|
+
(standard 404-over-403 IDOR mask; callers never reveal which).
|
|
320
|
+
|
|
321
|
+
Queries:
|
|
322
|
+
|
|
323
|
+
- `query_permit_offer_create` — INSERT with upsert-on-pending keyed by
|
|
324
|
+
`(to_account, role, scope, from_actor)`. Same-grantor re-offer refreshes
|
|
325
|
+
`message` + `expires_at` only. A terminal-state row with the same tuple
|
|
326
|
+
does not block a fresh INSERT.
|
|
327
|
+
- `query_permit_offer_decline(deps, id, to_account_id, reason)` — IDOR
|
|
328
|
+
guarded by `to_account_id`. `resolve_terminal_or_missing` helper
|
|
329
|
+
distinguishes "not found / different recipient" from "already terminal".
|
|
330
|
+
- `query_permit_offer_retract(deps, id, from_actor_id)` — IDOR guarded by
|
|
331
|
+
grantor actor.
|
|
332
|
+
- `query_permit_offer_list(deps, to_account_id)` — pending + non-expired +
|
|
333
|
+
non-superseded, soonest expiry first.
|
|
334
|
+
- `query_permit_offer_history_for_account(deps, account_id, limit?, offset?)` —
|
|
335
|
+
both directions (recipient or grantor), includes terminal rows, newest
|
|
336
|
+
first.
|
|
337
|
+
- `query_permit_offer_find_pending`.
|
|
338
|
+
- `query_permit_offer_sweep_expired` — returns pending offers past
|
|
339
|
+
`expires_at`; the caller emits `permit_offer_expire` audit events
|
|
340
|
+
per-row (no tombstone — caller is responsible for idempotency).
|
|
341
|
+
- **`query_accept_offer(deps, input)`** — atomic, must run inside a
|
|
342
|
+
transaction. Row-locks with `SELECT ... FOR UPDATE` (concurrent callers
|
|
343
|
+
block until commit / rollback, then branch idempotently). Inserts the
|
|
344
|
+
permit with normal idempotency (`ON CONFLICT DO NOTHING`), stamps
|
|
345
|
+
`accepted_at` + `resulting_permit_id` in one UPDATE (satisfying the
|
|
346
|
+
`permit_offer_permit_iff_accepted` CHECK), supersedes sibling pending
|
|
347
|
+
offers for `(to_account, role, scope)` via CTE joined to `actor` for
|
|
348
|
+
grantor `account_id`, and emits `permit_offer_accept` + `permit_grant`
|
|
349
|
+
- one `permit_offer_supersede` per sibling. On race, returns the
|
|
350
|
+
pre-existing permit with `created: false` and empty `superseded_offers`
|
|
351
|
+
/ `audit_events`. Error map: `PermitOfferNotFoundError`,
|
|
352
|
+
`PermitOfferAlreadyTerminalError`, `PermitOfferExpiredError`. Sibling
|
|
353
|
+
supersede is what forecloses the "accept a pre-revoke sibling later to
|
|
354
|
+
get the role back" path.
|
|
355
|
+
|
|
356
|
+
### `session_queries.ts`
|
|
357
|
+
|
|
358
|
+
Server-side sessions, keyed by blake3 hash of the session token:
|
|
359
|
+
|
|
360
|
+
- `AUTH_SESSION_LIFETIME_MS` (30 days), `AUTH_SESSION_EXTEND_THRESHOLD_MS` (1 day).
|
|
361
|
+
- `hash_session_token`, `generate_session_token`.
|
|
362
|
+
- `query_create_session(deps, token_hash, account_id, expires_at)`.
|
|
363
|
+
- `query_session_get_valid` — implicit `expires_at > NOW()` filter.
|
|
364
|
+
- `query_session_touch` — updates `last_seen_at`; extends `expires_at` only
|
|
365
|
+
when less than `AUTH_SESSION_EXTEND_THRESHOLD_MS` remains (avoids a write
|
|
366
|
+
on every request).
|
|
367
|
+
- **`query_session_revoke_by_hash`** — unscoped DELETE. Only safe from the
|
|
368
|
+
authenticated session cookie path (logout). For user-facing revocation by
|
|
369
|
+
ID, use `query_session_revoke_for_account`.
|
|
370
|
+
- `query_session_revoke_for_account(deps, hash, account_id)` — IDOR guarded.
|
|
371
|
+
- `query_session_revoke_all_for_account` — returns count.
|
|
372
|
+
- `query_session_list_for_account`, `query_session_list_all_active` (admin).
|
|
373
|
+
- `query_session_enforce_limit(deps, account_id, max_sessions)` — keeps
|
|
374
|
+
newest N, evicts the rest. **Must run in a transaction** with the INSERT
|
|
375
|
+
that created the new session. All callers satisfy this: `POST /login`
|
|
376
|
+
via `transaction: true`; `account_token_create` RPC via the dispatcher's
|
|
377
|
+
`side_effects: true` transaction path; `/bootstrap` / `/signup` via
|
|
378
|
+
explicit `db.transaction` wrappers.
|
|
379
|
+
- `query_session_cleanup_expired`.
|
|
380
|
+
- `session_touch_fire_and_forget(deps, hash, pending_effects?, log)` —
|
|
381
|
+
errors logged, never thrown.
|
|
382
|
+
|
|
383
|
+
### `api_token_queries.ts`
|
|
384
|
+
|
|
385
|
+
- `ApiTokenQueryDeps = QueryDeps & {log}`.
|
|
386
|
+
- `query_create_api_token` — caller provides `id`, `token_hash` (already
|
|
387
|
+
computed via `api_token.ts`).
|
|
388
|
+
- `query_validate_api_token(deps, raw_token, ip, pending_effects?)` — hashes,
|
|
389
|
+
looks up, checks expiry, fires a fire-and-forget UPDATE for `last_used_at`
|
|
390
|
+
/ `last_used_ip` (errors logged via `deps.log`).
|
|
391
|
+
- `query_revoke_all_api_tokens_for_account` (returns count),
|
|
392
|
+
`query_revoke_api_token_for_account` (IDOR guarded).
|
|
393
|
+
- `query_api_token_list_for_account` — columns enumerated explicitly to
|
|
394
|
+
exclude `token_hash`. Must be kept in sync when `api_token` gains columns.
|
|
395
|
+
- `query_api_token_enforce_limit` — same transaction-safety requirement as
|
|
396
|
+
the session variant.
|
|
397
|
+
|
|
398
|
+
### `invite_queries.ts`
|
|
399
|
+
|
|
400
|
+
- `query_create_invite` (requires at least one of `email` / `username` —
|
|
401
|
+
enforced by `CHECK constraint invite_has_identifier`).
|
|
402
|
+
- `query_invite_find_unclaimed_by_email`, `_by_username`.
|
|
403
|
+
- `query_invite_find_unclaimed_match(deps, email, username)` — three scoping
|
|
404
|
+
modes: email-only invite needs signup-email match; username-only invite
|
|
405
|
+
needs signup-username match; both-field invite requires both to match.
|
|
406
|
+
- `query_invite_claim` — sets `claimed_by` + `claimed_at` only if still
|
|
407
|
+
unclaimed. Return is a boolean for race-detection.
|
|
408
|
+
- `query_invite_list_all`, `query_invite_list_all_with_usernames` (joins to
|
|
409
|
+
`actor` for `created_by_username` and `account` for `claimed_by_username`).
|
|
410
|
+
- `query_invite_delete_unclaimed` — IDOR not a concern (admin-only surface),
|
|
411
|
+
but rejects already-claimed invites.
|
|
412
|
+
|
|
413
|
+
### `app_settings_queries.ts`
|
|
414
|
+
|
|
415
|
+
- `query_app_settings_load`, `query_app_settings_load_with_username`,
|
|
416
|
+
`query_app_settings_update(deps, open_signup, actor_id)`.
|
|
417
|
+
- All three throw `'app_settings row not found — migration may not have
|
|
418
|
+
run'` if the seed somehow missed (defensive — migrations always seed).
|
|
419
|
+
|
|
420
|
+
### `audit_log_queries.ts`
|
|
421
|
+
|
|
422
|
+
- `AUDIT_LOG_DEFAULT_LIMIT = 50`.
|
|
423
|
+
- `query_audit_log<T>(deps, input)` — DEV-only validates metadata against
|
|
424
|
+
`AUDIT_METADATA_SCHEMAS[event_type]` (warns on mismatch, never throws).
|
|
425
|
+
Returns the inserted row via `RETURNING *` (so callers get `id`, `seq`,
|
|
426
|
+
`created_at`).
|
|
427
|
+
- `query_audit_log_list(deps, options?)` — supports `event_type`,
|
|
428
|
+
`event_type_in`, `account_id` (matches either `account_id` OR
|
|
429
|
+
`target_account_id`), `outcome`, `since_seq`, `limit`, `offset`.
|
|
430
|
+
- `query_audit_log_list_with_usernames` — joins twice to `account`.
|
|
431
|
+
- `query_audit_log_list_for_account`, `query_audit_log_list_permit_history`
|
|
432
|
+
(filters to `permit_grant` / `permit_revoke`).
|
|
433
|
+
- `query_audit_log_cleanup_before`.
|
|
434
|
+
- **`audit_log_fire_and_forget(route, input, log, on_event)`** — writes to
|
|
435
|
+
`route.background_db` (pool-level), **not** the handler's transaction,
|
|
436
|
+
so audit entries **persist even when the request transaction rolls back**.
|
|
437
|
+
Write failures and `on_event` callback failures are logged separately so
|
|
438
|
+
the error message indicates the failing phase. Pushes onto
|
|
439
|
+
`route.pending_effects` for test flushing.
|
|
440
|
+
|
|
441
|
+
### `migrations.ts`
|
|
442
|
+
|
|
443
|
+
- `AUTH_MIGRATION_NAMESPACE = 'fuz_auth'`, `AUTH_MIGRATION_NS` (pre-composed).
|
|
444
|
+
- `AUTH_MIGRATIONS`:
|
|
445
|
+
- **v0 `full_auth_schema`** — every table + index + seed for the v1
|
|
446
|
+
identity system (account, actor, permit, auth_session, api_token,
|
|
447
|
+
audit_log, bootstrap_lock, invite, app_settings). All
|
|
448
|
+
`IF NOT EXISTS` — idempotent replay.
|
|
449
|
+
- **v1 `permit_offer_and_scoped_permits`** — adds `permit_offer` table
|
|
450
|
+
plus its two partial indexes; adds `permit.scope_id` /
|
|
451
|
+
`permit.source_offer_id` / `permit.revoked_reason`; drops
|
|
452
|
+
`permit_actor_role_active_unique` and installs scope-aware
|
|
453
|
+
`permit_actor_role_scope_active_unique` using the
|
|
454
|
+
`PERMIT_OFFER_SCOPE_SENTINEL_UUID`.
|
|
455
|
+
- Forward-only (no down). Named migrations are preferred so the name
|
|
456
|
+
surfaces in error messages.
|
|
457
|
+
|
|
458
|
+
## Middleware
|
|
459
|
+
|
|
460
|
+
Side of the chain ordering (concept-level — see the root `../../../CLAUDE.md`
|
|
461
|
+
§Middleware Ordering for the canonical assembly order):
|
|
462
|
+
|
|
463
|
+
**Session parsing is separate from auth enforcement.** The session /
|
|
464
|
+
request-context middleware populates `{account, actor, permits}` from a
|
|
465
|
+
cookie but does not 401; `require_auth` / `require_role` / `require_keeper`
|
|
466
|
+
enforce. This lets `/login` and `/bootstrap` participate in cookie refresh
|
|
467
|
+
without being blocked.
|
|
468
|
+
|
|
469
|
+
### `request_context.ts`
|
|
470
|
+
|
|
471
|
+
- `RequestContext = {account, actor, permits}`.
|
|
472
|
+
- `REQUEST_CONTEXT_KEY` — Hono context variable name.
|
|
473
|
+
- **`AUTH_SESSION_TOKEN_HASH_KEY`** — holds the blake3 session hash. Set on
|
|
474
|
+
successful session lookup; `null` for unauthenticated or non-session
|
|
475
|
+
credentials. Exposed so SSE endpoints can scope per-session resource
|
|
476
|
+
identity (the audit-log SSE uses this to close only the revoked session's
|
|
477
|
+
stream on `session_revoke`).
|
|
478
|
+
- `get_request_context(c)`, `require_request_context(c)` (throws on misuse
|
|
479
|
+
— misconfigured middleware surfaces immediately), `has_role(ctx, role, now?)`.
|
|
480
|
+
- `build_request_context(deps, account_id)` — shared helper used by
|
|
481
|
+
session, bearer, and daemon token middleware; does
|
|
482
|
+
`account → actor → permits` and returns `null` if either lookup misses.
|
|
483
|
+
- `refresh_permits(ctx, deps)` — reloads permits without mutating the
|
|
484
|
+
original (concurrent-safe). Useful for long-lived WebSocket connections.
|
|
485
|
+
- `create_request_context_middleware(deps, log, session_context_key?)` —
|
|
486
|
+
reads session token from context, hashes, validates, loads context, sets
|
|
487
|
+
`CREDENTIAL_TYPE_KEY = 'session'`, fires `session_touch_fire_and_forget`.
|
|
488
|
+
- `require_auth` — 401 (`ERROR_AUTHENTICATION_REQUIRED`) on no context.
|
|
489
|
+
- `require_role(role)` — 401 on no context, 403 (`ERROR_INSUFFICIENT_PERMISSIONS`
|
|
490
|
+
- `required_role`) on missing role.
|
|
491
|
+
|
|
492
|
+
### `bearer_auth.ts`
|
|
493
|
+
|
|
494
|
+
- `create_bearer_auth_middleware(deps, ip_rate_limiter, log)`.
|
|
495
|
+
- **Soft-fails** for invalid / expired / empty tokens — calls `next()`
|
|
496
|
+
without setting context. Lets downstream auth enforcement return a
|
|
497
|
+
consistent error and avoids leaking token-specific diagnostics. Only
|
|
498
|
+
429 is a hard-fail.
|
|
499
|
+
- **Rejects bearer tokens when `Origin` or `Referer` is present** (both,
|
|
500
|
+
not just `Origin` — some browser requests omit `Origin`). Checked via
|
|
501
|
+
`!== undefined` so empty-string headers still count as browser context.
|
|
502
|
+
Discards rather than 403s so public actions remain reachable.
|
|
503
|
+
- Case-insensitive scheme matching per RFC 7235 §2.1.
|
|
504
|
+
- Rate limiter: `record` before async DB work to close the TOCTOU window;
|
|
505
|
+
`reset` on valid token.
|
|
506
|
+
|
|
507
|
+
### `require_keeper.ts`
|
|
508
|
+
|
|
509
|
+
Two-part type guard:
|
|
510
|
+
|
|
511
|
+
1. `credential_type` must be `'daemon_token'` (not session, not API token).
|
|
512
|
+
A session cookie from the bootstrap account still fails this check.
|
|
513
|
+
2. Active `keeper` permit.
|
|
514
|
+
|
|
515
|
+
Returns 401 on no context, 403 (`ERROR_KEEPER_REQUIRES_DAEMON_TOKEN` or
|
|
516
|
+
`ERROR_INSUFFICIENT_PERMISSIONS`) otherwise.
|
|
517
|
+
|
|
518
|
+
### `session_middleware.ts` + `session_lifecycle.ts`
|
|
519
|
+
|
|
520
|
+
`session_middleware.ts`:
|
|
521
|
+
|
|
522
|
+
- `get_session_cookie`, `set_session_cookie`, `clear_session_cookie`.
|
|
523
|
+
- `create_session_middleware(keyring, options)` — always sets the
|
|
524
|
+
identity on context (null when invalid/missing) for type-safe reads.
|
|
525
|
+
Acts on `process_session_cookie`'s `action` (`'clear'` / `'refresh'` /
|
|
526
|
+
`'none'`).
|
|
527
|
+
|
|
528
|
+
`session_lifecycle.ts` — shared by login and bootstrap:
|
|
529
|
+
|
|
530
|
+
- `create_session_and_set_cookie({keyring, deps, c, account_id, session_options, max_sessions?})` —
|
|
531
|
+
generates token, hashes, persists `auth_session`, optionally enforces
|
|
532
|
+
per-account cap, signs the cookie.
|
|
533
|
+
|
|
534
|
+
### `daemon_token_middleware.ts`
|
|
535
|
+
|
|
536
|
+
- `DEFAULT_ROTATION_INTERVAL_MS = 30_000`.
|
|
537
|
+
- `get_daemon_token_path(runtime, name)` → `~/.{name}/run/daemon_token`
|
|
538
|
+
or `null` if `$HOME` unset.
|
|
539
|
+
- `write_daemon_token(runtime, path, token)` — atomic (temp + rename);
|
|
540
|
+
`chmod 0600` if available.
|
|
541
|
+
- `resolve_keeper_account_id(deps)` — wraps `query_permit_find_account_id_for_role(ROLE_KEEPER)`.
|
|
542
|
+
- `start_daemon_token_rotation(runtime, deps, options, log)` — writes initial
|
|
543
|
+
token, resolves keeper, sets up interval. Returns `{state, stop}`. The
|
|
544
|
+
interval guard `writing` skips the next rotation if the prior write is
|
|
545
|
+
still in flight. `stop` clears the interval and removes the token file
|
|
546
|
+
(errors swallowed — already removed or never written).
|
|
547
|
+
- `create_daemon_token_middleware(state, deps)` — checks `X-Daemon-Token`:
|
|
548
|
+
- No header → pass through.
|
|
549
|
+
- Present + Zod-invalid → 401 `ERROR_INVALID_DAEMON_TOKEN`.
|
|
550
|
+
- Present + invalid value → 401 (fail-closed, no downgrade).
|
|
551
|
+
- Present + valid + no `keeper_account_id` → 503 `ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED`.
|
|
552
|
+
- Present + valid + keeper account missing → 500 `ERROR_KEEPER_ACCOUNT_NOT_FOUND`.
|
|
553
|
+
- Present + valid + ok → builds context from keeper account (overrides
|
|
554
|
+
any existing session / bearer context), sets `credential_type: 'daemon_token'`.
|
|
555
|
+
|
|
556
|
+
### `middleware.ts`
|
|
557
|
+
|
|
558
|
+
- `create_auth_middleware_specs(deps, options)` — assembles the stack:
|
|
559
|
+
`[origin, session, request_context, bearer_auth]` plus an optional
|
|
560
|
+
`daemon_token` layer when `daemon_token_state` is passed. Returns
|
|
561
|
+
`Array<MiddlewareSpec>`. Dynamic imports keep heavy deps out of
|
|
562
|
+
consumers that only use types. `bearer_auth.errors: {429: RateLimitError}`
|
|
563
|
+
— bearer middleware only hard-fails on rate limit; `daemon_token.errors`
|
|
564
|
+
documents 401 / 500 / 503.
|
|
565
|
+
|
|
566
|
+
## Routes
|
|
567
|
+
|
|
568
|
+
### `account_routes.ts`
|
|
569
|
+
|
|
570
|
+
Session-based auth route specs. Factory: `create_account_route_specs(deps, options)`.
|
|
571
|
+
|
|
572
|
+
- `POST /login` — `UsernameProvided` + `PasswordProvided`. Two rate limiters:
|
|
573
|
+
per-IP and per-account (keyed by **canonical `account.id` after lookup**
|
|
574
|
+
— keying by submitted username would double the bucket when an attacker
|
|
575
|
+
alternates between username and email). **Login 401s are floored to
|
|
576
|
+
`DEFAULT_LOGIN_FAIL_FLOOR_MS` (250ms) + uniform jitter
|
|
577
|
+
`DEFAULT_LOGIN_FAIL_JITTER_MS` (±25ms)** via
|
|
578
|
+
`Promise.all(work, setTimeout)` — observed time is `max(work, delay)` so
|
|
579
|
+
found-wrong-password and not-found paths converge. 429 stays fast by
|
|
580
|
+
design. `verify_dummy` equalizes Argon2id timing on not-found.
|
|
581
|
+
- `POST /logout` — revokes session by hash, clears cookie.
|
|
582
|
+
- **`POST /password`** — `current_password: PasswordProvided` +
|
|
583
|
+
`new_password: Password`. Per-IP + per-account rate limited.
|
|
584
|
+
**Revokes all sessions + all API tokens** (force re-auth everywhere);
|
|
585
|
+
clears cookie.
|
|
586
|
+
- **`GET /verify`** — empty-body session-validity probe for nginx
|
|
587
|
+
`auth_request` subrequests. Status-code-only contract: 200 on valid
|
|
588
|
+
cookie, 401 otherwise. The auth middleware does the enforcement; the
|
|
589
|
+
handler is a one-line shim. Programmatic callers should use the
|
|
590
|
+
`account_verify` RPC action — that surface carries the typed
|
|
591
|
+
`SessionAccountJson` payload.
|
|
592
|
+
- `create_account_status_route_spec(options?)` — `GET /api/account/status`
|
|
593
|
+
returns `{account, actor, permits}` on 200 or 401 with optional
|
|
594
|
+
`bootstrap_available` flag. `actor` is the caller's own
|
|
595
|
+
`ActorSummaryJson` so clients don't need to derive `actor_id` from
|
|
596
|
+
the permit list. Lets the frontend fetch both session state
|
|
597
|
+
and bootstrap availability in one request (eliminates a separate `/health`
|
|
598
|
+
round trip).
|
|
599
|
+
|
|
600
|
+
Post-2026-04-23 RPC migration: session listing/revoke + revoke-all
|
|
601
|
+
and API token CRUD live in `account_actions.ts` (see
|
|
602
|
+
`account_session_list` / `_revoke` / `_revoke_all`,
|
|
603
|
+
`account_token_create` / `_list` / `_revoke` below). Each keeps its
|
|
604
|
+
guards (IDOR via `query_session_revoke_for_account` /
|
|
605
|
+
`query_revoke_api_token_for_account`; `Blake3Hash` on session ids;
|
|
606
|
+
`ApiTokenId` regex on token ids; `max_tokens` enforcement via
|
|
607
|
+
`query_api_token_enforce_limit`).
|
|
608
|
+
|
|
609
|
+
Constants:
|
|
610
|
+
|
|
611
|
+
- `DEFAULT_MAX_SESSIONS = 5`, `DEFAULT_MAX_TOKENS = 10`.
|
|
612
|
+
- `DEFAULT_LOGIN_FAIL_FLOOR_MS = 250`, `DEFAULT_LOGIN_FAIL_JITTER_MS = 25`.
|
|
613
|
+
- `AuthSessionRouteOptions` — shared base (`session_options`,
|
|
614
|
+
`ip_rate_limiter`). Extended by `AccountRouteOptions` and
|
|
615
|
+
`SignupRouteOptions`.
|
|
616
|
+
|
|
617
|
+
### `bootstrap_routes.ts`
|
|
618
|
+
|
|
619
|
+
- `BootstrapStatus = {available, token_path}` — runtime state (mutable ref).
|
|
620
|
+
- `check_bootstrap_status(deps, {token_path})` — returns `available: true`
|
|
621
|
+
iff the token path is configured, the file exists on disk, and
|
|
622
|
+
`bootstrap_lock.bootstrapped = false`.
|
|
623
|
+
- `create_bootstrap_route_specs(deps, options)` — `POST /bootstrap`. Short-
|
|
624
|
+
circuits on `!bootstrap_status.available`. `transaction: false` —
|
|
625
|
+
`bootstrap_account` manages its own. On success: flips
|
|
626
|
+
`bootstrap_status.available = false`, creates session, runs `on_bootstrap`
|
|
627
|
+
callback (for app-specific work like generating an API token), emits
|
|
628
|
+
audit event. **If token file deletion fails, throws** so the operator
|
|
629
|
+
gets a loud signal (all success side effects have already run).
|
|
630
|
+
- Rate limiter: per-IP only.
|
|
631
|
+
- Error shapes: 401 `ERROR_INVALID_TOKEN`, 403 `ERROR_ALREADY_BOOTSTRAPPED`,
|
|
632
|
+
404 `ERROR_TOKEN_FILE_MISSING | ERROR_BOOTSTRAP_NOT_CONFIGURED`.
|
|
633
|
+
|
|
634
|
+
### `signup_routes.ts`
|
|
635
|
+
|
|
636
|
+
- `SignupRouteOptions extends AuthSessionRouteOptions` with
|
|
637
|
+
`signup_account_rate_limiter` and a mutable `app_settings: AppSettings` ref.
|
|
638
|
+
- `POST /signup` — `transaction: false` (manages its own). When
|
|
639
|
+
`app_settings.open_signup` is false, requires a matching unclaimed invite.
|
|
640
|
+
On `open_signup: true` path, no invite check.
|
|
641
|
+
- Transaction body: `query_create_account_with_actor` → `query_invite_claim`
|
|
642
|
+
(if invite present; throws `SignupConflictError` on race — another claim
|
|
643
|
+
won) → `create_session_and_set_cookie`. Catches
|
|
644
|
+
`is_pg_unique_violation(e)` → 409 `ERROR_SIGNUP_CONFLICT` (username or
|
|
645
|
+
email already exists).
|
|
646
|
+
- Error shapes: 403 `ERROR_NO_MATCHING_INVITE`, 409 `ERROR_SIGNUP_CONFLICT`.
|
|
647
|
+
|
|
648
|
+
### `route_guards.ts`
|
|
649
|
+
|
|
650
|
+
`fuz_auth_guard_resolver: AuthGuardResolver` — maps `RouteAuth` discriminants
|
|
651
|
+
(`'none'` | `'authenticated'` | `'role'` | `'keeper'`) to middleware arrays.
|
|
652
|
+
Injected into `apply_route_specs` so the generic HTTP framework stays
|
|
653
|
+
auth-agnostic (see `../http/CLAUDE.md` §Validation pipeline for where it plugs in).
|
|
654
|
+
|
|
655
|
+
### `audit_log_routes.ts` (post-RPC-migration state)
|
|
656
|
+
|
|
657
|
+
The 2026-04-22 RPC migration moved audit-log list + permit-history reads
|
|
658
|
+
(plus admin session listing) to `admin_actions.ts`. The sole remaining
|
|
659
|
+
REST concern is the optional SSE stream:
|
|
660
|
+
|
|
661
|
+
- **`GET /audit-log/stream`** — optional, wired only when
|
|
662
|
+
`AuditLogRouteOptions.stream` is passed. Streams aren't an RPC concern.
|
|
663
|
+
Uses `AUTH_SESSION_TOKEN_HASH_KEY` for SSE `scope` identity (so
|
|
664
|
+
`session_revoke` can close only that session's stream); `groups: [account_id]`
|
|
665
|
+
for coarse close on `permit_revoke` / `session_revoke_all` / `password_change`.
|
|
666
|
+
|
|
667
|
+
`create_audit_log_route_specs(options?)` — returns an empty array when
|
|
668
|
+
`options.stream` is not set; `required_role` defaults to `'admin'`.
|
|
669
|
+
|
|
670
|
+
## RPC actions (SAES)
|
|
671
|
+
|
|
672
|
+
Three action surfaces that mount on a consumer's JSON-RPC endpoint via
|
|
673
|
+
`create_rpc_endpoint` (see `../actions/CLAUDE.md` §Single JSON-RPC 2.0 endpoint).
|
|
674
|
+
Each surface is split across two files:
|
|
675
|
+
|
|
676
|
+
- `*_action_specs.ts` — Input/Output Zod schemas (paired with `z.infer` type
|
|
677
|
+
exports), module-scope specs declared via `satisfies RequestResponseActionSpec`
|
|
678
|
+
(no per-method `*_METHOD` string constants — read `.method` off the spec),
|
|
679
|
+
and `all_*_action_specs: Array<RequestResponseActionSpec>` codegen-ready
|
|
680
|
+
registry. Plus any reason-string constants exported to the wire contract
|
|
681
|
+
(e.g. `ERROR_OFFER_*` for permit offers).
|
|
682
|
+
- `*_actions.ts` — `create_*_actions(deps, options) => Array<RpcAction>` factory
|
|
683
|
+
containing handler closures, the `*ActionDeps` / `*ActionOptions` interfaces,
|
|
684
|
+
and any handler-only helpers. Imports the specs from its sibling.
|
|
685
|
+
|
|
686
|
+
Client-side code that only needs the typed surface (codegen, attack-surface
|
|
687
|
+
reporting, form-state error matching) imports from `*_action_specs.ts` and
|
|
688
|
+
skips the handler module's transitive query-layer deps.
|
|
689
|
+
|
|
690
|
+
### `admin_action_specs.ts` + `admin_actions.ts` — eleven admin-only RPC actions
|
|
691
|
+
|
|
692
|
+
Authorization is **spec-level** (`auth: {role: 'admin'}`) so the dispatcher
|
|
693
|
+
enforces admin before the handler runs. `permit_revoke` in
|
|
694
|
+
`permit_offer_actions.ts` uses the same spec-level gate even though its
|
|
695
|
+
sibling methods are authenticated-but-not-admin — the dispatcher checks
|
|
696
|
+
auth per-spec, so mixed-auth endpoints compose cleanly.
|
|
697
|
+
|
|
698
|
+
| Spec | Side effects | Input | Output |
|
|
699
|
+
| -------------------------------------- | ------------ | --------------------------------------------------------- | ----------------------------- |
|
|
700
|
+
| `admin_account_list_action_spec` | false | `z.null()` | `{accounts, grantable_roles}` |
|
|
701
|
+
| `admin_session_list_action_spec` | false | `z.null()` | `{sessions}` |
|
|
702
|
+
| `admin_session_revoke_all_action_spec` | true | `{account_id}` | `{ok, count}` |
|
|
703
|
+
| `admin_token_revoke_all_action_spec` | true | `{account_id}` | `{ok, count}` |
|
|
704
|
+
| `audit_log_list_action_spec` | false | `{event_type?, account_id?, limit?, offset?, since_seq?}` | `{events}` |
|
|
705
|
+
| `audit_log_permit_history_action_spec` | false | `{limit?, offset?}` | `{events}` |
|
|
706
|
+
| `invite_create_action_spec` | true | `{email?, username?}` | `{ok, invite}` |
|
|
707
|
+
| `invite_list_action_spec` | false | `z.null()` | `{invites}` |
|
|
708
|
+
| `invite_delete_action_spec` | true | `{invite_id}` | `{ok}` |
|
|
709
|
+
| `app_settings_get_action_spec` | false | `z.null()` | `{settings}` |
|
|
710
|
+
| `app_settings_update_action_spec` | true | `{open_signup}` | `{ok, settings}` |
|
|
711
|
+
|
|
712
|
+
`AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp (mirrors the former REST
|
|
713
|
+
route).
|
|
714
|
+
|
|
715
|
+
Error reasons returned via `error.data.reason`:
|
|
716
|
+
|
|
717
|
+
| Method | Error |
|
|
718
|
+
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
719
|
+
| `admin_session_revoke_all` | `ERROR_ACCOUNT_NOT_FOUND` (404 via `jsonrpc_errors.not_found`) |
|
|
720
|
+
| `admin_token_revoke_all` | `ERROR_ACCOUNT_NOT_FOUND` |
|
|
721
|
+
| `invite_create` | `ERROR_INVITE_MISSING_IDENTIFIER` (invalid_params), `ERROR_INVITE_ACCOUNT_EXISTS_USERNAME`, `ERROR_INVITE_ACCOUNT_EXISTS_EMAIL`, `ERROR_INVITE_DUPLICATE` (conflict) |
|
|
722
|
+
| `invite_delete` | `ERROR_INVITE_NOT_FOUND` (not_found) |
|
|
723
|
+
|
|
724
|
+
Audit events fired by handlers (all pass `ip: ctx.client_ip` for
|
|
725
|
+
transport-uniform forensics — matches the REST convention and the
|
|
726
|
+
self-service `account_actions.ts` surface):
|
|
727
|
+
|
|
728
|
+
- `session_revoke_all` / `token_revoke_all` via `audit_log_fire_and_forget`
|
|
729
|
+
(mirrors the former REST behavior). Both also emit an
|
|
730
|
+
`outcome: 'failure'` row on the `ERROR_ACCOUNT_NOT_FOUND` 404 path for
|
|
731
|
+
forensic visibility — `target_account_id` is null (FK to `account`
|
|
732
|
+
rejects references to missing ids), and the probed id is preserved
|
|
733
|
+
under `metadata.attempted_account_id`. Metadata schema widening in
|
|
734
|
+
`audit_log_schema.ts` allows `reason`, `attempted_account_id`, and
|
|
735
|
+
makes `count` optional for the failure shape.
|
|
736
|
+
- `invite_create` / `invite_delete`.
|
|
737
|
+
- `app_settings_update` — metadata `{setting: 'open_signup', old_value, new_value}`.
|
|
738
|
+
|
|
739
|
+
Closure state:
|
|
740
|
+
|
|
741
|
+
- `grantable_roles` is derived once from `options.roles?.role_options ?? BUILTIN_ROLE_OPTIONS`
|
|
742
|
+
(the `web_grantable` subset) and closed over by the `admin_account_list` handler.
|
|
743
|
+
- `options.app_settings` — when provided, captured by the
|
|
744
|
+
`app_settings_get` / `app_settings_update` handlers. Update handler
|
|
745
|
+
**mutates the ref** (`open_signup`, `updated_at`, `updated_by`) so
|
|
746
|
+
`signup_routes.ts` reads the new value **without a DB round trip**.
|
|
747
|
+
When absent, those two specs are still present in `all_admin_action_specs`
|
|
748
|
+
(surface-wise) but the handlers are not wired — RPC dispatch returns
|
|
749
|
+
`method_not_found`.
|
|
750
|
+
|
|
751
|
+
`all_admin_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
|
|
752
|
+
registry of all eleven specs (always includes the two app-settings specs).
|
|
753
|
+
|
|
754
|
+
Deps: `AdminActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event'>`.
|
|
755
|
+
|
|
756
|
+
### `permit_offer_action_specs.ts` + `permit_offer_actions.ts` — seven RPC actions
|
|
757
|
+
|
|
758
|
+
Six offer-lifecycle methods plus `permit_revoke`. Authorization is a mix:
|
|
759
|
+
|
|
760
|
+
- `permit_offer_create` — `auth: 'authenticated'`. The **`web_grantable`
|
|
761
|
+
gate runs first**, then the `PermitOfferCreateAuthorize` callback
|
|
762
|
+
(default: caller holds the offered role globally). Consumers can only
|
|
763
|
+
tighten, never loosen past `web_grantable`.
|
|
764
|
+
- `permit_offer_accept` / `_decline` / `_retract` — `authenticated`; IDOR
|
|
765
|
+
guards in the `query_*` layer.
|
|
766
|
+
- `permit_offer_list` / `_history` — `side_effects: false` so GET-addressable;
|
|
767
|
+
**input-dependent elevation** — `'authenticated'` at the spec level so
|
|
768
|
+
any caller reaches their own inbox, then the handler requires admin
|
|
769
|
+
when `{account_id}` refers to another account. The spec can't express
|
|
770
|
+
this because auth runs before input parsing.
|
|
771
|
+
`permit_offer_history` accepts `limit` (1–500, default 100) + `offset`.
|
|
772
|
+
- **`permit_revoke`** — spec-level `auth: {role: 'admin'}`; the RPC
|
|
773
|
+
dispatcher rejects non-admin callers before the handler runs. Keys on
|
|
774
|
+
**`actor_id`, not `account_id`** — permits are actor-scoped and deriving
|
|
775
|
+
actor from account collapses under multi-actor accounts.
|
|
776
|
+
|
|
777
|
+
| Spec | Input | Output |
|
|
778
|
+
| ---------------------------------- | -------------------------------------------- | ------------------------------------------ |
|
|
779
|
+
| `permit_offer_create_action_spec` | `{to_account_id, role, scope_id?, message?}` | `{offer}` |
|
|
780
|
+
| `permit_offer_accept_action_spec` | `{offer_id}` | `{permit_id, offer, superseded_offer_ids}` |
|
|
781
|
+
| `permit_offer_decline_action_spec` | `{offer_id, reason?}` | `{ok}` |
|
|
782
|
+
| `permit_offer_retract_action_spec` | `{offer_id}` | `{ok}` |
|
|
783
|
+
| `permit_offer_list_action_spec` | `{account_id?}` | `{offers}` |
|
|
784
|
+
| `permit_offer_history_action_spec` | `{account_id?, limit?, offset?}` | `{offers}` |
|
|
785
|
+
| `permit_revoke_action_spec` | `{actor_id, permit_id, reason?}` | `{ok, revoked}` |
|
|
786
|
+
|
|
787
|
+
Error reason constants (exported as `as const` literals):
|
|
788
|
+
|
|
789
|
+
- `ERROR_OFFER_SELF_TARGET` (`'offer_self_target'`)
|
|
790
|
+
- `ERROR_OFFER_TERMINAL` (`'offer_terminal'`)
|
|
791
|
+
- `ERROR_OFFER_EXPIRED` (`'offer_expired'`)
|
|
792
|
+
- `ERROR_OFFER_NOT_FOUND` (`'offer_not_found'` — 404-over-403 IDOR mask)
|
|
793
|
+
- `ERROR_OFFER_ROLE_NOT_GRANTABLE` (`'offer_role_not_grantable'`)
|
|
794
|
+
- `ERROR_OFFER_NOT_AUTHORIZED` (`'offer_not_authorized'`)
|
|
795
|
+
|
|
796
|
+
Plus re-uses from `../http/error_schemas.ts`: `ERROR_PERMIT_NOT_FOUND`,
|
|
797
|
+
`ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_INSUFFICIENT_PERMISSIONS`,
|
|
798
|
+
`ERROR_ACCOUNT_NOT_FOUND`.
|
|
799
|
+
|
|
800
|
+
Failure-outcome audit events emitted (success and failure rows both carry
|
|
801
|
+
`ip: ctx.client_ip` — uniform with the admin and self-service surfaces):
|
|
802
|
+
|
|
803
|
+
- `permit_offer_create` failure — `web_grantable` denial, `authorize`
|
|
804
|
+
denial, self-target rejection (all three denial paths emit the same
|
|
805
|
+
audit row with `target_account_id`).
|
|
806
|
+
- `permit_revoke` failure — `web_grantable` denial after IDOR / role
|
|
807
|
+
lookup succeeded. The admin-role-denied path (pre-IDOR) emits no audit,
|
|
808
|
+
matching the middleware auth-guard precedent.
|
|
809
|
+
|
|
810
|
+
WS notifications (post-commit via `emit_after_commit` from
|
|
811
|
+
`../http/pending_effects.js` — swallows exceptions so one failed send
|
|
812
|
+
can't starve others; see `../http/CLAUDE.md` §Pending Effects):
|
|
813
|
+
|
|
814
|
+
- Create → `permit_offer_received` to recipient.
|
|
815
|
+
- Retract → `permit_offer_retracted` to recipient.
|
|
816
|
+
- Accept → `permit_offer_accepted` to grantor + one
|
|
817
|
+
`permit_offer_supersede` per superseded sibling to that sibling's grantor.
|
|
818
|
+
- Decline → `permit_offer_declined` to grantor.
|
|
819
|
+
- Revoke → `permit_revoke` to revokee + one `permit_offer_supersede` per
|
|
820
|
+
superseded sibling.
|
|
821
|
+
|
|
822
|
+
Deps: `PermitOfferActionDeps extends Pick<RouteFactoryDeps, 'log' | 'on_audit_event'> & {notification_sender?: NotificationSender | null}`.
|
|
823
|
+
Notification sender is optional — when absent, WS fan-out is silently
|
|
824
|
+
skipped (DB-only side effects still happen).
|
|
825
|
+
|
|
826
|
+
Options:
|
|
827
|
+
|
|
828
|
+
- `roles?: RoleSchemaResult` — drives `web_grantable` lookup (defaults to
|
|
829
|
+
`BUILTIN_ROLE_OPTIONS`).
|
|
830
|
+
- `default_ttl_ms?: number` — applied to new offers (defaults to
|
|
831
|
+
`PERMIT_OFFER_DEFAULT_TTL_MS`).
|
|
832
|
+
- `authorize?: PermitOfferCreateAuthorize` — custom policy for
|
|
833
|
+
`permit_offer_create`. Signature:
|
|
834
|
+
`(auth, input: {to_account_id, role, scope_id}, deps: Pick<RouteFactoryDeps, 'log'>, ctx: ActionContext) => boolean | Promise<boolean>`.
|
|
835
|
+
|
|
836
|
+
`all_permit_offer_action_specs: Array<RequestResponseActionSpec>` —
|
|
837
|
+
codegen-ready registry.
|
|
838
|
+
|
|
839
|
+
### `account_action_specs.ts` + `account_actions.ts` — seven self-service RPC actions
|
|
840
|
+
|
|
841
|
+
Counterpart to `account_routes.ts`. Cookie-lifecycle flows (`login`,
|
|
842
|
+
`logout`, `password`, `signup`, `bootstrap`) stay on REST, as does
|
|
843
|
+
`GET /verify` (empty-body nginx `auth_request` probe). Everything else
|
|
844
|
+
that was `/api/account/*` is on the RPC endpoint.
|
|
845
|
+
|
|
846
|
+
`account_verify` is intentionally on both surfaces: the REST shim is a
|
|
847
|
+
status-only probe, the RPC action returns `SessionAccountJson` for
|
|
848
|
+
programmatic callers.
|
|
849
|
+
|
|
850
|
+
Authorization is **spec-level** (`auth: 'authenticated'`). Revoke operations
|
|
851
|
+
are account-scoped via `query_session_revoke_for_account` /
|
|
852
|
+
`query_revoke_api_token_for_account` — passing another account's session
|
|
853
|
+
or token id returns `revoked: false` rather than revealing whether the id
|
|
854
|
+
exists.
|
|
855
|
+
|
|
856
|
+
| Spec | Side effects | Input | Output |
|
|
857
|
+
| ---------------------------------------- | ------------ | -------------- | ----------------------- |
|
|
858
|
+
| `account_verify_action_spec` | false | `z.null()` | `SessionAccountJson` |
|
|
859
|
+
| `account_session_list_action_spec` | false | `z.null()` | `{sessions}` |
|
|
860
|
+
| `account_session_revoke_action_spec` | true | `{session_id}` | `{ok, revoked}` |
|
|
861
|
+
| `account_session_revoke_all_action_spec` | true | `z.null()` | `{ok, count}` |
|
|
862
|
+
| `account_token_create_action_spec` | true | `{name?}` | `{ok, token, id, name}` |
|
|
863
|
+
| `account_token_list_action_spec` | false | `z.null()` | `{tokens}` |
|
|
864
|
+
| `account_token_revoke_action_spec` | true | `{token_id}` | `{ok, revoked}` |
|
|
865
|
+
|
|
866
|
+
`session_id` validates as `Blake3Hash`; `token_id` validates as
|
|
867
|
+
`ApiTokenId` (`tok_[A-Za-z0-9_-]{12}`).
|
|
868
|
+
|
|
869
|
+
Audit events emitted (via `audit_log_fire_and_forget` with `ip: ctx.client_ip`):
|
|
870
|
+
`session_revoke`, `session_revoke_all`, `token_create`, `token_revoke`. The
|
|
871
|
+
IP is the resolved trusted-proxy value from `ActionContext.client_ip`,
|
|
872
|
+
matching the REST handler convention.
|
|
873
|
+
|
|
874
|
+
Deps: `AccountActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event'>`.
|
|
875
|
+
Options: `{max_tokens?: number | null}` — defaults to `DEFAULT_MAX_TOKENS`
|
|
876
|
+
from `account_routes.ts`; `null` disables the cap.
|
|
877
|
+
|
|
878
|
+
`all_account_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
|
|
879
|
+
registry of all seven specs.
|
|
880
|
+
|
|
881
|
+
## Cleanup
|
|
882
|
+
|
|
883
|
+
`cleanup.ts` — periodic auth maintenance:
|
|
884
|
+
|
|
885
|
+
- `AuthCleanupDeps = QueryDeps & {log, on_audit_event?}`.
|
|
886
|
+
- `cleanup_expired_permit_offers(deps)` — wraps `query_permit_offer_sweep_expired`,
|
|
887
|
+
emits one `permit_offer_expire` audit row per expired offer. Per-row
|
|
888
|
+
`on_audit_event` exceptions are logged and swallowed; one failed callback
|
|
889
|
+
does not starve siblings. Audit-write failures are also logged and skipped
|
|
890
|
+
(not re-thrown) so sibling sweeps still complete.
|
|
891
|
+
- `run_auth_cleanup(deps)` — one-shot consumer entry point: expired
|
|
892
|
+
sessions + expired offers. Returns `{expired_sessions, expired_offers}`.
|
|
893
|
+
**Re-throws sweep errors** so the caller's scheduler can log / alert.
|
|
894
|
+
Call from `setInterval` / cron / similar.
|
|
895
|
+
|
|
896
|
+
Idempotency: the audit log has no tombstone on `permit_offer_expire`, so
|
|
897
|
+
concurrent sweep runs double-audit. Deploy a single scheduled invocation
|
|
898
|
+
per instance — matches `query_session_cleanup_expired`'s expected pattern.
|
|
899
|
+
Expired offer rows are **preserved** (not deleted) — they carry audit value
|
|
900
|
+
for the history view, and accepted rows are the provenance for the
|
|
901
|
+
resulting permit.
|
|
902
|
+
|
|
903
|
+
## Deps
|
|
904
|
+
|
|
905
|
+
`deps.ts` defines:
|
|
906
|
+
|
|
907
|
+
- **`AppDeps`** — the stateless capabilities bundle. Seven members:
|
|
908
|
+
- `stat`, `read_text_file`, `delete_file` — filesystem.
|
|
909
|
+
- `keyring: Keyring` — HMAC-SHA256 signing.
|
|
910
|
+
- `password: PasswordHashDeps` — use `argon2_password_deps` in production.
|
|
911
|
+
- `db: Db` — pool-level instance (middleware uses this; route handlers
|
|
912
|
+
get a transaction-scoped `Db` via `RouteContext`).
|
|
913
|
+
- `log: Logger`.
|
|
914
|
+
- `on_audit_event: (event) => void` — fires after every successful audit
|
|
915
|
+
INSERT. Wire to SSE broadcast for realtime audit streams. Defaults to
|
|
916
|
+
noop when unwired. Flows automatically through every factory that
|
|
917
|
+
receives `deps` / `RouteFactoryDeps`.
|
|
918
|
+
- **`RouteFactoryDeps = Omit<AppDeps, 'db'>`** — for route factories. Route
|
|
919
|
+
handlers receive DB access via `RouteContext`, so factories don't capture
|
|
920
|
+
a pool-level `Db`.
|
|
921
|
+
|
|
922
|
+
See root `../../../CLAUDE.md` §AppDeps Vocabulary for the
|
|
923
|
+
capability / options / runtime-state split across the whole project.
|