@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.
Files changed (34) hide show
  1. package/.bongos-core.json +43 -48
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +0 -2
  4. package/clients/bongos-client/index.cjs +0 -2
  5. package/clients/bongos-client/index.d.ts +1 -3
  6. package/clients/bongos-client/index.mjs +0 -2
  7. package/docs/adr/0398-every-hosted-project-takes-its-own-update-and-nobody-asks-for-one.md +45 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +1 -37
  10. package/docs/api-reference.md +2 -3
  11. package/docs/copy-inventory.md +38 -44
  12. package/docs/copy-registry.json +44 -108
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-readings.json +4 -4
  15. package/modules/hall-ui/public/deploy-handoff.js +11 -3
  16. package/modules/hall-ui/public/deploy.js +10 -3
  17. package/modules/hall-ui/public/settings-software-update.js +4 -71
  18. package/modules/hall-ui/public/update-pill.js +6 -31
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +6 -0
  22. package/scripts/bongos/provision-core-upgrade.js +30 -1
  23. package/scripts/bongos/provision-units.js +18 -0
  24. package/scripts/hall-preview/server.js +0 -3
  25. package/src/bongos/routes/core-update.js +36 -76
  26. package/src/bongos/routes/feedback.js +1 -2
  27. package/src/module-api.js +1 -1
  28. package/tests/deploy_handoff.mjs +23 -2
  29. package/tests/deploy_page_projects.mjs +35 -3
  30. package/tests/provision_settings_apply.mjs +52 -0
  31. package/tests/software_update.mjs +35 -5
  32. package/tests/update_pill.mjs +9 -5
  33. package/src/bongos/upgrade-request.js +0 -158
  34. 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
- const ask = data.ask || {};
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>${door.kind === 'hub' && ask.available ? askHtml(ask, offered, { lead: false }) : ''}`;
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
- const pending = data.can_update && data.ask && data.ask.available && askPending(data.ask, u.offered);
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 HUB_UPGRADE_URL_RE's shape): an
45
- // origin and /deploy?project=<slug>, nothing else. Anything else draws no button.
46
- var HUB_DOOR_RE = /^(https?:\/\/[^\s"'<>/]+)\/deploy\?project=[A-Za-z0-9._%-]+$/;
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 ask.available ? { kind: 'ask' } : { kind: 'none' };
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 ask.available ? { kind: 'ask' } : { kind: 'cli' };
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.57",
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.57",
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.57",
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",
@@ -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
- function updateDoor({ provisioning, idp, hubUpgradeUrl }) {
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
- // "Ask for this upgrade" (task 1004595): what src/bongos/upgrade-request.js needs from
191
- // the outside world, resolved per request so a test can stand every piece in. The receipt
192
- // goes into the SAME store the public redeem route reads (routes/feedback.js).
193
- function defaultAskDeps() {
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
- // the origin the hub calls back to redeem the receipt. Never read from the request: a
207
- // Host or X-Forwarded-Host header is the caller's to write, and would let them point the
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, ask, held, history });
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: the CLI's POST /feedback/receipt, or this project's own
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.57'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');
@@ -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: '?project=toast&handoff=1', assign() {} },
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);