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