@bongos/core 1.24.57 → 1.24.58
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 +43 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +0 -2
- package/clients/bongos-client/index.cjs +0 -2
- package/clients/bongos-client/index.d.ts +1 -3
- package/clients/bongos-client/index.mjs +0 -2
- package/docs/adr/0398-every-hosted-project-takes-its-own-update-and-nobody-asks-for-one.md +45 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -37
- package/docs/api-reference.md +2 -3
- package/docs/copy-inventory.md +38 -44
- package/docs/copy-registry.json +44 -108
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +4 -4
- package/modules/hall-ui/public/deploy-handoff.js +11 -3
- package/modules/hall-ui/public/deploy.js +10 -3
- package/modules/hall-ui/public/settings-software-update.js +4 -71
- package/modules/hall-ui/public/update-pill.js +6 -31
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/bongos/provision-core-upgrade.js +30 -1
- package/scripts/bongos/provision-units.js +18 -0
- package/scripts/hall-preview/server.js +0 -3
- package/src/bongos/routes/core-update.js +36 -76
- package/src/bongos/routes/feedback.js +1 -2
- package/src/module-api.js +1 -1
- package/tests/deploy_handoff.mjs +23 -2
- package/tests/deploy_page_projects.mjs +35 -3
- package/tests/provision_settings_apply.mjs +52 -0
- package/tests/software_update.mjs +35 -5
- package/tests/update_pill.mjs +9 -5
- package/src/bongos/upgrade-request.js +0 -158
- package/tests/upgrade_request.mjs +0 -348
|
@@ -66,49 +66,12 @@
|
|
|
66
66
|
</div>`;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// "Ask for this upgrade" (task 1004595). A project the hub's operator runs cannot take an
|
|
70
|
-
// update itself, so instead of a dead end the page offers one press that reaches the people
|
|
71
|
-
// who run its server, and then says that it was asked. The server decides whether it is
|
|
72
|
-
// offered (`ask.available`) and remembers the request (`ask.requested`).
|
|
73
69
|
function longDate(iso) {
|
|
74
70
|
const d = new Date(iso);
|
|
75
71
|
if (!Number.isFinite(d.getTime())) return null;
|
|
76
72
|
try { return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch (e) { return d.toISOString().slice(0, 10); }
|
|
77
73
|
}
|
|
78
74
|
|
|
79
|
-
// Asked, and still waiting: for this very version, or recently enough that asking again
|
|
80
|
-
// would only be refused.
|
|
81
|
-
function askPending(ask, offered) {
|
|
82
|
-
const r = ask && ask.requested;
|
|
83
|
-
if (!r) return false;
|
|
84
|
-
if (r.version === offered) return true;
|
|
85
|
-
const again = Date.parse(ask.ask_again_after);
|
|
86
|
-
return Number.isFinite(again) && Date.now() < again;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function askHtml(ask, offered, { lead }) {
|
|
90
|
-
const team = ask.hub_host ? `the team at ${escapeHtml(ask.hub_host)}` : 'the team that runs this project\'s server';
|
|
91
|
-
if (askPending(ask, offered)) {
|
|
92
|
-
const r = ask.requested;
|
|
93
|
-
const when = longDate(r.requested_at);
|
|
94
|
-
return `<div class="su-door su-ask">
|
|
95
|
-
<p class="panel-status panel-status--ok" role="status">Upgrade requested${when ? ` on ${escapeHtml(when)}` : ''}${r.by ? ` by @${escapeHtml(r.by)}` : ''}, for version ${mono(r.version)}. ${team.charAt(0).toUpperCase() + team.slice(1)} has your request. This page shows the new version once it is installed.</p>
|
|
96
|
-
</div>`;
|
|
97
|
-
}
|
|
98
|
-
const last = ask.requested && longDate(ask.requested.requested_at);
|
|
99
|
-
const before = last ? ` You last asked on ${escapeHtml(last)}, for ${mono(ask.requested.version)}.` : '';
|
|
100
|
-
const intro = lead
|
|
101
|
-
? `<p class="panel-hint">This project runs on a server that ${team} looks after, so they install its updates. Ask them for this one and they are told which version you want.${before}</p>`
|
|
102
|
-
: `<p class="panel-hint">If that page does not let you in, ask ${team} to install it for you.${before}</p>`;
|
|
103
|
-
return `<div class="su-door su-ask">
|
|
104
|
-
${intro}
|
|
105
|
-
<div class="row-end">
|
|
106
|
-
<button type="button" class="pbtn ${lead ? 'pbtn--save' : 'pbtn--quiet'}" id="su-ask">Ask for this upgrade</button>
|
|
107
|
-
<span class="panel-hint" id="su-ask-status" role="status"></span>
|
|
108
|
-
</div>
|
|
109
|
-
</div>`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
75
|
// Where the Update button goes, and what it says it will do. Only a plain same-origin path
|
|
113
76
|
// or an http(s) URL is ever put in an href; anything else draws no button.
|
|
114
77
|
function doorHtml(data, offered) {
|
|
@@ -116,9 +79,9 @@
|
|
|
116
79
|
if (!data.can_update) {
|
|
117
80
|
return '<p class="panel-hint su-door">Only the people allowed to upgrade this project see an Update button here.</p>';
|
|
118
81
|
}
|
|
119
|
-
|
|
82
|
+
// No request-the-upgrade button any more (task 1005128): a project the hub hosts always has
|
|
83
|
+
// the hub's door, so `cli` is only ever a server its owner runs themselves.
|
|
120
84
|
if (door.kind === 'cli') {
|
|
121
|
-
if (ask.available) return askHtml(ask, offered, { lead: true });
|
|
122
85
|
return '<p class="panel-hint su-door">This project is upgraded on the server that runs it: <code>bongos upgrade</code> there takes this version, with the same health check and rollback.</p>';
|
|
123
86
|
}
|
|
124
87
|
const url = String(door.url || '');
|
|
@@ -134,7 +97,7 @@
|
|
|
134
97
|
return `<div class="row-end su-door">
|
|
135
98
|
<a class="pbtn pbtn--save" id="su-update" href="${escapeHtml(url)}">Update to ${escapeHtml(offered)}</a>
|
|
136
99
|
<span class="panel-hint">Takes a few minutes. The project restarts once, checks itself, and goes back to the version it runs now if it does not come back healthy.${where ? ` A small window from ${escapeHtml(where)} opens for a moment to start it.` : ''}</span>
|
|
137
|
-
</div
|
|
100
|
+
</div>`;
|
|
138
101
|
}
|
|
139
102
|
|
|
140
103
|
// One entry: its text, and — only in the hall whose ledger the ids belong to, which is
|
|
@@ -223,8 +186,7 @@
|
|
|
223
186
|
html += row('Newest for this project', `${mono(u.running)} — this project is up to date.`, '<span class="su-pill su-pill--ok">Up to date</span>');
|
|
224
187
|
} else {
|
|
225
188
|
const when = ago(u.offered_published_at);
|
|
226
|
-
|
|
227
|
-
html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, `<span class="su-pill">${onHold ? 'On hold' : pending ? 'Requested' : 'Update ready'}</span>`);
|
|
189
|
+
html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, `<span class="su-pill">${onHold ? 'On hold' : 'Update ready'}</span>`);
|
|
228
190
|
// No button to take again the very version that was just rolled back here (task 1004730).
|
|
229
191
|
if (!onHold) html += doorHtml(data, u.offered);
|
|
230
192
|
html += whatsNewHtml(u.whats_new, (data.door || {}).kind === 'local');
|
|
@@ -237,9 +199,6 @@
|
|
|
237
199
|
body.innerHTML = html + historyHtml(data.history);
|
|
238
200
|
if (note) note.textContent = notes.join(' ');
|
|
239
201
|
|
|
240
|
-
const askBtn = byId('su-ask');
|
|
241
|
-
if (askBtn) askBtn.addEventListener('click', () => askForUpgrade(data, askBtn));
|
|
242
|
-
|
|
243
202
|
// The same press as the pill's Update now; only when it took the update in place is the
|
|
244
203
|
// link's own navigation stopped.
|
|
245
204
|
const updateBtn = byId('su-update');
|
|
@@ -259,32 +218,6 @@
|
|
|
259
218
|
}
|
|
260
219
|
}
|
|
261
220
|
|
|
262
|
-
// One press: the server sends the request and remembers it; the panel redraws from its
|
|
263
|
-
// answer. "Asked recently" carries the stored request, so it redraws as pending too.
|
|
264
|
-
async function askForUpgrade(data, btn) {
|
|
265
|
-
const status = byId('su-ask-status');
|
|
266
|
-
btn.disabled = true;
|
|
267
|
-
btn.textContent = 'Asking…';
|
|
268
|
-
if (status) status.textContent = '';
|
|
269
|
-
try {
|
|
270
|
-
const res = await api.request('POST', `${API}/software-update/request`, { body: {} });
|
|
271
|
-
const err = (res.data && res.data.error) || {};
|
|
272
|
-
const request = (res.data && res.data.request) || (err.details && err.details.request);
|
|
273
|
-
if ((res.ok || res.status === 429) && request) {
|
|
274
|
-
const again = new Date(Date.parse(request.requested_at) + 24 * 3600 * 1000).toISOString();
|
|
275
|
-
render({ ...data, ask: { ...data.ask, requested: request, ask_again_after: again } });
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
// Every refusal the server sends carries a sentence; a bare code is only ever a
|
|
279
|
-
// proxy or a crash, so the status stands in for it.
|
|
280
|
-
throw new Error(err.message && err.message !== err.code ? err.message : `HTTP ${res.status}`);
|
|
281
|
-
} catch (e) {
|
|
282
|
-
btn.disabled = false;
|
|
283
|
-
btn.textContent = 'Ask for this upgrade';
|
|
284
|
-
if (status) status.textContent = `Could not send the request: ${String(e.message || 'unknown error').replace(/\.$/, '')}.`;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
221
|
async function load() {
|
|
289
222
|
if (asked) return;
|
|
290
223
|
asked = true;
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
// with &handoff=1, this page tells it the version over postMessage, and the hub
|
|
24
24
|
// posts the move with the owner's OWN hub session (deploy-handoff.js). This
|
|
25
25
|
// project's server gains no capability; the only thing that crosses is a version.
|
|
26
|
-
// ask a hosted project the hub's operator runs: "Ask for this update" (task 1004595).
|
|
27
26
|
// cli a self-hosted instance: no button; the card names the command.
|
|
28
27
|
//
|
|
29
28
|
// PROGRESS IS READ, NEVER GUESSED (deploy.js's rule). The page watches its own public
|
|
@@ -41,9 +40,10 @@
|
|
|
41
40
|
var RUN_KEY = 'bongos.update.run';
|
|
42
41
|
var MSG = 'bongos-update'; // the tag deploy-handoff.js answers to; both sides check it
|
|
43
42
|
var VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
44
|
-
// A hub door as the server words it (core-update.js
|
|
45
|
-
//
|
|
46
|
-
|
|
43
|
+
// A hub door as the server words it (core-update.js updateDoor): an origin and
|
|
44
|
+
// /deploy?project=<slug>, or /deploy?host=<address> for a hosted project whose server never
|
|
45
|
+
// learned its slug (task 1005128). Anything else draws no button.
|
|
46
|
+
var HUB_DOOR_RE = /^(https?:\/\/[^\s"'<>/]+)\/deploy\?(?:project|host)=[A-Za-z0-9._%-]+$/;
|
|
47
47
|
var SETTINGS_HREF = '/settings#software-update';
|
|
48
48
|
var HIGHLIGHTS = 3;
|
|
49
49
|
var LINE_MAX = 120;
|
|
@@ -101,14 +101,13 @@
|
|
|
101
101
|
function doorPlan(sw) {
|
|
102
102
|
if (!sw || sw.can_update !== true) return { kind: 'none' };
|
|
103
103
|
var door = sw.door || {};
|
|
104
|
-
var ask = sw.ask || {};
|
|
105
104
|
if (door.kind === 'local' && door.url === '/deploy') return { kind: 'local' };
|
|
106
105
|
if (door.kind === 'hub') {
|
|
107
106
|
var m = HUB_DOOR_RE.exec(String(door.url || ''));
|
|
108
|
-
if (!m) return
|
|
107
|
+
if (!m) return { kind: 'none' };
|
|
109
108
|
return { kind: 'hub', url: door.url, origin: m[1] };
|
|
110
109
|
}
|
|
111
|
-
if (door.kind === 'cli') return
|
|
110
|
+
if (door.kind === 'cli') return { kind: 'cli' };
|
|
112
111
|
return { kind: 'none' };
|
|
113
112
|
}
|
|
114
113
|
|
|
@@ -263,13 +262,6 @@
|
|
|
263
262
|
note = '<p class="upd-card__note">Takes a few minutes. The project restarts once, and goes back to the version it runs now on its own if the new one is not healthy.' +
|
|
264
263
|
(host ? ' A small window from ' + esc(host) + ' opens for a moment to start it.' : '') + '</p>';
|
|
265
264
|
action = '<button type="button" class="upd-btn upd-btn--go" id="upd-go">Update now</button>';
|
|
266
|
-
} else if (plan.kind === 'ask') {
|
|
267
|
-
var ask = state.sw.ask || {};
|
|
268
|
-
var team = ask.hub_host ? 'the team at ' + ask.hub_host : 'the team that runs this project\'s server';
|
|
269
|
-
var asked = ask.requested && ask.requested.version === state.offered;
|
|
270
|
-
note = '<p class="upd-card__note">' + esc(asked ? 'You asked ' + team + ' for this update. This shows the new version once it is installed.'
|
|
271
|
-
: 'This project runs on a server that ' + team + ' looks after, so they install its updates.') + '</p>';
|
|
272
|
-
if (!asked) action = '<button type="button" class="upd-btn upd-btn--go" id="upd-ask">Ask for this update</button>';
|
|
273
265
|
} else if (plan.kind === 'cli') {
|
|
274
266
|
note = '<p class="upd-card__note">This project is updated on the server that runs it: <code>bongos upgrade</code> there takes this version, and puts the old one back if it fails.</p>';
|
|
275
267
|
} else {
|
|
@@ -294,7 +286,6 @@
|
|
|
294
286
|
var on = function (id, fn) { var el = c.querySelector('#' + id); if (el) el.addEventListener('click', fn); };
|
|
295
287
|
on('upd-close', closeCard);
|
|
296
288
|
on('upd-go', function () { updateNow(); });
|
|
297
|
-
on('upd-ask', function (e) { askForIt(e.currentTarget); });
|
|
298
289
|
on('upd-reload', reload);
|
|
299
290
|
on('upd-open', function () { updateNow(); });
|
|
300
291
|
}
|
|
@@ -565,22 +556,6 @@
|
|
|
565
556
|
if (root.location && root.location.reload) root.location.reload();
|
|
566
557
|
}
|
|
567
558
|
|
|
568
|
-
// "Ask for this update" (task 1004595): one press reaches the people who run the server.
|
|
569
|
-
function askForIt(btn) {
|
|
570
|
-
btn.disabled = true;
|
|
571
|
-
btn.textContent = 'Asking…';
|
|
572
|
-
state.api.request('POST', '/api/bongos/software-update/request', { body: {} }).then(function (res) {
|
|
573
|
-
var data = (res && res.data) || {};
|
|
574
|
-
var request = data.request || (data.error && data.error.details && data.error.details.request);
|
|
575
|
-
if (!((res.ok || res.status === 429) && request)) throw new Error(errorOf(res, 'Could not send the request.'));
|
|
576
|
-
state.sw.ask = Object.assign({}, state.sw.ask, { requested: request });
|
|
577
|
-
drawCard();
|
|
578
|
-
}).then(null, function (e) {
|
|
579
|
-
state.error = String((e && e.message) || 'Could not send the request.');
|
|
580
|
-
drawCard();
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
|
|
584
559
|
/** shell.js hands over GET /core-update's answer; the pill decides what, if anything, to show. */
|
|
585
560
|
function mount(data, opts) {
|
|
586
561
|
state.api = opts && opts.api;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.58",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.58",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.58",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -11313,5 +11313,11 @@
|
|
|
11313
11313
|
"id": "1005093",
|
|
11314
11314
|
"text": "Stylesheets can no longer gain new typed-in colours, text sizes, corners, shadows or breakpoints without the build failing, and the public design reference must be regenerated whenever its sources change."
|
|
11315
11315
|
}
|
|
11316
|
+
],
|
|
11317
|
+
"1.24.58": [
|
|
11318
|
+
{
|
|
11319
|
+
"id": "1005128",
|
|
11320
|
+
"text": "A project's owner now takes a new Bongos version with one Update button; the \"ask the team for this upgrade\" step is gone for every hosted project."
|
|
11321
|
+
}
|
|
11316
11322
|
]
|
|
11317
11323
|
}
|
|
@@ -338,6 +338,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
338
338
|
log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
|
|
339
339
|
return { ok: true, served: null, preflight: verdict };
|
|
340
340
|
}
|
|
341
|
+
backfillHubUpgradeUrl(inst, deps, boxExec);
|
|
341
342
|
try {
|
|
342
343
|
log(` [upgrade] (in ${runDir}) ${cmd}`);
|
|
343
344
|
boxExec(cmd, { cwd: runDir });
|
|
@@ -672,6 +673,34 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
|
|
|
672
673
|
// HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
|
|
673
674
|
// a call site whose safety depends on a helper's internals is one refactor away from a
|
|
674
675
|
// shell-injection path (and the apply leg's own guard sits at the route, not here).
|
|
676
|
+
// WHERE THIS PROJECT'S UPDATE BUTTON GOES, written on every move (task 1005128): a project
|
|
677
|
+
// provisioned before task 1004608 has no <PREFIX>_HUB_UPGRADE_URL line, and only a
|
|
678
|
+
// settings-apply ever wrote one. The move restarts the service, so the line is read the moment
|
|
679
|
+
// the new core comes up. BEST-EFFORT: it is written before the move and never decides it; an
|
|
680
|
+
// unreadable file, a refused write or a line already right changes nothing and fails nothing.
|
|
681
|
+
function backfillHubUpgradeUrl(inst, deps, boxExec) {
|
|
682
|
+
// The slug is interpolated into a shell command below, so it is checked HERE, at the point
|
|
683
|
+
// of use, not trusted to a caller that validated it earlier (the rule coreUpgradeInstance
|
|
684
|
+
// states for its own command).
|
|
685
|
+
if (!inst || !isValidSlug(inst.slug)) return false;
|
|
686
|
+
try {
|
|
687
|
+
const { hubUpgradeEnvPatch, webEnvPath } = require('./provision-units.js'); // eslint-disable-line global-require
|
|
688
|
+
const sudoP = deps.privileged ? 'sudo ' : '';
|
|
689
|
+
const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
|
|
690
|
+
if (!read || read.softFailed) return false;
|
|
691
|
+
const next = hubUpgradeEnvPatch(read.stdout, inst);
|
|
692
|
+
if (!next) return false;
|
|
693
|
+
const write = inst.hosting_shape === 'cloud-host' ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
|
|
694
|
+
if (typeof write !== 'function') return false;
|
|
695
|
+
write(webEnvPath(inst), next, { mode: 0o600, sudo: true });
|
|
696
|
+
deps.log(' [config] wrote where this project\'s Update button goes into web.env (read on restart)');
|
|
697
|
+
return true;
|
|
698
|
+
} catch (e) {
|
|
699
|
+
deps.log(` [config] could not write the Update button's address (${(e && e.message) || e}); the move goes on`);
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
675
704
|
function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', automatic = false, selfRoot = selfInstanceRoot, runAs = null, migrateAs = null } = {}) {
|
|
676
705
|
if (!isValidSlug(inst && inst.slug)) {
|
|
677
706
|
throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
|
|
@@ -1164,4 +1193,4 @@ async function snapshotAllCoreVersions(deps) {
|
|
|
1164
1193
|
return { seen };
|
|
1165
1194
|
}
|
|
1166
1195
|
|
|
1167
|
-
module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
1196
|
+
module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, backfillHubUpgradeUrl, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
@@ -447,9 +447,27 @@ function hubUpgradeEnvVarsFor(inst) {
|
|
|
447
447
|
return { [`${provisionedEnvPrefix()}_HUB_UPGRADE_URL`]: origin ? `${origin}/deploy?project=${encodeURIComponent(String(inst.slug))}` : '' };
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
// THE BACKFILL (task 1005128). Only a settings-apply wrote that line, and one runs only when a
|
|
451
|
+
// setting changes, so a project provisioned before task 1004608 (toast) never got it and its
|
|
452
|
+
// owner was offered "Ask for this upgrade" instead of the Update button. Every core move now
|
|
453
|
+
// carries the line too: the web.env as it stands, with that ONE line set, or null when it is
|
|
454
|
+
// already right, there is nothing to write, or the file is empty (a missing file is never
|
|
455
|
+
// "patched" into a one-line body). PURE; the core-upgrade leg does the read and the write.
|
|
456
|
+
function hubUpgradeEnvPatch(body, inst) {
|
|
457
|
+
const vars = hubUpgradeEnvVarsFor(inst);
|
|
458
|
+
const [key] = Object.keys(vars);
|
|
459
|
+
const text = String(body == null ? '' : body);
|
|
460
|
+
if (!key || !vars[key] || !text.trim()) return null;
|
|
461
|
+
const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
462
|
+
const have = new RegExp(`^${esc}=(.*)$`, 'm').exec(text);
|
|
463
|
+
if (have && have[1].trim() === vars[key]) return null;
|
|
464
|
+
return upsertEnvVars(text, vars);
|
|
465
|
+
}
|
|
466
|
+
|
|
450
467
|
module.exports = {
|
|
451
468
|
federationHubOrigin,
|
|
452
469
|
hubUpgradeEnvVarsFor,
|
|
470
|
+
hubUpgradeEnvPatch,
|
|
453
471
|
ENV_PREFIX_PACK,
|
|
454
472
|
envPrefixMismatch,
|
|
455
473
|
normalizePackEnvPrefix,
|
|
@@ -208,9 +208,6 @@ const CANNED_WRITES = [
|
|
|
208
208
|
// a vote cast from the street's matter card (task 1004528), so the card's
|
|
209
209
|
// answer (the toast, the building lighting) renders here too.
|
|
210
210
|
['POST', /^government\/board\/items\/\d+\/votes$/, 'government__board__items__votes.post'],
|
|
211
|
-
// Settings → Software update's "Ask for this upgrade" (task 1004595), so the
|
|
212
|
-
// pending line the panel draws after the press renders here too.
|
|
213
|
-
['POST', /^software-update\/request$/, 'software-update__request.post'],
|
|
214
211
|
// the studio's AI pages (task 1004921): a file uploads, a request is sent
|
|
215
212
|
// (it answers as 1002, the running one), and money is added.
|
|
216
213
|
['POST', /^ai-studio\/uploads$/, 'ai-studio__uploads.post'],
|
|
@@ -34,7 +34,6 @@ const { readSoftwareUpdate } = require('../software-update');
|
|
|
34
34
|
const { followForShape } = require('../../../scripts/bongos/update-channel.js');
|
|
35
35
|
const seams = require('../../module-seams');
|
|
36
36
|
const { loadIdpConfig } = require('../auth-config');
|
|
37
|
-
const upgradeRequest = require('../upgrade-request');
|
|
38
37
|
const { readUpgradeHold } = require('../upgrade-hold');
|
|
39
38
|
const { moveNotice, moveHistory, readInstalledReleaseNotes } = require('../core-moves');
|
|
40
39
|
|
|
@@ -82,10 +81,35 @@ function hubManageLinks(config) {
|
|
|
82
81
|
// address as <PREFIX>_HUB_UPGRADE_URL (task 1004608). Without it, those owners were told to
|
|
83
82
|
// run `bongos upgrade` on a server the platform runs. Only the hub's /deploy path is taken.
|
|
84
83
|
const HUB_UPGRADE_URL_RE = /^https?:\/\/[^\s"'<>/]+\/deploy\?project=[A-Za-z0-9._%-]+$/;
|
|
85
|
-
|
|
84
|
+
// THE LAST FALLBACK (task 1005128). A hosted project whose web.env predates the line above
|
|
85
|
+
// (toast, 2026-11) named neither, fell to `cli`, and its owner was offered "Ask for this
|
|
86
|
+
// upgrade" instead of the Update button — the owner: "if the update is generally available
|
|
87
|
+
// then they should be able to download it". So a project that answers on a name under its
|
|
88
|
+
// hub's own domain (the hub's rule for "a project we host") is sent to that hub's door by its
|
|
89
|
+
// address instead of its slug: `<hub>/deploy?host=<host>`. The hub resolves the address among
|
|
90
|
+
// the visitor's OWN projects, exactly as it resolves a slug, and the hand-off still only
|
|
91
|
+
// accepts the project's registered origin. A self-hosted instance on its own domain stays
|
|
92
|
+
// `cli`: nobody at the hub can reach its server.
|
|
93
|
+
function projectHost(raw) {
|
|
94
|
+
const host = String(raw || '').trim().toLowerCase();
|
|
95
|
+
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) return null;
|
|
96
|
+
if (/^\d+(\.\d+)+$/.test(host)) return null;
|
|
97
|
+
if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
|
|
98
|
+
return host;
|
|
99
|
+
}
|
|
100
|
+
function hubHostedDoor({ hub, host }) {
|
|
101
|
+
const h = projectHost(host);
|
|
102
|
+
let origin = null;
|
|
103
|
+
let hubHost = null;
|
|
104
|
+
try { const u = new URL(String(hub)); if (/^https?:$/.test(u.protocol)) { origin = u.origin; hubHost = u.host.toLowerCase(); } } catch { /* no hub */ }
|
|
105
|
+
if (!h || !origin || h === hubHost || !h.endsWith(`.${hubHost}`)) return null;
|
|
106
|
+
return `${origin}/deploy?host=${encodeURIComponent(h)}`;
|
|
107
|
+
}
|
|
108
|
+
function updateDoor({ provisioning, idp, hubUpgradeUrl, hub = null, host = null }) {
|
|
86
109
|
if (provisioning) return { kind: 'local', url: '/deploy' };
|
|
87
110
|
const url = hubDeployUrl(idp)
|
|
88
|
-
|| (typeof hubUpgradeUrl === 'string' && HUB_UPGRADE_URL_RE.test(hubUpgradeUrl.trim()) ? hubUpgradeUrl.trim() : null)
|
|
111
|
+
|| (typeof hubUpgradeUrl === 'string' && HUB_UPGRADE_URL_RE.test(hubUpgradeUrl.trim()) ? hubUpgradeUrl.trim() : null)
|
|
112
|
+
|| hubHostedDoor({ hub, host });
|
|
89
113
|
return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
|
|
90
114
|
}
|
|
91
115
|
|
|
@@ -187,25 +211,15 @@ async function resolveChannel(selfChannel) {
|
|
|
187
211
|
return { channel, source: channel ? 'deploy' : 'default' };
|
|
188
212
|
}
|
|
189
213
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const ps = require('../project-settings');
|
|
195
|
-
const fb = require('./feedback');
|
|
196
|
-
return {
|
|
197
|
-
hub: fb.feedbackHub(),
|
|
198
|
-
host: configuredHost(),
|
|
199
|
-
getSetting: ps.getSetting,
|
|
200
|
-
setSetting: ps.setSetting,
|
|
201
|
-
issueReceipt: (r) => fb.defaultReceiptStore().issue(r),
|
|
202
|
-
};
|
|
214
|
+
// The hub this project belongs to: the same answer the direct feedback door uses (null on the
|
|
215
|
+
// hub itself, so the platform never points at itself).
|
|
216
|
+
function safeHub() {
|
|
217
|
+
try { return require('./feedback').feedbackHub(); } catch { return null; }
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
// The host this project answers on, from its OWN branding pack (domains is server-only):
|
|
206
|
-
//
|
|
207
|
-
// Host or X-Forwarded-Host header is the caller's to write
|
|
208
|
-
// hub's call-back at any address they like. '' when the pack names none.
|
|
221
|
+
// what decides whether the hub hosts it (hubHostedDoor). Never read from the request: a
|
|
222
|
+
// Host or X-Forwarded-Host header is the caller's to write. '' when the pack names none.
|
|
209
223
|
function configuredHost() {
|
|
210
224
|
try {
|
|
211
225
|
const d = require('../../branding').branding().domains || {};
|
|
@@ -213,18 +227,6 @@ function configuredHost() {
|
|
|
213
227
|
} catch { return ''; }
|
|
214
228
|
}
|
|
215
229
|
|
|
216
|
-
// The page's ask state. Fail-soft: a stored row that cannot be read is "not asked yet",
|
|
217
|
-
// never a 500 on the whole Software update panel.
|
|
218
|
-
async function readAsk({ update, door, deps }) {
|
|
219
|
-
const offered = update && update.reading === 'ok' ? update.offered : null;
|
|
220
|
-
const base = { offered, host: upgradeRequest.projectHost(deps.host), doorKind: door.kind, hub: deps.hub };
|
|
221
|
-
let stored = null;
|
|
222
|
-
if (offered && upgradeRequest.isOperatorRun(base)) {
|
|
223
|
-
try { stored = upgradeRequest.parseStored(await deps.getSetting(upgradeRequest.SETTING_KEY)); } catch { stored = null; }
|
|
224
|
-
}
|
|
225
|
-
return upgradeRequest.askState({ ...base, stored });
|
|
226
|
-
}
|
|
227
|
-
|
|
228
230
|
// A core move this project's own ledger says was rolled back (task 1004730,
|
|
229
231
|
// src/bongos/upgrade-hold.js): which version was tried, why in plain words, and that this
|
|
230
232
|
// project is still on its old version and working. Both reads carry it, the Software update
|
|
@@ -270,16 +272,13 @@ function buildCoreUpdateRouter({
|
|
|
270
272
|
upgrades = readCoreUpgrades,
|
|
271
273
|
softwareUpdate = readSoftwareUpdate,
|
|
272
274
|
canUpdate = holdsCorePin,
|
|
273
|
-
doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig(), hubUpgradeUrl: safeHubUpgradeUrl() }),
|
|
274
|
-
askDeps = defaultAskDeps,
|
|
275
|
+
doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig(), hubUpgradeUrl: safeHubUpgradeUrl(), hub: safeHub(), host: configuredHost() }),
|
|
275
276
|
upgradeHold = readUpgradeHold,
|
|
276
277
|
releaseNotes = readInstalledReleaseNotes,
|
|
277
278
|
seenMove = defaultSeenMove,
|
|
278
279
|
} = {}) {
|
|
279
280
|
contributeCoreUpdateToDocket();
|
|
280
281
|
const router = express.Router();
|
|
281
|
-
// One send at a time: a double press waits for the first and is answered from its row.
|
|
282
|
-
let asking = null;
|
|
283
282
|
|
|
284
283
|
router.get(
|
|
285
284
|
'/core-update',
|
|
@@ -341,53 +340,13 @@ function buildCoreUpdateRouter({
|
|
|
341
340
|
resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel, follow: followFor(door) })), channel_source: source })),
|
|
342
341
|
canUpdate(req),
|
|
343
342
|
]);
|
|
344
|
-
const ask = await readAsk({ update, door, deps: askDeps() });
|
|
345
343
|
const held = await safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.offered : null });
|
|
346
344
|
// The history only for a viewer who holds core.pin.move (see safeMoved's gate note).
|
|
347
345
|
const history = can === true ? await safeHistory(upgrades) : null;
|
|
348
|
-
res.json({ ok: true, update, door, can_update: can === true,
|
|
346
|
+
res.json({ ok: true, update, door, can_update: can === true, held, history });
|
|
349
347
|
}, { errorCode: 'software_update_failed', message: false }),
|
|
350
348
|
);
|
|
351
349
|
|
|
352
|
-
// POST /software-update/request — "Ask for this upgrade" (task 1004595). On a project
|
|
353
|
-
// the hub's operator runs, sends one request for the version this project is offered
|
|
354
|
-
// to the operator, through the direct feedback door, and remembers it so the page can
|
|
355
|
-
// say it is pending. The version is the server's own reading, never the caller's.
|
|
356
|
-
// Idempotent per version; a different version waits out a 24h cooldown (429).
|
|
357
|
-
// rank: core.pin.move — the same atom as the Update button the panel draws.
|
|
358
|
-
router.post(
|
|
359
|
-
'/software-update/request',
|
|
360
|
-
auth.requireBuilder,
|
|
361
|
-
auth.requirePermission('core.pin.move'),
|
|
362
|
-
asyncHandler('POST /software-update/request', async (req, res) => {
|
|
363
|
-
while (asking) { try { await asking; } catch { /* the next one tries for itself */ } }
|
|
364
|
-
const run = (async () => {
|
|
365
|
-
let running = null;
|
|
366
|
-
try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
|
|
367
|
-
const { channel } = await resolveChannel(selfChannel);
|
|
368
|
-
const door = updateDoor(doorInputs());
|
|
369
|
-
const update = await softwareUpdate({ running, channel, follow: followFor(door) });
|
|
370
|
-
const deps = askDeps();
|
|
371
|
-
return upgradeRequest.requestUpgrade({
|
|
372
|
-
host: deps.host,
|
|
373
|
-
running,
|
|
374
|
-
wanted: update && update.reading === 'ok' ? update.offered : null,
|
|
375
|
-
builder: req.builder,
|
|
376
|
-
doorKind: door.kind,
|
|
377
|
-
}, deps);
|
|
378
|
-
})();
|
|
379
|
-
asking = run;
|
|
380
|
-
let out;
|
|
381
|
-
try { out = await run; } finally { if (asking === run) asking = null; }
|
|
382
|
-
if (out.status >= 400) {
|
|
383
|
-
const { error, message, ...details } = out.body;
|
|
384
|
-
if (details.retry_after_seconds) res.set('Retry-After', String(details.retry_after_seconds));
|
|
385
|
-
return res.fail(error, { status: out.status, message, details });
|
|
386
|
-
}
|
|
387
|
-
res.status(out.status).json(out.body);
|
|
388
|
-
}, { errorCode: 'upgrade_request_failed', message: false }),
|
|
389
|
-
);
|
|
390
|
-
|
|
391
350
|
// GET /project/manage-link — the hub's page for THIS project, where its owner takes it
|
|
392
351
|
// offline or disconnects it (task 1004570). On a hosted project the owner is its Archon,
|
|
393
352
|
// so the read is Archon-only: a builder below that is refused and the hall draws no
|
|
@@ -410,4 +369,5 @@ module.exports = buildCoreUpdateRouter;
|
|
|
410
369
|
module.exports.hubManageLinks = hubManageLinks;
|
|
411
370
|
module.exports.hubDeployUrl = hubDeployUrl;
|
|
412
371
|
module.exports.updateDoor = updateDoor;
|
|
372
|
+
module.exports.hubHostedDoor = hubHostedDoor;
|
|
413
373
|
module.exports.coreUpdateDocketMatters = coreUpdateDocketMatters;
|
|
@@ -78,8 +78,7 @@ function makeReceiptStore({ now = Date.now, ttlMs = RECEIPT_TTL_MS, max = RECEIP
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// The process's one receipt store. Shared, because a receipt is redeemed at the public
|
|
81
|
-
// route below whoever issued it
|
|
82
|
-
// "Ask for this upgrade" (src/bongos/upgrade-request.js, task 1004595).
|
|
81
|
+
// route below whoever issued it (the CLI's POST /feedback/receipt).
|
|
83
82
|
let sharedStore = null;
|
|
84
83
|
function defaultReceiptStore() {
|
|
85
84
|
if (!sharedStore) sharedStore = makeReceiptStore();
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.58'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/deploy_handoff.mjs
CHANGED
|
@@ -85,7 +85,7 @@ afterEach(() => { for (const id of armed) clearTimeout(id); armed.clear(); });
|
|
|
85
85
|
const settle = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
|
86
86
|
const plain = (x) => JSON.parse(JSON.stringify(x));
|
|
87
87
|
|
|
88
|
-
function boot({ opener = { postMessage(msg, origin) { told.push({ msg, origin }); } }, preview = PREVIEW, post = { ok: true, status: 200, data: { ok: true } }, rows = [{ id: '14', slug: 'toast', domain: 'toast.cloudbongos.com' }] } = {}) {
|
|
88
|
+
function boot({ opener = { postMessage(msg, origin) { told.push({ msg, origin }); } }, preview = PREVIEW, post = { ok: true, status: 200, data: { ok: true } }, rows = [{ id: '14', slug: 'toast', domain: 'toast.cloudbongos.com' }], search = '?project=toast&handoff=1' } = {}) {
|
|
89
89
|
const told = [];
|
|
90
90
|
const requests = [];
|
|
91
91
|
const listeners = {};
|
|
@@ -105,7 +105,7 @@ function boot({ opener = { postMessage(msg, origin) { told.push({ msg, origin })
|
|
|
105
105
|
if (opener) opener.postMessage = (msg, origin) => told.push({ msg, origin });
|
|
106
106
|
const windowObj = {
|
|
107
107
|
OTB: realOTB(), document: documentObj, opener, __handoffHearMs: 30,
|
|
108
|
-
location: { protocol: 'https:', pathname: '/deploy', search
|
|
108
|
+
location: { protocol: 'https:', pathname: '/deploy', search, assign() {} },
|
|
109
109
|
addEventListener: (ev, fn) => { (listeners[ev] ||= []).push(fn); },
|
|
110
110
|
setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); armed.add(id); return id; },
|
|
111
111
|
close() { closed = true; },
|
|
@@ -150,6 +150,27 @@ test('the go from the project\'s own hall moves it to that version, says so, and
|
|
|
150
150
|
assert.match(b.body.innerHTML, /The update has started/);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
// task 1005128: a hosted project whose server never learned its slug sends ?host=<address>.
|
|
154
|
+
test('?host= finds the project by its registered address, among the visitor\'s own, and moves it the same way', async () => {
|
|
155
|
+
const rows = [{ id: '9', slug: 'other', domain: 'other.cloudbongos.com' }, { id: '14', slug: 'toast', domain: 'Toast.CloudBongos.com' }];
|
|
156
|
+
const b = boot({ rows, search: '?host=toast.cloudbongos.com&handoff=1' });
|
|
157
|
+
await settle(5);
|
|
158
|
+
assert.deepEqual(plain(b.told), [{ msg: { tag: 'bongos-update', step: 'ready' }, origin: 'https://toast.cloudbongos.com' }]);
|
|
159
|
+
b.message(GO);
|
|
160
|
+
await settle(5);
|
|
161
|
+
assert.equal(b.posts().length, 1);
|
|
162
|
+
assert.equal(b.posts()[0].path, '/api/bongos/provisioning/instances/14/core-upgrade');
|
|
163
|
+
|
|
164
|
+
const none = boot({ rows, search: '?host=nobody.cloudbongos.com&handoff=1' });
|
|
165
|
+
await settle(5);
|
|
166
|
+
assert.equal(none.posts().length, 0);
|
|
167
|
+
assert.match(none.body.innerHTML, /No project called nobody\.cloudbongos\.com/);
|
|
168
|
+
|
|
169
|
+
const slugIsNotHost = boot({ rows, search: '?host=toast&handoff=1' });
|
|
170
|
+
await settle(5);
|
|
171
|
+
assert.equal(slugIsNotHost.told.length, 0, 'a bare slug in ?host= matches no address');
|
|
172
|
+
});
|
|
173
|
+
|
|
153
174
|
test('a go from another site, or another window, moves nothing', async () => {
|
|
154
175
|
const b = boot();
|
|
155
176
|
await settle(5);
|