@bongos/core 1.19.1041 → 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.
- package/.bongos-core.json +103 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -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 +64 -7
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +41 -21
- package/docs/copy-registry.json +207 -27
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +10 -0
- 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/task.html +1 -1
- 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/npm-release/public/work.js +8 -3
- package/modules/npm-release/work.js +8 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/main-audit.js +204 -7
- 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 +17 -0
- package/src/bongos/software-update.js +127 -0
- package/src/module-api.js +10 -1
- package/tests/core_upgrade_owner_door.mjs +27 -18
- package/tests/helpers.mjs +92 -0
- package/tests/main_audit_attestation.mjs +238 -0
- package/tests/module_api.mjs +1 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/npm_release_page.mjs +11 -1
- package/tests/npm_release_work.mjs +9 -94
- package/tests/software_update.mjs +449 -0
|
@@ -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-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -7543,5 +7543,15 @@
|
|
|
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
|
+
}
|
|
7546
7556
|
]
|
|
7547
7557
|
}
|
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
|
};
|
|
@@ -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 };
|
|
@@ -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({
|
|
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
|
-
|
|
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();
|