@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
@@ -54,6 +54,8 @@ A module's `module.json` declares `"coreVersion": "^1.4.0"` (a semver range). Th
54
54
 
55
55
  **Current `CORE_VERSION`:** `1.8.0`
56
56
 
57
+ > The table below is a hand-maintained summary of the MINOR bumps only, and it lags. The authoritative, complete release ledger — every version, including the CI carrier patches — is [`docs/module-api-changelog.md`](module-api-changelog.md) ([ADR 0203](adr/<redacted>.md) moved it out of `src/module-api.js`); the live value is the `const CORE_VERSION` literal in `src/module-api.js`.
58
+
57
59
  | Bump | What it added |
58
60
  |---|---|
59
61
  | 1.0.0 | Initial doorway: auth gates, pool accessor, config/branding, seams. |
@@ -72,7 +72,7 @@ Three steps. The first two are ordinary claimed work; only the publish (2c) is o
72
72
 
73
73
  ### 2a. Bump `CORE_VERSION` — the step with no automation behind it
74
74
 
75
- `CORE_VERSION` lives in [`src/module-api.js`](../../src/module-api.js) and is bumped **by hand, inside a claimed task**, with a changelog line in the same file. It is never auto-bumped: the version's provenance stays in git history + changelog ([ADR 0137](../adr/<redacted>.md) §2).
75
+ `CORE_VERSION` lives in [`src/module-api.js`](../../src/module-api.js) and is bumped **by hand, inside a claimed task**, with an entry appended to the release ledger [`docs/module-api-changelog.md`](../module-api-changelog.md) in the same commit. (The ledger used to be a comment block inside `module-api.js`; appending to it there on every CI release grew the file past the oversized-file fitness ratchet and broke `unit` on `main` — [ADR 0203](../adr/<redacted>.md).) The version's provenance stays in git history + ledger ([ADR 0137](../adr/<redacted>.md) §2).
76
76
 
77
77
  Semver here is not decoration — it decides **who receives the change automatically** ([ADR 0136](../adr/<redacted>.md)):
78
78
 
@@ -82,7 +82,7 @@ Semver here is not decoration — it decides **who receives the change automatic
82
82
  | **minor** — an added export / additive surface | ❌ **invisible to them** | ✅ |
83
83
  | **major** — never automatic for anyone | ❌ | ❌ |
84
84
 
85
- The practical trap: a **MINOR is silently withheld from every patch-only instance**, which is the default channel. If a fix must reach the fleet, it belongs behind a *patch* bump — so an additive export shipped alongside a needed fix will strand that fix. The changelog convention is to justify the level explicitly ("the doorway surface is byte-identical to X, so despite the volume this is → PATCH").
85
+ The practical trap: a **MINOR is silently withheld from every patch-only instance**, which is the default channel. If a fix must reach the fleet, it belongs behind a *patch* bump — so an additive export shipped alongside a needed fix will strand that fix. The ledger convention is to justify the level explicitly ("the doorway surface is byte-identical to X, so despite the volume this is → PATCH").
86
86
 
87
87
  **A bump is mandatory even for a one-file fix.** `bongos upgrade` refuses an equal version, and npm rejects a duplicate — a natural guard, and the reason "carrier bumps" exist to sweep up commits that landed with no bump behind them.
88
88
 
@@ -99,7 +99,7 @@ node scripts/gds/package-core.js --ref main --self-verify
99
99
 
100
100
  ### 2c. Publish — automatic on merge
101
101
 
102
- **Every merge to `main` publishes** ([ADR 0161](../adr/<redacted>.md), superseding ADR 0137's manual gate): once the required `unit` check succeeds on a main push, the `publish` workflow auto-bumps a **patch** if nobody bumped (via `scripts/gds/bump-version.js`, changelog line included), builds through the fail-closed no-leak gate with `--self-verify`, publishes, and tags `core-v<version>`. A **minor/major is still a human in-task bump** — CI never makes that call, because it decides what patch-channel subscribers receive unattended.
102
+ **Every merge to `main` publishes** ([ADR 0161](../adr/<redacted>.md), superseding ADR 0137's manual gate): once the required `unit` check succeeds on a main push, the `publish` workflow auto-bumps a **patch** if nobody bumped (via `scripts/gds/bump-version.js`, which writes the literal AND appends the ledger entry to [`docs/module-api-changelog.md`](../module-api-changelog.md) — ADR 0203; the carrier commit stages both), builds through the fail-closed no-leak gate with `--self-verify`, publishes, and tags `core-v<version>`. A **minor/major is still a human in-task bump** — CI never makes that call, because it decides what patch-channel subscribers receive unattended.
103
103
 
104
104
  The lane is armed by the owner via the `PUBLISH_ON_MERGE=1` repo variable + two secrets (`NPM_TOKEN`, `RELEASE_PUSH_TOKEN`); while disarmed it no-ops loudly. Manual fallback, same command CI runs:
105
105
 
@@ -55,7 +55,7 @@ npm publish ./clients/bongos-client # real (publishConfig makes it
55
55
 
56
56
  The steps below remain as the **manual fallback** (lane disarmed, npm outage, or a deliberate out-of-band release):
57
57
 
58
- 1. **Bump `CORE_VERSION`** in [`src/module-api.js`](../../src/module-api.js) (semver; a **patch** for a fix, **minor** for additive surface — a **major** is a deliberate, human-only event) and add a one-line changelog entry in the same file. This bump is a normal claimed task, not a release-tool side effect — the version's provenance stays in the git history + changelog.
58
+ 1. **Bump `CORE_VERSION`** in [`src/module-api.js`](../../src/module-api.js) (semver; a **patch** for a fix, **minor** for additive surface — a **major** is a deliberate, human-only event) and append an entry to the release ledger [`docs/module-api-changelog.md`](../module-api-changelog.md) in the same commit ([ADR 0203](../adr/<redacted>.md) moved that ledger out of `module-api.js`). This bump is a normal claimed task, not a release-tool side effect — the version's provenance stays in the git history + ledger.
59
59
  2. **Publish** (owner, from a clean checkout of the released commit):
60
60
  ```sh
61
61
  node scripts/gds/package-core.js --publish # DRY-RUN: runs the fail-closed no-leak gate, previews @bongos/core@<version>
@@ -0,0 +1,37 @@
1
+ -- core_229_goals_accepting_requests.sql — a private goal can be CLOSED to
2
+ -- join-requests without being closed as a goal (task 1002971, spec
3
+ -- docs/specs/bongos-v1-privacy-and-invites.md D6).
4
+ --
5
+ -- core_188 gave a goal `visibility`, and a private goal has always accepted
6
+ -- join-requests: the only "we're not taking applications" a lead could express
7
+ -- was to archive the goal, which is a different (and destructive) statement.
8
+ -- `accepting_requests` separates the two: a private goal with it FALSE still
9
+ -- runs, still invites, still shows in the list — it just refuses new
10
+ -- join-requests with honest copy instead of queueing asks nobody will answer.
11
+ --
12
+ -- INVITATIONS ARE UNAFFECTED BY DESIGN. Closing applications is about unsolicited
13
+ -- inbound; a lead reaching out to a specific builder is the deliberate act the
14
+ -- flag exists to protect. The flag is likewise MEANINGLESS on a public goal
15
+ -- (self-join needs no request), which is why the toggle renders only on private
16
+ -- goals — the column still exists on every row so the write path needs no
17
+ -- branch, and the refusal gate reads visibility first.
18
+ --
19
+ -- goals is a CORE-owned table (created in 160_goal_tier; ADR 0083 §Decision 5 —
20
+ -- a module migration may touch only its own <key>_ objects), so this lives in
21
+ -- core migrations/, core_-namespaced (ADR 0108 §5: a core migration numbered
22
+ -- above LEGACY_BARE_CORE_MAX=187 must carry the core_ prefix, else the fitness
23
+ -- namespacing check reds the build).
24
+ --
25
+ -- PURELY ADDITIVE + idempotent: DEFAULT true means every existing goal keeps
26
+ -- today's behaviour (a private goal takes requests), NOT NULL is safe because
27
+ -- the default backfills, and IF NOT EXISTS makes a replay a no-op.
28
+ -- Fresh-DB-replay safe; no hard-coded builder ids (learning 1238). migrate.sh
29
+ -- records this file's stem in schema_migrations (the modern 187 pattern — the
30
+ -- file does not self-insert).
31
+
32
+ BEGIN;
33
+
34
+ ALTER TABLE goals
35
+ ADD COLUMN IF NOT EXISTS accepting_requests boolean NOT NULL DEFAULT true;
36
+
37
+ COMMIT;
@@ -324,6 +324,8 @@ function createClient(opts = {}) {
324
324
  getGoalsId: (args) => request("GET", "/goals/{id}", { hasBody: false }, args),
325
325
  // PATCH /goals/{id} — rank: any-builder — PATCH /goals/:id
326
326
  patchGoalsId: (args) => request("PATCH", "/goals/{id}", { hasBody: true }, args),
327
+ // PATCH /goals/{id}/accepting-requests — rank: any-builder — PATCH /goals/:id/accepting-requests
328
+ patchGoalsIdAcceptingRequests: (args) => request("PATCH", "/goals/{id}/accepting-requests", { hasBody: true }, args),
327
329
  // POST /goals/{id}/archive — rank: metic+archon — POST /goals/:id/archive
328
330
  postGoalsIdArchive: (args) => request("POST", "/goals/{id}/archive", { hasBody: true }, args),
329
331
  // PATCH /goals/{id}/category — rank: any-builder — PATCH /goals/:id/category
@@ -334,6 +336,8 @@ function createClient(opts = {}) {
334
336
  postGoalsIdConflictsResolve: (args) => request("POST", "/goals/{id}/conflicts/resolve", { hasBody: true }, args),
335
337
  // POST /goals/{id}/invitations — rank: any-builder — POST /goals/:id/invitations
336
338
  postGoalsIdInvitations: (args) => request("POST", "/goals/{id}/invitations", { hasBody: true }, args),
339
+ // POST /goals/{id}/invitations/{reqId}/rescind — rank: any-builder — POST /goals/:id/invitations/:reqId/rescind
340
+ postGoalsIdInvitationsReqIdRescind: (args) => request("POST", "/goals/{id}/invitations/{reqId}/rescind", { hasBody: true }, args),
337
341
  // POST /goals/{id}/invitations/{reqId}/respond — rank: any-builder — POST /goals/:id/invitations/:reqId/respond
338
342
  postGoalsIdInvitationsReqIdRespond: (args) => request("POST", "/goals/{id}/invitations/{reqId}/respond", { hasBody: true }, args),
339
343
  // POST /goals/{id}/join — rank: any-builder — POST /goals/:id/join
@@ -678,9 +678,31 @@
678
678
  }
679
679
  }
680
680
 
681
+ // rescindInvitation (task 1002971) — the goal's own authority takes back an
682
+ // invitation nobody has answered yet, the manager-side mirror of the
683
+ // requester's Withdraw. Shares respondToJoinRequest's in-flight guard so a
684
+ // double-click can't fire two rescinds at one row.
685
+ async function rescindInvitation(goalId, reqId, btn) {
686
+ const key = `rescind:${reqId}`;
687
+ if (pendingActionsPending.has(key)) return;
688
+ pendingActionsPending.add(key);
689
+ if (btn) btn.disabled = true;
690
+ try {
691
+ await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/invitations/${encodeURIComponent(reqId)}/rescind`, {});
692
+ toast('Invitation rescinded.');
693
+ await loadDetail(goalId, { force: true });
694
+ renderDetail(goalId);
695
+ } catch (err) {
696
+ toast((err && err.data && err.data.message) || 'Could not rescind that invitation just now.');
697
+ if (btn) btn.disabled = false;
698
+ } finally {
699
+ pendingActionsPending.delete(key);
700
+ }
701
+ }
702
+
681
703
  // task 1953 — every pending row on THIS goal, rendered for a manager: join
682
- // requests get Approve/Deny; outstanding invites are informational (only the
683
- // invitee can accept/decline theirs — the server has no manager-cancel route).
704
+ // requests get Approve/Deny; an outstanding invite the goal sent can be
705
+ // rescinded (task 1002971), and only the invitee can accept/decline theirs.
684
706
 
685
707
  // task 1953 — async membership-queue load, kicked off (fire-and-forget) from
686
708
  // renderDetail like loadGoalTasks: my own pending request on this goal (any
@@ -1060,6 +1082,8 @@
1060
1082
  if (approveBtn) { respondToJoinRequest(state.current, approveBtn.dataset.approveReq, 'approve', approveBtn); return; }
1061
1083
  const denyBtn = e.target.closest('[data-deny-req]');
1062
1084
  if (denyBtn) { respondToJoinRequest(state.current, denyBtn.dataset.denyReq, 'deny', denyBtn); return; }
1085
+ const rescindBtn = e.target.closest('[data-rescind-inv]');
1086
+ if (rescindBtn) { rescindInvitation(state.current, rescindBtn.dataset.rescindInv, rescindBtn); return; }
1063
1087
  });
1064
1088
  window.addEventListener('hashchange', route);
1065
1089
 
@@ -772,6 +772,13 @@
772
772
  const nextVisibility = g.visibility === 'private' ? 'public' : 'private';
773
773
  const label = g.visibility === 'private' ? '🔒 Private — make public' : 'Make private';
774
774
  parts.push(`<button class="btn-ghost" id="goal-visibility-btn" type="button" data-next-visibility="${escapeHtml(nextVisibility)}">${label}</button>`);
775
+ // task 1002971 (spec D6) — "closed to applications" is a PRIVATE-goal idea
776
+ // only: a public goal is self-join, so the toggle would say nothing there.
777
+ // Closing never touches invitations — a lead can always still invite.
778
+ if (g.visibility === 'private') {
779
+ const closing = g.accepting_requests !== false;
780
+ parts.push(`<button class="btn-ghost" id="goal-accepting-btn" type="button" data-next-accepting="${closing ? 'false' : 'true'}" title="${closing ? 'Stop taking join-requests; you can still invite people' : 'Take join-requests again'}">${closing ? 'Close to requests' : 'Open to requests'}</button>`);
781
+ }
775
782
  }
776
783
  if (g.status === 'open') {
777
784
  if (joined) {
@@ -780,6 +787,10 @@
780
787
  if (state.myPendingRequest) {
781
788
  parts.push('<span class="member-pill" title="Awaiting the owner/manager\'s approval">Requested</span>');
782
789
  parts.push('<button class="btn-ghost" id="goal-withdraw-btn" type="button">Withdraw</button>');
790
+ } else if (g.visibility === 'private' && g.accepting_requests === false) {
791
+ // task 1002971 — say so instead of offering a button the server will
792
+ // refuse; the same honest copy the API answers with.
793
+ parts.push('<span class="member-pill" title="The goal\'s leads have paused applications; they can still invite you">Not taking requests</span>');
783
794
  } else {
784
795
  parts.push('<button class="btn-ghost" id="goal-request-btn" type="button">🔒 Request to join</button>');
785
796
  }
@@ -791,6 +802,7 @@
791
802
  $('#goal-join-btn')?.addEventListener('click', () => joinLeave(g.id, 'join'));
792
803
  $('#goal-leave-btn')?.addEventListener('click', () => joinLeave(g.id, 'leave'));
793
804
  $('#goal-visibility-btn')?.addEventListener('click', (e) => toggleVisibility(g.id, e.currentTarget.dataset.nextVisibility));
805
+ $('#goal-accepting-btn')?.addEventListener('click', (e) => toggleAcceptingRequests(g.id, e.currentTarget.dataset.nextAccepting === 'true'));
794
806
  $('#goal-request-btn')?.addEventListener('click', () => requestToJoin(g.id));
795
807
  $('#goal-withdraw-btn')?.addEventListener('click', () => withdrawRequest(g.id, state.myPendingRequest && state.myPendingRequest.id));
796
808
  }
@@ -965,6 +977,22 @@
965
977
  else toast('Could not update visibility just now.');
966
978
  }
967
979
  }
980
+ // task 1002971 (spec D6) — open/close a PRIVATE goal to join-requests. Closing
981
+ // refuses new asks with honest copy; it never affects invitations, which is why
982
+ // the toast says so out loud rather than reading as "the goal is shut".
983
+ async function toggleAcceptingRequests(goalId, accepting) {
984
+ try {
985
+ await patchJSON(`${API}/goals/${encodeURIComponent(goalId)}/accepting-requests`, { accepting_requests: accepting });
986
+ toast(accepting ? 'This goal is taking join-requests again.' : 'Closed to join-requests — you can still invite people.');
987
+ await loadDetail(goalId, { force: true });
988
+ renderDetail(goalId);
989
+ } catch (err) {
990
+ const code = err?.data?.error;
991
+ if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may do this.');
992
+ else if (code === 'goal_not_open') toast('Only an open goal can be managed.');
993
+ else toast('Could not change that just now.');
994
+ }
995
+ }
968
996
  async function requestToJoin(goalId) {
969
997
  try {
970
998
  await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests`, {});
@@ -976,6 +1004,7 @@
976
1004
  if (code === 'request_pending') toast('You already have a pending request or invitation for this goal.');
977
1005
  else if (code === 'already_a_member') toast('You are already a member of this goal.');
978
1006
  else if (code === 'goal_is_open') toast('This goal is open — joining directly instead.');
1007
+ else if (code === 'not_accepting_requests') toast('This goal isn\'t taking requests right now.');
979
1008
  else toast('Could not send the request just now.');
980
1009
  }
981
1010
  }
@@ -1004,9 +1033,15 @@
1004
1033
  <button class="btn-ghost" type="button" data-deny-req="${escapeHtml(String(r.id))}">Deny</button>
1005
1034
  </span>
1006
1035
  </li>`;
1036
+ // task 1002971 — an invitation the goal SENT can now be taken back by the
1037
+ // people who sent it (core_188's 'cancelled' = "withdrawn/rescinded by the
1038
+ // opener"); before this the only un-send was asking the invitee to decline.
1007
1039
  const inviteLi = (r) => `<li class="pending-row">
1008
1040
  <span class="pending-row__who">${escapeHtml(pendingRequestWho(r))}</span>
1009
1041
  <span class="pending-row__note">invited — awaiting their response</span>
1042
+ <span class="pending-row__actions">
1043
+ <button class="btn-ghost" type="button" data-rescind-inv="${escapeHtml(String(r.id))}">Rescind</button>
1044
+ </span>
1010
1045
  </li>`;
1011
1046
  const parts = [];
1012
1047
  if (requests.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Join requests<span class="count-pill">${requests.length}</span></h3><ul class="pending-list">${requests.map(requestLi).join('')}</ul></div>`);
@@ -32,7 +32,7 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
32
32
  // Shared column list so every goal read/return shapes a row identically.
33
33
 
34
34
  const GOAL_COLS =
35
- 'id, version_id, title, subtitle, description, scope_modules, status, visibility, created_by, succeeded_by_goal_id, sort_order, category_id, created_at, updated_at';
35
+ 'id, version_id, title, subtitle, description, scope_modules, status, visibility, accepting_requests, created_by, succeeded_by_goal_id, sort_order, category_id, created_at, updated_at';
36
36
 
37
37
  // Shared column list for a goal_membership_requests row (task 1951 / ADR 0112) —
38
38
  // the invite / join-request queue behind private goals. Every helper below shapes
@@ -567,6 +567,26 @@ async function setGoalVisibility({ goalId, visibility }, deps = {}) {
567
567
  return rows[0] ?? null;
568
568
  }
569
569
 
570
+ // setGoalAcceptingRequests — open or close a PRIVATE goal to new join-requests
571
+ // (task 1002971, spec D6). false = closed: the request path refuses with honest
572
+ // copy instead of queueing asks nobody will answer; invitations keep working, so
573
+ // a lead can still reach out to a specific builder. Meaningless on a public goal
574
+ // (self-join needs no request) — the column exists on every row, and the refusal
575
+ // gate is what reads visibility. Bumps updated_at. Returns the updated goal row,
576
+ // or null for an unknown id. The route gates WHO may call this
577
+ // (authorizeGoalManagement: owner|manager|Archon); this helper just writes.
578
+ async function setGoalAcceptingRequests({ goalId, acceptingRequests }, deps = {}) {
579
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
580
+ const { rows } = await activePool.query(
581
+ `UPDATE goals
582
+ SET accepting_requests = $2, updated_at = now()
583
+ WHERE id = $1
584
+ RETURNING ${GOAL_COLS}`,
585
+ [goalId, acceptingRequests]
586
+ );
587
+ return rows[0] ?? null;
588
+ }
589
+
570
590
  // createMembershipRequest — open a pending invite (kind='invite', a manager asks
571
591
  // builder_id in) or join-request (kind='request', the builder asks to join). The
572
592
  // partial-unique index (one pending row per goal+builder) means a duplicate ask
@@ -758,6 +778,7 @@ module.exports = {
758
778
  setGoalMembershipKind,
759
779
  setGoalStatus,
760
780
  setGoalSuccessor,
781
+ setGoalAcceptingRequests,
761
782
  setGoalVisibility,
762
783
  transferGoalOwnership,
763
784
  };
@@ -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.411",
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.411",
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.411",
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",