@fuzdev/fuz_app 0.114.0 → 0.115.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/auth/CLAUDE.md +52 -8
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +4 -3
- package/dist/auth/audit_log_schema.d.ts +3 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +13 -3
- package/dist/auth/cell_action_specs.js +1 -1
- package/dist/auth/cell_actions.d.ts.map +1 -1
- package/dist/auth/cell_actions.js +33 -4
- package/dist/auth/cell_authorize.d.ts +3 -0
- package/dist/auth/cell_authorize.d.ts.map +1 -1
- package/dist/auth/cell_authorize.js +7 -4
- package/dist/auth/request_context.d.ts +12 -3
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +12 -3
- package/dist/auth/role_grant_offer_action_specs.d.ts +30 -5
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +53 -17
- package/dist/auth/role_grant_offer_actions.d.ts +23 -12
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +132 -38
- package/dist/auth/role_grant_offer_queries.d.ts +36 -0
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +76 -0
- package/dist/auth/role_schema.d.ts +12 -0
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +12 -0
- package/dist/auth/self_service_role_actions.d.ts +14 -0
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +25 -1
- package/dist/server/serve_fact_route.d.ts +3 -1
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +7 -4
- package/dist/testing/CLAUDE.md +12 -4
- package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_gated_create.js +23 -1
- package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_participation.js +34 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +12 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +13 -1
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +9 -7
- package/dist/testing/entities.d.ts +6 -3
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +4 -1
- package/dist/ui/BootstrapForm.svelte +14 -10
- package/dist/ui/BootstrapForm.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +19 -7
- package/dist/ui/LoginForm.svelte +8 -4
- package/dist/ui/LoginForm.svelte.d.ts.map +1 -1
- package/dist/ui/RoleGrantOfferForm.svelte +29 -14
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts +2 -3
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -1
- package/dist/ui/SignupForm.svelte +14 -13
- package/dist/ui/form_state.svelte.d.ts +15 -7
- package/dist/ui/form_state.svelte.d.ts.map +1 -1
- package/dist/ui/form_state.svelte.js +10 -2
- package/dist/ui/role_grant_offers_state.svelte.d.ts +25 -15
- package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
- package/dist/ui/role_grant_offers_state.svelte.js +7 -1
- package/package.json +1 -21
|
@@ -16,6 +16,7 @@ import { columns_sql, qualify_columns } from '../db/sql_columns.js';
|
|
|
16
16
|
import { ROLE_GRANT_OFFER_COLUMNS, role_grant_offer_expr, ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN, ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID, ROLE_GRANT_OFFER_WITH_GRANTOR_SELECT } from './role_grant_offer_ddl.js';
|
|
17
17
|
import { query_create_role_grant, query_role_grant_by_id } from './role_grant_queries.js';
|
|
18
18
|
import { query_audit_log } from './audit_log_queries.js';
|
|
19
|
+
import { is_builtin_role, ROLE_ADMIN, ROLE_KEEPER } from './role_schema.js';
|
|
19
20
|
/**
|
|
20
21
|
* Error thrown by offer-lifecycle queries when the offer is in a non-pending
|
|
21
22
|
* state (accepted / declined / retracted / superseded) and therefore not
|
|
@@ -81,6 +82,22 @@ export class RoleGrantOfferActorMismatchError extends Error {
|
|
|
81
82
|
this.name = 'RoleGrantOfferActorMismatchError';
|
|
82
83
|
}
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Error thrown by `query_accept_offer` when the offer confers a builtin role
|
|
87
|
+
* (`admin` / `keeper`) that this accept must not mint — `refusal` names the
|
|
88
|
+
* rule. Carries the locked (still pending, untouched) offer so the caller can
|
|
89
|
+
* audit the refusal with the offer's role and scope.
|
|
90
|
+
*/
|
|
91
|
+
export class RoleGrantOfferBuiltinRefusedError extends Error {
|
|
92
|
+
offer;
|
|
93
|
+
refusal;
|
|
94
|
+
constructor(offer, refusal) {
|
|
95
|
+
super(`Offer ${offer.id} is refused: ${refusal}`);
|
|
96
|
+
this.name = 'RoleGrantOfferBuiltinRefusedError';
|
|
97
|
+
this.offer = offer;
|
|
98
|
+
this.refusal = refusal;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
84
101
|
/**
|
|
85
102
|
* Error thrown when `query_role_grant_offer_create` is called with a
|
|
86
103
|
* `to_actor_id` that does not exist or does not belong to `to_account_id`.
|
|
@@ -307,6 +324,35 @@ export const query_role_grant_offer_sweep_expired = async (deps) => {
|
|
|
307
324
|
AND expires_at <= NOW()
|
|
308
325
|
ORDER BY role_grant_offer.expires_at ASC`);
|
|
309
326
|
};
|
|
327
|
+
/**
|
|
328
|
+
* Whether `grantor_actor_id` holds an active **global** `admin` on an active
|
|
329
|
+
* actor of an active account — the authority a builtin-role offer needs at the
|
|
330
|
+
* moment its grant is minted.
|
|
331
|
+
*
|
|
332
|
+
* A locking read: `FOR SHARE OF rg` takes a share lock on the grant row that
|
|
333
|
+
* admits, so a concurrent revoke (an `UPDATE` of that row) is strictly ordered
|
|
334
|
+
* against this accept — it either commits first, and the recheck of the locked
|
|
335
|
+
* row sees `revoked_at` set and excludes it, or it waits for this transaction
|
|
336
|
+
* to commit the grant it authorized. A plain `EXISTS` could read the grant as
|
|
337
|
+
* active while a revoke commits in between. The actor and account tombstones
|
|
338
|
+
* are read but not locked — ordering a delete racing an accept isn't worth
|
|
339
|
+
* locking two more tables. Twin of the Rust `grantor_holds_global_admin`.
|
|
340
|
+
*/
|
|
341
|
+
const query_grantor_holds_global_admin = async (deps, grantor_actor_id) => {
|
|
342
|
+
const row = await deps.db.query_one(`SELECT 1 AS held FROM role_grant rg
|
|
343
|
+
JOIN actor act ON act.id = rg.actor_id
|
|
344
|
+
JOIN account a ON a.id = act.account_id
|
|
345
|
+
WHERE rg.actor_id = $1
|
|
346
|
+
AND rg.role = $2
|
|
347
|
+
AND rg.scope_id IS NULL
|
|
348
|
+
AND rg.revoked_at IS NULL
|
|
349
|
+
AND (rg.expires_at IS NULL OR rg.expires_at > NOW())
|
|
350
|
+
AND act.deleted_at IS NULL
|
|
351
|
+
AND a.deleted_at IS NULL
|
|
352
|
+
LIMIT 1
|
|
353
|
+
FOR SHARE OF rg`, [grantor_actor_id, ROLE_ADMIN]);
|
|
354
|
+
return row != null;
|
|
355
|
+
};
|
|
310
356
|
/**
|
|
311
357
|
* Accept an offer atomically: mark accepted, insert the role_grant, stamp
|
|
312
358
|
* `resulting_role_grant_id`, supersede sibling pending offers for the same
|
|
@@ -325,6 +371,11 @@ export const query_role_grant_offer_sweep_expired = async (deps) => {
|
|
|
325
371
|
* - `RoleGrantOfferAlreadyTerminalError` — offer is declined, retracted, or
|
|
326
372
|
* superseded.
|
|
327
373
|
* - `RoleGrantOfferExpiredError` — offer is pending but past `expires_at`.
|
|
374
|
+
* - `RoleGrantOfferActorMismatchError` — actor-targeted offer, different actor.
|
|
375
|
+
* - `RoleGrantOfferBuiltinRefusedError` — a builtin-role offer that must not
|
|
376
|
+
* mint: `keeper` at all, a builtin bound to a scope, or one whose grantor no
|
|
377
|
+
* longer holds an active global `admin` (read under `FOR SHARE`). The offer
|
|
378
|
+
* stays pending.
|
|
328
379
|
*
|
|
329
380
|
* Sibling supersede is what closes the "accept a pre-revoke sibling offer
|
|
330
381
|
* to bypass a revoke" path: once A is accepted, B/C/... can no longer be
|
|
@@ -337,6 +388,8 @@ export const query_role_grant_offer_sweep_expired = async (deps) => {
|
|
|
337
388
|
* @throws RoleGrantOfferNotFoundError if the offer is missing or belongs to another recipient
|
|
338
389
|
* @throws RoleGrantOfferAlreadyTerminalError if the offer is declined, retracted, or superseded
|
|
339
390
|
* @throws RoleGrantOfferExpiredError if the offer is pending but past `expires_at`
|
|
391
|
+
* @throws RoleGrantOfferActorMismatchError if the offer is actor-targeted to a different actor
|
|
392
|
+
* @throws RoleGrantOfferBuiltinRefusedError if a builtin-role offer must not mint (see `BuiltinOfferRefusal`)
|
|
340
393
|
* @throws Error if the accepting `actor_id` does not belong to `to_account_id`, or invariant assertions fail
|
|
341
394
|
*/
|
|
342
395
|
export const query_accept_offer = async (deps, input) => {
|
|
@@ -413,6 +466,29 @@ export const query_accept_offer = async (deps, input) => {
|
|
|
413
466
|
if (locked.to_actor_id != null && locked.to_actor_id !== actor_id) {
|
|
414
467
|
throw new RoleGrantOfferActorMismatchError(offer_id);
|
|
415
468
|
}
|
|
469
|
+
// Builtin-role offers — checked here, the chokepoint where an offer turns
|
|
470
|
+
// into a `role_grant`, so a direct caller of this query can't skip them. No
|
|
471
|
+
// current grant path creates a refused shape, but a pending row can still
|
|
472
|
+
// carry one (a direct `query_role_grant_offer_create`, or an offer made under
|
|
473
|
+
// a since-closed path).
|
|
474
|
+
if (is_builtin_role(locked.role)) {
|
|
475
|
+
let refusal = null;
|
|
476
|
+
if (locked.role === ROLE_KEEPER) {
|
|
477
|
+
// `keeper` is bootstrap-only — never conferred through an offer, even
|
|
478
|
+
// from a global admin.
|
|
479
|
+
refusal = 'bootstrap_only';
|
|
480
|
+
}
|
|
481
|
+
else if (locked.scope_id !== null) {
|
|
482
|
+
// Builtin roles are global-only.
|
|
483
|
+
refusal = 'scoped';
|
|
484
|
+
}
|
|
485
|
+
else if (!(await query_grantor_holds_global_admin(deps, locked.from_actor_id))) {
|
|
486
|
+
refusal = 'grantor_not_authorized';
|
|
487
|
+
}
|
|
488
|
+
if (refusal !== null) {
|
|
489
|
+
throw new RoleGrantOfferBuiltinRefusedError(locked, refusal);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
416
492
|
// Verify the accepting actor belongs to the recipient account.
|
|
417
493
|
// Defense-in-depth: the action handler passes `auth.actor.id` which is
|
|
418
494
|
// already session-bound, but enforcing the invariant here protects
|
|
@@ -39,6 +39,18 @@ export declare const BuiltinRole: z.ZodEnum<{
|
|
|
39
39
|
keeper: "keeper";
|
|
40
40
|
}>;
|
|
41
41
|
export type BuiltinRole = z.infer<typeof BuiltinRole>;
|
|
42
|
+
/**
|
|
43
|
+
* Whether `name` is a builtin role (`keeper` / `admin`).
|
|
44
|
+
*
|
|
45
|
+
* Builtin roles are **global-only**: no grant path binds one to a scope
|
|
46
|
+
* (`role_grant_assign`, `role_grant_offer_create`, and
|
|
47
|
+
* `role_grant_offer_accept` refuse it), and every gate that reads one checks
|
|
48
|
+
* the global grant, so a scoped builtin row confers nothing.
|
|
49
|
+
*
|
|
50
|
+
* @param name - the role name to check
|
|
51
|
+
* @returns `true` for `keeper` or `admin`
|
|
52
|
+
*/
|
|
53
|
+
export declare const is_builtin_role: (name: string) => name is BuiltinRole;
|
|
42
54
|
/**
|
|
43
55
|
* Configuration for a role.
|
|
44
56
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"role_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/role_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAEN,KAAK,0BAA0B,EAC/B,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAGN,KAAK,qBAAqB,EAC1B,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAEpE,0FAA0F;AAC1F,eAAO,MAAM,QAAQ,aAKnB,CAAC;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAIhD,sFAAsF;AACtF,eAAO,MAAM,WAAW,WAAW,CAAC;AAEpC,iEAAiE;AACjE,eAAO,MAAM,UAAU,UAAU,CAAC;AAElC,+CAA+C;AAC/C,eAAO,MAAM,aAAa,8BAAqC,CAAC;AAEhE,yCAAyC;AACzC,eAAO,MAAM,WAAW;;;EAAwB,CAAC;AACjD,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,WAAW,QAAQ;IACxB,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAClD;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/C;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAuBnE,CAAC;AAEH,iGAAiG;AACjG,MAAM,WAAW,uBAAuB;IACvC,uGAAuG;IACvG,gBAAgB,CAAC,EAAE,0BAA0B,CAAC;IAC9C,+FAA+F;IAC/F,WAAW,CAAC,EAAE,qBAAqB,CAAC;IACpC,oFAAoF;IACpF,WAAW,CAAC,EAAE,qBAAqB,CAAC;CACpC;AAED,oFAAoF;AACpF,MAAM,WAAW,gBAAgB;IAChC,0GAA0G;IAC1G,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACxB,yEAAyE;IACzE,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;CAC1C;AAqBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,eAAO,MAAM,kBAAkB,GAC9B,gBAAgB,aAAa,CAAC,QAAQ,CAAC,EACvC,UAAS,uBAA4B,KACnC,gBA2CF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAC/B,YAAY,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,EACzC,MAAM,MAAM,EACZ,YAAY,MAAM,KAChB,OAGF,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,0BAA0B,GACtC,YAAY,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,EACzC,YAAY,MAAM,KAChB,KAAK,CAAC,MAAM,CAMd,CAAC"}
|
|
1
|
+
{"version":3,"file":"role_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/role_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAEN,KAAK,0BAA0B,EAC/B,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAGN,KAAK,qBAAqB,EAC1B,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAEpE,0FAA0F;AAC1F,eAAO,MAAM,QAAQ,aAKnB,CAAC;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAIhD,sFAAsF;AACtF,eAAO,MAAM,WAAW,WAAW,CAAC;AAEpC,iEAAiE;AACjE,eAAO,MAAM,UAAU,UAAU,CAAC;AAElC,+CAA+C;AAC/C,eAAO,MAAM,aAAa,8BAAqC,CAAC;AAEhE,yCAAyC;AACzC,eAAO,MAAM,WAAW;;;EAAwB,CAAC;AACjD,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,IAAI,IAAI,WACX,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,WAAW,QAAQ;IACxB,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAClD;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/C;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAuBnE,CAAC;AAEH,iGAAiG;AACjG,MAAM,WAAW,uBAAuB;IACvC,uGAAuG;IACvG,gBAAgB,CAAC,EAAE,0BAA0B,CAAC;IAC9C,+FAA+F;IAC/F,WAAW,CAAC,EAAE,qBAAqB,CAAC;IACpC,oFAAoF;IACpF,WAAW,CAAC,EAAE,qBAAqB,CAAC;CACpC;AAED,oFAAoF;AACpF,MAAM,WAAW,gBAAgB;IAChC,0GAA0G;IAC1G,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACxB,yEAAyE;IACzE,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;CAC1C;AAqBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,eAAO,MAAM,kBAAkB,GAC9B,gBAAgB,aAAa,CAAC,QAAQ,CAAC,EACvC,UAAS,uBAA4B,KACnC,gBA2CF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAC/B,YAAY,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,EACzC,MAAM,MAAM,EACZ,YAAY,MAAM,KAChB,OAGF,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,0BAA0B,GACtC,YAAY,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,EACzC,YAAY,MAAM,KAChB,KAAK,CAAC,MAAM,CAMd,CAAC"}
|
package/dist/auth/role_schema.js
CHANGED
|
@@ -36,6 +36,18 @@ export const ROLE_ADMIN = 'admin';
|
|
|
36
36
|
export const BUILTIN_ROLES = [ROLE_KEEPER, ROLE_ADMIN];
|
|
37
37
|
/** Zod schema for builtin roles only. */
|
|
38
38
|
export const BuiltinRole = z.enum(BUILTIN_ROLES);
|
|
39
|
+
/**
|
|
40
|
+
* Whether `name` is a builtin role (`keeper` / `admin`).
|
|
41
|
+
*
|
|
42
|
+
* Builtin roles are **global-only**: no grant path binds one to a scope
|
|
43
|
+
* (`role_grant_assign`, `role_grant_offer_create`, and
|
|
44
|
+
* `role_grant_offer_accept` refuse it), and every gate that reads one checks
|
|
45
|
+
* the global grant, so a scoped builtin row confers nothing.
|
|
46
|
+
*
|
|
47
|
+
* @param name - the role name to check
|
|
48
|
+
* @returns `true` for `keeper` or `admin`
|
|
49
|
+
*/
|
|
50
|
+
export const is_builtin_role = (name) => name === ROLE_KEEPER || name === ROLE_ADMIN;
|
|
39
51
|
/**
|
|
40
52
|
* Builtin role specs, keyed by role name. Not overridable by consumers
|
|
41
53
|
* — read once at startup by `create_role_schema` and the action
|
|
@@ -39,6 +39,19 @@
|
|
|
39
39
|
import { type RpcAction } from '../actions/action_rpc.js';
|
|
40
40
|
import { type RoleSchemaResult } from './role_schema.js';
|
|
41
41
|
import type { ActionFactoryDeps } from './deps.js';
|
|
42
|
+
/**
|
|
43
|
+
* Validate a deployment's self-service eligible-role set at construction.
|
|
44
|
+
*
|
|
45
|
+
* Refuses a builtin role (`admin` / `keeper`) — self-toggling one would hand
|
|
46
|
+
* any account instance-wide authority, so no eligible set may name it — and a
|
|
47
|
+
* name outside the `RoleName` grammar. The same fail-loud-at-boot shape as
|
|
48
|
+
* `create_role_schema`, which refuses a builtin name where only app roles
|
|
49
|
+
* belong. Twin of the Rust `validate_self_service_eligible_roles`.
|
|
50
|
+
*
|
|
51
|
+
* @param roles - the eligible role names
|
|
52
|
+
* @throws Error on a builtin role or a name outside the role grammar
|
|
53
|
+
*/
|
|
54
|
+
export declare const validate_self_service_eligible_roles: (roles: Iterable<string>) => void;
|
|
42
55
|
/** Options for `create_self_service_role_actions`. */
|
|
43
56
|
export interface SelfServiceRoleActionsOptions {
|
|
44
57
|
/**
|
|
@@ -70,6 +83,7 @@ export interface SelfServiceRoleActionsOptions {
|
|
|
70
83
|
* @param options - optional eligible-role override plus optional role schema for default-eligibility derivation
|
|
71
84
|
* @returns the `RpcAction` array to spread into a `create_rpc_endpoint` call
|
|
72
85
|
* @throws Error at factory time if any `eligible_roles` entry is missing from `options.roles.role_specs`
|
|
86
|
+
* @throws Error at factory time if the eligible set names a builtin role or an invalid role name
|
|
73
87
|
*/
|
|
74
88
|
export declare const create_self_service_role_actions: (deps: ActionFactoryDeps, options?: SelfServiceRoleActionsOptions) => Array<RpcAction>;
|
|
75
89
|
//# sourceMappingURL=self_service_role_actions.d.ts.map
|
|
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE/F,OAAO,
|
|
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE/F,OAAO,EAKN,KAAK,gBAAgB,EACrB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAWnD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oCAAoC,GAAI,OAAO,QAAQ,CAAC,MAAM,CAAC,KAAG,IAW9E,CAAC;AAEF,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC7C;;;;;;;;;;;OAWG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,iBAAiB,EACvB,UAAS,6BAAkC,KACzC,KAAK,CAAC,SAAS,CA6HjB,CAAC"}
|
|
@@ -38,12 +38,34 @@
|
|
|
38
38
|
*/
|
|
39
39
|
import { rpc_action } from '../actions/action_rpc.js';
|
|
40
40
|
import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
|
|
41
|
-
import { builtin_role_specs_by_name, list_roles_with_grant_path } from './role_schema.js';
|
|
41
|
+
import { builtin_role_specs_by_name, is_builtin_role, list_roles_with_grant_path, RoleName } from './role_schema.js';
|
|
42
42
|
import { GRANT_PATH_SELF_SERVICE } from './grant_path_schema.js';
|
|
43
43
|
import { query_create_role_grant, query_revoke_role_grant } from './role_grant_queries.js';
|
|
44
44
|
import { is_role_grant_active } from './account_schema.js';
|
|
45
45
|
import { has_scoped_role } from './request_context.js';
|
|
46
46
|
import { ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE, self_service_role_set_action_spec } from './self_service_role_action_specs.js';
|
|
47
|
+
/**
|
|
48
|
+
* Validate a deployment's self-service eligible-role set at construction.
|
|
49
|
+
*
|
|
50
|
+
* Refuses a builtin role (`admin` / `keeper`) — self-toggling one would hand
|
|
51
|
+
* any account instance-wide authority, so no eligible set may name it — and a
|
|
52
|
+
* name outside the `RoleName` grammar. The same fail-loud-at-boot shape as
|
|
53
|
+
* `create_role_schema`, which refuses a builtin name where only app roles
|
|
54
|
+
* belong. Twin of the Rust `validate_self_service_eligible_roles`.
|
|
55
|
+
*
|
|
56
|
+
* @param roles - the eligible role names
|
|
57
|
+
* @throws Error on a builtin role or a name outside the role grammar
|
|
58
|
+
*/
|
|
59
|
+
export const validate_self_service_eligible_roles = (roles) => {
|
|
60
|
+
for (const role of roles) {
|
|
61
|
+
if (is_builtin_role(role)) {
|
|
62
|
+
throw new Error(`self-service eligible role "${role}" is a builtin role — builtin roles are never self-service`);
|
|
63
|
+
}
|
|
64
|
+
if (!RoleName.safeParse(role).success) {
|
|
65
|
+
throw new Error(`self-service eligible role "${role}" is not a valid role name`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
47
69
|
/**
|
|
48
70
|
* Build the unified self-service role toggle RPC action.
|
|
49
71
|
*
|
|
@@ -53,12 +75,14 @@ import { ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE, self_service_role_set_action_spec
|
|
|
53
75
|
* @param options - optional eligible-role override plus optional role schema for default-eligibility derivation
|
|
54
76
|
* @returns the `RpcAction` array to spread into a `create_rpc_endpoint` call
|
|
55
77
|
* @throws Error at factory time if any `eligible_roles` entry is missing from `options.roles.role_specs`
|
|
78
|
+
* @throws Error at factory time if the eligible set names a builtin role or an invalid role name
|
|
56
79
|
*/
|
|
57
80
|
export const create_self_service_role_actions = (deps, options = {}) => {
|
|
58
81
|
const role_specs = options.roles?.role_specs ?? builtin_role_specs_by_name;
|
|
59
82
|
const eligible = options.eligible_roles
|
|
60
83
|
? new Set(options.eligible_roles)
|
|
61
84
|
: new Set(list_roles_with_grant_path(role_specs, GRANT_PATH_SELF_SERVICE));
|
|
85
|
+
validate_self_service_eligible_roles(eligible);
|
|
62
86
|
if (options.eligible_roles && options.roles) {
|
|
63
87
|
for (const r of eligible) {
|
|
64
88
|
if (!role_specs.has(r)) {
|
|
@@ -138,7 +138,9 @@ export declare const create_serve_cell_fact_route_spec: (options: CreateServeFac
|
|
|
138
138
|
* Auth is `{account: 'required', actor: 'required', roles: ['admin']}` —
|
|
139
139
|
* the dispatcher's authorization phase resolves the acting actor and the
|
|
140
140
|
* post-authorization guard enforces the admin role before the handler runs.
|
|
141
|
-
* The handler re-checks
|
|
141
|
+
* The handler re-checks the **global** admin grant
|
|
142
|
+
* (`has_scoped_role(_, admin, null)`, the auth phase's own reading) as
|
|
143
|
+
* defense-in-depth so a future
|
|
142
144
|
* mounting/auth-shape regression fails closed rather than serving by bare
|
|
143
145
|
* hash to a non-admin.
|
|
144
146
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwFG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAM/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAkEjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAgHD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAgDF,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwFG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAM/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAkEjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAgHD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAgDF,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA2CF,CAAC"}
|
|
@@ -93,7 +93,7 @@ import { join } from 'node:path';
|
|
|
93
93
|
import { FactHashSchema } from '@fuzdev/fuz_util/hash_schemas.ts';
|
|
94
94
|
import { Uuid } from '@fuzdev/fuz_util/id.ts';
|
|
95
95
|
import { z } from 'zod';
|
|
96
|
-
import { build_request_context, get_request_context,
|
|
96
|
+
import { build_request_context, get_request_context, has_scoped_role } from '../auth/request_context.js';
|
|
97
97
|
import { ROLE_ADMIN } from '../auth/role_schema.js';
|
|
98
98
|
import { ActingActor } from '../http/auth_shape.js';
|
|
99
99
|
import { ACCOUNT_ID_KEY } from '../hono_context.js';
|
|
@@ -315,7 +315,9 @@ export const create_serve_cell_fact_route_spec = (options) => {
|
|
|
315
315
|
* Auth is `{account: 'required', actor: 'required', roles: ['admin']}` —
|
|
316
316
|
* the dispatcher's authorization phase resolves the acting actor and the
|
|
317
317
|
* post-authorization guard enforces the admin role before the handler runs.
|
|
318
|
-
* The handler re-checks
|
|
318
|
+
* The handler re-checks the **global** admin grant
|
|
319
|
+
* (`has_scoped_role(_, admin, null)`, the auth phase's own reading) as
|
|
320
|
+
* defense-in-depth so a future
|
|
319
321
|
* mounting/auth-shape regression fails closed rather than serving by bare
|
|
320
322
|
* hash to a non-admin.
|
|
321
323
|
*/
|
|
@@ -351,8 +353,9 @@ export const create_serve_fact_route_spec = (options) => {
|
|
|
351
353
|
const { hash } = get_route_params(c);
|
|
352
354
|
// Defense-in-depth: the auth phase already gated this on the admin
|
|
353
355
|
// role, but re-check the resolved context so a mounting/auth-shape
|
|
354
|
-
// regression fails closed instead of serving by bare hash.
|
|
355
|
-
|
|
356
|
+
// regression fails closed instead of serving by bare hash. Global
|
|
357
|
+
// grant only — a scoped `admin` row must not read every fact.
|
|
358
|
+
if (!has_scoped_role(get_request_context(c), ROLE_ADMIN, null)) {
|
|
356
359
|
return c.body(null, 404);
|
|
357
360
|
}
|
|
358
361
|
return serve_fact_bytes(c, route, hash, config);
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -1237,8 +1237,11 @@ the option type states whether the bundle is read.
|
|
|
1237
1237
|
(gate on `capabilities.cell_gated_create`, **cross-only**) — the parent-aware
|
|
1238
1238
|
cell-creation authorizer (`CellCreateAuthorize`, **directory model**) +
|
|
1239
1239
|
the `cell_moderate` verb parity proofs. Both spines mount the same
|
|
1240
|
-
`test_cell_gated_create_authorize` policy
|
|
1241
|
-
|
|
1240
|
+
`test_cell_gated_create_authorize` policy (its admin bypass and `min_role`
|
|
1241
|
+
read the **global** grant): a `kind: 'space'` **root** is
|
|
1242
|
+
admin-only (a non-admin → **403** `cell_create_forbidden`, and the same for a
|
|
1243
|
+
non-admin `cell_clone` of a viewable space — clone runs the authorizer for
|
|
1244
|
+
every cell it writes); a **contribution**
|
|
1242
1245
|
under a space is gated by the root's `data.policy[kind] = {min_role,
|
|
1243
1246
|
moderation_required}`, with `moderation` set per the verdict (`pending`+private
|
|
1244
1247
|
vs `approved`+author-visibility); a **hidden** parent **404**-masks vs a
|
|
@@ -1619,12 +1622,17 @@ non-admin _holder_ of `participant` (and a fresh non-admin) can't offer it (403
|
|
|
1619
1622
|
`role_grant_assign` is admin-only** — non-admin holder / fresh → 403
|
|
1620
1623
|
`insufficient_permissions` (dispatcher gate), anon → 401; plus the
|
|
1621
1624
|
`to_actor_id`-must-belong-to-`to_account_id` resolution arm (400
|
|
1622
|
-
`role_grant_offer_actor_account_mismatch`)
|
|
1625
|
+
`role_grant_offer_actor_account_mismatch`); **(d) builtin roles are
|
|
1626
|
+
global-only** — an unpaired `scope_kind` / `scope_id` → 400 on assign + offer,
|
|
1627
|
+
a scoped `admin` → 400 `role_grant_builtin_scoped` on assign + offer, a builtin
|
|
1628
|
+
offer from a non-global-admin → 403 `role_grant_offer_not_authorized`.
|
|
1623
1629
|
|
|
1624
1630
|
`describe_role_grant_participation_cross_tests` (this module) is the **imperative
|
|
1625
1631
|
escape hatch** for the multi-step success paths a static row can't express (they
|
|
1626
1632
|
need a real recipient): admin **assigns** `participant` to a fresh account
|
|
1627
|
-
(`{ok, role_grant_id}` + idempotent re-assign),
|
|
1633
|
+
(`{ok, role_grant_id}` + idempotent re-assign), admin **assigns** a scoped
|
|
1634
|
+
`participant` whose `scope_kind` + `scope_id` round-trip through
|
|
1635
|
+
`admin_account_list` (distinct from the global grant), and admin **offers**
|
|
1628
1636
|
`participant` → recipient **accepts** → a role_grant lands. The `role_holder`
|
|
1629
1637
|
principal is seeded via `extra_accounts: [{username, roles: ['participant']}]`
|
|
1630
1638
|
(bootstrap-cradle direct grant) and named through `principals.role_holder`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cell_gated_create.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_gated_create.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"cell_gated_create.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_gated_create.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAyD9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAkB1E,eAAO,MAAM,sCAAsC,GAClD,SAAS,uCAAuC,KAC9C,IAwPF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,kCAAkC,GAC9C,SAAS,uCAAuC,KAC9C,IAyKF,CAAC"}
|
|
@@ -30,6 +30,9 @@ import '../assert_dev_env.js';
|
|
|
30
30
|
* **private** space the caller can't view → **404** `cell_not_found` (the
|
|
31
31
|
* parent is masked); under a **public** space → **403** (you see it, you
|
|
32
32
|
* can't contribute).
|
|
33
|
+
* - **clone is no side door** — `cell_clone` runs the authorizer for every cell
|
|
34
|
+
* it writes as a parentless create, so a non-admin clone of a viewable
|
|
35
|
+
* `space` root → **403** `cell_create_forbidden`; admin → succeeds.
|
|
33
36
|
*
|
|
34
37
|
* Gated on `capabilities.cell_gated_create` — `true` only on the reference
|
|
35
38
|
* spine binaries that mount the policy, so it skips for generic consumers and
|
|
@@ -41,7 +44,8 @@ import '../assert_dev_env.js';
|
|
|
41
44
|
* @module
|
|
42
45
|
*/
|
|
43
46
|
import { describe, assert } from 'vitest';
|
|
44
|
-
import { CellCreateOutput, CellModerateOutput } from '../../auth/cell_action_specs.js';
|
|
47
|
+
import { CellCloneOutput, CellCreateOutput, CellModerateOutput } from '../../auth/cell_action_specs.js';
|
|
48
|
+
import { JSONRPC_ERROR_CODES } from '../../http/jsonrpc_errors.js';
|
|
45
49
|
import { test_if } from './capabilities.js';
|
|
46
50
|
import { cross_rpc_call, error_reason, expect_output } from './cell_cross_helpers.js';
|
|
47
51
|
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
@@ -211,4 +215,22 @@ export const describe_cell_moderate_cross_tests = (options) => {
|
|
|
211
215
|
assert.strictEqual(moderated.cell.visibility, 'private', 'rejection leaves the post private');
|
|
212
216
|
});
|
|
213
217
|
});
|
|
218
|
+
describe('cell_clone runs the creation authorizer (parity)', () => {
|
|
219
|
+
test_if(capabilities.cell_gated_create, 'a non-admin clone of a viewable `space` root → 403, admin clone succeeds', async () => {
|
|
220
|
+
const fixture = await setup_test();
|
|
221
|
+
const space = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: SPACE_CELL_KIND, data: SPACE_POLICY, visibility: 'public' }, fixture.create_session_headers()), CellCreateOutput);
|
|
222
|
+
// Every cell a clone writes is a parentless create of the source's
|
|
223
|
+
// kind, gated like `cell_create` — so a caller denied a `space` root
|
|
224
|
+
// can't clone one they can see into one they own.
|
|
225
|
+
const stranger = await fixture.create_account({ username: 'clone_stranger' });
|
|
226
|
+
const denied = await cross_rpc_call(fixture.transport, rpc_path, 'cell_clone', { source_id: space.cell.id }, stranger.create_session_headers());
|
|
227
|
+
assert.ok(!denied.ok, 'a non-admin must not clone a space root');
|
|
228
|
+
assert.strictEqual(denied.error?.code, JSONRPC_ERROR_CODES.forbidden, 'a 403');
|
|
229
|
+
assert.strictEqual(error_reason(denied), 'cell_create_forbidden');
|
|
230
|
+
const cloned = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_clone', { source_id: space.cell.id }, fixture.create_session_headers()), CellCloneOutput);
|
|
231
|
+
assert.strictEqual(cloned.cell.kind, SPACE_CELL_KIND, 'the admin clones the space');
|
|
232
|
+
assert.notStrictEqual(cloned.cell.id, space.cell.id);
|
|
233
|
+
assert.strictEqual(cloned.cell.moderation, null, 'a clone stamps no moderation');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
214
236
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"role_grant_participation.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/role_grant_participation.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"role_grant_participation.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/role_grant_participation.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAyC9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,0EAA0E;AAC1E,MAAM,WAAW,sCAAsC;IACtD,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,eAAO,MAAM,6CAA6C,GACzD,SAAS,sCAAsC,KAC7C,IA8IF,CAAC"}
|
|
@@ -15,6 +15,9 @@ import '../assert_dev_env.js';
|
|
|
15
15
|
* re-assigning the active grant is idempotent (same id).
|
|
16
16
|
* - **consent flow** — `role_grant_offer_create` of `participant` (admin-only)
|
|
17
17
|
* → the recipient `role_grant_offer_accept`s → a role_grant lands.
|
|
18
|
+
* - **scoped assign** — `role_grant_assign` of `participant` with a paired
|
|
19
|
+
* `scope_kind` + `scope_id` lands a scoped grant carrying both, read back
|
|
20
|
+
* through `admin_account_list`, distinct from the global grant.
|
|
18
21
|
*
|
|
19
22
|
* The single-request gate/denial matrix (grantability refusal, admin-only
|
|
20
23
|
* conferral, dispatcher admin gate, auth) lives in the declarative table
|
|
@@ -55,6 +58,37 @@ export const describe_role_grant_participation_cross_tests = (options) => {
|
|
|
55
58
|
assert.ok(second.ok, `re-assign must succeed: ${JSON.stringify(second)}`);
|
|
56
59
|
assert.strictEqual(second.result.role_grant_id, assigned.role_grant_id, 're-assigning an active grant returns the existing role_grant id');
|
|
57
60
|
});
|
|
61
|
+
test('admin assigns a scoped participant grant that round-trips scope_kind', async () => {
|
|
62
|
+
const fixture = await setup_test();
|
|
63
|
+
const recipient = await fixture.create_account({ username: 'participation_scoped' });
|
|
64
|
+
const scope_id = '33333333-3333-4333-8333-333333333333';
|
|
65
|
+
const scoped = await rpc(fixture.transport, 'role_grant_assign', {
|
|
66
|
+
to_account_id: recipient.account.id,
|
|
67
|
+
role: SPINE_PARTICIPANT_ROLE,
|
|
68
|
+
scope_kind: 'space',
|
|
69
|
+
scope_id
|
|
70
|
+
}, fixture.create_session_headers());
|
|
71
|
+
assert.ok(scoped.ok, `scoped assign must succeed: ${JSON.stringify(scoped)}`);
|
|
72
|
+
const scoped_id = scoped.result.role_grant_id;
|
|
73
|
+
const global = await rpc(fixture.transport, 'role_grant_assign', { to_account_id: recipient.account.id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
|
|
74
|
+
assert.ok(global.ok, `global assign must succeed: ${JSON.stringify(global)}`);
|
|
75
|
+
const global_id = global.result.role_grant_id;
|
|
76
|
+
assert.notStrictEqual(global_id, scoped_id, 'the global grant is a distinct row');
|
|
77
|
+
const listed = await rpc(fixture.transport, 'admin_account_list', {}, fixture.create_session_headers());
|
|
78
|
+
assert.ok(listed.ok, `admin_account_list must succeed: ${JSON.stringify(listed)}`);
|
|
79
|
+
const entry = listed.result.accounts.find((a) => a.account.id === recipient.account.id);
|
|
80
|
+
assert.ok(entry, 'the recipient is listed');
|
|
81
|
+
const by_id = new Map(entry.role_grants.map((g) => [g.id, g]));
|
|
82
|
+
const scoped_row = by_id.get(scoped_id);
|
|
83
|
+
assert.ok(scoped_row, 'the scoped grant is listed');
|
|
84
|
+
assert.strictEqual(scoped_row.role, SPINE_PARTICIPANT_ROLE);
|
|
85
|
+
assert.strictEqual(scoped_row.scope_kind, 'space', 'scope_kind round-trips');
|
|
86
|
+
assert.strictEqual(scoped_row.scope_id, scope_id, 'scope_id round-trips');
|
|
87
|
+
const global_row = by_id.get(global_id);
|
|
88
|
+
assert.ok(global_row, 'the global grant is listed');
|
|
89
|
+
assert.strictEqual(global_row.scope_kind, null);
|
|
90
|
+
assert.strictEqual(global_row.scope_id, null);
|
|
91
|
+
});
|
|
58
92
|
test('admin offers the participant app-role and the recipient accepts', async () => {
|
|
59
93
|
const fixture = await setup_test();
|
|
60
94
|
const recipient = await fixture.create_account({ username: 'participation_offeree' });
|
|
@@ -2,6 +2,14 @@ import '../assert_dev_env.js';
|
|
|
2
2
|
import type { BackendConfig } from './backend_config.js';
|
|
3
3
|
/** Env var naming the prebuilt `testing_spine_stub` binary. Required when `binary_path` is omitted. */
|
|
4
4
|
export declare const RUST_SPINE_STUB_BIN_ENV = "FUZ_TESTING_RUST_SPINE_STUB_BIN";
|
|
5
|
+
/**
|
|
6
|
+
* Env var overriding the stub's Postgres URL when `database_url` is omitted —
|
|
7
|
+
* e.g. a scratch cluster on a spare port, keeping the run off the machine's
|
|
8
|
+
* default server. Pair it with the libpq `PGHOST` / `PGPORT` the runner's
|
|
9
|
+
* idempotent `createdb` reads, so the database it ensures is the one the URL
|
|
10
|
+
* names.
|
|
11
|
+
*/
|
|
12
|
+
export declare const RUST_SPINE_STUB_DATABASE_URL_ENV = "FUZ_TESTING_RUST_SPINE_STUB_DATABASE_URL";
|
|
5
13
|
/**
|
|
6
14
|
* Env var the stub reads for the absolute path of the committed
|
|
7
15
|
* `expected_schema.json` its `/ready` gate introspects against. Pointed at the
|
|
@@ -16,7 +24,10 @@ export declare const RUST_SPINE_STUB_DEFAULT_DATABASE_URL = "postgres://localhos
|
|
|
16
24
|
export interface SpineStubBackendConfigOptions {
|
|
17
25
|
/** Listening port. Default `RUST_SPINE_STUB_DEFAULT_PORT`. */
|
|
18
26
|
readonly port?: number;
|
|
19
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Postgres connection URL. Default `$FUZ_TESTING_RUST_SPINE_STUB_DATABASE_URL`,
|
|
29
|
+
* then `RUST_SPINE_STUB_DEFAULT_DATABASE_URL`.
|
|
30
|
+
*/
|
|
20
31
|
readonly database_url?: string;
|
|
21
32
|
/**
|
|
22
33
|
* Prebuilt binary path. Overrides the `FUZ_TESTING_RUST_SPINE_STUB_BIN` env
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rust_spine_stub_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/rust_spine_stub_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"rust_spine_stub_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/rust_spine_stub_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA0C9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AASzD,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,oCAAoC,CAAC;AAEzE;;;;;;GAMG;AACH,eAAO,MAAM,gCAAgC,6CAA6C,CAAC;AAE3F;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,6CAA6C,CAAC;AAkBnG,kGAAkG;AAClG,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,0FAA0F;AAC1F,eAAO,MAAM,oCAAoC,sDACG,CAAC;AAErD,MAAM,WAAW,6BAA6B;IAC7C,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CAClC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,GAC1C,UAAS,6BAAkC,KACzC,aAkDF,CAAC"}
|
|
@@ -25,6 +25,9 @@ import '../assert_dev_env.js';
|
|
|
25
25
|
* createdb fuz_app_test_rust_spine_stub 2>/dev/null || true
|
|
26
26
|
* ```
|
|
27
27
|
*
|
|
28
|
+
* `FUZ_TESTING_RUST_SPINE_STUB_DATABASE_URL` points the stub at another
|
|
29
|
+
* server (e.g. a scratch cluster on a spare port).
|
|
30
|
+
*
|
|
28
31
|
* The binary self-wipes the auth-namespace schema on every boot
|
|
29
32
|
* (`FUZ_TESTING_RESET_DB_ON_STARTUP=true`, set by the Rust-family builder),
|
|
30
33
|
* so no manual `DROP TABLE` between sessions is needed; per-test reset is
|
|
@@ -39,6 +42,14 @@ import { SPINE_EXPECTED_SCHEMA_URL } from './spine_surface_constants.js';
|
|
|
39
42
|
import { LOGIN_RATE_LIMIT_ENABLED_ENV, make_default_rust_backend_config, rust_default_capabilities } from './default_backend_configs.js';
|
|
40
43
|
/** Env var naming the prebuilt `testing_spine_stub` binary. Required when `binary_path` is omitted. */
|
|
41
44
|
export const RUST_SPINE_STUB_BIN_ENV = 'FUZ_TESTING_RUST_SPINE_STUB_BIN';
|
|
45
|
+
/**
|
|
46
|
+
* Env var overriding the stub's Postgres URL when `database_url` is omitted —
|
|
47
|
+
* e.g. a scratch cluster on a spare port, keeping the run off the machine's
|
|
48
|
+
* default server. Pair it with the libpq `PGHOST` / `PGPORT` the runner's
|
|
49
|
+
* idempotent `createdb` reads, so the database it ensures is the one the URL
|
|
50
|
+
* names.
|
|
51
|
+
*/
|
|
52
|
+
export const RUST_SPINE_STUB_DATABASE_URL_ENV = 'FUZ_TESTING_RUST_SPINE_STUB_DATABASE_URL';
|
|
42
53
|
/**
|
|
43
54
|
* Env var the stub reads for the absolute path of the committed
|
|
44
55
|
* `expected_schema.json` its `/ready` gate introspects against. Pointed at the
|
|
@@ -77,7 +88,8 @@ export const RUST_SPINE_STUB_DEFAULT_DATABASE_URL = 'postgres://localhost/fuz_ap
|
|
|
77
88
|
* @throws Error when no binary path is available.
|
|
78
89
|
*/
|
|
79
90
|
export const rust_spine_stub_backend_config = (options = {}) => {
|
|
80
|
-
const { port = RUST_SPINE_STUB_DEFAULT_PORT, database_url =
|
|
91
|
+
const { port = RUST_SPINE_STUB_DEFAULT_PORT, database_url = process.env[RUST_SPINE_STUB_DATABASE_URL_ENV] ||
|
|
92
|
+
RUST_SPINE_STUB_DEFAULT_DATABASE_URL, binary_path = process.env[RUST_SPINE_STUB_BIN_ENV], enable_login_rate_limit, trusted_proxies } = options;
|
|
81
93
|
if (!binary_path) {
|
|
82
94
|
throw new Error(`rust_spine_stub_backend_config: no binary path — set ${RUST_SPINE_STUB_BIN_ENV} to a prebuilt ` +
|
|
83
95
|
'`testing_spine_stub` binary (build it with `cargo build -p testing_spine_stub --release`) ' +
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test_cell_gated_create_authorize.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/test_cell_gated_create_authorize.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA+B9B,OAAO,KAAK,EACX,mBAAmB,EAGnB,MAAM,4BAA4B,CAAC;AAGpC,8EAA8E;AAC9E,eAAO,MAAM,eAAe,UAAU,CAAC;AAEvC;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,gBAAyB,CAAC;AAEzD,yFAAyF;AACzF,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAYD;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,EAAE,
|
|
1
|
+
{"version":3,"file":"test_cell_gated_create_authorize.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/test_cell_gated_create_authorize.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA+B9B,OAAO,KAAK,EACX,mBAAmB,EAGnB,MAAM,4BAA4B,CAAC;AAGpC,8EAA8E;AAC9E,eAAO,MAAM,eAAe,UAAU,CAAC;AAEvC;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,gBAAyB,CAAC;AAEzD,yFAAyF;AACzF,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAYD;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,EAAE,mBAsB9C,CAAC"}
|
|
@@ -6,15 +6,15 @@ import '../assert_dev_env.js';
|
|
|
6
6
|
* the parent-aware cell-creation authorizer agrees TS↔Rust. The twin of the
|
|
7
7
|
* Rust `TestCellGatedCreateAuthorize`, **directory model**.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* a
|
|
9
|
+
* A **global** admin bypasses everything (`{allow: true, moderation_required:
|
|
10
|
+
* false}`) — a scoped `admin` row is no admin here. For anyone else:
|
|
11
11
|
*
|
|
12
12
|
* - **Root creation** (`root_id` null): `kind: 'space'` is admin-only (denied);
|
|
13
13
|
* every other parentless kind stays open, so the plain-create `cell_crud` /
|
|
14
14
|
* `cell_relations` suites are unaffected.
|
|
15
15
|
* - **Contribution** (`root_id` set): the governing root's
|
|
16
16
|
* `data.policy[kind] = {min_role?, moderation_required?}` decides — a missing
|
|
17
|
-
* entry denies, a present `min_role` the actor lacks denies, and otherwise it
|
|
17
|
+
* entry denies, a present `min_role` the actor lacks globally denies, and otherwise it
|
|
18
18
|
* admits with the entry's `moderation_required` folded into the verdict. The
|
|
19
19
|
* root's `data` arrives in `input.root_data` (the handler read it in-tx), so
|
|
20
20
|
* the predicate is **pure** — no DB read of its own (which also dodges the
|
|
@@ -25,7 +25,7 @@ import '../assert_dev_env.js';
|
|
|
25
25
|
*
|
|
26
26
|
* @module
|
|
27
27
|
*/
|
|
28
|
-
import {
|
|
28
|
+
import { has_scoped_role } from '../../auth/request_context.js';
|
|
29
29
|
import { ROLE_ADMIN } from '../../auth/role_schema.js';
|
|
30
30
|
import { SPINE_PARTICIPANT_ROLE } from './spine_surface_constants.js';
|
|
31
31
|
/** The admin-only root kind the test policy gates (talk's `space` analog). */
|
|
@@ -54,8 +54,9 @@ const get_contribution_rule = (data, kind) => {
|
|
|
54
54
|
* handler). Byte-equivalent with the Rust `TestCellGatedCreateAuthorize`.
|
|
55
55
|
*/
|
|
56
56
|
export const test_cell_gated_create_authorize = (auth, input) => {
|
|
57
|
-
// Admin bypass — admins create roots + any kind, live immediately.
|
|
58
|
-
|
|
57
|
+
// Admin bypass — admins create roots + any kind, live immediately. The
|
|
58
|
+
// global grant only: a scoped `admin` row bypasses nothing.
|
|
59
|
+
if (has_scoped_role(auth, ROLE_ADMIN, null))
|
|
59
60
|
return { allow: true, moderation_required: false };
|
|
60
61
|
// Root creation (no governing root): `space` is admin-only; other parentless
|
|
61
62
|
// kinds stay open (the plain-create suites).
|
|
@@ -68,7 +69,8 @@ export const test_cell_gated_create_authorize = (auth, input) => {
|
|
|
68
69
|
const rule = get_contribution_rule(input.root_data, input.kind ?? '');
|
|
69
70
|
if (!rule)
|
|
70
71
|
return { allow: false };
|
|
71
|
-
|
|
72
|
+
// `min_role` names no scope, so only the global grant satisfies it.
|
|
73
|
+
const admitted = rule.min_role === undefined || has_scoped_role(auth, rule.min_role, null);
|
|
72
74
|
return admitted
|
|
73
75
|
? { allow: true, moderation_required: rule.moderation_required ?? false }
|
|
74
76
|
: { allow: false };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import './assert_dev_env.js';
|
|
2
2
|
import type { Account, Actor, RoleGrant } from '../auth/account_schema.js';
|
|
3
3
|
import type { AuditLogEvent } from '../auth/audit_log_schema.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { RequestActorContext } from '../auth/request_context.js';
|
|
5
5
|
/** Override type for `create_test_account` — id-like fields accept plain `string`. */
|
|
6
6
|
export type TestAccountOverrides = Partial<Omit<Account, 'id' | 'created_by' | 'updated_by'>> & {
|
|
7
7
|
id?: string;
|
|
@@ -30,8 +30,11 @@ export type TestRoleGrantOverrides = Partial<Omit<RoleGrant, 'id' | 'actor_id' |
|
|
|
30
30
|
};
|
|
31
31
|
/** Create a test `RoleGrant` with sensible defaults. */
|
|
32
32
|
export declare const create_test_role_grant: (overrides?: TestRoleGrantOverrides) => RoleGrant;
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Create a test `RequestActorContext` (a `RequestContext` with a resolved
|
|
35
|
+
* actor) with role_grants from partial overrides.
|
|
36
|
+
*/
|
|
37
|
+
export declare const create_test_context: (role_grants?: Array<TestRoleGrantOverrides>) => RequestActorContext;
|
|
35
38
|
/** Override type for `create_test_audit_event` — id-like fields accept plain `string`. */
|
|
36
39
|
export type TestAuditEventOverrides = Partial<Omit<AuditLogEvent, 'id' | 'actor_id' | 'account_id' | 'target_account_id' | 'target_actor_id'>> & {
|
|
37
40
|
id?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entities.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/entities.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAiB7B,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAC3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"entities.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/entities.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAiB7B,OAAO,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAC3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,sFAAsF;AACtF,MAAM,MAAM,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,GAAG,YAAY,GAAG,YAAY,CAAC,CAAC,GAAG;IAC/F,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,GAAI,YAAY,oBAAoB,KAAG,OAarE,CAAC;AAEH,oFAAoF;AACpF,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,GAAG,YAAY,GAAG,YAAY,CAAC,CAAC,GAAG;IAC3F,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAAC;AAEF,oDAAoD;AACpD,eAAO,MAAM,iBAAiB,GAAI,YAAY,kBAAkB,KAAG,KAUjE,CAAC;AAEH,yFAAyF;AACzF,MAAM,MAAM,sBAAsB,GAAG,OAAO,CAC3C,IAAI,CACH,SAAS,EACT,IAAI,GAAG,UAAU,GAAG,YAAY,GAAG,UAAU,GAAG,YAAY,GAAG,YAAY,GAAG,iBAAiB,CAC/F,CACD,GAAG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC,CAAC;AAEF,wDAAwD;AACxD,eAAO,MAAM,sBAAsB,GAAI,YAAY,sBAAsB,KAAG,SAgB3E,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,GAC/B,cAAa,KAAK,CAAC,sBAAsB,CAAQ,KAC/C,mBAID,CAAC;AAEH,0FAA0F;AAC1F,MAAM,MAAM,uBAAuB,GAAG,OAAO,CAC5C,IAAI,CAAC,aAAa,EAAE,IAAI,GAAG,UAAU,GAAG,YAAY,GAAG,mBAAmB,GAAG,iBAAiB,CAAC,CAC/F,GAAG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC,CAAC;AAEF,4DAA4D;AAC5D,eAAO,MAAM,uBAAuB,GAAI,YAAY,uBAAuB,KAAG,aAa5E,CAAC"}
|
package/dist/testing/entities.js
CHANGED
|
@@ -44,7 +44,10 @@ export const create_test_role_grant = (overrides) => {
|
|
|
44
44
|
};
|
|
45
45
|
return overrides ? { ...base, ...overrides } : base;
|
|
46
46
|
};
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Create a test `RequestActorContext` (a `RequestContext` with a resolved
|
|
49
|
+
* actor) with role_grants from partial overrides.
|
|
50
|
+
*/
|
|
48
51
|
export const create_test_context = (role_grants = [{}]) => ({
|
|
49
52
|
account: create_test_account(),
|
|
50
53
|
actor: create_test_actor(),
|