@fuzdev/fuz_app 0.54.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 (142) 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 +219 -66
  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 +98 -90
  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 +321 -38
  73. package/dist/auth/request_context.d.ts.map +1 -1
  74. package/dist/auth/request_context.js +393 -66
  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 +18 -8
  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/ws_round_trip.d.ts.map +1 -1
  131. package/dist/testing/ws_round_trip.js +5 -1
  132. package/dist/ui/CLAUDE.md +16 -10
  133. package/dist/ui/PermitOfferForm.svelte +14 -0
  134. package/dist/ui/PermitOfferForm.svelte.d.ts +6 -0
  135. package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -1
  136. package/dist/ui/admin_accounts_state.svelte.d.ts +8 -1
  137. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  138. package/dist/ui/admin_accounts_state.svelte.js +14 -3
  139. package/dist/ui/permit_offers_state.svelte.d.ts +9 -1
  140. package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -1
  141. package/dist/ui/permit_offers_state.svelte.js +7 -1
  142. 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
  };
@@ -75,16 +133,19 @@ export const has_role = (ctx, role, now = new Date()) => ctx?.permits.some((p) =
75
133
  * Whether the request context holds an active permit for `role` at `scope_id`.
76
134
  *
77
135
  * Walks the in-memory `ctx.permits` snapshot loaded once per request by
78
- * `create_request_context_middleware`; zero DB roundtrip per check. The
79
- * "freshness" framing of a SQL re-query is illusory because the race window
80
- * is between predicate and the actual mutation, not predicate and middleware
81
- * load. Closing that race needs a transactional re-check inside the
82
- * UPDATE/INSERT, which neither style provides.
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.
83
143
  *
84
- * Null-tolerant — `null` ctx (unauthenticated) returns `false`. Same
144
+ * Null-tolerant — `null` ctx (unauthenticated) and account-grain
145
+ * contexts (`actor: null`, empty `permits`) both return `false`. Same
85
146
  * convention as `has_role`; lets the helper drop into `auth: 'public'`
86
- * handlers without a manual narrow. See `cell_authorize` for the
87
- * resource-side analog.
147
+ * or account-grain handlers without a manual narrow. See `cell_authorize`
148
+ * for the resource-side analog.
88
149
  *
89
150
  * `scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so
90
151
  * JS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly:
@@ -123,55 +184,85 @@ export const has_any_scoped_role = (ctx, roles, scope_id, now = new Date()) => {
123
184
  return ctx.permits.some((p) => roles.includes(p.role) && p.scope_id === scope_id && is_permit_active(p, now));
124
185
  };
125
186
  /**
126
- * Create middleware that builds the request context from a session cookie.
187
+ * Resolve the acting actor for an authenticated request.
188
+ *
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:
192
+ *
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.
127
228
  *
128
- * Reads the session identity (set by session middleware), looks up
129
- * the `auth_session`, loads account + actor + active permits, and
130
- * sets the `RequestContext` on the Hono context.
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.
131
236
  *
132
- * If the session is invalid or the account is not found, the context
133
- * is set to `null` (unauthenticated). No 401 is returned — use
134
- * `require_role` or `require_auth` for enforcement.
237
+ * Invalid / missing session leaves all keys null and calls `next()`
238
+ * `require_auth` / `require_role` enforce.
135
239
  *
136
240
  * @param deps - query dependencies (pool-level db for middleware)
137
241
  * @param log - the logger instance
138
242
  * @param session_context_key - the Hono context key where session middleware stored the session token
139
- * @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`
140
244
  */
141
245
  export const create_request_context_middleware = (deps, log, session_context_key = 'auth_session_id') => {
142
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);
143
252
  const session_token = c.get(session_context_key) ?? null;
144
253
  if (!session_token) {
145
- c.set(REQUEST_CONTEXT_KEY, null);
146
- c.set(CREDENTIAL_TYPE_KEY, null);
147
- c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
148
- c.set(AUTH_API_TOKEN_ID_KEY, null);
149
254
  await next();
150
255
  return;
151
256
  }
152
257
  const token_hash = hash_session_token(session_token);
153
258
  const session = await query_session_get_valid(deps, token_hash);
154
259
  if (!session) {
155
- c.set(REQUEST_CONTEXT_KEY, null);
156
- c.set(CREDENTIAL_TYPE_KEY, null);
157
- c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
158
- c.set(AUTH_API_TOKEN_ID_KEY, null);
159
- await next();
160
- return;
161
- }
162
- const ctx = await build_request_context(deps, session.account_id);
163
- if (!ctx) {
164
- c.set(REQUEST_CONTEXT_KEY, null);
165
- c.set(CREDENTIAL_TYPE_KEY, null);
166
- c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
167
- c.set(AUTH_API_TOKEN_ID_KEY, null);
168
260
  await next();
169
261
  return;
170
262
  }
171
- c.set(REQUEST_CONTEXT_KEY, ctx);
263
+ c.set(ACCOUNT_ID_KEY, session.account_id);
172
264
  c.set(CREDENTIAL_TYPE_KEY, 'session');
173
265
  c.set(AUTH_SESSION_TOKEN_HASH_KEY, token_hash);
174
- c.set(AUTH_API_TOKEN_ID_KEY, null);
175
266
  // Touch session (fire-and-forget, don't block the request)
176
267
  void session_touch_fire_and_forget(deps, token_hash, c.var.pending_effects, log);
177
268
  await next();
@@ -180,11 +271,10 @@ export const create_request_context_middleware = (deps, log, session_context_key
180
271
  /**
181
272
  * Middleware that requires authentication.
182
273
  *
183
- * Returns 401 if no request context is set.
274
+ * Returns 401 if the auth middleware did not set `c.var.auth_account_id`.
184
275
  */
185
276
  export const require_auth = async (c, next) => {
186
- const ctx = get_request_context(c);
187
- if (!ctx) {
277
+ if (c.get(ACCOUNT_ID_KEY) == null) {
188
278
  return c.json({ error: ERROR_AUTHENTICATION_REQUIRED }, 401);
189
279
  }
190
280
  await next();
@@ -192,17 +282,20 @@ export const require_auth = async (c, next) => {
192
282
  /**
193
283
  * Create middleware that requires a specific role.
194
284
  *
195
- * 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`).
196
289
  *
197
290
  * @param role - the required role
198
291
  */
199
292
  export const require_role = (role) => {
200
293
  return async (c, next) => {
201
- const ctx = get_request_context(c);
202
- if (!ctx) {
294
+ if (c.get(ACCOUNT_ID_KEY) == null) {
203
295
  return c.json({ error: ERROR_AUTHENTICATION_REQUIRED }, 401);
204
296
  }
205
- if (!has_role(ctx, role)) {
297
+ const ctx = get_request_context(c);
298
+ if (!ctx || !has_role(ctx, role)) {
206
299
  return c.json({ error: ERROR_INSUFFICIENT_PERMISSIONS, required_role: role }, 403);
207
300
  }
208
301
  await next();
@@ -216,35 +309,269 @@ export const require_role = (role) => {
216
309
  * or after receiving a revocation signal.
217
310
  *
218
311
  * Returns a new `RequestContext` with updated permits — the original
219
- * 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.
220
314
  *
221
315
  * @param ctx - the request context to refresh
222
316
  * @param deps - query dependencies
223
317
  * @returns a new `RequestContext` with fresh permits
318
+ * @throws Error when called on an account-grain context (`actor: null`)
224
319
  */
225
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
+ }
226
324
  const permits = await query_permit_find_active_for_actor(deps, ctx.actor.id);
227
325
  return { ...ctx, permits };
228
326
  };
229
327
  /**
230
- * 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.
231
336
  *
232
- * Shared helper used by session, bearer, and daemon token middleware,
233
- * as well as WebSocket upgrade handlers. Does the account actor → permits
234
- * lookup pipeline and returns the composed context, or `null` if
235
- * 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.
236
340
  *
237
341
  * @param deps - query dependencies
238
342
  * @param account_id - the account to build context for
239
- * @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
240
345
  */
241
- export const build_request_context = async (deps, account_id) => {
346
+ export const build_request_context = async (deps, account_id, actor_id) => {
242
347
  const account = await query_account_by_id(deps, account_id);
243
348
  if (!account)
244
349
  return null;
245
- const actor = await query_actor_by_account(deps, account.id);
350
+ const actor = await query_actor_by_id(deps, actor_id);
246
351
  if (!actor)
247
352
  return null;
353
+ if (actor.account_id !== account.id)
354
+ return null;
248
355
  const permits = await query_permit_find_active_for_actor(deps, actor.id);
249
356
  return { account, actor, permits };
250
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"}
@@ -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
  *
@@ -14,19 +20,19 @@ import { require_keeper } from './require_keeper.js';
14
20
  *
15
21
  * Maps `RouteAuth` to middleware:
16
22
  * - `none` → no guards
17
- * - `authenticated` → `require_auth`
18
- * - `role` → `require_role(role)`
19
- * - `keeper` → `require_keeper`
23
+ * - `authenticated` → pre-validation `require_auth`
24
+ * - `role` → pre-validation `require_auth` + post-authorization `require_role(role)`
25
+ * - `keeper` → pre-validation `require_auth` + post-authorization `require_keeper`
20
26
  */
21
27
  export const fuz_auth_guard_resolver = (auth) => {
22
28
  switch (auth.type) {
23
29
  case 'none':
24
- return [];
30
+ return { pre_validation: [], post_authorization: [] };
25
31
  case 'authenticated':
26
- return [require_auth];
32
+ return { pre_validation: [require_auth], post_authorization: [] };
27
33
  case 'role':
28
- return [require_role(auth.role)];
34
+ return { pre_validation: [require_auth], post_authorization: [require_role(auth.role)] };
29
35
  case 'keeper':
30
- return [require_keeper];
36
+ return { pre_validation: [require_auth], post_authorization: [require_keeper] };
31
37
  }
32
38
  };
@@ -15,6 +15,7 @@ export declare const ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE: "role_not_self_servic
15
15
  export declare const SelfServiceRoleSetInput: z.ZodObject<{
16
16
  role: z.ZodString;
17
17
  enabled: z.ZodBoolean;
18
+ acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
18
19
  }, z.core.$strict>;
19
20
  export type SelfServiceRoleSetInput = z.infer<typeof SelfServiceRoleSetInput>;
20
21
  /**
@@ -37,6 +38,7 @@ export declare const self_service_role_set_action_spec: {
37
38
  input: z.ZodObject<{
38
39
  role: z.ZodString;
39
40
  enabled: z.ZodBoolean;
41
+ acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
40
42
  }, z.core.$strict>;
41
43
  output: z.ZodObject<{
42
44
  ok: z.ZodLiteral<true>;