@fuzdev/fuz_app 0.53.0 → 0.54.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.
@@ -360,7 +360,10 @@ CRUD + listing:
360
360
  - `query_permit_find_active_for_actor`, `query_permit_list_for_actor`.
361
361
  - `query_permit_has_role(deps, actor_id, role, scope_id?)` — `IS NOT DISTINCT FROM`
362
362
  handles the NULL case. Omitted scope matches `scope_id IS NULL` (pre-scope
363
- callers keep semantics).
363
+ callers keep semantics). Use only when checking an arbitrary `actor_id`
364
+ that isn't the request actor (e.g., post-mutation verification, scripts,
365
+ audit-time checks). For the request actor, prefer `has_scoped_role` /
366
+ `has_any_scoped_role` on the in-memory `auth.permits` snapshot.
364
367
  - `query_permit_find_account_id_for_role(deps, role)` — joins
365
368
  permit → actor → account, returns first match. Used by daemon token
366
369
  middleware to resolve the keeper account.
@@ -653,7 +656,17 @@ without being blocked.
653
656
  identity (the audit-log SSE uses this to close only the revoked session's
654
657
  stream on `session_revoke`).
655
658
  - `get_request_context(c)`, `require_request_context(c)` (throws on misuse
656
- — misconfigured middleware surfaces immediately), `has_role(ctx, role, now?)`.
659
+ — misconfigured middleware surfaces immediately).
660
+ - **In-memory permit predicates** — `has_role(ctx, role, now?)`,
661
+ `has_scoped_role(ctx, role, scope_id, now?)`,
662
+ `has_any_scoped_role(ctx, roles, scope_id, now?)`. All three take
663
+ `RequestContext | null` (null returns `false`) so they drop into
664
+ `auth: 'public'` handlers without a manual narrow. `scope_id === null`
665
+ matches global permits only; UUID matches that exact scope. Empty
666
+ `roles` short-circuits `has_any_scoped_role` to `false`. Decide-time
667
+ predicates only — the predicate / mutation race window is the same as
668
+ the SQL `query_permit_has_role` style and only a transactional re-check
669
+ inside the UPDATE/INSERT closes it.
657
670
  - `build_request_context(deps, account_id)` — shared helper used by
658
671
  session, bearer, and daemon token middleware; does
659
672
  `account → actor → permits` and returns `null` if either lookup misses.
@@ -1172,9 +1185,10 @@ codegen invariant and grow the surface linearly per role.
1172
1185
  `eligible_roles` is checked against `roles.role_options` at factory
1173
1186
  time so typos throw at startup instead of at first call.
1174
1187
 
1175
- Grant branch uses `query_permit_has_role` for a benign-TOCTOU pre-check
1176
- (distinguishes new grant from idempotent re-grant), then
1177
- `query_grant_permit` for the actual insert. Revoke branch filters
1188
+ Grant branch uses `has_scoped_role(auth, role, null)` for a
1189
+ benign-TOCTOU pre-check (distinguishes new grant from idempotent
1190
+ re-grant) reads from the in-memory `auth.permits` snapshot, no DB
1191
+ roundtrip — then `query_grant_permit` for the actual insert. Revoke branch filters
1178
1192
  `query_permit_find_active_for_actor` in JS for the matching
1179
1193
  `(actor, role, scope_id IS NULL)` row before calling
1180
1194
  `query_revoke_permit`. Bundle is **not** included in
@@ -1 +1 @@
1
- {"version":3,"file":"permit_offer_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_offer_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAa,KAAK,aAAa,EAAE,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAGxF,OAAO,EAAmC,KAAK,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AAsBzF,OAAO,EAAW,KAAK,cAAc,EAAC,MAAM,sBAAsB,CAAC;AACnE,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAChD,OAAO,EAON,KAAK,kBAAkB,EACvB,MAAM,iCAAiC,CAAC;AAmCzC;;;;;;;;GAQG;AACH,MAAM,MAAM,0BAA0B,GAAG,CACxC,IAAI,EAAE,cAAc,EACpB,KAAK,EAAE;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAC,EACrE,IAAI,EAAE,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,EACnC,GAAG,EAAE,aAAa,KACd,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC,iDAAiD;AACjD,MAAM,WAAW,wBAAwB;IACxC;;;OAGG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,sFAAsF;IACtF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,0BAA0B,CAAC;CACvC;AAyBD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,yBAAyB,EAAE,0BAQvC,CAAC;AAcF;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAsB,SAAQ,IAAI,CAClD,gBAAgB,EAChB,KAAK,GAAG,gBAAgB,GAAG,kBAAkB,CAC7C;IACA,+EAA+E;IAC/E,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CAChD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,qBAAqB,EAC3B,UAAS,wBAA6B,KACpC,KAAK,CAAC,SAAS,CAudjB,CAAC"}
1
+ {"version":3,"file":"permit_offer_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_offer_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAa,KAAK,aAAa,EAAE,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAGxF,OAAO,EAAmC,KAAK,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AAkBzF,OAAO,EAA4B,KAAK,cAAc,EAAC,MAAM,sBAAsB,CAAC;AACpF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAChD,OAAO,EAON,KAAK,kBAAkB,EACvB,MAAM,iCAAiC,CAAC;AAmCzC;;;;;;;;GAQG;AACH,MAAM,MAAM,0BAA0B,GAAG,CACxC,IAAI,EAAE,cAAc,EACpB,KAAK,EAAE;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAC,EACrE,IAAI,EAAE,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,EACnC,GAAG,EAAE,aAAa,KACd,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC,iDAAiD;AACjD,MAAM,WAAW,wBAAwB;IACxC;;;OAGG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,sFAAsF;IACtF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,0BAA0B,CAAC;CACvC;AA4BD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,yBAAyB,EAAE,0BASvC,CAAC;AAcF;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAsB,SAAQ,IAAI,CAClD,gBAAgB,EAChB,KAAK,GAAG,gBAAgB,GAAG,kBAAkB,CAC7C;IACA,+EAA+E;IAC/E,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CAChD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,qBAAqB,EAC3B,UAAS,wBAA6B,KACpC,KAAK,CAAC,SAAS,CAudjB,CAAC"}
@@ -42,10 +42,10 @@ import { emit_after_commit } from '../http/pending_effects.js';
42
42
  import { BUILTIN_ROLE_OPTIONS, ROLE_ADMIN } from './role_schema.js';
43
43
  import { PERMIT_OFFER_DEFAULT_TTL_MS, to_permit_offer_json } from './permit_offer_schema.js';
44
44
  import { query_permit_offer_create, query_permit_offer_decline, query_permit_offer_retract, query_permit_offer_list, query_permit_offer_history_for_account, query_accept_offer, PermitOfferAlreadyTerminalError, PermitOfferExpiredError, PermitOfferNotFoundError, PermitOfferSelfTargetError, } from './permit_offer_queries.js';
45
- import { query_permit_find_active_role_for_actor, query_permit_has_role, query_revoke_permit, } from './permit_queries.js';
45
+ import { query_permit_find_active_role_for_actor, query_revoke_permit } from './permit_queries.js';
46
46
  import { query_actor_by_id } from './account_queries.js';
47
47
  import { audit_log_fire_and_forget } from './audit_log_queries.js';
48
- import { has_role } from './request_context.js';
48
+ import { has_role, has_scoped_role } from './request_context.js';
49
49
  import { build_permit_offer_accepted_notification, build_permit_offer_declined_notification, build_permit_offer_received_notification, build_permit_offer_retracted_notification, build_permit_offer_supersede_notification, build_permit_revoke_notification, } from './permit_offer_notifications.js';
50
50
  import { ERROR_ACCOUNT_NOT_FOUND, ERROR_PERMIT_NOT_FOUND, ERROR_ROLE_NOT_WEB_GRANTABLE, } from '../http/error_schemas.js';
51
51
  import { ERROR_OFFER_EXPIRED, ERROR_OFFER_NOT_AUTHORIZED, ERROR_OFFER_NOT_FOUND, ERROR_OFFER_ROLE_NOT_GRANTABLE, ERROR_OFFER_SELF_TARGET, ERROR_OFFER_TERMINAL, permit_offer_create_action_spec, permit_offer_accept_action_spec, permit_offer_decline_action_spec, permit_offer_retract_action_spec, permit_offer_list_action_spec, permit_offer_history_action_spec, permit_revoke_action_spec, } from './permit_offer_action_specs.js';
@@ -61,10 +61,13 @@ const fan_out_audit_events = (events, on_audit_event, log) => {
61
61
  }
62
62
  }
63
63
  };
64
- const default_authorize = async (auth, input, _deps, ctx) => {
64
+ // eslint-disable-next-line @typescript-eslint/require-await
65
+ const default_authorize = async (auth, input, _deps, _ctx) => {
65
66
  // Caller must hold an active permit for the offered role. Global (no scope)
66
67
  // check — the scope-aware "only in this classroom" policy is consumer-level.
67
- return query_permit_has_role(ctx, auth.actor.id, input.role);
68
+ // Reads from the in-memory `auth.permits` snapshot loaded once per request
69
+ // by `create_request_context_middleware`; no DB roundtrip needed.
70
+ return has_scoped_role(auth, input.role, null);
68
71
  };
69
72
  /**
70
73
  * Authorization callback that admits any admin and otherwise falls back to
@@ -79,10 +82,10 @@ const default_authorize = async (auth, input, _deps, ctx) => {
79
82
  * classroom_student in their own scope) wrap this and short-circuit `true`
80
83
  * before delegating.
81
84
  */
82
- export const authorize_admin_or_holder = async (auth, input, _deps, ctx) => {
85
+ export const authorize_admin_or_holder = async (auth, input, _deps, _ctx) => {
83
86
  if (has_role(auth, ROLE_ADMIN))
84
87
  return true;
85
- return query_permit_has_role(ctx, auth.actor.id, input.role);
88
+ return has_scoped_role(auth, input.role, null);
86
89
  };
87
90
  /**
88
91
  * Narrow `ctx.auth` to non-null. The RPC dispatcher has already enforced
@@ -56,13 +56,58 @@ export declare const require_request_context: (c: Context) => RequestContext;
56
56
  * Check if a request context has an active permit for a given role.
57
57
  *
58
58
  * Checks the permits already loaded in the context (no DB query).
59
+ * Null-tolerant — `null` ctx (unauthenticated) returns `false`. Symmetric
60
+ * with `has_scoped_role` / `has_any_scoped_role` so the three helpers
61
+ * compose freely in the same predicate (e.g.
62
+ * `has_role(auth, ADMIN) || has_scoped_role(auth, role, scope)`).
59
63
  *
60
- * @param ctx - the request context
64
+ * @param ctx - the request context, or `null` for unauthenticated callers
61
65
  * @param role - the role to check
62
66
  * @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
63
67
  * @returns `true` if the actor has an active permit for the role
64
68
  */
65
- export declare const has_role: (ctx: RequestContext, role: string, now?: Date) => boolean;
69
+ export declare const has_role: (ctx: RequestContext | null, role: string, now?: Date) => boolean;
70
+ /**
71
+ * Whether the request context holds an active permit for `role` at `scope_id`.
72
+ *
73
+ * Walks the in-memory `ctx.permits` snapshot loaded once per request by
74
+ * `create_request_context_middleware`; zero DB roundtrip per check. The
75
+ * "freshness" framing of a SQL re-query is illusory because the race window
76
+ * is between predicate and the actual mutation, not predicate and middleware
77
+ * load. Closing that race needs a transactional re-check inside the
78
+ * UPDATE/INSERT, which neither style provides.
79
+ *
80
+ * Null-tolerant — `null` ctx (unauthenticated) returns `false`. Same
81
+ * convention as `has_role`; lets the helper drop into `auth: 'public'`
82
+ * handlers without a manual narrow. See `cell_authorize` for the
83
+ * resource-side analog.
84
+ *
85
+ * `scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so
86
+ * JS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly:
87
+ *
88
+ * - `scope_id === null` matches global permits (`scope_id IS NULL`).
89
+ * - `scope_id === '<uuid>'` matches permits bound to that exact scope.
90
+ *
91
+ * @param ctx - the request context, or `null` for unauthenticated callers
92
+ * @param role - the role to check
93
+ * @param scope_id - the scope to check (`null` for global)
94
+ * @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
95
+ * @returns `true` iff the actor holds an active permit for the role at the requested scope
96
+ */
97
+ export declare const has_scoped_role: (ctx: RequestContext | null, role: string, scope_id: string | null, now?: Date) => boolean;
98
+ /**
99
+ * Whether the request context holds an active permit for any role in `roles`
100
+ * at `scope_id`. Empty `roles` short-circuits to `false` — documents intent
101
+ * at the call site ("zero roles trivially admit no-one"). Same scope and
102
+ * null-tolerance semantics as `has_scoped_role`.
103
+ *
104
+ * @param ctx - the request context, or `null` for unauthenticated callers
105
+ * @param roles - the roles that would admit the caller (any-of)
106
+ * @param scope_id - the scope to check (`null` for global)
107
+ * @param now - current time (defaults to `new Date()`, pass for testability)
108
+ * @returns `true` iff the actor holds an active permit for any role in `roles` at the requested scope
109
+ */
110
+ export declare const has_any_scoped_role: (ctx: RequestContext | null, roles: ReadonlyArray<string>, scope_id: string | null, now?: Date) => boolean;
66
111
  /**
67
112
  * Create middleware that builds the request context from a session cookie.
68
113
  *
@@ -1 +1 @@
1
- {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AACrD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAoB,KAAK,MAAM,EAAC,MAAM,qBAAqB,CAAC;AAQ5F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAOnD,kEAAkE;AAClE,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,4BAA4B,CAAC;AAErE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,cAAc,GAAG,IAEjE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,GAAI,GAAG,OAAO,KAAG,cAMpD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,cAAc,EAAE,MAAM,MAAM,EAAE,MAAK,IAAiB,KAAG,OAChB,CAAC;AAEtE;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,iCAAiC,GAC7C,MAAM,SAAS,EACf,KAAK,MAAM,EACX,4BAAuC,KACrC,iBA6CF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,iBAM1B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,iBAW3C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,EACnB,MAAM,SAAS,KACb,OAAO,CAAC,cAAc,CAGxB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,cAAc,GAAG,IAAI,CAS/B,CAAC"}
1
+ {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AACrD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAoB,KAAK,MAAM,EAAC,MAAM,qBAAqB,CAAC;AAQ5F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAOnD,kEAAkE;AAClE,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,4BAA4B,CAAC;AAErE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,cAAc,GAAG,IAEjE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,GAAI,GAAG,OAAO,KAAG,cAMpD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,QAAQ,GACpB,KAAK,cAAc,GAAG,IAAI,EAC1B,MAAM,MAAM,EACZ,MAAK,IAAiB,KACpB,OAAyF,CAAC;AAE7F;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,GAAG,IAAI,EAC1B,MAAM,MAAM,EACZ,UAAU,MAAM,GAAG,IAAI,EACvB,MAAK,IAAiB,KACpB,OAKF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,cAAc,GAAG,IAAI,EAC1B,OAAO,aAAa,CAAC,MAAM,CAAC,EAC5B,UAAU,MAAM,GAAG,IAAI,EACvB,MAAK,IAAiB,KACpB,OAMF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,iCAAiC,GAC7C,MAAM,SAAS,EACf,KAAK,MAAM,EACX,4BAAuC,KACrC,iBA6CF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,iBAM1B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,iBAW3C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,EACnB,MAAM,SAAS,KACb,OAAO,CAAC,cAAc,CAGxB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,cAAc,GAAG,IAAI,CAS/B,CAAC"}
@@ -60,13 +60,68 @@ export const require_request_context = (c) => {
60
60
  * Check if a request context has an active permit for a given role.
61
61
  *
62
62
  * Checks the permits already loaded in the context (no DB query).
63
+ * Null-tolerant — `null` ctx (unauthenticated) returns `false`. Symmetric
64
+ * with `has_scoped_role` / `has_any_scoped_role` so the three helpers
65
+ * compose freely in the same predicate (e.g.
66
+ * `has_role(auth, ADMIN) || has_scoped_role(auth, role, scope)`).
63
67
  *
64
- * @param ctx - the request context
68
+ * @param ctx - the request context, or `null` for unauthenticated callers
65
69
  * @param role - the role to check
66
70
  * @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
67
71
  * @returns `true` if the actor has an active permit for the role
68
72
  */
69
- export const has_role = (ctx, role, now = new Date()) => ctx.permits.some((p) => p.role === role && is_permit_active(p, now));
73
+ export const has_role = (ctx, role, now = new Date()) => ctx?.permits.some((p) => p.role === role && is_permit_active(p, now)) ?? false;
74
+ /**
75
+ * Whether the request context holds an active permit for `role` at `scope_id`.
76
+ *
77
+ * 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.
83
+ *
84
+ * Null-tolerant — `null` ctx (unauthenticated) returns `false`. Same
85
+ * 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.
88
+ *
89
+ * `scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so
90
+ * JS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly:
91
+ *
92
+ * - `scope_id === null` matches global permits (`scope_id IS NULL`).
93
+ * - `scope_id === '<uuid>'` matches permits bound to that exact scope.
94
+ *
95
+ * @param ctx - the request context, or `null` for unauthenticated callers
96
+ * @param role - the role to check
97
+ * @param scope_id - the scope to check (`null` for global)
98
+ * @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
99
+ * @returns `true` iff the actor holds an active permit for the role at the requested scope
100
+ */
101
+ export const has_scoped_role = (ctx, role, scope_id, now = new Date()) => {
102
+ if (!ctx)
103
+ return false;
104
+ return ctx.permits.some((p) => p.role === role && p.scope_id === scope_id && is_permit_active(p, now));
105
+ };
106
+ /**
107
+ * Whether the request context holds an active permit for any role in `roles`
108
+ * at `scope_id`. Empty `roles` short-circuits to `false` — documents intent
109
+ * at the call site ("zero roles trivially admit no-one"). Same scope and
110
+ * null-tolerance semantics as `has_scoped_role`.
111
+ *
112
+ * @param ctx - the request context, or `null` for unauthenticated callers
113
+ * @param roles - the roles that would admit the caller (any-of)
114
+ * @param scope_id - the scope to check (`null` for global)
115
+ * @param now - current time (defaults to `new Date()`, pass for testability)
116
+ * @returns `true` iff the actor holds an active permit for any role in `roles` at the requested scope
117
+ */
118
+ export const has_any_scoped_role = (ctx, roles, scope_id, now = new Date()) => {
119
+ if (!ctx)
120
+ return false;
121
+ if (roles.length === 0)
122
+ return false;
123
+ return ctx.permits.some((p) => roles.includes(p.role) && p.scope_id === scope_id && is_permit_active(p, now));
124
+ };
70
125
  /**
71
126
  * Create middleware that builds the request context from a session cookie.
72
127
  *
@@ -1 +1 @@
1
- {"version":3,"file":"self_service_role_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/self_service_role_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAExF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAgBhD,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC7C;;;;OAIG;IACH,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAC3C,gBAAgB,EAChB,KAAK,GAAG,gBAAgB,GAAG,kBAAkB,CAC7C,CAAC;AAOF;;;;;;;GAOG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,yBAAyB,EAC/B,SAAS,6BAA6B,KACpC,KAAK,CAAC,SAAS,CA4GjB,CAAC"}
1
+ {"version":3,"file":"self_service_role_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/self_service_role_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAExF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAYhD,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC7C;;;;OAIG;IACH,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAC3C,gBAAgB,EAChB,KAAK,GAAG,gBAAgB,GAAG,kBAAkB,CAC7C,CAAC;AAOF;;;;;;;GAOG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,yBAAyB,EAC/B,SAAS,6BAA6B,KACpC,KAAK,CAAC,SAAS,CA+GjB,CAAC"}
@@ -33,8 +33,10 @@
33
33
  */
34
34
  import { rpc_action } from '../actions/action_rpc.js';
35
35
  import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
36
- import { query_grant_permit, query_permit_find_active_for_actor, query_permit_has_role, query_revoke_permit, } from './permit_queries.js';
36
+ import { query_grant_permit, query_revoke_permit } from './permit_queries.js';
37
37
  import { audit_log_fire_and_forget } from './audit_log_queries.js';
38
+ import { is_permit_active } from './account_schema.js';
39
+ import { has_scoped_role } from './request_context.js';
38
40
  import { ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE, self_service_role_set_action_spec, } from './self_service_role_action_specs.js';
39
41
  const require_request_auth = (auth) => {
40
42
  if (!auth)
@@ -73,13 +75,13 @@ export const create_self_service_role_actions = (deps, options) => {
73
75
  // Pre-check for idempotent re-grant. `query_grant_permit` is itself
74
76
  // idempotent (returns the existing permit instead of inserting), but
75
77
  // it doesn't signal "already existed" vs "newly inserted" — so we
76
- // peek first. The TOCTOU window is benign for self-service: two
77
- // concurrent grants both observe "no permit", both call
78
+ // peek first. Reads from the in-memory `auth.permits` snapshot
79
+ // (no DB roundtrip). The TOCTOU window is benign for self-service:
80
+ // two concurrent grants both observe "no permit", both call
78
81
  // `query_grant_permit`, and one collapses onto the other inside the
79
82
  // query's `ON CONFLICT DO NOTHING`. Worst case both responses report
80
83
  // `changed: true`; the DB still ends up with exactly one permit.
81
- const already = await query_permit_has_role(ctx, auth.actor.id, input.role);
82
- if (already) {
84
+ if (has_scoped_role(auth, input.role, null)) {
83
85
  return { ok: true, enabled: true, changed: false };
84
86
  }
85
87
  const permit = await query_grant_permit(ctx, {
@@ -103,12 +105,13 @@ export const create_self_service_role_actions = (deps, options) => {
103
105
  }, deps);
104
106
  return { ok: true, enabled: true, changed: true };
105
107
  }
106
- // Find an active global permit for this (actor, role). No dedicated
107
- // query exists, but `query_permit_find_active_for_actor` returns the
108
- // short list of every active permit and we filter in JS — fewer
109
- // round-trips than a new helper for a one-call-per-revoke path.
110
- const active = await query_permit_find_active_for_actor(ctx, auth.actor.id);
111
- const target = active.find((p) => p.role === input.role && p.scope_id === null);
108
+ // Find an active global permit for this (actor, role) in the in-memory
109
+ // `auth.permits` snapshot. No DB roundtrip — same correctness-equivalent
110
+ // pattern as `has_scoped_role` above (race window is between predicate
111
+ // and `query_revoke_permit`'s actual UPDATE, not between predicate and
112
+ // middleware load).
113
+ const now = new Date();
114
+ const target = auth.permits.find((p) => p.role === input.role && p.scope_id === null && is_permit_active(p, now));
112
115
  if (!target) {
113
116
  return { ok: true, enabled: false, changed: false };
114
117
  }
@@ -1 +1 @@
1
- {"version":3,"file":"schema_generators.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_generators.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAKN,KAAK,YAAY,EACjB,MAAM,yBAAyB,CAAC;AAIjC;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,MAAM,GAAG,IAShE,CAAC;AA+FF,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,GAAI,OAAO,YAAY,EAAE,cAAc,CAAC,CAAC,OAAO,KAAG,OAmEnF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,gBAAgB,CAAC,CAAC,SAAS,KAAG,MAa9E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC/B,cAAc,CAAC,CAAC,OAAO,KACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAkB5B,CAAC"}
1
+ {"version":3,"file":"schema_generators.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_generators.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAKN,KAAK,YAAY,EACjB,MAAM,yBAAyB,CAAC;AAIjC;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,MAAM,GAAG,IAShE,CAAC;AA+FF,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,GAAI,OAAO,YAAY,EAAE,cAAc,CAAC,CAAC,OAAO,KAAG,OA+EnF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,gBAAgB,CAAC,CAAC,SAAS,KAAG,MAa9E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC/B,cAAc,CAAC,CAAC,OAAO,KACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAkB5B,CAAC"}
@@ -182,6 +182,18 @@ export const generate_valid_value = (field, field_schema) => {
182
182
  }
183
183
  return 'test_value';
184
184
  }
185
+ case 'literal': {
186
+ // Zod 4 stores literal values on `def.values` (always an array, even
187
+ // for single-valued literals). Returning the first literal satisfies
188
+ // `z.literal('foo')` as well as required discriminator fields in
189
+ // `z.discriminatedUnion` variants — without this branch the literal
190
+ // would fall through to the default and break parse.
191
+ const literal_def = zod_unwrap_def(field_schema);
192
+ if (literal_def.values && literal_def.values.length > 0) {
193
+ return literal_def.values[0];
194
+ }
195
+ return 'test_value';
196
+ }
185
197
  case 'enum': {
186
198
  const enum_def = zod_unwrap_def(field_schema);
187
199
  if ('entries' in enum_def) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",