@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
@@ -93,13 +93,18 @@ test('what the update brings: the first lines, dashes as commas (V4), and an hon
93
93
  assert.equal(long.more, true, 'older versions left out make the count a floor');
94
94
  });
95
95
 
96
- test('the door decides the button: local, hub (by its origin), ask, the command, or none', () => {
96
+ test('the door decides the button: local, hub (by its origin), the command, or none', () => {
97
97
  assert.deepEqual(P.doorPlan(SW({ kind: 'local', url: '/deploy' })), { kind: 'local' });
98
98
  assert.deepEqual(P.doorPlan(SW({ kind: 'hub', url: HUB })), { kind: 'hub', url: HUB, origin: 'https://cloudbongos.com' });
99
99
  assert.equal(P.doorPlan(SW({ kind: 'hub', url: 'https://cloudbongos.com/elsewhere' })).kind, 'none', 'only a hub /deploy address');
100
100
  assert.equal(P.doorPlan(SW({ kind: 'hub', url: 'javascript:alert(1)' })).kind, 'none');
101
101
  assert.equal(P.doorPlan(SW({ kind: 'cli', url: null })).kind, 'cli');
102
- assert.equal(P.doorPlan(SW({ kind: 'cli', url: null }, { ask: { available: true } })).kind, 'ask');
102
+ // task 1005128: there is no "ask" plan any more, whatever a stale reading carries.
103
+ assert.equal(P.doorPlan(SW({ kind: 'cli', url: null }, { ask: { available: true } })).kind, 'cli');
104
+ // A hosted project whose server never learned its slug goes to the hub by its address.
105
+ const BY_HOST = 'https://cloudbongos.com/deploy?host=toast.cloudbongos.com';
106
+ assert.deepEqual(P.doorPlan(SW({ kind: 'hub', url: BY_HOST })), { kind: 'hub', url: BY_HOST, origin: 'https://cloudbongos.com' });
107
+ assert.equal(P.doorPlan(SW({ kind: 'hub', url: 'https://cloudbongos.com/deploy?who=x' })).kind, 'none', 'only ?project= or ?host=');
103
108
  assert.equal(P.doorPlan(SW({ kind: 'local', url: '/deploy' }, { can_update: 'yes' })).kind, 'none', 'can_update is strictly true');
104
109
  assert.equal(P.doorPlan(null).kind, 'none');
105
110
  });
@@ -379,7 +384,7 @@ test('a blocked window is said, with a second press that opens it', async () =>
379
384
  assert.ok(again, 'a button to try again');
380
385
  });
381
386
 
382
- test('a self-hosted project names the command; an operator-run one offers to ask; a viewer who may not, nothing', async () => {
387
+ test('a self-hosted project names the command; nobody is ever offered to ask; a viewer who may not, nothing', async () => {
383
388
  const cli = boot({ sw: SW({ kind: 'cli', url: null }) });
384
389
  await settle(); cli.press(cli.pill()); await settle();
385
390
  assert.match(cli.card().innerHTML, /<code>bongos upgrade<\/code>/);
@@ -387,8 +392,7 @@ test('a self-hosted project names the command; an operator-run one offers to ask
387
392
 
388
393
  const ask = boot({ sw: SW({ kind: 'cli', url: null }, { ask: { available: true, hub_host: 'cloudbongos.com' } }) });
389
394
  await settle(); ask.press(ask.pill()); await settle();
390
- assert.match(ask.card().innerHTML, /id="upd-ask"/);
391
- assert.match(ask.card().innerHTML, /the team at cloudbongos\.com/);
395
+ assert.doesNotMatch(ask.card().innerHTML, /upd-ask|Ask for this/, 'task 1005128: no ask, ever');
392
396
 
393
397
  const no = boot({ sw: SW({ kind: 'local', url: '/deploy' }, { can_update: false }) });
394
398
  await settle(); no.press(no.pill()); await settle();
@@ -1,158 +0,0 @@
1
- 'use strict';
2
-
3
- // src/bongos/upgrade-request.js — "Ask for this upgrade" on Settings → Software update
4
- // (task 1004595).
5
- //
6
- // WHY. A project that Cloud Bongos hosts is upgraded by the people who run its server,
7
- // not by its owner. Its Settings page said "Update ready" and then nothing: no button,
8
- // no next step (Will, mercury, 2026-10-06, 46 versions behind with nobody told). This
9
- // file gives that owner one press that reaches the operator, and remembers that it was
10
- // pressed so the page can say the request is pending.
11
- //
12
- // THE CHANNEL IS ONE THAT ALREADY EXISTS: the direct feedback door (task 1004591). The
13
- // project issues a one-time receipt for the signed-in builder in its OWN receipt store
14
- // (src/bongos/routes/feedback.js), then posts the request straight to the hub's
15
- // POST /api/bongos/sso/feedback/direct. The hub redeems the receipt at this project's
16
- // public URL, so the request lands in the maintainers' backlog attributed to the person
17
- // who really pressed the button, with no new credential and no new hub route.
18
- //
19
- // ONE REQUEST PER TARGET VERSION. Asking again for a version already asked for is
20
- // answered from the stored row and sends nothing. A different version may be asked for
21
- // once the cooldown has passed, so a project that falls further behind can say so,
22
- // without a press per patch release flooding the backlog.
23
-
24
- const crypto = require('node:crypto');
25
-
26
- const SETTING_KEY = 'software_update.request';
27
- const COOLDOWN_MS = 24 * 60 * 60 * 1000;
28
- const HUB_TIMEOUT_MS = 15000;
29
-
30
- const VERSION_RE = /^\d{1,4}\.\d{1,5}\.\d{1,6}$/;
31
- const LOGIN_RE = /^[A-Za-z0-9-]{1,39}$/;
32
-
33
- // The host this hall answers on, as the hub will need it to redeem the receipt: a bare
34
- // public DNS name. Anything else (an IP, a port, localhost, a dev name) cannot be
35
- // reached by the hub, so it is not askable. Mirrors the hub's own projectOrigin check.
36
- function projectHost(raw) {
37
- const host = String(raw || '').trim().toLowerCase();
38
- if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) return null;
39
- if (/^\d+(\.\d+)+$/.test(host)) return null;
40
- if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
41
- return host;
42
- }
43
-
44
- function hubHostOf(hub) {
45
- try { return new URL(String(hub)).host.toLowerCase(); } catch { return null; }
46
- }
47
-
48
- // Is this a project the hub's operator runs? Its door is the hub's, or it answers on a
49
- // name under the hub's own domain (the same rule the hub uses to call a sender "verified").
50
- function isOperatorRun({ doorKind, host, hub }) {
51
- if (doorKind === 'local' || !hub) return false;
52
- if (doorKind === 'hub') return true;
53
- const h = hubHostOf(hub);
54
- return !!(h && host && (host === h || host.endsWith(`.${h}`)));
55
- }
56
-
57
- // The stored request, or null. A row this file did not write reads as no request.
58
- function parseStored(row) {
59
- if (!row || typeof row.value !== 'string') return null;
60
- let v;
61
- try { v = JSON.parse(row.value); } catch { return null; }
62
- if (!v || !VERSION_RE.test(String(v.version || ''))) return null;
63
- const at = Date.parse(v.requested_at);
64
- if (!Number.isFinite(at)) return null;
65
- return {
66
- version: String(v.version),
67
- requested_at: new Date(at).toISOString(),
68
- by: typeof v.by === 'string' && LOGIN_RE.test(v.by) ? v.by : null,
69
- };
70
- }
71
-
72
- // What the page needs: may it offer the button, and what was asked before.
73
- // `ask_again_after` is when a request for a DIFFERENT version would be taken.
74
- function askState({ offered, host, doorKind, hub, stored }) {
75
- const available = !!offered && isOperatorRun({ doorKind, host, hub }) && !!projectHost(host);
76
- const ask = { available, hub_host: hub ? hubHostOf(hub) : null, requested: stored || null, ask_again_after: null };
77
- if (stored) ask.ask_again_after = new Date(Date.parse(stored.requested_at) + COOLDOWN_MS).toISOString();
78
- return ask;
79
- }
80
-
81
- // The text the operator reads. Its first line becomes the hub task's title.
82
- function requestMessage({ host, running, wanted, login }) {
83
- const who = login && LOGIN_RE.test(login) ? `@${login}` : 'its owner';
84
- return [
85
- `Upgrade request: ${host} asks for core ${wanted} (it runs ${running || 'an unknown version'})`,
86
- '',
87
- `${who} pressed "Ask for this upgrade" in Settings, Software update on ${host}.`,
88
- 'This project cannot upgrade itself: the people who run its server do it.',
89
- `Move its core to ${wanted} or newer, or tell them when it will be.`,
90
- ].join('\n');
91
- }
92
-
93
- /**
94
- * Send one upgrade request, or answer from the stored one. Never throws for an
95
- * expected refusal; returns { status, body } for the route to send.
96
- *
97
- * deps: { hub, issueReceipt(receipt) → id|null, fetchImpl, getSetting, setSetting, now }
98
- */
99
- async function requestUpgrade({ host, running, wanted, builder, doorKind }, deps) {
100
- const now = deps.now || Date.now;
101
- const hub = deps.hub;
102
- const target = projectHost(host);
103
- if (!wanted || !VERSION_RE.test(String(wanted))) return { status: 409, body: { error: 'nothing_to_ask', message: 'This project is up to date.' } };
104
- if (!target || !isOperatorRun({ doorKind, host: target, hub })) {
105
- return { status: 409, body: { error: 'not_operator_run', message: 'This project is not upgraded by an operator, so there is no one to ask.' } };
106
- }
107
-
108
- const stored = parseStored(await deps.getSetting(SETTING_KEY));
109
- if (stored && stored.version === wanted) return { status: 200, body: { ok: true, already: true, request: stored } };
110
- if (stored && now() < Date.parse(stored.requested_at) + COOLDOWN_MS) {
111
- const retry = Math.ceil((Date.parse(stored.requested_at) + COOLDOWN_MS - now()) / 1000);
112
- return { status: 429, body: { error: 'asked_recently', message: 'This project already asked for an upgrade today. Ask again tomorrow.', retry_after_seconds: retry, request: stored } };
113
- }
114
-
115
- const login = builder && typeof builder.github_login === 'string' && LOGIN_RE.test(builder.github_login) ? builder.github_login : null;
116
- const message = requestMessage({ host: target, running, wanted, login });
117
- const sha256 = crypto.createHash('sha256').update(message, 'utf8').digest('hex');
118
- const receipt = deps.issueReceipt({
119
- github_id: String(builder && builder.github_id),
120
- github_login: login,
121
- sha256,
122
- kind: 'bug',
123
- core_version: running || null,
124
- });
125
- if (!receipt) return { status: 429, body: { error: 'rate_limited', message: 'Too many requests just now. Try again in ten minutes.', retry_after_seconds: 600 } };
126
-
127
- let res;
128
- try {
129
- res = await (deps.fetchImpl || fetch)(`${String(hub).replace(/\/+$/, '')}/api/bongos/sso/feedback/direct`, {
130
- method: 'POST',
131
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
132
- body: JSON.stringify({ origin: `https://${target}`, receipt, message, kind: 'bug', context: {} }),
133
- redirect: 'error',
134
- signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
135
- });
136
- } catch {
137
- return { status: 502, body: { error: 'operator_unreachable', message: 'Could not reach the people who run this project. Try again in a few minutes.' } };
138
- }
139
- if (res.status === 429) return { status: 429, body: { error: 'rate_limited', message: 'Too many requests just now. Try again in an hour.', retry_after_seconds: 3600 } };
140
- if (res.status !== 202) {
141
- return { status: 502, body: { error: 'operator_unreachable', message: `The request was not taken (HTTP ${res.status}). Try again later.` } };
142
- }
143
-
144
- const request = { version: String(wanted), requested_at: new Date(now()).toISOString(), by: login };
145
- await deps.setSetting(SETTING_KEY, JSON.stringify(request), builder && builder.id != null ? builder.id : null);
146
- return { status: 201, body: { ok: true, already: false, request } };
147
- }
148
-
149
- module.exports = {
150
- SETTING_KEY,
151
- COOLDOWN_MS,
152
- projectHost,
153
- isOperatorRun,
154
- parseStored,
155
- askState,
156
- requestMessage,
157
- requestUpgrade,
158
- };
@@ -1,348 +0,0 @@
1
- // tests/upgrade_request.mjs — "Ask for this upgrade" on Settings → Software update
2
- // (task 1004595).
3
- //
4
- // WHAT IS AT RISK. Will's mercury, a project the Cloud Bongos operator runs, said "Update
5
- // ready" and then nothing: no button, no next step. The fix gives its owner one press that
6
- // reaches the operator and then says it is pending. So these tests hold that:
7
- // - the press is offered only on a project the hub's operator runs, never on a
8
- // self-hosted one (there is nobody at the hub to do it);
9
- // - it reaches the operator through the direct feedback door, with a receipt the
10
- // project's PUBLIC redeem route will vouch for (the hub files nothing without it);
11
- // - it is sent once per version, remembered, and the page then shows it pending;
12
- // - nothing in this file touches the network or a database.
13
- //
14
- // Run: node --test tests/upgrade_request.mjs
15
- import assert from 'node:assert/strict';
16
- import { test } from 'node:test';
17
- import crypto from 'node:crypto';
18
- import fs from 'node:fs';
19
- import path from 'node:path';
20
- import vm from 'node:vm';
21
- import { fileURLToPath } from 'node:url';
22
- import { createRequire } from 'node:module';
23
-
24
- const require = createRequire(import.meta.url);
25
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
- const ur = require('../src/bongos/upgrade-request.js');
27
- const { makeReceiptStore } = require('../src/bongos/routes/feedback.js');
28
-
29
- const HUB = 'https://cloudbongos.com';
30
- const WILL = { id: '12', github_id: '4242', github_login: 'will-x' };
31
- const T0 = Date.parse('2026-10-06T21:00:00Z');
32
-
33
- /** A hub that records what it was sent and answers `status`. */
34
- function fakeHub(status = 202) {
35
- const sent = [];
36
- const fetchImpl = async (url, init) => { sent.push({ url, init, body: JSON.parse(init.body) }); return { status }; };
37
- return { sent, fetchImpl };
38
- }
39
- /** project_settings in memory. */
40
- function fakeSettings(initial = null) {
41
- const rows = new Map();
42
- if (initial) rows.set(ur.SETTING_KEY, { key: ur.SETTING_KEY, value: JSON.stringify(initial) });
43
- return {
44
- rows,
45
- getSetting: async (k) => rows.get(k) || null,
46
- setSetting: async (k, v, by) => { const r = { key: k, value: String(v), updated_by: by }; rows.set(k, r); return r; },
47
- };
48
- }
49
- function deps({ hub = fakeHub(), settings = fakeSettings(), store = makeReceiptStore(), now = () => T0 } = {}) {
50
- return { hub: HUB, fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: (r) => store.issue(r), now, _hub: hub, _settings: settings, _store: store };
51
- }
52
- const ASK = { host: 'mercury.cloudbongos.com', running: '1.21.20', wanted: '1.21.66', builder: WILL, doorKind: 'cli' };
53
-
54
- // ---- who is offered the press -------------------------------------------------------
55
-
56
- test('offered on a project the hub runs (by its door, or by its name under the hub), never on a self-hosted one', () => {
57
- const base = { offered: '1.21.66', hub: HUB, stored: null };
58
- assert.equal(ur.askState({ ...base, host: 'mercury.cloudbongos.com', doorKind: 'cli' }).available, true, 'Will\'s mercury: no door, hosted name');
59
- assert.equal(ur.askState({ ...base, host: 'shop.example.org', doorKind: 'hub' }).available, true, 'a hosted project on its own domain, with a hub door');
60
- assert.equal(ur.askState({ ...base, host: 'shop.example.org', doorKind: 'cli' }).available, false, 'self-hosted: nobody at the hub upgrades it');
61
- assert.equal(ur.askState({ ...base, host: 'cloudbongos.com', doorKind: 'local' }).available, false, 'the platform runs its own door');
62
- assert.equal(ur.askState({ ...base, host: 'mercury.cloudbongos.com', doorKind: 'cli', hub: null }).available, false, 'no hub to ask');
63
- assert.equal(ur.askState({ ...base, offered: null, host: 'mercury.cloudbongos.com', doorKind: 'cli' }).available, false, 'up to date: nothing to ask for');
64
- assert.equal(ur.askState({ ...base, host: 'localhost:4600', doorKind: 'hub' }).available, false, 'a host the hub cannot reach to check the receipt');
65
- });
66
-
67
- // ---- the press itself ---------------------------------------------------------------
68
-
69
- test('a press reaches the operator through the direct feedback door, and is remembered', async () => {
70
- const d = deps();
71
- const out = await ur.requestUpgrade(ASK, d);
72
- assert.equal(out.status, 201);
73
- assert.deepEqual(out.body.request, { version: '1.21.66', requested_at: new Date(T0).toISOString(), by: 'will-x' });
74
-
75
- assert.equal(d._hub.sent.length, 1);
76
- const { url, body, init } = d._hub.sent[0];
77
- assert.equal(url, `${HUB}/api/bongos/sso/feedback/direct`);
78
- assert.equal(init.redirect, 'error');
79
- assert.equal(body.origin, 'https://mercury.cloudbongos.com', 'the hub redeems the receipt at this project');
80
- assert.equal(body.kind, 'bug');
81
- assert.match(body.message.split('\n')[0], /^Upgrade request: mercury\.cloudbongos\.com asks for core 1\.21\.66 \(it runs 1\.21\.20\)$/);
82
- assert.match(body.message, /@will-x pressed "Ask for this upgrade"/);
83
-
84
- // The receipt is the one the hub will redeem: same sender, same text hash.
85
- const receipt = d._store.redeem(body.receipt);
86
- assert.equal(receipt.github_id, '4242');
87
- assert.equal(receipt.github_login, 'will-x');
88
- assert.equal(receipt.sha256, crypto.createHash('sha256').update(body.message, 'utf8').digest('hex'));
89
- assert.equal(receipt.kind, 'bug');
90
-
91
- const stored = ur.parseStored(d._settings.rows.get(ur.SETTING_KEY));
92
- assert.deepEqual(stored, out.body.request, 'remembered, so the page can say it is pending');
93
- assert.equal(d._settings.rows.get(ur.SETTING_KEY).updated_by, '12');
94
- });
95
-
96
- test('pressing again for the same version sends nothing and answers from the stored request', async () => {
97
- const d = deps({ settings: fakeSettings({ version: '1.21.66', requested_at: '2026-09-01T00:00:00Z', by: 'will-x' }) });
98
- const out = await ur.requestUpgrade(ASK, d);
99
- assert.equal(out.status, 200);
100
- assert.equal(out.body.already, true);
101
- assert.equal(out.body.request.requested_at, '2026-09-01T00:00:00.000Z');
102
- assert.equal(d._hub.sent.length, 0);
103
- });
104
-
105
- test('a newer version may be asked for, but not twice inside a day', async () => {
106
- const recent = fakeSettings({ version: '1.21.60', requested_at: new Date(T0 - 3600e3).toISOString(), by: 'will-x' });
107
- const d1 = deps({ settings: recent });
108
- const out = await ur.requestUpgrade(ASK, d1);
109
- assert.equal(out.status, 429);
110
- assert.equal(out.body.error, 'asked_recently');
111
- assert.match(out.body.message, /already asked for an upgrade today/, 'every refusal carries a sentence the page can show');
112
- assert.equal(out.body.request.version, '1.21.60', 'the refusal carries the pending request, so the page can show it');
113
- assert.equal(d1._hub.sent.length, 0);
114
-
115
- const old = fakeSettings({ version: '1.21.60', requested_at: new Date(T0 - 2 * 86400e3).toISOString(), by: 'will-x' });
116
- const d2 = deps({ settings: old });
117
- assert.equal((await ur.requestUpgrade(ASK, d2)).status, 201);
118
- assert.equal(d2._hub.sent.length, 1);
119
- });
120
-
121
- test('nothing is sent, and nothing remembered, when there is no one to ask or nothing to ask for', async () => {
122
- for (const over of [{ doorKind: 'cli', host: 'shop.example.org' }, { doorKind: 'local' }, { wanted: null }, { wanted: '1.21.66"><b>' }]) {
123
- const d = deps();
124
- const out = await ur.requestUpgrade({ ...ASK, ...over }, d);
125
- assert.equal(out.status, 409, JSON.stringify(over));
126
- assert.equal(d._hub.sent.length, 0);
127
- assert.equal(d._settings.rows.size, 0);
128
- }
129
- });
130
-
131
- test('a hub that does not take it, or cannot be reached, leaves the request unasked so it can be pressed again', async () => {
132
- for (const hub of [fakeHub(503), fakeHub(400), { sent: [], fetchImpl: async () => { throw new Error('ECONNRESET'); } }]) {
133
- const d = deps({ hub });
134
- const out = await ur.requestUpgrade(ASK, d);
135
- assert.equal(out.status, 502);
136
- assert.equal(out.body.error, 'operator_unreachable');
137
- assert.equal(d._settings.rows.size, 0);
138
- }
139
- const limited = deps({ hub: fakeHub(429) });
140
- const lim = await ur.requestUpgrade(ASK, limited);
141
- assert.equal(lim.status, 429);
142
- assert.match(lim.body.message, /Try again in an hour/);
143
- assert.equal(limited._settings.rows.size, 0);
144
- });
145
-
146
- test('a stored row this file did not write reads as not asked', () => {
147
- for (const value of ['', 'nope', '{}', JSON.stringify({ version: 'x', requested_at: '2026-10-01' }), JSON.stringify({ version: '1.2.3', requested_at: 'never' })]) {
148
- assert.equal(ur.parseStored({ value }), null, value);
149
- }
150
- assert.equal(ur.parseStored({ value: JSON.stringify({ version: '1.2.3', requested_at: '2026-10-01T00:00:00Z', by: '<script>' }) }).by, null);
151
- });
152
-
153
- // ---- the receipt is redeemable at the project's PUBLIC route -------------------------
154
-
155
- test('the receipt the press issues is the one the public redeem route answers (one shared store)', async () => {
156
- const fb = require('../src/bongos/routes/feedback.js');
157
- const express = require('express');
158
- const app = express();
159
- app.use(require('../src/bongos/middleware/error-envelope.js').attachFail);
160
- app.use(fb());
161
- const hub = fakeHub();
162
- const settings = fakeSettings();
163
- const out = await ur.requestUpgrade(ASK, { hub: HUB, fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting,
164
- issueReceipt: (r) => fb.defaultReceiptStore().issue(r), now: () => T0 });
165
- assert.equal(out.status, 201);
166
- const server = app.listen(0);
167
- await new Promise((r) => server.once('listening', r));
168
- try {
169
- const res = await fetch(`http://127.0.0.1:${server.address().port}/feedback/receipt/${hub.sent[0].body.receipt}`);
170
- assert.equal(res.status, 200);
171
- const body = await res.json();
172
- assert.equal(body.github_id, '4242');
173
- assert.equal(body.sha256, crypto.createHash('sha256').update(hub.sent[0].body.message, 'utf8').digest('hex'));
174
- } finally { server.close(); }
175
- });
176
-
177
- // ---- the route ----------------------------------------------------------------------
178
-
179
- const ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
180
-
181
- test('[source] the press is gated on the same atom as the Update button', () => {
182
- const at = ROUTE_SRC.indexOf("'/software-update/request'");
183
- assert.ok(at > 0);
184
- const seg = ROUTE_SRC.slice(at, ROUTE_SRC.indexOf("asyncHandler('POST /software-update/request'"));
185
- assert.match(seg, /auth\.requireBuilder/);
186
- assert.match(seg, /auth\.requirePermission\('core\.pin\.move'\)/);
187
- });
188
-
189
- async function serve(deps, { forwardedHost = 'evil.example' } = {}) {
190
- const auth = require('../src/bongos/auth.js');
191
- const saved = { b: auth.requireBuilder, p: auth.requirePermission };
192
- auth.requireBuilder = (req, _res, next) => { req.builder = { ...WILL }; next(); };
193
- auth.requirePermission = () => (_req, _res, next) => next();
194
- const build = require('../src/bongos/routes/core-update.js');
195
- const express = require('express');
196
- const app = express();
197
- app.use(express.json());
198
- app.use((_req, res, next) => { res.fail = (code, o) => res.status(o.status).json({ error: { code, message: o.message || code, details: o.details } }); next(); });
199
- try { app.use(build(deps)); } finally { auth.requireBuilder = saved.b; auth.requirePermission = saved.p; }
200
- const server = app.listen(0);
201
- await new Promise((r) => server.once('listening', r));
202
- const base = `http://127.0.0.1:${server.address().port}`;
203
- const call = async (method, p) => {
204
- const res = await fetch(`${base}${p}`, { method, headers: { 'X-Forwarded-Host': forwardedHost, Host: forwardedHost, 'content-type': 'application/json' }, body: method === 'POST' ? '{}' : undefined });
205
- return { status: res.status, body: await res.json() };
206
- };
207
- return { call, close: () => server.close() };
208
- }
209
- const UPDATE = { reading: 'ok', running: '1.21.20', offered: '1.21.66' };
210
-
211
- test('GET then POST then GET: offered, sent once, then shown as pending', async () => {
212
- // A hub that takes a moment, so the two presses below truly overlap.
213
- const hub = fakeHub();
214
- const quick = hub.fetchImpl;
215
- hub.fetchImpl = async (...a) => { await new Promise((r) => setTimeout(r, 40)); return quick(...a); };
216
- const settings = fakeSettings();
217
- const store = makeReceiptStore();
218
- const s = await serve({
219
- softwareUpdate: async () => UPDATE, selfChannel: async () => null, canUpdate: async () => true,
220
- doorInputs: () => ({ provisioning: false, idp: null }),
221
- askDeps: () => ({ hub: HUB, host: 'mercury.cloudbongos.com', fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: (r) => store.issue(r) }),
222
- });
223
- try {
224
- let r = await s.call('GET', '/software-update');
225
- assert.equal(r.status, 200);
226
- assert.deepEqual(r.body.door, { kind: 'cli', url: null });
227
- assert.equal(r.body.ask.available, true);
228
- assert.equal(r.body.ask.hub_host, 'cloudbongos.com');
229
- assert.equal(r.body.ask.requested, null);
230
-
231
- const [a, b] = await Promise.all([s.call('POST', '/software-update/request'), s.call('POST', '/software-update/request')]);
232
- assert.deepEqual([a.status, b.status].sort(), [200, 201], 'a double press sends once; the second is answered from the row');
233
- assert.equal(hub.sent.length, 1);
234
- assert.equal(hub.sent[0].body.origin, 'https://mercury.cloudbongos.com',
235
- 'the call-back address is the project\'s own configured host, never the request\'s (X-Forwarded-Host: evil.example here)');
236
-
237
- r = await s.call('GET', '/software-update');
238
- assert.equal(r.body.ask.requested.version, '1.21.66');
239
- assert.equal(r.body.ask.requested.by, 'will-x');
240
- } finally { s.close(); }
241
- });
242
-
243
- test('the version asked for is the server\'s own reading, and a self-hosted project is refused', async () => {
244
- const hub = fakeHub();
245
- const settings = fakeSettings();
246
- const askDeps = () => ({ hub: HUB, host: 'shop.example.org', fetchImpl: hub.fetchImpl, getSetting: settings.getSetting, setSetting: settings.setSetting, issueReceipt: () => 'r'.repeat(24) });
247
- const common = { softwareUpdate: async () => UPDATE, selfChannel: async () => null, canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }), askDeps };
248
- // A self-hosted project whose caller claims a hosted name in the headers is still refused.
249
- const self = await serve(common, { forwardedHost: 'mercury.cloudbongos.com' });
250
- try {
251
- const r = await self.call('POST', '/software-update/request');
252
- assert.equal(r.status, 409);
253
- assert.equal(r.body.error.code, 'not_operator_run');
254
- assert.equal((await self.call('GET', '/software-update')).body.ask.available, false);
255
- assert.equal(hub.sent.length, 0);
256
- } finally { self.close(); }
257
- });
258
-
259
- // ---- the panel ----------------------------------------------------------------------
260
-
261
- const PANEL_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-software-update.js'), 'utf8');
262
- const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
263
-
264
- function bootPanel(answers) {
265
- const el = (id) => ({ id, hidden: true, disabled: false, textContent: '', _html: '', dataset: {}, handlers: {},
266
- set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
267
- querySelectorAll: () => [], addEventListener(ev, fn) { this.handlers[ev] = fn; } });
268
- const els = new Map(['software-update-scroll', 'su-body', 'su-note', 'settings-groups'].map((id) => [id, el(id)]));
269
- els.get('settings-groups').dataset.active = 'update';
270
- // Buttons drawn into su-body exist only while its html carries their id.
271
- const dynamic = new Map();
272
- const getElementById = (id) => {
273
- if (els.has(id)) return els.get(id);
274
- if (!els.get('su-body').innerHTML.includes(`id="${id}"`)) return null;
275
- const key = `${id}@${els.get('su-body').innerHTML.length}:${els.get('su-body').innerHTML.slice(0, 40)}`;
276
- if (!dynamic.has(key)) dynamic.set(key, el(id));
277
- return dynamic.get(key);
278
- };
279
- const calls = [];
280
- const windowObj = {
281
- BongosClient: { createClient: () => ({ request: async (m, url, opts) => { calls.push({ m, url, opts }); return answers(m, url); } }) },
282
- OTB: { escapeHtml },
283
- location: { assign: () => {} },
284
- };
285
- const sandbox = { window: windowObj, document: { getElementById, addEventListener: () => {} }, console, setTimeout, Date, Math, URL, Promise, String, Number };
286
- vm.createContext(sandbox);
287
- vm.runInContext(PANEL_SRC, sandbox, { filename: 'settings-software-update.js' });
288
- const settle = async () => { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); };
289
- return { calls, settle, body: () => els.get('su-body').innerHTML, byId: getElementById };
290
- }
291
- const ok = (data, status = 200) => ({ ok: status < 400, status, data });
292
- const PAGE = { ok: true, update: { reading: 'ok', running: '1.21.20', offered: '1.21.66', whats_new: null }, door: { kind: 'cli', url: null }, can_update: true,
293
- ask: { available: true, hub_host: 'cloudbongos.com', requested: null, ask_again_after: null } };
294
-
295
- test('Will\'s page: who upgrades it, and an Ask button, never a server command', async () => {
296
- const b = bootPanel(() => ok(PAGE));
297
- await b.settle();
298
- assert.match(b.body(), /the team at cloudbongos\.com looks after, so they install its updates/);
299
- assert.match(b.body(), /id="su-ask">Ask for this upgrade</);
300
- assert.doesNotMatch(b.body(), /bongos upgrade/);
301
- assert.match(b.body(), /Update ready/);
302
- });
303
-
304
- test('pressing it sends the request and the page then says it is pending', async () => {
305
- const request = { version: '1.21.66', requested_at: '2026-10-06T21:00:00.000Z', by: 'will-x' };
306
- const b = bootPanel((m) => (m === 'POST' ? ok({ ok: true, already: false, request }, 201) : ok(PAGE)));
307
- await b.settle();
308
- await b.byId('su-ask').handlers.click();
309
- await b.settle();
310
- assert.deepEqual(b.calls.map((c) => `${c.m} ${c.url}`), ['GET /api/bongos/software-update', 'POST /api/bongos/software-update/request']);
311
- assert.match(b.body(), /Upgrade requested on 6 October 2026 by @will-x, for version <span class="su-ver">1\.21\.66<\/span>\. The team at cloudbongos\.com has your request/);
312
- assert.match(b.body(), /<span class="su-pill">Requested<\/span>/);
313
- assert.doesNotMatch(b.body(), /id="su-ask"/, 'no second press offered');
314
- });
315
-
316
- test('a page loaded after asking shows it pending; a failed press says why and can be pressed again', async () => {
317
- const asked = { ...PAGE, ask: { ...PAGE.ask, requested: { version: '1.21.66', requested_at: '2026-10-05T10:00:00Z', by: null }, ask_again_after: '2026-10-06T10:00:00Z' } };
318
- const p = bootPanel(() => ok(asked));
319
- await p.settle();
320
- assert.match(p.body(), /Upgrade requested on 5 October 2026, for version/);
321
- assert.doesNotMatch(p.body(), /id="su-ask"/);
322
-
323
- const f = bootPanel((m) => (m === 'POST' ? ok({ error: { code: 'operator_unreachable', message: 'Could not reach the people who run this project. Try again in a few minutes.' } }, 502) : ok(PAGE)));
324
- await f.settle();
325
- const btn = f.byId('su-ask');
326
- await btn.handlers.click();
327
- await f.settle();
328
- assert.equal(btn.disabled, false);
329
- assert.equal(f.byId('su-ask-status').textContent, 'Could not send the request: Could not reach the people who run this project. Try again in a few minutes.');
330
- });
331
-
332
- test('a hosted project with a hub door keeps its Update button and offers asking as the fallback', async () => {
333
- const b = bootPanel(() => ok({ ...PAGE, door: { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=mercury' } }));
334
- await b.settle();
335
- assert.match(b.body(), /id="su-update" href="https:\/\/cloudbongos\.com\/deploy\?project=mercury"/);
336
- assert.match(b.body(), /If that page does not let you in, ask the team at cloudbongos\.com/);
337
- assert.match(b.body(), /class="pbtn pbtn--quiet" id="su-ask"/);
338
- });
339
-
340
- test('no ask for a viewer who may not upgrade, or where the server does not offer it', async () => {
341
- const viewer = bootPanel(() => ok({ ...PAGE, can_update: false }));
342
- await viewer.settle();
343
- assert.doesNotMatch(viewer.body(), /id="su-ask"/);
344
- const self = bootPanel(() => ok({ ...PAGE, ask: { ...PAGE.ask, available: false } }));
345
- await self.settle();
346
- assert.doesNotMatch(self.body(), /id="su-ask"/);
347
- assert.match(self.body(), /<code>bongos upgrade<\/code>/);
348
- });