@bongos/core 1.19.788 → 1.19.790

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 (48) hide show
  1. package/.bongos-core.json +111 -41
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +6 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +129 -1
  10. package/docs/api-reference.md +5 -2
  11. package/docs/copy-inventory.md +31 -14
  12. package/docs/copy-registry.json +205 -31
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/recipes/core-release-pipeline.md +12 -0
  15. package/docs/recipes/upgrading-the-core.md +7 -1
  16. package/migrations/core_241_core_upgrades_actor.sql +65 -0
  17. package/migrations/core_242_core_version_snapshot.sql +54 -0
  18. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  19. package/modules/government/catalog.js +8 -0
  20. package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
  21. package/modules/hall-ui/public/deploy.html +64 -0
  22. package/modules/hall-ui/public/deploy.js +188 -0
  23. package/modules/hall-ui/public/shell.js +4 -0
  24. package/modules/provisioning/control-plane-guard.js +102 -0
  25. package/modules/provisioning/core-upgrade.js +289 -0
  26. package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
  27. package/modules/provisioning/module.json +1 -1
  28. package/modules/provisioning/provisioning.js +8 -8
  29. package/modules/provisioning/routes/core-upgrade.js +95 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/scripts/gds/enroll-control-plane.js +101 -0
  33. package/scripts/gds/provision-core-upgrade.js +311 -0
  34. package/scripts/gds/provision.js +4 -2
  35. package/scripts/gds/upgrade.js +53 -14
  36. package/src/bongos/auth.js +19 -0
  37. package/src/bongos/serve-internal.js +21 -0
  38. package/src/module-api.js +1 -1
  39. package/tests/auth_resolve_failure.mjs +4 -2
  40. package/tests/core_upgrade_door.mjs +408 -0
  41. package/tests/core_upgrade_runner.mjs +377 -0
  42. package/tests/hall_nav.mjs +4 -1
  43. package/tests/hall_page_gate_map.mjs +7 -0
  44. package/tests/provisioning_disconnect.mjs +30 -5
  45. package/tests/provisioning_paid_shape_gate.mjs +14 -4
  46. package/tests/provisioning_restart.mjs +25 -9
  47. package/tests/provisioning_settings_apply.mjs +11 -2
  48. package/tests/provisioning_teardown_intent.mjs +7 -0
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/provision-core-upgrade.js — the owner's deploy door, CONTROL-PLANE half
4
+ // (task 1003159, ADR 0293).
5
+ //
6
+ // The web tier queues a 'core-upgrade' intent naming a target version and gains no new
7
+ // capability doing so (modules/provisioning/core-upgrade.js). THIS is the half that acts:
8
+ // the runner drains that intent and drives the proven `bongos upgrade` path, with its
9
+ // health gate, its served-version check and its auto-rollback (task 2149) intact.
10
+ //
11
+ // It is a DOOR ONTO THE PROVEN PATH, NOT A SECOND UPGRADE MECHANISM. Every step that
12
+ // could get an upgrade wrong — install, migrate, pin, restart, verify, revert — already
13
+ // lives in upgrade.js and is exercised by every hand-run bump. Re-implementing any of it
14
+ // here would mean two paths that must stay in step, and the one used less often would be
15
+ // the one that rots. So this file does exactly three things upgrade.js cannot do for
16
+ // itself: decide whether the request is ALLOWED, decide whether the target is REAL, and
17
+ // report what the box actually serves afterwards.
18
+ //
19
+ // WHY THE REGISTRY CHECK LIVES HERE AND NOT ON THE ROUTE (ADR 0293 D3). Answering "is
20
+ // this version published" means `npm view`, which update-channel.js runs via spawnSync —
21
+ // on the single-process web server that blocks every other request for the seconds npm
22
+ // takes. Here it is a CLI process with nothing else to serve, and blocking is free. The
23
+ // check is not optional tidiness: the release lane mints a git tag BEFORE the npm publish
24
+ // completes (8–13 minutes, measured 2026-09-16 — learnings 1000263/1000266), so a door
25
+ // that trusted the request would routinely queue a version that does not exist yet and
26
+ // fail deep inside npm with `notarget`.
27
+ //
28
+ // Its own file because scripts/gds/provision.js sits at the 1500-line oversized-file
29
+ // ratchet, the same reason provision-net.js and provision-units.js were carved out.
30
+
31
+ const path = require('path');
32
+ const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
33
+ const { standaloneRoot } = require('./provision-repo.js');
34
+ const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
35
+
36
+ // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
37
+ // web-tier half, and deliberately EXCLUDES 'dedicated': a dedicated droplet is stood up
38
+ // by cloud-init and the runner has no post-hoc exec path to it, so queueing an upgrade
39
+ // for one would be a request that is accepted and never lands. The route refuses the
40
+ // same shape; this is the second gate, and it is the one that holds if a row's shape
41
+ // changes between the request and the drain.
42
+ const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'standalone', 'control-plane']));
43
+
44
+ // The box's own answer to "what core am I running?" — loopback, on the instance's port.
45
+ //
46
+ // THE APEX CANNOT BE ASKED (ADR 0293 D6). It sits behind pre-launch basic_auth and its
47
+ // HTML rides a CDN, so a public read can report a stale or gated answer with complete
48
+ // confidence. 127.0.0.1 is the only address that is answering FROM the process whose
49
+ // version is in question. This is the same reasoning healthzCmd already encodes, and the
50
+ // DONE-WHEN task 1003156 established in so many words.
51
+ function versionCmd(inst) {
52
+ return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/version`;
53
+ }
54
+
55
+ // Pull coreVersion out of whatever /version answers with.
56
+ //
57
+ // TOLERANT BY DESIGN, and the tolerance is load-bearing rather than lazy. This reads a
58
+ // process that may be running a core OLDER than the one that wrote this parser — that is
59
+ // the entire point of an upgrade — so the shape of its response is not something this
60
+ // side gets to assume. A body it cannot read returns null, which the caller reports as
61
+ // "could not confirm", never as a mismatch: claiming the upgrade failed because the
62
+ // reply was unfamiliar would roll back a perfectly good deploy.
63
+ function parseServedVersion(stdout) {
64
+ if (!stdout || typeof stdout !== 'string') return null;
65
+ try {
66
+ const j = JSON.parse(stdout);
67
+ for (const k of ['coreVersion', 'core_version', 'version']) {
68
+ if (typeof j[k] === 'string' && j[k].trim()) return j[k].trim();
69
+ }
70
+ } catch { /* not JSON — fall through to the text probe */ }
71
+ const m = stdout.match(/\b\d+\.\d+\.\d+\b/);
72
+ return m ? m[0] : null;
73
+ }
74
+
75
+ /**
76
+ * Is this target a version the instance may actually be moved to? PURE.
77
+ *
78
+ * Two separate questions, and conflating them is how a door starts lying:
79
+ * • does it EXIST — is it published to the registry at all;
80
+ * • is it ALLOWED — does this instance's channel policy admit it (ADR 0136).
81
+ *
82
+ * A version that exists but the channel forbids is a refusal the owner can act on
83
+ * ("your channel is stable and that is a prerelease"); one that does not exist yet is a
84
+ * refusal they can only wait out. Saying which is which is the difference between a
85
+ * useful message and "upgrade failed".
86
+ *
87
+ * @param {string} to the requested version
88
+ * @param {string[]} available versions the registry reports
89
+ * @param {string} installed the version running now
90
+ * @param {string} channel this instance's update channel
91
+ */
92
+ function targetFault(to, available, installed, channel, channelAllows) {
93
+ if (!Array.isArray(available) || available.length === 0) {
94
+ return `could not read the published core versions from the registry — nothing was changed; the release may still be publishing (a tag is minted before the publish completes)`;
95
+ }
96
+ if (!available.includes(to)) {
97
+ return `core ${to} is not published yet — nothing was changed. A release tag appears several minutes before the package itself does; try again once it lands.`;
98
+ }
99
+ if (installed && channelAllows && !channelAllows(installed, to, channel)) {
100
+ return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. Change the channel first if this is deliberate.`;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Perform a queued core-pin move. Returns { ok } — NEVER throws for an expected refusal.
107
+ *
108
+ * The drain loop's catch flips the instance to 'error', which is the wrong outcome for
109
+ * every refusal here: a request that was never valid, or a version that is not published
110
+ * yet, says nothing at all about the health of a running instance. So each of those
111
+ * returns { ok:false, error } and the loop retries or retires the INTENT, leaving the
112
+ * instance exactly as it found it (ADR 0190 §2, the shape restartInstance established).
113
+ *
114
+ * @param {object} inst the provisioning_instances row
115
+ * @param {object} deps the runner's dependency bag
116
+ * @param {object} intent the claimed provisioning_intents row (carries target_version)
117
+ */
118
+ async function coreUpgradeInstance(inst, deps, intent) {
119
+ const { db, provisioning, exec, log, apply, privileged } = deps;
120
+ // A standalone's box IS the control plane's box, the same routing restartInstance and
121
+ // settingsApplyInstance use; a co-tenant shares this checkout.
122
+ const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
123
+ const to = intent && intent.target_version;
124
+ log(`core-upgrade ${inst.slug} → ${to || '(no target)'} (shape=${inst.hosting_shape}, status=${inst.status})`);
125
+
126
+ // ---- 1. is this request allowed at all ------------------------------------------
127
+ // UPGRADE_SHAPES IS THE WHOLE SHAPE CHECK. The self-row guard is deliberately not
128
+ // consulted: `intentAllowedForShape('core-upgrade', …)` is true for every shape
129
+ // there is, because 'core-upgrade' is permanently on CONTROL_PLANE_ACTIONS — so
130
+ // calling it here would read as a second gate while being unable to refuse
131
+ // anything. The guard's real job is the OTHER actions on the self row, enforced at
132
+ // enqueueIntent where every intent passes.
133
+ if (!UPGRADE_SHAPES.has(inst.hosting_shape)) {
134
+ return { ok: false, error: `core-upgrade is not automated for a '${inst.hosting_shape}' instance — whoever runs that machine has to upgrade it there` };
135
+ }
136
+ if (inst.status !== 'active') {
137
+ // Not an error: a teardown or a stand-up that raced the request must not burn the
138
+ // intent's retry budget, and there is no settled pin to move either way.
139
+ log(` instance is '${inst.status}' — no running core whose pin can move`);
140
+ return { ok: true, noop: true };
141
+ }
142
+ if (!to) {
143
+ return { ok: false, error: 'core-upgrade intent carries no target_version — refusing to guess which version was meant' };
144
+ }
145
+ // THE SLUG IS INTERPOLATED INTO A SHELL COMMAND BELOW, so it is re-checked HERE
146
+ // rather than trusted. `isValidSlug` is enforced on the create route, but the
147
+ // control-plane self row is inserted by an operator script (enroll-control-plane.js)
148
+ // and any later write path is one more place that would have to remember. The value
149
+ // reaching a shell is this function's problem, and a format check at the point of use
150
+ // is the only one that cannot be bypassed by a path nobody has written yet. The other
151
+ // two interpolated values are already constrained — the version by the column's own
152
+ // CHECK and by VERSION_RE, the actor by the digits-only extraction below.
153
+ if (!isValidSlug(inst.slug)) {
154
+ return { ok: false, error: `instance slug '${inst.slug}' is not a valid slug — refusing to build a command from it` };
155
+ }
156
+
157
+ // ---- 2. is the target real, and permitted -----------------------------------------
158
+ const channelMod = deps.updateChannel || require('./update-channel.js');
159
+ const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
160
+ const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
161
+ const installed = readInstalled(instanceDir);
162
+ // THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
163
+ // otherwise. provisioning_instances carries no channel column; the subscription
164
+ // roster update-channel.js reads is a control-plane file, not a per-row setting. So
165
+ // this bounds every instance by the same policy (DEFAULT_CHANNEL: patch-only), which
166
+ // is a real bound — it refuses a prerelease or a major jump — and is the honest one
167
+ // available today. When a per-instance channel lands, this line is where it is read;
168
+ // writing `inst.update_channel ||` now would only have created a guard that reads as
169
+ // present and can never fire, which is the trap the web-tier half already documents.
170
+ const channel = channelMod.normalizeChannel(channelMod.DEFAULT_CHANNEL);
171
+ const listed = channelMod.listAvailableVersions({ cwd: instanceDir });
172
+ const available = (listed && listed.versions) || [];
173
+ const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
174
+ if (fault) return { ok: false, error: fault };
175
+ if (installed && installed === to) {
176
+ // `bongos upgrade` would no-op on this too, but saying so here keeps a re-drained
177
+ // intent from restarting a healthy service to land exactly where it already is.
178
+ log(` already serving core ${to} — nothing to move`);
179
+ return { ok: true, noop: true };
180
+ }
181
+
182
+ // ---- 3. drive the proven path -----------------------------------------------------
183
+ // Exactly the invocation docs/recipes/core-release-pipeline.md gate 3 documents, plus
184
+ // the two attribution flags (ADR 0293 D5). --commit-pin is NOT optional here: without
185
+ // it the pin lives only in the working tree, and the next pull-deploy resets it away —
186
+ // the silent thirteen-release revert upgrade.js's own header records.
187
+ const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
188
+ const versionUrl = `http://127.0.0.1:${inst.port}/version`;
189
+ const sudoP = privileged ? 'sudo ' : '';
190
+ const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
191
+ const requestedBy = intent.requested_by && /^api:archon:(\d+)$/.test(intent.requested_by)
192
+ ? intent.requested_by.replace(/^api:archon:/, '')
193
+ : '';
194
+ const cmd = [
195
+ `${sudoP}node ${cli}`,
196
+ '--registry',
197
+ `--to ${to}`,
198
+ `--service ${inst.slug}`,
199
+ `--health-url ${healthUrl}`,
200
+ `--version-url ${versionUrl}`,
201
+ '--commit-pin',
202
+ requestedBy ? `--requested-by ${requestedBy}` : '',
203
+ requestedBy ? '--upgrade-source owner-control' : '',
204
+ ].filter(Boolean).join(' ');
205
+
206
+ if (!apply) {
207
+ log(` ✓ would run: ${cmd} (dry-run — nothing ran)`);
208
+ log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
209
+ return { ok: true, served: null };
210
+ }
211
+ try {
212
+ log(` [upgrade] ${cmd}`);
213
+ boxExec(cmd, { cwd: instanceDir });
214
+ } catch (e) {
215
+ // upgrade.js rolls itself back on a failed health check or an unchanged served
216
+ // version, so by the time this throws the instance is back on the core it had.
217
+ // ok:false, never a throw: the instance is healthy and must not be marked broken.
218
+ return { ok: false, error: `core upgrade to ${to} failed and was rolled back: ${(e && e.message) || e}` };
219
+ }
220
+
221
+ // ---- 4. read back what the BOX serves (ADR 0293 D6) --------------------------------
222
+ // With the same patience the manifest probe uses: the service has just restarted and a
223
+ // single immediate read races it binding its port. A false "did not take" here is
224
+ // never cosmetic — it is what the owner is shown after the one act they were told they
225
+ // could finally perform.
226
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
227
+ let served = null;
228
+ for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
229
+ const res = boxExec(versionCmd(inst), { allowFail: true });
230
+ served = parseServedVersion(res && !res.softFailed ? (res.stdout || '') : null);
231
+ if (served) break;
232
+ if (i < MANIFEST_VERIFY_TRIES - 1) await sleep(MANIFEST_VERIFY_INTERVAL_MS);
233
+ }
234
+ if (served && served !== to) {
235
+ // upgrade.js's own --version-url gate should have caught and reverted this, so
236
+ // reaching here means the two disagree. Report what was SEEN rather than a verdict.
237
+ return { ok: false, error: `the box is serving core ${served}, not the requested ${to} — the move did not take` };
238
+ }
239
+ await provisioning.recordEvent(db, {
240
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'core-upgraded',
241
+ detail: served ? `core pin moved to ${served}` : `core pin moved to ${to}; the box did not answer /version, so this is unconfirmed`,
242
+ actor: intent.requested_by || 'runner',
243
+ }).catch(() => {});
244
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
245
+ log(served ? ` ✓ ${inst.slug} serves core ${served}` : ` ⚠ ${inst.slug} upgraded to ${to}, but did not answer /version — unconfirmed`);
246
+ return { ok: true, served, confirmed: served === to };
247
+ }
248
+
249
+ /**
250
+ * Observe one instance and cache what the deploy control needs, without acting.
251
+ *
252
+ * Runs on every drain tick for each upgradable instance. Both facts it records are ones
253
+ * the web tier cannot reach for itself — the served version lives on loopback, the
254
+ * available versions need a blocking `npm view` (migration core_242 says why at length)
255
+ * — so this is the runner reporting on behalf of a process that may only ask.
256
+ *
257
+ * BEST-EFFORT, ALWAYS. A registry hiccup or a box that will not answer must never fail a
258
+ * tick that has real work in it: the snapshot simply keeps its previous value and its
259
+ * previous, now-visibly-older, observed_at. That staleness is the honest signal, and it
260
+ * is why observed_at is stored rather than assumed.
261
+ */
262
+ async function snapshotCoreVersions(inst, deps) {
263
+ const { db, exec, log } = deps;
264
+ if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') return { ok: true, skipped: true };
265
+ const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
266
+ const channelMod = deps.updateChannel || require('./update-channel.js');
267
+ const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
268
+ try {
269
+ const probe = boxExec(versionCmd(inst), { allowFail: true });
270
+ const served = parseServedVersion(probe && !probe.softFailed ? (probe.stdout || '') : null);
271
+ const listed = channelMod.listAvailableVersions({ cwd: instanceDir });
272
+ const available = (listed && listed.versions) || [];
273
+ // What the channel would take FROM the version the box is actually on. Resolving it
274
+ // here rather than in the browser is what keeps the button's promise and the
275
+ // runner's behaviour the same sentence.
276
+ // resolveChannelTarget returns the bare version string, or null when the channel
277
+ // admits nothing newer — "already current" and "nothing to take" are the same
278
+ // answer here, and both correctly leave `recommended` NULL.
279
+ const recommended = served
280
+ ? channelMod.resolveChannelTarget({ installed: served, channel: channelMod.DEFAULT_CHANNEL, available })
281
+ : null;
282
+ await db.query(
283
+ `INSERT INTO core_version_snapshot (instance_id, served_version, available, recommended, observed_at)
284
+ VALUES ($1, $2, $3::jsonb, $4, now())
285
+ ON CONFLICT (instance_id) DO UPDATE
286
+ SET served_version = EXCLUDED.served_version, available = EXCLUDED.available,
287
+ recommended = EXCLUDED.recommended, observed_at = now()`,
288
+ [inst.id, served, JSON.stringify(available), recommended || null]);
289
+ return { ok: true, served, available: available.length };
290
+ } catch (e) {
291
+ log(` ~ core-version snapshot for ${inst.slug} skipped (${(e && e.message) || e})`);
292
+ return { ok: true, skipped: true };
293
+ }
294
+ }
295
+
296
+ /** Snapshot every instance whose box the runner can reach. Never throws. */
297
+ async function snapshotAllCoreVersions(deps) {
298
+ const { db, log } = deps;
299
+ const shapes = [...UPGRADE_SHAPES];
300
+ const { rows } = await db.query(
301
+ `SELECT * FROM provisioning_instances WHERE status = 'active' AND hosting_shape = ANY($1)`, [shapes]);
302
+ let seen = 0;
303
+ for (const inst of rows) {
304
+ const r = await snapshotCoreVersions(inst, deps);
305
+ if (r && !r.skipped) seen++;
306
+ }
307
+ if (seen) log(`core-version snapshot: refreshed ${seen} instance(s)`);
308
+ return { seen };
309
+ }
310
+
311
+ module.exports = { UPGRADE_SHAPES, versionCmd, parseServedVersion, targetFault, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
@@ -56,6 +56,7 @@ const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dns
56
56
  const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
57
57
  const { teardownInstance } = require('./provision-teardown.js');
58
58
  const { disconnectInstance } = require('./provision-disconnect.js');
59
+ const { coreUpgradeInstance, snapshotAllCoreVersions } = require('./provision-core-upgrade.js'); // the owner's deploy door, ADR 0293
59
60
 
60
61
  function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
61
62
  // `shown` (task 1003140): what to LOG in place of the command text — for a command
@@ -1238,6 +1239,7 @@ async function cmdRunIntents(deps) {
1238
1239
  else if (intent.action === 'settings-apply') result = await settingsApplyInstance(inst, deps);
1239
1240
  else if (intent.action === 'restart') result = await restartInstance(inst, deps);
1240
1241
  else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
1242
+ else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
1241
1243
  else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
1242
1244
  if (result && result.ok === false) {
1243
1245
  if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
@@ -1260,8 +1262,8 @@ async function cmdRunIntents(deps) {
1260
1262
  errors++; log(` ✖ intent #${intent.id}: ${e.message}`);
1261
1263
  }
1262
1264
  }
1263
- // Same tick: convert + place any GitHub-App-Manifest creds awaiting the control plane.
1264
- const m = await drainOAuthManifests(deps);
1265
+ await snapshotAllCoreVersions(deps); // refresh what the deploy control reads, AFTER the drain (ADR 0293 D3/D6)
1266
+ const m = await drainOAuthManifests(deps); // same tick: convert + place any GitHub-App-Manifest creds awaiting the control plane
1265
1267
  log(`done — ran=${ran} ok=${ok} errors=${errors}; oauth-manifest converted=${m.converted} errors=${m.errors}`);
1266
1268
  return { ran, ok, errors, manifests: m };
1267
1269
  }
@@ -746,7 +746,33 @@ function restartService({ service, restartCmd }, run = spawnSync, deps = {}) {
746
746
  return { ok: null, skipped: true };
747
747
  }
748
748
 
749
- const LEDGER_INSERT_COLS = 'INSERT INTO core_upgrades (from_version, to_version, source_commit, note)';
749
+ // The two attribution columns migration core_241 adds (task 1003159, ADR 0293 D5).
750
+ //
751
+ // APPENDED ONLY WHEN THE CALLER SUPPLIES THEM, and that conditionality is the whole
752
+ // design rather than laziness. An upgrade run the old way — a human at a shell — has
753
+ // no builder to attribute, and this file also runs ON instances whose DB may not have
754
+ // core_241 yet (the core upgrades itself; the migration lands during the very run that
755
+ // would be writing this row). Naming a column that does not exist turns a best-effort
756
+ // ledger append into a hard `column "source" does not exist`, so the bump would report
757
+ // a failure over bookkeeping. Absent → the original four-column INSERT, byte for byte.
758
+ //
759
+ // The DB is still the backstop: core_upgrades_door_needs_actor_chk refuses a row that
760
+ // claims source='owner-control' without an actor, so a caller that passes one and not
761
+ // the other is rejected there rather than landing an unattributable privileged row.
762
+ const LEDGER_ACTOR_COLS = Object.freeze(['requested_by_builder_id', 'source']);
763
+
764
+ // Build the INSERT for the columns this call actually has. Returns the statement plus
765
+ // the ordered values, so the two connection paths below cannot drift apart.
766
+ function ledgerInsert({ fromVersion, toVersion, sourceCommit, note, requestedByBuilderId, source }) {
767
+ const cols = ['from_version', 'to_version', 'source_commit', 'note'];
768
+ const vals = [fromVersion || null, toVersion, sourceCommit || null, note || null];
769
+ if (requestedByBuilderId != null || source) {
770
+ cols.push(...LEDGER_ACTOR_COLS);
771
+ vals.push(requestedByBuilderId != null ? String(requestedByBuilderId) : null, source || null);
772
+ }
773
+ const holders = cols.map((_, i) => `$${i + 1}`).join(',');
774
+ return { sql: `INSERT INTO core_upgrades (${cols.join(', ')}) VALUES (${holders})`, cols, vals };
775
+ }
750
776
 
751
777
  // Best-effort append to the core_upgrades ledger (migration core_189). Resolves the DB the same two
752
778
  // ways a consumer might connect:
@@ -759,10 +785,11 @@ const LEDGER_INSERT_COLS = 'INSERT INTO core_upgrades (from_version, to_version,
759
785
  // Skips silently only when NEITHER is present (a local rehearsal with no DB pointed at) — the bump's
760
786
  // verification never depends on the ledger. An attempted-but-failed insert returns skipped:false so
761
787
  // the caller can surface it (the manual-INSERT footgun from 1976), never lose it.
762
- async function recordLedger({ fromVersion, toVersion, sourceCommit, note }, deps = {}) {
788
+ async function recordLedger({ fromVersion, toVersion, sourceCommit, note, requestedByBuilderId, source }, deps = {}) {
763
789
  const url = deps.databaseUrl !== undefined ? deps.databaseUrl : process.env.DATABASE_URL;
764
790
  const database = deps.pgDatabase !== undefined ? deps.pgDatabase : process.env.PGDATABASE;
765
791
  if (!url && !database) return { ok: false, skipped: true, reason: 'no DATABASE_URL or PGDATABASE' };
792
+ const stmt = ledgerInsert({ fromVersion, toVersion, sourceCommit, note, requestedByBuilderId, source });
766
793
 
767
794
  // DATABASE_URL path: the pg driver with a bound-parameter query (no psql client needed).
768
795
  if (url) {
@@ -770,10 +797,7 @@ async function recordLedger({ fromVersion, toVersion, sourceCommit, note }, deps
770
797
  try { ({ Pool } = deps.pg || require('pg')); } catch { return { ok: false, skipped: true, reason: 'pg not resolvable' }; }
771
798
  const pool = new Pool({ connectionString: url });
772
799
  try {
773
- await pool.query(
774
- `${LEDGER_INSERT_COLS} VALUES ($1,$2,$3,$4)`,
775
- [fromVersion || null, toVersion, sourceCommit || null, note || null]
776
- );
800
+ await pool.query(stmt.sql, stmt.vals);
777
801
  return { ok: true };
778
802
  } catch (e) {
779
803
  return { ok: false, skipped: false, reason: e.message };
@@ -790,15 +814,23 @@ async function recordLedger({ fromVersion, toVersion, sourceCommit, note }, deps
790
814
  // literal `:'from_version'` and syntax-error — psql only interpolates variables in script/stdin
791
815
  // mode. ON_ERROR_STOP=1 guarantees a non-zero exit on any SQL error.
792
816
  const run = deps.run || spawnSync;
793
- const sql = `${LEDGER_INSERT_COLS}\n VALUES (NULLIF(:'from_version',''), :'to_version', NULLIF(:'source_commit',''), NULLIF(:'note',''));\n`;
817
+ // Built from the SAME column list the pg path uses, so the two cannot drift.
818
+ // to_version is the one NOT NULL column, hence the one without a NULLIF; the
819
+ // builder id is text on the wire (a psql :'var' is always a quoted literal) and
820
+ // casts at the DB, which also rejects anything non-numeric rather than coercing.
821
+ const psqlValue = (col) => {
822
+ if (col === 'to_version') return `:'${col}'`;
823
+ if (col === 'requested_by_builder_id') return `NULLIF(:'${col}','')::bigint`;
824
+ return `NULLIF(:'${col}','')`;
825
+ };
826
+ const sql = `${stmt.sql.slice(0, stmt.sql.indexOf(' VALUES '))}\n VALUES (${stmt.cols.map(psqlValue).join(', ')});\n`;
827
+ const sets = [];
828
+ for (let i = 0; i < stmt.cols.length; i++) sets.push('--set', `${stmt.cols[i]}=${stmt.vals[i] == null ? '' : stmt.vals[i]}`);
794
829
  const r = run('psql', [
795
830
  '-d', database,
796
831
  '-v', 'ON_ERROR_STOP=1',
797
832
  '-q', '--no-psqlrc',
798
- '--set', `from_version=${fromVersion || ''}`,
799
- '--set', `to_version=${toVersion || ''}`,
800
- '--set', `source_commit=${sourceCommit || ''}`,
801
- '--set', `note=${note || ''}`,
833
+ ...sets,
802
834
  ], { encoding: 'utf8', input: sql });
803
835
  if (!r || r.error) {
804
836
  const enoent = !!(r && r.error && r.error.code === 'ENOENT');
@@ -1031,7 +1063,7 @@ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts },
1031
1063
  // 5. record the reversal in the ledger via the always-present `note` column (no schema
1032
1064
  // dependency — so it records even when a FAILED migrate means a newer column wouldn't exist).
1033
1065
  const note = `rolled_back: ${reason} (attempted ${fromVersion || '(none)'} → ${toVersion}, restored ${snapshot.prevVersion || 'previous'})`;
1034
- const ledger = await recordLedger({ fromVersion, toVersion, sourceCommit: opts.sourceCommit, note }, deps);
1066
+ const ledger = await recordLedger({ fromVersion, toVersion, sourceCommit: opts.sourceCommit, note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
1035
1067
  if (ledger.ok) log(' ✓ recorded rollback in core_upgrades ledger');
1036
1068
  else if (ledger.skipped) log(` • rollback ledger skipped (${ledger.reason})`);
1037
1069
  else err(` ! rollback ledger insert failed (${ledger.reason}) — record it manually`);
@@ -1310,7 +1342,7 @@ async function runUpgrade(opts, deps = {}) {
1310
1342
  return { ok: false, error: `${why} — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, rolledBack: true, rollback: rb };
1311
1343
  }
1312
1344
 
1313
- const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: opts.note }, deps);
1345
+ const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
1314
1346
  if (ledger.ok) log(' ✓ recorded in core_upgrades ledger');
1315
1347
  else if (ledger.skipped) log(` • ledger skipped (${ledger.reason})`);
1316
1348
  else err(` ! ledger insert failed (${ledger.reason}) — record it manually`);
@@ -1354,6 +1386,13 @@ function parseArgs(argv) {
1354
1386
  versionUrl: arg('--version-url', argv), // task 1002884: read the LIVE process's coreVersion back; defaults to /version on the health URL's origin
1355
1387
  sourceCommit: arg('--source-commit', argv),
1356
1388
  note: arg('--note', argv),
1389
+ // Who asked, and through which door (task 1003159, ADR 0293 D5). Both reach the
1390
+ // core_upgrades row so a privileged act that an HTTP request could start is never
1391
+ // unattributable. Omitted on a hand-run bump, where there is no builder to name —
1392
+ // and omitting them keeps the INSERT on its pre-core_241 four columns, which is
1393
+ // what lets this script keep working against an instance DB that has not migrated.
1394
+ requestedBy: arg('--requested-by', argv),
1395
+ upgradeSource: arg('--upgrade-source', argv),
1357
1396
  pinPath: arg('--pin-path', argv),
1358
1397
  registry: hasFlag('--registry', argv), // ADR 0134: pull @bongos/core@<--to> from the npm registry instead of vendoring
1359
1398
  commitPin: hasFlag('--commit-pin', argv), // task 1002712: commit + push package.json/-lock so a pull-deploy reset cannot revert the bump
@@ -1419,7 +1458,7 @@ module.exports = {
1419
1458
  runUpgrade, parseArgs, main, rollback,
1420
1459
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
1421
1460
  vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, API_ARTIFACT_FILES, preflightDbIdentity,
1422
- resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
1461
+ resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, ledgerInsert, LEDGER_ACTOR_COLS, pollHealth, deriveVersionUrl, pollServedVersion,
1423
1462
  resolvePinPushAuth, remoteGithubRepo, pinnedDeployKey, // task 1003081 — the pin push prefers the owner's GitHub token (ADR 0176 follow-up)
1424
1463
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
1425
1464
  CORE_PKG, ARTIFACT, vendorRelFor,
@@ -451,6 +451,17 @@ const PAGE_GATES = Object.freeze({
451
451
  // members who can cast a vote through the API and cannot load the page they
452
452
  // would cast it on. That is the code-path change ADR 0175 §6 exists to remove.
453
453
  requireBoardVotePage: Object.freeze({ permission: 'board.vote.cast', deniedTo: '/builders' }),
454
+ // The /deploy page (task 1003159, ADR 0293) — gated on `core.pin.move`, the SAME
455
+ // atom both its routes check, so the shell can never drift wider or narrower than
456
+ // the action it fronts. That atom is archon-floor AND system:true, making this the
457
+ // narrowest gate in the table, which is correct for the one page whose button
458
+ // restarts the live service and changes what code the platform runs.
459
+ //
460
+ // The page's READ is gated identically to its write, and that is deliberate: the
461
+ // preview discloses the exact core version the box serves and how far behind it is
462
+ // — a vulnerability window, and exactly the kind of topology ADR 0043 keeps off
463
+ // unauthenticated surfaces.
464
+ requireCorePinPage: Object.freeze({ permission: 'core.pin.move', deniedTo: '/builders' }),
454
465
  });
455
466
 
456
467
  // The `alsoAdmits` door — "is this visitor the page's OTHER audience?", asked of
@@ -620,6 +631,13 @@ async function requireBoardVotePage(req, res, next) {
620
631
  return runPageGate('requireBoardVotePage', req, res, next);
621
632
  }
622
633
 
634
+ // The /deploy page (ADR 0293) — the owner's deploy door. Gated on `core.pin.move`,
635
+ // the same atom the preview and the move both check, so this layer decides only who
636
+ // receives the shell and never becomes the authority (ADR 0016).
637
+ async function requireCorePinPage(req, res, next) {
638
+ return runPageGate('requireCorePinPage', req, res, next);
639
+ }
640
+
623
641
  // ---------------------------------------------------------------------------
624
642
  // Route-gate inventory (V3.R06 #231) — the canonical "who can call what" list.
625
643
  // Derived from ADR 0016's fail-closed rule: privileged routes that mutate
@@ -909,6 +927,7 @@ module.exports = {
909
927
  requireGovernmentPage,
910
928
  requireProjectCuratePage,
911
929
  requireBoardVotePage,
930
+ requireCorePinPage,
912
931
  requireRank,
913
932
  rankMeetsThreshold,
914
933
  requirePermission,
@@ -973,6 +973,23 @@ function mountInternalSurfaces(app) {
973
973
  // exact permission its data route (GET /scouting) checks — same posture as the
974
974
  // /government tab above (the page can never drift wider than the API it fronts).
975
975
  const PEOPLE_PAGE_RE = /^\/builders\/people(?:\.html)?\/?$/;
976
+ // /deploy — the owner's deploy door (task 1003159, ADR 0293). Gated on the
977
+ // `core.pin.move` atom, the same key its preview and its move both check, so the
978
+ // shell's reach equals the right to press the button. Archon-floor and system:true,
979
+ // which makes this the narrowest page gate here — correct for the one surface whose
980
+ // button restarts the live service and changes what code the platform runs.
981
+ //
982
+ // DECLARED HERE, WITH THE OTHER GATES, AND NOT BELOW: the SCOUTING_LEGACY_RE block
983
+ // that follows is a matcher immediately followed by the redirect it feeds, and
984
+ // tests/hall_roster_world.mjs reads them as one span. Wedging a declaration between
985
+ // the two separates a matcher from its handler for a reader as well as for that test.
986
+ //
987
+ // NB as at PROJECT_SETTINGS_PAGE_RE: this assignment must stay
988
+ // `const NAME_RE = /…/;` with nothing between the `=` and the literal —
989
+ // tests/hall_page_gate_map.mjs rebuilds the URL→gate map by parsing this file
990
+ // statically, and a comment in that gap makes the page test as UNGATED while
991
+ // looking gated here. Prose goes above the const, as here.
992
+ const DEPLOY_PAGE_RE = /^\/builders\/deploy(?:\.html)?\/?$/;
976
993
  // /scouting WAS the legacy twin of /people — the same /api/gds/scouting
977
994
  // directory, left behind when task 1002577 graduated it into the hall. Task
978
995
  // 1003470 had already made the two one page (scouting.html rendered people's
@@ -1019,6 +1036,10 @@ function mountInternalSurfaces(app) {
1019
1036
  if (PEOPLE_PAGE_RE.test(norm)) {
1020
1037
  return gdsAuth.requireProjectCuratePage(req, res, next);
1021
1038
  }
1039
+ // ADR 0293: the deploy door, on the same atom its two routes check.
1040
+ if (DEPLOY_PAGE_RE.test(norm)) {
1041
+ return gdsAuth.requireCorePinPage(req, res, next);
1042
+ }
1022
1043
  next();
1023
1044
  });
1024
1045
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.788'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.790'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -96,16 +96,18 @@ test('AUDIT: every async middleware mounted directly on a router owns its reject
96
96
  // requireGovernmentPage \ all FOUR delegate to runPageGate, guarded by
97
97
  // requireProjectCuratePage / BV1.R107 (the /people curate gate; the
98
98
  // requireBoardVotePage / ADR 0266 /board-room vote gate)
99
+ // requireCorePinPage / ADR 0293's /deploy door — the narrowest of them,
100
+ // on the archon-floor `core.pin.move` atom.
99
101
  // Task 1003450 removed two more — requireNonXenosPage and the R113
100
102
  // requireGovernmentManagePage — which had both ended up dispatched from no
101
103
  // route at all; tests/hall_page_gate_map.mjs now fails if that recurs.
102
104
  // Anything else async and router-mounted must be added with its own try/catch.
103
105
  const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
104
106
  const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
105
- assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireGovernmentPage', 'requireProjectCuratePage'],
107
+ assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireCorePinPage', 'requireGovernmentPage', 'requireProjectCuratePage'],
106
108
  'a new async router middleware appeared — give it a try/catch and add it here');
107
109
  // The page gates must actually delegate (that is WHY they are safe).
108
- for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireProjectCuratePage', 'requireBoardVotePage']) {
110
+ for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireProjectCuratePage', 'requireBoardVotePage', 'requireCorePinPage']) {
109
111
  const fn = src.slice(src.indexOf(`async function ${name}`));
110
112
  assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
111
113
  }