@fuzdev/fuz_app 0.53.0 → 0.55.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.
Files changed (144) hide show
  1. package/dist/actions/CLAUDE.md +68 -13
  2. package/dist/actions/action_codegen.d.ts +13 -0
  3. package/dist/actions/action_codegen.d.ts.map +1 -1
  4. package/dist/actions/action_codegen.js +15 -1
  5. package/dist/actions/action_rpc.d.ts +60 -7
  6. package/dist/actions/action_rpc.d.ts.map +1 -1
  7. package/dist/actions/action_rpc.js +158 -44
  8. package/dist/actions/register_action_ws.d.ts +4 -4
  9. package/dist/actions/register_action_ws.js +6 -6
  10. package/dist/actions/register_ws_endpoint.d.ts +20 -7
  11. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  12. package/dist/actions/register_ws_endpoint.js +30 -5
  13. package/dist/actions/transports.d.ts.map +1 -1
  14. package/dist/actions/transports.js +0 -4
  15. package/dist/auth/CLAUDE.md +230 -63
  16. package/dist/auth/account_actions.d.ts +6 -6
  17. package/dist/auth/account_actions.d.ts.map +1 -1
  18. package/dist/auth/account_actions.js +8 -11
  19. package/dist/auth/account_queries.d.ts +6 -3
  20. package/dist/auth/account_queries.d.ts.map +1 -1
  21. package/dist/auth/account_queries.js +14 -5
  22. package/dist/auth/account_routes.d.ts +7 -10
  23. package/dist/auth/account_routes.d.ts.map +1 -1
  24. package/dist/auth/account_routes.js +70 -23
  25. package/dist/auth/account_schema.d.ts +19 -0
  26. package/dist/auth/account_schema.d.ts.map +1 -1
  27. package/dist/auth/account_schema.js +20 -0
  28. package/dist/auth/admin_action_specs.d.ts +45 -11
  29. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  30. package/dist/auth/admin_action_specs.js +23 -8
  31. package/dist/auth/admin_actions.d.ts +8 -7
  32. package/dist/auth/admin_actions.d.ts.map +1 -1
  33. package/dist/auth/admin_actions.js +11 -18
  34. package/dist/auth/audit_log_queries.d.ts +53 -14
  35. package/dist/auth/audit_log_queries.d.ts.map +1 -1
  36. package/dist/auth/audit_log_queries.js +45 -2
  37. package/dist/auth/audit_log_schema.d.ts +55 -1
  38. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  39. package/dist/auth/audit_log_schema.js +19 -3
  40. package/dist/auth/bearer_auth.d.ts +9 -7
  41. package/dist/auth/bearer_auth.d.ts.map +1 -1
  42. package/dist/auth/bearer_auth.js +13 -21
  43. package/dist/auth/cleanup.d.ts.map +1 -1
  44. package/dist/auth/cleanup.js +5 -0
  45. package/dist/auth/daemon_token_middleware.d.ts +23 -11
  46. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  47. package/dist/auth/daemon_token_middleware.js +26 -20
  48. package/dist/auth/deps.d.ts +14 -0
  49. package/dist/auth/deps.d.ts.map +1 -1
  50. package/dist/auth/middleware.d.ts.map +1 -1
  51. package/dist/auth/middleware.js +4 -2
  52. package/dist/auth/migrations.d.ts +15 -7
  53. package/dist/auth/migrations.d.ts.map +1 -1
  54. package/dist/auth/migrations.js +15 -7
  55. package/dist/auth/permit_offer_action_specs.d.ts +45 -6
  56. package/dist/auth/permit_offer_action_specs.d.ts.map +1 -1
  57. package/dist/auth/permit_offer_action_specs.js +38 -7
  58. package/dist/auth/permit_offer_actions.d.ts +2 -2
  59. package/dist/auth/permit_offer_actions.d.ts.map +1 -1
  60. package/dist/auth/permit_offer_actions.js +106 -95
  61. package/dist/auth/permit_offer_notifications.d.ts +10 -0
  62. package/dist/auth/permit_offer_notifications.d.ts.map +1 -1
  63. package/dist/auth/permit_offer_queries.d.ts +68 -9
  64. package/dist/auth/permit_offer_queries.d.ts.map +1 -1
  65. package/dist/auth/permit_offer_queries.js +147 -35
  66. package/dist/auth/permit_offer_schema.d.ts +23 -1
  67. package/dist/auth/permit_offer_schema.d.ts.map +1 -1
  68. package/dist/auth/permit_offer_schema.js +5 -0
  69. package/dist/auth/permit_queries.d.ts +17 -5
  70. package/dist/auth/permit_queries.d.ts.map +1 -1
  71. package/dist/auth/permit_queries.js +19 -8
  72. package/dist/auth/request_context.d.ts +360 -32
  73. package/dist/auth/request_context.d.ts.map +1 -1
  74. package/dist/auth/request_context.js +442 -60
  75. package/dist/auth/route_guards.d.ts +10 -4
  76. package/dist/auth/route_guards.d.ts.map +1 -1
  77. package/dist/auth/route_guards.js +14 -8
  78. package/dist/auth/self_service_role_action_specs.d.ts +2 -0
  79. package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
  80. package/dist/auth/self_service_role_action_specs.js +2 -0
  81. package/dist/auth/self_service_role_actions.d.ts +6 -5
  82. package/dist/auth/self_service_role_actions.d.ts.map +1 -1
  83. package/dist/auth/self_service_role_actions.js +32 -19
  84. package/dist/db/migrate.d.ts +11 -7
  85. package/dist/db/migrate.d.ts.map +1 -1
  86. package/dist/db/migrate.js +9 -6
  87. package/dist/dev/setup.d.ts.map +1 -1
  88. package/dist/dev/setup.js +5 -3
  89. package/dist/hono_context.d.ts +77 -0
  90. package/dist/hono_context.d.ts.map +1 -1
  91. package/dist/hono_context.js +50 -0
  92. package/dist/http/CLAUDE.md +80 -17
  93. package/dist/http/error_schemas.d.ts +92 -1
  94. package/dist/http/error_schemas.d.ts.map +1 -1
  95. package/dist/http/error_schemas.js +73 -16
  96. package/dist/http/jsonrpc_errors.d.ts +27 -2
  97. package/dist/http/jsonrpc_errors.d.ts.map +1 -1
  98. package/dist/http/jsonrpc_errors.js +26 -2
  99. package/dist/http/route_spec.d.ts +62 -4
  100. package/dist/http/route_spec.d.ts.map +1 -1
  101. package/dist/http/route_spec.js +117 -21
  102. package/dist/http/schema_helpers.d.ts +13 -1
  103. package/dist/http/schema_helpers.d.ts.map +1 -1
  104. package/dist/http/schema_helpers.js +21 -2
  105. package/dist/http/surface.d.ts +10 -1
  106. package/dist/http/surface.d.ts.map +1 -1
  107. package/dist/http/surface.js +2 -2
  108. package/dist/server/app_server.d.ts.map +1 -1
  109. package/dist/server/app_server.js +11 -1
  110. package/dist/testing/CLAUDE.md +23 -17
  111. package/dist/testing/admin_integration.d.ts.map +1 -1
  112. package/dist/testing/admin_integration.js +15 -13
  113. package/dist/testing/adversarial_headers.js +1 -1
  114. package/dist/testing/app_server.js +2 -2
  115. package/dist/testing/audit_completeness.d.ts.map +1 -1
  116. package/dist/testing/audit_completeness.js +21 -7
  117. package/dist/testing/auth_apps.d.ts.map +1 -1
  118. package/dist/testing/auth_apps.js +6 -3
  119. package/dist/testing/entities.d.ts +2 -1
  120. package/dist/testing/entities.d.ts.map +1 -1
  121. package/dist/testing/entities.js +1 -0
  122. package/dist/testing/integration_helpers.d.ts +4 -2
  123. package/dist/testing/integration_helpers.d.ts.map +1 -1
  124. package/dist/testing/integration_helpers.js +9 -5
  125. package/dist/testing/middleware.d.ts +12 -8
  126. package/dist/testing/middleware.d.ts.map +1 -1
  127. package/dist/testing/middleware.js +67 -25
  128. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  129. package/dist/testing/rpc_helpers.js +3 -1
  130. package/dist/testing/schema_generators.d.ts.map +1 -1
  131. package/dist/testing/schema_generators.js +12 -0
  132. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  133. package/dist/testing/ws_round_trip.js +5 -1
  134. package/dist/ui/CLAUDE.md +16 -10
  135. package/dist/ui/PermitOfferForm.svelte +14 -0
  136. package/dist/ui/PermitOfferForm.svelte.d.ts +6 -0
  137. package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -1
  138. package/dist/ui/admin_accounts_state.svelte.d.ts +8 -1
  139. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  140. package/dist/ui/admin_accounts_state.svelte.js +14 -3
  141. package/dist/ui/permit_offers_state.svelte.d.ts +9 -1
  142. package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -1
  143. package/dist/ui/permit_offers_state.svelte.js +7 -1
  144. package/package.json +1 -1
@@ -1,22 +1,47 @@
1
1
  /**
2
2
  * Request context middleware and permit checking helpers.
3
3
  *
4
- * Builds `{ account, actor, permits }` from a session cookie
5
- * for every authenticated request. Downstream handlers check
6
- * permits, never flags.
4
+ * Two-phase identity resolution:
7
5
  *
8
- * `build_request_context` is the shared helper used by session,
9
- * bearer, and daemon token middleware to resolve account → actor → permits.
10
- * `refresh_permits` reloads permits on an existing context.
6
+ * 1. **Authentication (middleware)** `create_request_context_middleware`,
7
+ * `bearer_auth`, and `daemon_token_middleware` validate the credential
8
+ * (session cookie, bearer token, daemon token) and set `c.var.account_id`
9
+ * + `c.var.credential_type` on the Hono context. They do not resolve
10
+ * an acting actor or load permits; `REQUEST_CONTEXT_KEY` stays null at
11
+ * this stage, so account-grain identity is the only thing known.
12
+ * 2. **Authorization (route-spec wrapper / RPC dispatcher)** — after input
13
+ * validation, the per-route layer inspects the route. If the input
14
+ * schema declared `acting?: ActingActor` (reference equality with the
15
+ * canonical `ActingActor` schema) or the auth requires permits
16
+ * (`role` / `keeper`), `apply_authorization_phase` resolves the actor
17
+ * against `c.var.account_id` plus the validated `acting` value via
18
+ * `resolve_acting_actor`, builds the `{account, actor, permits}`
19
+ * context via `build_request_context`, and sets it on
20
+ * `REQUEST_CONTEXT_KEY` before auth guards fire. Authenticated routes
21
+ * that don't need an actor still get an account-only context via
22
+ * `build_account_context` so handler signatures stay uniform.
23
+ *
24
+ * Account-grain operations (logout, password_change, account_verify,
25
+ * etc.) declare neither `acting` nor permit-requiring auth, so no actor
26
+ * is resolved and their handlers see a `RequestContext` with
27
+ * `actor: null` + empty `permits`. They never trigger `actor_required`,
28
+ * which is what makes multi-actor logout work without first picking a
29
+ * persona.
30
+ *
31
+ * `build_request_context` loads `account → actor → permits` and verifies
32
+ * the `actor.account_id === account.id` binding. `refresh_permits`
33
+ * reloads permits on an existing context.
11
34
  *
12
35
  * @module
13
36
  */
14
- import { is_permit_active } from './account_schema.js';
37
+ import { z } from 'zod';
38
+ import { zod_unwrap_to_object } from '@fuzdev/fuz_util/zod.js';
39
+ import { ActingActor, is_permit_active, } from './account_schema.js';
15
40
  import { hash_session_token, session_touch_fire_and_forget, query_session_get_valid, } from './session_queries.js';
16
- import { query_actor_by_account, query_account_by_id } from './account_queries.js';
41
+ import { query_account_by_id, query_actor_by_id, query_actors_by_account, } from './account_queries.js';
17
42
  import { query_permit_find_active_for_actor } from './permit_queries.js';
18
- import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
19
- import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, } from '../http/error_schemas.js';
43
+ import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CACHED_REQUEST_BODY_KEY, CREDENTIAL_TYPE_KEY, TEST_CONTEXT_PRESET_KEY, } from '../hono_context.js';
44
+ 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';
20
45
  /** Hono context variable name for the request context. */
21
46
  export const REQUEST_CONTEXT_KEY = 'request_context';
22
47
  /**
@@ -41,18 +66,51 @@ export const get_request_context = (c) => {
41
66
  /**
42
67
  * Get the request context, throwing if unauthenticated.
43
68
  *
44
- * Use in route handlers where auth middleware guarantees a context exists
45
- * (i.e., routes with `auth: {type: 'authenticated'}` or stricter).
46
- * Prefer this over `get_request_context(c)!` for explicit error handling.
69
+ * Use in route handlers where the dispatcher's authorization phase guarantees
70
+ * a context exists (i.e., routes with `auth: {type: 'authenticated'}` or
71
+ * stricter). Prefer this over `get_request_context(c)!` for explicit error
72
+ * handling.
47
73
  *
48
74
  * @param c - the Hono context
49
75
  * @returns the request context (never null)
50
- * @throws Error if no request context is set (middleware misconfiguration)
76
+ * @throws Error if no request context is set (dispatcher misconfiguration)
51
77
  */
52
78
  export const require_request_context = (c) => {
53
79
  const ctx = get_request_context(c);
54
80
  if (!ctx) {
55
- throw new Error('require_request_context: no request context — is auth middleware applied?');
81
+ throw new Error('require_request_context: no request context — is the dispatcher authorization phase wired?');
82
+ }
83
+ return ctx;
84
+ };
85
+ /**
86
+ * Narrow `RequestContext | null` to a non-null context (auth invariant).
87
+ *
88
+ * Use in RPC action handlers whose spec is non-public — the dispatcher's
89
+ * pre-validation auth gate has already short-circuited unauthenticated
90
+ * callers, so `ctx.auth` is non-null by the time the handler runs.
91
+ *
92
+ * @throws Error when called from a public-auth handler (programmer error)
93
+ */
94
+ export const require_request_auth = (auth) => {
95
+ if (!auth) {
96
+ throw new Error('require_request_auth: no auth — is this handler bound to a non-public action spec?');
97
+ }
98
+ return auth;
99
+ };
100
+ /**
101
+ * Narrow `RequestContext | null` to `RequestActorContext` (actor invariant).
102
+ *
103
+ * Use in RPC action handlers whose spec declares `auth: 'keeper' | {role}`
104
+ * or whose input declares `acting?: ActingActor` — the dispatcher's
105
+ * authorization phase resolves an actor before the handler runs. Replaces
106
+ * the `ctx.auth!.actor!.id` chain that the type system can't otherwise see.
107
+ *
108
+ * @throws Error when the handler runs without actor resolution (programmer error)
109
+ */
110
+ export const require_request_actor = (auth) => {
111
+ const ctx = require_request_auth(auth);
112
+ if (!ctx.actor) {
113
+ 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`?');
56
114
  }
57
115
  return ctx;
58
116
  };
@@ -60,63 +118,151 @@ export const require_request_context = (c) => {
60
118
  * Check if a request context has an active permit for a given role.
61
119
  *
62
120
  * Checks the permits already loaded in the context (no DB query).
121
+ * Null-tolerant — `null` ctx (unauthenticated) returns `false`. Symmetric
122
+ * with `has_scoped_role` / `has_any_scoped_role` so the three helpers
123
+ * compose freely in the same predicate (e.g.
124
+ * `has_role(auth, ADMIN) || has_scoped_role(auth, role, scope)`).
63
125
  *
64
- * @param ctx - the request context
126
+ * @param ctx - the request context, or `null` for unauthenticated callers
65
127
  * @param role - the role to check
66
128
  * @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
67
129
  * @returns `true` if the actor has an active permit for the role
68
130
  */
69
- export const has_role = (ctx, role, now = new Date()) => ctx.permits.some((p) => p.role === role && is_permit_active(p, now));
131
+ export const has_role = (ctx, role, now = new Date()) => ctx?.permits.some((p) => p.role === role && is_permit_active(p, now)) ?? false;
132
+ /**
133
+ * Whether the request context holds an active permit for `role` at `scope_id`.
134
+ *
135
+ * Walks the in-memory `ctx.permits` snapshot loaded once per request by
136
+ * the route-spec / RPC dispatcher's authorization phase (when the route
137
+ * declares `acting?: ActingActor` or has permit-requiring auth); zero DB
138
+ * roundtrip per check. The "freshness" framing of a SQL re-query is
139
+ * illusory because the race window is between predicate and the actual
140
+ * mutation, not predicate and authorization load. Closing that race needs
141
+ * a transactional re-check inside the UPDATE/INSERT, which neither style
142
+ * provides.
143
+ *
144
+ * Null-tolerant — `null` ctx (unauthenticated) and account-grain
145
+ * contexts (`actor: null`, empty `permits`) both return `false`. Same
146
+ * convention as `has_role`; lets the helper drop into `auth: 'public'`
147
+ * or account-grain handlers without a manual narrow. See `cell_authorize`
148
+ * for the resource-side analog.
149
+ *
150
+ * `scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so
151
+ * JS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly:
152
+ *
153
+ * - `scope_id === null` matches global permits (`scope_id IS NULL`).
154
+ * - `scope_id === '<uuid>'` matches permits bound to that exact scope.
155
+ *
156
+ * @param ctx - the request context, or `null` for unauthenticated callers
157
+ * @param role - the role to check
158
+ * @param scope_id - the scope to check (`null` for global)
159
+ * @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
160
+ * @returns `true` iff the actor holds an active permit for the role at the requested scope
161
+ */
162
+ export const has_scoped_role = (ctx, role, scope_id, now = new Date()) => {
163
+ if (!ctx)
164
+ return false;
165
+ return ctx.permits.some((p) => p.role === role && p.scope_id === scope_id && is_permit_active(p, now));
166
+ };
167
+ /**
168
+ * Whether the request context holds an active permit for any role in `roles`
169
+ * at `scope_id`. Empty `roles` short-circuits to `false` — documents intent
170
+ * at the call site ("zero roles trivially admit no-one"). Same scope and
171
+ * null-tolerance semantics as `has_scoped_role`.
172
+ *
173
+ * @param ctx - the request context, or `null` for unauthenticated callers
174
+ * @param roles - the roles that would admit the caller (any-of)
175
+ * @param scope_id - the scope to check (`null` for global)
176
+ * @param now - current time (defaults to `new Date()`, pass for testability)
177
+ * @returns `true` iff the actor holds an active permit for any role in `roles` at the requested scope
178
+ */
179
+ export const has_any_scoped_role = (ctx, roles, scope_id, now = new Date()) => {
180
+ if (!ctx)
181
+ return false;
182
+ if (roles.length === 0)
183
+ return false;
184
+ return ctx.permits.some((p) => roles.includes(p.role) && p.scope_id === scope_id && is_permit_active(p, now));
185
+ };
70
186
  /**
71
- * Create middleware that builds the request context from a session cookie.
187
+ * Resolve the acting actor for an authenticated request.
72
188
  *
73
- * Reads the session identity (set by session middleware), looks up
74
- * the `auth_session`, loads account + actor + active permits, and
75
- * sets the `RequestContext` on the Hono context.
189
+ * Called from the route-spec / RPC dispatcher's authorization phase
190
+ * with the authenticated account id and the validated `acting` value
191
+ * (from the request payload). Applies the uniform resolution rules:
76
192
  *
77
- * If the session is invalid or the account is not found, the context
78
- * is set to `null` (unauthenticated). No 401 is returned use
79
- * `require_role` or `require_auth` for enforcement.
193
+ * - `acting_actor_id` omitted + 1 actor use it.
194
+ * - `acting_actor_id` omitted + 0 actors `no_actors` (defensive
195
+ * signup / bootstrap always create an actor in the same tx, so this
196
+ * is a server error).
197
+ * - `acting_actor_id` omitted + multiple actors → `actor_required` with
198
+ * the available list so the client can prompt; never pick silently.
199
+ * - `acting_actor_id` present + matches an actor on the account → use it.
200
+ * - `acting_actor_id` present + does not match → `actor_not_on_account`.
201
+ * The available list is intentionally not echoed in this branch (treat
202
+ * as opaque rejection).
203
+ *
204
+ * @param deps - query dependencies
205
+ * @param account_id - the authenticated account
206
+ * @param acting_actor_id - the requested acting actor id, or `undefined`
207
+ */
208
+ export const resolve_acting_actor = async (deps, account_id, acting_actor_id) => {
209
+ const actors = await query_actors_by_account(deps, account_id);
210
+ if (actors.length === 0)
211
+ return { ok: false, reason: 'no_actors' };
212
+ if (acting_actor_id == null) {
213
+ if (actors.length === 1)
214
+ return { ok: true, actor_id: actors[0].id };
215
+ return {
216
+ ok: false,
217
+ reason: 'actor_required',
218
+ available: actors.map((a) => ({ id: a.id, name: a.name })),
219
+ };
220
+ }
221
+ const match = actors.find((a) => a.id === acting_actor_id);
222
+ if (!match)
223
+ return { ok: false, reason: 'actor_not_on_account' };
224
+ return { ok: true, actor_id: match.id };
225
+ };
226
+ /**
227
+ * Create middleware that authenticates the account from a session cookie.
228
+ *
229
+ * Reads the session identity (set by session middleware), looks up the
230
+ * `auth_session`, and on a valid session sets `c.var.auth_account_id`,
231
+ * `CREDENTIAL_TYPE_KEY = 'session'`, and `AUTH_SESSION_TOKEN_HASH_KEY`.
232
+ * Touches the session (fire-and-forget). Does not load actor or permits;
233
+ * `REQUEST_CONTEXT_KEY` is left null — the route-spec / RPC dispatcher
234
+ * authorization phase resolves the acting actor and builds the full
235
+ * `RequestContext` when the route needs one.
236
+ *
237
+ * Invalid / missing session leaves all keys null and calls `next()` —
238
+ * `require_auth` / `require_role` enforce.
80
239
  *
81
240
  * @param deps - query dependencies (pool-level db for middleware)
82
241
  * @param log - the logger instance
83
242
  * @param session_context_key - the Hono context key where session middleware stored the session token
84
- * @mutates Hono context - sets `REQUEST_CONTEXT_KEY`, `CREDENTIAL_TYPE_KEY`, `AUTH_SESSION_TOKEN_HASH_KEY`, and `AUTH_API_TOKEN_ID_KEY`
243
+ * @mutates Hono context - sets `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, `AUTH_SESSION_TOKEN_HASH_KEY`, and `AUTH_API_TOKEN_ID_KEY`
85
244
  */
86
245
  export const create_request_context_middleware = (deps, log, session_context_key = 'auth_session_id') => {
87
246
  return async (c, next) => {
247
+ c.set(REQUEST_CONTEXT_KEY, null);
248
+ c.set(ACCOUNT_ID_KEY, null);
249
+ c.set(CREDENTIAL_TYPE_KEY, null);
250
+ c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
251
+ c.set(AUTH_API_TOKEN_ID_KEY, null);
88
252
  const session_token = c.get(session_context_key) ?? null;
89
253
  if (!session_token) {
90
- c.set(REQUEST_CONTEXT_KEY, null);
91
- c.set(CREDENTIAL_TYPE_KEY, null);
92
- c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
93
- c.set(AUTH_API_TOKEN_ID_KEY, null);
94
254
  await next();
95
255
  return;
96
256
  }
97
257
  const token_hash = hash_session_token(session_token);
98
258
  const session = await query_session_get_valid(deps, token_hash);
99
259
  if (!session) {
100
- c.set(REQUEST_CONTEXT_KEY, null);
101
- c.set(CREDENTIAL_TYPE_KEY, null);
102
- c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
103
- c.set(AUTH_API_TOKEN_ID_KEY, null);
104
260
  await next();
105
261
  return;
106
262
  }
107
- const ctx = await build_request_context(deps, session.account_id);
108
- if (!ctx) {
109
- c.set(REQUEST_CONTEXT_KEY, null);
110
- c.set(CREDENTIAL_TYPE_KEY, null);
111
- c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
112
- c.set(AUTH_API_TOKEN_ID_KEY, null);
113
- await next();
114
- return;
115
- }
116
- c.set(REQUEST_CONTEXT_KEY, ctx);
263
+ c.set(ACCOUNT_ID_KEY, session.account_id);
117
264
  c.set(CREDENTIAL_TYPE_KEY, 'session');
118
265
  c.set(AUTH_SESSION_TOKEN_HASH_KEY, token_hash);
119
- c.set(AUTH_API_TOKEN_ID_KEY, null);
120
266
  // Touch session (fire-and-forget, don't block the request)
121
267
  void session_touch_fire_and_forget(deps, token_hash, c.var.pending_effects, log);
122
268
  await next();
@@ -125,11 +271,10 @@ export const create_request_context_middleware = (deps, log, session_context_key
125
271
  /**
126
272
  * Middleware that requires authentication.
127
273
  *
128
- * Returns 401 if no request context is set.
274
+ * Returns 401 if the auth middleware did not set `c.var.auth_account_id`.
129
275
  */
130
276
  export const require_auth = async (c, next) => {
131
- const ctx = get_request_context(c);
132
- if (!ctx) {
277
+ if (c.get(ACCOUNT_ID_KEY) == null) {
133
278
  return c.json({ error: ERROR_AUTHENTICATION_REQUIRED }, 401);
134
279
  }
135
280
  await next();
@@ -137,17 +282,20 @@ export const require_auth = async (c, next) => {
137
282
  /**
138
283
  * Create middleware that requires a specific role.
139
284
  *
140
- * Returns 401 if unauthenticated, 403 if the role is missing.
285
+ * Returns 401 if unauthenticated, 403 if the role is missing. Reads
286
+ * `REQUEST_CONTEXT_KEY` because role-gated routes always run the
287
+ * dispatcher's authorization phase before this guard (the phase sets the
288
+ * actor-bound `RequestContext`).
141
289
  *
142
290
  * @param role - the required role
143
291
  */
144
292
  export const require_role = (role) => {
145
293
  return async (c, next) => {
146
- const ctx = get_request_context(c);
147
- if (!ctx) {
294
+ if (c.get(ACCOUNT_ID_KEY) == null) {
148
295
  return c.json({ error: ERROR_AUTHENTICATION_REQUIRED }, 401);
149
296
  }
150
- if (!has_role(ctx, role)) {
297
+ const ctx = get_request_context(c);
298
+ if (!ctx || !has_role(ctx, role)) {
151
299
  return c.json({ error: ERROR_INSUFFICIENT_PERMISSIONS, required_role: role }, 403);
152
300
  }
153
301
  await next();
@@ -161,35 +309,269 @@ export const require_role = (role) => {
161
309
  * or after receiving a revocation signal.
162
310
  *
163
311
  * Returns a new `RequestContext` with updated permits — the original
164
- * context is not mutated, making concurrent calls safe.
312
+ * context is not mutated, making concurrent calls safe. Throws when
313
+ * `ctx.actor` is null; account-grain contexts have no permits to refresh.
165
314
  *
166
315
  * @param ctx - the request context to refresh
167
316
  * @param deps - query dependencies
168
317
  * @returns a new `RequestContext` with fresh permits
318
+ * @throws Error when called on an account-grain context (`actor: null`)
169
319
  */
170
320
  export const refresh_permits = async (ctx, deps) => {
321
+ if (!ctx.actor) {
322
+ throw new Error('refresh_permits: account-grain context has no actor / permits to refresh');
323
+ }
171
324
  const permits = await query_permit_find_active_for_actor(deps, ctx.actor.id);
172
325
  return { ...ctx, permits };
173
326
  };
174
327
  /**
175
- * Build a full `RequestContext` from an account id.
328
+ * Build a full `RequestContext` from an account id and an explicit
329
+ * actor id (already resolved via `resolve_acting_actor`).
330
+ *
331
+ * Loads `account` + the named `actor` + the actor's active permits.
332
+ * Verifies the `actor.account_id === account.id` binding so downstream
333
+ * handlers can trust `ctx.actor.account_id === ctx.account.id`. Returns
334
+ * `null` when the account is missing, the actor is missing, or the
335
+ * actor doesn't belong to the supplied account.
176
336
  *
177
- * Shared helper used by session, bearer, and daemon token middleware,
178
- * as well as WebSocket upgrade handlers. Does the account actor → permits
179
- * lookup pipeline and returns the composed context, or `null` if
180
- * the account or actor is not found.
337
+ * Called by the route-spec / RPC dispatcher's authorization phase for
338
+ * routes that need an acting actor; account-grain routes use
339
+ * `build_account_context` instead.
181
340
  *
182
341
  * @param deps - query dependencies
183
342
  * @param account_id - the account to build context for
184
- * @returns a request context, or `null` if account/actor not found
343
+ * @param actor_id - the actor this request acts as
344
+ * @returns a request context, or `null` if account/actor not found or mismatched
185
345
  */
186
- export const build_request_context = async (deps, account_id) => {
346
+ export const build_request_context = async (deps, account_id, actor_id) => {
187
347
  const account = await query_account_by_id(deps, account_id);
188
348
  if (!account)
189
349
  return null;
190
- const actor = await query_actor_by_account(deps, account.id);
350
+ const actor = await query_actor_by_id(deps, actor_id);
191
351
  if (!actor)
192
352
  return null;
353
+ if (actor.account_id !== account.id)
354
+ return null;
193
355
  const permits = await query_permit_find_active_for_actor(deps, actor.id);
194
356
  return { account, actor, permits };
195
357
  };
358
+ /**
359
+ * Build an account-only `RequestContext` (no actor, no permits) from
360
+ * an account id.
361
+ *
362
+ * Used by the dispatcher's authorization phase for authenticated routes
363
+ * that don't need an acting actor — account-grain operations (logout,
364
+ * password change, account self-service). Lets handlers read
365
+ * `auth.account.id` / `auth.account.username` uniformly with permit-bound
366
+ * routes; the cost is one extra `query_account_by_id` per request.
367
+ *
368
+ * Returns `null` when the account row is missing (e.g. deleted between
369
+ * the auth middleware's session lookup and the dispatcher) — caller
370
+ * surfaces that as a 500 since it represents a torn read.
371
+ *
372
+ * @param deps - query dependencies
373
+ * @param account_id - the account to build context for
374
+ * @returns an account-only request context, or `null` if the account is missing
375
+ */
376
+ export const build_account_context = async (deps, account_id) => {
377
+ const account = await query_account_by_id(deps, account_id);
378
+ if (!account)
379
+ return null;
380
+ return { account, actor: null, permits: [] };
381
+ };
382
+ /**
383
+ * Whether the supplied auth descriptor implies an acting actor must be
384
+ * resolved (i.e., permit-requiring auth: `'role'` or `'keeper'`).
385
+ *
386
+ * The dispatcher's authorization phase uses this to decide whether to
387
+ * walk the actor list when the input schema doesn't already declare
388
+ * `acting?: ActingActor`. Accepts either auth shape — the route-spec
389
+ * `RouteAuth` (`{type: 'role' | 'keeper' | ...}`) or the action-spec
390
+ * `ActionAuth` (`'keeper' | {role}`) — so HTTP and RPC dispatchers share
391
+ * one source of truth for the "permit-bound" rule.
392
+ */
393
+ export const is_actor_implying_auth = (auth) => {
394
+ if (typeof auth === 'string')
395
+ return auth === 'keeper';
396
+ if ('type' in auth)
397
+ return auth.type === 'role' || auth.type === 'keeper';
398
+ return 'role' in auth;
399
+ };
400
+ /**
401
+ * Whether an input schema declares the canonical `acting?: ActingActor`
402
+ * field. Reference-equality on the exported `ActingActor` schema —
403
+ * consumer schemas with unrelated `acting` fields don't trip this check.
404
+ *
405
+ * Peels through Zod wrappers (`optional`, `nullable`, `default`,
406
+ * `transform`, `pipe`, `prefault`) via `zod_unwrap_to_object` so a spec
407
+ * authored as `z.optional(z.strictObject({acting: ActingActor}))` or
408
+ * `z.strictObject({acting: ActingActor}).default({})` still trips the
409
+ * predicate. The wrapper-tolerant lookup is defense-in-depth — the
410
+ * canonical shape is the un-wrapped `z.strictObject({acting: ActingActor})`,
411
+ * but variant B in `~/dev/grimoire/lore/fuz_app/TODO_PUBLIC_AUTH_PHASE.md`
412
+ * makes this predicate authorization-correctness load-bearing for
413
+ * `auth: 'public'` actions, so missing a wrapper-bound declaration
414
+ * would silently skip actor resolution. The reference-equality check
415
+ * on `ActingActor` keeps consumer schemas with unrelated `acting`
416
+ * fields from tripping the predicate even after the wrapper peel.
417
+ *
418
+ * The dispatcher's authorization phase uses this to decide whether to
419
+ * pull the actor id from validated input (so multi-actor users can pick
420
+ * a persona on actor-needing routes).
421
+ */
422
+ export const input_schema_declares_acting = (schema) => {
423
+ const obj = zod_unwrap_to_object(schema);
424
+ if (!obj)
425
+ return false;
426
+ return obj.shape.acting === ActingActor;
427
+ };
428
+ /**
429
+ * Apply the dispatcher's authorization phase. Shared by the route-spec
430
+ * wrapper and the RPC dispatcher.
431
+ *
432
+ * - When `c.var.auth_account_id` is `null`, returns `void` so the
433
+ * downstream auth guard can fire 401 (less-helpful than `actor_required`
434
+ * for the unauthenticated case).
435
+ * - When `needs_actor` is true, resolves the actor against the account
436
+ * plus the supplied `acting` value, then builds the full
437
+ * `{account, actor, permits}` context.
438
+ * - When `needs_actor` is false, builds an account-only context so
439
+ * handler signatures stay uniform across the surface.
440
+ *
441
+ * On resolution failure returns an `AuthorizationFailure` (`{status, body}`)
442
+ * the caller wraps in a transport-appropriate response. Three 500 branches
443
+ * are kept distinct so the wire shape names what actually went wrong:
444
+ *
445
+ * - 500 `ERROR_NO_ACTORS_ON_ACCOUNT` — `resolve_acting_actor` returned
446
+ * `no_actors`. The actor enumeration succeeded and came back empty;
447
+ * signup / bootstrap should have created one in the same transaction,
448
+ * so this is a real corruption signal.
449
+ * - 500 `ERROR_ACCOUNT_VANISHED` — `build_request_context` /
450
+ * `build_account_context` returned null after a successful
451
+ * `resolve_acting_actor`. The account or actor row was deleted between
452
+ * the credential check and authorization (torn read race), or — in
453
+ * the `build_request_context` actor↔account mismatch sub-branch — the
454
+ * binding flipped under us. Reachability of the mismatch sub-branch in
455
+ * production is essentially zero (`resolve_acting_actor` already
456
+ * verified the actor was on this account, and `actor.account_id` only
457
+ * changes via row-level edits no production path makes), so collapsing
458
+ * that case into the torn-read shape costs nothing.
459
+ *
460
+ * Other failure paths: 400 `ERROR_ACTOR_REQUIRED` / `ERROR_ACTOR_NOT_ON_ACCOUNT`.
461
+ * Returns `undefined` on success.
462
+ *
463
+ * @mutates Hono context - sets `REQUEST_CONTEXT_KEY` on success
464
+ */
465
+ export const apply_authorization_phase = async (deps, c, needs_actor, acting_value) => {
466
+ // Test escape hatch: when a harness pre-populates `REQUEST_CONTEXT_KEY`
467
+ // it must also flag `TEST_CONTEXT_PRESET_KEY = true` (set by
468
+ // `create_test_app_from_specs` / `create_fake_hono_context` / per-test
469
+ // middleware). Production middleware never sets this flag, so future
470
+ // production code that consults `REQUEST_CONTEXT_KEY` cannot silently
471
+ // bypass the live build the way an implicit presence probe would.
472
+ if (c.get(TEST_CONTEXT_PRESET_KEY))
473
+ return;
474
+ const account_id = c.get(ACCOUNT_ID_KEY) ?? null;
475
+ if (account_id == null)
476
+ return; // auth guard handles 401
477
+ if (needs_actor) {
478
+ const acting = await resolve_acting_actor(deps, account_id, acting_value);
479
+ if (!acting.ok) {
480
+ if (acting.reason === 'actor_required') {
481
+ return {
482
+ status: 400,
483
+ body: { error: ERROR_ACTOR_REQUIRED, available: acting.available },
484
+ };
485
+ }
486
+ if (acting.reason === 'actor_not_on_account') {
487
+ return { status: 400, body: { error: ERROR_ACTOR_NOT_ON_ACCOUNT } };
488
+ }
489
+ return { status: 500, body: { error: ERROR_NO_ACTORS_ON_ACCOUNT } };
490
+ }
491
+ const ctx = await build_request_context(deps, account_id, acting.actor_id);
492
+ if (!ctx)
493
+ return { status: 500, body: { error: ERROR_ACCOUNT_VANISHED } };
494
+ c.set(REQUEST_CONTEXT_KEY, ctx);
495
+ return;
496
+ }
497
+ const ctx = await build_account_context(deps, account_id);
498
+ if (!ctx)
499
+ return { status: 500, body: { error: ERROR_ACCOUNT_VANISHED } };
500
+ c.set(REQUEST_CONTEXT_KEY, ctx);
501
+ };
502
+ /**
503
+ * Create the route-spec authorization handler used by `apply_route_specs`.
504
+ *
505
+ * Decides whether the route needs actor resolution from `spec.auth` plus
506
+ * `spec.input` introspection, extracts the raw `acting` value (string
507
+ * typeguard, no schema validation), and delegates to
508
+ * `apply_authorization_phase`. Public routes (`auth.type === 'none'`) skip
509
+ * the phase entirely; their handlers see no `RequestContext`.
510
+ *
511
+ * Authorization runs before input validation (matches the RPC dispatcher's
512
+ * order). For GET routes `acting` comes from the URL query string; for
513
+ * mutating methods it comes from a pre-parse of the JSON body. The pre-
514
+ * parse result lands on `c.var.cached_request_body` so the subsequent
515
+ * `create_input_validation` step reads the parsed value from there
516
+ * without re-running `JSON.parse` — explicit cache, independent of
517
+ * Hono's internal `bodyCache` behavior. A malformed body fails the
518
+ * pre-parse silently (`acting` treated as undefined, cache flagged
519
+ * `{ok: false}`) and is then rejected with `ERROR_INVALID_JSON_BODY`
520
+ * by the input-validation step that reads the failure flag — producing
521
+ * the same final response as if the validation step had parsed first.
522
+ */
523
+ export const create_fuz_authorization_handler = (deps) => {
524
+ return async (c, spec) => {
525
+ if (spec.auth.type === 'none')
526
+ return;
527
+ const declares_acting = input_schema_declares_acting(spec.input);
528
+ const needs_actor = is_actor_implying_auth(spec.auth) || declares_acting;
529
+ let acting_value;
530
+ if (declares_acting) {
531
+ const raw_acting = await read_raw_acting(c, spec.method);
532
+ acting_value = typeof raw_acting === 'string' ? raw_acting : undefined;
533
+ }
534
+ const failure = await apply_authorization_phase(deps, c, needs_actor, acting_value);
535
+ if (!failure)
536
+ return;
537
+ return c.json(failure.body, failure.status);
538
+ };
539
+ };
540
+ /**
541
+ * Extract the raw `acting` value from a request before input validation
542
+ * has run. Returns `undefined` on parse failure or non-object body; the
543
+ * downstream input-validation step then rejects malformed bodies with
544
+ * `ERROR_INVALID_JSON_BODY`.
545
+ *
546
+ * Writes the parse result to `c.var.cached_request_body` so the
547
+ * input-validation step does not re-run `JSON.parse` on the same Hono-
548
+ * cached body text. Hono's internal `bodyCache` keeps the body text
549
+ * alive across multiple `c.req.json()` calls, but each call still
550
+ * re-parses — caching the parsed value here decouples our pipeline
551
+ * from that undocumented detail (and saves the second parse).
552
+ *
553
+ * Three cache states:
554
+ *
555
+ * - GET (early return) — no cache write; the input-validation step is
556
+ * a no-op for GET so nothing reads the cache anyway.
557
+ * - Successful parse (any JSON value) — `{ok: true, body}`. The
558
+ * input-validation step reads `body` and runs the non-object check
559
+ * itself.
560
+ * - Parse failure — `{ok: false}`. The input-validation step short-
561
+ * circuits with `ERROR_INVALID_JSON_BODY` without re-parsing.
562
+ */
563
+ const read_raw_acting = async (c, method) => {
564
+ if (method === 'GET')
565
+ return c.req.query('acting');
566
+ try {
567
+ const body = await c.req.json();
568
+ c.set(CACHED_REQUEST_BODY_KEY, { ok: true, body });
569
+ if (typeof body === 'object' && body !== null && !Array.isArray(body)) {
570
+ return body.acting;
571
+ }
572
+ }
573
+ catch {
574
+ c.set(CACHED_REQUEST_BODY_KEY, { ok: false });
575
+ }
576
+ return undefined;
577
+ };
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Auth guard resolver for the route spec system.
3
3
  *
4
- * Maps `RouteAuth` discriminants to auth middleware handlers.
4
+ * Maps `RouteAuth` discriminants to two-phase auth middleware sets.
5
+ * `pre_validation` carries the 401 check (`require_auth`) so
6
+ * unauthenticated callers never see route-shape information from input
7
+ * parse failures. `post_authorization` carries the 403 role / keeper
8
+ * checks because they read the `RequestContext` populated by the
9
+ * dispatcher's authorization phase.
10
+ *
5
11
  * Injected into `apply_route_specs` to decouple the generic HTTP
6
12
  * framework (`http/route_spec.ts`) from auth-specific middleware.
7
13
  *
@@ -13,9 +19,9 @@ import type { AuthGuardResolver } from '../http/route_spec.js';
13
19
  *
14
20
  * Maps `RouteAuth` to middleware:
15
21
  * - `none` → no guards
16
- * - `authenticated` → `require_auth`
17
- * - `role` → `require_role(role)`
18
- * - `keeper` → `require_keeper`
22
+ * - `authenticated` → pre-validation `require_auth`
23
+ * - `role` → pre-validation `require_auth` + post-authorization `require_role(role)`
24
+ * - `keeper` → pre-validation `require_auth` + post-authorization `require_keeper`
19
25
  */
20
26
  export declare const fuz_auth_guard_resolver: AuthGuardResolver;
21
27
  //# sourceMappingURL=route_guards.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"route_guards.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/route_guards.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,uBAAuB,CAAC;AAE7D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,EAAE,iBAWrC,CAAC"}
1
+ {"version":3,"file":"route_guards.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/route_guards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,uBAAuB,CAAC;AAE7D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,EAAE,iBAWrC,CAAC"}