@fuzdev/fuz_app 0.58.0 → 0.60.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 (107) hide show
  1. package/dist/actions/CLAUDE.md +13 -8
  2. package/dist/actions/action_codegen.d.ts +1 -1
  3. package/dist/actions/action_codegen.js +2 -2
  4. package/dist/actions/action_event_helpers.d.ts +3 -3
  5. package/dist/actions/action_event_helpers.js +8 -8
  6. package/dist/actions/action_event_types.d.ts +3 -3
  7. package/dist/actions/action_event_types.js +3 -3
  8. package/dist/actions/transports_ws_auth_guard.d.ts +2 -2
  9. package/dist/actions/transports_ws_auth_guard.js +3 -3
  10. package/dist/auth/CLAUDE.md +215 -45
  11. package/dist/auth/account_action_specs.d.ts +9 -0
  12. package/dist/auth/account_action_specs.d.ts.map +1 -1
  13. package/dist/auth/account_action_specs.js +9 -0
  14. package/dist/auth/actor_lookup_action_specs.d.ts +127 -0
  15. package/dist/auth/actor_lookup_action_specs.d.ts.map +1 -0
  16. package/dist/auth/actor_lookup_action_specs.js +93 -0
  17. package/dist/auth/actor_lookup_actions.d.ts +19 -0
  18. package/dist/auth/actor_lookup_actions.d.ts.map +1 -0
  19. package/dist/auth/actor_lookup_actions.js +32 -0
  20. package/dist/auth/actor_lookup_queries.d.ts +44 -0
  21. package/dist/auth/actor_lookup_queries.d.ts.map +1 -0
  22. package/dist/auth/actor_lookup_queries.js +42 -0
  23. package/dist/auth/actor_search_action_specs.d.ts +166 -0
  24. package/dist/auth/actor_search_action_specs.d.ts.map +1 -0
  25. package/dist/auth/actor_search_action_specs.js +139 -0
  26. package/dist/auth/actor_search_actions.d.ts +31 -0
  27. package/dist/auth/actor_search_actions.d.ts.map +1 -0
  28. package/dist/auth/actor_search_actions.js +61 -0
  29. package/dist/auth/actor_search_queries.d.ts +75 -0
  30. package/dist/auth/actor_search_queries.d.ts.map +1 -0
  31. package/dist/auth/actor_search_queries.js +91 -0
  32. package/dist/auth/admin_action_specs.d.ts +35 -0
  33. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  34. package/dist/auth/admin_action_specs.js +35 -0
  35. package/dist/auth/admin_actions.js +2 -2
  36. package/dist/auth/all_action_spec_registries.d.ts +55 -0
  37. package/dist/auth/all_action_spec_registries.d.ts.map +1 -0
  38. package/dist/auth/all_action_spec_registries.js +59 -0
  39. package/dist/auth/audit_emitter.d.ts +1 -1
  40. package/dist/auth/audit_emitter.js +2 -2
  41. package/dist/auth/audit_log_queries.d.ts +1 -1
  42. package/dist/auth/audit_log_queries.js +3 -3
  43. package/dist/auth/audit_log_routes.d.ts +1 -1
  44. package/dist/auth/audit_log_routes.js +1 -1
  45. package/dist/auth/audit_log_schema.d.ts +5 -5
  46. package/dist/auth/audit_log_schema.js +7 -7
  47. package/dist/auth/auth_ddl.d.ts +7 -0
  48. package/dist/auth/auth_ddl.d.ts.map +1 -1
  49. package/dist/auth/auth_ddl.js +8 -0
  50. package/dist/auth/credential_type_schema.d.ts +1 -1
  51. package/dist/auth/credential_type_schema.js +3 -3
  52. package/dist/auth/grant_path_schema.d.ts +1 -1
  53. package/dist/auth/grant_path_schema.js +3 -3
  54. package/dist/auth/migrations.d.ts +4 -4
  55. package/dist/auth/migrations.d.ts.map +1 -1
  56. package/dist/auth/migrations.js +7 -6
  57. package/dist/auth/role_grant_offer_action_specs.d.ts +17 -0
  58. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  59. package/dist/auth/role_grant_offer_action_specs.js +17 -0
  60. package/dist/auth/role_grant_offer_actions.js +2 -2
  61. package/dist/auth/role_grant_offer_notifications.d.ts +2 -2
  62. package/dist/auth/role_grant_offer_notifications.js +2 -2
  63. package/dist/auth/role_grant_queries.d.ts +21 -0
  64. package/dist/auth/role_grant_queries.d.ts.map +1 -1
  65. package/dist/auth/role_grant_queries.js +31 -0
  66. package/dist/auth/role_schema.d.ts +2 -2
  67. package/dist/auth/role_schema.js +3 -3
  68. package/dist/auth/self_service_role_action_specs.d.ts +8 -0
  69. package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
  70. package/dist/auth/self_service_role_action_specs.js +8 -0
  71. package/dist/auth/self_service_role_actions.d.ts +1 -1
  72. package/dist/auth/self_service_role_actions.js +2 -2
  73. package/dist/auth/session_cookie.d.ts +1 -1
  74. package/dist/auth/session_cookie.js +1 -1
  75. package/dist/auth/session_middleware.d.ts +1 -1
  76. package/dist/auth/session_middleware.js +5 -5
  77. package/dist/rate_limiter.d.ts +5 -5
  78. package/dist/rate_limiter.js +6 -6
  79. package/dist/realtime/sse_auth_guard.d.ts +3 -3
  80. package/dist/realtime/sse_auth_guard.js +4 -4
  81. package/dist/server/app_backend.d.ts +3 -3
  82. package/dist/server/app_backend.js +4 -4
  83. package/dist/server/app_server.d.ts +1 -1
  84. package/dist/server/app_server.js +10 -10
  85. package/dist/testing/CLAUDE.md +22 -12
  86. package/dist/testing/admin_integration.js +4 -4
  87. package/dist/testing/app_server.d.ts +1 -1
  88. package/dist/testing/app_server.js +2 -2
  89. package/dist/testing/attack_surface.d.ts +4 -4
  90. package/dist/testing/attack_surface.js +6 -6
  91. package/dist/testing/audit_completeness.js +4 -4
  92. package/dist/testing/data_exposure.d.ts +2 -2
  93. package/dist/testing/data_exposure.js +7 -7
  94. package/dist/testing/db.d.ts +8 -8
  95. package/dist/testing/db.js +11 -11
  96. package/dist/testing/integration.js +4 -4
  97. package/dist/testing/integration_helpers.d.ts +6 -6
  98. package/dist/testing/integration_helpers.js +7 -7
  99. package/dist/testing/rate_limiting.js +4 -4
  100. package/dist/testing/round_trip.js +2 -2
  101. package/dist/testing/rpc_round_trip.js +2 -2
  102. package/dist/testing/schema_generators.d.ts.map +1 -1
  103. package/dist/testing/schema_generators.js +23 -2
  104. package/dist/testing/sse_round_trip.js +2 -2
  105. package/dist/testing/surface_invariants.d.ts +4 -4
  106. package/dist/testing/surface_invariants.js +5 -5
  107. package/package.json +1 -1
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `actor_lookup` RPC spec — authenticated batched id → username/display_name
3
+ * resolver, keyed by actor id.
4
+ *
5
+ * Powers the labels arc for surfaces that stamp an actor id (bylines,
6
+ * owner columns, grantor labels, audit-log "by" cells). One round trip
7
+ * resolves an array of ids to display strings.
8
+ *
9
+ * ## Auth + rate-limit posture
10
+ *
11
+ * `{account: 'required', actor: 'none'}` + `rate_limit: 'account'`.
12
+ * Account-grain — only that the caller is signed in matters, not which
13
+ * actor is calling, so resolution skips the actor phase. The auth gate
14
+ * + per-account rate limit (default 1200/15min) + the
15
+ * {@link ACTOR_LOOKUP_IDS_MAX | per-call cap} bound the batched
16
+ * username-enumeration surface that the `cell_list` ↔ `actor_lookup`
17
+ * pair would otherwise present.
18
+ *
19
+ * If a public-surface byline ever lands (e.g. an unauthenticated
20
+ * gallery), it should resolve via a separate public-safe mechanism
21
+ * (SSR-stamped labels or a per-cell embedded actor label), **not** by
22
+ * loosening this gate.
23
+ *
24
+ * ## Wire shape — info-leak audit
25
+ *
26
+ * Output: `{actors: [{id, username, display_name?}]}`. Deliberately
27
+ * omitted:
28
+ *
29
+ * - `account_id` — the actor↔account join is a control-plane detail
30
+ * - `email`, password/credential fields — never queried
31
+ * - `created_at` / `updated_at` — timing-oracle avoidance
32
+ * - role / role_grants / session state — separation of concern
33
+ *
34
+ * `display_name` is omitted (not `null`) when `actor.name` is blank, so
35
+ * clients see `undefined` rather than a sentinel string. Unknown ids are
36
+ * silently absent from the response — by construction this is an
37
+ * existence-oracle (the caller can diff response ids against request
38
+ * ids), bounded by:
39
+ *
40
+ * 1. rate-limit (per-account, see above),
41
+ * 2. {@link ACTOR_LOOKUP_IDS_MAX} cap per call,
42
+ * 3. actor-uuid intractability (122-bit random),
43
+ * 4. hard-deleted actors are indistinguishable from never-existed (no
44
+ * tombstone oracle — see `actor_lookup_queries.ts`).
45
+ *
46
+ * Response order is unspecified — callers index by `id` when needed.
47
+ *
48
+ * @module
49
+ */
50
+ import { z } from 'zod';
51
+ import { Uuid } from '@fuzdev/fuz_util/id.js';
52
+ /**
53
+ * Hard cap on the number of ids resolvable in one call. Bounds the
54
+ * batched username-enumeration surface.
55
+ */
56
+ export const ACTOR_LOOKUP_IDS_MAX = 50;
57
+ /** One resolved actor row. `display_name` omitted when blank. */
58
+ export const ActorLookupEntryJson = z.strictObject({
59
+ id: Uuid,
60
+ username: z.string(),
61
+ display_name: z.string().optional(),
62
+ });
63
+ export const ActorLookupInput = z.strictObject({
64
+ ids: z
65
+ .array(Uuid)
66
+ .min(1)
67
+ .max(ACTOR_LOOKUP_IDS_MAX)
68
+ .meta({
69
+ description: `Actor ids to resolve. Capped at ${ACTOR_LOOKUP_IDS_MAX}; unknown ids are silently absent from the response.`,
70
+ }),
71
+ });
72
+ export const ActorLookupOutput = z.strictObject({
73
+ actors: z.array(ActorLookupEntryJson),
74
+ });
75
+ export const actor_lookup_action_spec = {
76
+ method: 'actor_lookup',
77
+ kind: 'request_response',
78
+ initiator: 'frontend',
79
+ auth: { account: 'required', actor: 'none' },
80
+ side_effects: false,
81
+ input: ActorLookupInput,
82
+ output: ActorLookupOutput,
83
+ async: true,
84
+ rate_limit: 'account',
85
+ description: `Batched id → (username, display_name) resolver, keyed by actor id. Powers the labels arc for surfaces that stamp an actor id (e.g. cell owners, grant grantors). Authenticated + per-account rate-limited to bound batched username enumeration. Cap ${ACTOR_LOOKUP_IDS_MAX}; unknown ids absent from response.`,
86
+ };
87
+ /**
88
+ * All actor_lookup action specs — independent opt-in registry. Consumers
89
+ * spread alongside `all_standard_action_specs` if they want the labels
90
+ * arc; not folded into the standard bundle because consumers without a
91
+ * byline surface can skip it.
92
+ */
93
+ export const all_actor_lookup_action_specs = [actor_lookup_action_spec];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `actor_lookup` RPC handler.
3
+ *
4
+ * Pure read — no audit, no side effects. Auth (`account: 'required'`) +
5
+ * rate-limit (`account`-grain) enforced at the spec layer; see
6
+ * `./actor_lookup_action_specs.ts` for the info-leak audit.
7
+ *
8
+ * `display_name` is omitted (not `null`) when `actor.name` is blank,
9
+ * matching the wire shape `display_name?` so the typed client sees an
10
+ * `undefined` rather than a sentinel string.
11
+ *
12
+ * @module
13
+ */
14
+ import { type RpcAction } from '../actions/action_rpc.js';
15
+ import type { RouteFactoryDeps } from './deps.js';
16
+ /** Dependencies for `create_actor_lookup_actions`. */
17
+ export type ActorLookupActionDeps = Pick<RouteFactoryDeps, 'log'>;
18
+ export declare const create_actor_lookup_actions: (_deps: ActorLookupActionDeps) => Array<RpcAction>;
19
+ //# sourceMappingURL=actor_lookup_actions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actor_lookup_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/actor_lookup_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAExF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAShD,sDAAsD;AACtD,MAAM,MAAM,qBAAqB,GAAG,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,CAAC;AAElE,eAAO,MAAM,2BAA2B,GAAI,OAAO,qBAAqB,KAAG,KAAK,CAAC,SAAS,CAkBzF,CAAC"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `actor_lookup` RPC handler.
3
+ *
4
+ * Pure read — no audit, no side effects. Auth (`account: 'required'`) +
5
+ * rate-limit (`account`-grain) enforced at the spec layer; see
6
+ * `./actor_lookup_action_specs.ts` for the info-leak audit.
7
+ *
8
+ * `display_name` is omitted (not `null`) when `actor.name` is blank,
9
+ * matching the wire shape `display_name?` so the typed client sees an
10
+ * `undefined` rather than a sentinel string.
11
+ *
12
+ * @module
13
+ */
14
+ import { rpc_action } from '../actions/action_rpc.js';
15
+ import { query_actors_by_ids } from './actor_lookup_queries.js';
16
+ import { actor_lookup_action_spec, } from './actor_lookup_action_specs.js';
17
+ export const create_actor_lookup_actions = (_deps) => {
18
+ const handler = async (input, ctx) => {
19
+ const rows = await query_actors_by_ids(ctx, input.ids);
20
+ return {
21
+ actors: rows.map((row) => {
22
+ const display_name = row.display_name?.trim();
23
+ return {
24
+ id: row.id,
25
+ username: row.username,
26
+ ...(display_name ? { display_name } : {}),
27
+ };
28
+ }),
29
+ };
30
+ };
31
+ return [rpc_action(actor_lookup_action_spec, handler)];
32
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Batched actor-by-id resolver.
3
+ *
4
+ * Joins `actor` ⨝ `account` so callers see `(username, display_name)` for
5
+ * each actor row. The byline / owner-column / grantor surfaces stamp an
6
+ * actor id, so resolving "who is this actor?" lands the human label in
7
+ * one round trip.
8
+ *
9
+ * Accounts may host multiple actors (multi-actor shipped in v0.55.0).
10
+ * The inner join still resolves one row per actor — `actor.account_id`
11
+ * is `NOT NULL` so every actor has exactly one account.
12
+ *
13
+ * Info-leak posture (see `actor_lookup_action_specs.ts` § audit):
14
+ *
15
+ * - Row shape **omits** `account_id` — the join is control-plane,
16
+ * not wire-visible.
17
+ * - Hard-deleted actors (or account-cascade-orphaned rows) drop out
18
+ * silently — indistinguishable from never-existed (no tombstone
19
+ * oracle).
20
+ * - No `created_at` / `updated_at` projected (timing-oracle avoidance).
21
+ * - Response order is unspecified — `WHERE id = ANY(...)` returns
22
+ * index-scan order in practice but callers must not depend on it.
23
+ *
24
+ * Caller is responsible for capping `ids.length` — the SQL itself does
25
+ * not enforce a bound; the action-spec layer surfaces `invalid_params`
26
+ * via `ACTOR_LOOKUP_IDS_MAX`.
27
+ *
28
+ * @module
29
+ */
30
+ import type { Uuid } from '@fuzdev/fuz_util/id.js';
31
+ import type { QueryDeps } from '../db/query_deps.js';
32
+ /** Row shape returned to handlers — wire mapping happens at the action layer. */
33
+ export interface ActorLookupRow {
34
+ id: Uuid;
35
+ username: string;
36
+ display_name: string | null;
37
+ }
38
+ /**
39
+ * Resolve a batch of actor ids to `(id, username, display_name)`. Empty
40
+ * input fast-paths to `[]`. Hard-deleted actors (or account-cascade-
41
+ * orphaned rows) drop out of the result silently.
42
+ */
43
+ export declare const query_actors_by_ids: (deps: QueryDeps, ids: ReadonlyArray<Uuid>) => Promise<Array<ActorLookupRow>>;
44
+ //# sourceMappingURL=actor_lookup_queries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actor_lookup_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/actor_lookup_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAEjD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAEnD,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC9B,EAAE,EAAE,IAAI,CAAC;IACT,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAC/B,MAAM,SAAS,EACf,KAAK,aAAa,CAAC,IAAI,CAAC,KACtB,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,CAS/B,CAAC"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Batched actor-by-id resolver.
3
+ *
4
+ * Joins `actor` ⨝ `account` so callers see `(username, display_name)` for
5
+ * each actor row. The byline / owner-column / grantor surfaces stamp an
6
+ * actor id, so resolving "who is this actor?" lands the human label in
7
+ * one round trip.
8
+ *
9
+ * Accounts may host multiple actors (multi-actor shipped in v0.55.0).
10
+ * The inner join still resolves one row per actor — `actor.account_id`
11
+ * is `NOT NULL` so every actor has exactly one account.
12
+ *
13
+ * Info-leak posture (see `actor_lookup_action_specs.ts` § audit):
14
+ *
15
+ * - Row shape **omits** `account_id` — the join is control-plane,
16
+ * not wire-visible.
17
+ * - Hard-deleted actors (or account-cascade-orphaned rows) drop out
18
+ * silently — indistinguishable from never-existed (no tombstone
19
+ * oracle).
20
+ * - No `created_at` / `updated_at` projected (timing-oracle avoidance).
21
+ * - Response order is unspecified — `WHERE id = ANY(...)` returns
22
+ * index-scan order in practice but callers must not depend on it.
23
+ *
24
+ * Caller is responsible for capping `ids.length` — the SQL itself does
25
+ * not enforce a bound; the action-spec layer surfaces `invalid_params`
26
+ * via `ACTOR_LOOKUP_IDS_MAX`.
27
+ *
28
+ * @module
29
+ */
30
+ /**
31
+ * Resolve a batch of actor ids to `(id, username, display_name)`. Empty
32
+ * input fast-paths to `[]`. Hard-deleted actors (or account-cascade-
33
+ * orphaned rows) drop out of the result silently.
34
+ */
35
+ export const query_actors_by_ids = async (deps, ids) => {
36
+ if (ids.length === 0)
37
+ return [];
38
+ return deps.db.query(`SELECT act.id, a.username, act.name AS display_name
39
+ FROM actor act
40
+ JOIN account a ON a.id = act.account_id
41
+ WHERE act.id = ANY($1)`, [ids]);
42
+ };
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `actor_search` RPC spec — authenticated case-insensitive prefix search
3
+ * over `actor.name`, returning the same `{id, username, display_name?}`
4
+ * wire shape as `actor_lookup`.
5
+ *
6
+ * Powers person-target pickers — visiones' `CellGrantsEditor.svelte`
7
+ * teacher-picks-student flow replaces the deferred `actor_by_name` arm of
8
+ * `cell_grant_create` with a debounced search against this method. Sibling
9
+ * to `actor_lookup`: that resolves a known batch of ids → labels; this
10
+ * resolves a partial name → candidate actors.
11
+ *
12
+ * ## Auth + rate-limit posture
13
+ *
14
+ * `{account: 'required', actor: 'none'}` + `rate_limit: 'account'`. Same
15
+ * shape as `actor_lookup`: only that the caller is signed in matters, not
16
+ * which actor is calling. The auth gate, the per-account rate limit
17
+ * (default 1200/15min), and the `ACTOR_SEARCH_LIMIT_MAX` per-call cap
18
+ * bound the enumeration surface this method would otherwise present.
19
+ *
20
+ * The handler additionally requires the caller to be admin when
21
+ * `scope_ids` is empty (the unbounded global-search arm). Non-admin
22
+ * callers must always pass at least one scope_id — the SQL filters
23
+ * actors to those holding a role_grant on one of the supplied scopes, so
24
+ * a non-admin caller is restricted to actors they share a scope with.
25
+ * The admin check is account-grain (any actor on the caller's account
26
+ * holds a global `admin` role_grant), matching the `actor: 'none'` posture.
27
+ *
28
+ * ## Caller-passes-scope_ids design
29
+ *
30
+ * `scope_ids` is trusted as a filter, not as an authority claim — the
31
+ * SQL filters to actors with role_grants on those scopes regardless of
32
+ * whether the caller has authority over them. Consumers are responsible
33
+ * for pre-filtering `scope_ids` against their own authority before
34
+ * calling. Visiones passes the set of classrooms the teacher teaches,
35
+ * sourced client-side from the teacher's role_grant list; the teacher
36
+ * predicate stays in the visiones layer rather than baked into fuz_app.
37
+ *
38
+ * Crucially, this does **not** widen the scope-existence oracle: an
39
+ * attacker passing a random scope_id cannot learn "this scope has
40
+ * members matching X" because the join filters to actors holding a
41
+ * role_grant on the scope, and the SQL surfaces neither "did the scope
42
+ * exist" nor "did the scope have non-matching members" — only the
43
+ * matching subset is returned.
44
+ *
45
+ * ## Wire shape — info-leak audit
46
+ *
47
+ * Output `{actors: [{id, username, display_name?}]}` is identical to
48
+ * `actor_lookup`'s — see `./actor_lookup_action_specs.ts` for the full
49
+ * field-by-field audit. Same omissions (`account_id`, email,
50
+ * timestamps, role / role_grants / session state), same `display_name`
51
+ * omitted-not-null contract, same response-order-unspecified rule.
52
+ *
53
+ * Additional `actor_search`-specific posture:
54
+ *
55
+ * - Prefix match (`LOWER(name) LIKE LOWER(query) || '%'`), not full
56
+ * `%query%`. Full-LIKE would let a single call enumerate one
57
+ * alphabetical bucket spread across many starting letters, which
58
+ * defeats the per-call cap as an enumeration bound.
59
+ * - Hard-deleted actors silently drop (cascade through `actor.account_id`
60
+ * FK) — no tombstone oracle, same posture as `actor_lookup`.
61
+ * - Empty result set on no-match — fail-soft like `cell_list`. No
62
+ * "no actor matches" error message that would leak an existence
63
+ * boundary on the search-term axis.
64
+ *
65
+ * ## Why not extend `actor_lookup`?
66
+ *
67
+ * Splitting the methods keeps the wire contracts independent: `actor_lookup`'s
68
+ * input is `{ids}`, `actor_search`'s is `{query}` + optional filters.
69
+ * Both surface the same `ActorLookupEntryJson` row shape (re-used here),
70
+ * so the labels arc on the consumer side stays uniform.
71
+ *
72
+ * @module
73
+ */
74
+ import { z } from 'zod';
75
+ /**
76
+ * Hard cap on the number of rows returned per call. Bounds the search-result
77
+ * enumeration surface. Default limit (`ACTOR_SEARCH_LIMIT_DEFAULT`) is
78
+ * smaller — most pickers render fewer rows than the cap.
79
+ */
80
+ export declare const ACTOR_SEARCH_LIMIT_MAX = 50;
81
+ /** Default `limit` when the caller omits it. */
82
+ export declare const ACTOR_SEARCH_LIMIT_DEFAULT = 20;
83
+ /**
84
+ * Hard cap on the query string length. Long inputs offer no extra search
85
+ * value once they exceed `actor.name` realistic lengths, and a low cap
86
+ * keeps the per-request work bounded for pathological inputs.
87
+ */
88
+ export declare const ACTOR_SEARCH_QUERY_LENGTH_MAX = 50;
89
+ /**
90
+ * Reason: `scope_ids` was empty and the caller is not admin. Distinct from
91
+ * standard `invalid_params` issues so the visiones picker can surface a
92
+ * specific "pick a scope first" message rather than echoing Zod issues.
93
+ */
94
+ export declare const ERROR_ACTOR_SEARCH_SCOPE_REQUIRED: "actor_search_scope_required";
95
+ export declare const ActorSearchInput: z.ZodObject<{
96
+ query: z.ZodString;
97
+ scope_ids: z.ZodOptional<z.ZodArray<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>>;
98
+ limit: z.ZodOptional<z.ZodNumber>;
99
+ }, z.core.$strict>;
100
+ export type ActorSearchInput = z.infer<typeof ActorSearchInput>;
101
+ export declare const ActorSearchOutput: z.ZodObject<{
102
+ actors: z.ZodArray<z.ZodObject<{
103
+ id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
104
+ username: z.ZodString;
105
+ display_name: z.ZodOptional<z.ZodString>;
106
+ }, z.core.$strict>>;
107
+ }, z.core.$strict>;
108
+ export type ActorSearchOutput = z.infer<typeof ActorSearchOutput>;
109
+ export declare const actor_search_action_spec: {
110
+ method: string;
111
+ kind: "request_response";
112
+ initiator: "frontend";
113
+ auth: {
114
+ account: "required";
115
+ actor: "none";
116
+ };
117
+ side_effects: false;
118
+ input: z.ZodObject<{
119
+ query: z.ZodString;
120
+ scope_ids: z.ZodOptional<z.ZodArray<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>>;
121
+ limit: z.ZodOptional<z.ZodNumber>;
122
+ }, z.core.$strict>;
123
+ output: z.ZodObject<{
124
+ actors: z.ZodArray<z.ZodObject<{
125
+ id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
126
+ username: z.ZodString;
127
+ display_name: z.ZodOptional<z.ZodString>;
128
+ }, z.core.$strict>>;
129
+ }, z.core.$strict>;
130
+ async: true;
131
+ rate_limit: "account";
132
+ error_reasons: "actor_search_scope_required"[];
133
+ description: string;
134
+ };
135
+ /**
136
+ * All actor_search action specs — independent opt-in registry. Like
137
+ * `all_actor_lookup_action_specs`, not folded into `all_standard_action_specs`
138
+ * because consumers without a person-target picker can skip it.
139
+ */
140
+ export declare const all_actor_search_action_specs: readonly [{
141
+ method: string;
142
+ kind: "request_response";
143
+ initiator: "frontend";
144
+ auth: {
145
+ account: "required";
146
+ actor: "none";
147
+ };
148
+ side_effects: false;
149
+ input: z.ZodObject<{
150
+ query: z.ZodString;
151
+ scope_ids: z.ZodOptional<z.ZodArray<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>>;
152
+ limit: z.ZodOptional<z.ZodNumber>;
153
+ }, z.core.$strict>;
154
+ output: z.ZodObject<{
155
+ actors: z.ZodArray<z.ZodObject<{
156
+ id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
157
+ username: z.ZodString;
158
+ display_name: z.ZodOptional<z.ZodString>;
159
+ }, z.core.$strict>>;
160
+ }, z.core.$strict>;
161
+ async: true;
162
+ rate_limit: "account";
163
+ error_reasons: "actor_search_scope_required"[];
164
+ description: string;
165
+ }];
166
+ //# sourceMappingURL=actor_search_action_specs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actor_search_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/actor_search_action_specs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAMtB;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,KAAK,CAAC;AAEzC,gDAAgD;AAChD,eAAO,MAAM,0BAA0B,KAAK,CAAC;AAE7C;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,iCAAiC,EAAG,6BAAsC,CAAC;AAExF,eAAO,MAAM,gBAAgB;;;;kBAqB3B,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,eAAO,MAAM,iBAAiB;;;;;;kBAE5B,CAAC;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAElE,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;CAYA,CAAC;AAEtC;;;;GAIG;AACH,eAAO,MAAM,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;;;EAAsC,CAAC"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `actor_search` RPC spec — authenticated case-insensitive prefix search
3
+ * over `actor.name`, returning the same `{id, username, display_name?}`
4
+ * wire shape as `actor_lookup`.
5
+ *
6
+ * Powers person-target pickers — visiones' `CellGrantsEditor.svelte`
7
+ * teacher-picks-student flow replaces the deferred `actor_by_name` arm of
8
+ * `cell_grant_create` with a debounced search against this method. Sibling
9
+ * to `actor_lookup`: that resolves a known batch of ids → labels; this
10
+ * resolves a partial name → candidate actors.
11
+ *
12
+ * ## Auth + rate-limit posture
13
+ *
14
+ * `{account: 'required', actor: 'none'}` + `rate_limit: 'account'`. Same
15
+ * shape as `actor_lookup`: only that the caller is signed in matters, not
16
+ * which actor is calling. The auth gate, the per-account rate limit
17
+ * (default 1200/15min), and the `ACTOR_SEARCH_LIMIT_MAX` per-call cap
18
+ * bound the enumeration surface this method would otherwise present.
19
+ *
20
+ * The handler additionally requires the caller to be admin when
21
+ * `scope_ids` is empty (the unbounded global-search arm). Non-admin
22
+ * callers must always pass at least one scope_id — the SQL filters
23
+ * actors to those holding a role_grant on one of the supplied scopes, so
24
+ * a non-admin caller is restricted to actors they share a scope with.
25
+ * The admin check is account-grain (any actor on the caller's account
26
+ * holds a global `admin` role_grant), matching the `actor: 'none'` posture.
27
+ *
28
+ * ## Caller-passes-scope_ids design
29
+ *
30
+ * `scope_ids` is trusted as a filter, not as an authority claim — the
31
+ * SQL filters to actors with role_grants on those scopes regardless of
32
+ * whether the caller has authority over them. Consumers are responsible
33
+ * for pre-filtering `scope_ids` against their own authority before
34
+ * calling. Visiones passes the set of classrooms the teacher teaches,
35
+ * sourced client-side from the teacher's role_grant list; the teacher
36
+ * predicate stays in the visiones layer rather than baked into fuz_app.
37
+ *
38
+ * Crucially, this does **not** widen the scope-existence oracle: an
39
+ * attacker passing a random scope_id cannot learn "this scope has
40
+ * members matching X" because the join filters to actors holding a
41
+ * role_grant on the scope, and the SQL surfaces neither "did the scope
42
+ * exist" nor "did the scope have non-matching members" — only the
43
+ * matching subset is returned.
44
+ *
45
+ * ## Wire shape — info-leak audit
46
+ *
47
+ * Output `{actors: [{id, username, display_name?}]}` is identical to
48
+ * `actor_lookup`'s — see `./actor_lookup_action_specs.ts` for the full
49
+ * field-by-field audit. Same omissions (`account_id`, email,
50
+ * timestamps, role / role_grants / session state), same `display_name`
51
+ * omitted-not-null contract, same response-order-unspecified rule.
52
+ *
53
+ * Additional `actor_search`-specific posture:
54
+ *
55
+ * - Prefix match (`LOWER(name) LIKE LOWER(query) || '%'`), not full
56
+ * `%query%`. Full-LIKE would let a single call enumerate one
57
+ * alphabetical bucket spread across many starting letters, which
58
+ * defeats the per-call cap as an enumeration bound.
59
+ * - Hard-deleted actors silently drop (cascade through `actor.account_id`
60
+ * FK) — no tombstone oracle, same posture as `actor_lookup`.
61
+ * - Empty result set on no-match — fail-soft like `cell_list`. No
62
+ * "no actor matches" error message that would leak an existence
63
+ * boundary on the search-term axis.
64
+ *
65
+ * ## Why not extend `actor_lookup`?
66
+ *
67
+ * Splitting the methods keeps the wire contracts independent: `actor_lookup`'s
68
+ * input is `{ids}`, `actor_search`'s is `{query}` + optional filters.
69
+ * Both surface the same `ActorLookupEntryJson` row shape (re-used here),
70
+ * so the labels arc on the consumer side stays uniform.
71
+ *
72
+ * @module
73
+ */
74
+ import { z } from 'zod';
75
+ import { Uuid } from '@fuzdev/fuz_util/id.js';
76
+ import { ActorLookupEntryJson } from './actor_lookup_action_specs.js';
77
+ /**
78
+ * Hard cap on the number of rows returned per call. Bounds the search-result
79
+ * enumeration surface. Default limit (`ACTOR_SEARCH_LIMIT_DEFAULT`) is
80
+ * smaller — most pickers render fewer rows than the cap.
81
+ */
82
+ export const ACTOR_SEARCH_LIMIT_MAX = 50;
83
+ /** Default `limit` when the caller omits it. */
84
+ export const ACTOR_SEARCH_LIMIT_DEFAULT = 20;
85
+ /**
86
+ * Hard cap on the query string length. Long inputs offer no extra search
87
+ * value once they exceed `actor.name` realistic lengths, and a low cap
88
+ * keeps the per-request work bounded for pathological inputs.
89
+ */
90
+ export const ACTOR_SEARCH_QUERY_LENGTH_MAX = 50;
91
+ /**
92
+ * Reason: `scope_ids` was empty and the caller is not admin. Distinct from
93
+ * standard `invalid_params` issues so the visiones picker can surface a
94
+ * specific "pick a scope first" message rather than echoing Zod issues.
95
+ */
96
+ export const ERROR_ACTOR_SEARCH_SCOPE_REQUIRED = 'actor_search_scope_required';
97
+ export const ActorSearchInput = z.strictObject({
98
+ query: z
99
+ .string()
100
+ .min(1)
101
+ .max(ACTOR_SEARCH_QUERY_LENGTH_MAX)
102
+ .meta({
103
+ description: `Case-insensitive prefix match against \`actor.name\`. Length 1–${ACTOR_SEARCH_QUERY_LENGTH_MAX}.`,
104
+ }),
105
+ scope_ids: z.array(Uuid).optional().meta({
106
+ description: 'Restrict results to actors holding a role_grant on any of these scopes. Required (non-empty) for non-admin callers; admin callers may omit or pass empty for unbounded search. Caller is responsible for pre-filtering against their own authority — the SQL filter does not enforce it.',
107
+ }),
108
+ limit: z
109
+ .number()
110
+ .int()
111
+ .min(1)
112
+ .max(ACTOR_SEARCH_LIMIT_MAX)
113
+ .optional()
114
+ .meta({
115
+ description: `Maximum rows to return. Defaults to ${ACTOR_SEARCH_LIMIT_DEFAULT}, hard cap ${ACTOR_SEARCH_LIMIT_MAX}.`,
116
+ }),
117
+ });
118
+ export const ActorSearchOutput = z.strictObject({
119
+ actors: z.array(ActorLookupEntryJson),
120
+ });
121
+ export const actor_search_action_spec = {
122
+ method: 'actor_search',
123
+ kind: 'request_response',
124
+ initiator: 'frontend',
125
+ auth: { account: 'required', actor: 'none' },
126
+ side_effects: false,
127
+ input: ActorSearchInput,
128
+ output: ActorSearchOutput,
129
+ async: true,
130
+ rate_limit: 'account',
131
+ error_reasons: [ERROR_ACTOR_SEARCH_SCOPE_REQUIRED],
132
+ description: `Case-insensitive prefix search over actor.name, returning {id, username, display_name?} rows. Authenticated + per-account rate-limited; non-admin callers must pass at least one scope_id. Default limit ${ACTOR_SEARCH_LIMIT_DEFAULT}, hard cap ${ACTOR_SEARCH_LIMIT_MAX}.`,
133
+ };
134
+ /**
135
+ * All actor_search action specs — independent opt-in registry. Like
136
+ * `all_actor_lookup_action_specs`, not folded into `all_standard_action_specs`
137
+ * because consumers without a person-target picker can skip it.
138
+ */
139
+ export const all_actor_search_action_specs = [actor_search_action_spec];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `actor_search` RPC handler.
3
+ *
4
+ * Pure read — no audit, no side effects. Auth (`account: 'required'`,
5
+ * `actor: 'none'`) + rate-limit (`account`-grain) enforced at the spec
6
+ * layer; see `./actor_search_action_specs.ts` for the info-leak audit
7
+ * and threat model.
8
+ *
9
+ * The handler adds two checks the spec layer can't express:
10
+ *
11
+ * - **Admin gate on empty `scope_ids`** — unbounded global search is
12
+ * admin-only. Non-admin callers without a `scope_ids` filter are
13
+ * rejected with `invalid_params` carrying `actor_search_scope_required`.
14
+ * The admin check is account-grain (any actor on the caller's account
15
+ * holds a global `admin` role_grant) since the `actor: 'none'` posture
16
+ * doesn't load `auth.role_grants` for an in-memory check.
17
+ * - **Limit clamp** — input is bounded by `ACTOR_SEARCH_LIMIT_MAX` at
18
+ * the schema; the handler picks the default when omitted.
19
+ *
20
+ * `display_name` is omitted (not `null`) when `actor.name` is blank,
21
+ * matching the wire shape `ActorLookupEntryJson.display_name?` — same
22
+ * convention as `actor_lookup_actions.ts`.
23
+ *
24
+ * @module
25
+ */
26
+ import { type RpcAction } from '../actions/action_rpc.js';
27
+ import type { RouteFactoryDeps } from './deps.js';
28
+ /** Dependencies for `create_actor_search_actions`. */
29
+ export type ActorSearchActionDeps = Pick<RouteFactoryDeps, 'log'>;
30
+ export declare const create_actor_search_actions: (_deps: ActorSearchActionDeps) => Array<RpcAction>;
31
+ //# sourceMappingURL=actor_search_actions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actor_search_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/actor_search_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,EAAqC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAE5F,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAahD,sDAAsD;AACtD,MAAM,MAAM,qBAAqB,GAAG,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,CAAC;AAElE,eAAO,MAAM,2BAA2B,GAAI,OAAO,qBAAqB,KAAG,KAAK,CAAC,SAAS,CAiCzF,CAAC"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `actor_search` RPC handler.
3
+ *
4
+ * Pure read — no audit, no side effects. Auth (`account: 'required'`,
5
+ * `actor: 'none'`) + rate-limit (`account`-grain) enforced at the spec
6
+ * layer; see `./actor_search_action_specs.ts` for the info-leak audit
7
+ * and threat model.
8
+ *
9
+ * The handler adds two checks the spec layer can't express:
10
+ *
11
+ * - **Admin gate on empty `scope_ids`** — unbounded global search is
12
+ * admin-only. Non-admin callers without a `scope_ids` filter are
13
+ * rejected with `invalid_params` carrying `actor_search_scope_required`.
14
+ * The admin check is account-grain (any actor on the caller's account
15
+ * holds a global `admin` role_grant) since the `actor: 'none'` posture
16
+ * doesn't load `auth.role_grants` for an in-memory check.
17
+ * - **Limit clamp** — input is bounded by `ACTOR_SEARCH_LIMIT_MAX` at
18
+ * the schema; the handler picks the default when omitted.
19
+ *
20
+ * `display_name` is omitted (not `null`) when `actor.name` is blank,
21
+ * matching the wire shape `ActorLookupEntryJson.display_name?` — same
22
+ * convention as `actor_lookup_actions.ts`.
23
+ *
24
+ * @module
25
+ */
26
+ import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
27
+ import { rpc_action } from '../actions/action_rpc.js';
28
+ import { query_actor_search } from './actor_search_queries.js';
29
+ import { query_account_has_global_role } from './role_grant_queries.js';
30
+ import { ROLE_ADMIN } from './role_schema.js';
31
+ import { ACTOR_SEARCH_LIMIT_DEFAULT, ERROR_ACTOR_SEARCH_SCOPE_REQUIRED, actor_search_action_spec, } from './actor_search_action_specs.js';
32
+ export const create_actor_search_actions = (_deps) => {
33
+ const handler = async (input, ctx) => {
34
+ if (!input.scope_ids || input.scope_ids.length === 0) {
35
+ // Unbounded global search is admin-only. Account-grain admin
36
+ // check — any actor on the caller's account holds the role.
37
+ const is_admin = await query_account_has_global_role(ctx, ctx.auth.account.id, ROLE_ADMIN);
38
+ if (!is_admin) {
39
+ throw jsonrpc_errors.invalid_params('scope_ids required for non-admin callers', {
40
+ reason: ERROR_ACTOR_SEARCH_SCOPE_REQUIRED,
41
+ });
42
+ }
43
+ }
44
+ const rows = await query_actor_search(ctx, {
45
+ query: input.query,
46
+ scope_ids: input.scope_ids,
47
+ limit: input.limit ?? ACTOR_SEARCH_LIMIT_DEFAULT,
48
+ });
49
+ return {
50
+ actors: rows.map((row) => {
51
+ const display_name = row.display_name?.trim();
52
+ return {
53
+ id: row.id,
54
+ username: row.username,
55
+ ...(display_name ? { display_name } : {}),
56
+ };
57
+ }),
58
+ };
59
+ };
60
+ return [rpc_action(actor_search_action_spec, handler)];
61
+ };