@bongos/core 1.19.1033 → 1.19.1035
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 +204 -99
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/grader-health/SKILL.md +4 -0
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- 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 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +120 -4
- package/docs/api-reference.md +9 -2
- package/docs/architecture.md +3 -2
- package/docs/canonical-permissions.md +2 -2
- package/docs/copy-inventory.md +34 -30
- package/docs/copy-registry.json +69 -33
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/local-dev.md +45 -3
- package/docs/recipes/packaging-the-core.md +1 -1
- package/modules/autonomy/cadence.js +52 -0
- package/modules/autonomy/db.js +19 -0
- package/modules/autonomy/routes/autonomy.js +28 -0
- package/modules/government/catalog.js +2 -2
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/grading/grade-engine.js +267 -0
- package/modules/grading/grader-subagent.js +4 -3
- package/modules/grading/grader-workers/index.js +67 -11
- package/modules/grading/grader-workers/worker-verdict.js +32 -2
- package/modules/grading/grader.js +109 -88
- package/modules/hall-ui/public/deploy.js +35 -0
- package/modules/hall-ui/public/project-settings.css +7 -0
- package/modules/lifecycle/routes/tasks.js +19 -4
- package/modules/lifecycle/ship-assurance.js +17 -1
- package/modules/provisioning/control-plane-guard.js +14 -13
- package/modules/provisioning/core-upgrade.js +30 -17
- package/modules/provisioning/public-refusal.js +44 -0
- package/modules/provisioning/routes/disconnect.js +2 -1
- package/modules/provisioning/routes/env-manifest.js +5 -4
- package/modules/provisioning/routes/provisioning.js +16 -15
- package/modules/provisioning/routes/repo-private.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +80 -0
- package/scripts/gds/autobongos-run.js +10 -2
- package/scripts/gds/backfill-cost.js +20 -9
- package/scripts/gds/claim.js +2 -2
- package/scripts/gds/credential-presence.js +281 -0
- package/scripts/gds/deploy-main-audit-gate.sh +11 -4
- package/scripts/gds/dev-lib.js +212 -5
- package/scripts/gds/dev.js +102 -50
- package/scripts/gds/doctor.js +3 -2
- package/scripts/gds/grade-correlation-audit.js +280 -0
- package/scripts/gds/grade-replay.js +237 -0
- package/scripts/gds/hooks/pre-push.js +7 -3
- package/scripts/gds/main-audit.js +19 -10
- package/scripts/gds/memory-map.js +3 -1
- package/scripts/gds/onboarding-config.js +3 -2
- package/scripts/gds/plain-cards.js +4 -0
- package/scripts/gds/preflight.js +12 -4
- package/scripts/gds/provision-core-upgrade.js +155 -4
- package/scripts/gds/provision-repo-auth.js +15 -0
- package/scripts/gds/provision.js +1 -1
- package/scripts/gds/publish-credential-check.js +3 -0
- package/scripts/gds/push-main.js +14 -3
- package/scripts/gds/push-path-brief.js +3 -2
- package/scripts/gds/run-routine.js +18 -5
- package/scripts/gds/run-unit-tests.js +14 -3
- package/scripts/gds/session-cost.js +12 -3
- package/scripts/gds/ship-deploy-target.js +11 -6
- package/scripts/gds/ship-grade-steps.js +33 -0
- package/scripts/gds/ship-land.js +157 -11
- package/scripts/gds/ship-merge.js +3 -2
- package/scripts/gds/ship-preflight-steps.js +30 -8
- package/scripts/gds/ship.js +1 -1
- package/scripts/gds/upgrade.js +3 -3
- package/scripts/hall-preview/README.md +20 -3
- package/scripts/hall-preview/live-identity.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +71 -11
- package/scripts/hall-preview/scrub-identity.js +296 -0
- package/src/bongos/core-update.js +23 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/routes/security.js +3 -3
- package/src/build-info.js +4 -2
- package/src/module-api.js +1 -1
- package/tests/adr_0089_scope.mjs +70 -0
- package/tests/autobongos_cadence.mjs +74 -0
- package/tests/consumer_layout_boot.mjs +363 -31
- package/tests/core_upgrade_door.mjs +40 -6
- package/tests/core_upgrade_runner.mjs +240 -4
- package/tests/credential_presence.mjs +153 -0
- package/tests/deploy_audit_gate.mjs +5 -2
- package/tests/deploy_page_projects.mjs +63 -2
- package/tests/dev_lib.mjs +2 -1
- package/tests/dev_windows.mjs +324 -0
- package/tests/env_knobs_neutral_prefix.mjs +203 -0
- package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
- package/tests/government_protected_surfaces.mjs +10 -0
- package/tests/grade_correlation_audit.mjs +159 -0
- package/tests/grade_engine.mjs +249 -0
- package/tests/grade_engine_replay.mjs +99 -0
- package/tests/grader_prompt_window_bound.mjs +132 -0
- package/tests/grader_unbacked_score.mjs +194 -0
- package/tests/hall_preview_scrub.mjs +214 -0
- package/tests/integration_parked_ratchet.mjs +24 -1
- package/tests/provisioning_public_refusal.mjs +195 -0
- package/tests/publish_status_branch.mjs +128 -0
- package/tests/routine_observability_api.mjs +195 -0
- package/tests/ship_assurance.mjs +86 -3
- package/tests/ship_land_red_pr.mjs +236 -0
- package/tests/ship_preflight.mjs +67 -1
- package/tests/start_env_guard.mjs +16 -0
- package/tests/upgrade.mjs +20 -1
|
@@ -68,6 +68,33 @@ function shipInputPaths(args, { cwd = process.cwd(), repoRoot = null, realpath =
|
|
|
68
68
|
return out;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// The code that RUNS the ship and the grade on this machine — what step (c) of
|
|
72
|
+
// preflight() refuses to run from a stale copy. PATHSPECS, not a file list (task
|
|
73
|
+
// 1004273): the list used to name ship.js and grader.js alone, and two carves moved
|
|
74
|
+
// the logic out from under it (task 1003205 split ship.js into ~20 ship-*.js files;
|
|
75
|
+
// grader.js now delegates to grader-*.js, grader-workers/ and grade-engine.js), so a
|
|
76
|
+
// branch behind main on ship-land.js or grader-score.js sailed through. A prefix
|
|
77
|
+
// covers the next carve too. They reach git as argv, never through a shell, so a
|
|
78
|
+
// file that exists ONLY on origin/main (a new carve file) still matches — a shell
|
|
79
|
+
// would have expanded the glob against this checkout and missed exactly that one.
|
|
80
|
+
// tests/ship_preflight.mjs fails if a ship*.js / grader* / grade-engine file is not covered.
|
|
81
|
+
const TOOLING_PATHSPECS = Object.freeze([
|
|
82
|
+
'scripts/gds/ship*.js', // ship.js + every ship-*.js carved out of it
|
|
83
|
+
'scripts/gds/smoke-gds.js',
|
|
84
|
+
'scripts/gds/cli-lib.js',
|
|
85
|
+
'modules/grading/grader*', // grader.js, grader-*.js, grader-rubric.json, grader-workers/
|
|
86
|
+
'modules/grading/grade-engine.js', // grader.js requires it (task 1002839)
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// staleToolingFiles — the tooling files origin/main has changed since this branch's
|
|
90
|
+
// merge-base. Three-dot (HEAD...origin/main) scopes to the origin/main side, so the
|
|
91
|
+
// builder's OWN edits to these files (shipping a ship fix) do not count. A failed git
|
|
92
|
+
// call reads as nothing stale, as it always has. `cwd` is injectable for the test.
|
|
93
|
+
function staleToolingFiles({ cwd } = {}) {
|
|
94
|
+
const out = argvOut('git', ['diff', '--name-only', 'HEAD...origin/main', '--', ...TOOLING_PATHSPECS], cwd ? { cwd } : {});
|
|
95
|
+
return out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : [];
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
// opts: { force, inputFiles } — inputFiles is shipInputPaths(args) from the caller;
|
|
72
99
|
// omitted (the resume path has no prose flags), nothing is excluded from the
|
|
73
100
|
// untracked-file warning.
|
|
@@ -123,14 +150,7 @@ function preflight(claim, opts = {}) {
|
|
|
123
150
|
// (HEAD...origin/main) scopes to changes on the origin/main side since the
|
|
124
151
|
// merge-base, so the builder's OWN edits to these files (e.g. shipping a
|
|
125
152
|
// ship.js fix) do not trip it.
|
|
126
|
-
const
|
|
127
|
-
'scripts/gds/ship.js',
|
|
128
|
-
'scripts/gds/smoke-gds.js',
|
|
129
|
-
'scripts/gds/cli-lib.js',
|
|
130
|
-
'modules/grading/grader.js', // BV1.R73: grading carved to modules/grading/
|
|
131
|
-
];
|
|
132
|
-
const staleToolingStr = gitOk(`git diff --name-only HEAD...origin/main -- ${TOOLING_PATHS.join(' ')}`);
|
|
133
|
-
const staleTooling = staleToolingStr ? staleToolingStr.trim().split('\n').filter(Boolean) : [];
|
|
153
|
+
const staleTooling = staleToolingFiles();
|
|
134
154
|
if (staleTooling.length > 0) {
|
|
135
155
|
if (opts.force) {
|
|
136
156
|
console.log(` preflight: ship tooling is behind origin/main (${staleTooling.join(', ')}) — proceeding due to --force`);
|
|
@@ -373,4 +393,6 @@ module.exports = {
|
|
|
373
393
|
parseDeletedUpstreamPaths,
|
|
374
394
|
preflight,
|
|
375
395
|
shipInputPaths,
|
|
396
|
+
staleToolingFiles,
|
|
397
|
+
TOOLING_PATHSPECS,
|
|
376
398
|
};
|
package/scripts/gds/ship.js
CHANGED
|
@@ -354,7 +354,7 @@ module.exports = {
|
|
|
354
354
|
// task 1045: the gate that defers the broadcast to the server on the server-
|
|
355
355
|
// mediated publish path — exported so the truth table (ci+server → true, laptop
|
|
356
356
|
// / non-ci → false) is unit-tested with no gh/network (env-driven via
|
|
357
|
-
//
|
|
357
|
+
// <PREFIX>_DEPLOY_MODE + <PREFIX>_PUSH_VIA_SERVER).
|
|
358
358
|
broadcastHandledByServer,
|
|
359
359
|
fatalRecoveryLines, // G04 re-export (definition in ship-honesty.js)
|
|
360
360
|
};
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -246,7 +246,7 @@ async function resolvePinPushAuth({ instanceDir, remote = 'origin' }, deps = {},
|
|
|
246
246
|
if (!repo) return null;
|
|
247
247
|
const auth = require('./provision-repo-auth');
|
|
248
248
|
const inst = await auth.lookupInstanceRow(instanceDir, deps);
|
|
249
|
-
if (!inst) return null;
|
|
249
|
+
if (!inst) { const key = pinnedDeployKey(instanceDir, run); if (key) log(auth.missingRowNote(key, remote)); return null; } // task 1004065
|
|
250
250
|
const rowRepo = auth.parseTargetRef(inst.target_ref);
|
|
251
251
|
const same = (a, b) => a && b && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
|
|
252
252
|
if (!same(rowRepo, repo)) {
|
|
@@ -407,7 +407,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
|
|
|
407
407
|
const repo = remoteGithubRepo(instanceDir, remote, run);
|
|
408
408
|
err(` ! PIN NOT PUSHED — GitHub refused the push to ${remote}, so this bump is NOT durable.`);
|
|
409
409
|
err(` ${remote} authenticates with the deploy key at ${key}, which is registered READ-ONLY on the`);
|
|
410
|
-
err(` repository (ADR 0176): it can pull, never push. No owner GitHub token
|
|
410
|
+
err(` repository (ADR 0176): it can pull, never push. No owner GitHub token was available to push with instead (none is stored, or — on a hosted project's own box — the hub's record of it is not readable from here).${gitSaid(pushed)}`);
|
|
411
411
|
revert();
|
|
412
412
|
err(' Fix: have the instance owner reconnect GitHub with repository access (that stores a short-lived repo token),');
|
|
413
413
|
err(` then re-run \`bongos upgrade --commit-pin\` where that token is readable — the control plane, with --instance ${q(instanceDir)};`);
|
|
@@ -415,7 +415,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
|
|
|
415
415
|
err(` ${at} push ${q(`https://github.com/${repo ? `${repo.owner}/${repo.name}` : '<owner>/<repo>'}.git`)} ${q(target)}`);
|
|
416
416
|
return {
|
|
417
417
|
applicable: true, committed: true, pushed: false, dirty, refused: 'read-only-deploy-key',
|
|
418
|
-
error: `GitHub refused the pin push: ${remote} authenticates with the read-only deploy key at ${key}, and no owner GitHub token
|
|
418
|
+
error: `GitHub refused the pin push: ${remote} authenticates with the read-only deploy key at ${key}, and no owner GitHub token was available to push with — the pin is committed locally but not on the remote`,
|
|
419
419
|
};
|
|
420
420
|
}
|
|
421
421
|
err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
|
|
@@ -41,17 +41,18 @@ fetched live — read-only GETs — and layered over the fixtures in memory. The
|
|
|
41
41
|
banner names whose hall you are looking at:
|
|
42
42
|
|
|
43
43
|
```
|
|
44
|
-
account: @
|
|
44
|
+
account: @your-login (archon) — your own, live
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Nothing is written to `fixtures/` (that's `refresh-fixtures.js`'s job), so the
|
|
48
48
|
checked-in snapshot stays the fallback. It serves whenever the live read can't
|
|
49
49
|
happen — signed out, expired session, offline, or an endpoint your rank can't
|
|
50
50
|
call (that one keeps its own fixture and the rest of your identity still lands).
|
|
51
|
-
Every fallback says why, and says the hall is about to show you someone else
|
|
51
|
+
Every fallback says why, and says the hall is about to show you someone else — a
|
|
52
|
+
stand-in, since the snapshot's people are scrubbed (see [Fixtures](#fixtures)):
|
|
52
53
|
|
|
53
54
|
```
|
|
54
|
-
account: @
|
|
55
|
+
account: @builder-4 (metic) — NOT YOURS, the checked-in fixture: this machine has no Bongos CLI session — run /builder-setup
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
**`--fixture-me` pins the snapshot.** Use it for the screenshot matrix below —
|
|
@@ -74,6 +75,22 @@ node scripts/hall-preview/refresh-fixtures.js
|
|
|
74
75
|
|
|
75
76
|
Box/terminal endpoints are deliberately never snapshotted (connection details).
|
|
76
77
|
|
|
78
|
+
**No real person is ever checked in (task 1003647).** `fixtures/` is committed, so
|
|
79
|
+
`refresh-fixtures.js` runs every snapshot through [`scrub-identity.js`](scrub-identity.js)
|
|
80
|
+
*before* writing it: logins, display names, avatars, GitHub ids and emails become
|
|
81
|
+
stand-ins (`builder-3` / `Builder 3`, GitHub's ghost avatar), one per person across the
|
|
82
|
+
run, and the snapshotting account's own credit balance, karma and spend (`me`, `me__*`)
|
|
83
|
+
become fixed values. Instance-local ids and free text (task titles, descriptions) are
|
|
84
|
+
left alone. Hand-edited a fixture? Run the same pass over the files on disk — no
|
|
85
|
+
network, and it rewrites only the values that change:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
node scripts/hall-preview/refresh-fixtures.js --scrub-only
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`tests/hall_preview_scrub.mjs` fails while any committed fixture still carries a real
|
|
92
|
+
identity.
|
|
93
|
+
|
|
77
94
|
## The evidence matrix (per page-redesign ship)
|
|
78
95
|
|
|
79
96
|
Screenshot every changed page at **{light, dark} × {1280w, 390w}**, plus one
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// The harness answers every read from checked-in fixtures, and fixtures/me.json
|
|
6
6
|
// is one particular builder's snapshot. So every preview greeted whoever ran it
|
|
7
|
-
// as somebody else ("Welcome back,
|
|
7
|
+
// as somebody else ("Welcome back, <whoever was snapshotted>"), with that builder's credits,
|
|
8
8
|
// achievements, goals and RANK — and because that snapshot is a metic, the
|
|
9
9
|
// Archon-gated pages rendered their sealed notice, so a UI change to them could
|
|
10
10
|
// not be reviewed at all. Reviewing your own UI change against someone else's
|
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
// Read-only: GETs only. Endpoints that 403/404 for the caller's rank are
|
|
8
8
|
// skipped (the existing fixture, if any, is kept). Box/terminal endpoints are
|
|
9
9
|
// deliberately NOT snapshotted (they carry host/connection details).
|
|
10
|
+
//
|
|
11
|
+
// PEOPLE ARE SCRUBBED ON WRITE (task 1003647). fixtures/ is committed, so every
|
|
12
|
+
// snapshot goes through ./scrub-identity.js before it touches the disk: logins,
|
|
13
|
+
// names, avatars, GitHub ids and emails become stand-ins (`builder-3` / `Builder
|
|
14
|
+
// 3`), and the snapshotting account's own balance and spend become fixed values.
|
|
15
|
+
// Hand-edited a fixture? Re-run the same pass over the files on disk, no network:
|
|
16
|
+
// node scripts/hall-preview/refresh-fixtures.js --scrub-only
|
|
10
17
|
'use strict';
|
|
11
18
|
|
|
12
19
|
const fs = require('fs');
|
|
13
20
|
const path = require('path');
|
|
14
|
-
const {
|
|
21
|
+
const { createScrubber, isOwnAccountFixture, highestStandIn, rewriteScalars } = require('./scrub-identity');
|
|
15
22
|
|
|
16
23
|
const FIXTURES = path.join(__dirname, 'fixtures');
|
|
17
24
|
|
|
@@ -77,20 +84,73 @@ const SNAPSHOTS = [
|
|
|
77
84
|
['public/versions', 'public__versions'],
|
|
78
85
|
];
|
|
79
86
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
// Every existing fixture's text — read so a run can number new stand-ins above the
|
|
88
|
+
// ones already in use (a hand-written fixture may carry `builder-7`).
|
|
89
|
+
function readFixtureTexts(dir) {
|
|
90
|
+
try {
|
|
91
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (e.code === 'ENOENT') return [];
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// refreshFixtures — GET each snapshot, scrub the people out of it, write it. The api,
|
|
99
|
+
// directory and logger are injected so tests/hall_preview_scrub.mjs drives this exact
|
|
100
|
+
// writer with no network and a temp directory.
|
|
101
|
+
async function refreshFixtures({ api, dir = FIXTURES, snapshots = SNAPSHOTS, log = console.log } = {}) {
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
103
|
+
const scrubber = createScrubber({ firstNumber: highestStandIn(readFixtureTexts(dir)) + 1 });
|
|
83
104
|
let wrote = 0;
|
|
84
|
-
for (const [apiPath, name, trim] of
|
|
105
|
+
for (const [apiPath, name, trim] of snapshots) {
|
|
85
106
|
const res = await api.request('GET', `/${apiPath}`, {});
|
|
86
107
|
if (!res.ok) {
|
|
87
|
-
|
|
108
|
+
log(` skip ${apiPath} → ${res.status} (kept existing fixture if present)`);
|
|
88
109
|
continue;
|
|
89
110
|
}
|
|
90
|
-
|
|
91
|
-
|
|
111
|
+
// Scrubbed BEFORE the write, never after: fixtures/ is committed, so a copy of
|
|
112
|
+
// the live response that touched the disk would already be one `git add` away.
|
|
113
|
+
const data = scrubber.scrub(trim ? trim(res.data) : res.data, { ownAccount: isOwnAccountFixture(name) });
|
|
114
|
+
fs.writeFileSync(path.join(dir, `${name}.json`), `${JSON.stringify(data, null, 2)}\n`);
|
|
92
115
|
wrote += 1;
|
|
93
|
-
|
|
116
|
+
log(` wrote ${name}.json (${apiPath})`);
|
|
94
117
|
}
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
log(`refresh-fixtures: ${wrote}/${snapshots.length} snapshots written to ${dir} (people replaced by stand-ins)`);
|
|
119
|
+
return wrote;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// scrubFixturesOnDisk — the same pass over the files already in fixtures/: the
|
|
123
|
+
// hand-written ones (several copied real people in by hand) and anything written
|
|
124
|
+
// before the scrub existed. Only values that change are rewritten, so a fixture
|
|
125
|
+
// someone formatted by hand keeps its formatting.
|
|
126
|
+
function scrubFixturesOnDisk({ dir = FIXTURES, log = console.log } = {}) {
|
|
127
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
|
|
128
|
+
const texts = files.map((f) => fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
129
|
+
const scrubber = createScrubber({ firstNumber: highestStandIn(texts) + 1 });
|
|
130
|
+
let changed = 0;
|
|
131
|
+
files.forEach((file, idx) => {
|
|
132
|
+
const clean = scrubber.scrub(JSON.parse(texts[idx]), { ownAccount: isOwnAccountFixture(file.slice(0, -'.json'.length)) });
|
|
133
|
+
const next = rewriteScalars(texts[idx], clean);
|
|
134
|
+
if (next === texts[idx]) return;
|
|
135
|
+
fs.writeFileSync(path.join(dir, file), next);
|
|
136
|
+
changed += 1;
|
|
137
|
+
log(` scrubbed ${file}`);
|
|
138
|
+
});
|
|
139
|
+
log(`refresh-fixtures --scrub-only: ${changed}/${files.length} fixture(s) rewritten in ${dir}`);
|
|
140
|
+
return changed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { refreshFixtures, scrubFixturesOnDisk, SNAPSHOTS };
|
|
144
|
+
|
|
145
|
+
if (require.main === module) {
|
|
146
|
+
(async () => {
|
|
147
|
+
if (process.argv.includes('--scrub-only')) {
|
|
148
|
+
scrubFixturesOnDisk();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Required here, not at the top: a test that loads this file for the writer must
|
|
152
|
+
// not also load the CLI session machinery.
|
|
153
|
+
const { cliClient } = require('../gds/cli-lib');
|
|
154
|
+
await refreshFixtures({ api: await cliClient() });
|
|
155
|
+
})().catch((e) => { console.error(e.message || e); process.exit(1); });
|
|
156
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/hall-preview/scrub-identity.js — replace the real people in a hall-preview
|
|
3
|
+
// fixture with stand-ins before the file is written (task 1003647).
|
|
4
|
+
//
|
|
5
|
+
// WHY. refresh-fixtures.js snapshots LIVE API responses into fixtures/, and fixtures/
|
|
6
|
+
// is committed. So every refresh published the GitHub login, display name, avatar and
|
|
7
|
+
// GitHub id of everyone the snapshot could see — and, in me.json, the snapshotting
|
|
8
|
+
// account's own credit balance and month-to-date spend — into the repo and every clone
|
|
9
|
+
// of it. A leak like that is fixed in the WRITER: cleaning the output once re-leaks on
|
|
10
|
+
// the next refresh. So refresh-fixtures.js passes every snapshot through here, and its
|
|
11
|
+
// --scrub-only mode runs the same pass over files already on disk (hand-written ones
|
|
12
|
+
// included, which copied real people in by hand).
|
|
13
|
+
//
|
|
14
|
+
// THE SCHEME.
|
|
15
|
+
// • A person becomes `builder-<n>` (login-shaped fields) / `Builder <n>` (names), one
|
|
16
|
+
// n per person for the whole run, so a builder reads the same on every page. The
|
|
17
|
+
// person is keyed by login; a name travels with the login beside it.
|
|
18
|
+
// • An avatar becomes GitHub's own "ghost" avatar — the account GitHub shows for
|
|
19
|
+
// deleted users. It is a real image, so pages still render, and it is nobody.
|
|
20
|
+
// • A GitHub id becomes the stand-in's n; an email, `builder-<n>@example.com`.
|
|
21
|
+
// • Private account figures (month-to-date spend, budgets) become fixed values
|
|
22
|
+
// wherever they appear. In the snapshotting account's OWN files (me, me__*), so do
|
|
23
|
+
// its credit balance and karma — the "caller's credits" this task was filed about.
|
|
24
|
+
// Other builders' balances are kept: with their names gone they describe "Builder
|
|
25
|
+
// 3", not a person, and flattening them would flatten the leaderboard and roster
|
|
26
|
+
// previews the harness exists for.
|
|
27
|
+
// • null stays null and "" stays "". Pages branch on absence (initials when there is
|
|
28
|
+
// no avatar, the login when there is no display name) and tests pin those states.
|
|
29
|
+
//
|
|
30
|
+
// NOT TOUCHED. Instance-local numeric ids (builder, task, goal) — the harness routes on
|
|
31
|
+
// them (analytics/builder/:id, /profile?id=…) and they name nobody outside that one
|
|
32
|
+
// instance. Free text (task titles, descriptions) — project content, not a person's
|
|
33
|
+
// record. The repo's own coordinates (instance.repo.owner is where the code lives).
|
|
34
|
+
//
|
|
35
|
+
// FIXED POINT. Every stand-in is recognised on the way back in (`builder-3` is person
|
|
36
|
+
// 3), so scrubbing an already-scrubbed fixture changes nothing. tests/hall_preview_scrub.mjs
|
|
37
|
+
// leans on exactly that: each committed fixture must already be a fixed point, which is
|
|
38
|
+
// the same as saying no real identity is committed.
|
|
39
|
+
|
|
40
|
+
// GitHub's "ghost" user (id 10137): the placeholder GitHub itself substitutes for a
|
|
41
|
+
// deleted account. An https URL on the host the real avatars use, because the hall
|
|
42
|
+
// renders avatars only through an http(s) check (roster.js / collab-lib.js httpUrl).
|
|
43
|
+
const GHOST_AVATAR_URL = 'https://avatars.githubusercontent.com/u/10137?v=4';
|
|
44
|
+
|
|
45
|
+
// The fixed values that replace private figures. Round, obviously fake, stable.
|
|
46
|
+
const OWN_CREDITS = 1000;
|
|
47
|
+
const OWN_KARMA = 10;
|
|
48
|
+
const PRIVATE_FIGURES = { spend_mtd_usd: 42, budget_mtd_usd: 100, monthly_budget_usd: 100 };
|
|
49
|
+
const PLACEHOLDER_TEXT = {
|
|
50
|
+
device_name: 'Preview device',
|
|
51
|
+
anthropic_user_id: 'anthropic-user-preview',
|
|
52
|
+
bio: 'A builder bio, replaced for the preview.',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const STAND_IN_LOGIN = /^builder-(\d+)$/i;
|
|
56
|
+
const STAND_IN_NAME = /^Builder (\d+)$/;
|
|
57
|
+
const STAND_IN_EMAIL = /^builder-(\d+)@example\.com$/i;
|
|
58
|
+
|
|
59
|
+
const isLoginKey = (k) => k === 'github_login' || k === 'login' || k === 'handle' || /_login$/.test(k);
|
|
60
|
+
const isAvatarKey = (k) => k === 'avatar_url' || /_avatar_url$/.test(k);
|
|
61
|
+
const isGithubIdKey = (k) => k === 'github_id' || /_github_id$/.test(k);
|
|
62
|
+
const isEmailKey = (k) => k === 'email' || /_email$/.test(k);
|
|
63
|
+
// `by` / `*_by` holds a builder ID almost everywhere (created_by, shipped_by); only a
|
|
64
|
+
// non-numeric value is a login (sandbox_review.by, who approved a staged change).
|
|
65
|
+
const isByKey = (k) => k === 'by' || /_by$/.test(k);
|
|
66
|
+
const looksLikeLogin = (v) => /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(v) && !/^\d+$/.test(v);
|
|
67
|
+
|
|
68
|
+
// The login a name-shaped key belongs to, or undefined when the key is not a person's
|
|
69
|
+
// name. `display_name` pairs with the object's own login; `<who>_name` and
|
|
70
|
+
// `<who>_display_name` pair with `<who>_login` beside them — which is what keeps
|
|
71
|
+
// `worktree_name` (no `worktree_login`) out.
|
|
72
|
+
function nameOwnerKey(key, obj) {
|
|
73
|
+
if (key === 'display_name') return ['github_login', 'login', 'handle'].find((k) => k in obj) || null;
|
|
74
|
+
const m = /^(.+?)(?:_display)?_name$/.exec(key);
|
|
75
|
+
if (!m) return undefined;
|
|
76
|
+
const loginKey = `${m[1]}_login`;
|
|
77
|
+
return loginKey in obj ? loginKey : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// createScrubber — one registry of people for one run, so a builder gets the same n in
|
|
81
|
+
// every file the run touches. `firstNumber` lets a run start above stand-ins already on
|
|
82
|
+
// disk, so a new person never collides with one a hand-written fixture already uses.
|
|
83
|
+
function createScrubber({ firstNumber = 1 } = {}) {
|
|
84
|
+
const people = new Map(); // 'login:<lower>' | 'name:<text>' | 'id:<github id>' | 'email:<lower>' → n
|
|
85
|
+
let next = Math.max(1, Number(firstNumber) || 1);
|
|
86
|
+
|
|
87
|
+
function numberFor(keys) {
|
|
88
|
+
for (const k of keys) if (k && people.has(k)) return remember(keys, people.get(k));
|
|
89
|
+
return remember(keys, next++);
|
|
90
|
+
}
|
|
91
|
+
function remember(keys, n) {
|
|
92
|
+
for (const k of keys) if (k) people.set(k, n);
|
|
93
|
+
if (n >= next) next = n + 1;
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The person an object describes, from whichever identity fields it carries. A
|
|
98
|
+
// stand-in keeps its own number; a real value gets the registry's.
|
|
99
|
+
function personOf(obj, loginKey, nameKey) {
|
|
100
|
+
const login = loginKey && typeof obj[loginKey] === 'string' ? obj[loginKey] : '';
|
|
101
|
+
const name = nameKey && typeof obj[nameKey] === 'string' ? obj[nameKey] : '';
|
|
102
|
+
const standIn = STAND_IN_LOGIN.exec(login) || STAND_IN_NAME.exec(name);
|
|
103
|
+
if (standIn) return remember([], Number(standIn[1]));
|
|
104
|
+
return numberFor([login && `login:${login.toLowerCase()}`, name && `name:${name}`]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scrubObject(obj, ctx) {
|
|
108
|
+
const out = {};
|
|
109
|
+
// The object's own person, resolved once so its login, name, avatar, GitHub id and
|
|
110
|
+
// email all land on the same n.
|
|
111
|
+
const ownLoginKey = ['github_login', 'login', 'handle'].find((k) => typeof obj[k] === 'string' && obj[k]);
|
|
112
|
+
const ownNameKey = typeof obj.display_name === 'string' && obj.display_name ? 'display_name' : null;
|
|
113
|
+
let selfN = null;
|
|
114
|
+
const self = () => {
|
|
115
|
+
if (selfN == null) selfN = (ownLoginKey || ownNameKey) ? personOf(obj, ownLoginKey, ownNameKey) : null;
|
|
116
|
+
return selfN;
|
|
117
|
+
};
|
|
118
|
+
const isPersonRecord = Boolean(ownLoginKey || ownNameKey);
|
|
119
|
+
|
|
120
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
121
|
+
out[key] = scrubValue(key, value, obj, ctx, self, isPersonRecord);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function scrubArray(arr, ctx) {
|
|
127
|
+
return arr.map((v) => (Array.isArray(v) ? scrubArray(v, ctx) : v && typeof v === 'object' ? scrubObject(v, ctx) : v));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function scrubValue(key, value, obj, ctx, self, isPersonRecord) {
|
|
131
|
+
if (value && typeof value === 'object') {
|
|
132
|
+
if (key === 'links' && isPersonRecord) return scrubLinks(value);
|
|
133
|
+
const inner = Array.isArray(value) ? scrubArray(value, ctx) : scrubObject(value, ctx);
|
|
134
|
+
// An OWN-account file's roll-up totals are the account's own credits too.
|
|
135
|
+
if (ctx.ownAccount && key === 'totals' && !Array.isArray(inner)) {
|
|
136
|
+
if (typeof inner.credits === 'number') inner.credits = OWN_CREDITS;
|
|
137
|
+
if (typeof inner.karma === 'number') inner.karma = OWN_KARMA;
|
|
138
|
+
}
|
|
139
|
+
return inner;
|
|
140
|
+
}
|
|
141
|
+
if (value == null || value === '') return value; // absence is a state the pages render
|
|
142
|
+
if (key in PRIVATE_FIGURES) return typeof value === 'string' ? String(PRIVATE_FIGURES[key]) : PRIVATE_FIGURES[key];
|
|
143
|
+
|
|
144
|
+
if (typeof value === 'string') {
|
|
145
|
+
if (isLoginKey(key)) {
|
|
146
|
+
if (STAND_IN_LOGIN.test(value)) return value;
|
|
147
|
+
const n = ['github_login', 'login', 'handle'].includes(key) ? self() : personOf(obj, key, siblingNameKey(key, obj));
|
|
148
|
+
return `builder-${n}`;
|
|
149
|
+
}
|
|
150
|
+
const owner = nameOwnerKey(key, obj);
|
|
151
|
+
if (owner !== undefined) {
|
|
152
|
+
if (STAND_IN_NAME.test(value)) return value;
|
|
153
|
+
const n = key === 'display_name' ? self() : personOf(obj, owner, key);
|
|
154
|
+
return `Builder ${n}`;
|
|
155
|
+
}
|
|
156
|
+
if (isAvatarKey(key)) return GHOST_AVATAR_URL;
|
|
157
|
+
if (isEmailKey(key)) {
|
|
158
|
+
if (STAND_IN_EMAIL.test(value)) return value;
|
|
159
|
+
const n = isPersonRecord ? self() : numberFor([`email:${value.toLowerCase()}`]);
|
|
160
|
+
return `builder-${n}@example.com`;
|
|
161
|
+
}
|
|
162
|
+
if (isByKey(key) && looksLikeLogin(value)) {
|
|
163
|
+
if (STAND_IN_LOGIN.test(value)) return value;
|
|
164
|
+
return `builder-${numberFor([`login:${value.toLowerCase()}`])}`;
|
|
165
|
+
}
|
|
166
|
+
// A PR / commit author is a GitHub login; elsewhere `author` credits an
|
|
167
|
+
// organisation (a module's author), which is nobody's personal record.
|
|
168
|
+
if (key === 'author' && 'head_sha' in obj && looksLikeLogin(value)) {
|
|
169
|
+
if (STAND_IN_LOGIN.test(value)) return value;
|
|
170
|
+
return `builder-${numberFor([`login:${value.toLowerCase()}`])}`;
|
|
171
|
+
}
|
|
172
|
+
if (key in PLACEHOLDER_TEXT) return PLACEHOLDER_TEXT[key];
|
|
173
|
+
if (isGithubIdKey(key)) return String(githubIdFor(obj, value, self, isPersonRecord));
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof value === 'number') {
|
|
178
|
+
if (isGithubIdKey(key)) return githubIdFor(obj, value, self, isPersonRecord);
|
|
179
|
+
if (ctx.ownAccount && isPersonRecord) {
|
|
180
|
+
if (key === 'total_credits') return OWN_CREDITS;
|
|
181
|
+
if (key === 'karma' || key === 'total_karma') return OWN_KARMA;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// A GitHub id names an account anywhere on GitHub, so it follows its person's n.
|
|
188
|
+
function githubIdFor(obj, value, self, isPersonRecord) {
|
|
189
|
+
if (isPersonRecord) return self();
|
|
190
|
+
const n = Number(value);
|
|
191
|
+
// A standalone small id is already a stand-in (real GitHub ids of any account a
|
|
192
|
+
// builder holds today are seven-plus digits).
|
|
193
|
+
if (Number.isInteger(n) && n > 0 && n < 100000) return n;
|
|
194
|
+
return numberFor([`id:${value}`]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The login key that sits beside a `<who>_name` key, for pairing the two.
|
|
198
|
+
function siblingNameKey(loginKey, obj) {
|
|
199
|
+
const who = loginKey.replace(/_login$/, '');
|
|
200
|
+
return [`${who}_name`, `${who}_display_name`].find((k) => typeof obj[k] === 'string' && obj[k]) || null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A profile's links are the person's own sites. Keep the shape, lose the addresses.
|
|
204
|
+
function scrubLinks(value) {
|
|
205
|
+
if (Array.isArray(value)) return value.map((v) => (typeof v === 'string' ? 'https://example.com/' : (v && typeof v === 'object' ? scrubLinks(v) : v)));
|
|
206
|
+
const out = {};
|
|
207
|
+
for (const [k, v] of Object.entries(value)) out[k] = typeof v === 'string' && v ? 'https://example.com/' : (v && typeof v === 'object' ? scrubLinks(v) : v);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// scrub(data, { ownAccount }) — a scrubbed deep copy; the input is not mutated.
|
|
212
|
+
// ownAccount: the fixture IS the snapshotting account (me, me__*), so its own
|
|
213
|
+
// credit balance and karma are replaced too.
|
|
214
|
+
function scrub(data, { ownAccount = false } = {}) {
|
|
215
|
+
const ctx = { ownAccount };
|
|
216
|
+
if (Array.isArray(data)) return scrubArray(data, ctx);
|
|
217
|
+
return data && typeof data === 'object' ? scrubObject(data, ctx) : data;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { scrub };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A fixture named `me` or `me__…` is the snapshotting account's own record.
|
|
224
|
+
const isOwnAccountFixture = (name) => name === 'me' || String(name).startsWith('me__');
|
|
225
|
+
|
|
226
|
+
// The highest stand-in number already used in some fixture texts, so a new run can
|
|
227
|
+
// start above it (createScrubber's firstNumber).
|
|
228
|
+
function highestStandIn(texts) {
|
|
229
|
+
let max = 0;
|
|
230
|
+
for (const t of texts) {
|
|
231
|
+
for (const m of String(t).matchAll(/"builder-(\d+)(?:@example\.com)?"|"Builder (\d+)"/gi)) {
|
|
232
|
+
max = Math.max(max, Number(m[1] || m[2]));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return max;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// rewriteScalars(text, next) — the JSON text of a hand-written fixture with ONLY the
|
|
239
|
+
// changed scalar values replaced, byte-for-byte everywhere else. A plain
|
|
240
|
+
// JSON.stringify would also reformat every compact array and inline object a person
|
|
241
|
+
// wrote by hand, burying a handful of identity edits in a whole-file rewrite.
|
|
242
|
+
function rewriteScalars(text, next) {
|
|
243
|
+
const splices = [];
|
|
244
|
+
let i = 0;
|
|
245
|
+
const ws = () => { while (i < text.length && /\s/.test(text[i])) i++; };
|
|
246
|
+
const readString = () => {
|
|
247
|
+
const start = i++;
|
|
248
|
+
while (text[i] !== '"') i += text[i] === '\\' ? 2 : 1;
|
|
249
|
+
i++;
|
|
250
|
+
return { start, end: i, value: JSON.parse(text.slice(start, i)) };
|
|
251
|
+
};
|
|
252
|
+
const readScalar = () => {
|
|
253
|
+
if (text[i] === '"') return readString();
|
|
254
|
+
const start = i;
|
|
255
|
+
while (i < text.length && !/[\s,\]}]/.test(text[i])) i++;
|
|
256
|
+
return { start, end: i, value: JSON.parse(text.slice(start, i)) };
|
|
257
|
+
};
|
|
258
|
+
const visit = (after) => {
|
|
259
|
+
ws();
|
|
260
|
+
if (text[i] === '{') {
|
|
261
|
+
i++; ws();
|
|
262
|
+
if (text[i] === '}') { i++; return; }
|
|
263
|
+
for (;;) {
|
|
264
|
+
ws(); const { value: key } = readString(); ws(); i++; // the ':'
|
|
265
|
+
visit(after == null ? undefined : after[key]);
|
|
266
|
+
ws();
|
|
267
|
+
if (text[i] === ',') { i++; continue; }
|
|
268
|
+
i++; return; // the '}'
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (text[i] === '[') {
|
|
272
|
+
i++; ws();
|
|
273
|
+
if (text[i] === ']') { i++; return; }
|
|
274
|
+
for (let idx = 0; ; idx++) {
|
|
275
|
+
visit(after == null ? undefined : after[idx]);
|
|
276
|
+
ws();
|
|
277
|
+
if (text[i] === ',') { i++; continue; }
|
|
278
|
+
i++; return; // the ']'
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const tok = readScalar();
|
|
282
|
+
if (after !== undefined && after !== tok.value) splices.push([tok.start, tok.end, JSON.stringify(after)]);
|
|
283
|
+
};
|
|
284
|
+
visit(next);
|
|
285
|
+
let out = text;
|
|
286
|
+
for (const [start, end, repl] of splices.reverse()) out = out.slice(0, start) + repl + out.slice(end);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = {
|
|
291
|
+
createScrubber,
|
|
292
|
+
isOwnAccountFixture,
|
|
293
|
+
highestStandIn,
|
|
294
|
+
rewriteScalars,
|
|
295
|
+
GHOST_AVATAR_URL,
|
|
296
|
+
};
|
|
@@ -144,4 +144,26 @@ async function readCoreUpdate({ running, channel, get, ...deps } = {}) {
|
|
|
144
144
|
return coreUpdateVerdict({ running, available: r.ok ? r.versions : [], channel });
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
// readCoreUpgrades — the instance's own core-upgrade ledger, newest first: when the
|
|
148
|
+
// core pin last moved, from which version to which, and who or what moved it (task
|
|
149
|
+
// 1002669). `core_upgrades` is written by `bongos upgrade` on every successful move and
|
|
150
|
+
// rollback (scripts/gds/upgrade.js), including the unattended patch sweep's, and no
|
|
151
|
+
// route read it — so "when did the pin last move" needed an SSH session. The pool is
|
|
152
|
+
// injectable so the test runs with no database; the default is the instance's own.
|
|
153
|
+
async function readCoreUpgrades({ limit = 20, pool = null } = {}) {
|
|
154
|
+
const db = pool || require('./pool').pool;
|
|
155
|
+
const { rows } = await db.query(
|
|
156
|
+
`SELECT id, from_version, to_version, source_commit, note, applied_at, source, requested_by_builder_id
|
|
157
|
+
FROM core_upgrades
|
|
158
|
+
ORDER BY applied_at DESC, id DESC
|
|
159
|
+
LIMIT $1`,
|
|
160
|
+
[limit]
|
|
161
|
+
);
|
|
162
|
+
return rows.map((r) => ({
|
|
163
|
+
...r,
|
|
164
|
+
id: String(r.id),
|
|
165
|
+
requested_by_builder_id: r.requested_by_builder_id == null ? null : String(r.requested_by_builder_id),
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { readCoreUpdate, readCoreUpgrades };
|
|
@@ -21,9 +21,16 @@
|
|
|
21
21
|
const express = require('express');
|
|
22
22
|
const { asyncHandler } = require('./_helpers');
|
|
23
23
|
const auth = require('../auth');
|
|
24
|
-
const { readCoreUpdate } = require('../core-update');
|
|
24
|
+
const { readCoreUpdate, readCoreUpgrades } = require('../core-update');
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// ?limit for GET /core-upgrades: an integer 1..100, else 20.
|
|
27
|
+
function upgradesLimit(raw) {
|
|
28
|
+
const n = Number.parseInt(String(raw ?? ''), 10);
|
|
29
|
+
if (!Number.isFinite(n)) return 20;
|
|
30
|
+
return Math.min(100, Math.max(1, n));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = function buildCoreUpdateRouter({ upgrades = readCoreUpgrades } = {}) {
|
|
27
34
|
const router = express.Router();
|
|
28
35
|
|
|
29
36
|
router.get(
|
|
@@ -45,5 +52,23 @@ module.exports = function buildCoreUpdateRouter() {
|
|
|
45
52
|
}, { errorCode: 'core_update_failed', message: false }),
|
|
46
53
|
);
|
|
47
54
|
|
|
55
|
+
// GET /core-upgrades — the instance's own core-upgrade ledger, newest first: when
|
|
56
|
+
// the pin last moved, from and to which version, and whether a person or the
|
|
57
|
+
// unattended sweep moved it (task 1002669). READ-ONLY. Gated on the SAME atom as
|
|
58
|
+
// /core-update above and the deploy door, for the same first reason: it names the
|
|
59
|
+
// exact core this box has run and when, which is a vulnerability-window disclosure
|
|
60
|
+
// (ADR 0043). No new atom, so no migration. ?limit=1..100 (default 20).
|
|
61
|
+
router.get(
|
|
62
|
+
'/core-upgrades',
|
|
63
|
+
auth.requireBuilder,
|
|
64
|
+
auth.requirePermission('core.pin.move'),
|
|
65
|
+
asyncHandler('GET /core-upgrades', async (req, res) => {
|
|
66
|
+
let running = null;
|
|
67
|
+
try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
|
|
68
|
+
const limit = upgradesLimit(req.query.limit);
|
|
69
|
+
res.json({ ok: true, running, limit, upgrades: await upgrades({ limit }) });
|
|
70
|
+
}, { errorCode: 'core_upgrades_unreadable', message: false }),
|
|
71
|
+
);
|
|
72
|
+
|
|
48
73
|
return router;
|
|
49
74
|
};
|
|
@@ -21,7 +21,7 @@ const fs = require('node:fs/promises');
|
|
|
21
21
|
const auth = require('../auth');
|
|
22
22
|
const db = require('../db');
|
|
23
23
|
const { validateOrRespond } = require('./_helpers');
|
|
24
|
-
const { resolveEnv } = require('../../instance-config');
|
|
24
|
+
const { resolveEnv, envPrefix } = require('../../instance-config');
|
|
25
25
|
|
|
26
26
|
// Slug shape for security_docs (task 991): lowercase kebab, 1–128 chars. Used to
|
|
27
27
|
// validate POST /security/docs and to safely interpolate :slug into the GET.
|
|
@@ -102,8 +102,8 @@ module.exports = function buildSecurityRouter() {
|
|
|
102
102
|
}
|
|
103
103
|
const hint =
|
|
104
104
|
result.reason === 'env_not_set'
|
|
105
|
-
?
|
|
106
|
-
:
|
|
105
|
+
? `${envPrefix()}_SECURITY_ADR_PATH is unset on the server; an Archon must provision the file and the env var.`
|
|
106
|
+
: `${envPrefix()}_SECURITY_ADR_PATH (or its legacy GDS_ spelling) is set but the file is unreadable (missing, wrong perms, or wrong path).`;
|
|
107
107
|
return res.fail('security_adr_not_provisioned', 404, { reason: result.reason, hint });
|
|
108
108
|
}
|
|
109
109
|
);
|