@bongos/core 1.19.409 → 1.19.411
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/.bongos-core.json +56 -41
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0203-doorway-changelog-out-of-module-api.md +54 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +122 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +43 -36
- package/docs/copy-registry.json +122 -39
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +1263 -0
- package/docs/modules-contract.md +2 -0
- package/docs/recipes/core-release-pipeline.md +3 -3
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_229_goals_accepting_requests.sql +37 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/goals-page.js +26 -2
- package/modules/hall-ui/public/goals-render.js +35 -0
- package/modules/lifecycle/db-goals.js +22 -1
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/goal-edits.js +40 -0
- package/modules/lifecycle/routes/goal-route-authz.js +51 -1
- package/modules/lifecycle/routes/goals.js +46 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bump-version.js +53 -25
- package/scripts/gds/deploy-divergence.js +1 -1
- package/scripts/gds/publish-manifest.js +6 -0
- package/src/bongos/module-scope-map.js +6 -1
- package/src/bongos/route-rank-check.js +6 -0
- package/src/module-api.js +10 -1227
- package/tests/bump_version.mjs +72 -13
- package/tests/goal_membership_requests.mjs +154 -1
- package/tests/prose_edits.mjs +8 -4
package/tests/bump_version.mjs
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// These tests pin the three properties that must survive any refactor:
|
|
5
5
|
// 1. the bumped literal keeps the exact `^const CORE_VERSION = '…';` shape
|
|
6
6
|
// deploy-divergence.js greps and regex-reads;
|
|
7
|
-
// 2. a changelog entry lands
|
|
7
|
+
// 2. a changelog entry lands above the ledger's closing divider in
|
|
8
|
+
// docs/module-api-changelog.md (ADR 0203 moved the ledger out of
|
|
9
|
+
// src/module-api.js — appending to it there grew the file past the
|
|
10
|
+
// oversized-file fitness ratchet and broke `unit` on main);
|
|
8
11
|
// 3. unrecognizable input throws instead of silently mis-editing.
|
|
9
12
|
|
|
10
13
|
import test from 'node:test';
|
|
@@ -22,16 +25,28 @@ const { bumpPatch, CONST_RE } = bump;
|
|
|
22
25
|
const { execFileSync } = require('node:child_process');
|
|
23
26
|
|
|
24
27
|
const SAMPLE = [
|
|
25
|
-
'// prologue',
|
|
26
|
-
'// 1.19.0 — some entry. (task 1)',
|
|
27
|
-
'// 1.19.1 — another entry. (task 2)',
|
|
28
28
|
'// ---------------------------------------------------------------------------',
|
|
29
|
-
|
|
29
|
+
'// CORE_VERSION — the changelog lives in docs/module-api-changelog.md.',
|
|
30
|
+
'// ---------------------------------------------------------------------------',
|
|
31
|
+
"const CORE_VERSION = '1.19.1'; // carrier; changelog: docs/module-api-changelog.md",
|
|
30
32
|
'',
|
|
31
33
|
'module.exports = { CORE_VERSION };',
|
|
32
34
|
'',
|
|
33
35
|
].join('\n');
|
|
34
36
|
|
|
37
|
+
// The ledger's shape: a fenced text block, oldest entry first, closed by the
|
|
38
|
+
// divider each new entry is inserted immediately above.
|
|
39
|
+
const SAMPLE_CHANGELOG = [
|
|
40
|
+
'# `CORE_VERSION` changelog',
|
|
41
|
+
'',
|
|
42
|
+
'```text',
|
|
43
|
+
'1.19.0 — some entry. (task 1)',
|
|
44
|
+
' 1.19.1 — another entry. (task 2)',
|
|
45
|
+
'---------------------------------------------------------------------------',
|
|
46
|
+
'```',
|
|
47
|
+
'',
|
|
48
|
+
].join('\n');
|
|
49
|
+
|
|
35
50
|
test('bumps patch and keeps the exact const line shape', () => {
|
|
36
51
|
const { from, to, text } = bumpPatch(SAMPLE);
|
|
37
52
|
assert.equal(from, '1.19.1');
|
|
@@ -43,13 +58,29 @@ test('bumps patch and keeps the exact const line shape', () => {
|
|
|
43
58
|
assert.equal([...text.matchAll(/^const CORE_VERSION = /gm)].length, 1);
|
|
44
59
|
});
|
|
45
60
|
|
|
61
|
+
test('the const line points a reader at the ledger, wherever it moved to', () => {
|
|
62
|
+
// The pointer is the whole reason moving the changelog out is safe: someone
|
|
63
|
+
// grepping src/module-api.js for the history must be told where it went.
|
|
64
|
+
const { text } = bumpPatch(SAMPLE);
|
|
65
|
+
assert.match(text, /^const CORE_VERSION = .*docs\/module-api-changelog\.md/m);
|
|
66
|
+
});
|
|
67
|
+
|
|
46
68
|
test('inserts the changelog entry above the divider, mentioning both versions', () => {
|
|
47
|
-
const {
|
|
48
|
-
const dividerAt =
|
|
49
|
-
const entryAt =
|
|
69
|
+
const { changelogText } = bumpPatch(SAMPLE, { note: 'run 42', changelog: SAMPLE_CHANGELOG });
|
|
70
|
+
const dividerAt = changelogText.indexOf('\n---------');
|
|
71
|
+
const entryAt = changelogText.indexOf('1.19.2 — CI auto-patch');
|
|
50
72
|
assert.ok(entryAt !== -1, 'entry exists');
|
|
51
|
-
assert.ok(entryAt < dividerAt, 'entry sits inside the
|
|
52
|
-
assert.match(
|
|
73
|
+
assert.ok(entryAt < dividerAt, 'entry sits inside the ledger, above its closing divider');
|
|
74
|
+
assert.match(changelogText, /landed since 1\.19\.1 with no explicit bump\. run 42\./);
|
|
75
|
+
// oldest-first ordering survives: the new entry is the LAST one.
|
|
76
|
+
assert.ok(changelogText.indexOf('1.19.1 — another entry') < entryAt, 'newest entry lands last');
|
|
77
|
+
// the entry is a comment-free ledger line — no `//` scaffolding leaks back in.
|
|
78
|
+
assert.equal(/^\s*\/\//m.test(changelogText.slice(entryAt - 4, entryAt)), false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('without a changelog the ledger is untouched, never silently half-written', () => {
|
|
82
|
+
const { changelogText } = bumpPatch(SAMPLE);
|
|
83
|
+
assert.equal(changelogText, null, 'no ledger in, no ledger out — main() refuses this case');
|
|
53
84
|
});
|
|
54
85
|
|
|
55
86
|
test('a 9→10 patch rollover carries, not truncates', () => {
|
|
@@ -61,16 +92,31 @@ test('a 9→10 patch rollover carries, not truncates', () => {
|
|
|
61
92
|
test('unrecognizable input throws loudly instead of mis-editing', () => {
|
|
62
93
|
assert.throws(() => bumpPatch('nothing here'), /could not find the CORE_VERSION literal/);
|
|
63
94
|
assert.throws(
|
|
64
|
-
() => bumpPatch(
|
|
95
|
+
() => bumpPatch(SAMPLE, { changelog: '# a ledger with no closing divider\n' }),
|
|
65
96
|
/changelog closing divider/
|
|
66
97
|
);
|
|
67
98
|
});
|
|
68
99
|
|
|
69
|
-
test('the REAL src/module-api.js
|
|
100
|
+
test('the REAL src/module-api.js + ledger are bumpable right now (shape not drifted)', () => {
|
|
70
101
|
const real = fs.readFileSync(path.join(ROOT, 'src', 'module-api.js'), 'utf8');
|
|
71
|
-
const
|
|
102
|
+
const ledger = fs.readFileSync(path.join(ROOT, 'docs', 'module-api-changelog.md'), 'utf8');
|
|
103
|
+
const { from, to, text, changelogText } = bumpPatch(real, { changelog: ledger });
|
|
72
104
|
assert.match(from, /^\d+\.\d+\.\d+$/);
|
|
73
105
|
assert.ok(text.includes(`const CORE_VERSION = '${to}';`));
|
|
106
|
+
assert.ok(changelogText.includes(`${to} — CI auto-patch`), 'the real ledger takes the entry');
|
|
107
|
+
assert.ok(changelogText.length > ledger.length, 'the real ledger grew');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('the ledger is NOT a .js file — the ratchet that forced the move stays satisfied', () => {
|
|
111
|
+
// ADR 0203: the carrier appends on every release and pushes straight to main.
|
|
112
|
+
// Inside a .js file that growth trips scripts/gds/fitness-ratchets.js's
|
|
113
|
+
// oversized_file_count (it counts `git ls-files '*.js'`) and turns `unit` red
|
|
114
|
+
// for main and every open PR at once. Markdown is the whole fix.
|
|
115
|
+
const rel = 'docs/module-api-changelog.md';
|
|
116
|
+
assert.equal(rel.endsWith('.js'), false);
|
|
117
|
+
assert.ok(fs.existsSync(path.join(ROOT, rel)), 'the ledger exists where the carrier writes it');
|
|
118
|
+
const api = fs.readFileSync(path.join(ROOT, 'src', 'module-api.js'), 'utf8').split('\n').length;
|
|
119
|
+
assert.ok(api <= 1500, `src/module-api.js is ${api} lines — the 1500-line ratchet is the reason the ledger moved`);
|
|
74
120
|
});
|
|
75
121
|
|
|
76
122
|
// ---- isReleaseCommit: the cascade terminator (task 1002636) ----
|
|
@@ -108,6 +154,19 @@ test('work-merge subjects, junk, and empties never match', () => {
|
|
|
108
154
|
assert.equal(RELEASE_COMMIT_RE.source.startsWith('^'), true, 'anchored — a subject QUOTING a release message must not match');
|
|
109
155
|
});
|
|
110
156
|
|
|
157
|
+
test('publish.yml stages BOTH files the bump writes (ADR 0203)', () => {
|
|
158
|
+
// The carrier writes two files now. Staging only src/module-api.js would push
|
|
159
|
+
// a version whose changelog entry never left the runner — the ledger main
|
|
160
|
+
// carries would fall one entry behind on every release, forever, and nobody
|
|
161
|
+
// would see it until someone went looking for a version's history.
|
|
162
|
+
const m = PUBLISH_YML.match(/^\s*git add (.+)$/m);
|
|
163
|
+
assert.ok(m, 'publish.yml must stage the bump');
|
|
164
|
+
const staged = m[1].trim().split(/\s+/);
|
|
165
|
+
for (const f of ['src/module-api.js', 'docs/module-api-changelog.md']) {
|
|
166
|
+
assert.ok(staged.includes(f), `publish.yml must \`git add\` ${f} — it is written by bump-version.js --apply`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
111
170
|
test('publish.yml wires the terminator: guard step present, every armed step gated', () => {
|
|
112
171
|
assert.ok(PUBLISH_YML.includes('id: relhead'), 'the Already released? guard step exists');
|
|
113
172
|
assert.match(PUBLISH_YML, /isReleaseCommit/, 'the guard delegates to bump-version.js — single source');
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
// goal is still OPEN (a stale invite can't admit to an achieved/archived goal);
|
|
20
20
|
// declining stays allowed, so the route declines before ever reading the goal.
|
|
21
21
|
// • the migration is additive + private-safe (default 'public', partial unique).
|
|
22
|
+
// • setGoalAcceptingRequests + the D6 closed-to-requests gate (task 1002971) —
|
|
23
|
+
// a private goal can stop taking join-requests WITHOUT stopping invitations.
|
|
24
|
+
// • authorizeRescindInvitation (task 1002971) — the manager-side rescind, the
|
|
25
|
+
// missing half of core_188's 'cancelled' state.
|
|
22
26
|
|
|
23
27
|
import { strict as assert } from 'node:assert';
|
|
24
28
|
import { createRequire } from 'node:module';
|
|
@@ -32,6 +36,7 @@ process.env.NODE_ENV = 'test';
|
|
|
32
36
|
const require = createRequire(import.meta.url);
|
|
33
37
|
const {
|
|
34
38
|
setGoalVisibility,
|
|
39
|
+
setGoalAcceptingRequests,
|
|
35
40
|
createMembershipRequest,
|
|
36
41
|
getMembershipRequest,
|
|
37
42
|
resolveMembershipRequest,
|
|
@@ -48,6 +53,8 @@ const {
|
|
|
48
53
|
authorizeInvitationAccept,
|
|
49
54
|
authorizeJoinRequestResponse,
|
|
50
55
|
authorizeWithdrawJoinRequest,
|
|
56
|
+
authorizeRescindInvitation,
|
|
57
|
+
authorizeGoalManagement,
|
|
51
58
|
decorateGoalScope,
|
|
52
59
|
} = require('../modules/lifecycle/routes/goals.js');
|
|
53
60
|
const { MODULE_EXPECTED_RANKS } = require('../src/bongos/route-rank-check.js');
|
|
@@ -76,6 +83,27 @@ await test('setGoalVisibility: writes visibility + returns the goal row', async
|
|
|
76
83
|
assert.deepEqual(q.params, [7, 'private']);
|
|
77
84
|
});
|
|
78
85
|
|
|
86
|
+
// =========================================================================
|
|
87
|
+
// setGoalAcceptingRequests (task 1002971, spec D6) — the same shape as its
|
|
88
|
+
// visibility sibling: the route owns WHO may call it, the helper just writes.
|
|
89
|
+
// =========================================================================
|
|
90
|
+
|
|
91
|
+
await test('setGoalAcceptingRequests: writes the flag + returns the goal row', async () => {
|
|
92
|
+
const GOAL = { id: 7, visibility: 'private', accepting_requests: false };
|
|
93
|
+
const pool = fakePool((s) => (/UPDATE goals SET accepting_requests = \$2/.test(s) ? { rows: [GOAL] } : null));
|
|
94
|
+
const g = await setGoalAcceptingRequests({ goalId: 7, acceptingRequests: false }, { pool });
|
|
95
|
+
assert.deepEqual(g, GOAL);
|
|
96
|
+
const q = find(pool.queries, /UPDATE goals SET accepting_requests/);
|
|
97
|
+
assert.deepEqual(q.params, [7, false]);
|
|
98
|
+
assert.ok(/RETURNING .*accepting_requests/.test(norm(q.sql)),
|
|
99
|
+
'the returned row carries the flag, so the hall can render the toggle state');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test('setGoalAcceptingRequests: unknown goal → null', async () => {
|
|
103
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
104
|
+
assert.equal(await setGoalAcceptingRequests({ goalId: 999, acceptingRequests: true }, { pool }), null);
|
|
105
|
+
});
|
|
106
|
+
|
|
79
107
|
await test('setGoalVisibility: unknown goal → null', async () => {
|
|
80
108
|
const pool = fakePool(() => ({ rows: [] }));
|
|
81
109
|
assert.equal(await setGoalVisibility({ goalId: 999, visibility: 'public' }, { pool }), null);
|
|
@@ -302,6 +330,15 @@ await test('migration core_188: additive, defaults public, partial-unique pendin
|
|
|
302
330
|
'migration is purely additive');
|
|
303
331
|
});
|
|
304
332
|
|
|
333
|
+
await test('migration core_229: accepting_requests is additive and defaults TRUE', () => {
|
|
334
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
335
|
+
const sql = readFileSync(join(here, '..', 'migrations', 'core_229_goals_accepting_requests.sql'), 'utf8');
|
|
336
|
+
assert.ok(/ADD COLUMN IF NOT EXISTS accepting_requests boolean NOT NULL DEFAULT true/.test(sql),
|
|
337
|
+
'defaults true so every existing private goal keeps taking requests');
|
|
338
|
+
assert.ok(!/\bDROP\s+(TABLE|COLUMN|INDEX)\b|\bDELETE\s+FROM\b|\bTRUNCATE\b/i.test(sql),
|
|
339
|
+
'migration is purely additive');
|
|
340
|
+
});
|
|
341
|
+
|
|
305
342
|
// =========================================================================
|
|
306
343
|
// The pure route gates (task 1952) — DB-free, ordering included.
|
|
307
344
|
// =========================================================================
|
|
@@ -348,6 +385,45 @@ await test('authorizeCreateJoinRequest: public but PROTECTED scope → ALLOW (no
|
|
|
348
385
|
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'public' }, isMember: false, scopeProtected: true }), { ok: true });
|
|
349
386
|
});
|
|
350
387
|
|
|
388
|
+
// The D6 closed-to-requests arm (task 1002971). A private goal whose lead closed
|
|
389
|
+
// applications refuses the ask with honest copy instead of queueing it.
|
|
390
|
+
const CLOSED_PRIVATE = { id: 5, status: 'open', created_by: '1', visibility: 'private', accepting_requests: false };
|
|
391
|
+
await test('authorizeCreateJoinRequest: private + closed → 409 not_accepting_requests, honest copy', () => {
|
|
392
|
+
const d = authorizeCreateJoinRequest({ goal: CLOSED_PRIVATE, isMember: false, scopeProtected: false });
|
|
393
|
+
assert.equal(d.status, 409);
|
|
394
|
+
assert.equal(d.body.error, 'not_accepting_requests');
|
|
395
|
+
assert.equal(d.body.message, "This goal isn't taking requests right now.",
|
|
396
|
+
'the spec D6 copy verbatim — the refusal says why, it does not pretend the goal is gone');
|
|
397
|
+
});
|
|
398
|
+
await test('authorizeCreateJoinRequest: private + explicitly accepting → ALLOW', () => {
|
|
399
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { ...CLOSED_PRIVATE, accepting_requests: true }, isMember: false, scopeProtected: false }), { ok: true });
|
|
400
|
+
});
|
|
401
|
+
// A goal row read before the migration (or by an older release) has no such key.
|
|
402
|
+
// Only an explicit false closes the door — undefined must never read as closed.
|
|
403
|
+
await test('authorizeCreateJoinRequest: private, flag absent → ALLOW (fails OPEN, not closed)', () => {
|
|
404
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'private' }, isMember: false, scopeProtected: false }), { ok: true });
|
|
405
|
+
});
|
|
406
|
+
// D6 calls the flag meaningless on a public goal, so a PUBLIC protected-scope goal
|
|
407
|
+
// keeps its Archon-mediated request door open whatever the column says.
|
|
408
|
+
await test('authorizeCreateJoinRequest: PUBLIC protected goal + closed flag → ALLOW (flag is private-only)', () => {
|
|
409
|
+
assert.deepEqual(authorizeCreateJoinRequest({ goal: { status: 'open', visibility: 'public', accepting_requests: false }, isMember: false, scopeProtected: true }), { ok: true });
|
|
410
|
+
});
|
|
411
|
+
// The whole point of D6: closing applications is about unsolicited inbound. The
|
|
412
|
+
// invite path is gated by authorizeGoalManagement, which never reads the flag —
|
|
413
|
+
// so a lead can still invite into a goal that is closed to requests.
|
|
414
|
+
await test('closed to requests: INVITATIONS are unaffected — the invite gate still allows the owner', () => {
|
|
415
|
+
assert.deepEqual(authorizeGoalManagement({ goal: CLOSED_PRIVATE, actorId: '1', actorRank: 'metic', actorMembership: null }), { ok: true });
|
|
416
|
+
});
|
|
417
|
+
await test('routes: the invitation paths never read accepting_requests (only the request gate does)', () => {
|
|
418
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
419
|
+
const authz = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goal-route-authz.js'), 'utf8');
|
|
420
|
+
const gates = authz.match(/^function authorize\w+[\s\S]*?^}/gm) || [];
|
|
421
|
+
const reading = gates.filter((g) => /goal\.accepting_requests/.test(g));
|
|
422
|
+
assert.equal(reading.length, 1, 'exactly one gate reads the flag');
|
|
423
|
+
assert.ok(/^function authorizeCreateJoinRequest/.test(reading[0]),
|
|
424
|
+
'and it is the JOIN-REQUEST gate — invitations must stay unaffected (spec D6)');
|
|
425
|
+
});
|
|
426
|
+
|
|
351
427
|
// authorizeInvitationResponse — only the invitee resolves their own pending invite.
|
|
352
428
|
const INVITE = { id: 9, goal_id: '5', builder_id: '2', kind: 'invite', status: 'pending' };
|
|
353
429
|
await test('authorizeInvitationResponse: unknown request → 404', () => {
|
|
@@ -480,6 +556,80 @@ await test('authorizeWithdrawJoinRequest: the requester (string vs number id)
|
|
|
480
556
|
assert.deepEqual(authorizeWithdrawJoinRequest({ request: REQUEST_FOR_WITHDRAW, goalId: '5', actorId: 2 }), { ok: true });
|
|
481
557
|
});
|
|
482
558
|
|
|
559
|
+
// =========================================================================
|
|
560
|
+
// authorizeRescindInvitation (task 1002971) — the manager-side mirror of
|
|
561
|
+
// withdraw, and the missing half of core_188's 'cancelled' state. Authority is
|
|
562
|
+
// authorizeGoalManagement (owner|manager|Archon), checked BEFORE the request
|
|
563
|
+
// lookup so an unauthorized actor never learns an invitation exists.
|
|
564
|
+
// =========================================================================
|
|
565
|
+
|
|
566
|
+
const RESCIND_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
|
|
567
|
+
const PENDING_INVITE = { id: 12, goal_id: '5', builder_id: '2', kind: 'invite', status: 'pending' };
|
|
568
|
+
const rescind = (over = {}) => authorizeRescindInvitation({
|
|
569
|
+
goal: RESCIND_GOAL, actorId: '1', actorRank: 'metic', actorMembership: null,
|
|
570
|
+
request: PENDING_INVITE, goalId: 5, ...over,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await test('authorizeRescindInvitation: the goal OWNER → ALLOW', () => {
|
|
574
|
+
assert.deepEqual(rescind(), { ok: true });
|
|
575
|
+
});
|
|
576
|
+
await test('authorizeRescindInvitation: a manager (lead) → ALLOW', () => {
|
|
577
|
+
assert.deepEqual(rescind({ actorId: '3', actorMembership: mgr(3) }), { ok: true });
|
|
578
|
+
});
|
|
579
|
+
await test('authorizeRescindInvitation: an Archon → ALLOW (acts as owner anywhere)', () => {
|
|
580
|
+
assert.deepEqual(rescind({ actorId: '99', actorRank: 'archon' }), { ok: true });
|
|
581
|
+
});
|
|
582
|
+
await test('authorizeRescindInvitation: a non-owner/manager → 403 (before any request lookup — no leak)', () => {
|
|
583
|
+
const d = rescind({ actorId: '7' });
|
|
584
|
+
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|
|
585
|
+
const blind = authorizeRescindInvitation({
|
|
586
|
+
goal: RESCIND_GOAL, actorId: '7', actorRank: 'metic', actorMembership: null, request: null, goalId: 5,
|
|
587
|
+
});
|
|
588
|
+
assert.deepEqual(blind, d, 'the answer is identical whether or not the invitation exists');
|
|
589
|
+
});
|
|
590
|
+
await test('authorizeRescindInvitation: the INVITEE cannot rescind their own invitation → 403', () => {
|
|
591
|
+
const d = rescind({ actorId: '2' });
|
|
592
|
+
assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');
|
|
593
|
+
});
|
|
594
|
+
await test('authorizeRescindInvitation: unknown request → 404', () => {
|
|
595
|
+
const d = rescind({ request: null });
|
|
596
|
+
assert.equal(d.status, 404); assert.equal(d.body.error, 'request_not_found');
|
|
597
|
+
});
|
|
598
|
+
await test('authorizeRescindInvitation: invitation on a different goal → 404 (containment)', () => {
|
|
599
|
+
assert.equal(rescind({ request: { ...PENDING_INVITE, goal_id: '99' } }).status, 404);
|
|
600
|
+
});
|
|
601
|
+
await test('authorizeRescindInvitation: row is a join-request, not an invitation → 400', () => {
|
|
602
|
+
const d = rescind({ request: { ...PENDING_INVITE, kind: 'request' } });
|
|
603
|
+
assert.equal(d.status, 400); assert.equal(d.body.error, 'not_an_invitation');
|
|
604
|
+
});
|
|
605
|
+
// Rescinding an invite that is already resolved changes nothing: the gate refuses
|
|
606
|
+
// 409 not_pending, and db.resolveMembershipRequest moves only from 'pending', so a
|
|
607
|
+
// repeat rescind can never re-cancel an accepted membership.
|
|
608
|
+
await test('authorizeRescindInvitation: already resolved → 409 not_pending (no state change on a repeat)', () => {
|
|
609
|
+
for (const status of ['accepted', 'rejected', 'cancelled']) {
|
|
610
|
+
const d = rescind({ request: { ...PENDING_INVITE, status } });
|
|
611
|
+
assert.equal(d.status, 409); assert.equal(d.body.error, 'not_pending'); assert.equal(d.body.status, status);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
await test('authorizeRescindInvitation: a closed goal cannot be managed → 409 goal_not_open', () => {
|
|
615
|
+
const d = rescind({ goal: { ...RESCIND_GOAL, status: 'archived' } });
|
|
616
|
+
assert.equal(d.status, 409); assert.equal(d.body.error, 'goal_not_open');
|
|
617
|
+
});
|
|
618
|
+
// The route must land the state core_188 already defined for "rescinded by the
|
|
619
|
+
// opener" — not a second word for the same thing (source-pinned, the addGoalMember
|
|
620
|
+
// resolvedBy precedent; the live route is exercised in goal_routes.mjs).
|
|
621
|
+
await test('routes: the rescind route resolves to cancelled, attributed to the actor', () => {
|
|
622
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
623
|
+
const src = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goals.js'), 'utf8');
|
|
624
|
+
const handler = src.slice(src.indexOf("router.post('/goals/:id/invitations/:reqId/rescind'"));
|
|
625
|
+
assert.ok(handler.startsWith("router.post('/goals/:id/invitations/:reqId/rescind'"), 'the rescind route exists');
|
|
626
|
+
const body = handler.slice(0, handler.indexOf('\n });'));
|
|
627
|
+
assert.ok(/db\.resolveMembershipRequest\(\{ id: reqId, status: 'cancelled', resolvedBy: req\.builder\.id \}\)/.test(body),
|
|
628
|
+
"cancelled — core_188's 'withdrawn/rescinded by the opener' state, attributed to the rescinder");
|
|
629
|
+
assert.ok(body.indexOf('authorizeRescindInvitation({') < body.indexOf('db.resolveMembershipRequest('),
|
|
630
|
+
'the gate runs before the write');
|
|
631
|
+
});
|
|
632
|
+
|
|
483
633
|
// =========================================================================
|
|
484
634
|
// decorateGoalScope (task 1002689) — goal payloads carry a derived
|
|
485
635
|
// scope_protected flag so the hall can render the join-gate on a
|
|
@@ -509,7 +659,7 @@ await test('decorateGoalScope: returns a copy, never mutates the row', () => {
|
|
|
509
659
|
// stay 'any-builder'; an accidental requireRank re-gate must be a red build.
|
|
510
660
|
// =========================================================================
|
|
511
661
|
|
|
512
|
-
await test('route-rank pins: the
|
|
662
|
+
await test('route-rank pins: the ten private-goal routes are pinned any-builder', () => {
|
|
513
663
|
const pins = MODULE_EXPECTED_RANKS.lifecycle;
|
|
514
664
|
for (const r of [
|
|
515
665
|
'GET /goals/inbox',
|
|
@@ -520,6 +670,9 @@ await test('route-rank pins: the eight private-goal routes are pinned any-builde
|
|
|
520
670
|
'POST /goals/:id/invitations/:reqId/respond',
|
|
521
671
|
'GET /goals/:id/requests',
|
|
522
672
|
'POST /goals/:id/requests/:reqId/withdraw',
|
|
673
|
+
// task 1002971 — the D6 toggle + the manager-side rescind.
|
|
674
|
+
'PATCH /goals/:id/accepting-requests',
|
|
675
|
+
'POST /goals/:id/invitations/:reqId/rescind',
|
|
523
676
|
]) {
|
|
524
677
|
assert.equal(pins[r], 'any-builder', `${r} must be pinned any-builder (wall is in-handler)`);
|
|
525
678
|
}
|
package/tests/prose_edits.mjs
CHANGED
|
@@ -182,16 +182,20 @@ await test('ROUTE SHAPE: the author gate is fail-closed and uses the array form
|
|
|
182
182
|
assert.match(src, /captured_by != null/, 'and the author check treats a NULL author as not-you');
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
await test('ROUTE SHAPE:
|
|
186
|
-
//
|
|
187
|
-
// of operation behind the same gate; this pins that they still are.
|
|
185
|
+
await test('ROUTE SHAPE: every goal-edit handler shares ONE authority gate', () => {
|
|
186
|
+
// Each was moved into goal-edits.js precisely because they are the same kind
|
|
187
|
+
// of operation behind the same gate; this pins that they still are. The count
|
|
188
|
+
// grows with the file — task 1002971 added the D6 accepting-requests toggle,
|
|
189
|
+
// the third write to a goal's own row behind authorizeGoalManagement.
|
|
188
190
|
const src = readFileSync(new URL('../modules/lifecycle/goal-edits.js', import.meta.url), 'utf8');
|
|
189
|
-
assert.equal((src.match(/authorizeGoalManagement\(/g) || []).length,
|
|
191
|
+
assert.equal((src.match(/authorizeGoalManagement\(/g) || []).length, 3, 'prose + visibility + accepting-requests, all gated');
|
|
190
192
|
assert.equal(typeof goalEdits.goalProseHandler, 'function');
|
|
191
193
|
assert.equal(typeof goalEdits.goalVisibilityHandler, 'function');
|
|
194
|
+
assert.equal(typeof goalEdits.goalAcceptingRequestsHandler, 'function');
|
|
192
195
|
const routes = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
|
|
193
196
|
assert.match(routes, /router\.patch\('\/goals\/:id',/, 'the REGISTRATION stays in the route file so route-rank-check sees it');
|
|
194
197
|
assert.match(routes, /router\.patch\('\/goals\/:id\/visibility',/);
|
|
198
|
+
assert.match(routes, /router\.patch\('\/goals\/:id\/accepting-requests',/);
|
|
195
199
|
});
|
|
196
200
|
|
|
197
201
|
// ---- 5. THE HANDLERS ARE ACTUALLY INVOKED ---------------------------------
|