@fuzdev/fuz_app 0.67.1 → 0.69.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 (235) hide show
  1. package/dist/actions/perform_action.d.ts.map +1 -1
  2. package/dist/actions/perform_action.js +10 -3
  3. package/dist/auth/CLAUDE.md +99 -5
  4. package/dist/auth/account_queries.d.ts +87 -4
  5. package/dist/auth/account_queries.d.ts.map +1 -1
  6. package/dist/auth/account_queries.js +107 -17
  7. package/dist/auth/account_schema.d.ts +19 -0
  8. package/dist/auth/account_schema.d.ts.map +1 -1
  9. package/dist/auth/account_schema.js +8 -0
  10. package/dist/auth/admin_action_specs.d.ts +170 -3
  11. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  12. package/dist/auth/admin_action_specs.js +148 -4
  13. package/dist/auth/admin_actions.d.ts +4 -14
  14. package/dist/auth/admin_actions.d.ts.map +1 -1
  15. package/dist/auth/admin_actions.js +246 -40
  16. package/dist/auth/audit_log_ddl.d.ts +10 -1
  17. package/dist/auth/audit_log_ddl.d.ts.map +1 -1
  18. package/dist/auth/audit_log_ddl.js +13 -4
  19. package/dist/auth/audit_log_schema.d.ts +34 -1
  20. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  21. package/dist/auth/audit_log_schema.js +73 -0
  22. package/dist/auth/auth_ddl.d.ts +2 -2
  23. package/dist/auth/auth_ddl.d.ts.map +1 -1
  24. package/dist/auth/auth_ddl.js +10 -2
  25. package/dist/auth/cell_action_specs.d.ts +1295 -0
  26. package/dist/auth/cell_action_specs.d.ts.map +1 -0
  27. package/dist/auth/cell_action_specs.js +397 -0
  28. package/dist/auth/cell_actions.d.ts +63 -0
  29. package/dist/auth/cell_actions.d.ts.map +1 -0
  30. package/dist/auth/cell_actions.js +546 -0
  31. package/dist/auth/cell_audit_action_specs.d.ts +131 -0
  32. package/dist/auth/cell_audit_action_specs.d.ts.map +1 -0
  33. package/dist/auth/cell_audit_action_specs.js +70 -0
  34. package/dist/auth/cell_audit_actions.d.ts +18 -0
  35. package/dist/auth/cell_audit_actions.d.ts.map +1 -0
  36. package/dist/auth/cell_audit_actions.js +59 -0
  37. package/dist/auth/cell_audit_events.d.ts +28 -0
  38. package/dist/auth/cell_audit_events.d.ts.map +1 -0
  39. package/dist/auth/cell_audit_events.js +42 -0
  40. package/dist/auth/cell_audit_metadata.d.ts +48 -0
  41. package/dist/auth/cell_audit_metadata.d.ts.map +1 -0
  42. package/dist/auth/cell_audit_metadata.js +46 -0
  43. package/dist/auth/cell_authorize.d.ts +88 -0
  44. package/dist/auth/cell_authorize.d.ts.map +1 -0
  45. package/dist/auth/cell_authorize.js +172 -0
  46. package/dist/auth/cell_data_schema.d.ts +44 -0
  47. package/dist/auth/cell_data_schema.d.ts.map +1 -0
  48. package/dist/auth/cell_data_schema.js +42 -0
  49. package/dist/auth/cell_field_action_specs.d.ts +244 -0
  50. package/dist/auth/cell_field_action_specs.d.ts.map +1 -0
  51. package/dist/auth/cell_field_action_specs.js +136 -0
  52. package/dist/auth/cell_field_actions.d.ts +34 -0
  53. package/dist/auth/cell_field_actions.d.ts.map +1 -0
  54. package/dist/auth/cell_field_actions.js +153 -0
  55. package/dist/auth/cell_field_audit_metadata.d.ts +30 -0
  56. package/dist/auth/cell_field_audit_metadata.d.ts.map +1 -0
  57. package/dist/auth/cell_field_audit_metadata.js +28 -0
  58. package/dist/auth/cell_grant_action_specs.d.ts +333 -0
  59. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -0
  60. package/dist/auth/cell_grant_action_specs.js +148 -0
  61. package/dist/auth/cell_grant_actions.d.ts +50 -0
  62. package/dist/auth/cell_grant_actions.d.ts.map +1 -0
  63. package/dist/auth/cell_grant_actions.js +208 -0
  64. package/dist/auth/cell_grant_audit_metadata.d.ts +75 -0
  65. package/dist/auth/cell_grant_audit_metadata.d.ts.map +1 -0
  66. package/dist/auth/cell_grant_audit_metadata.js +54 -0
  67. package/dist/auth/cell_item_action_specs.d.ts +331 -0
  68. package/dist/auth/cell_item_action_specs.d.ts.map +1 -0
  69. package/dist/auth/cell_item_action_specs.js +182 -0
  70. package/dist/auth/cell_item_actions.d.ts +37 -0
  71. package/dist/auth/cell_item_actions.d.ts.map +1 -0
  72. package/dist/auth/cell_item_actions.js +204 -0
  73. package/dist/auth/cell_item_audit_metadata.d.ts +35 -0
  74. package/dist/auth/cell_item_audit_metadata.d.ts.map +1 -0
  75. package/dist/auth/cell_item_audit_metadata.js +32 -0
  76. package/dist/auth/cell_relation_visibility.d.ts +32 -0
  77. package/dist/auth/cell_relation_visibility.d.ts.map +1 -0
  78. package/dist/auth/cell_relation_visibility.js +57 -0
  79. package/dist/auth/deps.d.ts +9 -0
  80. package/dist/auth/deps.d.ts.map +1 -1
  81. package/dist/auth/role_grant_queries.d.ts +30 -0
  82. package/dist/auth/role_grant_queries.d.ts.map +1 -1
  83. package/dist/auth/role_grant_queries.js +54 -0
  84. package/dist/auth/signup_routes.d.ts +0 -3
  85. package/dist/auth/signup_routes.d.ts.map +1 -1
  86. package/dist/auth/signup_routes.js +9 -3
  87. package/dist/auth/standard_rpc_actions.d.ts +5 -5
  88. package/dist/auth/standard_rpc_actions.js +4 -4
  89. package/dist/db/CLAUDE.md +118 -0
  90. package/dist/db/cell_audit_queries.d.ts +26 -0
  91. package/dist/db/cell_audit_queries.d.ts.map +1 -0
  92. package/dist/db/cell_audit_queries.js +53 -0
  93. package/dist/db/cell_ddl.d.ts +151 -0
  94. package/dist/db/cell_ddl.d.ts.map +1 -0
  95. package/dist/db/cell_ddl.js +247 -0
  96. package/dist/db/cell_field_queries.d.ts +105 -0
  97. package/dist/db/cell_field_queries.d.ts.map +1 -0
  98. package/dist/db/cell_field_queries.js +113 -0
  99. package/dist/db/cell_grant_queries.d.ts +132 -0
  100. package/dist/db/cell_grant_queries.d.ts.map +1 -0
  101. package/dist/db/cell_grant_queries.js +145 -0
  102. package/dist/db/cell_history_ddl.d.ts +38 -0
  103. package/dist/db/cell_history_ddl.d.ts.map +1 -0
  104. package/dist/db/cell_history_ddl.js +61 -0
  105. package/dist/db/cell_item_queries.d.ts +107 -0
  106. package/dist/db/cell_item_queries.d.ts.map +1 -0
  107. package/dist/db/cell_item_queries.js +119 -0
  108. package/dist/db/cell_queries.d.ts +327 -0
  109. package/dist/db/cell_queries.d.ts.map +1 -0
  110. package/dist/db/cell_queries.js +431 -0
  111. package/dist/db/fact_ddl.d.ts +38 -0
  112. package/dist/db/fact_ddl.d.ts.map +1 -0
  113. package/dist/db/fact_ddl.js +71 -0
  114. package/dist/db/fact_queries.d.ts +140 -0
  115. package/dist/db/fact_queries.d.ts.map +1 -0
  116. package/dist/db/fact_queries.js +161 -0
  117. package/dist/db/fact_store.d.ts +112 -0
  118. package/dist/db/fact_store.d.ts.map +1 -0
  119. package/dist/db/fact_store.js +225 -0
  120. package/dist/server/app_server.d.ts +1 -7
  121. package/dist/server/app_server.d.ts.map +1 -1
  122. package/dist/server/app_server.js +1 -5
  123. package/dist/server/env.d.ts +2 -0
  124. package/dist/server/env.d.ts.map +1 -1
  125. package/dist/server/env.js +6 -0
  126. package/dist/server/fact_write.d.ts +32 -0
  127. package/dist/server/fact_write.d.ts.map +1 -0
  128. package/dist/server/fact_write.js +56 -0
  129. package/dist/server/file_fact_fetcher.d.ts +42 -0
  130. package/dist/server/file_fact_fetcher.d.ts.map +1 -0
  131. package/dist/server/file_fact_fetcher.js +60 -0
  132. package/dist/server/file_fact_url.d.ts +53 -0
  133. package/dist/server/file_fact_url.d.ts.map +1 -0
  134. package/dist/server/file_fact_url.js +52 -0
  135. package/dist/server/serve_fact_route.d.ts +78 -0
  136. package/dist/server/serve_fact_route.d.ts.map +1 -0
  137. package/dist/server/serve_fact_route.js +205 -0
  138. package/dist/testing/CLAUDE.md +142 -6
  139. package/dist/testing/app_server.d.ts +46 -0
  140. package/dist/testing/app_server.d.ts.map +1 -1
  141. package/dist/testing/app_server.js +67 -8
  142. package/dist/testing/audit_completeness.d.ts.map +1 -1
  143. package/dist/testing/audit_completeness.js +67 -1
  144. package/dist/testing/cross_backend/account_lifecycle.d.ts +10 -0
  145. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -0
  146. package/dist/testing/cross_backend/account_lifecycle.js +144 -0
  147. package/dist/testing/cross_backend/actor_lookup.d.ts +10 -0
  148. package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -0
  149. package/dist/testing/cross_backend/actor_lookup.js +83 -0
  150. package/dist/testing/cross_backend/actor_search.d.ts +6 -0
  151. package/dist/testing/cross_backend/actor_search.d.ts.map +1 -0
  152. package/dist/testing/cross_backend/actor_search.js +92 -0
  153. package/dist/testing/cross_backend/app_settings.d.ts +6 -0
  154. package/dist/testing/cross_backend/app_settings.d.ts.map +1 -0
  155. package/dist/testing/cross_backend/app_settings.js +95 -0
  156. package/dist/testing/cross_backend/backend_config.d.ts +1 -1
  157. package/dist/testing/cross_backend/capabilities.d.ts +29 -7
  158. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  159. package/dist/testing/cross_backend/capabilities.js +3 -1
  160. package/dist/testing/cross_backend/cell_cross_helpers.d.ts +39 -0
  161. package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -0
  162. package/dist/testing/cross_backend/cell_cross_helpers.js +45 -0
  163. package/dist/testing/cross_backend/cell_crud.d.ts +4 -0
  164. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -0
  165. package/dist/testing/cross_backend/cell_crud.js +168 -0
  166. package/dist/testing/cross_backend/cell_grant_role.d.ts +8 -0
  167. package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -0
  168. package/dist/testing/cross_backend/cell_grant_role.js +102 -0
  169. package/dist/testing/cross_backend/cell_relations.d.ts +4 -0
  170. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -0
  171. package/dist/testing/cross_backend/cell_relations.js +229 -0
  172. package/dist/testing/cross_backend/conformance_case.d.ts +144 -0
  173. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -0
  174. package/dist/testing/cross_backend/conformance_case.js +132 -0
  175. package/dist/testing/cross_backend/conformance_table.d.ts +46 -0
  176. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -0
  177. package/dist/testing/cross_backend/conformance_table.js +199 -0
  178. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  179. package/dist/testing/cross_backend/default_backend_configs.js +6 -2
  180. package/dist/testing/cross_backend/default_spine_surface.d.ts +17 -9
  181. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  182. package/dist/testing/cross_backend/default_spine_surface.js +20 -12
  183. package/dist/testing/cross_backend/origin.d.ts +10 -0
  184. package/dist/testing/cross_backend/origin.d.ts.map +1 -0
  185. package/dist/testing/cross_backend/origin.js +73 -0
  186. package/dist/testing/cross_backend/setup.d.ts +22 -40
  187. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  188. package/dist/testing/cross_backend/setup.js +39 -5
  189. package/dist/testing/cross_backend/testing_reset_actions.d.ts +90 -2
  190. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  191. package/dist/testing/cross_backend/testing_reset_actions.js +91 -3
  192. package/dist/testing/cross_backend/xfail.d.ts +15 -0
  193. package/dist/testing/cross_backend/xfail.d.ts.map +1 -0
  194. package/dist/testing/cross_backend/xfail.js +37 -0
  195. package/dist/testing/entities.d.ts.map +1 -1
  196. package/dist/testing/entities.js +4 -0
  197. package/dist/testing/integration.d.ts +2 -3
  198. package/dist/testing/integration.d.ts.map +1 -1
  199. package/dist/testing/integration.js +20 -85
  200. package/dist/testing/rate_limiting.d.ts +1 -1
  201. package/dist/testing/rpc_helpers.d.ts +3 -3
  202. package/dist/testing/sse_round_trip.d.ts +1 -1
  203. package/dist/testing/stubs.d.ts.map +1 -1
  204. package/dist/testing/stubs.js +0 -1
  205. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  206. package/dist/testing/ws_round_trip.js +4 -0
  207. package/dist/ui/AdminAccounts.svelte +84 -35
  208. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  209. package/dist/ui/AdminSessions.svelte +21 -23
  210. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  211. package/dist/ui/CLAUDE.md +17 -26
  212. package/dist/ui/OpenSignupToggle.svelte +2 -5
  213. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  214. package/dist/ui/account_sessions_state.svelte.d.ts +9 -10
  215. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  216. package/dist/ui/account_sessions_state.svelte.js +7 -17
  217. package/dist/ui/admin_accounts_state.svelte.d.ts +41 -20
  218. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  219. package/dist/ui/admin_accounts_state.svelte.js +52 -22
  220. package/dist/ui/admin_invites_state.svelte.d.ts +8 -11
  221. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  222. package/dist/ui/admin_invites_state.svelte.js +7 -16
  223. package/dist/ui/admin_rpc_adapters.d.ts +6 -2
  224. package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
  225. package/dist/ui/admin_rpc_adapters.js +5 -1
  226. package/dist/ui/admin_sessions_state.svelte.d.ts +6 -10
  227. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  228. package/dist/ui/admin_sessions_state.svelte.js +4 -14
  229. package/dist/ui/app_settings_state.svelte.d.ts +8 -12
  230. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  231. package/dist/ui/app_settings_state.svelte.js +6 -16
  232. package/dist/ui/audit_log_state.svelte.d.ts +9 -8
  233. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  234. package/dist/ui/audit_log_state.svelte.js +8 -20
  235. package/package.json +2 -2
@@ -0,0 +1,168 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Dedicated stateful cell-CRUD parity suite for the cross-backend harness.
4
+ *
5
+ * The generic `describe_rpc_round_trip_tests` can't cover cells: the verbs
6
+ * are stateful (update / delete / get-by-id need a real cell id threaded
7
+ * from a prior create) and `cell_get`'s input has a top-level `.refine()`.
8
+ * So cells stay off the standard declared surface (`create_spine_surface_spec`)
9
+ * — exactly like ws / sse — and this suite plus its sibling
10
+ * `describe_cell_relations_cross_tests` (grant / field / item / clone / audit)
11
+ * are the cell validators. This one gates on `capabilities.cell_crud`; it runs
12
+ * against any backend that live-mounts the cell surface (the TS spine binary,
13
+ * the in-process Hono app, and the Rust `testing_spine_stub`).
14
+ *
15
+ * Drives the full lifecycle (create → get → update → delete → list, threading
16
+ * the created id) plus the authz matrix the wire contract guarantees. Every
17
+ * success response is parsed against the verb's declared Zod **output** schema
18
+ * (`CellCreateOutput` / `CellGetOutput` / …), so a TS↔Rust envelope drift —
19
+ * not just a `CellJson` field drift — fails the suite:
20
+ *
21
+ * - owner does full CRUD; responses match the output schemas exactly;
22
+ * - anon sees `public` cells only — `private` is 404 (existence not leaked);
23
+ * - a non-owner non-admin editing / reading / deleting another's private cell
24
+ * gets 404 (IDOR mask), never 403;
25
+ * - admin reaches any cell;
26
+ * - duplicate active `path` → 409 (`cell_path_taken`);
27
+ * - `path` write by a non-admin → 403 (`cell_path_admin_only`), on both create
28
+ * and update (even by the owner);
29
+ * - `cell_get` with neither `id` nor `path` → `invalid_params`;
30
+ * - null-auth `cell_list` with `created_by` → `invalid_params`.
31
+ *
32
+ * The visibility-manage-tier 403 (`cell_visibility_manage_only`) needs a
33
+ * non-owner editor, which only a `cell_grant` can produce, so it lives in
34
+ * `describe_cell_relations_cross_tests` alongside the grant verbs rather than
35
+ * here.
36
+ *
37
+ * `$lib`-free by contract (relative specifiers only) so the suite can be
38
+ * imported from the spawnable cross-process test files.
39
+ *
40
+ * @module
41
+ */
42
+ import { describe, assert } from 'vitest';
43
+ import { CellCreateOutput, CellDeleteOutput, CellGetOutput, CellListOutput, CellUpdateOutput, } from '../../auth/cell_action_specs.js';
44
+ import { test_if } from './capabilities.js';
45
+ import { cross_rpc_call, error_reason, expect_output, } from './cell_cross_helpers.js';
46
+ import { SPINE_RPC_PATH } from './default_spine_surface.js';
47
+ export const describe_cell_crud_cross_tests = (options) => {
48
+ const { setup_test, capabilities } = options;
49
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
50
+ describe('cell CRUD parity', () => {
51
+ test_if(capabilities.cell_crud, 'owner lifecycle: create → get → update → delete → list', async () => {
52
+ const fixture = await setup_test();
53
+ const owner = await fixture.create_account({ username: 'cell_owner' });
54
+ const t = fixture.fresh_transport();
55
+ const owner_headers = owner.create_session_headers();
56
+ // create — default visibility, owner-stamped, no grants
57
+ const created = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note', label: 'hi' } }, owner_headers), CellCreateOutput);
58
+ assert.strictEqual(created.cell.visibility, 'private');
59
+ assert.strictEqual(created.cell.created_by, owner.actor.id);
60
+ assert.strictEqual(created.cell.updated_by, null);
61
+ assert.strictEqual(created.cell.grant_count, 0);
62
+ const cell_id = created.cell.id;
63
+ // get by id — full CellGetOutput envelope; relations empty in the first cut
64
+ const got = expect_output(await cross_rpc_call(t, rpc_path, 'cell_get', { id: cell_id }, owner_headers), CellGetOutput);
65
+ assert.strictEqual(got.cell.id, cell_id);
66
+ assert.deepStrictEqual(got.fields, []);
67
+ assert.deepStrictEqual(got.items, []);
68
+ assert.strictEqual(got.fields_truncated, false);
69
+ assert.strictEqual(got.items_truncated, false);
70
+ assert.strictEqual(got.can_edit, true);
71
+ assert.strictEqual(got.can_grant, true); // owner is manage-tier
72
+ // update data + flip to public (owner is manage-tier, so visibility write is allowed)
73
+ const updated = expect_output(await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id, data: { kind: 'note', label: 'hi2' }, visibility: 'public' }, owner_headers), CellUpdateOutput);
74
+ assert.strictEqual(updated.cell.visibility, 'public');
75
+ assert.strictEqual(updated.cell.updated_by, owner.actor.id);
76
+ // list includes it
77
+ const listed = expect_output(await cross_rpc_call(t, rpc_path, 'cell_list', {}, owner_headers), CellListOutput);
78
+ assert.ok(listed.cells.some((c) => c.id === cell_id), 'owner cell_list omitted the cell');
79
+ // delete → subsequent get is 404
80
+ const deleted = expect_output(await cross_rpc_call(t, rpc_path, 'cell_delete', { cell_id }, owner_headers), CellDeleteOutput);
81
+ assert.strictEqual(deleted.deleted, true);
82
+ const gone = await cross_rpc_call(t, rpc_path, 'cell_get', { id: cell_id }, owner_headers);
83
+ assert.ok(!gone.ok, 'deleted cell still readable');
84
+ assert.strictEqual(error_reason(gone), 'cell_not_found');
85
+ });
86
+ test_if(capabilities.cell_crud, 'anon sees public cells only; private is 404', async () => {
87
+ const fixture = await setup_test();
88
+ const owner = await fixture.create_account({ username: 'cell_anon_owner' });
89
+ const owner_headers = owner.create_session_headers();
90
+ const authed = fixture.fresh_transport();
91
+ const priv = expect_output(await cross_rpc_call(authed, rpc_path, 'cell_create', { data: { kind: 'note' }, visibility: 'private' }, owner_headers), CellCreateOutput).cell;
92
+ const pub = expect_output(await cross_rpc_call(authed, rpc_path, 'cell_create', { data: { kind: 'note' }, visibility: 'public' }, owner_headers), CellCreateOutput).cell;
93
+ const anon = fixture.fresh_transport({ origin: null });
94
+ const anon_pub = await cross_rpc_call(anon, rpc_path, 'cell_get', { id: pub.id }, {});
95
+ assert.ok(anon_pub.ok, `anon could not read public cell: ${JSON.stringify(anon_pub.error)}`);
96
+ const anon_priv = await cross_rpc_call(anon, rpc_path, 'cell_get', { id: priv.id }, {});
97
+ assert.ok(!anon_priv.ok, 'anon read a private cell');
98
+ assert.strictEqual(error_reason(anon_priv), 'cell_not_found');
99
+ const anon_list = expect_output(await cross_rpc_call(anon, rpc_path, 'cell_list', {}, {}), CellListOutput);
100
+ const anon_ids = anon_list.cells.map((c) => c.id);
101
+ assert.ok(anon_ids.includes(pub.id), 'anon list missing public cell');
102
+ assert.ok(!anon_ids.includes(priv.id), 'anon list leaked private cell');
103
+ });
104
+ test_if(capabilities.cell_crud, 'non-owner edit/read/delete of a private cell → 404 (IDOR mask)', async () => {
105
+ const fixture = await setup_test();
106
+ const owner = await fixture.create_account({ username: 'cell_idor_owner' });
107
+ const other = await fixture.create_account({ username: 'cell_idor_other' });
108
+ const t = fixture.fresh_transport();
109
+ const priv = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner.create_session_headers()), CellCreateOutput).cell;
110
+ const other_headers = other.create_session_headers();
111
+ const read = await cross_rpc_call(t, rpc_path, 'cell_get', { id: priv.id }, other_headers);
112
+ assert.strictEqual(error_reason(read), 'cell_not_found');
113
+ const edit = await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id: priv.id, data: { kind: 'note', label: 'x' } }, other_headers);
114
+ assert.ok(!edit.ok, 'non-owner edited a private cell');
115
+ assert.strictEqual(error_reason(edit), 'cell_not_found');
116
+ const del = await cross_rpc_call(t, rpc_path, 'cell_delete', { cell_id: priv.id }, other_headers);
117
+ assert.ok(!del.ok, 'non-owner deleted a private cell');
118
+ assert.strictEqual(error_reason(del), 'cell_not_found');
119
+ });
120
+ test_if(capabilities.cell_crud, 'admin (keeper) reaches another actor’s private cell', async () => {
121
+ const fixture = await setup_test();
122
+ const owner = await fixture.create_account({ username: 'cell_admin_owner' });
123
+ const t = fixture.fresh_transport();
124
+ const priv = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner.create_session_headers()), CellCreateOutput).cell;
125
+ // `fixture` is the bootstrapped keeper (holds ROLE_ADMIN).
126
+ const admin_read = expect_output(await cross_rpc_call(t, rpc_path, 'cell_get', { id: priv.id }, fixture.create_session_headers()), CellGetOutput);
127
+ assert.strictEqual(admin_read.cell.id, priv.id);
128
+ });
129
+ test_if(capabilities.cell_crud, 'duplicate active path → 409 conflict', async () => {
130
+ const fixture = await setup_test();
131
+ const t = fixture.fresh_transport();
132
+ // `path` writes are admin-only; the keeper is admin.
133
+ const admin_headers = fixture.create_session_headers();
134
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' }, path: 'parity/dup' }, admin_headers), CellCreateOutput);
135
+ const dup = await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' }, path: 'parity/dup' }, admin_headers);
136
+ assert.ok(!dup.ok, 'duplicate path was accepted');
137
+ assert.strictEqual(error_reason(dup), 'cell_path_taken');
138
+ });
139
+ test_if(capabilities.cell_crud, 'path write by non-admin → 403 (create and update)', async () => {
140
+ const fixture = await setup_test();
141
+ const owner = await fixture.create_account({ username: 'cell_path_owner' });
142
+ const t = fixture.fresh_transport();
143
+ const owner_headers = owner.create_session_headers();
144
+ const create_with_path = await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' }, path: 'parity/forbidden' }, owner_headers);
145
+ assert.ok(!create_with_path.ok, 'non-admin set a path on create');
146
+ assert.strictEqual(error_reason(create_with_path), 'cell_path_admin_only');
147
+ // Even owning the cell, a non-admin cannot write `path` on update.
148
+ const owned = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner_headers), CellCreateOutput).cell;
149
+ const update_path = await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id: owned.id, path: 'parity/owned' }, owner_headers);
150
+ assert.ok(!update_path.ok, 'non-admin set a path on update');
151
+ assert.strictEqual(error_reason(update_path), 'cell_path_admin_only');
152
+ });
153
+ test_if(capabilities.cell_crud, 'cell_get without id or path → invalid_params', async () => {
154
+ const fixture = await setup_test();
155
+ const bad = await cross_rpc_call(fixture.fresh_transport(), rpc_path, 'cell_get', {}, fixture.create_session_headers());
156
+ assert.ok(!bad.ok, 'cell_get with empty params succeeded');
157
+ // -32602 invalid_params (refine or handler guard).
158
+ assert.strictEqual(bad.error?.code, -32602);
159
+ });
160
+ test_if(capabilities.cell_crud, 'null-auth cell_list with created_by → invalid_params', async () => {
161
+ const fixture = await setup_test();
162
+ const anon = fixture.fresh_transport({ origin: null });
163
+ const bad = await cross_rpc_call(anon, rpc_path, 'cell_list', { created_by: fixture.actor.id }, {});
164
+ assert.ok(!bad.ok, 'anon created_by filter accepted');
165
+ assert.strictEqual(error_reason(bad), 'cell_list_created_by_requires_auth');
166
+ });
167
+ });
168
+ };
@@ -0,0 +1,8 @@
1
+ import '../assert_dev_env.js';
2
+ import { type CellCrossTestOptions } from './cell_cross_helpers.js';
3
+ /** App role the holder is seeded with; matches the spine's registered role. */
4
+ export declare const CELL_EDITOR_ROLE = "cell_editor";
5
+ /** Username the fixture seeds (via `extra_accounts`) holding `CELL_EDITOR_ROLE`. */
6
+ export declare const CELL_ROLE_HOLDER_USERNAME = "cell_role_holder";
7
+ export declare const describe_cell_grant_role_cross_tests: (options: CellCrossTestOptions) => void;
8
+ //# sourceMappingURL=cell_grant_role.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cell_grant_role.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_grant_role.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA6C9B,OAAO,EACN,KAAK,oBAAoB,EAIzB,MAAM,yBAAyB,CAAC;AAGjC,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,gBAAyB,CAAC;AAEvD,oFAAoF;AACpF,eAAO,MAAM,yBAAyB,qBAAqB,CAAC;AAK5D,eAAO,MAAM,oCAAoC,GAAI,SAAS,oBAAoB,KAAG,IAuIpF,CAAC"}
@@ -0,0 +1,102 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for **role-shaped** `cell_grant`s.
4
+ *
5
+ * The cell CRUD / relations suites exercise only actor-shaped grant
6
+ * principals. This suite covers the role-shaped path and its closed-registry
7
+ * gate — the security-correctness property that the Rust spine previously
8
+ * lacked (it created inert grant rows for any role string). Both spines now
9
+ * validate the role against a closed registry at create.
10
+ *
11
+ * - **role grant admits a holder; excludes a non-holder** — an owner grants
12
+ * `{role}` on a private cell; an account holding that role can `cell_get`
13
+ * it (200), an account without it gets the IDOR-mask 404.
14
+ * - **unknown role rejected at create (security-correctness)** — granting a
15
+ * role outside the registry is `invalid_params` / `cell_grant_unknown_role`,
16
+ * not a silent inert row.
17
+ * - **editor-level role grant admits edit** — a holder of an `editor`-level
18
+ * role grant can `cell_update` the cell's content.
19
+ *
20
+ * The holder is seeded via `extra_accounts` under `CELL_ROLE_HOLDER_USERNAME`
21
+ * holding `CELL_EDITOR_ROLE` (the role has no grant path, so it can't be
22
+ * offered — the bootstrap-cradle seed is the only path). Both legs configure
23
+ * that seed and register `CELL_EDITOR_ROLE` in their role registry; the Rust
24
+ * `testing_spine_stub` mirrors the same membership in its `known_roles`.
25
+ *
26
+ * Cites `security.md` §Authorization (role-shaped cell-grant validation).
27
+ * Runs both legs via the shared `{setup_test}` protocol: in-process
28
+ * (`auth/cell_grant_role_parity.db.test.ts`) + cross-process
29
+ * (`cross_backend/cell_grant_role.cross.test.ts`). Gated on
30
+ * `capabilities.cell_relations` (true on every spine, so it never skips).
31
+ *
32
+ * `$lib`-free by contract (relative specifiers only).
33
+ *
34
+ * @module
35
+ */
36
+ import { describe, assert } from 'vitest';
37
+ import { CellCreateOutput, CellGetOutput, CellUpdateOutput } from '../../auth/cell_action_specs.js';
38
+ import { CellGrantCreateOutput, ERROR_CELL_GRANT_UNKNOWN_ROLE, } from '../../auth/cell_grant_action_specs.js';
39
+ import { test_if } from './capabilities.js';
40
+ import { cross_rpc_call, error_reason, expect_output, } from './cell_cross_helpers.js';
41
+ import { SPINE_CELL_EDITOR_ROLE, SPINE_RPC_PATH } from './default_spine_surface.js';
42
+ /** App role the holder is seeded with; matches the spine's registered role. */
43
+ export const CELL_EDITOR_ROLE = SPINE_CELL_EDITOR_ROLE;
44
+ /** Username the fixture seeds (via `extra_accounts`) holding `CELL_EDITOR_ROLE`. */
45
+ export const CELL_ROLE_HOLDER_USERNAME = 'cell_role_holder';
46
+ /** A role string deliberately absent from the registry. */
47
+ const UNREGISTERED_ROLE = 'not_a_registered_role';
48
+ export const describe_cell_grant_role_cross_tests = (options) => {
49
+ const { setup_test, capabilities } = options;
50
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
51
+ describe('cell_grant role-shaped parity', () => {
52
+ test_if(capabilities.cell_relations, 'role grant admits a holder of the role and excludes a non-holder', async () => {
53
+ const fixture = await setup_test();
54
+ const t = fixture.transport;
55
+ const owner = await fixture.create_account({ username: 'cell_role_owner' });
56
+ const owner_h = owner.create_session_headers();
57
+ const holder = fixture.extra_accounts[CELL_ROLE_HOLDER_USERNAME];
58
+ assert.ok(holder, `fixture must seed the ${CELL_ROLE_HOLDER_USERNAME} extra account`);
59
+ const stranger = await fixture.create_account({ username: 'cell_role_stranger' });
60
+ // Owner creates a private cell (default visibility) and grants
61
+ // view access to anyone holding CELL_EDITOR_ROLE.
62
+ const created = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner_h), CellCreateOutput);
63
+ const cell_id = created.cell.id;
64
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_create', { cell_id, level: 'viewer', principal: { kind: 'role', role: CELL_EDITOR_ROLE } }, owner_h), CellGrantCreateOutput);
65
+ // Holder of the role is admitted through the role-shaped grant.
66
+ const holder_view = expect_output(await cross_rpc_call(t, rpc_path, 'cell_get', { id: cell_id }, holder.create_session_headers()), CellGetOutput);
67
+ assert.strictEqual(holder_view.cell.id, cell_id, 'holder sees the granted cell');
68
+ // A non-holder sees the IDOR-mask 404 — the grant keys on the role,
69
+ // not mere authentication.
70
+ const stranger_view = await cross_rpc_call(t, rpc_path, 'cell_get', { id: cell_id }, stranger.create_session_headers());
71
+ assert.ok(!stranger_view.ok, 'non-holder must not see the cell');
72
+ assert.strictEqual(error_reason(stranger_view), 'cell_not_found');
73
+ });
74
+ test_if(capabilities.cell_relations, 'role-shaped grant for an unregistered role is rejected at create', async () => {
75
+ const fixture = await setup_test();
76
+ const t = fixture.transport;
77
+ const owner = await fixture.create_account({ username: 'cell_unknown_role_owner' });
78
+ const owner_h = owner.create_session_headers();
79
+ const created = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner_h), CellCreateOutput);
80
+ const denied = await cross_rpc_call(t, rpc_path, 'cell_grant_create', {
81
+ cell_id: created.cell.id,
82
+ level: 'viewer',
83
+ principal: { kind: 'role', role: UNREGISTERED_ROLE },
84
+ }, owner_h);
85
+ assert.ok(!denied.ok, 'granting an unregistered role must fail');
86
+ assert.strictEqual(error_reason(denied), ERROR_CELL_GRANT_UNKNOWN_ROLE);
87
+ });
88
+ test_if(capabilities.cell_relations, 'editor-level role grant admits content edit', async () => {
89
+ const fixture = await setup_test();
90
+ const t = fixture.transport;
91
+ const owner = await fixture.create_account({ username: 'cell_role_edit_owner' });
92
+ const owner_h = owner.create_session_headers();
93
+ const holder = fixture.extra_accounts[CELL_ROLE_HOLDER_USERNAME];
94
+ assert.ok(holder, `fixture must seed the ${CELL_ROLE_HOLDER_USERNAME} extra account`);
95
+ const created = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner_h), CellCreateOutput);
96
+ const cell_id = created.cell.id;
97
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_create', { cell_id, level: 'editor', principal: { kind: 'role', role: CELL_EDITOR_ROLE } }, owner_h), CellGrantCreateOutput);
98
+ const edited = expect_output(await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id, data: { kind: 'note', label: 'by role editor' } }, holder.create_session_headers()), CellUpdateOutput);
99
+ assert.strictEqual(edited.cell.updated_by, holder.actor.id, 'edit attributed to the holder');
100
+ });
101
+ });
102
+ };
@@ -0,0 +1,4 @@
1
+ import '../assert_dev_env.js';
2
+ import { type CellCrossTestOptions } from './cell_cross_helpers.js';
3
+ export declare const describe_cell_relations_cross_tests: (options: CellCrossTestOptions) => void;
4
+ //# sourceMappingURL=cell_relations.d.ts.map
@@ -0,0 +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;AAkE9B,OAAO,EAIN,KAAK,oBAAoB,EACzB,MAAM,yBAAyB,CAAC;AAGjC,eAAO,MAAM,mCAAmC,GAAI,SAAS,oBAAoB,KAAG,IAsfnF,CAAC"}
@@ -0,0 +1,229 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Dedicated cell relation / ACL / audit parity suite for the cross-backend
4
+ * harness — the sibling of `describe_cell_crud_cross_tests` covering every
5
+ * cell verb beyond plain CRUD: `cell_grant_*`, `cell_field_*`,
6
+ * `cell_item_*`, `cell_clone`, and `cell_audit_list`.
7
+ *
8
+ * Like the CRUD suite (and ws / sse), these verbs are live-mounted on the
9
+ * spine RPC path but stay off the standard declared surface, so the generic
10
+ * `describe_rpc_round_trip_tests` never drives them. Every success response
11
+ * is parsed against the verb's declared Zod **output** schema, so a TS↔Rust
12
+ * envelope drift fails the suite — not just a payload-field drift.
13
+ *
14
+ * Coverage (gated on `capabilities.cell_relations`):
15
+ *
16
+ * - **grant lifecycle** — owner grants an actor-shaped editor; the grantee
17
+ * gains edit (was 404 before the grant); `cell_grant_list` is manage-tier
18
+ * (owner sees it, the editor gets the IDOR 404); revoke drops the edit path.
19
+ * - **`cell_visibility_manage_only`** — the editor-grant holder can edit
20
+ * content but flipping `visibility` is 403 (the case the 5-verb CRUD cut
21
+ * couldn't reach without a grant principal).
22
+ * - **fields** — `cell_field_set` UPSERT, forward + reverse `cell_field_list`,
23
+ * idempotent `cell_field_delete`.
24
+ * - **items** — `cell_item_insert` at fractional-index positions, forward
25
+ * (lex-ordered) + reverse `cell_item_list`, `cell_item_move`, idempotent
26
+ * `cell_item_delete`.
27
+ * - **clone** — shallow copies item / field *edges* (shared `child_id` /
28
+ * `target_id`); deep clones each viewable child into a fresh cell at the
29
+ * same position. Both null `path` and stamp the caller as owner.
30
+ * - **audit** — `cell_audit_list` is manage-tier: the owner reads the cell's
31
+ * timeline; a viewer-grant holder who can `cell_get` the cell still gets the
32
+ * IDOR 404 on the timeline (D14).
33
+ *
34
+ * Only **actor-shaped** grants are exercised — role-shaped principals need a
35
+ * closed role registry the Rust spine deliberately lacks, so role-grant
36
+ * parity is out of scope here (the TS impl covers it in-process).
37
+ *
38
+ * `$lib`-free by contract (relative specifiers only) so the suite can be
39
+ * imported from the spawnable cross-process test files.
40
+ *
41
+ * @module
42
+ */
43
+ import { describe, assert } from 'vitest';
44
+ import { fractional_index_between } from '@fuzdev/fuz_util/fractional_index.js';
45
+ import { CellCreateOutput, CellUpdateOutput, CellCloneOutput } from '../../auth/cell_action_specs.js';
46
+ import { CellGrantCreateOutput, CellGrantListOutput, CellGrantRevokeOutput, } from '../../auth/cell_grant_action_specs.js';
47
+ import { CellFieldDeleteOutput, CellFieldListOutput, CellFieldSetOutput, } from '../../auth/cell_field_action_specs.js';
48
+ import { CellItemDeleteOutput, CellItemInsertOutput, CellItemListOutput, CellItemMoveOutput, } from '../../auth/cell_item_action_specs.js';
49
+ import { CellAuditListOutput } from '../../auth/cell_audit_action_specs.js';
50
+ import { test_if } from './capabilities.js';
51
+ import { cross_rpc_call, error_reason, expect_output, } from './cell_cross_helpers.js';
52
+ import { SPINE_RPC_PATH } from './default_spine_surface.js';
53
+ export const describe_cell_relations_cross_tests = (options) => {
54
+ const { setup_test, capabilities } = options;
55
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
56
+ describe('cell relations parity', () => {
57
+ test_if(capabilities.cell_relations, 'grant lifecycle: editor grant enables edit; grant_list is manage-tier; revoke drops it', async () => {
58
+ const fixture = await setup_test();
59
+ const owner = await fixture.create_account({ username: 'cell_grant_owner' });
60
+ const editor = await fixture.create_account({ username: 'cell_grant_editor' });
61
+ const t = fixture.fresh_transport();
62
+ const owner_h = owner.create_session_headers();
63
+ const editor_h = editor.create_session_headers();
64
+ const cell = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner_h), CellCreateOutput).cell;
65
+ // Before the grant the editor can't even see the private cell.
66
+ const pre = await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id: cell.id, data: { kind: 'note', label: 'x' } }, editor_h);
67
+ assert.ok(!pre.ok, 'non-grantee edited a private cell');
68
+ assert.strictEqual(error_reason(pre), 'cell_not_found');
69
+ // Owner (manage-tier) grants the editor an actor-shaped editor grant.
70
+ const grant = expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_create', {
71
+ cell_id: cell.id,
72
+ level: 'editor',
73
+ principal: { kind: 'actor', actor_id: editor.actor.id },
74
+ }, owner_h), CellGrantCreateOutput).grant;
75
+ assert.strictEqual(grant.level, 'editor');
76
+ assert.strictEqual(grant.actor_id, editor.actor.id);
77
+ assert.strictEqual(grant.role, null);
78
+ // The editor can now edit content.
79
+ const edited = expect_output(await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id: cell.id, data: { kind: 'note', label: 'by editor' } }, editor_h), CellUpdateOutput);
80
+ assert.strictEqual(edited.cell.updated_by, editor.actor.id);
81
+ // grant_list is manage-tier: owner sees the grant.
82
+ const owner_grants = expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_list', { cell_id: cell.id }, owner_h), CellGrantListOutput);
83
+ assert.ok(owner_grants.grants.some((g) => g.id === grant.id), 'owner grant_list omitted the grant');
84
+ // The editor (non-manage) gets the IDOR 404 on grant_list.
85
+ const editor_grants = await cross_rpc_call(t, rpc_path, 'cell_grant_list', { cell_id: cell.id }, editor_h);
86
+ assert.ok(!editor_grants.ok, 'editor read the grant list');
87
+ assert.strictEqual(error_reason(editor_grants), 'cell_not_found');
88
+ // Revoke and confirm the edit path is gone.
89
+ const revoked = expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_revoke', { grant_id: grant.id }, owner_h), CellGrantRevokeOutput);
90
+ assert.strictEqual(revoked.ok, true);
91
+ const post = await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id: cell.id, data: { kind: 'note', label: 'y' } }, editor_h);
92
+ assert.ok(!post.ok, 'revoked editor still edited');
93
+ assert.strictEqual(error_reason(post), 'cell_not_found');
94
+ });
95
+ test_if(capabilities.cell_relations, 'editor-grant holder cannot flip visibility → cell_visibility_manage_only', async () => {
96
+ const fixture = await setup_test();
97
+ const owner = await fixture.create_account({ username: 'cell_vis_owner' });
98
+ const editor = await fixture.create_account({ username: 'cell_vis_editor' });
99
+ const t = fixture.fresh_transport();
100
+ const owner_h = owner.create_session_headers();
101
+ const editor_h = editor.create_session_headers();
102
+ const cell = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner_h), CellCreateOutput).cell;
103
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_create', {
104
+ cell_id: cell.id,
105
+ level: 'editor',
106
+ principal: { kind: 'actor', actor_id: editor.actor.id },
107
+ }, owner_h), CellGrantCreateOutput);
108
+ // Content edits pass (editor tier); a visibility write is manage-tier.
109
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id: cell.id, data: { kind: 'note', label: 'ok' } }, editor_h), CellUpdateOutput);
110
+ const vis = await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id: cell.id, visibility: 'public' }, editor_h);
111
+ assert.ok(!vis.ok, 'editor flipped visibility');
112
+ assert.strictEqual(error_reason(vis), 'cell_visibility_manage_only');
113
+ });
114
+ test_if(capabilities.cell_relations, 'field set → forward + reverse list → idempotent delete', async () => {
115
+ const fixture = await setup_test();
116
+ const owner = await fixture.create_account({ username: 'cell_field_owner' });
117
+ const t = fixture.fresh_transport();
118
+ const h = owner.create_session_headers();
119
+ const source = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, h), CellCreateOutput).cell;
120
+ const target = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, h), CellCreateOutput).cell;
121
+ const set = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_set', { source_id: source.id, name: 'cover', target_id: target.id }, h), CellFieldSetOutput);
122
+ assert.strictEqual(set.field.name, 'cover');
123
+ assert.strictEqual(set.field.source_id, source.id);
124
+ assert.strictEqual(set.field.target_id, target.id);
125
+ const forward = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_list', { source_id: source.id }, h), CellFieldListOutput);
126
+ assert.ok(forward.fields.some((f) => f.name === 'cover' && f.target_id === target.id), 'forward field_list missing the field');
127
+ const reverse = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_list', { target_id: target.id }, h), CellFieldListOutput);
128
+ assert.ok(reverse.fields.some((f) => f.source_id === source.id), 'reverse field_list missing the upfield');
129
+ const del = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_delete', { source_id: source.id, name: 'cover' }, h), CellFieldDeleteOutput);
130
+ assert.strictEqual(del.deleted, true);
131
+ const del2 = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_delete', { source_id: source.id, name: 'cover' }, h), CellFieldDeleteOutput);
132
+ assert.strictEqual(del2.deleted, false);
133
+ });
134
+ test_if(capabilities.cell_relations, 'item insert → ordered forward + reverse list → move → idempotent delete', async () => {
135
+ const fixture = await setup_test();
136
+ const owner = await fixture.create_account({ username: 'cell_item_owner' });
137
+ const t = fixture.fresh_transport();
138
+ const h = owner.create_session_headers();
139
+ const make = async () => expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, h), CellCreateOutput).cell.id;
140
+ const parent = await make();
141
+ const child_a = await make();
142
+ const child_b = await make();
143
+ const pos_a = fractional_index_between(null, null);
144
+ const ins_a = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_insert', { parent_id: parent, child_id: child_a, position: pos_a }, h), CellItemInsertOutput);
145
+ assert.strictEqual(ins_a.item.child_id, child_a);
146
+ const pos_b = fractional_index_between(pos_a, null);
147
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_insert', { parent_id: parent, child_id: child_b, position: pos_b }, h), CellItemInsertOutput);
148
+ // Forward list is lex-ordered by position; child_a (pos_a < pos_b) first.
149
+ const forward = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_list', { parent_id: parent }, h), CellItemListOutput);
150
+ assert.strictEqual(forward.items.length, 2);
151
+ assert.strictEqual(forward.items[0].child_id, child_a);
152
+ assert.strictEqual(forward.items[1].child_id, child_b);
153
+ // Reverse list — which parents contain child_a.
154
+ const reverse = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_list', { child_id: child_a }, h), CellItemListOutput);
155
+ assert.ok(reverse.items.some((i) => i.parent_id === parent), 'reverse item_list missing the parent');
156
+ // Move child_b ahead of child_a.
157
+ const new_pos = fractional_index_between(null, pos_a);
158
+ const moved = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_move', { parent_id: parent, position: pos_b, new_position: new_pos }, h), CellItemMoveOutput);
159
+ assert.strictEqual(moved.item.position, new_pos);
160
+ assert.strictEqual(moved.item.child_id, child_b);
161
+ // Idempotent delete on the slot key.
162
+ const del = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_delete', { parent_id: parent, position: pos_a }, h), CellItemDeleteOutput);
163
+ assert.strictEqual(del.deleted, true);
164
+ const del2 = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_delete', { parent_id: parent, position: pos_a }, h), CellItemDeleteOutput);
165
+ assert.strictEqual(del2.deleted, false);
166
+ });
167
+ test_if(capabilities.cell_relations, 'clone: shallow shares edges; deep clones children', async () => {
168
+ const fixture = await setup_test();
169
+ const owner = await fixture.create_account({ username: 'cell_clone_owner' });
170
+ const t = fixture.fresh_transport();
171
+ const h = owner.create_session_headers();
172
+ const make = async (label) => expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note', ...(label === undefined ? {} : { label }) } }, h), CellCreateOutput).cell.id;
173
+ const source = await make('orig');
174
+ const child = await make('child');
175
+ const field_target = await make('target');
176
+ const child_pos = fractional_index_between(null, null);
177
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_insert', { parent_id: source, child_id: child, position: child_pos }, h), CellItemInsertOutput);
178
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_set', { source_id: source, name: 'link', target_id: field_target }, h), CellFieldSetOutput);
179
+ // Shallow clone: new owned cell, path nulled, item edge shares the
180
+ // same child_id, field edge shares the same target_id.
181
+ const shallow = expect_output(await cross_rpc_call(t, rpc_path, 'cell_clone', { source_id: source }, h), CellCloneOutput).cell;
182
+ assert.notStrictEqual(shallow.id, source);
183
+ assert.strictEqual(shallow.created_by, owner.actor.id);
184
+ assert.strictEqual(shallow.path, null);
185
+ const shallow_items = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_list', { parent_id: shallow.id }, h), CellItemListOutput);
186
+ assert.strictEqual(shallow_items.items.length, 1);
187
+ assert.strictEqual(shallow_items.items[0].child_id, child, 'shallow clone re-pointed the child');
188
+ const shallow_fields = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_list', { source_id: shallow.id }, h), CellFieldListOutput);
189
+ assert.strictEqual(shallow_fields.fields.length, 1);
190
+ assert.strictEqual(shallow_fields.fields[0].target_id, field_target);
191
+ // Deep clone: the child edge points at a NEW cloned cell, not the
192
+ // original child; the field edge still shares the target.
193
+ const deep = expect_output(await cross_rpc_call(t, rpc_path, 'cell_clone', { source_id: source, deep: true }, h), CellCloneOutput).cell;
194
+ const deep_items = expect_output(await cross_rpc_call(t, rpc_path, 'cell_item_list', { parent_id: deep.id }, h), CellItemListOutput);
195
+ assert.strictEqual(deep_items.items.length, 1);
196
+ assert.notStrictEqual(deep_items.items[0].child_id, child, 'deep clone reused the original child');
197
+ const deep_fields = expect_output(await cross_rpc_call(t, rpc_path, 'cell_field_list', { source_id: deep.id }, h), CellFieldListOutput);
198
+ assert.strictEqual(deep_fields.fields.length, 1);
199
+ assert.strictEqual(deep_fields.fields[0].target_id, field_target);
200
+ });
201
+ test_if(capabilities.cell_relations, 'audit_list is manage-tier: owner reads the timeline; viewer-grant gets 404', async () => {
202
+ const fixture = await setup_test();
203
+ const owner = await fixture.create_account({ username: 'cell_audit_owner' });
204
+ const viewer = await fixture.create_account({ username: 'cell_audit_viewer' });
205
+ const t = fixture.fresh_transport();
206
+ const owner_h = owner.create_session_headers();
207
+ const viewer_h = viewer.create_session_headers();
208
+ const cell = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { data: { kind: 'note' } }, owner_h), CellCreateOutput).cell;
209
+ // A mutation so the timeline has at least the create + update events.
210
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_update', { cell_id: cell.id, data: { kind: 'note', label: 'v2' } }, owner_h), CellUpdateOutput);
211
+ // Owner (manage-tier) reads the timeline.
212
+ const timeline = expect_output(await cross_rpc_call(t, rpc_path, 'cell_audit_list', { cell_id: cell.id }, owner_h), CellAuditListOutput);
213
+ assert.ok(timeline.events.length > 0, 'owner audit timeline empty');
214
+ assert.ok(timeline.events.some((e) => e.event_type === 'cell_create'), 'audit timeline missing the create event');
215
+ // Grant the viewer a view-only grant: they can read the cell …
216
+ expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_create', {
217
+ cell_id: cell.id,
218
+ level: 'viewer',
219
+ principal: { kind: 'actor', actor_id: viewer.actor.id },
220
+ }, owner_h), CellGrantCreateOutput);
221
+ const can_read = await cross_rpc_call(t, rpc_path, 'cell_get', { id: cell.id }, viewer_h);
222
+ assert.ok(can_read.ok, 'viewer-grant holder could not read the cell');
223
+ // … but the timeline is manage-tier, so they get the IDOR 404.
224
+ const denied = await cross_rpc_call(t, rpc_path, 'cell_audit_list', { cell_id: cell.id }, viewer_h);
225
+ assert.ok(!denied.ok, 'viewer read the audit timeline');
226
+ assert.strictEqual(error_reason(denied), 'cell_not_found');
227
+ });
228
+ });
229
+ };