@bongos/core 1.19.409 → 1.19.410

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.
@@ -90,6 +90,7 @@ const {
90
90
  setGoalMembershipKind,
91
91
  setGoalStatus,
92
92
  setGoalSuccessor,
93
+ setGoalAcceptingRequests,
93
94
  setGoalVisibility,
94
95
  transferGoalOwnership,
95
96
  } = require('./db-goals.js');
@@ -227,8 +228,10 @@ module.exports = {
227
228
  rescopeTaskTouches,
228
229
  addGoalScopeModules,
229
230
  setGoalSuccessor,
230
- // Private goals: visibility + the invite/join-request queue (task 1951 / ADR 0112).
231
+ // Private goals: visibility + the invite/join-request queue (task 1951 / ADR 0112),
232
+ // plus the D6 open-/closed-to-requests flag (task 1002971).
231
233
  setGoalVisibility,
234
+ setGoalAcceptingRequests,
232
235
  createMembershipRequest,
233
236
  getMembershipRequest,
234
237
  resolveMembershipRequest,
@@ -289,8 +289,48 @@ function goalVisibilityHandler({ db, authorizeGoalManagement, validateOrRespond,
289
289
  };
290
290
  }
291
291
 
292
+ // goalAcceptingRequestsHandler — PATCH /goals/:id/accepting-requests (task
293
+ // 1002971, spec docs/specs/bongos-v1-privacy-and-invites.md D6): a goal's own
294
+ // authority opens or closes the goal to new join-requests. Lives beside
295
+ // goalVisibilityHandler for the same reason that one does — a write to the goal's
296
+ // own row behind the IDENTICAL gate (authorizeGoalManagement) — and here rather
297
+ // than in routes/goals.js because that file is over the fitness oversize
298
+ // threshold and the ratchet is shrink-only.
299
+ //
300
+ // A SEPARATE route from PATCH /goals/:id/visibility on purpose: they are two
301
+ // different statements a lead makes ("who may see the door" vs "is the door
302
+ // taking knocks"), a lead flips the second far more often than the first, and
303
+ // folding them would make one careless request able to un-privatise a goal. The
304
+ // server does NOT refuse the write on a public goal — the flag is simply
305
+ // meaningless there (self-join needs no request) and the refusal gate is what
306
+ // reads visibility; that also means a goal can be pre-closed before going
307
+ // private without a two-step dance.
308
+ function goalAcceptingRequestsHandler({ db, authorizeGoalManagement, validateOrRespond, parseId }) {
309
+ return async function patchGoalAcceptingRequests(req, res) {
310
+ const id = parseId(req, res, { code: 'bad_goal_id' });
311
+ if (id === null) return;
312
+ if (validateOrRespond(req, res, { accepting_requests: { required: true, type: 'boolean' } })) return;
313
+ try {
314
+ const goal = await db.getGoal(id);
315
+ const actorMembership = goal
316
+ ? await db.getGoalMember({ goalId: id, builderId: req.builder.id })
317
+ : null;
318
+ const decision = authorizeGoalManagement({
319
+ goal, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
320
+ });
321
+ if (!decision.ok) return res.status(decision.status).json(decision.body);
322
+ const updated = await db.setGoalAcceptingRequests({ goalId: id, acceptingRequests: req.body.accepting_requests });
323
+ return res.json({ ok: true, goal: updated });
324
+ } catch (err) {
325
+ proseLog().error({ err: err && err.message }, 'PATCH /goals/:id/accepting-requests failed');
326
+ return res.fail('accepting_requests_update_failed', { status: 500, message: 'internal error' });
327
+ }
328
+ };
329
+ }
330
+
292
331
  module.exports = {
293
332
  criterionProseRefusal,
333
+ goalAcceptingRequestsHandler,
294
334
  logProseEdit,
295
335
  recordProseEdit,
296
336
  goalProseHandler,
@@ -236,9 +236,20 @@ function protectedScopeAdmissionBlock({ scopeProtected, actorRank, targetRank })
236
236
  // POST /goals/:id/requests (task 1952): a builder asks to join. A request only
237
237
  // makes sense where self-join is gated — a PUBLIC, NON-protected goal is open-join,
238
238
  // so a request there is refused with a "just join" hint. Ordering:
239
- // goal_not_found → goal_not_open → already_a_member → goal_is_open (public+unprotected).
239
+ // goal_not_found → goal_not_open → already_a_member → goal_is_open (public+unprotected)
240
+ // → not_accepting_requests (private + closed to applications).
240
241
  // isMember is the caller's own membership; scopeProtected is scopeIncludesProtected(goal).
241
242
  // PURE + exported (the authorizeGoalTaskCreate precedent).
243
+ //
244
+ // THE CLOSED-TO-REQUESTS ARM (task 1002971, spec D6): a private goal whose lead
245
+ // set accepting_requests=false is not taking applications, so the ask is refused
246
+ // with honest copy rather than queued where nobody will answer it. Deliberately
247
+ // narrowed to visibility='private' — D6 calls the flag meaningless on a public
248
+ // goal (self-join needs no request), so a PUBLIC protected-scope goal keeps
249
+ // today's Archon-mediated request door open whatever the column says. And it is
250
+ // checked HERE, not on the invite paths: closing applications is about
251
+ // unsolicited inbound, and a lead reaching out to a named builder is the
252
+ // deliberate act the flag exists to protect.
242
253
  function authorizeCreateJoinRequest({ goal, isMember, scopeProtected }) {
243
254
  if (!goal) return { ok: false, status: 404, body: { error: 'goal_not_found' } };
244
255
  if (goal.status !== 'open') {
@@ -258,6 +269,12 @@ function authorizeCreateJoinRequest({ goal, isMember, scopeProtected }) {
258
269
  message: 'This goal is open — just join it (POST /goals/:id/join); no request needed.',
259
270
  } };
260
271
  }
272
+ if (goal.visibility === 'private' && goal.accepting_requests === false) {
273
+ return { ok: false, status: 409, body: {
274
+ error: 'not_accepting_requests',
275
+ message: "This goal isn't taking requests right now.",
276
+ } };
277
+ }
261
278
  return { ok: true };
262
279
  }
263
280
 
@@ -374,6 +391,38 @@ function authorizeWithdrawJoinRequest({ request, goalId, actorId }) {
374
391
  return { ok: true };
375
392
  }
376
393
 
394
+ // authorizeRescindInvitation — the gate for POST /goals/:id/invitations/:reqId/rescind
395
+ // (task 1002971): an owner|manager|Archon takes back an invitation the goal SENT
396
+ // but nobody has answered yet. This is the MISSING HALF of core_188's 'cancelled'
397
+ // state, which that migration defined as "withdrawn/rescinded by the opener" —
398
+ // only the requester-withdraw half (authorizeWithdrawJoinRequest) was ever built,
399
+ // so a mis-addressed or regretted invite could only be un-sent by asking the
400
+ // invitee to decline it. The opener is a MANAGER here, not the row's builder_id,
401
+ // which is why the authority prefix is authorizeGoalManagement VERBATIM (the
402
+ // authorizeJoinRequestResponse shape) rather than the "is this your row" identity
403
+ // check its withdraw mirror uses — and it runs BEFORE the request lookup so an
404
+ // unauthorized actor never learns whether an invitation exists (the O1 no-leak
405
+ // ordering). Then request containment + state: request_not_found → wrong_goal →
406
+ // not_an_invitation → not_pending, so this route can never touch a join-request
407
+ // row. Resolving an already-resolved invite is refused (409 not_pending) rather
408
+ // than silently re-cancelled: db.resolveMembershipRequest moves only from
409
+ // status='pending', so a repeat rescind changes no state either way. PURE + exported.
410
+ function authorizeRescindInvitation({ goal, actorId, actorRank, actorMembership, request, goalId }) {
411
+ const mgmt = authorizeGoalManagement({ goal, actorId, actorRank, actorMembership });
412
+ if (!mgmt.ok) return mgmt;
413
+ if (!request) return { ok: false, status: 404, body: { error: 'request_not_found' } };
414
+ if (String(request.goal_id) !== String(goalId)) {
415
+ return { ok: false, status: 404, body: { error: 'request_not_found', message: 'No such invitation on this goal.' } };
416
+ }
417
+ if (request.kind !== 'invite') {
418
+ return { ok: false, status: 400, body: { error: 'not_an_invitation', message: 'That row is a join-request, not an invitation.' } };
419
+ }
420
+ if (request.status !== 'pending') {
421
+ return { ok: false, status: 409, body: { error: 'not_pending', status: request.status } };
422
+ }
423
+ return { ok: true };
424
+ }
425
+
377
426
  // decorateGoalScope — additive, derived scope_protected flag on goal payloads
378
427
  // (task 1002689). A protected-scope goal is join-gated like a private one
379
428
  // (Archon-mediated membership, ADR 0086 §3), but the hall cannot re-derive the
@@ -393,6 +442,7 @@ module.exports = {
393
442
  authorizeJoinRequestResponse,
394
443
  authorizeMembershipKindChange,
395
444
  authorizeOwnershipTransfer,
445
+ authorizeRescindInvitation,
396
446
  authorizeWithdrawJoinRequest,
397
447
  decorateGoalScope,
398
448
  isMemberOrArchon,
@@ -63,7 +63,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
63
63
  // proceed; a false result means "not a member" → the route should 403. This is the
64
64
  // membership half of the wall; the protected-module rank check is enforced per-route
65
65
  // (scope is never self-granted).
66
- const { authorizeCreateJoinRequest, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
66
+ const { authorizeCreateJoinRequest, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
67
67
  // task 1003208: structured logging (pino via the doorway) — was console.*.
68
68
  const log = api.logger('lifecycle');
69
69
  module.exports = function buildGoalsRouter() {
@@ -334,6 +334,15 @@ module.exports = function buildGoalsRouter() {
334
334
  // Pinned in route-rank-check.MODULE_EXPECTED_RANKS['lifecycle'].
335
335
  router.patch('/goals/:id/visibility', auth.requireBuilder, goalEdits.goalVisibilityHandler({ db, authorizeGoalManagement, validateOrRespond, parseId, VALID_VISIBILITIES }));
336
336
 
337
+ // PATCH /goals/:id/accepting-requests { accepting_requests } — a private goal's
338
+ // lead closes or re-opens it to join-requests (task 1002971, spec D6). Closed
339
+ // refuses new asks with honest copy; INVITATIONS ARE UNAFFECTED, so a lead can
340
+ // still reach out. Handler in goal-edits.js beside the visibility toggle (the
341
+ // same gate, the same kind of write); only the registration lives here so
342
+ // route-rank-check still classifies it.
343
+ // rank: any-builder (requireBuilder) + the in-handler owner|manager|Archon gate.
344
+ router.patch('/goals/:id/accepting-requests', auth.requireBuilder, goalEdits.goalAcceptingRequestsHandler({ db, authorizeGoalManagement, validateOrRespond, parseId }));
345
+
337
346
  // ---- the ADVISORY human category (task 1003275, criterion legibility-categories) ----
338
347
  // A category ORIENTS a person and steers Claude; it authorises nothing. Nothing
339
348
  // in this file, in claim eligibility, or in the claimable feed may branch on it —
@@ -605,6 +614,39 @@ module.exports = function buildGoalsRouter() {
605
614
  }
606
615
  });
607
616
 
617
+ // POST /goals/:id/invitations/:reqId/rescind — an owner|manager|Archon takes
618
+ // back a PENDING invitation the goal sent (task 1002971): the manager-side
619
+ // mirror of the requester's withdraw, and the missing half of core_188's
620
+ // 'cancelled' state ("withdrawn/rescinded by the opener" — only the requester
621
+ // half existed). Sets status='cancelled', kept as the audit trail like a deny's
622
+ // 'rejected'; never touches a join-request row.
623
+ // rank: any-builder (requireBuilder) + the in-handler authorizeRescindInvitation.
624
+ router.post('/goals/:id/invitations/:reqId/rescind', auth.requireBuilder, async (req, res) => {
625
+ const id = parseId(req, res, { code: 'bad_goal_id' });
626
+ if (id === null) return;
627
+ const reqId = parseId(req, res, { param: 'reqId', code: 'bad_request_id', positiveInt: true });
628
+ if (reqId === null) return;
629
+ try {
630
+ const goal = await db.getGoal(id);
631
+ const [actorMembership, request] = goal
632
+ ? await Promise.all([
633
+ db.getGoalMember({ goalId: id, builderId: req.builder.id }),
634
+ db.getMembershipRequest(reqId),
635
+ ])
636
+ : [null, null];
637
+ const decision = authorizeRescindInvitation({
638
+ goal, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership, request, goalId: id,
639
+ });
640
+ if (!decision.ok) return res.status(decision.status).json(decision.body);
641
+ const resolved = await db.resolveMembershipRequest({ id: reqId, status: 'cancelled', resolvedBy: req.builder.id });
642
+ if (!resolved) return res.fail('not_pending', { status: 409, message: 'The invitation changed underneath this rescind — re-read and retry.' });
643
+ return res.json({ ok: true, request: resolved });
644
+ } catch (err) {
645
+ log.error('[gds] POST /goals/:id/invitations/:reqId/rescind', err);
646
+ return res.fail('rescind_failed', { status: 500, message: 'internal error' });
647
+ }
648
+ });
649
+
608
650
  // POST /goals/:id/members — the goal's own authority (owner | manager | Archon)
609
651
  // invites a builder to the goal (task 1762: widened from Archon-only so a
610
652
  // manager doesn't need an Archon in the loop for a normal goal). This stays the
@@ -1237,6 +1279,9 @@ module.exports.authorizeJoinRequestResponse = authorizeJoinRequestResponse;
1237
1279
  // task 1953: the withdraw gate — exported for tests/goal_membership_requests.mjs,
1238
1280
  // same precedent as its siblings above.
1239
1281
  module.exports.authorizeWithdrawJoinRequest = authorizeWithdrawJoinRequest;
1282
+ // task 1002971: the manager-side rescind gate — the missing half of core_188's
1283
+ // 'cancelled' state; exported for tests/goal_membership_requests.mjs, same precedent.
1284
+ module.exports.authorizeRescindInvitation = authorizeRescindInvitation;
1240
1285
  // task 1002689: the derived scope_protected payload decorator — exported for
1241
1286
  // tests/goal_membership_requests.mjs (DB-free), same precedent.
1242
1287
  module.exports.decorateGoalScope = decorateGoalScope;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.409",
3
+ "version": "1.19.410",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.409",
9
+ "version": "1.19.410",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.409",
3
+ "version": "1.19.410",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -391,6 +391,12 @@ const MODULE_EXPECTED_RANKS = {
391
391
  // (authorizeWithdrawJoinRequest).
392
392
  'GET /goals/:id/requests': 'any-builder',
393
393
  'POST /goals/:id/requests/:reqId/withdraw': 'any-builder',
394
+ // task 1002971 (spec D6): the private-goal open/closed-to-requests toggle and
395
+ // the manager-side rescind of a pending invitation. Both walls are in-handler
396
+ // (authorizeGoalManagement / authorizeRescindInvitation), so both stay
397
+ // 'any-builder' — pinned so an accidental requireRank re-gate reds the build.
398
+ 'PATCH /goals/:id/accepting-requests': 'any-builder',
399
+ 'POST /goals/:id/invitations/:reqId/rescind': 'any-builder',
394
400
  // criterion confirmation + goal auto-achieve (ADR 0086 §6) — the planner's close loop.
395
401
  'POST /done-when/:criterionId/satisfy': 'metic+archon',
396
402
  // runtime criterion create (ADR 0105 gap fix, task 1874) — re-ranked Archon→Metic+
package/src/module-api.js CHANGED
@@ -1249,8 +1249,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1249
1249
  // landed since 1.19.407 with no explicit bump. run 33216756796. (task 1002620)
1250
1250
  // 1.19.409 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1251
1251
  // landed since 1.19.408 with no explicit bump. run 33217065997. (task 1002620)
1252
+ // 1.19.410 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1253
+ // landed since 1.19.409 with no explicit bump. run 33217599338. (task 1002620)
1252
1254
  // ---------------------------------------------------------------------------
1253
- const CORE_VERSION = '1.19.409'; // CI auto-patch carrier (ADR 0161); see changelog above
1255
+ const CORE_VERSION = '1.19.410'; // CI auto-patch carrier (ADR 0161); see changelog above
1254
1256
 
1255
1257
  // A namespaced logger so a module's log lines are attributable + consistent.
1256
1258
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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 ---------------------------------