@bongos/core 1.19.789 → 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.
- package/.bongos-core.json +110 -40
- 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 +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +129 -1
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +31 -14
- package/docs/copy-registry.json +205 -31
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/core-release-pipeline.md +12 -0
- package/migrations/core_241_core_upgrades_actor.sql +65 -0
- package/migrations/core_242_core_version_snapshot.sql +54 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +8 -0
- package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
- package/modules/hall-ui/public/deploy.html +64 -0
- package/modules/hall-ui/public/deploy.js +188 -0
- package/modules/hall-ui/public/shell.js +4 -0
- package/modules/provisioning/control-plane-guard.js +102 -0
- package/modules/provisioning/core-upgrade.js +289 -0
- package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +8 -8
- package/modules/provisioning/routes/core-upgrade.js +95 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/enroll-control-plane.js +101 -0
- package/scripts/gds/provision-core-upgrade.js +311 -0
- package/scripts/gds/provision.js +4 -2
- package/scripts/gds/upgrade.js +53 -14
- package/src/bongos/auth.js +19 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +4 -2
- package/tests/core_upgrade_door.mjs +408 -0
- package/tests/core_upgrade_runner.mjs +377 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +7 -0
- package/tests/provisioning_disconnect.mjs +30 -5
- package/tests/provisioning_paid_shape_gate.mjs +14 -4
- package/tests/provisioning_restart.mjs +25 -9
- package/tests/provisioning_settings_apply.mjs +11 -2
- 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 };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -746,7 +746,33 @@ function restartService({ service, restartCmd }, run = spawnSync, deps = {}) {
|
|
|
746
746
|
return { ok: null, skipped: true };
|
|
747
747
|
}
|
|
748
748
|
|
|
749
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/bongos/auth.js
CHANGED
|
@@ -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.
|
|
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
|
}
|