@bongos/core 1.19.1041 → 1.19.1043

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 (87) hide show
  1. package/.bongos-core.json +127 -87
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -1
  6. package/clients/bongos-client/index.mjs +6 -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 +109 -7
  12. package/docs/api-reference.md +9 -2
  13. package/docs/copy-inventory.md +79 -59
  14. package/docs/copy-registry.json +248 -68
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +12 -0
  17. package/docs/modules-contract.md +4 -1
  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/style.css +4 -0
  51. package/modules/hall-ui/public/task.css +6 -0
  52. package/modules/hall-ui/public/task.html +2 -2
  53. package/modules/hall-ui/public/task.js +17 -0
  54. package/modules/hall-ui/public/watch.html +1 -1
  55. package/modules/hall-ui/public/work.html +1 -1
  56. package/modules/hall-ui/records/panel-family.md +9 -0
  57. package/modules/hall-ui/records/record-family.md +2 -0
  58. package/modules/npm-release/module.json +8 -2
  59. package/modules/npm-release/public/task-widget.js +79 -0
  60. package/modules/npm-release/public/work.js +8 -3
  61. package/modules/npm-release/routes/task-where.js +48 -0
  62. package/modules/npm-release/work.js +78 -11
  63. package/package-lock.json +2 -2
  64. package/package.json +1 -1
  65. package/release-notes.json +16 -0
  66. package/scripts/gds/exec-path-guard.js +1 -1
  67. package/scripts/gds/fitness.js +7 -0
  68. package/scripts/gds/main-audit.js +204 -7
  69. package/scripts/gds/seam-wiring-guard.js +4 -2
  70. package/scripts/hall-preview/server.js +14 -1
  71. package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
  72. package/src/bongos/routes/core-update.js +65 -3
  73. package/src/bongos/serve-internal.js +35 -4
  74. package/src/bongos/software-update.js +127 -0
  75. package/src/module-api.js +10 -1
  76. package/src/module-loader/loader.js +13 -3
  77. package/tests/core_upgrade_owner_door.mjs +27 -18
  78. package/tests/hall_record_world.mjs +23 -1
  79. package/tests/helpers.mjs +92 -0
  80. package/tests/main_audit_attestation.mjs +238 -0
  81. package/tests/module_api.mjs +1 -0
  82. package/tests/module_loader.mjs +27 -0
  83. package/tests/nav_permission_atoms.mjs +1 -1
  84. package/tests/npm_release_page.mjs +11 -1
  85. package/tests/npm_release_where.mjs +323 -0
  86. package/tests/npm_release_work.mjs +9 -94
  87. package/tests/software_update.mjs +449 -0
@@ -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;
@@ -51,9 +57,16 @@ function configuredPackage(env = process.env) {
51
57
  return v || CORE_PACKAGE;
52
58
  }
53
59
 
60
+ // READ ONCE PER PROCESS. The file ships inside the installed package, so it cannot change
61
+ // while this process runs — an upgrade replaces the package AND restarts the server. The
62
+ // deploy page's reading reached it rarely; a task's own page (task 1004301) reaches it on
63
+ // every view by every builder, and a synchronous disk read has no business on that path.
64
+ let installedNotesMemo;
54
65
  function readInstalledNotes() {
55
- try { return JSON.parse(fs.readFileSync(INSTALLED_NOTES, 'utf8')); }
56
- catch { return null; }
66
+ if (installedNotesMemo !== undefined) return installedNotesMemo;
67
+ try { installedNotesMemo = JSON.parse(fs.readFileSync(INSTALLED_NOTES, 'utf8')); }
68
+ catch { installedNotesMemo = null; }
69
+ return installedNotesMemo;
57
70
  }
58
71
 
59
72
  function boundText(text) {
@@ -188,12 +201,9 @@ function clampInt(v, dflt, max) {
188
201
  * `now`, `store` and `installedNotes` are the seams tests drive; `live` defaults to this
189
202
  * process's own core version when the package is the core.
190
203
  */
191
- async function readWork({
192
- pool, pkg = configuredPackage(), live, days, before, limit,
193
- installedNotes = readInstalledNotes, get, now, store,
194
- } = {}) {
195
- const windowDays = clampInt(days, DEFAULT_DAYS, MAX_DAYS);
196
- const pageLimit = clampInt(limit, DEFAULT_LIMIT, MAX_LIMIT);
204
+ // What versions exist and what each carried — the half of the reading both the deploy page
205
+ // and a task's own page need (task 1004301 split it out of readWork unchanged).
206
+ async function readVersions({ pkg, live, installedNotes, get, now, store }) {
197
207
  const running = live !== undefined ? live : (pkg === CORE_PACKAGE ? api.CORE_VERSION : null);
198
208
  const liveVersion = VERSION_RE.test(String(running || '')) ? running : null;
199
209
 
@@ -201,7 +211,7 @@ async function readWork({
201
211
  const localVersions = versionsIn(local);
202
212
  const localTop = localVersions.length ? localVersions[localVersions.length - 1].version : null;
203
213
 
204
- const { registry, notes } = await readRegistry({
214
+ const { registry, notes } = await api.readPackageRegistry({
205
215
  pkg, get, now, store,
206
216
  haveNotesFor: (v) => !!localTop && compareVersions(localTop, v) >= 0,
207
217
  });
@@ -219,6 +229,17 @@ async function readWork({
219
229
  const coversTo = versions.length ? versions[versions.length - 1].version : null;
220
230
  const published = registry.ok ? registry.published : {};
221
231
  const released = registry.ok ? registry.latest : null;
232
+ return { liveVersion, registry, versions, source, notesError, coversTo, published, released };
233
+ }
234
+
235
+ async function readWork({
236
+ pool, pkg = configuredPackage(), live, days, before, limit,
237
+ installedNotes = readInstalledNotes, get, now, store,
238
+ } = {}) {
239
+ const windowDays = clampInt(days, DEFAULT_DAYS, MAX_DAYS);
240
+ const pageLimit = clampInt(limit, DEFAULT_LIMIT, MAX_LIMIT);
241
+ const { liveVersion, registry, versions, source, notesError, coversTo, published, released } =
242
+ await readVersions({ pkg, live, installedNotes, get, now, store });
222
243
 
223
244
  const tasks = await recentTasks(pool, windowDays);
224
245
  const stages = placeTasks({ tasks, versions, live: liveVersion, released, coveredAt: coversTo ? published[coversTo] || null : null });
@@ -249,4 +270,50 @@ async function readWork({
249
270
  };
250
271
  }
251
272
 
252
- module.exports = { readWork };
273
+ /**
274
+ * Where ONE task's code is (task 1004301) — the line its own page shows: the same
275
+ * placement the deploy page's list makes, for one task and without the 14-day window.
276
+ *
277
+ * Returns null for an id that is not a task. For a task not yet declared done, `stage` is
278
+ * null: there is no code to place. Otherwise `stage` is one of the deploy page's six, with
279
+ * the version that carries it when there is one.
280
+ *
281
+ * WHAT IT DISCLOSES, because every builder can open a task page: the version that names the
282
+ * task (the package's own release notes, published on npm), whether that version is the one
283
+ * this hall runs (GET /version already says which core runs), and whether it is released
284
+ * (the registry's `latest` label). Nothing here is not already public, which is why its
285
+ * route asks only for a signed-in builder — unlike the deploy page, whose ledger view and
286
+ * upgrade door are the owner's.
287
+ */
288
+ async function readTaskWhere({ pool, id, pkg = configuredPackage(), live, installedNotes = readInstalledNotes, get, now, store } = {}) {
289
+ const taskId = String(id == null ? '' : id);
290
+ if (!/^\d{1,12}$/.test(taskId)) return null;
291
+ const { rows } = await pool.query(
292
+ `SELECT id, status, value_summary, COALESCE(shipped_at, updated_at) AS at FROM tasks WHERE id = $1::bigint`,
293
+ [taskId],
294
+ );
295
+ if (!rows.length) return null;
296
+ const r = rows[0];
297
+ const task = { id: String(r.id), status: r.status, summary: r.value_summary || null, at: r.at instanceof Date ? r.at.toISOString() : r.at };
298
+ const base = { task_id: task.id, package: pkg, status: task.status };
299
+ if (!['completed', 'confirmed', 'shipped'].includes(task.status)) return { ...base, stage: null };
300
+
301
+ const { liveVersion, registry, versions, notesError, coversTo, published, released } =
302
+ await readVersions({ pkg, live, installedNotes, get, now, store });
303
+ const stages = placeTasks({ tasks: [task], versions, live: liveVersion, released, coveredAt: coversTo ? published[coversTo] || null : null });
304
+ const stage = Object.keys(stages).find((k) => stages[k].length) || null;
305
+ const row = stage ? stages[stage][0] : {};
306
+ return {
307
+ ...base,
308
+ stage,
309
+ version: row.version || null,
310
+ offered: !!row.offered,
311
+ live: liveVersion,
312
+ released,
313
+ // A partial reading is said, not hidden: without the notes a shipped task reads as "in no
314
+ // version", which would be a claim the page cannot back.
315
+ partial: !registry.ok || !!notesError,
316
+ };
317
+ }
318
+
319
+ module.exports = { readWork, readTaskWhere };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1041",
3
+ "version": "1.19.1043",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1041",
9
+ "version": "1.19.1043",
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.1041",
3
+ "version": "1.19.1043",
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",
@@ -7543,5 +7543,21 @@
7543
7543
  "id": "1000380",
7544
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
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
+ }
7556
+ ],
7557
+ "1.19.1043": [
7558
+ {
7559
+ "id": "1004301",
7560
+ "text": "A task's own page now says where its code is — which version carries it, whether it is running on Bongos, whether it is released — and says plainly when a task marked done never actually landed."
7561
+ }
7546
7562
  ]
7547
7563
  }
@@ -103,7 +103,7 @@ const EXEMPTIONS = [
103
103
  + 'absorbed into this check.',
104
104
  },
105
105
  {
106
- at: 'src/module-loader/loader.js:299',
106
+ at: 'src/module-loader/loader.js:309',
107
107
  why: 'The poller twin of the route require at :196 — same DEFAULT_ROOTS bound, same plugin-by-'
108
108
  + 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
109
109
  + 'change to either call re-opens only that one.',
@@ -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
  };
@@ -95,11 +95,13 @@ const SEAM_CONSUMERS = {
95
95
  artifact: (dir, entry) => path.join(dir, 'rooms', `${entry}.js`),
96
96
  },
97
97
  uiSections: {
98
- // Per-MODULE, not per-entry: hallWidgetScripts looks for one hall-widget.js
99
- // however many sections the module names.
98
+ // Per-MODULE, not per-entry: hallWidgetScripts looks for one widget file per page
99
+ // however many sections the module names — hall-widget.js for the index, and since
100
+ // task 1004301 task-widget.js for a task's page. Either one wires the declaration.
100
101
  consumer: 'src/module-loader/loader.js hallWidgetScripts',
101
102
  kind: 'file',
102
103
  artifact: (dir) => path.join(dir, 'public', 'hall-widget.js'),
104
+ alternates: (dir) => [path.join(dir, 'public', 'task-widget.js')],
103
105
  },
104
106
  skills: {
105
107
  // moduleSkillSources resolves a declared name against the module's OWN tree or
@@ -77,7 +77,8 @@ let express;
77
77
  try { express = require(path.join(ROOT, 'node_modules', 'express')); }
78
78
  catch (_) { express = require('express'); }
79
79
  const { buildStampedHtml } = require(path.join(ROOT, 'src', 'asset-version.js'));
80
- const { injectBrandingGlobal, mountInternalSurfaces } = require(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'));
80
+ const { injectBrandingGlobal, mountInternalSurfaces, injectModuleHallWidgets } = require(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'));
81
+ const { hallWidgetScripts } = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
81
82
  const { clientModules } = require(path.join(ROOT, 'src', 'modules.js'));
82
83
  const { overrideKey, resolveLiveIdentity } = require('./live-identity');
83
84
 
@@ -128,6 +129,9 @@ const FIXTURE_FALLBACKS = [
128
129
  // without it /deploy?project=… only ever rendered its "could not read" state, which
129
130
  // is the trap this directory's README calls out by name.
130
131
  [/^provisioning\/instances\/\d+\/core-upgrade$/, 'provisioning__instances__core-upgrade'],
132
+ // the npm-release module's line on a task's page (task 1004301): one reading serves any
133
+ // id, as the core-upgrade preview above does — the page shows one task.
134
+ [/^npm-release\/task\/\d+$/, 'npm-release__task'],
131
135
  ];
132
136
 
133
137
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
@@ -283,6 +287,15 @@ for (const key of MODULES_OFF) {
283
287
  for (const key of MODULES_ON) {
284
288
  (mods.enabled = mods.enabled || {})[key] = true;
285
289
  }
290
+ // Module page widgets, injected at boot exactly as serve-internal.js does for prod: the
291
+ // hall index's hall-widget.js and a task page's task-widget.js (task 1004301), for the
292
+ // modules THIS harness runs with. Without it a module's line on a page could never be
293
+ // previewed — the page would only ever render its empty slot.
294
+ {
295
+ const isOn = (k) => !!(mods.enabled && mods.enabled[k]);
296
+ if (stamped['index.html']) stamped['index.html'] = injectModuleHallWidgets(stamped['index.html'], hallWidgetScripts({ isEnabled: isOn }));
297
+ if (stamped['task.html']) stamped['task.html'] = injectModuleHallWidgets(stamped['task.html'], hallWidgetScripts({ isEnabled: isOn, file: 'task-widget.js' }), 'task');
298
+ }
286
299
 
287
300
  function sendPage(res, name) {
288
301
  const html = stamped[name];
@@ -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 };