@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.
- package/.bongos-core.json +127 -87
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +14 -0
- package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +1 -0
- package/docs/adr/0340-every-hall-takes-its-update-from-settings-and-deploy-is-the-platform-halls-page.md +64 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +109 -7
- package/docs/api-reference.md +9 -2
- package/docs/copy-inventory.md +79 -59
- package/docs/copy-registry.json +248 -68
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +12 -0
- package/docs/modules-contract.md +4 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/palette.js +1 -0
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-software-update.js +194 -0
- package/modules/hall-ui/public/settings.css +39 -0
- package/modules/hall-ui/public/settings.html +24 -2
- package/modules/hall-ui/public/settings.js +5 -0
- package/modules/hall-ui/public/settings.states.json +2 -1
- package/modules/hall-ui/public/shell.js +25 -39
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +4 -0
- package/modules/hall-ui/public/task.css +6 -0
- package/modules/hall-ui/public/task.html +2 -2
- package/modules/hall-ui/public/task.js +17 -0
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/panel-family.md +9 -0
- package/modules/hall-ui/records/record-family.md +2 -0
- package/modules/npm-release/module.json +8 -2
- package/modules/npm-release/public/task-widget.js +79 -0
- package/modules/npm-release/public/work.js +8 -3
- package/modules/npm-release/routes/task-where.js +48 -0
- package/modules/npm-release/work.js +78 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/main-audit.js +204 -7
- package/scripts/gds/seam-wiring-guard.js +4 -2
- package/scripts/hall-preview/server.js +14 -1
- package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
- package/src/bongos/routes/core-update.js +65 -3
- package/src/bongos/serve-internal.js +35 -4
- package/src/bongos/software-update.js +127 -0
- package/src/module-api.js +10 -1
- package/src/module-loader/loader.js +13 -3
- package/tests/core_upgrade_owner_door.mjs +27 -18
- package/tests/hall_record_world.mjs +23 -1
- package/tests/helpers.mjs +92 -0
- package/tests/main_audit_attestation.mjs +238 -0
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +27 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/npm_release_page.mjs +11 -1
- package/tests/npm_release_where.mjs +323 -0
- package/tests/npm_release_work.mjs +9 -94
- 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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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:
|
|
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.',
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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
|
-
|
|
223
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
10
|
-
// running core carries stops at its own version, which is exactly the range
|
|
11
|
-
//
|
|
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
|
|
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
|
|
278
|
+
module.exports = { readRegistry };
|