@fuzdev/fuz_app 0.103.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/auth/CLAUDE.md +29 -11
  2. package/dist/auth/cell_action_specs.d.ts +226 -1
  3. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  4. package/dist/auth/cell_action_specs.js +79 -1
  5. package/dist/auth/cell_actions.d.ts +32 -9
  6. package/dist/auth/cell_actions.d.ts.map +1 -1
  7. package/dist/auth/cell_actions.js +126 -12
  8. package/dist/auth/cell_audit_events.d.ts +1 -1
  9. package/dist/auth/cell_audit_events.d.ts.map +1 -1
  10. package/dist/auth/cell_audit_events.js +3 -2
  11. package/dist/auth/cell_audit_metadata.d.ts +15 -0
  12. package/dist/auth/cell_audit_metadata.d.ts.map +1 -1
  13. package/dist/auth/cell_audit_metadata.js +11 -0
  14. package/dist/db/CLAUDE.md +16 -6
  15. package/dist/db/cell_ddl.d.ts +14 -1
  16. package/dist/db/cell_ddl.d.ts.map +1 -1
  17. package/dist/db/cell_ddl.js +20 -0
  18. package/dist/db/cell_queries.d.ts +45 -0
  19. package/dist/db/cell_queries.d.ts.map +1 -1
  20. package/dist/db/cell_queries.js +40 -3
  21. package/dist/testing/CLAUDE.md +25 -16
  22. package/dist/testing/cross_backend/cell_gated_create.d.ts +24 -0
  23. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  24. package/dist/testing/cross_backend/cell_gated_create.js +177 -35
  25. package/dist/testing/cross_backend/expected_schema.json +3 -0
  26. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  27. package/dist/testing/cross_backend/full_spine_mount.js +7 -4
  28. package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts +12 -7
  29. package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
  30. package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +54 -15
  31. package/package.json +1 -1
@@ -51,14 +51,17 @@ const grant_count_projection = (cell_alias) => `(SELECT COUNT(*)::int FROM cell_
51
51
  export const query_cell_create = async (deps, input) => {
52
52
  const refs = derive_refs(input.data);
53
53
  const row = await deps.db.query_one(`INSERT INTO cell
54
- (data, kind, visibility, path, refs, created_by)
55
- VALUES ($1::jsonb, $2, COALESCE($3::cell_visibility, 'private'::cell_visibility), $4, $5::text[], $6)
54
+ (data, kind, visibility, path, refs, parent_id, root_id, moderation, created_by)
55
+ VALUES ($1::jsonb, $2, COALESCE($3::cell_visibility, 'private'::cell_visibility), $4, $5::text[], $6, $7, $8, $9)
56
56
  RETURNING *, ${grant_count_projection('cell')}`, [
57
57
  JSON.stringify(input.data),
58
58
  input.kind ?? null,
59
59
  input.visibility ?? null,
60
60
  input.path ?? null,
61
61
  refs,
62
+ input.parent_id ?? null,
63
+ input.root_id ?? null,
64
+ input.moderation ?? null,
62
65
  input.created_by ?? null,
63
66
  ]);
64
67
  return assert_row(row, 'INSERT INTO cell');
@@ -171,6 +174,34 @@ export const query_cell_delete = async (deps, id, options) => {
171
174
  RETURNING id`, [id, options?.deleted_by ?? null]);
172
175
  return row !== undefined;
173
176
  };
177
+ /**
178
+ * Transition a contribution's `moderation` marker (the `cell_moderate` verb's
179
+ * write). On `set_visibility_public` (an approval) it also flips `visibility`
180
+ * to `'public'` so the approved contribution goes live; a rejection leaves
181
+ * visibility untouched (stays private). `updated_at` / `updated_by` are stamped.
182
+ *
183
+ * `moderation` is deliberately **not** writable through `query_cell_update`
184
+ * (it's absent from `CellUpdatePatch`) — gating the transition on a dedicated
185
+ * query (with the manage-tier authority check in the handler) is what stops an
186
+ * author self-approving their own pending cell.
187
+ *
188
+ * @param deps - query deps
189
+ * @param id - cell id
190
+ * @param moderation - the terminal marker to write (`'approved'` | `'rejected'`)
191
+ * @param options - `set_visibility_public` flips visibility on approval; `updated_by` records the moderator
192
+ * @returns the updated row, or `null` when no active row matched (raced delete)
193
+ * @mutates `cell` - sets `moderation` (+ maybe `visibility`) on one row
194
+ */
195
+ export const query_cell_set_moderation = async (deps, id, moderation, options) => {
196
+ const row = await deps.db.query_one(`UPDATE cell SET
197
+ moderation = $2,
198
+ visibility = CASE WHEN $3::bool THEN 'public'::cell_visibility ELSE visibility END,
199
+ updated_by = $4,
200
+ updated_at = NOW()
201
+ WHERE id = $1 AND deleted_at IS NULL
202
+ RETURNING *, ${grant_count_projection('cell')}`, [id, moderation, options.set_visibility_public, options.updated_by ?? null]);
203
+ return row ?? null;
204
+ };
174
205
  /**
175
206
  * List active cells with the given `kind`, newest first. Uses the
176
207
  * `idx_cell_kind` index (`cell.kind = ?`).
@@ -272,7 +303,7 @@ export const query_cell_list = async (deps, params) => {
272
303
  // supplied wildcards (`%`, `_`, `\`) match literally — Postgres 11+ has
273
304
  // the function and pglite/PG 16 supports it.
274
305
  //
275
- // Two SQL shapes share the same 14-param positional layout:
306
+ // Two SQL shapes share the same 16-param positional layout:
276
307
  //
277
308
  // - `shared_with_caller_only: false` — cell-driven scan with the
278
309
  // visibility predicate (admin / public / owner / grant-admits).
@@ -298,6 +329,8 @@ export const query_cell_list = async (deps, params) => {
298
329
  role_grant_roles,
299
330
  role_grant_scope_ids,
300
331
  params.visibility ?? null,
332
+ params.root_id ?? null,
333
+ params.moderation ?? null,
301
334
  ]);
302
335
  };
303
336
  /**
@@ -340,6 +373,8 @@ const build_general_sql = (order_column, order_direction) => `${CALLER_ROLE_GRAN
340
373
  AND ($4::uuid IS NULL OR c.created_by = $4)
341
374
  AND ($5::text IS NULL OR starts_with(c.path, $5))
342
375
  AND ($10::uuid[] IS NULL OR c.id = ANY($10))
376
+ AND ($15::uuid IS NULL OR c.root_id = $15)
377
+ AND ($16::text IS NULL OR c.moderation = $16::text)
343
378
  AND (
344
379
  $6::bool
345
380
  OR c.visibility = 'public'
@@ -376,6 +411,8 @@ const build_shared_with_sql = (order_column, order_direction) => `${CALLER_ROLE_
376
411
  AND ($4::uuid IS NULL OR c.created_by = $4)
377
412
  AND ($5::text IS NULL OR starts_with(c.path, $5))
378
413
  AND ($10::uuid[] IS NULL OR c.id = ANY($10))
414
+ AND ($15::uuid IS NULL OR c.root_id = $15)
415
+ AND ($16::text IS NULL OR c.moderation = $16::text)
379
416
  AND $7::uuid IS NOT NULL
380
417
  AND c.created_by IS DISTINCT FROM $7
381
418
  AND c.id IN (
@@ -915,13 +915,15 @@ source of truth for wire-shape conformance.
915
915
  parity suite — both `true` on every backend that live-mounts the full cell
916
916
  surface (TS spine binary, in-process app, Rust stub). A backend mounting only
917
917
  plain CRUD would declare `cell_crud: true, cell_relations: false`.
918
- `cell_gated_create` gates `describe_cell_gated_create_cross_tests` (the
919
- cell-creation-authorizer parity proof); unlike `cell_crud`/`cell_relations`
918
+ `cell_gated_create` gates `describe_cell_gated_create_cross_tests` +
919
+ `describe_cell_moderate_cross_tests` (the parent-aware-creation-authorizer +
920
+ `cell_moderate` parity proofs); unlike `cell_crud`/`cell_relations`
920
921
  it is **cross-only** (`false` in-process) — `true` only on the reference
921
922
  spine binaries that live-mount the `test_cell_gated_create_authorize` policy
922
923
  (the TS spine `full_spine_mount` + the Rust `testing_spine_stub`), since the
923
- authorizer adds no method/column/wire shape and so the schema + manifest
924
- parity gates are blind to an authorizer divergence.
924
+ authorizer _behavior_ adds no method/wire shape and so the schema + manifest
925
+ parity gates are blind to an authorizer divergence (the `cell_moderate`
926
+ _verb_ it pairs with is on the manifest, separately gated).
925
927
  `account_lifecycle` gates `describe_account_lifecycle_cross_tests` (the
926
928
  `account_delete` / `account_undelete` / `account_purge` parity suite); unlike
927
929
  cells those verbs **are** on the declared surface (in `create_admin_actions`,
@@ -1197,22 +1199,29 @@ backdoor, cell).
1197
1199
  holder who can `cell_get` still gets the IDOR 404). Only **actor-shaped**
1198
1200
  grants are exercised — role-shaped principals need a closed role registry the
1199
1201
  Rust spine deliberately lacks.
1200
- - **`describe_cell_gated_create_cross_tests`** (gates on
1201
- `capabilities.cell_gated_create`, **cross-only**) — the cell-creation
1202
- authorizer (`CellCreateAuthorize`) parity proof. Both spines mount the same
1203
- `test_cell_gated_create_authorize` policy (gate `kind: 'gated'` behind the
1204
- `participant` role or admin): a non-participant is denied with the
1205
- `cell_not_found` 404 IDOR mask, an ungated kind is open, a `participant` is
1206
- admitted, and admin bypasses. The authorizer adds no method/column/wire
1207
- shape, so schema + manifest parity are **blind** to a divergence — this is the
1208
- only gate that catches one. In-process hook coverage is the standalone
1209
- `auth/cell_create_authorize.db.test.ts` (this cross suite skips in-process,
1210
- where the default app mounts no authorizer).
1202
+ - **`describe_cell_gated_create_cross_tests`** + **`describe_cell_moderate_cross_tests`**
1203
+ (gate on `capabilities.cell_gated_create`, **cross-only**) — the parent-aware
1204
+ cell-creation authorizer (`CellCreateAuthorize`, **directory model**) +
1205
+ the `cell_moderate` verb parity proofs. Both spines mount the same
1206
+ `test_cell_gated_create_authorize` policy: a `kind: 'space'` **root** is
1207
+ admin-only (a non-admin → **403** `cell_create_forbidden`); a **contribution**
1208
+ under a space is gated by the root's `data.policy[kind] = {min_role,
1209
+ moderation_required}`, with `moderation` set per the verdict (`pending`+private
1210
+ vs `approved`+author-visibility); a **hidden** parent **404**-masks vs a
1211
+ **visible** one **403**s; plain parentless creates stay open. The moderate
1212
+ suite then proves the `pending → approved | rejected` transition: an admin
1213
+ (root manager) approves → public, the **author** self-approve → 403
1214
+ (anti-self-approval), a non-viewer → 404, reject → stays private. The
1215
+ authorizer _behavior_ adds no method/wire shape, so schema + manifest parity
1216
+ are **blind** to a divergence — these are the only gates that catch one (the
1217
+ `cell_moderate` verb itself is manifest-covered). In-process hook coverage is
1218
+ the standalone `auth/cell_create_authorize.db.test.ts` (these cross suites skip
1219
+ in-process, where the default app mounts no authorizer).
1211
1220
 
1212
1221
  `cell_crud` / `cell_relations` gate `true` on TS + Rust (cells run on both, no
1213
1222
  `.skip`); `cell_gated_create` is cross-only (the policy is a spine-binary
1214
1223
  fixture). Cross-process wiring is `src/test/cross_backend/cell.cross.test.ts`
1215
- (all three suites); the in-process legs (plain `gro test`) for crud/relations
1224
+ (all the cell suites); the in-process legs (plain `gro test`) for crud/relations
1216
1225
  are `src/test/auth/cell_crud_parity.db.test.ts`
1217
1226
 
1218
1227
  - `cell_relations_parity.db.test.ts`, sharing the full-surface
@@ -1,4 +1,28 @@
1
1
  import '../assert_dev_env.js';
2
2
  import type { RpcPathCrossSuiteOptions } from './setup.js';
3
3
  export declare const describe_cell_gated_create_cross_tests: (options: RpcPathCrossSuiteOptions) => void;
4
+ /**
5
+ * Cross-backend parity for the **`cell_moderate` verb** (the `pending →
6
+ * approved | rejected` transition) — root-authority-gated.
7
+ *
8
+ * Builds a moderated public space (admin) + a `participant`'s `post` (born
9
+ * `pending` + private under the `moderation_required: true` policy), then
10
+ * proves both spines agree:
11
+ *
12
+ * - **a root manager (admin) approves** → `moderation: 'approved'` +
13
+ * `visibility: 'public'` (the contribution goes live).
14
+ * - **the author cannot self-approve** → `403 cell_moderate_forbidden` (the
15
+ * author can *view* their own pending cell, so they reach the gate, and are
16
+ * denied — moderation authority is over the governing root, not the
17
+ * contribution).
18
+ * - **a non-viewer is 404-masked** → a stranger who can't see the pending
19
+ * contribution gets `cell_not_found`, never learns it exists.
20
+ * - **reject** → `moderation: 'rejected'`, visibility stays private.
21
+ *
22
+ * Gated on `capabilities.cell_gated_create` — a `pending` contribution only
23
+ * exists when the directory authorizer is mounted (reference spine binaries).
24
+ *
25
+ * `$lib`-free by contract (relative specifiers only).
26
+ */
27
+ export declare const describe_cell_moderate_cross_tests: (options: RpcPathCrossSuiteOptions) => void;
4
28
  //# sourceMappingURL=cell_gated_create.d.ts.map
@@ -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;AAuC9B,OAAO,KAAK,EAAC,wBAAwB,EAAC,MAAM,YAAY,CAAC;AAIzD,eAAO,MAAM,sCAAsC,GAAI,SAAS,wBAAwB,KAAG,IAqF1F,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;AAiD9B,OAAO,KAAK,EAAC,wBAAwB,EAAC,MAAM,YAAY,CAAC;AAkBzD,eAAO,MAAM,sCAAsC,GAAI,SAAS,wBAAwB,KAAG,IAwP1F,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,kCAAkC,GAAI,SAAS,wBAAwB,KAAG,IAwHtF,CAAC"}
@@ -1,25 +1,35 @@
1
1
  import '../assert_dev_env.js';
2
2
  /**
3
- * Cross-backend parity suite for the **cell-creation authorizer**
4
- * (`CellCreateAuthorize`).
3
+ * Cross-backend parity suite for the **parent-aware cell-creation authorizer**
4
+ * (`CellCreateAuthorize`) — the directory model.
5
5
  *
6
6
  * The authorizer adds no method, column, or wire shape, so the schema-snapshot
7
7
  * and action-manifest parity gates are **blind** to a TS↔Rust authorizer
8
- * divergence (the authorizer in the wrong phase, a different deny shape,
9
- * create-vs-update). This behavioral cross case is the only gate that catches
10
- * one — proven *here in fuz_app*, against both reference spines.
8
+ * divergence (the authorizer in the wrong phase, a different deny shape, the
9
+ * 404/403 split, the moderation outcome). This behavioral cross case is the
10
+ * only gate that catches one — proven *here in fuz_app*, against both reference
11
+ * spines.
11
12
  *
12
- * Both spines mount the same `test_cell_gated_create_authorize` policy (the
13
- * TS spine binary via `full_spine_mount`, the Rust `testing_spine_stub` via
14
- * `TestCellGatedCreateAuthorize`): creating a `kind: 'gated'` cell requires the
15
- * `participant` app-role or admin; every other kind (and a typeless cell) is
16
- * open. The suite asserts all three spines agree:
13
+ * Both spines mount the same directory-model policy (the TS spine binary via
14
+ * `create_test_cell_gated_create_authorize`, the Rust `testing_spine_stub` via
15
+ * `TestCellGatedCreateAuthorize`): admin bypasses; a non-admin creating a
16
+ * `kind: 'space'` **root** is denied (admin-only); a **contribution** under a
17
+ * space is gated by the space's `data.policy[kind] = {min_role, moderation_required}`,
18
+ * with the moderation outcome folded into the verdict; plain parentless creates
19
+ * stay open. The suite asserts all spines agree on:
17
20
  *
18
- * - **non-participant → gated kind is denied** with the `cell_not_found` 404
19
- * IDOR mask (a gated kind is wire-indistinguishable from a missing resource).
20
- * - **non-participant → ungated kind succeeds** (the policy gates only `gated`).
21
- * - **participant → gated kind succeeds** (the role unlocks it).
22
- * - **admin → gated kind succeeds** (the admin bypass).
21
+ * - **root-create admin-only** — a non-admin `space` root → **403**
22
+ * `cell_create_forbidden`; admin → succeeds.
23
+ * - **contribution gated by the root's policy** — under a public space, an
24
+ * unauthorized stranger → **403**; a `participant` → succeeds.
25
+ * - **moderation per `moderation_required`** — a `participant`'s `post`
26
+ * (`moderation_required: true`) is born `moderation: 'pending'` + private; a
27
+ * `react` (`moderation_required: false`) is born `moderation: 'approved'` at
28
+ * the author's visibility.
29
+ * - **404 on a hidden parent vs 403 on a visible one** — a contribution under a
30
+ * **private** space the caller can't view → **404** `cell_not_found` (the
31
+ * parent is masked); under a **public** space → **403** (you see it, you
32
+ * can't contribute).
23
33
  *
24
34
  * Gated on `capabilities.cell_gated_create` — `true` only on the reference
25
35
  * spine binaries that mount the policy, so it skips for generic consumers and
@@ -31,41 +41,173 @@ import '../assert_dev_env.js';
31
41
  * @module
32
42
  */
33
43
  import { describe, assert } from 'vitest';
34
- import { CellCreateOutput } from '../../auth/cell_action_specs.js';
44
+ import { CellCreateOutput, CellModerateOutput } from '../../auth/cell_action_specs.js';
35
45
  import { test_if } from './capabilities.js';
36
46
  import { cross_rpc_call, error_reason, expect_output } from './cell_cross_helpers.js';
37
47
  import { SPINE_RPC_PATH } from './spine_surface_constants.js';
38
- import { GATED_CELL_KIND, GATED_CELL_ROLE } from './test_cell_gated_create_authorize.js';
48
+ import { SPACE_CELL_KIND, PARTICIPATION_ROLE } from './test_cell_gated_create_authorize.js';
49
+ /**
50
+ * A space `data.policy` mirroring the test ladder: gated `post` (moderated) +
51
+ * `react` (live) + `comment` (live). `comment` exists so the deep-case test can
52
+ * create a comment under a post (`root_id !== parent_id`) and prove the policy
53
+ * resolves from the governing root (the space), not the immediate parent.
54
+ */
55
+ const SPACE_POLICY = {
56
+ policy: {
57
+ post: { min_role: PARTICIPATION_ROLE, moderation_required: true },
58
+ react: { min_role: PARTICIPATION_ROLE, moderation_required: false },
59
+ comment: { min_role: PARTICIPATION_ROLE, moderation_required: false },
60
+ },
61
+ };
39
62
  export const describe_cell_gated_create_cross_tests = (options) => {
40
63
  const { setup_test, capabilities } = options;
41
64
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
42
- describe('cell_gated_create authorizer parity', () => {
43
- test_if(capabilities.cell_gated_create, 'a non-participant is denied the gated kind (cell_not_found 404 IDOR mask)', async () => {
65
+ describe('cell_gated_create authorizer parity (directory model)', () => {
66
+ test_if(capabilities.cell_gated_create, 'a non-admin is denied a `space` root (admin-only), admin succeeds', async () => {
44
67
  const fixture = await setup_test();
45
- const stranger = await fixture.create_account({ username: 'gated_stranger' });
46
- const denied = await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: GATED_CELL_KIND, data: {} }, stranger.create_session_headers());
47
- assert.ok(!denied.ok, 'a non-participant must not create the gated kind');
48
- assert.strictEqual(error_reason(denied), 'cell_not_found');
68
+ const stranger = await fixture.create_account({ username: 'space_stranger' });
69
+ const denied = await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: SPACE_CELL_KIND, data: {} }, stranger.create_session_headers());
70
+ assert.ok(!denied.ok, 'a non-admin must not create a space root');
71
+ assert.strictEqual(error_reason(denied), 'cell_create_forbidden');
72
+ 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);
73
+ assert.strictEqual(space.cell.kind, SPACE_CELL_KIND, 'admin creates the space root');
74
+ assert.strictEqual(space.cell.parent_id, null, 'a root is parentless');
75
+ assert.strictEqual(space.cell.root_id, null, 'a root has no governing root');
76
+ assert.strictEqual(space.cell.moderation, null, 'a root carries no moderation marker even under a mounted authorizer (moderation is a contribution concept)');
49
77
  });
50
- test_if(capabilities.cell_gated_create, 'a non-participant may create an ungated kind (the policy gates only `gated`)', async () => {
78
+ test_if(capabilities.cell_gated_create, 'a contribution under a public space: unauthorized → 403, participant → allowed', async () => {
51
79
  const fixture = await setup_test();
52
- const stranger = await fixture.create_account({ username: 'ungated_stranger' });
53
- const created = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'note', data: {} }, stranger.create_session_headers()), CellCreateOutput);
54
- assert.strictEqual(created.cell.kind, 'note', 'the ungated cell keeps its kind');
80
+ 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);
81
+ // A non-participant sees the public space but can't contribute → 403.
82
+ const stranger = await fixture.create_account({ username: 'post_stranger' });
83
+ const forbidden = await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: {}, parent_id: space.cell.id }, stranger.create_session_headers());
84
+ assert.ok(!forbidden.ok, 'a non-participant must not post in the space');
85
+ assert.strictEqual(error_reason(forbidden), 'cell_create_forbidden');
86
+ // A participant may post; the contribution carries the directory edges.
87
+ const participant = await fixture.create_account({
88
+ username: 'post_participant',
89
+ roles: [PARTICIPATION_ROLE],
90
+ });
91
+ const post = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: {}, parent_id: space.cell.id }, participant.create_session_headers()), CellCreateOutput);
92
+ assert.strictEqual(post.cell.parent_id, space.cell.id, 'parent edge set');
93
+ assert.strictEqual(post.cell.root_id, space.cell.id, 'root resolved to the space');
55
94
  });
56
- test_if(capabilities.cell_gated_create, 'a participant may create the gated kind', async () => {
95
+ test_if(capabilities.cell_gated_create, 'moderation is set per `moderation_required` (post → pending+private, react → approved)', async () => {
57
96
  const fixture = await setup_test();
97
+ 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);
58
98
  const participant = await fixture.create_account({
59
- username: 'gated_participant',
60
- roles: [GATED_CELL_ROLE],
99
+ username: 'mod_participant',
100
+ roles: [PARTICIPATION_ROLE],
61
101
  });
62
- const created = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: GATED_CELL_KIND, data: {} }, participant.create_session_headers()), CellCreateOutput);
63
- assert.strictEqual(created.cell.kind, GATED_CELL_KIND, 'the participant created the gated kind');
102
+ // `post` requires moderation → born pending + forced private.
103
+ const post = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: {}, parent_id: space.cell.id, visibility: 'public' }, participant.create_session_headers()), CellCreateOutput);
104
+ assert.strictEqual(post.cell.moderation, 'pending', 'gated post is born pending');
105
+ assert.strictEqual(post.cell.visibility, 'private', 'a pending contribution is forced private until approved');
106
+ // `react` does not require moderation → born approved at the author's visibility.
107
+ const react = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'react', data: {}, parent_id: space.cell.id, visibility: 'public' }, participant.create_session_headers()), CellCreateOutput);
108
+ assert.strictEqual(react.cell.moderation, 'approved', 'unmoderated kind is born approved');
109
+ assert.strictEqual(react.cell.visibility, 'public', "the author's visibility is honored");
110
+ });
111
+ test_if(capabilities.cell_gated_create, 'a contribution under a hidden (private) space is 404-masked (not 403)', async () => {
112
+ const fixture = await setup_test();
113
+ // A private space — only admin/owner can view it.
114
+ const space = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: SPACE_CELL_KIND, data: SPACE_POLICY, visibility: 'private' }, fixture.create_session_headers()), CellCreateOutput);
115
+ const stranger = await fixture.create_account({ username: 'hidden_stranger' });
116
+ const masked = await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: {}, parent_id: space.cell.id }, stranger.create_session_headers());
117
+ assert.ok(!masked.ok, 'a stranger must not contribute under a hidden space');
118
+ assert.strictEqual(error_reason(masked), 'cell_not_found', 'a hidden parent 404-masks the attempt (never reveals it exists)');
64
119
  });
65
- test_if(capabilities.cell_gated_create, 'an admin (keeper) may create the gated kind (admin bypass)', async () => {
120
+ test_if(capabilities.cell_gated_create, 'a deep contribution (comment under a post) resolves policy from the governing root, not the parent', async () => {
66
121
  const fixture = await setup_test();
67
- const created = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: GATED_CELL_KIND, data: {} }, fixture.create_session_headers()), CellCreateOutput);
68
- assert.strictEqual(created.cell.kind, GATED_CELL_KIND, 'admin bypasses the gate');
122
+ 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);
123
+ const participant = await fixture.create_account({
124
+ username: 'deep_participant',
125
+ roles: [PARTICIPATION_ROLE],
126
+ });
127
+ // A post lives directly under the space — the shallow case
128
+ // (`root_id === parent_id`), so the handler reuses the parent row as
129
+ // `root_data`. The post is gated `moderation_required: true`, so it's
130
+ // born pending + private; the author can still view their own pending
131
+ // cell, so it's a valid parent for the comment below.
132
+ const post = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: {}, parent_id: space.cell.id }, participant.create_session_headers()), CellCreateOutput);
133
+ assert.strictEqual(post.cell.root_id, space.cell.id, 'a post roots at the space');
134
+ // A comment lives under the post — the DEEP case. Here
135
+ // `root_id !== parent_id`, so the handler must read the governing
136
+ // root (the space) *through* the post (the separate in-tx root read),
137
+ // and the authorizer must resolve `policy.comment` from the SPACE's
138
+ // data, not the post's. This is the only case that exercises the
139
+ // deep-read branch on both spines.
140
+ const comment = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'comment', data: {}, parent_id: post.cell.id }, participant.create_session_headers()), CellCreateOutput);
141
+ assert.strictEqual(comment.cell.parent_id, post.cell.id, 'the immediate parent is the post');
142
+ assert.strictEqual(comment.cell.root_id, space.cell.id, 'the governing root resolves to the space (deep read through the post), not the post');
143
+ assert.strictEqual(comment.cell.moderation, 'approved', 'the comment policy (moderation_required: false) resolved from the space, not the post');
144
+ });
145
+ });
146
+ };
147
+ /**
148
+ * Cross-backend parity for the **`cell_moderate` verb** (the `pending →
149
+ * approved | rejected` transition) — root-authority-gated.
150
+ *
151
+ * Builds a moderated public space (admin) + a `participant`'s `post` (born
152
+ * `pending` + private under the `moderation_required: true` policy), then
153
+ * proves both spines agree:
154
+ *
155
+ * - **a root manager (admin) approves** → `moderation: 'approved'` +
156
+ * `visibility: 'public'` (the contribution goes live).
157
+ * - **the author cannot self-approve** → `403 cell_moderate_forbidden` (the
158
+ * author can *view* their own pending cell, so they reach the gate, and are
159
+ * denied — moderation authority is over the governing root, not the
160
+ * contribution).
161
+ * - **a non-viewer is 404-masked** → a stranger who can't see the pending
162
+ * contribution gets `cell_not_found`, never learns it exists.
163
+ * - **reject** → `moderation: 'rejected'`, visibility stays private.
164
+ *
165
+ * Gated on `capabilities.cell_gated_create` — a `pending` contribution only
166
+ * exists when the directory authorizer is mounted (reference spine binaries).
167
+ *
168
+ * `$lib`-free by contract (relative specifiers only).
169
+ */
170
+ export const describe_cell_moderate_cross_tests = (options) => {
171
+ const { setup_test, capabilities } = options;
172
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
173
+ // Build an admin-owned public space (moderated `post` policy) + a
174
+ // participant's pending post under it.
175
+ const setup_pending_post = async () => {
176
+ const fixture = await setup_test();
177
+ 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);
178
+ const author = await fixture.create_account({
179
+ username: 'moderate_author',
180
+ roles: [PARTICIPATION_ROLE],
181
+ });
182
+ const post = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: {}, parent_id: space.cell.id }, author.create_session_headers()), CellCreateOutput);
183
+ return { fixture, author, post };
184
+ };
185
+ describe('cell_moderate verb parity (root-authority-gated)', () => {
186
+ test_if(capabilities.cell_gated_create, 'a root manager (admin) approves a pending post → approved + public', async () => {
187
+ const { fixture, post } = await setup_pending_post();
188
+ assert.strictEqual(post.cell.moderation, 'pending', 'the post is born pending');
189
+ const moderated = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_moderate', { cell_id: post.cell.id, moderation: 'approved' }, fixture.create_session_headers()), CellModerateOutput);
190
+ assert.strictEqual(moderated.cell.moderation, 'approved', 'approval sets the marker');
191
+ assert.strictEqual(moderated.cell.visibility, 'public', 'approval publishes the post');
192
+ });
193
+ test_if(capabilities.cell_gated_create, 'the author (non-manager) cannot moderate their own pending post → 403', async () => {
194
+ const { fixture, author, post } = await setup_pending_post();
195
+ const denied = await cross_rpc_call(fixture.transport, rpc_path, 'cell_moderate', { cell_id: post.cell.id, moderation: 'approved' }, author.create_session_headers());
196
+ assert.ok(!denied.ok, 'the author must not self-approve');
197
+ assert.strictEqual(error_reason(denied), 'cell_moderate_forbidden');
198
+ });
199
+ test_if(capabilities.cell_gated_create, 'a non-viewer is 404-masked from moderating a pending post', async () => {
200
+ const { fixture, post } = await setup_pending_post();
201
+ const stranger = await fixture.create_account({ username: 'moderate_stranger' });
202
+ const masked = await cross_rpc_call(fixture.transport, rpc_path, 'cell_moderate', { cell_id: post.cell.id, moderation: 'approved' }, stranger.create_session_headers());
203
+ assert.ok(!masked.ok, 'a non-viewer must not moderate');
204
+ assert.strictEqual(error_reason(masked), 'cell_not_found', 'a non-viewer never learns the pending post exists');
205
+ });
206
+ test_if(capabilities.cell_gated_create, 'a root manager rejects a pending post → rejected, stays private', async () => {
207
+ const { fixture, post } = await setup_pending_post();
208
+ const moderated = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_moderate', { cell_id: post.cell.id, moderation: 'rejected' }, fixture.create_session_headers()), CellModerateOutput);
209
+ assert.strictEqual(moderated.cell.moderation, 'rejected', 'rejection sets the marker');
210
+ assert.strictEqual(moderated.cell.visibility, 'private', 'rejection leaves the post private');
69
211
  });
70
212
  });
71
213
  };
@@ -55,8 +55,11 @@
55
55
  "deleted_at",
56
56
  "id",
57
57
  "kind",
58
+ "moderation",
59
+ "parent_id",
58
60
  "path",
59
61
  "refs",
62
+ "root_id",
60
63
  "updated_at",
61
64
  "updated_by",
62
65
  "visibility"
@@ -1 +1 @@
1
- {"version":3,"file":"full_spine_mount.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/full_spine_mount.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,6BAA6B,CAAC;AAE3D,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AAKrF,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AASzE,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACrC;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,EAAE,gBAAgB,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B,GACxC,MAAM,OAAO,EACb,SAAS,qBAAqB,KAC5B,KAAK,CAAC,SAAS,CAiCjB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,GACpC,KAAK,gBAAgB,EACrB,SAAS,qBAAqB,KAC5B,KAAK,CAAC,eAAe,CAEvB,CAAC"}
1
+ {"version":3,"file":"full_spine_mount.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/full_spine_mount.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,6BAA6B,CAAC;AAE3D,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAChD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AAKrF,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AASzE,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACrC;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,EAAE,gBAAgB,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B,GACxC,MAAM,OAAO,EACb,SAAS,qBAAqB,KAC5B,KAAK,CAAC,SAAS,CAoCjB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,GACpC,KAAK,gBAAgB,EACrB,SAAS,qBAAqB,KAC5B,KAAK,CAAC,eAAe,CAEvB,CAAC"}
@@ -29,10 +29,13 @@ export const build_full_spine_rpc_actions = (deps, options) => {
29
29
  session_options: spine_session_options,
30
30
  daemon_token_state: options.daemon_token_state,
31
31
  }),
32
- // Mount the `cell_gated_create` test policy (twin of the Rust stub's
33
- // `TestCellGatedCreateAuthorize`) so the cross-backend authorizer-parity
34
- // suite has a known gate. It only gates `kind: 'gated'`; every other kind
35
- // stays open, so the other cell suites are unaffected.
32
+ // Mount the directory-model `cell_gated_create` test policy (twin of the
33
+ // Rust stub's `TestCellGatedCreateAuthorize`) so the cross-backend
34
+ // authorizer-parity suite has a known gate: `kind: 'space'` roots are
35
+ // admin-only, contributions are gated by the root's `data.policy`, and
36
+ // plain parentless creates stay open (the other cell suites are
37
+ // unaffected). The handler hands the governing root's `data` to the
38
+ // (pure) authorizer.
36
39
  ...create_all_cell_actions({ ...deps, authorize_create: test_cell_gated_create_authorize }, { roles: spine_roles }),
37
40
  ...create_actor_lookup_actions(deps),
38
41
  ...create_actor_search_actions(deps),
@@ -1,17 +1,22 @@
1
1
  import '../assert_dev_env.js';
2
2
  import type { CellCreateAuthorize } from '../../auth/cell_actions.js';
3
- /** The single kind the test policy gates (shared with the driving suite). */
4
- export declare const GATED_CELL_KIND = "gated";
3
+ /** The admin-only root kind the test policy gates (talk's `space` analog). */
4
+ export declare const SPACE_CELL_KIND = "space";
5
5
  /**
6
- * The app-role that unlocks creating the gated kind (besides admin) — the
6
+ * The app-role a space policy references as `min_role` (besides admin) — the
7
7
  * `participant` role both reference spines register. Mirrors the Rust policy's
8
8
  * `"participant"` literal.
9
9
  */
10
- export declare const GATED_CELL_ROLE = "participant";
10
+ export declare const PARTICIPATION_ROLE = "participant";
11
+ /** A space's per-kind contribution rule (the shape stamped into `space.data.policy`). */
12
+ export interface ContributionRule {
13
+ min_role?: string;
14
+ moderation_required?: boolean;
15
+ }
11
16
  /**
12
- * Allow unless the cell's `kind` is `'gated'`, in which case the actor must
13
- * hold the `participant` role or be a (scope-agnostic) admin. Mirrors the Rust
14
- * `TestCellGatedCreateAuthorize` exactly.
17
+ * The directory-model test authorizer — a **pure** function of the input (it
18
+ * reads the governing root's policy off `input.root_data`, supplied by the
19
+ * handler). Byte-equivalent with the Rust `TestCellGatedCreateAuthorize`.
15
20
  */
16
21
  export declare const test_cell_gated_create_authorize: CellCreateAuthorize;
17
22
  //# sourceMappingURL=test_cell_gated_create_authorize.d.ts.map
@@ -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;AAuB9B,OAAO,KAAK,EAAC,mBAAmB,EAA2B,MAAM,4BAA4B,CAAC;AAG9F,6EAA6E;AAC7E,eAAO,MAAM,eAAe,UAAU,CAAC;AAEvC;;;;GAIG;AACH,eAAO,MAAM,eAAe,gBAAyB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,EAAE,mBAIiD,CAAC"}
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,mBAoB9C,CAAC"}
@@ -3,14 +3,22 @@ import '../assert_dev_env.js';
3
3
  * Test `CellCreateAuthorize` policy mounted on **both** reference spines — the
4
4
  * TS spine binary's full mount (`full_spine_mount.ts`) and the Rust
5
5
  * `testing_spine_stub` — so the cross-backend `cell_gated_create` suite proves
6
- * the cell-creation authorizer agrees TS↔Rust. The twin of the Rust
7
- * `TestCellGatedCreateAuthorize`.
6
+ * the parent-aware cell-creation authorizer agrees TS↔Rust. The twin of the
7
+ * Rust `TestCellGatedCreateAuthorize`, **directory model**.
8
8
  *
9
- * The policy gates exactly one kind: creating a `kind: 'gated'` cell requires
10
- * the `participant` app-role (registered on both spines, `grant_paths:
11
- * ['admin']`) or admin. Every other kind — and a typeless cell — is open, so
12
- * the existing cell suites (which use `note` / `collection` kinds) are
13
- * unaffected, exactly as on the Rust stub.
9
+ * Admin bypasses everything (`{allow: true, moderation_required: false}`). For
10
+ * a non-admin:
11
+ *
12
+ * - **Root creation** (`root_id` null): `kind: 'space'` is admin-only (denied);
13
+ * every other parentless kind stays open, so the plain-create `cell_crud` /
14
+ * `cell_relations` suites are unaffected.
15
+ * - **Contribution** (`root_id` set): the governing root's
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
18
+ * admits with the entry's `moderation_required` folded into the verdict. The
19
+ * root's `data` arrives in `input.root_data` (the handler read it in-tx), so
20
+ * the predicate is **pure** — no DB read of its own (which also dodges the
21
+ * single-connection PGlite deadlock a separate handle would hit).
14
22
  *
15
23
  * **`$lib`-free by contract** — reached by the spawned TS spine binary under
16
24
  * Gro's loader (no `$lib` alias). Keep every import relative.
@@ -20,17 +28,48 @@ import '../assert_dev_env.js';
20
28
  import { has_role } from '../../auth/request_context.js';
21
29
  import { ROLE_ADMIN } from '../../auth/role_schema.js';
22
30
  import { SPINE_PARTICIPANT_ROLE } from './spine_surface_constants.js';
23
- /** The single kind the test policy gates (shared with the driving suite). */
24
- export const GATED_CELL_KIND = 'gated';
31
+ /** The admin-only root kind the test policy gates (talk's `space` analog). */
32
+ export const SPACE_CELL_KIND = 'space';
25
33
  /**
26
- * The app-role that unlocks creating the gated kind (besides admin) — the
34
+ * The app-role a space policy references as `min_role` (besides admin) — the
27
35
  * `participant` role both reference spines register. Mirrors the Rust policy's
28
36
  * `"participant"` literal.
29
37
  */
30
- export const GATED_CELL_ROLE = SPINE_PARTICIPANT_ROLE;
38
+ export const PARTICIPATION_ROLE = SPINE_PARTICIPANT_ROLE;
39
+ /** Pull the governing root's `data.policy[kind]` rule, defensively (loose `data`). */
40
+ const get_contribution_rule = (data, kind) => {
41
+ if (data === null || typeof data !== 'object')
42
+ return undefined;
43
+ const policy = data.policy;
44
+ if (policy === null || typeof policy !== 'object')
45
+ return undefined;
46
+ const rule = policy[kind];
47
+ if (rule === null || typeof rule !== 'object')
48
+ return undefined;
49
+ return rule;
50
+ };
31
51
  /**
32
- * Allow unless the cell's `kind` is `'gated'`, in which case the actor must
33
- * hold the `participant` role or be a (scope-agnostic) admin. Mirrors the Rust
34
- * `TestCellGatedCreateAuthorize` exactly.
52
+ * The directory-model test authorizer — a **pure** function of the input (it
53
+ * reads the governing root's policy off `input.root_data`, supplied by the
54
+ * handler). Byte-equivalent with the Rust `TestCellGatedCreateAuthorize`.
35
55
  */
36
- export const test_cell_gated_create_authorize = (auth, input) => input.kind !== GATED_CELL_KIND || has_role(auth, GATED_CELL_ROLE) || has_role(auth, ROLE_ADMIN);
56
+ export const test_cell_gated_create_authorize = (auth, input) => {
57
+ // Admin bypass — admins create roots + any kind, live immediately.
58
+ if (has_role(auth, ROLE_ADMIN))
59
+ return { allow: true, moderation_required: false };
60
+ // Root creation (no governing root): `space` is admin-only; other parentless
61
+ // kinds stay open (the plain-create suites).
62
+ if (input.root_id === null) {
63
+ return input.kind === SPACE_CELL_KIND
64
+ ? { allow: false }
65
+ : { allow: true, moderation_required: false };
66
+ }
67
+ // Contribution: resolve the governing root's per-kind policy.
68
+ const rule = get_contribution_rule(input.root_data, input.kind ?? '');
69
+ if (!rule)
70
+ return { allow: false };
71
+ const admitted = rule.min_role === undefined || has_role(auth, rule.min_role);
72
+ return admitted
73
+ ? { allow: true, moderation_required: rule.moderation_required ?? false }
74
+ : { allow: false };
75
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.103.0",
3
+ "version": "0.104.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",