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