@bongos/core 1.19.1040 → 1.19.1042

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 (75) hide show
  1. package/.bongos-core.json +110 -80
  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 +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +14 -0
  8. package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +1 -0
  9. package/docs/adr/0340-every-hall-takes-its-update-from-settings-and-deploy-is-the-platform-halls-page.md +64 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +64 -7
  12. package/docs/api-reference.md +7 -1
  13. package/docs/copy-inventory.md +41 -21
  14. package/docs/copy-registry.json +207 -27
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +12 -0
  17. package/modules/grading/grader-subagent.js +7 -0
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/collab.html +1 -1
  22. package/modules/hall-ui/public/copy-desk.html +1 -1
  23. package/modules/hall-ui/public/deploy.html +1 -1
  24. package/modules/hall-ui/public/diagrams.html +1 -1
  25. package/modules/hall-ui/public/drachmae.html +1 -1
  26. package/modules/hall-ui/public/fleet.html +1 -1
  27. package/modules/hall-ui/public/gate.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/idea.html +1 -1
  31. package/modules/hall-ui/public/ideas.html +1 -1
  32. package/modules/hall-ui/public/index.html +1 -1
  33. package/modules/hall-ui/public/modules.html +1 -1
  34. package/modules/hall-ui/public/palette.js +1 -0
  35. package/modules/hall-ui/public/people.html +1 -1
  36. package/modules/hall-ui/public/primer.html +1 -1
  37. package/modules/hall-ui/public/profile.html +1 -1
  38. package/modules/hall-ui/public/project-settings.html +1 -1
  39. package/modules/hall-ui/public/ranks.html +1 -1
  40. package/modules/hall-ui/public/roadmap.html +1 -1
  41. package/modules/hall-ui/public/roster.html +1 -1
  42. package/modules/hall-ui/public/sessions.html +1 -1
  43. package/modules/hall-ui/public/settings-software-update.js +194 -0
  44. package/modules/hall-ui/public/settings.css +39 -0
  45. package/modules/hall-ui/public/settings.html +24 -2
  46. package/modules/hall-ui/public/settings.js +5 -0
  47. package/modules/hall-ui/public/settings.states.json +2 -1
  48. package/modules/hall-ui/public/shell.js +25 -39
  49. package/modules/hall-ui/public/studio.html +1 -1
  50. package/modules/hall-ui/public/task.html +1 -1
  51. package/modules/hall-ui/public/watch.html +1 -1
  52. package/modules/hall-ui/public/work.html +1 -1
  53. package/modules/hall-ui/records/panel-family.md +9 -0
  54. package/modules/npm-release/public/work.js +8 -3
  55. package/modules/npm-release/work.js +8 -2
  56. package/package-lock.json +2 -2
  57. package/package.json +1 -1
  58. package/release-notes.json +16 -0
  59. package/scripts/gds/fitness.js +7 -0
  60. package/scripts/gds/main-audit.js +204 -7
  61. package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
  62. package/src/bongos/routes/core-update.js +65 -3
  63. package/src/bongos/serve-internal.js +17 -0
  64. package/src/bongos/software-update.js +127 -0
  65. package/src/module-api.js +10 -1
  66. package/tests/core_upgrade_owner_door.mjs +27 -18
  67. package/tests/grade_model_provider.mjs +1 -1
  68. package/tests/grader_reviewer_isolation.mjs +68 -0
  69. package/tests/helpers.mjs +92 -0
  70. package/tests/main_audit_attestation.mjs +238 -0
  71. package/tests/module_api.mjs +1 -0
  72. package/tests/nav_permission_atoms.mjs +1 -1
  73. package/tests/npm_release_page.mjs +11 -1
  74. package/tests/npm_release_work.mjs +9 -94
  75. package/tests/software_update.mjs +449 -0
@@ -57,7 +57,7 @@
57
57
 
58
58
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
59
59
  time so the chrome exists before the deferred page scripts execute. -->
60
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
60
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
61
61
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
62
62
  <script src="/builders/palette.js" defer></script>
63
63
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
@@ -162,7 +162,7 @@
162
162
 
163
163
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
164
164
  chrome exists before the page script runs. -->
165
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
165
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
166
166
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
167
167
  <script src="/builders/palette.js" defer></script>
168
168
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -171,7 +171,7 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
174
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
175
175
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
176
176
  <script src="/builders/palette.js" defer></script>
177
177
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -17,3 +17,12 @@ Also fixed here, as the board task handed forward: the two sub-24px links on Hom
17
17
  - **Honest empties, one line each.** Signed out (or a 401 mid-way) the grid is the same permission sentence the activity ledgers use; an empty record is "No works shipped yet"; a failed read says so. Never three empty cards.
18
18
  - **The Works key, and its public fallback.** Works is the full server-paged ledger from `/builders/:id/activity?section=tasks` (any signed-in viewer, any builder); signed out, the page's PUBLIC `recent_ships` stand in with a sign-in line, so a stranger still sees the last few. The Overview no longer carries a works strip — that removes the duplicate the DONE-WHEN named.
19
19
  - **A lookback control (owner request, task 1003489) scopes every chart** — 30 days / 90 days / 1 year / All time, one segmented row above the grid (the dataviz date-range-first rule). The cumulative charts clip their axis to the window and ENTER it at the running total, not at zero (`clipToWindow`); the craft mix counts within it; the pace chart passes the window to `/analytics/builder/:id?days=`. Remembered per viewer; changing it re-draws from the rows already in hand — no re-fetch. (A chart LIBRARY was floated by the owner and deliberately NOT taken here — the hall ships none, the hand-drawn charts satisfy the ask, and the swap is its own idea to weigh CSP/weight/theming/table-twin against.)
20
+
21
+ **Settings gained a sixth group, Software update (task 1004296)** — every project's running core, the newest release its update channel lets it take, and what each version in between brings, like a phone's Software Update screen. `settings-software-update.js` renders it from `GET /api/bongos/software-update`; the reading is `src/bongos/software-update.js` over the core's registry reader.
22
+
23
+ - **It is read when its group opens, not with the page.** An update's notes come out of the newer package's download, and most visits to Settings are for something else, so `activateGroup` in `settings.js` announces each switch as a `settings:group` event and the panel loads on its own group, once.
24
+ - **The Update button leads to the door; it is not the door.** The act is the deploy door's two steps and auto-rollback (ADR 0293): `/deploy` in the platform's hall, the hub's `/deploy?project=` for a hosted project (ADR 0339 D3), and a `bongos upgrade` sentence where there is no door at all. A second copy of that flow here would be a second place for its safety rules to drift. The button is drawn only when the server says the viewer holds `core.pin.move` — cosmetic, the door re-checks.
25
+ - **Everyone reads it.** The version and what's new are for every builder (the owner's ask); the route discloses nothing the public `/version` and npm do not already publish.
26
+ - **"Up to date" is said only from an answer.** A failed registry read, an unnamed running version and unread release notes each render as what they are; unread notes are never drawn as a version with no changes.
27
+ - **The notes are the soft ink, underlined, on the platform.** There every entry links to its task, and a list of accent-coloured sentences read as a warning. Elsewhere the ids belong to another project's ledger, so the entries are plain text.
28
+ - **Kit:** `settings.states.json` `update`, ALL CLEAN at 1440/390/320 × dark/light against the harness with the fixture `software-update.json` (a live reading for 1.19.1033). Tests: `tests/software_update.mjs`.
@@ -64,8 +64,13 @@
64
64
 
65
65
  // The title IS the link, the hall's own row idiom (oversight.css `a.ov-row__title`),
66
66
  // which is what buys it the 24px floor and the row's ink rather than link colour.
67
+ // Voice rule V4 (docs/voice.md): a dash is banned in QUOTED data — and a task title and a
68
+ // release-note line are both handed over by the ledger — so it becomes a comma at render,
69
+ // the way goal-inbox.js and the public feed's cleanTitle() do. Never by editing the data.
70
+ const unDash = (raw) => String(raw == null ? '' : raw).replace(/\s*[—–]\s*/g, ', ').replace(/,\s*,/g, ',').trim();
71
+
67
72
  function taskTitle(id, title) {
68
- return `<a class="ov-row__title" href="/task/${encodeURIComponent(id)}">${escapeHtml(title || `Task ${id}`)}</a>`;
73
+ return `<a class="ov-row__title" href="/task/${encodeURIComponent(id)}">${escapeHtml(unDash(title) || `Task ${id}`)}</a>`;
69
74
  }
70
75
 
71
76
  function taskRow(t, { offeredInNote = false } = {}) {
@@ -151,8 +156,8 @@
151
156
  const shown = expanded.has(key) ? v.entries : v.entries.slice(0, ENTRIES_SHOWN);
152
157
  body = `<ul class="ov-rows">${shown.map((e) => `<li class="ov-row ov-row--dense">
153
158
  <div class="ov-row__main">
154
- ${e.id ? taskTitle(e.id, e.title) : `<span class="ov-row__title">${escapeHtml(e.text)}</span>`}
155
- ${e.id ? `<p class="ov-row__sub">${escapeHtml(e.text)}</p>` : ''}
159
+ ${e.id ? taskTitle(e.id, e.title) : `<span class="ov-row__title">${escapeHtml(unDash(e.text))}</span>`}
160
+ ${e.id ? `<p class="ov-row__sub">${escapeHtml(unDash(e.text))}</p>` : ''}
156
161
  </div>
157
162
  </li>`).join('')}</ul>${moreButton(key, v.entries.length, shown.length)}`;
158
163
  }
@@ -30,12 +30,18 @@
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
  const api = require('../../src/module-api');
33
- const { readRegistry, compareVersions } = require('./registry');
34
33
 
35
34
  // The package this server runs — the one whose "live" version is this process's own core.
36
35
  // Any other package has no "live on this hall" stage: nothing here runs it.
37
36
  const CORE_PACKAGE = '@bongos/core';
38
37
  const VERSION_RE = /^\d+\.\d+\.\d+$/;
38
+
39
+ function compareVersions(a, b) {
40
+ const x = String(a).split('.').map(Number);
41
+ const y = String(b).split('.').map(Number);
42
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
43
+ return 0;
44
+ }
39
45
  const TEXT_MAX = 400;
40
46
  const DEFAULT_DAYS = 14;
41
47
  const MAX_DAYS = 60;
@@ -201,7 +207,7 @@ async function readWork({
201
207
  const localVersions = versionsIn(local);
202
208
  const localTop = localVersions.length ? localVersions[localVersions.length - 1].version : null;
203
209
 
204
- const { registry, notes } = await readRegistry({
210
+ const { registry, notes } = await api.readPackageRegistry({
205
211
  pkg, get, now, store,
206
212
  haveNotesFor: (v) => !!localTop && compareVersions(localTop, v) >= 0,
207
213
  });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1040",
3
+ "version": "1.19.1042",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1040",
9
+ "version": "1.19.1042",
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.1040",
3
+ "version": "1.19.1042",
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",
@@ -7537,5 +7537,21 @@
7537
7537
  "id": "1003368",
7538
7538
  "text": "Closed a security hole where a builder's branch could choose the program the server runs while auto-resolving merge conflicts; the server now always uses its own trusted copy."
7539
7539
  }
7540
+ ],
7541
+ "1.19.1041": [
7542
+ {
7543
+ "id": "1000380",
7544
+ "text": "The grader's reviewers now see only the file-reading tool they need. Before, they could also reach every service connected on the machine running them, such as documents and email, with no approval prompts, while reading code"
7545
+ }
7546
+ ],
7547
+ "1.19.1042": [
7548
+ {
7549
+ "id": "1004296",
7550
+ "text": "Every project's Settings now has a Software update page: the version it runs, whether a newer one is ready, what each newer version brings, and an Update button for the people allowed to upgrade."
7551
+ },
7552
+ {
7553
+ "id": "1001433",
7554
+ "text": "The nightly security check of the main code line no longer trusts the name typed on each change, which anyone can fake. It now checks that every change to a sensitive file carries the server's own sign-off from a senior builde"
7555
+ }
7540
7556
  ]
7541
7557
  }
@@ -586,6 +586,13 @@ const KERNEL_FILES = [
586
586
  // the composed door, so reader and composer had to sit together or the two would
587
587
  // cycle.) The ARTIST GATE joins for the same reason — task 1003575, ADR 0241.
588
588
  'src/bongos/project-door.js', 'src/bongos/artist-gate.js',
589
+ // task 1004296 (ADR 0340 D5): the npm registry reader — packument, the in-process
590
+ // tarball stream and its small tar reader. It moved INTO the core because Settings →
591
+ // Software update needs it in every hall, and the doorway re-exports it
592
+ // (readPackageRegistry) for the module that publishes a package, so leaving it off
593
+ // this roster reads as a kernel->non-kernel leak. It imports only node built-ins
594
+ // (https, zlib), so joining costs nothing.
595
+ 'src/bongos/package-registry.js',
589
596
  ];
590
597
 
591
598
  // Kernel files exempt from the no-module-keys scan (Check 10) because naming a
@@ -26,6 +26,20 @@
26
26
  // rank on a protected-path commit is a 'review' finding (in --verify-head it
27
27
  // blocks unless --allow-unverified, fail-closed).
28
28
  //
29
+ // THE ATTESTATION PASS (task 1001433; owner's option 1, 2026-09-26). The author
30
+ // NAME above is typed text — anyone can commit as 'IAMMASTERQUA'. What cannot be
31
+ // typed is the `gate-author-trust` commit status the SERVER posts on a ship's
32
+ // pushed tip after resolving the builder's CLI session to a Metic+ rank
33
+ // (POST /tasks/:id/attest-gate + publish-branch, ADR 0058). So each protected-path
34
+ // commit is traced to the pull request that brought it onto main (the second
35
+ // parent of its first-parent merge) and that head must carry a SUCCESS status of
36
+ // that context, posted by the server's own GitHub identity. A status with the same
37
+ // name posted by any other account is ignored: anyone with write access can post a
38
+ // status, so the CREATOR is the part that cannot be faked. FLAG-ONLY for now, by
39
+ // the owner's ruling: an unattested commit is a 'review' finding and never changes
40
+ // the exit code. It needs a GitHub token (GITHUB_TOKEN / GH_TOKEN); without one it
41
+ // says it did not run rather than passing.
42
+ //
29
43
  // Usage:
30
44
  // node scripts/gds/main-audit.js # audit last 50 on origin/main
31
45
  // node scripts/gds/main-audit.js --range A..B # audit an explicit range
@@ -35,6 +49,10 @@
35
49
  // node scripts/gds/main-audit.js --strict # also block on unresolved authors
36
50
  // node scripts/gds/main-audit.js --db # resolve ranks from Postgres instead of the API
37
51
  // node scripts/gds/main-audit.js --require-roster # CI: an empty roster is a loud DID-NOT-RUN, not a vacuous green
52
+ // node scripts/gds/main-audit.js --require-attestation # CI: an attestation pass that could not run exits 2
53
+ // node scripts/gds/main-audit.js --attest-login <login> # the account the server posts statuses as (default: the repo owner)
54
+ // node scripts/gds/main-audit.js --no-attestation # skip the attestation pass
55
+ // GH_TOKEN=$(gh auth token) node scripts/gds/main-audit.js # a local run with the attestation pass
38
56
  //
39
57
  // EXIT CONTRACT — never `process.exit()` here, use `cliExit(code)` + `return`
40
58
  // (task 1003772). Every exit in this file lands after the roster request, and a
@@ -160,6 +178,102 @@ function strictestFloorLabel(hits, registry) {
160
178
  return top ? top.charAt(0).toUpperCase() + top.slice(1) : 'Metic';
161
179
  }
162
180
 
181
+ // ---------- attestation pass (task 1001433) — pure half ----------
182
+
183
+ // The status context the server posts on a ship's pushed tip (task-route-helpers.js
184
+ // postGateAuthorTrust). Only a SUCCESS of this context by the trusted creator counts.
185
+ const ATTEST_CONTEXT = 'gate-author-trust';
186
+
187
+ // Which pull-request head vouches for each commit. For every MAINLINE merge (a
188
+ // first-parent commit of main with two parents), the commits it brought in —
189
+ // reachable from its second parent and not from its first — are covered by that
190
+ // second parent: the PR head the server attested at ship time. `revList(range)`
191
+ // returns the shas of a `git rev-list` range; it is a parameter so the mapping is
192
+ // testable without a repository.
193
+ //
194
+ // Only MAINLINE merges may be passed in. A merge INSIDE a pull request ("Merge
195
+ // origin/main into my-branch") has main commits as its second parent, and letting
196
+ // it vouch would credit a branch's merge-from-main for work it never carried.
197
+ function mapIntroducingHeads(mainlineMerges, revList) {
198
+ const headOf = new Map();
199
+ for (const m of mainlineMerges || []) {
200
+ const [first, second] = m.parents || [];
201
+ if (!first || !second) continue;
202
+ for (const sha of revList(`${first}..${second}`)) {
203
+ if (!headOf.has(sha)) headOf.set(sha, second);
204
+ }
205
+ }
206
+ return headOf;
207
+ }
208
+
209
+ // Did the trusted account attest this head? `statuses` is GitHub's list for one
210
+ // commit, newest first. Statuses from any other creator are ignored outright —
211
+ // they can neither attest nor revoke — and of the trusted creator's own statuses
212
+ // for the context, only the newest decides, so a later failure withdraws a success.
213
+ function isAttested(statuses, trustedLogin) {
214
+ const want = String(trustedLogin || '').toLowerCase();
215
+ if (!want) return false;
216
+ const latest = (Array.isArray(statuses) ? statuses : []).find((s) => s
217
+ && s.context === ATTEST_CONTEXT
218
+ && String(s.creator?.login || '').toLowerCase() === want);
219
+ return !!latest && latest.state === 'success';
220
+ }
221
+
222
+ // commits: parsed commits (only the protected-path ones matter; others are skipped).
223
+ // headOf: Map sha → the PR head that brought it in (mapIntroducingHeads). A commit
224
+ // with no entry — a direct push to main, or a merge's own smuggled --cc surface —
225
+ // can only be vouched for by an attestation on ITSELF.
226
+ // attested: Set of head shas that carry the trusted attestation (fetchAttestations).
227
+ // Returns { checked, findings[] }. Every finding is severity 'review': the owner
228
+ // ruled this pass flag-only, so it can never make the audit fail.
229
+ function analyzeAttestation(commits, { headOf = new Map(), attested = new Set(), trustedLogin = '', registry = ppc } = {}) {
230
+ const findings = [];
231
+ let checked = 0;
232
+ for (const c of commits || []) {
233
+ const hits = registry.matchProtected(c.files || []);
234
+ if (hits.length === 0) continue;
235
+ checked += 1;
236
+ // `viaPr`, not `head === c.sha`: a one-commit pull request's head IS the commit.
237
+ const viaPr = headOf.has(c.sha);
238
+ const head = viaPr ? headOf.get(c.sha) : c.sha;
239
+ if (attested.has(head)) continue;
240
+ const fileList = hits.map((h) => h.file).join(', ');
241
+ const mark = `successful '${ATTEST_CONTEXT}' status from '${trustedLogin}'`;
242
+ const confirm = `Confirm it came through a Bongos ship by a ${strictestFloorLabel(hits, registry)}+ builder.`;
243
+ const summary = viaPr
244
+ ? `commit ${short(c.sha)} by '${c.authorName}' touched permission-sensitive path(s) [${fileList}], but the pull request that brought it in (head ${short(head)}) carries no ${mark}. ${confirm}`
245
+ : `commit ${short(c.sha)} by '${c.authorName}' touched permission-sensitive path(s) [${fileList}] and reached main without passing through a pull request (a direct push, or changes a merge made itself), and it carries no ${mark} of its own. ${confirm}`;
246
+ findings.push({
247
+ severity: 'review',
248
+ kind: 'unattested',
249
+ sha: c.sha,
250
+ author: c.authorName,
251
+ head,
252
+ files: hits.map((h) => h.file),
253
+ summary,
254
+ });
255
+ }
256
+ return { checked, findings };
257
+ }
258
+
259
+ // Where the pass reads GitHub from. The token and repository come from the
260
+ // environment GitHub Actions already provides (GITHUB_TOKEN, GITHUB_REPOSITORY),
261
+ // with GH_TOKEN and the origin remote as the laptop fallbacks. The trusted login
262
+ // defaults to the repository OWNER: this platform's server posts statuses with the
263
+ // owner's push token (observed on PR 1233: the status creator is the owner's account), and the owner can
264
+ // push to main directly anyway, so trusting that identity grants nothing new. An
265
+ // instance whose server posts as a GitHub App passes --attest-login <app>[bot].
266
+ function attestationConfig({ env = process.env, remoteUrl = '', loginArg = null } = {}) {
267
+ const token = env.GITHUB_TOKEN || env.GH_TOKEN || null;
268
+ let repo = env.GITHUB_REPOSITORY || null;
269
+ if (!repo && remoteUrl) {
270
+ const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(String(remoteUrl).trim());
271
+ if (m) repo = m[1];
272
+ }
273
+ const trustedLogin = (typeof loginArg === 'string' && loginArg) || (repo ? repo.split('/')[0] : null);
274
+ return { token, repo, trustedLogin };
275
+ }
276
+
163
277
  // ---------- git + API glue (CLI) ----------
164
278
 
165
279
  function git(args) {
@@ -219,8 +333,10 @@ function parseCommits(range) {
219
333
  const subject = rest.shift();
220
334
  const files = rest.map((s) => s.trim()).filter(Boolean);
221
335
  if (!sha) continue;
222
- const isMerge = parents.split(/\s+/).filter(Boolean).length > 1;
223
- commits.push({ sha, subject, authorName, authorEmail, isMerge, files });
336
+ // Kept in order: the attestation pass needs a merge's SECOND parent (the PR head).
337
+ const parentList = parents.split(/\s+/).filter(Boolean);
338
+ const isMerge = parentList.length > 1;
339
+ commits.push({ sha, subject, authorName, authorEmail, isMerge, parents: parentList, files });
224
340
  }
225
341
  // #863-followup (finding #5): `git log --name-only` emits NO files for merge
226
342
  // commits, so a merge that SMUGGLES a protected change (an "evil merge" /
@@ -310,6 +426,69 @@ async function fetchRankMap({ useDb = false } = {}) {
310
426
  return {};
311
427
  }
312
428
 
429
+ // ---------- attestation pass (task 1001433) — I/O half ----------
430
+
431
+ const GITHUB_API = 'https://api.github.com';
432
+
433
+ // Read each head's statuses and keep the ones the trusted account attested. A head
434
+ // whose statuses could not be read is reported as `unreadable`, never guessed at:
435
+ // an unread head is not an unattested one, and treating it as attested would be
436
+ // the vacuous pass this file keeps refusing.
437
+ async function fetchAttestations(heads, { repo, token, trustedLogin, fetchImpl = fetch, apiBase = GITHUB_API } = {}) {
438
+ const attested = new Set();
439
+ const unreadable = [];
440
+ for (const sha of new Set(heads || [])) {
441
+ try {
442
+ const res = await fetchImpl(`${apiBase}/repos/${repo}/commits/${sha}/statuses?per_page=100`, {
443
+ headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, 'X-GitHub-Api-Version': '2022-11-28' },
444
+ });
445
+ if (!res.ok) { unreadable.push({ sha, reason: `HTTP ${res.status}` }); continue; }
446
+ if (isAttested(await res.json(), trustedLogin)) attested.add(sha);
447
+ } catch (e) {
448
+ unreadable.push({ sha, reason: e.message });
449
+ }
450
+ }
451
+ return { attested, unreadable };
452
+ }
453
+
454
+ function shaLines(out) {
455
+ return String(out || '').split('\n').map((s) => s.trim()).filter(Boolean);
456
+ }
457
+
458
+ // The whole pass for one audited range. Returns { ran: true, checked, findings }
459
+ // or { ran: false, reason } — the caller prints either, and only
460
+ // --require-attestation turns a pass that could not run into a non-zero exit.
461
+ async function runAttestationPass(commits, range, { loginArg = null, env = process.env, gitImpl = git, fetchImpl } = {}) {
462
+ const protectedCommits = (commits || []).filter((c) => ppc.matchProtected(c.files || []).length > 0);
463
+ if (protectedCommits.length === 0) return { ran: true, checked: 0, findings: [] };
464
+ let remoteUrl = '';
465
+ try { remoteUrl = gitImpl('remote get-url origin').trim(); } catch (_) { /* no origin: env must name the repo */ }
466
+ const cfg = attestationConfig({ env, remoteUrl, loginArg });
467
+ if (!cfg.token) return { ran: false, reason: 'no GitHub token (set GITHUB_TOKEN or GH_TOKEN) to read commit statuses with' };
468
+ if (!cfg.repo) return { ran: false, reason: 'could not tell which GitHub repository this is (set GITHUB_REPOSITORY=owner/repo)' };
469
+
470
+ let mainline;
471
+ try {
472
+ mainline = new Set(shaLines(gitImpl(`rev-list --first-parent ${range}`)));
473
+ } catch (e) {
474
+ return { ran: false, reason: `git could not list the first-parent history of ${range}: ${e.message}` };
475
+ }
476
+ const merges = (commits || []).filter((c) => mainline.has(c.sha) && (c.parents || []).length > 1);
477
+ let headOf;
478
+ try {
479
+ headOf = mapIntroducingHeads(merges, (r) => shaLines(gitImpl(`rev-list ${r}`)));
480
+ } catch (e) {
481
+ return { ran: false, reason: `git could not trace which pull request brought each commit in: ${e.message}` };
482
+ }
483
+ const heads = protectedCommits.map((c) => headOf.get(c.sha) || c.sha);
484
+ const { attested, unreadable } = await fetchAttestations(heads, { ...cfg, ...(fetchImpl ? { fetchImpl } : {}) });
485
+ if (unreadable.length) {
486
+ const sample = unreadable.slice(0, 3).map((u) => `${short(u.sha)} (${u.reason})`).join(', ');
487
+ return { ran: false, reason: `could not read the commit statuses of ${unreadable.length} pull-request head(s): ${sample}` };
488
+ }
489
+ return { ran: true, ...analyzeAttestation(protectedCommits, { headOf, attested, trustedLogin: cfg.trustedLogin }), trustedLogin: cfg.trustedLogin };
490
+ }
491
+
313
492
  function arg(name, fallback = null) {
314
493
  const i = process.argv.indexOf(name);
315
494
  return i >= 0 ? (process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : true) : fallback;
@@ -381,14 +560,28 @@ async function main() {
381
560
  }
382
561
 
383
562
  const result = analyzeCommits(parsed.commits, rankMap, { strict });
563
+ // Task 1001433: the attestation pass. FLAG-ONLY — its findings are 'review' and
564
+ // never reach `hardFail`; the one thing it can do to the exit code is report
565
+ // that it did not run, and only when the caller asked with --require-attestation.
566
+ const loginArg = arg('--attest-login');
567
+ const attestation = process.argv.includes('--no-attestation')
568
+ ? { ran: false, skipped: true, reason: 'skipped (--no-attestation)' }
569
+ : await runAttestationPass(parsed.commits, range, { loginArg: loginArg === true ? null : loginArg });
570
+ const findings = result.findings.concat(attestation.findings || []);
384
571
 
385
572
  console.log(`\nMain audit (SEC #863) — range ${range}, ${parsed.commits.length} commit(s)`);
386
- if (result.findings.length === 0) {
573
+ if (attestation.ran) {
574
+ console.log(` attestation (task 1001433, flag-only): ${attestation.checked} permission-sensitive commit(s) checked, ${attestation.findings.length} without the server's '${ATTEST_CONTEXT}' mark.`);
575
+ } else {
576
+ console.log(` attestation (task 1001433, flag-only): DID NOT RUN — ${attestation.reason}.`);
577
+ }
578
+ const attestationMissing = !attestation.ran && !attestation.skipped && process.argv.includes('--require-attestation');
579
+ if (findings.length === 0) {
387
580
  console.log(' ✓ clean — no sub-Metic edits to permission-sensitive paths.\n');
388
- cliExit(0);
581
+ cliExit(attestationMissing ? 2 : 0);
389
582
  return;
390
583
  }
391
- for (const f of result.findings) {
584
+ for (const f of findings) {
392
585
  const tag = f.severity === 'blocker' ? '\x1b[31m[BLOCKER]\x1b[0m' : '\x1b[33m[review]\x1b[0m';
393
586
  console.log(` ${tag} ${f.summary}`);
394
587
  }
@@ -407,8 +600,9 @@ async function main() {
407
600
  cliExit(result.hardFail ? 1 : 0);
408
601
  return;
409
602
  }
410
- // report mode: non-zero on a real blocker so CI can fail.
411
- cliExit(result.hardFail ? 1 : 0);
603
+ // report mode: non-zero on a real blocker so CI can fail. A blocker outranks an
604
+ // attestation pass that did not run: 1 is a definite answer, 2 is "no answer".
605
+ cliExit(result.hardFail ? 1 : (attestationMissing ? 2 : 0));
412
606
  }
413
607
 
414
608
  if (require.main === module) {
@@ -425,4 +619,7 @@ module.exports = {
425
619
  // path was dead for as long as the db.js carve has been landed precisely because
426
620
  // nothing checked that this require target still exports what it is called for.
427
621
  ROSTER_DB_MODULE, fetchRankMap,
622
+ // The attestation pass (task 1001433): pure halves + the I/O half with its seams.
623
+ ATTEST_CONTEXT, mapIntroducingHeads, isAttested, analyzeAttestation, attestationConfig,
624
+ fetchAttestations, runAttestationPass,
428
625
  };
@@ -1,14 +1,21 @@
1
1
  'use strict';
2
2
 
3
- // modules/npm-release/registry.js — what the npm registry says about the package: which
3
+ // src/bongos/package-registry.js — what the npm registry says about a package: which
4
4
  // versions exist, when each was published, which one carries the `latest` label, and the
5
- // release notes the newest version was packed with (task 1002622).
5
+ // release notes the newest version was packed with (task 1002622; moved into the core by
6
+ // task 1004296).
7
+ //
8
+ // WHY IT IS THE CORE'S. Two readers need it. Settings → Software update (software-update.js)
9
+ // runs in EVERY project's hall, because every project installs releases; a deploy-page
10
+ // module runs in the one hall that publishes them and reaches this through the doorway
11
+ // (`readPackageRegistry` in src/module-api.js). Kept in a module, the one reader every hall
12
+ // needs would have been inside the one module only one hall runs.
6
13
  //
7
14
  // WHY THE NEWEST PACKAGE'S NOTES. package-core writes release-notes.json from the WHOLE
8
15
  // history at pack time (scripts/gds/release-notes.js, task 1004218), so the newest version
9
- // alone lists every version before it. One file answers the whole page. The copy the
10
- // running core carries stops at its own version, which is exactly the range the deploy
11
- // page is asked about least: the work that is published but not running yet.
16
+ // alone lists every version before it. One file answers every reader. The copy the
17
+ // running core carries stops at its own version, which is exactly the range both readers
18
+ // are asked about most: what is published but not running yet.
12
19
  //
13
20
  // NEVER A CHILD PROCESS, NEVER A SYNC CALL. This runs inside the web process, so the
14
21
  // upgrade runner's `npm pack` + `tar -xzOf` (provision-core-upgrade.js readPackagedNotes)
@@ -242,7 +249,7 @@ async function cached(slot, fresh, load, t) {
242
249
  }
243
250
 
244
251
  /**
245
- * Everything the registry can tell the deploy page about `pkg`, in one call.
252
+ * Everything the registry can tell a reader about `pkg`, in one call.
246
253
  *
247
254
  * Returns `{ registry, notes }`:
248
255
  * registry — `{ ok, versions, published, latest, newest }` or `{ ok:false, error }`
@@ -268,4 +275,4 @@ async function readRegistry({ pkg, haveNotesFor = () => false, get = https.get,
268
275
  return { registry, notes: { ...notes, version } };
269
276
  }
270
277
 
271
- module.exports = { readRegistry, compareVersions };
278
+ module.exports = { readRegistry };
@@ -17,11 +17,21 @@
17
17
  // press the button is a worse experience than showing nothing.
18
18
  // The gate costs its real audience nothing: an owner holds the atom on their own
19
19
  // instance by construction.
20
+ //
21
+ // GET /software-update (task 1004296) is the exception, and deliberately so: it is the
22
+ // Settings panel's read, and the owner asked for the version and what's new to be shown in
23
+ // EVERY builder's Settings, like a phone's Software Update screen. It discloses nothing the
24
+ // public GET /version does not already publish (the running core) or npm does not (what is
25
+ // newer, and its release notes), so the vulnerability-window reason above does not apply to
26
+ // it — and it offers no act itself. Who may press Update is still the door's decision; this
27
+ // only says, cosmetically, whether the viewer holds the atom the door checks.
20
28
 
21
29
  const express = require('express');
22
30
  const { asyncHandler } = require('./_helpers');
23
31
  const auth = require('../auth');
24
32
  const { readCoreUpdate, readCoreUpgrades } = require('../core-update');
33
+ const { readSoftwareUpdate } = require('../software-update');
34
+ const seams = require('../../module-seams');
25
35
  const { loadIdpConfig } = require('../auth-config');
26
36
 
27
37
  // WHERE A HOSTED PROJECT'S OWNER TAKES THE UPGRADE (task 1004174, ADR 0339): the hub's
@@ -30,7 +40,9 @@ const { loadIdpConfig } = require('../auth-config');
30
40
  // no capability. Both halves are already here on a federated project: provisioning
31
41
  // writes the hub origin and this project's hub client id, which IS its slug (ADR 0246),
32
42
  // into web.env. null when this instance is not federated with a hub — a self-hosted
33
- // instance, or the hub itself — so the hall offers no link rather than a wrong one.
43
+ // instance, or the hub itself — so the hall offers no link rather than a wrong one. Since
44
+ // task 1004296 it is the Settings panel's Update button that carries it (updateDoor below),
45
+ // not the banner or the rail.
34
46
  // Only the origin and the client id are read; the client secret never leaves auth-config.
35
47
  function hubDeployUrl(config) {
36
48
  if (!config || typeof config.origin !== 'string' || !config.clientId) return null;
@@ -38,6 +50,35 @@ function hubDeployUrl(config) {
38
50
  return `${config.origin.replace(/\/+$/, '')}/deploy?project=${encodeURIComponent(String(config.clientId))}`;
39
51
  }
40
52
 
53
+ // WHERE THIS PROJECT'S UPDATE BUTTON GOES (task 1004296). The act itself never happens in
54
+ // Settings: it is the deploy door's two-step confirm and auto-rollback (ADR 0293), and that
55
+ // door is the provisioning module's — so only a hall that runs provisioning has one.
56
+ // local — this hall runs the door: its own /deploy.
57
+ // hub — a hosted project: the hub's door for this project (ADR 0339 D3).
58
+ // cli — neither: a self-hosted instance, upgraded on its server with `bongos upgrade`.
59
+ function updateDoor({ provisioning, idp }) {
60
+ if (provisioning) return { kind: 'local', url: '/deploy' };
61
+ const url = hubDeployUrl(idp);
62
+ return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
63
+ }
64
+
65
+ function provisioningEnabled() {
66
+ try { return require('../../modules').isModuleEnabled('provisioning'); } catch { return false; }
67
+ }
68
+
69
+ // Does the viewer hold `core.pin.move`? COSMETIC — it decides whether the panel draws the
70
+ // Update button, never whether an update may happen; the door re-checks per request. The
71
+ // same read GET /me's nav permissions make, and the same fail-closed reading: authority that
72
+ // cannot be established is "no".
73
+ async function holdsCorePin(req) {
74
+ try {
75
+ const government = seams.resolveOptional('government');
76
+ if (!government || typeof government.resolveBuilderPermissions !== 'function') return false;
77
+ const held = (await government.resolveBuilderPermissions(req.builder && req.builder.id)) || [];
78
+ return held.includes('core.pin.move');
79
+ } catch { return false; }
80
+ }
81
+
41
82
  function safeIdpConfig() {
42
83
  try { return loadIdpConfig(); } catch { return null; }
43
84
  }
@@ -49,7 +90,12 @@ function upgradesLimit(raw) {
49
90
  return Math.min(100, Math.max(1, n));
50
91
  }
51
92
 
52
- function buildCoreUpdateRouter({ upgrades = readCoreUpgrades } = {}) {
93
+ function buildCoreUpdateRouter({
94
+ upgrades = readCoreUpgrades,
95
+ softwareUpdate = readSoftwareUpdate,
96
+ canUpdate = holdsCorePin,
97
+ doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig() }),
98
+ } = {}) {
53
99
  const router = express.Router();
54
100
 
55
101
  router.get(
@@ -67,7 +113,9 @@ function buildCoreUpdateRouter({ upgrades = readCoreUpgrades } = {}) {
67
113
  // that cannot name its own core reports `no_answer` and renders no banner.
68
114
  let running = null;
69
115
  try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
70
- res.json({ ok: true, update: await readCoreUpdate({ running }), deploy_url: hubDeployUrl(safeIdpConfig()) });
116
+ // No `deploy_url` any more (task 1004296): the banner on a hall without a door of its
117
+ // own links to Settings → Software update, and the hand-off lives on GET /software-update.
118
+ res.json({ ok: true, update: await readCoreUpdate({ running }) });
71
119
  }, { errorCode: 'core_update_failed', message: false }),
72
120
  );
73
121
 
@@ -89,6 +137,20 @@ function buildCoreUpdateRouter({ upgrades = readCoreUpgrades } = {}) {
89
137
  }, { errorCode: 'core_upgrades_unreadable', message: false }),
90
138
  );
91
139
 
140
+ // GET /software-update — Settings → Software update (task 1004296): this project's core,
141
+ // the newest release its channel allows, what the versions between them carry, and where
142
+ // this project's Update button leads. Any signed-in builder (see the header).
143
+ router.get(
144
+ '/software-update',
145
+ auth.requireBuilder,
146
+ asyncHandler('GET /software-update', async (req, res) => {
147
+ let running = null;
148
+ try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
149
+ const [update, can] = await Promise.all([softwareUpdate({ running }), canUpdate(req)]);
150
+ res.json({ ok: true, update, door: updateDoor(doorInputs()), can_update: can === true });
151
+ }, { errorCode: 'software_update_failed', message: false }),
152
+ );
153
+
92
154
  return router;
93
155
  }
94
156
 
@@ -975,6 +975,23 @@ function mountInternalSurfaces(app) {
975
975
  return res.redirect(301, `${isBuildersHost(req) ? '' : '/builders'}/people${qs}`);
976
976
  });
977
977
 
978
+ // /deploy EXISTS ONLY WHERE THE DOOR DOES (task 1004296). The owner, 2026-09-25: /deploy is
979
+ // "only for bongos builders hall itself, not for all production ones". Every datum and
980
+ // both acts on that page are provisioning-module routes, so a hall that does not run the
981
+ // module — a hosted project, a self-hosted instance — was serving a page with nothing
982
+ // behind it. It now answers with Settings → Software update, where every hall reads its
983
+ // version and takes its update. Like the /scouting redirect above it carries no gate: the
984
+ // redirect discloses nothing, and Settings' own wall applies the moment it is followed.
985
+ app.use((req, res, next) => {
986
+ if (isModuleSurfaceHost(req)) return next();
987
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
988
+ if (!DEPLOY_PAGE_RE.test(path.posix.normalize(req.path))) return next();
989
+ let door = false;
990
+ try { door = isModuleEnabled('provisioning'); } catch { door = false; }
991
+ if (door) return next();
992
+ return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/settings#software-update`);
993
+ });
994
+
978
995
  app.use((req, res, next) => {
979
996
  if (isModuleSurfaceHost(req)) return next();
980
997
  if (!req.path.startsWith('/builders')) return next();