@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.
Files changed (114) hide show
  1. package/.bongos-core.json +204 -99
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-recover/SKILL.md +2 -1
  4. package/.claude/skills/grader-health/SKILL.md +4 -0
  5. package/.claude/skills/merge-mode/SKILL.md +1 -1
  6. package/clients/bongos-client/README.md +1 -1
  7. package/clients/bongos-client/bongos-client.global.js +6 -0
  8. package/clients/bongos-client/index.cjs +6 -0
  9. package/clients/bongos-client/index.d.ts +8 -0
  10. package/clients/bongos-client/index.mjs +6 -0
  11. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  12. package/docs/adr/README.md +1 -1
  13. package/docs/api/openapi.json +120 -4
  14. package/docs/api-reference.md +9 -2
  15. package/docs/architecture.md +3 -2
  16. package/docs/canonical-permissions.md +2 -2
  17. package/docs/copy-inventory.md +34 -30
  18. package/docs/copy-registry.json +69 -33
  19. package/docs/module-api-changelog.md +5 -1
  20. package/docs/recipes/local-dev.md +45 -3
  21. package/docs/recipes/packaging-the-core.md +1 -1
  22. package/modules/autonomy/cadence.js +52 -0
  23. package/modules/autonomy/db.js +19 -0
  24. package/modules/autonomy/routes/autonomy.js +28 -0
  25. package/modules/government/catalog.js +2 -2
  26. package/modules/government/protected-surfaces.json +1 -0
  27. package/modules/grading/grade-engine.js +267 -0
  28. package/modules/grading/grader-subagent.js +4 -3
  29. package/modules/grading/grader-workers/index.js +67 -11
  30. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  31. package/modules/grading/grader.js +109 -88
  32. package/modules/hall-ui/public/deploy.js +35 -0
  33. package/modules/hall-ui/public/project-settings.css +7 -0
  34. package/modules/lifecycle/routes/tasks.js +19 -4
  35. package/modules/lifecycle/ship-assurance.js +17 -1
  36. package/modules/provisioning/control-plane-guard.js +14 -13
  37. package/modules/provisioning/core-upgrade.js +30 -17
  38. package/modules/provisioning/public-refusal.js +44 -0
  39. package/modules/provisioning/routes/disconnect.js +2 -1
  40. package/modules/provisioning/routes/env-manifest.js +5 -4
  41. package/modules/provisioning/routes/provisioning.js +16 -15
  42. package/modules/provisioning/routes/repo-private.js +2 -1
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +80 -0
  46. package/scripts/gds/autobongos-run.js +10 -2
  47. package/scripts/gds/backfill-cost.js +20 -9
  48. package/scripts/gds/claim.js +2 -2
  49. package/scripts/gds/credential-presence.js +281 -0
  50. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  51. package/scripts/gds/dev-lib.js +212 -5
  52. package/scripts/gds/dev.js +102 -50
  53. package/scripts/gds/doctor.js +3 -2
  54. package/scripts/gds/grade-correlation-audit.js +280 -0
  55. package/scripts/gds/grade-replay.js +237 -0
  56. package/scripts/gds/hooks/pre-push.js +7 -3
  57. package/scripts/gds/main-audit.js +19 -10
  58. package/scripts/gds/memory-map.js +3 -1
  59. package/scripts/gds/onboarding-config.js +3 -2
  60. package/scripts/gds/plain-cards.js +4 -0
  61. package/scripts/gds/preflight.js +12 -4
  62. package/scripts/gds/provision-core-upgrade.js +155 -4
  63. package/scripts/gds/provision-repo-auth.js +15 -0
  64. package/scripts/gds/provision.js +1 -1
  65. package/scripts/gds/publish-credential-check.js +3 -0
  66. package/scripts/gds/push-main.js +14 -3
  67. package/scripts/gds/push-path-brief.js +3 -2
  68. package/scripts/gds/run-routine.js +18 -5
  69. package/scripts/gds/run-unit-tests.js +14 -3
  70. package/scripts/gds/session-cost.js +12 -3
  71. package/scripts/gds/ship-deploy-target.js +11 -6
  72. package/scripts/gds/ship-grade-steps.js +33 -0
  73. package/scripts/gds/ship-land.js +157 -11
  74. package/scripts/gds/ship-merge.js +3 -2
  75. package/scripts/gds/ship-preflight-steps.js +30 -8
  76. package/scripts/gds/ship.js +1 -1
  77. package/scripts/gds/upgrade.js +3 -3
  78. package/scripts/hall-preview/README.md +20 -3
  79. package/scripts/hall-preview/live-identity.js +1 -1
  80. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  81. package/scripts/hall-preview/scrub-identity.js +296 -0
  82. package/src/bongos/core-update.js +23 -1
  83. package/src/bongos/routes/core-update.js +27 -2
  84. package/src/bongos/routes/security.js +3 -3
  85. package/src/build-info.js +4 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/adr_0089_scope.mjs +70 -0
  88. package/tests/autobongos_cadence.mjs +74 -0
  89. package/tests/consumer_layout_boot.mjs +363 -31
  90. package/tests/core_upgrade_door.mjs +40 -6
  91. package/tests/core_upgrade_runner.mjs +240 -4
  92. package/tests/credential_presence.mjs +153 -0
  93. package/tests/deploy_audit_gate.mjs +5 -2
  94. package/tests/deploy_page_projects.mjs +63 -2
  95. package/tests/dev_lib.mjs +2 -1
  96. package/tests/dev_windows.mjs +324 -0
  97. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  98. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  99. package/tests/government_protected_surfaces.mjs +10 -0
  100. package/tests/grade_correlation_audit.mjs +159 -0
  101. package/tests/grade_engine.mjs +249 -0
  102. package/tests/grade_engine_replay.mjs +99 -0
  103. package/tests/grader_prompt_window_bound.mjs +132 -0
  104. package/tests/grader_unbacked_score.mjs +194 -0
  105. package/tests/hall_preview_scrub.mjs +214 -0
  106. package/tests/integration_parked_ratchet.mjs +24 -1
  107. package/tests/provisioning_public_refusal.mjs +195 -0
  108. package/tests/publish_status_branch.mjs +128 -0
  109. package/tests/routine_observability_api.mjs +195 -0
  110. package/tests/ship_assurance.mjs +86 -3
  111. package/tests/ship_land_red_pr.mjs +236 -0
  112. package/tests/ship_preflight.mjs +67 -1
  113. package/tests/start_env_guard.mjs +16 -0
  114. 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 TOOLING_PATHS = [
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
  };
@@ -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
- // OTB_DEPLOY_MODE + OTB_PUSH_VIA_SERVER).
357
+ // <PREFIX>_DEPLOY_MODE + <PREFIX>_PUSH_VIA_SERVER).
358
358
  broadcastHandledByServer,
359
359
  fatalRecoveryLines, // G04 re-export (definition in ship-honesty.js)
360
360
  };
@@ -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 is stored to push with instead.${gitSaid(pushed)}`);
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 is stored to push with — the pin is committed locally but not on the remote`,
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: @scootermack (archon) — your own, live
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: @jaxri (metic) — NOT YOURS, the checked-in fixture: this machine has no Bongos CLI session — run /builder-setup
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, Jackson Rini"), with that builder's credits,
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 { cliClient } = require('../gds/cli-lib');
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
- (async () => {
81
- const api = await cliClient();
82
- fs.mkdirSync(FIXTURES, { recursive: true });
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 SNAPSHOTS) {
105
+ for (const [apiPath, name, trim] of snapshots) {
85
106
  const res = await api.request('GET', `/${apiPath}`, {});
86
107
  if (!res.ok) {
87
- console.log(` skip ${apiPath} → ${res.status} (kept existing fixture if present)`);
108
+ log(` skip ${apiPath} → ${res.status} (kept existing fixture if present)`);
88
109
  continue;
89
110
  }
90
- const data = trim ? trim(res.data) : res.data;
91
- fs.writeFileSync(path.join(FIXTURES, `${name}.json`), `${JSON.stringify(data, null, 2)}\n`);
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
- console.log(` wrote ${name}.json (${apiPath})`);
116
+ log(` wrote ${name}.json (${apiPath})`);
94
117
  }
95
- console.log(`refresh-fixtures: ${wrote}/${SNAPSHOTS.length} snapshots written to ${FIXTURES}`);
96
- })().catch((e) => { console.error(e.message || e); process.exit(1); });
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
- module.exports = { readCoreUpdate };
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
- module.exports = function buildCoreUpdateRouter() {
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
- ? 'GDS_SECURITY_ADR_PATH is unset on the server; an Archon must provision the file and the env var.'
106
- : 'GDS_SECURITY_ADR_PATH is set but the file is unreadable (missing, wrong perms, or wrong path).';
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
  );