@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.
Files changed (37) hide show
  1. package/.bongos-core.json +56 -41
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +5 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0203-doorway-changelog-out-of-module-api.md +54 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +122 -3
  10. package/docs/api-reference.md +4 -2
  11. package/docs/copy-inventory.md +43 -36
  12. package/docs/copy-registry.json +122 -39
  13. package/docs/file-map.md +2 -1
  14. package/docs/module-api-changelog.md +1263 -0
  15. package/docs/modules-contract.md +2 -0
  16. package/docs/recipes/core-release-pipeline.md +3 -3
  17. package/docs/recipes/private-npm-distribution.md +1 -1
  18. package/migrations/core_229_goals_accepting_requests.sql +37 -0
  19. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  20. package/modules/hall-ui/public/goals-page.js +26 -2
  21. package/modules/hall-ui/public/goals-render.js +35 -0
  22. package/modules/lifecycle/db-goals.js +22 -1
  23. package/modules/lifecycle/db.js +4 -1
  24. package/modules/lifecycle/goal-edits.js +40 -0
  25. package/modules/lifecycle/routes/goal-route-authz.js +51 -1
  26. package/modules/lifecycle/routes/goals.js +46 -1
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/bump-version.js +53 -25
  30. package/scripts/gds/deploy-divergence.js +1 -1
  31. package/scripts/gds/publish-manifest.js +6 -0
  32. package/src/bongos/module-scope-map.js +6 -1
  33. package/src/bongos/route-rank-check.js +6 -0
  34. package/src/module-api.js +10 -1227
  35. package/tests/bump_version.mjs +72 -13
  36. package/tests/goal_membership_requests.mjs +154 -1
  37. package/tests/prose_edits.mjs +8 -4
@@ -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 inside the comment block, not after the const;
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
- "const CORE_VERSION = '1.19.1'; // carrier; see changelog above",
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 { text } = bumpPatch(SAMPLE, { note: 'run 42' });
48
- const dividerAt = text.indexOf('// ------');
49
- const entryAt = text.indexOf('1.19.2 — CI auto-patch');
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 changelog block, above the divider');
52
- assert.match(text, /landed since 1\.19\.1 with no explicit bump\. run 42\./);
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("const CORE_VERSION = '1.2.3'; // no divider above"),
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 is bumpable right now (shape not drifted)', () => {
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 { from, to, text } = bumpPatch(real);
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 eight private-goal routes are pinned any-builder', () => {
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
  }
@@ -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: the goal + visibility handlers share ONE authority gate', () => {
186
- // Both were 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.
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, 2, 'prose + visibility, both gated');
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 ---------------------------------