@bongos/core 1.19.409 → 1.19.411
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +56 -41
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0203-doorway-changelog-out-of-module-api.md +54 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +122 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +43 -36
- package/docs/copy-registry.json +122 -39
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +1263 -0
- package/docs/modules-contract.md +2 -0
- package/docs/recipes/core-release-pipeline.md +3 -3
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_229_goals_accepting_requests.sql +37 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/goals-page.js +26 -2
- package/modules/hall-ui/public/goals-render.js +35 -0
- package/modules/lifecycle/db-goals.js +22 -1
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/goal-edits.js +40 -0
- package/modules/lifecycle/routes/goal-route-authz.js +51 -1
- package/modules/lifecycle/routes/goals.js +46 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bump-version.js +53 -25
- package/scripts/gds/deploy-divergence.js +1 -1
- package/scripts/gds/publish-manifest.js +6 -0
- package/src/bongos/module-scope-map.js +6 -1
- package/src/bongos/route-rank-check.js +6 -0
- package/src/module-api.js +10 -1227
- package/tests/bump_version.mjs +72 -13
- package/tests/goal_membership_requests.mjs +154 -1
- package/tests/prose_edits.mjs +8 -4
package/docs/modules-contract.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
683
|
-
//
|
|
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
|
};
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|