@bongos/core 1.24.56 → 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 (42) hide show
  1. package/.bongos-core.json +71 -51
  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 +4 -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 +12 -0
  22. package/scripts/bongos/css-literal-scan.js +144 -0
  23. package/scripts/bongos/design-generators-fresh.js +32 -0
  24. package/scripts/bongos/fitness-ratchets.js +70 -3
  25. package/scripts/bongos/fitness.js +3 -2
  26. package/scripts/bongos/provision-core-upgrade.js +30 -1
  27. package/scripts/bongos/provision-units.js +18 -0
  28. package/scripts/gds/css-literal-scan.js +7 -0
  29. package/scripts/gds/design-generators-fresh.js +6 -0
  30. package/scripts/hall-preview/server.js +0 -3
  31. package/src/bongos/module-scope-map.js +1 -1
  32. package/src/bongos/routes/core-update.js +36 -76
  33. package/src/bongos/routes/feedback.js +1 -2
  34. package/src/module-api.js +1 -1
  35. package/tests/deploy_handoff.mjs +23 -2
  36. package/tests/deploy_page_projects.mjs +35 -3
  37. package/tests/design_counts_ratchet.mjs +134 -0
  38. package/tests/provision_settings_apply.mjs +52 -0
  39. package/tests/software_update.mjs +35 -5
  40. package/tests/update_pill.mjs +9 -5
  41. package/src/bongos/upgrade-request.js +0 -158
  42. package/tests/upgrade_request.mjs +0 -348
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/css-literal-scan.js — @bongos-forwarder (task 1004848). Generated by
3
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
4
+ // The real script is scripts/bongos/css-literal-scan.js. This forwarder keeps callers that still name
5
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
6
+ 'use strict';
7
+ require('../bongos/forwarder.js').forward(module, 'css-literal-scan.js');
@@ -0,0 +1,6 @@
1
+ // scripts/gds/design-generators-fresh.js — @bongos-forwarder (task 1004848). Generated by
2
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
3
+ // The real script is scripts/bongos/design-generators-fresh.js. This forwarder keeps callers that still name
4
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
5
+ 'use strict';
6
+ require('../bongos/forwarder.js').forward(module, 'design-generators-fresh.js');
@@ -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'],
@@ -473,7 +473,7 @@ const MODULE_GLOBS = {
473
473
  'scripts/bongos/adr-namespace.js', 'scripts/bongos/baseline-staleness.js',
474
474
  'scripts/bongos/category-advisory-guard.js', 'scripts/bongos/client-baseurl-guard.js',
475
475
  'scripts/bongos/doc-cli-guard.js', 'scripts/bongos/doc-control-claims.js', 'scripts/bongos/doc-comment-coverage.js',
476
- 'scripts/bongos/exec-path-guard.js', 'scripts/bongos/fitness-lib.js',
476
+ 'scripts/bongos/exec-path-guard.js', 'scripts/bongos/fitness-lib.js', 'scripts/bongos/design-generators-fresh.js',
477
477
  'scripts/bongos/fitness-checks-error-envelope.js', 'scripts/bongos/fitness-checks-identity.js',
478
478
  'scripts/bongos/fitness-checks-packaging.js', 'scripts/bongos/fitness-checks-route-pins.js',
479
479
  'scripts/bongos/fitness-checks-vocabulary.js', 'scripts/bongos/fitness-checks-write-validation.js',
@@ -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.56'; // 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);
@@ -34,14 +34,14 @@ const preview = (id, slug, over = {}) => ({
34
34
 
35
35
  // The fleet as the control plane really reports it: mostly torn down, a couple live.
36
36
  const FLEET = [
37
- { id: '31', slug: 'mercury', status: 'active', hosting_shape: 'cloud-host' },
37
+ { id: '31', slug: 'mercury', status: 'active', hosting_shape: 'cloud-host', domain: 'mercury.cloudbongos.com' },
38
38
  { id: '32', slug: 'hoodify-finance', status: 'active', hosting_shape: 'co-tenant' },
39
39
  { id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: 'cloud-host' },
40
40
  { id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: 'cloud-host' },
41
41
  { id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'cloud-host' },
42
42
  ];
43
43
 
44
- function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200, scope = null, href = null } = {}) {
44
+ function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200, scope = null, search = null, href = null } = {}) {
45
45
  const els = new Map();
46
46
  const posts = [];
47
47
  const gets = [];
@@ -99,7 +99,7 @@ function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus =
99
99
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }, // dom-utils' OTB.sleep (task 1004893)
100
100
  // `search` is what decides which project the page is about. A stub without it is the
101
101
  // shape the page has to survive, and the default here keeps that path exercised.
102
- location: { assign() {}, search: scope ? `?project=${encodeURIComponent(scope)}` : '', ...(href ? { href } : {}) },
102
+ location: { assign() {}, search: search != null ? search : (scope ? `?project=${encodeURIComponent(scope)}` : ''), ...(href ? { href } : {}) },
103
103
  };
104
104
  // The page's poll interval, driven rather than slept through. Injecting the REAL
105
105
  // setTimeout made every case whose preview never converges sit out the page's full
@@ -155,6 +155,38 @@ test('?project= renders THAT project, by slug, and still only one row', async ()
155
155
  assert.ok(!b.gets.includes('/provisioning/control-plane'), 'and the self row is not even read');
156
156
  });
157
157
 
158
+ // task 1005128: a hosted project whose server never learned its slug sends its owner by address.
159
+ test('?host= renders the project registered at THAT address, named by its slug, on its own id', async () => {
160
+ const b = boot({ search: '?host=Mercury.CloudBongos.com' });
161
+ await b.settle();
162
+ const html = b.panel();
163
+ assert.match(html, /mercury/);
164
+ assert.doesNotMatch(html, /hoodify-finance/, 'no other project');
165
+ assert.ok(!b.gets.includes('/provisioning/control-plane'), 'the self row is not read');
166
+ b.els.get('dp-preview-31')._on.click();
167
+ b.els.get('dp-confirm-31')._on.click();
168
+ await b.settle();
169
+ const moves = b.posts.filter((x) => !(x.body && x.body.dry_run));
170
+ assert.equal(moves.length, 1);
171
+ assert.equal(moves[0].path, '/provisioning/instances/31/core-upgrade');
172
+ });
173
+
174
+ test('?host= matches only a registered address: a slug, or an address nobody registered, finds nothing', async () => {
175
+ for (const search of ['?host=mercury', '?host=nobody.cloudbongos.com', '?host=hoodify-finance']) {
176
+ const b = boot({ search });
177
+ await b.settle();
178
+ assert.match(b.panel(), /no project by that name is hosted here/, search);
179
+ assert.equal(b.posts.length, 0, search);
180
+ }
181
+ });
182
+
183
+ test('?project= wins over ?host= when both are given', async () => {
184
+ const b = boot({ search: '?project=hoodify-finance&host=mercury.cloudbongos.com' });
185
+ await b.settle();
186
+ assert.match(b.panel(), /hoodify-finance/);
187
+ assert.doesNotMatch(b.panel(), /mercury/);
188
+ });
189
+
158
190
  test('a scoped project is offered its own move, on its own id', async () => {
159
191
  const b = boot({ scope: 'mercury' });
160
192
  await b.settle();
@@ -0,0 +1,134 @@
1
+ // tests/design_counts_ratchet.mjs — design counts that can only go down, and the
2
+ // design generators wired into the fitness freshness gate (task 1005093, goal 1000138).
3
+ //
4
+ // Done when: adding a new hard-coded font-size to a hall sheet fails fitness, and
5
+ // editing the shared token sheet without regenerating fails the freshness gate.
6
+ // Both are proved here on the real tree: the first by scanning the real sheets with
7
+ // one extra declaration, the second by running the gate on a copied tree.
8
+ //
9
+ // Run: node --test tests/design_counts_ratchet.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { createRequire } from 'node:module';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const scan = require(path.join(ROOT, 'scripts', 'bongos', 'css-literal-scan.js'));
22
+ const ratchets = require(path.join(ROOT, 'scripts', 'bongos', 'fitness-ratchets.js'));
23
+ const fitness = require(path.join(ROOT, 'scripts', 'bongos', 'fitness.js'));
24
+ const { checkDesignGeneratorsFresh } = require(path.join(ROOT, 'scripts', 'bongos', 'design-generators-fresh.js'));
25
+
26
+ const HALL_SHEET = 'modules/hall-ui/public/style.css';
27
+ const readReal = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
28
+
29
+ test('the detector counts typed-in values and leaves tokens alone', () => {
30
+ const c = scan.countSheet(`
31
+ :root { --ds-x: 14px; --tone: #123456; } /* token definitions: not counted */
32
+ .a { font-size: 13px; border-radius: 6px; color: #abc; }
33
+ .b { font-size: var(--ds-text-sm); border-radius: var(--ds-radius-tile); color: var(--ink); }
34
+ .c { box-shadow: 0 2px 4px rgba(0, 0, 0, .2); } /* a shadow; the black scrim is allowed */
35
+ .d { box-shadow: none; background: url("data:image/svg+xml;fill='#fff'"); }
36
+ .e { background: color-mix(in srgb, var(--ink) 40%, rgb(12 34 56)); padding: 0; }
37
+ @media (max-width: 720px) { .f { border-radius: 0; } }
38
+ @media (prefers-reduced-motion: reduce) { .g { transition: none; } }
39
+ `);
40
+ assert.deepEqual(c, { colours: 2, font_sizes: 1, radii: 1, shadows: 1, breakpoints: 1 });
41
+ });
42
+
43
+ test('a selector with a colon is not read as a declaration', () => {
44
+ assert.equal(scan.countSheet('a:hover, b:focus-visible { color: var(--ink); }').colours, 0);
45
+ });
46
+
47
+ test('the real tree matches its committed design-count baselines', () => {
48
+ const { sheets, scanned } = scan.scanCssLiterals();
49
+ assert.ok(scanned > 40, `the scan should see the live stylesheets (saw ${scanned})`);
50
+ const r = ratchets.checkQualityRatchets({ metrics: {}, designCounts: sheets });
51
+ assert.deepEqual(r.violations, [], 'design counts regressed against config/fitness-baselines.json');
52
+ });
53
+
54
+ test('a NEW hard-coded font-size in a hall sheet fails fitness', () => {
55
+ const { sheets } = scan.scanCssLiterals({
56
+ read: (f) => readReal(f) + (f === HALL_SHEET ? '\n.planted { font-size: 13px; }\n' : ''),
57
+ });
58
+ const r = ratchets.checkQualityRatchets({ metrics: {}, designCounts: sheets });
59
+ assert.equal(r.hardFail, true);
60
+ assert.ok(r.violations.some((v) => v.includes(`font_sizes in ${HALL_SHEET} regressed`)), r.violations.join('\n'));
61
+ });
62
+
63
+ test('a new sheet starts at zero: any typed-in colour in it fails', () => {
64
+ const d = ratchets.compareDesignCounts({ 'modules/x/public/new.css': { colours: 1 } }, { _comment: 'note' });
65
+ assert.equal(d.violations.length, 1);
66
+ assert.match(d.violations[0], /colours in modules\/x\/public\/new\.css regressed: 1 > baseline 0/);
67
+ });
68
+
69
+ test('a lower count nudges a ratchet-down and does not fail', () => {
70
+ const d = ratchets.compareDesignCounts({ 'a.css': { radii: 2 } }, { 'a.css': { radii: 3 } });
71
+ assert.deepEqual(d.violations, []);
72
+ assert.match(d.warnings[0], /ratchet-down available: design count radii in a\.css improved to 2/);
73
+ });
74
+
75
+ test('a baseline file with no design_counts fails closed', () => {
76
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-counts-'));
77
+ const file = path.join(dir, 'baselines.json');
78
+ fs.writeFileSync(file, JSON.stringify({ metrics: {} }));
79
+ try {
80
+ const r = ratchets.checkQualityRatchets({ baselinePath: file, metrics: {}, designCounts: {} });
81
+ assert.equal(r.hardFail, true);
82
+ assert.match(r.violations[0], /no "design_counts" object/);
83
+ } finally {
84
+ fs.rmSync(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ // The generators resolve their sources from their own location, so a copy of the
89
+ // files they read is a complete, isolated tree the gate can run against.
90
+ const GENERATOR_SOURCES = [
91
+ 'config/branding.neutral.json',
92
+ 'config/design-tokens.neutral.json',
93
+ 'modules/ui-design/public/tokens.css',
94
+ 'modules/ui-design/public/brand.html',
95
+ 'modules/ui-design/public/brand',
96
+ 'modules/ui-design/scripts/gen-design-tokens.js',
97
+ 'modules/ui-design/scripts/gen-design-reference.js',
98
+ 'modules/hall-ui/public/style.css',
99
+ 'modules/hall-ui/public/controls.css',
100
+ 'modules/hall-ui/public/dom-utils.js',
101
+ 'scripts/bongos/gen-brand.js',
102
+ 'DESIGN.md',
103
+ ];
104
+
105
+ function copiedTree() {
106
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-fresh-'));
107
+ for (const rel of GENERATOR_SOURCES) {
108
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
109
+ fs.cpSync(path.join(ROOT, rel), path.join(dir, rel), { recursive: true });
110
+ }
111
+ return dir;
112
+ }
113
+
114
+ test('the freshness gate passes on a fresh tree and fails when the token sheet changes without a regeneration', () => {
115
+ const dir = copiedTree();
116
+ try {
117
+ assert.deepEqual(checkDesignGeneratorsFresh(dir), [], 'the copied tree should start fresh');
118
+ const sheet = path.join(dir, 'modules/ui-design/public/tokens.css');
119
+ const before = fs.readFileSync(sheet, 'utf8');
120
+ const after = before.replace(/--ds-space-xs:\s*[^;]+;/, '--ds-space-xs: 11px;');
121
+ assert.notEqual(after, before, 'the token sheet should declare --ds-space-xs');
122
+ fs.writeFileSync(sheet, after);
123
+ const v = checkDesignGeneratorsFresh(dir);
124
+ assert.ok(v.some((x) => x.startsWith('config/design-tokens.neutral.json is STALE')), v.join('\n'));
125
+ assert.ok(v.some((x) => x.startsWith('modules/ui-design/public/brand.html')), v.join('\n'));
126
+ // …and the registered fitness check carries that verdict, so the build goes red.
127
+ assert.ok(fitness.CHECKS.includes(fitness.checkGeneratedArtifactsFresh));
128
+ const gate = fitness.checkGeneratedArtifactsFresh({ designRoot: dir });
129
+ assert.equal(gate.hardFail, true);
130
+ assert.ok(gate.violations.some((x) => x.startsWith('config/design-tokens.neutral.json is STALE')), gate.violations.join('\n'));
131
+ } finally {
132
+ fs.rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ });
@@ -658,5 +658,57 @@ t('both tenant shapes get the hub door for THEIR slug; no other shape gets the k
658
658
  }
659
659
  });
660
660
 
661
+ console.log('\nthe backfill — every core move writes the Update button\'s address (task 1005128):');
662
+
663
+ t('a web.env with no line gets it; one already right is left alone; an empty file or a shape with no door is never patched', () => {
664
+ const saved = process.env.PROVISION_HUB_ORIGIN;
665
+ process.env.PROVISION_HUB_ORIGIN = 'https://hub.example.com';
666
+ try {
667
+ const U = require('../scripts/bongos/provision-units.js');
668
+ const key = `${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`;
669
+ const want = 'https://hub.example.com/deploy?project=toast';
670
+ const toast = { slug: 'toast', hosting_shape: 'cloud-host' };
671
+ const before = 'PORT=3011\nPGDATABASE=toast\n';
672
+ const after = U.hubUpgradeEnvPatch(before, toast);
673
+ assert.match(after, new RegExp(`^${key}=https://hub\\.example\\.com/deploy\\?project=toast$`, 'm'), 'the line is added');
674
+ assert.match(after, /^PORT=3011$/m, 'every other line kept');
675
+ assert.match(after, /^PGDATABASE=toast$/m);
676
+ assert.equal(U.hubUpgradeEnvPatch(after, toast), null, 'already right: nothing to write');
677
+ assert.match(U.hubUpgradeEnvPatch(`${before}${key}=\n`, toast), new RegExp(`^${key}=${want.replace(/[.?]/g, '\\$&')}$`, 'm'), 'an empty line is filled');
678
+ assert.equal(U.hubUpgradeEnvPatch('', toast), null, 'an empty file is never patched into a one-line body');
679
+ assert.equal(U.hubUpgradeEnvPatch(null, toast), null);
680
+ assert.equal(U.hubUpgradeEnvPatch(before, { slug: 'x', hosting_shape: 'byo-host' }), null, 'no door on this hub, nothing written');
681
+ } finally {
682
+ if (saved === undefined) delete process.env.PROVISION_HUB_ORIGIN; else process.env.PROVISION_HUB_ORIGIN = saved;
683
+ }
684
+ });
685
+
686
+ t('the move writes it before it runs, and a failed read or write never stops the move', () => {
687
+ const saved = process.env.PROVISION_HUB_ORIGIN;
688
+ process.env.PROVISION_HUB_ORIGIN = 'https://hub.example.com';
689
+ try {
690
+ const { backfillHubUpgradeUrl } = require('../scripts/bongos/provision-core-upgrade.js');
691
+ const toast = { slug: 'toast', hosting_shape: 'cloud-host' };
692
+ const writes = [];
693
+ const logs = [];
694
+ const deps = { privileged: true, log: (l) => logs.push(l), controlWriteFile: (p, body, o) => writes.push({ p, body, o }) };
695
+ const exec = (cmd) => (/cat .*web\.env$/.test(cmd) ? { stdout: 'PORT=3011\n' } : { stdout: '' });
696
+ assert.equal(backfillHubUpgradeUrl(toast, deps, exec), true);
697
+ assert.equal(writes.length, 1);
698
+ assert.match(writes[0].p, /toast\/web\.env$/);
699
+ assert.match(writes[0].body, /_HUB_UPGRADE_URL=https:\/\/hub\.example\.com\/deploy\?project=toast$/m);
700
+ assert.equal(writes[0].o.mode, 0o600);
701
+ assert.equal(backfillHubUpgradeUrl(toast, deps, () => ({ softFailed: true })), false, 'unreadable: nothing written');
702
+ const ran = [];
703
+ assert.equal(backfillHubUpgradeUrl({ slug: 'x; rm -rf /', hosting_shape: 'cloud-host' }, deps, (c) => { ran.push(c); return { stdout: 'PORT=1\n' }; }), false, 'a slug that is not a slug');
704
+ assert.deepEqual(ran, [], 'and no command is built from it');
705
+ assert.equal(backfillHubUpgradeUrl(toast, { ...deps, controlWriteFile: () => { throw new Error('EACCES'); } }, exec), false, 'a refused write is logged, not thrown');
706
+ assert.ok(logs.some((l) => /the move goes on/.test(l)));
707
+ assert.equal(writes.length, 1);
708
+ } finally {
709
+ if (saved === undefined) delete process.env.PROVISION_HUB_ORIGIN; else process.env.PROVISION_HUB_ORIGIN = saved;
710
+ }
711
+ });
712
+
661
713
  console.log(`\nprovision_settings_apply: ${passed} passed, ${failed} failed`);
662
714
  process.exit(failed ? 1 : 0);
@@ -219,8 +219,7 @@ async function drive(deps) {
219
219
  const build = require('../src/bongos/routes/core-update.js');
220
220
  const express = require('express');
221
221
  const app = express();
222
- // The ask state's stored row is the DB's; these tests never reach one (task 1004595).
223
- app.use(build({ askDeps: () => ({ hub: 'https://cloudbongos.com', getSetting: async () => null }), ...deps }));
222
+ app.use(build({ ...deps }));
224
223
  auth.requireBuilder = saved;
225
224
  const server = app.listen(0);
226
225
  await new Promise((r) => server.once('listening', r));
@@ -261,6 +260,33 @@ test('HUB_UPGRADE_URL is taken only as a hub /deploy address; anything else stay
261
260
  }
262
261
  });
263
262
 
263
+ // Task 1005128: toast predates HUB_UPGRADE_URL and federates no sign-in, so it fell to `cli`
264
+ // and its owner was offered "Ask for this upgrade". A project on a name under its hub's own
265
+ // domain is the hub's, so it goes to the hub's door by its address.
266
+ test('a hosted project with neither address still gets the hub\'s door, by its own address (task 1005128)', async () => {
267
+ const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
268
+ doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: null, hub: 'https://cloudbongos.com', host: 'Toast.CloudBongos.com' }) });
269
+ assert.deepEqual(r.body.door, { kind: 'hub', url: 'https://cloudbongos.com/deploy?host=toast.cloudbongos.com' });
270
+ assert.equal('ask' in r.body, false, 'the panel is never told to offer an ask');
271
+ });
272
+
273
+ test('only a name under the hub\'s domain is the hub\'s: a self-hosted name, the hub itself, or no hub stays cli (task 1005128)', async () => {
274
+ const cases = [
275
+ { hub: 'https://cloudbongos.com', host: 'shop.example.org' },
276
+ { hub: 'https://cloudbongos.com', host: 'cloudbongos.com' },
277
+ { hub: 'https://cloudbongos.com', host: 'evilcloudbongos.com' },
278
+ { hub: null, host: 'toast.cloudbongos.com' },
279
+ { hub: 'https://cloudbongos.com', host: 'localhost' },
280
+ { hub: 'https://cloudbongos.com', host: '' },
281
+ { hub: 'javascript:alert(1)', host: 'toast.cloudbongos.com' },
282
+ ];
283
+ for (const c of cases) {
284
+ const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
285
+ doorInputs: () => ({ provisioning: false, idp: null, ...c }) });
286
+ assert.deepEqual(r.body.door, { kind: 'cli', url: null }, JSON.stringify(c));
287
+ }
288
+ });
289
+
264
290
  test('a self-hosted instance has no door to link, and says how it is upgraded instead', async () => {
265
291
  const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) });
266
292
  assert.deepEqual(r.body.door, { kind: 'cli', url: null });
@@ -600,9 +626,13 @@ test('the route hands the reader the follow of the hall it serves: candidates on
600
626
  }
601
627
  });
602
628
 
603
- test('[source] the upgrade request asks with the same follow as the panel (task 1004593)', () => {
604
- const seg = ROUTE_SRC.slice(ROUTE_SRC.indexOf("'/software-update/request'"), ROUTE_SRC.indexOf("'/project/manage-link'"));
605
- assert.match(seg, /softwareUpdate\(\{ running, channel, follow: followFor\(door\) \}\)/, 'the version asked for is one the runner would take');
629
+ test('[source] there is no "Ask for this upgrade" route or panel any more (task 1005128)', () => {
630
+ assert.equal(ROUTE_SRC.includes('/software-update/request'), false, 'the route is gone');
631
+ assert.equal(fs.existsSync(path.join(ROOT, 'src/bongos/upgrade-request.js')), false, 'and its module');
632
+ for (const f of ['modules/hall-ui/public/settings-software-update.js', 'modules/hall-ui/public/update-pill.js']) {
633
+ const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
634
+ assert.equal(/software-update\/request|Ask for this/.test(src), false, f);
635
+ }
606
636
  });
607
637
 
608
638
  test('[source] the port the route reads is the port the provisioning module registers and declares (task 1004535)', () => {