@fuzdev/fuz_app 0.141.0 → 0.143.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 (159) hide show
  1. package/dist/actions/CLAUDE.md +10 -5
  2. package/dist/actions/connection_closer.d.ts +19 -2
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/connection_closer.js +25 -2
  5. package/dist/actions/perform_action.d.ts +10 -0
  6. package/dist/actions/perform_action.d.ts.map +1 -1
  7. package/dist/actions/perform_action.js +23 -9
  8. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  9. package/dist/actions/register_ws_endpoint.js +5 -2
  10. package/dist/auth/CLAUDE.md +33 -13
  11. package/dist/auth/account_actions.d.ts.map +1 -1
  12. package/dist/auth/account_actions.js +12 -1
  13. package/dist/auth/account_route_schema.d.ts.map +1 -1
  14. package/dist/auth/account_route_schema.js +8 -0
  15. package/dist/auth/account_routes.d.ts +7 -2
  16. package/dist/auth/account_routes.d.ts.map +1 -1
  17. package/dist/auth/account_routes.js +240 -187
  18. package/dist/auth/admin_action_specs.d.ts +6 -1
  19. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  20. package/dist/auth/admin_action_specs.js +6 -1
  21. package/dist/auth/admin_actions.d.ts.map +1 -1
  22. package/dist/auth/admin_actions.js +14 -2
  23. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  24. package/dist/auth/bootstrap_routes.js +85 -76
  25. package/dist/auth/cell_action_specs.d.ts +7 -3
  26. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  27. package/dist/auth/cell_action_specs.js +9 -5
  28. package/dist/auth/cell_actions.d.ts +3 -3
  29. package/dist/auth/cell_actions.d.ts.map +1 -1
  30. package/dist/auth/cell_actions.js +67 -38
  31. package/dist/auth/cell_authorize.d.ts +69 -8
  32. package/dist/auth/cell_authorize.d.ts.map +1 -1
  33. package/dist/auth/cell_authorize.js +89 -9
  34. package/dist/auth/cell_field_action_specs.d.ts +5 -4
  35. package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
  36. package/dist/auth/cell_field_action_specs.js +7 -7
  37. package/dist/auth/cell_field_actions.d.ts +7 -5
  38. package/dist/auth/cell_field_actions.d.ts.map +1 -1
  39. package/dist/auth/cell_field_actions.js +24 -22
  40. package/dist/auth/cell_grant_action_specs.d.ts +6 -0
  41. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
  42. package/dist/auth/cell_grant_action_specs.js +6 -0
  43. package/dist/auth/cell_grant_actions.d.ts +3 -1
  44. package/dist/auth/cell_grant_actions.d.ts.map +1 -1
  45. package/dist/auth/cell_grant_actions.js +16 -2
  46. package/dist/auth/cell_item_action_specs.d.ts +5 -4
  47. package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
  48. package/dist/auth/cell_item_action_specs.js +7 -7
  49. package/dist/auth/cell_item_actions.d.ts +6 -5
  50. package/dist/auth/cell_item_actions.d.ts.map +1 -1
  51. package/dist/auth/cell_item_actions.js +23 -22
  52. package/dist/auth/cell_relation_list_limit.d.ts +20 -0
  53. package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
  54. package/dist/auth/cell_relation_list_limit.js +27 -0
  55. package/dist/auth/cell_relation_visibility.d.ts +4 -3
  56. package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
  57. package/dist/auth/cell_relation_visibility.js +6 -5
  58. package/dist/auth/cell_steward.d.ts +45 -0
  59. package/dist/auth/cell_steward.d.ts.map +1 -0
  60. package/dist/auth/cell_steward.js +53 -0
  61. package/dist/auth/request_context.d.ts +4 -0
  62. package/dist/auth/request_context.d.ts.map +1 -1
  63. package/dist/auth/request_context.js +6 -1
  64. package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
  65. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  66. package/dist/auth/role_grant_offer_action_specs.js +6 -1
  67. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_actions.js +23 -16
  69. package/dist/auth/role_grant_offer_queries.d.ts +8 -6
  70. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  71. package/dist/auth/role_grant_offer_queries.js +16 -8
  72. package/dist/auth/signup_routes.d.ts +4 -3
  73. package/dist/auth/signup_routes.d.ts.map +1 -1
  74. package/dist/auth/signup_routes.js +157 -134
  75. package/dist/auth/token_scope.d.ts +7 -1
  76. package/dist/auth/token_scope.d.ts.map +1 -1
  77. package/dist/auth/token_scope.js +8 -2
  78. package/dist/db/CLAUDE.md +32 -19
  79. package/dist/db/cell_ddl.d.ts +1 -2
  80. package/dist/db/cell_ddl.d.ts.map +1 -1
  81. package/dist/db/cell_ddl.js +1 -2
  82. package/dist/db/cell_field_queries.d.ts +20 -18
  83. package/dist/db/cell_field_queries.d.ts.map +1 -1
  84. package/dist/db/cell_field_queries.js +28 -22
  85. package/dist/db/cell_item_queries.d.ts +20 -14
  86. package/dist/db/cell_item_queries.d.ts.map +1 -1
  87. package/dist/db/cell_item_queries.js +28 -18
  88. package/dist/db/cell_moderation.d.ts +67 -0
  89. package/dist/db/cell_moderation.d.ts.map +1 -0
  90. package/dist/db/cell_moderation.js +66 -0
  91. package/dist/db/cell_queries.d.ts +44 -14
  92. package/dist/db/cell_queries.d.ts.map +1 -1
  93. package/dist/db/cell_queries.js +68 -45
  94. package/dist/db/cell_view_sql.d.ts +102 -0
  95. package/dist/db/cell_view_sql.d.ts.map +1 -0
  96. package/dist/db/cell_view_sql.js +112 -0
  97. package/dist/db/db_pg.d.ts +1 -1
  98. package/dist/db/db_pg.js +1 -1
  99. package/dist/http/CLAUDE.md +1 -1
  100. package/dist/http/auth_shape.d.ts +5 -2
  101. package/dist/http/auth_shape.d.ts.map +1 -1
  102. package/dist/http/auth_shape.js +5 -2
  103. package/dist/http/ip_canonical.d.ts +56 -5
  104. package/dist/http/ip_canonical.d.ts.map +1 -1
  105. package/dist/http/ip_canonical.js +147 -35
  106. package/dist/primitive_schemas.d.ts +10 -1
  107. package/dist/primitive_schemas.d.ts.map +1 -1
  108. package/dist/primitive_schemas.js +11 -2
  109. package/dist/rate_limiter.d.ts +179 -12
  110. package/dist/rate_limiter.d.ts.map +1 -1
  111. package/dist/rate_limiter.js +352 -42
  112. package/dist/server/serve_fact_route.d.ts +8 -4
  113. package/dist/server/serve_fact_route.d.ts.map +1 -1
  114. package/dist/server/serve_fact_route.js +15 -6
  115. package/dist/testing/CLAUDE.md +16 -7
  116. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  117. package/dist/testing/cross_backend/account_lifecycle.js +37 -2
  118. package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
  119. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/cell_gated_create.js +401 -5
  121. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  122. package/dist/testing/cross_backend/cell_relations.js +145 -2
  123. package/dist/testing/cross_backend/expected_schema.json +0 -1
  124. package/dist/testing/cross_backend/full_spine_mount.js +2 -1
  125. package/dist/testing/cross_backend/login_security.d.ts +16 -0
  126. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/login_security.js +154 -0
  128. package/dist/testing/cross_backend/origin_conformance.d.ts +1 -1
  129. package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -1
  130. package/dist/testing/cross_backend/origin_conformance.js +1 -1
  131. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  132. package/dist/testing/cross_backend/query_shape.js +24 -2
  133. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  134. package/dist/testing/cross_backend/role_grant_participation.js +64 -0
  135. package/dist/testing/cross_backend/spine_surface_constants.d.ts +13 -21
  136. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  137. package/dist/testing/cross_backend/spine_surface_constants.js +13 -21
  138. package/dist/testing/cross_backend/testing_origin_check.d.ts +128 -0
  139. package/dist/testing/cross_backend/testing_origin_check.d.ts.map +1 -0
  140. package/dist/testing/cross_backend/testing_origin_check.js +87 -0
  141. package/dist/testing/cross_backend/testing_reset_actions.d.ts +7 -119
  142. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  143. package/dist/testing/cross_backend/testing_reset_actions.js +8 -92
  144. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  145. package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
  146. package/dist/testing/schema_introspect.js +1 -1
  147. package/dist/testing/schema_parity.d.ts +4 -3
  148. package/dist/testing/schema_parity.d.ts.map +1 -1
  149. package/dist/testing/schema_parity.js +4 -3
  150. package/dist/testing/schema_ready_fixture.d.ts +2 -4
  151. package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
  152. package/dist/testing/schema_ready_fixture.js +2 -4
  153. package/dist/ui/auth_state.svelte.d.ts +10 -0
  154. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  155. package/dist/ui/auth_state.svelte.js +22 -4
  156. package/package.json +1 -1
  157. package/dist/db/cell_history_ddl.d.ts +0 -38
  158. package/dist/db/cell_history_ddl.d.ts.map +0 -1
  159. package/dist/db/cell_history_ddl.js +0 -61
@@ -1 +1 @@
1
- {"version":3,"file":"account_lifecycle.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/account_lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAmC9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAG1E;;;;;GAKG;AACH,MAAM,MAAM,gCAAgC,GAAG,uCAAuC,CAAC;AAEvF,eAAO,MAAM,sCAAsC,GAClD,SAAS,gCAAgC,KACvC,IAoUF,CAAC"}
1
+ {"version":3,"file":"account_lifecycle.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/account_lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuC9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAG1E;;;;;GAKG;AACH,MAAM,MAAM,gCAAgC,GAAG,uCAAuC,CAAC;AAEvF,eAAO,MAAM,sCAAsC,GAClD,SAAS,gCAAgC,KACvC,IAqZF,CAAC"}
@@ -2,7 +2,9 @@ import '../assert_dev_env.js';
2
2
  /**
3
3
  * Cross-backend parity suite for the account-lifecycle admin verbs:
4
4
  * `account_delete` (soft), `account_undelete` (reactivation), and
5
- * `account_purge` (keeper hard-delete), plus the keeper guard.
5
+ * `account_purge` (keeper hard-delete), plus the keeper guard and the
6
+ * session-only self-delete (an API token can't end its own account; an admin
7
+ * token still deletes another).
6
8
  *
7
9
  * Like the cell suites, these verbs can't ride the generic
8
10
  * `describe_rpc_round_trip_tests`: they're stateful and destructive (a
@@ -16,7 +18,8 @@ import '../assert_dev_env.js';
16
18
  */
17
19
  import { describe, assert } from 'vitest';
18
20
  import { AccountDeleteOutput, AccountUndeleteOutput, AccountPurgeOutput, AdminAccountListOutput, AuditLogListOutput, ERROR_CANNOT_DELETE_KEEPER } from '../../auth/admin_action_specs.js';
19
- import { ERROR_ACCOUNT_NOT_FOUND, ERROR_AUTHENTICATION_REQUIRED } from '../../http/error_schemas.js';
21
+ import { ERROR_ACCOUNT_NOT_FOUND, ERROR_AUTHENTICATION_REQUIRED, ERROR_CREDENTIAL_TYPE_REQUIRED } from '../../http/error_schemas.js';
22
+ import { JSONRPC_ERROR_CODES } from '../../http/jsonrpc_errors.js';
20
23
  import { test_if } from './capabilities.js';
21
24
  import { cross_rpc_call, error_reason, expect_output } from './cell_cross_helpers.js';
22
25
  import { assert_iso8601_seconds } from './wire_shapes.js';
@@ -55,6 +58,38 @@ export const describe_account_lifecycle_cross_tests = (options) => {
55
58
  assert.strictEqual(purge.ok, false, 'purge of keeper account must be refused');
56
59
  assert.strictEqual(error_reason(purge), ERROR_CANNOT_DELETE_KEEPER);
57
60
  });
61
+ test_if(capabilities.account_lifecycle, 'self-delete is session-only; an admin bearer still deletes another account', async () => {
62
+ const fixture = await setup_test();
63
+ const member = await fixture.create_account({ username: 'lifecycle_self_bearer' });
64
+ // The member's own bearer may not end its account (handler-enforced,
65
+ // the dispatcher's credential-type refusal).
66
+ const refused = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, 'account_delete', {}, member.create_bearer_headers());
67
+ assert.strictEqual(refused.ok, false, 'a bearer self-delete must be refused');
68
+ assert.strictEqual(refused.error?.code, JSONRPC_ERROR_CODES.forbidden);
69
+ assert.strictEqual(error_reason(refused), ERROR_CREDENTIAL_TYPE_REQUIRED);
70
+ assert.deepStrictEqual(refused.error?.data
71
+ ?.required_credential_types, ['session']);
72
+ // An explicit own id is self in either case (`Uuid` admits both) — the
73
+ // keeper's too, whose refusal is the credential one, not the keeper guard.
74
+ for (const [label, account_id, headers] of [
75
+ ['member', member.account.id, member.create_bearer_headers()],
76
+ ['keeper', fixture.account.id, fixture.create_bearer_headers()]
77
+ ]) {
78
+ const upper = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, 'account_delete', { account_id: account_id.toUpperCase() }, headers);
79
+ assert.strictEqual(upper.ok, false, `${label}: an uppercased own id is self`);
80
+ assert.deepStrictEqual(upper.error, refused.error, label);
81
+ }
82
+ // Nothing was tombstoned: the member's session still verifies.
83
+ const still = await cross_rpc_call(fixture.fresh_transport(), rpc_path, 'account_verify', undefined, member.create_session_headers());
84
+ assert.ok(still.ok, 'the refused self-delete leaves the account live');
85
+ // The keeper's bearer (admin-capable) deletes another account.
86
+ const deleted = expect_output(await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, 'account_delete', { account_id: member.account.id }, fixture.create_bearer_headers()), AccountDeleteOutput);
87
+ assert.strictEqual(deleted.deleted, true);
88
+ // A self-delete over a session goes through.
89
+ const self = await fixture.create_account({ username: 'lifecycle_self_session' });
90
+ const self_deleted = expect_output(await cross_rpc_call(fixture.fresh_transport(), rpc_path, 'account_delete', {}, self.create_session_headers()), AccountDeleteOutput);
91
+ assert.strictEqual(self_deleted.deleted, true);
92
+ });
58
93
  test_if(capabilities.account_lifecycle, 'fail-closed: a soft-deleted account’s session + bearer credentials no longer authenticate', async () => {
59
94
  const fixture = await setup_test();
60
95
  const victim = await fixture.create_account({ username: 'lifecycle_failclosed' });
@@ -19,6 +19,44 @@ export declare const describe_cell_gated_create_cross_tests: (options: RpcPathCa
19
19
  * contribution gets `cell_not_found`, never learns it exists.
20
20
  * - **reject** → `moderation: 'rejected'`, visibility stays private.
21
21
  *
22
+ * And that moderation gates reads — a cell's effective audience is its
23
+ * visibility or grants **and** its moderation admission (unmoderated or
24
+ * `approved`), so a pending or rejected contribution reaches no one past its
25
+ * author and admins:
26
+ *
27
+ * - **an author's self-flip to public admits nobody** — a pending post its
28
+ * author makes public stays 404 to anon and a fresh non-admin on `cell_get`,
29
+ * and out of their `cell_list`; the author and admin still read it.
30
+ * - **rejection holds a public post back** — pending → rejected → public, and
31
+ * approved → rejected (approval made it public, rejection leaves visibility
32
+ * alone), both 404 past the author; a grant made while the post was approved
33
+ * stops admitting once it is rejected, on `cell_get` and `shared_with: 'me'`.
34
+ * - **an approved post is live** — the control.
35
+ * - **relation reads follow** — a pending post in a public collection is
36
+ * omitted from anon `cell_item_list`, beside a live sibling that isn't; at
37
+ * the far end of all four relation lists, a pending post is hidden from
38
+ * anon even flipped public, and a rejected one from its grant holder.
39
+ * - **fact serving follows** — a pending post's fact is 404 to anon, even
40
+ * with the post flipped public (also gated on `capabilities.fact_serving`).
41
+ *
42
+ * And the steward arm — the owner of a **non-admin** root (a parentless
43
+ * non-`space` kind carrying the same policy, which the test authorizer leaves
44
+ * open) reads the pending posts under it and moderates them:
45
+ *
46
+ * - **the queue** — `cell_list({root_id, moderation: 'pending'})` and
47
+ * `cell_get` (with `can_edit` / `can_grant` false) → 200; a stranger 404s.
48
+ * - **moderation** — the root owner approves → 200; the author 403, a
49
+ * stranger 404.
50
+ * - **the arm stops there** — the root owner's `cell_clone` of the pending
51
+ * post and a contribution under it are both 404.
52
+ * - **fact serving** — the root owner fetches a pending post's fact → 200,
53
+ * anon 404 (also gated on `capabilities.fact_serving`).
54
+ *
55
+ * And that `cell_grant_create` refuses a pending contribution — **403**
56
+ * `cell_grant_unapproved` for its author and an admin, actor- and role-shaped,
57
+ * while a non-manager keeps the **404** mask; the same grant lands once the
58
+ * post is approved.
59
+ *
22
60
  * Gated on `capabilities.cell_gated_create` — a `pending` contribution only
23
61
  * exists when the directory authorizer is mounted (reference spine binaries).
24
62
  */
@@ -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;AAuD9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAkB1E,eAAO,MAAM,sCAAsC,GAClD,SAAS,uCAAuC,KAC9C,IAwPF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,kCAAkC,GAC9C,SAAS,uCAAuC,KAC9C,IAyKF,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;AAoE9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAqB1E,eAAO,MAAM,sCAAsC,GAClD,SAAS,uCAAuC,KAC9C,IAuTF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AACH,eAAO,MAAM,kCAAkC,GAC9C,SAAS,uCAAuC,KAC9C,IA25BF,CAAC"}
@@ -30,6 +30,10 @@ 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
+ * - **a soft-deleted root refuses contributions** — soft delete doesn't
34
+ * cascade, so a live post can sit under a deleted space; a comment under it
35
+ * → **404** `cell_not_found`, the same shape as a missing parent (the
36
+ * authorizer never sees a root id without its data).
33
37
  * - **clone is no side door** — `cell_clone` runs the authorizer for every cell
34
38
  * it writes as a parentless create, so a non-admin clone of a viewable
35
39
  * `space` root → **403** `cell_create_forbidden`; admin → succeeds.
@@ -42,7 +46,13 @@ import '../assert_dev_env.js';
42
46
  * @module
43
47
  */
44
48
  import { describe, assert } from 'vitest';
45
- import { CellCloneOutput, CellCreateOutput, CellModerateOutput } from '../../auth/cell_action_specs.js';
49
+ import { fractional_index_between } from '@fuzdev/fuz_util/fractional_index.ts';
50
+ import { CellCloneOutput, CellCreateOutput, CellGetOutput, CellListOutput, CellModerateOutput, CellUpdateOutput } from '../../auth/cell_action_specs.js';
51
+ import { CellItemInsertOutput, CellItemListOutput } from '../../auth/cell_item_action_specs.js';
52
+ import { CellGrantCreateOutput } from '../../auth/cell_grant_action_specs.js';
53
+ import { CellFieldListOutput, CellFieldSetOutput } from '../../auth/cell_field_action_specs.js';
54
+ import { z } from 'zod';
55
+ import { FactHashSchema } from '../../primitive_schemas.js';
46
56
  import { JSONRPC_ERROR_CODES } from '../../http/jsonrpc_errors.js';
47
57
  import { test_if } from './capabilities.js';
48
58
  import { cross_rpc_call, error_reason, expect_output } from './cell_cross_helpers.js';
@@ -61,6 +71,8 @@ const SPACE_POLICY = {
61
71
  comment: { min_role: PARTICIPATION_ROLE, moderation_required: false }
62
72
  }
63
73
  };
74
+ /** `_testing_put_fact`'s output envelope. */
75
+ const TestingPutFactOutput = z.strictObject({ hash: FactHashSchema });
64
76
  export const describe_cell_gated_create_cross_tests = (options) => {
65
77
  const { setup_test, capabilities } = options;
66
78
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
@@ -144,6 +156,27 @@ export const describe_cell_gated_create_cross_tests = (options) => {
144
156
  assert.strictEqual(comment.cell.root_id, space.cell.id, 'the governing root resolves to the space (deep read through the post), not the post');
145
157
  assert.strictEqual(comment.cell.moderation, 'approved', 'the comment policy (moderation_required: false) resolved from the space, not the post');
146
158
  });
159
+ test_if(capabilities.cell_gated_create, 'a contribution under a soft-deleted root is 404 cell_not_found (the missing-parent shape)', async () => {
160
+ const fixture = await setup_test();
161
+ const admin_headers = fixture.create_session_headers();
162
+ const space = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: SPACE_CELL_KIND, data: SPACE_POLICY, visibility: 'public' }, admin_headers), CellCreateOutput);
163
+ const participant = await fixture.create_account({
164
+ username: 'tombstone_participant',
165
+ roles: [PARTICIPATION_ROLE]
166
+ });
167
+ const participant_headers = participant.create_session_headers();
168
+ const post = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: {}, parent_id: space.cell.id }, participant_headers), CellCreateOutput);
169
+ // Soft delete doesn't cascade: the post stays live (and viewable by
170
+ // its author) under the tombstoned space.
171
+ const deleted = await cross_rpc_call(fixture.transport, rpc_path, 'cell_delete', { cell_id: space.cell.id }, admin_headers);
172
+ assert.ok(deleted.ok, `cell_delete failed: ${JSON.stringify(deleted.error)}`);
173
+ const refused = await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'comment', data: {}, parent_id: post.cell.id }, participant_headers);
174
+ assert.ok(!refused.ok, 'a contribution under a deleted root must be refused');
175
+ assert.strictEqual(error_reason(refused), 'cell_not_found');
176
+ // Byte-identical to the missing-parent refusal.
177
+ const missing_parent = await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'comment', data: {}, parent_id: '00000000-0000-4000-8000-000000000000' }, participant_headers);
178
+ assert.deepStrictEqual(refused.error, missing_parent.error);
179
+ });
147
180
  });
148
181
  };
149
182
  /**
@@ -164,6 +197,44 @@ export const describe_cell_gated_create_cross_tests = (options) => {
164
197
  * contribution gets `cell_not_found`, never learns it exists.
165
198
  * - **reject** → `moderation: 'rejected'`, visibility stays private.
166
199
  *
200
+ * And that moderation gates reads — a cell's effective audience is its
201
+ * visibility or grants **and** its moderation admission (unmoderated or
202
+ * `approved`), so a pending or rejected contribution reaches no one past its
203
+ * author and admins:
204
+ *
205
+ * - **an author's self-flip to public admits nobody** — a pending post its
206
+ * author makes public stays 404 to anon and a fresh non-admin on `cell_get`,
207
+ * and out of their `cell_list`; the author and admin still read it.
208
+ * - **rejection holds a public post back** — pending → rejected → public, and
209
+ * approved → rejected (approval made it public, rejection leaves visibility
210
+ * alone), both 404 past the author; a grant made while the post was approved
211
+ * stops admitting once it is rejected, on `cell_get` and `shared_with: 'me'`.
212
+ * - **an approved post is live** — the control.
213
+ * - **relation reads follow** — a pending post in a public collection is
214
+ * omitted from anon `cell_item_list`, beside a live sibling that isn't; at
215
+ * the far end of all four relation lists, a pending post is hidden from
216
+ * anon even flipped public, and a rejected one from its grant holder.
217
+ * - **fact serving follows** — a pending post's fact is 404 to anon, even
218
+ * with the post flipped public (also gated on `capabilities.fact_serving`).
219
+ *
220
+ * And the steward arm — the owner of a **non-admin** root (a parentless
221
+ * non-`space` kind carrying the same policy, which the test authorizer leaves
222
+ * open) reads the pending posts under it and moderates them:
223
+ *
224
+ * - **the queue** — `cell_list({root_id, moderation: 'pending'})` and
225
+ * `cell_get` (with `can_edit` / `can_grant` false) → 200; a stranger 404s.
226
+ * - **moderation** — the root owner approves → 200; the author 403, a
227
+ * stranger 404.
228
+ * - **the arm stops there** — the root owner's `cell_clone` of the pending
229
+ * post and a contribution under it are both 404.
230
+ * - **fact serving** — the root owner fetches a pending post's fact → 200,
231
+ * anon 404 (also gated on `capabilities.fact_serving`).
232
+ *
233
+ * And that `cell_grant_create` refuses a pending contribution — **403**
234
+ * `cell_grant_unapproved` for its author and an admin, actor- and role-shaped,
235
+ * while a non-manager keeps the **404** mask; the same grant lands once the
236
+ * post is approved.
237
+ *
167
238
  * Gated on `capabilities.cell_gated_create` — a `pending` contribution only
168
239
  * exists when the directory authorizer is mounted (reference spine binaries).
169
240
  */
@@ -172,16 +243,58 @@ export const describe_cell_moderate_cross_tests = (options) => {
172
243
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
173
244
  // Build an admin-owned public space (moderated `post` policy) + a
174
245
  // participant's pending post under it.
175
- const setup_pending_post = async () => {
176
- const fixture = await setup_test();
246
+ const setup_pending_post_on = async (fixture, post_data = {}) => {
177
247
  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
248
  const author = await fixture.create_account({
179
249
  username: 'moderate_author',
180
250
  roles: [PARTICIPATION_ROLE]
181
251
  });
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 };
252
+ const post = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: post_data, parent_id: space.cell.id }, author.create_session_headers()), CellCreateOutput);
253
+ return { fixture, space, author, post };
254
+ };
255
+ const setup_pending_post = async () => setup_pending_post_on(await setup_test());
256
+ // A non-admin's policy root (a parentless non-`space` kind stays open under
257
+ // the test authorizer, and contributions read the policy off any root) + a
258
+ // participant's pending post under it.
259
+ const setup_stewarded_post_on = async (fixture, post_data = {}) => {
260
+ const root_owner = await fixture.create_account({ username: 'steward_root_owner' });
261
+ const root = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'forum', data: SPACE_POLICY, visibility: 'public' }, root_owner.create_session_headers()), CellCreateOutput);
262
+ const author = await fixture.create_account({
263
+ username: 'steward_author',
264
+ roles: [PARTICIPATION_ROLE]
265
+ });
266
+ const post = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'post', data: post_data, parent_id: root.cell.id }, author.create_session_headers()), CellCreateOutput);
267
+ assert.strictEqual(post.cell.moderation, 'pending', "the root's policy gates the post");
268
+ return { fixture, root, root_owner, author, post };
269
+ };
270
+ const setup_stewarded_post = async () => setup_stewarded_post_on(await setup_test());
271
+ /** Seed an embedded fact over the daemon channel; returns its `blake3:` hash. */
272
+ const put_fact = async (fixture, content) => expect_output(await cross_rpc_call(
273
+ // origin-free — the daemon-token middleware discards the credential beside an `Origin`
274
+ fixture.fresh_transport({ origin: null }), rpc_path, '_testing_put_fact', { content }, fixture.create_daemon_token_headers()), TestingPutFactOutput).hash;
275
+ /** The status of a fact GET — `null` headers = anon. */
276
+ const fact_status = async (fixture, path, headers) => {
277
+ const transport = headers === null ? fixture.fresh_transport({ origin: null }) : fixture.transport;
278
+ const res = await transport(path, { method: 'GET', headers: headers ?? {} });
279
+ await res.text();
280
+ return res.status;
281
+ };
282
+ /** `'ok'`, or the refusal reason, for one caller's RPC — `null` headers = anon. */
283
+ const outcome = async (fixture, method, params, headers) => {
284
+ const r = headers === null
285
+ ? await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, method, params, {})
286
+ : await cross_rpc_call(fixture.transport, rpc_path, method, params, headers);
287
+ return r.ok ? 'ok' : error_reason(r);
184
288
  };
289
+ /** The ids a caller's `cell_list` returns — `null` headers = anon. */
290
+ const listed_ids = async (fixture, params, headers) => {
291
+ const r = headers === null
292
+ ? await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, 'cell_list', params, {})
293
+ : await cross_rpc_call(fixture.transport, rpc_path, 'cell_list', params, headers);
294
+ return expect_output(r, CellListOutput).cells.map((c) => c.id);
295
+ };
296
+ const moderate = async (fixture, cell_id, moderation, headers = fixture.create_session_headers()) => expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_moderate', { cell_id, moderation }, headers), CellModerateOutput).cell;
297
+ const set_visibility = async (fixture, headers, cell_id, visibility) => expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_update', { cell_id, visibility }, headers), CellUpdateOutput).cell;
185
298
  describe('cell_moderate verb parity (root-authority-gated)', () => {
186
299
  test_if(capabilities.cell_gated_create, 'a root manager (admin) approves a pending post → approved + public', async () => {
187
300
  const { fixture, post } = await setup_pending_post();
@@ -211,6 +324,289 @@ export const describe_cell_moderate_cross_tests = (options) => {
211
324
  assert.strictEqual(moderated.cell.visibility, 'private', 'rejection leaves the post private');
212
325
  });
213
326
  });
327
+ describe('moderation gates reads (parity)', () => {
328
+ test_if(capabilities.cell_gated_create, 'a pending post its author flips public stays hidden past the author and admins', async () => {
329
+ const { fixture, space, author, post } = await setup_pending_post();
330
+ const author_h = author.create_session_headers();
331
+ const flipped = await set_visibility(fixture, author_h, post.cell.id, 'public');
332
+ assert.strictEqual(flipped.visibility, 'public', 'the author manages their own post');
333
+ assert.strictEqual(flipped.moderation, 'pending', 'a visibility write leaves moderation');
334
+ const stranger = await fixture.create_account({ username: 'flip_stranger' });
335
+ const stranger_h = stranger.create_session_headers();
336
+ const get = { id: post.cell.id };
337
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, null), 'cell_not_found');
338
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, stranger_h), 'cell_not_found');
339
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, author_h), 'ok');
340
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, fixture.create_session_headers()), 'ok');
341
+ const feed = { root_id: space.cell.id };
342
+ assert.notInclude(await listed_ids(fixture, feed, null), post.cell.id, 'anon list');
343
+ assert.notInclude(await listed_ids(fixture, feed, stranger_h), post.cell.id, 'stranger');
344
+ assert.include(await listed_ids(fixture, feed, author_h), post.cell.id, 'author list');
345
+ assert.include(await listed_ids(fixture, feed, fixture.create_session_headers()), post.cell.id, 'admin list');
346
+ });
347
+ test_if(capabilities.cell_gated_create, 'pending → rejected → public stays hidden past the author', async () => {
348
+ const { fixture, author, post } = await setup_pending_post();
349
+ const author_h = author.create_session_headers();
350
+ assert.strictEqual((await moderate(fixture, post.cell.id, 'rejected')).moderation, 'rejected');
351
+ await set_visibility(fixture, author_h, post.cell.id, 'public');
352
+ const stranger = await fixture.create_account({ username: 'rejected_stranger' });
353
+ const get = { id: post.cell.id };
354
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, null), 'cell_not_found');
355
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, stranger.create_session_headers()), 'cell_not_found');
356
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, author_h), 'ok');
357
+ });
358
+ test_if(capabilities.cell_gated_create, 'approved → rejected stays hidden, and a grant made while approved stops admitting', async () => {
359
+ const { fixture, author, post } = await setup_pending_post();
360
+ const author_h = author.create_session_headers();
361
+ const friend = await fixture.create_account({ username: 'approved_friend' });
362
+ const friend_h = friend.create_session_headers();
363
+ const stranger = await fixture.create_account({ username: 'approved_stranger' });
364
+ const get = { id: post.cell.id };
365
+ const shared = { shared_with: 'me' };
366
+ // The control: approval publishes the post to everyone.
367
+ assert.strictEqual((await moderate(fixture, post.cell.id, 'approved')).visibility, 'public');
368
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, null), 'ok', 'approved is live');
369
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, stranger.create_session_headers()), 'ok');
370
+ // A grant made while approved admits on its own once the post is private.
371
+ expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_grant_create', {
372
+ cell_id: post.cell.id,
373
+ level: 'viewer',
374
+ principal: { kind: 'actor', actor_id: friend.actor.id }
375
+ }, author_h), CellGrantCreateOutput);
376
+ await set_visibility(fixture, author_h, post.cell.id, 'private');
377
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, friend_h), 'ok', 'grant admits');
378
+ assert.include(await listed_ids(fixture, shared, friend_h), post.cell.id);
379
+ // Rejection holds it back from the grant holder ...
380
+ assert.strictEqual((await moderate(fixture, post.cell.id, 'rejected')).moderation, 'rejected');
381
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, friend_h), 'cell_not_found');
382
+ assert.notInclude(await listed_ids(fixture, shared, friend_h), post.cell.id);
383
+ // ... and from everyone once public again; the author still reads it.
384
+ await set_visibility(fixture, author_h, post.cell.id, 'public');
385
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, null), 'cell_not_found');
386
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, stranger.create_session_headers()), 'cell_not_found');
387
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, friend_h), 'cell_not_found');
388
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, author_h), 'ok');
389
+ });
390
+ test_if(capabilities.cell_gated_create, 'approval leaves visibility to rejection: an approved-then-rejected post is still public, and hidden', async () => {
391
+ const { fixture, post } = await setup_pending_post();
392
+ await moderate(fixture, post.cell.id, 'approved');
393
+ const rejected = await moderate(fixture, post.cell.id, 'rejected');
394
+ assert.strictEqual(rejected.visibility, 'public', 'rejection does not reset visibility');
395
+ assert.strictEqual(await outcome(fixture, 'cell_get', { id: post.cell.id }, null), 'cell_not_found');
396
+ });
397
+ test_if(capabilities.cell_gated_create, 'an editor grant made while approved admits no write once rejected; the author keeps the owner arm', async () => {
398
+ const { fixture, author, post } = await setup_pending_post();
399
+ const author_h = author.create_session_headers();
400
+ const editor = await fixture.create_account({ username: 'rejected_editor' });
401
+ const editor_h = editor.create_session_headers();
402
+ const other = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'note', data: {}, visibility: 'public' }, editor_h), CellCreateOutput);
403
+ await moderate(fixture, post.cell.id, 'approved');
404
+ expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_grant_create', {
405
+ cell_id: post.cell.id,
406
+ level: 'editor',
407
+ principal: { kind: 'actor', actor_id: editor.actor.id }
408
+ }, author_h), CellGrantCreateOutput);
409
+ const update = { cell_id: post.cell.id };
410
+ assert.strictEqual(await outcome(fixture, 'cell_update', update, editor_h), 'ok', 'the grant admits a write while approved');
411
+ await moderate(fixture, post.cell.id, 'rejected');
412
+ const writes = [
413
+ ['cell_get', { id: post.cell.id }],
414
+ ['cell_update', update],
415
+ ['cell_field_set', { source_id: post.cell.id, name: 'link', target_id: other.cell.id }],
416
+ [
417
+ 'cell_item_insert',
418
+ { parent_id: post.cell.id, child_id: other.cell.id, position: 'a0' }
419
+ ],
420
+ ['cell_delete', { cell_id: post.cell.id }]
421
+ ];
422
+ for (const [method, params] of writes) {
423
+ assert.strictEqual(await outcome(fixture, method, params, editor_h), 'cell_not_found', method);
424
+ }
425
+ assert.strictEqual(await outcome(fixture, 'cell_update', update, author_h), 'ok', 'author');
426
+ });
427
+ test_if(capabilities.cell_gated_create, 'a pending post in a public collection is omitted from anon cell_item_list', async () => {
428
+ const { fixture, space, author, post } = await setup_pending_post();
429
+ const author_h = author.create_session_headers();
430
+ // A live sibling under the same space: a `react` is born approved + public.
431
+ const react = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'react', data: {}, parent_id: space.cell.id, visibility: 'public' }, author_h), CellCreateOutput);
432
+ await set_visibility(fixture, author_h, post.cell.id, 'public');
433
+ const collection = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'collection', data: {}, visibility: 'public' }, author_h), CellCreateOutput);
434
+ const pos_a = fractional_index_between(null, null);
435
+ const pos_b = fractional_index_between(pos_a, null);
436
+ for (const [child_id, position] of [
437
+ [post.cell.id, pos_a],
438
+ [react.cell.id, pos_b]
439
+ ]) {
440
+ expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_item_insert', { parent_id: collection.cell.id, child_id, position }, author_h), CellItemInsertOutput);
441
+ }
442
+ const items = async (headers) => {
443
+ const params = { parent_id: collection.cell.id };
444
+ const r = headers === null
445
+ ? await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, 'cell_item_list', params, {})
446
+ : await cross_rpc_call(fixture.transport, rpc_path, 'cell_item_list', params, headers);
447
+ return expect_output(r, CellItemListOutput).items.map((i) => i.child_id);
448
+ };
449
+ assert.deepStrictEqual(await items(null), [react.cell.id], 'anon sees the live sibling only');
450
+ assert.deepStrictEqual(await items(author_h), [post.cell.id, react.cell.id]);
451
+ });
452
+ test_if(capabilities.cell_gated_create, 'every relation list hides a pending end, and a rejected one from its grant holder', async () => {
453
+ const { fixture, author, post } = await setup_pending_post();
454
+ const author_h = author.create_session_headers();
455
+ const holder = await fixture.create_account({ username: 'relation_grant_holder' });
456
+ const holder_h = holder.create_session_headers();
457
+ const create = async (kind) => expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind, data: {}, visibility: 'public' }, author_h), CellCreateOutput).cell.id;
458
+ const call = async (method, params) => cross_rpc_call(fixture.transport, rpc_path, method, params, author_h);
459
+ // The post at the far end of all four lists: a public source and
460
+ // collection point at it, and it points at a public target and child.
461
+ const source = await create('note');
462
+ const collection = await create('collection');
463
+ const target = await create('note');
464
+ const child = await create('note');
465
+ const post_id = post.cell.id;
466
+ expect_output(await call('cell_field_set', { source_id: source, name: 'post', target_id: post_id }), CellFieldSetOutput);
467
+ expect_output(await call('cell_item_insert', {
468
+ parent_id: collection,
469
+ child_id: post_id,
470
+ position: 'a0'
471
+ }), CellItemInsertOutput);
472
+ expect_output(await call('cell_field_set', { source_id: post_id, name: 'link', target_id: target }), CellFieldSetOutput);
473
+ expect_output(await call('cell_item_insert', { parent_id: post_id, child_id: child, position: 'a0' }), CellItemInsertOutput);
474
+ // Which lists show the post to a caller — `null` headers = anon.
475
+ const reach = async (headers) => {
476
+ const list = async (method, params) => headers === null
477
+ ? cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, method, params, {})
478
+ : cross_rpc_call(fixture.transport, rpc_path, method, params, headers);
479
+ const forward_fields = expect_output(await list('cell_field_list', { source_id: source }), CellFieldListOutput).fields.map((f) => f.target_id);
480
+ const forward_items = expect_output(await list('cell_item_list', { parent_id: collection }), CellItemListOutput).items.map((i) => i.child_id);
481
+ const reverse_fields = expect_output(await list('cell_field_list', { target_id: target }), CellFieldListOutput).fields.map((f) => f.source_id);
482
+ const reverse_items = expect_output(await list('cell_item_list', { child_id: child }), CellItemListOutput).items.map((i) => i.parent_id);
483
+ return [forward_fields, forward_items, reverse_fields, reverse_items].map((ids) => ids.includes(post_id));
484
+ };
485
+ const everywhere = [true, true, true, true];
486
+ const nowhere = [false, false, false, false];
487
+ // pending, flipped public: the audience sees nothing, the author all
488
+ await set_visibility(fixture, author_h, post_id, 'public');
489
+ assert.deepStrictEqual(await reach(null), nowhere, 'anon, pending + public');
490
+ assert.deepStrictEqual(await reach(author_h), everywhere, 'author, pending + public');
491
+ // approved and granted, then private: the grant admits it
492
+ await moderate(fixture, post_id, 'approved');
493
+ expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_grant_create', {
494
+ cell_id: post_id,
495
+ level: 'viewer',
496
+ principal: { kind: 'actor', actor_id: holder.actor.id }
497
+ }, author_h), CellGrantCreateOutput);
498
+ await set_visibility(fixture, author_h, post_id, 'private');
499
+ assert.deepStrictEqual(await reach(holder_h), everywhere, 'grant holder, approved');
500
+ assert.deepStrictEqual(await reach(null), nowhere, 'anon, approved + private');
501
+ // rejected: the grant admits nothing, public or not
502
+ await moderate(fixture, post_id, 'rejected');
503
+ assert.deepStrictEqual(await reach(holder_h), nowhere, 'grant holder, rejected');
504
+ await set_visibility(fixture, author_h, post_id, 'public');
505
+ assert.deepStrictEqual(await reach(holder_h), nowhere, 'grant holder, rejected + public');
506
+ assert.deepStrictEqual(await reach(null), nowhere, 'anon, rejected + public');
507
+ assert.deepStrictEqual(await reach(author_h), everywhere, 'author, rejected');
508
+ });
509
+ test_if(capabilities.cell_gated_create && capabilities.fact_serving, "a pending post's fact is 404 to anon, even flipped public; live once approved", async () => {
510
+ const fixture = await setup_test();
511
+ const hash = await put_fact(fixture, 'pending-post-fact');
512
+ const { author, post } = await setup_pending_post_on(fixture, { cover: hash });
513
+ await set_visibility(fixture, author.create_session_headers(), post.cell.id, 'public');
514
+ const path = `/api/cells/${post.cell.id}/facts/${hash}`;
515
+ assert.strictEqual(await fact_status(fixture, path, null), 404, 'anon');
516
+ assert.strictEqual(await fact_status(fixture, path, author.create_session_headers()), 200, 'author');
517
+ await moderate(fixture, post.cell.id, 'approved');
518
+ assert.strictEqual(await fact_status(fixture, path, null), 200, 'approved is live');
519
+ });
520
+ });
521
+ describe('the steward arm: a root owner moderates (parity)', () => {
522
+ test_if(capabilities.cell_gated_create, 'a non-admin root owner reads the pending queue and the post, and moderates it', async () => {
523
+ const { fixture, root, root_owner, author, post } = await setup_stewarded_post();
524
+ const steward_h = root_owner.create_session_headers();
525
+ const stranger = await fixture.create_account({ username: 'steward_stranger' });
526
+ const stranger_h = stranger.create_session_headers();
527
+ const queue = { root_id: root.cell.id, moderation: 'pending' };
528
+ assert.deepStrictEqual(await listed_ids(fixture, queue, steward_h), [post.cell.id]);
529
+ assert.deepStrictEqual(await listed_ids(fixture, queue, stranger_h), []);
530
+ const got = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_get', { id: post.cell.id }, steward_h), CellGetOutput);
531
+ assert.strictEqual(got.cell.id, post.cell.id);
532
+ assert.isFalse(got.can_edit, 'a steward does not edit');
533
+ assert.isFalse(got.can_grant, 'a steward does not manage');
534
+ assert.strictEqual(await outcome(fixture, 'cell_get', { id: post.cell.id }, stranger_h), 'cell_not_found');
535
+ // a self-flip to public still reaches no one past author, steward, and admin
536
+ await set_visibility(fixture, author.create_session_headers(), post.cell.id, 'public');
537
+ assert.strictEqual(await outcome(fixture, 'cell_get', { id: post.cell.id }, null), 'cell_not_found');
538
+ assert.strictEqual(await outcome(fixture, 'cell_get', { id: post.cell.id }, steward_h), 'ok');
539
+ const by_author = await cross_rpc_call(fixture.transport, rpc_path, 'cell_moderate', { cell_id: post.cell.id, moderation: 'approved' }, author.create_session_headers());
540
+ assert.strictEqual(error_reason(by_author), 'cell_moderate_forbidden');
541
+ assert.strictEqual(await outcome(fixture, 'cell_moderate', { cell_id: post.cell.id, moderation: 'approved' }, stranger_h), 'cell_not_found');
542
+ const approved = await moderate(fixture, post.cell.id, 'approved', steward_h);
543
+ assert.strictEqual(approved.moderation, 'approved', 'the root owner moderates');
544
+ assert.strictEqual(approved.visibility, 'public');
545
+ });
546
+ test_if(capabilities.cell_gated_create, 'the steward arm stops at the moderator surfaces: clone and create-under are 404', async () => {
547
+ const { fixture, root_owner, post } = await setup_stewarded_post();
548
+ const steward_h = root_owner.create_session_headers();
549
+ assert.strictEqual(await outcome(fixture, 'cell_clone', { source_id: post.cell.id }, steward_h), 'cell_not_found');
550
+ assert.strictEqual(await outcome(fixture, 'cell_create', { kind: 'comment', data: {}, parent_id: post.cell.id }, steward_h), 'cell_not_found');
551
+ });
552
+ test_if(capabilities.cell_gated_create, 'the steward arm keys on the governing root, not the parent: a rejected comment under an approved post', async () => {
553
+ const { fixture, root_owner, author, post } = await setup_stewarded_post();
554
+ const steward_h = root_owner.create_session_headers();
555
+ await moderate(fixture, post.cell.id, 'approved', steward_h);
556
+ const commenter = await fixture.create_account({
557
+ username: 'steward_commenter',
558
+ roles: [PARTICIPATION_ROLE]
559
+ });
560
+ const commenter_h = commenter.create_session_headers();
561
+ const comment = expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_create', { kind: 'comment', data: {}, parent_id: post.cell.id, visibility: 'public' }, commenter_h), CellCreateOutput);
562
+ assert.strictEqual(comment.cell.root_id, post.cell.root_id, 'governed by the root');
563
+ const rejected = await moderate(fixture, comment.cell.id, 'rejected', steward_h);
564
+ assert.strictEqual(rejected.moderation, 'rejected');
565
+ const get = { id: comment.cell.id };
566
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, steward_h), 'ok', 'root owner');
567
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, author.create_session_headers()), 'cell_not_found', "the parent's author is not the steward");
568
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, commenter_h), 'ok', 'author');
569
+ assert.strictEqual(await outcome(fixture, 'cell_get', get, null), 'cell_not_found', 'anon');
570
+ });
571
+ test_if(capabilities.cell_gated_create && capabilities.fact_serving, "the root owner fetches a pending post's fact; anon 404", async () => {
572
+ const fixture = await setup_test();
573
+ const hash = await put_fact(fixture, 'stewarded-post-fact');
574
+ const { root_owner, post } = await setup_stewarded_post_on(fixture, { cover: hash });
575
+ const path = `/api/cells/${post.cell.id}/facts/${hash}`;
576
+ assert.strictEqual(await fact_status(fixture, path, null), 404, 'anon');
577
+ assert.strictEqual(await fact_status(fixture, path, root_owner.create_session_headers()), 200, 'steward');
578
+ });
579
+ });
580
+ describe('cell_grant_create refuses an unapproved contribution (parity)', () => {
581
+ test_if(capabilities.cell_gated_create, 'a grant on a pending post → 403 cell_grant_unapproved; non-manager 404; lands after approval', async () => {
582
+ const { fixture, author, post } = await setup_pending_post();
583
+ const friend = await fixture.create_account({ username: 'unapproved_friend' });
584
+ const stranger = await fixture.create_account({ username: 'unapproved_stranger' });
585
+ const actor_shaped = {
586
+ cell_id: post.cell.id,
587
+ level: 'viewer',
588
+ principal: { kind: 'actor', actor_id: friend.actor.id }
589
+ };
590
+ const role_shaped = {
591
+ cell_id: post.cell.id,
592
+ level: 'viewer',
593
+ principal: { kind: 'role', role: PARTICIPATION_ROLE }
594
+ };
595
+ for (const headers of [author.create_session_headers(), fixture.create_session_headers()]) {
596
+ for (const params of [actor_shaped, role_shaped]) {
597
+ const refused = await cross_rpc_call(fixture.transport, rpc_path, 'cell_grant_create', params, headers);
598
+ assert.ok(!refused.ok, 'a grant on a pending post must be refused');
599
+ assert.strictEqual(refused.error?.code, JSONRPC_ERROR_CODES.forbidden, 'a 403');
600
+ assert.strictEqual(error_reason(refused), 'cell_grant_unapproved');
601
+ }
602
+ }
603
+ assert.strictEqual(await outcome(fixture, 'cell_grant_create', actor_shaped, stranger.create_session_headers()), 'cell_not_found', 'a non-manager keeps the 404 mask');
604
+ await moderate(fixture, post.cell.id, 'approved');
605
+ for (const params of [actor_shaped, role_shaped]) {
606
+ expect_output(await cross_rpc_call(fixture.transport, rpc_path, 'cell_grant_create', params, author.create_session_headers()), CellGrantCreateOutput);
607
+ }
608
+ });
609
+ });
214
610
  describe('cell_clone runs the creation authorizer (parity)', () => {
215
611
  test_if(capabilities.cell_gated_create, 'a non-admin clone of a viewable `space` root → 403, admin clone succeeds', async () => {
216
612
  const fixture = await setup_test();
@@ -1 +1 @@
1
- {"version":3,"file":"cell_relations.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_relations.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAyF9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAsF1E,eAAO,MAAM,mCAAmC,GAC/C,SAAS,uCAAuC,KAC9C,IAi2BF,CAAC"}
1
+ {"version":3,"file":"cell_relations.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_relations.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8G9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAsF1E,eAAO,MAAM,mCAAmC,GAC/C,SAAS,uCAAuC,KAC9C,IAioCF,CAAC"}