@fuzdev/fuz_app 0.59.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.
- package/dist/actions/CLAUDE.md +5 -5
- package/dist/actions/action_codegen.d.ts +1 -1
- package/dist/actions/action_codegen.js +2 -2
- package/dist/actions/action_event_helpers.d.ts +3 -3
- package/dist/actions/action_event_helpers.js +8 -8
- package/dist/actions/action_event_types.d.ts +3 -3
- package/dist/actions/action_event_types.js +3 -3
- package/dist/actions/transports_ws_auth_guard.d.ts +2 -2
- package/dist/actions/transports_ws_auth_guard.js +3 -3
- package/dist/auth/CLAUDE.md +157 -15
- package/dist/auth/actor_lookup_action_specs.d.ts +127 -0
- package/dist/auth/actor_lookup_action_specs.d.ts.map +1 -0
- package/dist/auth/actor_lookup_action_specs.js +93 -0
- package/dist/auth/actor_lookup_actions.d.ts +19 -0
- package/dist/auth/actor_lookup_actions.d.ts.map +1 -0
- package/dist/auth/actor_lookup_actions.js +32 -0
- package/dist/auth/actor_lookup_queries.d.ts +44 -0
- package/dist/auth/actor_lookup_queries.d.ts.map +1 -0
- package/dist/auth/actor_lookup_queries.js +42 -0
- package/dist/auth/actor_search_action_specs.d.ts +166 -0
- package/dist/auth/actor_search_action_specs.d.ts.map +1 -0
- package/dist/auth/actor_search_action_specs.js +139 -0
- package/dist/auth/actor_search_actions.d.ts +31 -0
- package/dist/auth/actor_search_actions.d.ts.map +1 -0
- package/dist/auth/actor_search_actions.js +61 -0
- package/dist/auth/actor_search_queries.d.ts +75 -0
- package/dist/auth/actor_search_queries.d.ts.map +1 -0
- package/dist/auth/actor_search_queries.js +91 -0
- package/dist/auth/admin_actions.js +2 -2
- package/dist/auth/all_action_spec_registries.d.ts +55 -0
- package/dist/auth/all_action_spec_registries.d.ts.map +1 -0
- package/dist/auth/all_action_spec_registries.js +59 -0
- package/dist/auth/audit_emitter.d.ts +1 -1
- package/dist/auth/audit_emitter.js +2 -2
- package/dist/auth/audit_log_queries.d.ts +1 -1
- package/dist/auth/audit_log_queries.js +3 -3
- package/dist/auth/audit_log_routes.d.ts +1 -1
- package/dist/auth/audit_log_routes.js +1 -1
- package/dist/auth/audit_log_schema.d.ts +5 -5
- package/dist/auth/audit_log_schema.js +7 -7
- package/dist/auth/auth_ddl.d.ts +7 -0
- package/dist/auth/auth_ddl.d.ts.map +1 -1
- package/dist/auth/auth_ddl.js +8 -0
- package/dist/auth/credential_type_schema.d.ts +1 -1
- package/dist/auth/credential_type_schema.js +3 -3
- package/dist/auth/grant_path_schema.d.ts +1 -1
- package/dist/auth/grant_path_schema.js +3 -3
- package/dist/auth/migrations.d.ts +4 -4
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +7 -6
- package/dist/auth/role_grant_offer_actions.js +2 -2
- package/dist/auth/role_grant_offer_notifications.d.ts +2 -2
- package/dist/auth/role_grant_offer_notifications.js +2 -2
- package/dist/auth/role_grant_queries.d.ts +21 -0
- package/dist/auth/role_grant_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_queries.js +31 -0
- package/dist/auth/role_schema.d.ts +2 -2
- package/dist/auth/role_schema.js +3 -3
- package/dist/auth/self_service_role_actions.d.ts +1 -1
- package/dist/auth/self_service_role_actions.js +2 -2
- package/dist/auth/session_cookie.d.ts +1 -1
- package/dist/auth/session_cookie.js +1 -1
- package/dist/auth/session_middleware.d.ts +1 -1
- package/dist/auth/session_middleware.js +5 -5
- package/dist/rate_limiter.d.ts +5 -5
- package/dist/rate_limiter.js +6 -6
- package/dist/realtime/sse_auth_guard.d.ts +3 -3
- package/dist/realtime/sse_auth_guard.js +4 -4
- package/dist/server/app_backend.d.ts +3 -3
- package/dist/server/app_backend.js +4 -4
- package/dist/server/app_server.d.ts +1 -1
- package/dist/server/app_server.js +10 -10
- package/dist/testing/CLAUDE.md +22 -12
- package/dist/testing/admin_integration.js +4 -4
- package/dist/testing/app_server.d.ts +1 -1
- package/dist/testing/app_server.js +2 -2
- package/dist/testing/attack_surface.d.ts +4 -4
- package/dist/testing/attack_surface.js +6 -6
- package/dist/testing/audit_completeness.js +4 -4
- package/dist/testing/data_exposure.d.ts +2 -2
- package/dist/testing/data_exposure.js +7 -7
- package/dist/testing/db.d.ts +8 -8
- package/dist/testing/db.js +11 -11
- package/dist/testing/integration.js +4 -4
- package/dist/testing/integration_helpers.d.ts +6 -6
- package/dist/testing/integration_helpers.js +7 -7
- package/dist/testing/rate_limiting.js +4 -4
- package/dist/testing/round_trip.js +2 -2
- package/dist/testing/rpc_round_trip.js +2 -2
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +23 -2
- package/dist/testing/sse_round_trip.js +2 -2
- package/dist/testing/surface_invariants.d.ts +4 -4
- package/dist/testing/surface_invariants.js +5 -5
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"actor_lookup_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/actor_lookup_action_specs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAKtB;;;GAGG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,iEAAiE;AACjE,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE,eAAO,MAAM,gBAAgB;;kBAQ3B,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;;;;;;;;;;;;;;;;;;;;;;CAWA,CAAC;AAEtC;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B;;;;;;;;;;;;;;;;;;;;;;EAAsC,CAAC"}
|
|
@@ -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
|
+
};
|