@bongos/core 1.24.57 → 1.24.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +77 -67
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +0 -2
- package/clients/bongos-client/index.cjs +0 -2
- package/clients/bongos-client/index.d.ts +1 -3
- package/clients/bongos-client/index.mjs +0 -2
- package/docs/adr/0398-every-hosted-project-takes-its-own-update-and-nobody-asks-for-one.md +45 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -37
- package/docs/api-reference.md +2 -3
- package/docs/architecture.md +1 -1
- package/docs/copy-inventory.md +38 -44
- package/docs/copy-registry.json +44 -108
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +5 -5
- package/docs/recipes/ops-gotchas.md +2 -2
- package/modules/hall-ui/public/deploy-handoff.js +11 -3
- package/modules/hall-ui/public/deploy.js +10 -3
- package/modules/hall-ui/public/settings-software-update.js +4 -71
- package/modules/hall-ui/public/update-pill.js +6 -31
- package/modules/status-ui/public/style.css +5 -2
- package/modules/ui-design/public/brand/png-sources.json +10 -0
- package/modules/ui-design/public/brand.css +6 -0
- package/modules/ui-design/public/brand.html +2 -2
- package/modules/ui-design/scripts/gen-design-reference.js +2 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-docs/index.html +3 -1
- package/release-notes.json +16 -0
- package/scripts/bongos/design-generators-fresh.js +14 -6
- package/scripts/bongos/gen-brand.js +82 -36
- package/scripts/bongos/provision-core-upgrade.js +30 -1
- package/scripts/bongos/provision-units.js +18 -0
- package/scripts/hall-preview/server.js +0 -3
- package/scripts/status-mirror-sync.js +4 -1
- package/src/bongos/routes/auth.js +4 -9
- package/src/bongos/routes/core-update.js +36 -76
- package/src/bongos/routes/feedback.js +1 -2
- package/src/bongos/serve-internal.js +5 -70
- package/src/bongos/sign-in-pages.js +20 -4
- package/src/brand-mark.js +10 -1
- package/src/error-page.js +11 -10
- package/src/module-api.js +1 -1
- package/src/theme-markup.js +81 -0
- package/tests/brand_mark.mjs +11 -1
- package/tests/brand_surfaces.mjs +5 -2
- package/tests/deploy_handoff.mjs +23 -2
- package/tests/deploy_page_projects.mjs +35 -3
- package/tests/design_counts_ratchet.mjs +38 -0
- package/tests/design_reference.mjs +1 -1
- package/tests/provision_settings_apply.mjs +52 -0
- package/tests/software_update.mjs +35 -5
- package/tests/update_pill.mjs +9 -5
- package/tests/wordmark_and_faces.mjs +111 -0
- package/src/bongos/upgrade-request.js +0 -158
- package/tests/upgrade_request.mjs +0 -348
package/tests/deploy_handoff.mjs
CHANGED
|
@@ -85,7 +85,7 @@ afterEach(() => { for (const id of armed) clearTimeout(id); armed.clear(); });
|
|
|
85
85
|
const settle = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
|
86
86
|
const plain = (x) => JSON.parse(JSON.stringify(x));
|
|
87
87
|
|
|
88
|
-
function boot({ opener = { postMessage(msg, origin) { told.push({ msg, origin }); } }, preview = PREVIEW, post = { ok: true, status: 200, data: { ok: true } }, rows = [{ id: '14', slug: 'toast', domain: 'toast.cloudbongos.com' }] } = {}) {
|
|
88
|
+
function boot({ opener = { postMessage(msg, origin) { told.push({ msg, origin }); } }, preview = PREVIEW, post = { ok: true, status: 200, data: { ok: true } }, rows = [{ id: '14', slug: 'toast', domain: 'toast.cloudbongos.com' }], search = '?project=toast&handoff=1' } = {}) {
|
|
89
89
|
const told = [];
|
|
90
90
|
const requests = [];
|
|
91
91
|
const listeners = {};
|
|
@@ -105,7 +105,7 @@ function boot({ opener = { postMessage(msg, origin) { told.push({ msg, origin })
|
|
|
105
105
|
if (opener) opener.postMessage = (msg, origin) => told.push({ msg, origin });
|
|
106
106
|
const windowObj = {
|
|
107
107
|
OTB: realOTB(), document: documentObj, opener, __handoffHearMs: 30,
|
|
108
|
-
location: { protocol: 'https:', pathname: '/deploy', search
|
|
108
|
+
location: { protocol: 'https:', pathname: '/deploy', search, assign() {} },
|
|
109
109
|
addEventListener: (ev, fn) => { (listeners[ev] ||= []).push(fn); },
|
|
110
110
|
setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); armed.add(id); return id; },
|
|
111
111
|
close() { closed = true; },
|
|
@@ -150,6 +150,27 @@ test('the go from the project\'s own hall moves it to that version, says so, and
|
|
|
150
150
|
assert.match(b.body.innerHTML, /The update has started/);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
// task 1005128: a hosted project whose server never learned its slug sends ?host=<address>.
|
|
154
|
+
test('?host= finds the project by its registered address, among the visitor\'s own, and moves it the same way', async () => {
|
|
155
|
+
const rows = [{ id: '9', slug: 'other', domain: 'other.cloudbongos.com' }, { id: '14', slug: 'toast', domain: 'Toast.CloudBongos.com' }];
|
|
156
|
+
const b = boot({ rows, search: '?host=toast.cloudbongos.com&handoff=1' });
|
|
157
|
+
await settle(5);
|
|
158
|
+
assert.deepEqual(plain(b.told), [{ msg: { tag: 'bongos-update', step: 'ready' }, origin: 'https://toast.cloudbongos.com' }]);
|
|
159
|
+
b.message(GO);
|
|
160
|
+
await settle(5);
|
|
161
|
+
assert.equal(b.posts().length, 1);
|
|
162
|
+
assert.equal(b.posts()[0].path, '/api/bongos/provisioning/instances/14/core-upgrade');
|
|
163
|
+
|
|
164
|
+
const none = boot({ rows, search: '?host=nobody.cloudbongos.com&handoff=1' });
|
|
165
|
+
await settle(5);
|
|
166
|
+
assert.equal(none.posts().length, 0);
|
|
167
|
+
assert.match(none.body.innerHTML, /No project called nobody\.cloudbongos\.com/);
|
|
168
|
+
|
|
169
|
+
const slugIsNotHost = boot({ rows, search: '?host=toast&handoff=1' });
|
|
170
|
+
await settle(5);
|
|
171
|
+
assert.equal(slugIsNotHost.told.length, 0, 'a bare slug in ?host= matches no address');
|
|
172
|
+
});
|
|
173
|
+
|
|
153
174
|
test('a go from another site, or another window, moves nothing', async () => {
|
|
154
175
|
const b = boot();
|
|
155
176
|
await settle(5);
|
|
@@ -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();
|
|
@@ -99,6 +99,8 @@ const GENERATOR_SOURCES = [
|
|
|
99
99
|
'modules/hall-ui/public/controls.css',
|
|
100
100
|
'modules/hall-ui/public/dom-utils.js',
|
|
101
101
|
'scripts/bongos/gen-brand.js',
|
|
102
|
+
'src/brand-mark.js',
|
|
103
|
+
'src/html-escape.js',
|
|
102
104
|
'DESIGN.md',
|
|
103
105
|
];
|
|
104
106
|
|
|
@@ -132,3 +134,39 @@ test('the freshness gate passes on a fresh tree and fails when the token sheet c
|
|
|
132
134
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
133
135
|
}
|
|
134
136
|
});
|
|
137
|
+
|
|
138
|
+
// task 1005133: the logo set joins the gate. Editing the one source (mark.svg) without
|
|
139
|
+
// running gen-brand.js fails it; so does a PNG whose source page moved (the pack's ground)
|
|
140
|
+
// or a PNG swapped by hand — the PNGs are checked by hash, not just their pixel size.
|
|
141
|
+
test('the freshness gate covers the logo set: mark.svg, the pack and both PNGs', () => {
|
|
142
|
+
const dir = copiedTree();
|
|
143
|
+
const brand = path.join(dir, 'modules/ui-design/public/brand');
|
|
144
|
+
const gate = () => checkDesignGeneratorsFresh(dir).filter((x) => x.startsWith('the logo set'));
|
|
145
|
+
try {
|
|
146
|
+
assert.deepEqual(gate(), [], 'the copied logo set should start fresh');
|
|
147
|
+
|
|
148
|
+
const mark = path.join(brand, 'mark.svg');
|
|
149
|
+
const src = fs.readFileSync(mark, 'utf8');
|
|
150
|
+
const moved = src.replace(/(id="mark-dot"[^>]*?\br=")(\d+)/, (_, a, r) => a + (Number(r) + 1));
|
|
151
|
+
assert.notEqual(moved, src, 'mark.svg should carry the moon\'s radius');
|
|
152
|
+
fs.writeFileSync(mark, moved);
|
|
153
|
+
assert.ok(gate().some((x) => /mark-light\.svg/.test(x)), `editing mark.svg must fail the gate: ${gate().join('\n')}`);
|
|
154
|
+
fs.writeFileSync(mark, src);
|
|
155
|
+
assert.deepEqual(gate(), [], 'restoring mark.svg restores the gate');
|
|
156
|
+
|
|
157
|
+
const pngFile = path.join(brand, 'og-card.png');
|
|
158
|
+
const png = fs.readFileSync(pngFile);
|
|
159
|
+
const swapped = Buffer.from(png); swapped[swapped.length - 20] ^= 0xff; // same size, other bytes
|
|
160
|
+
fs.writeFileSync(pngFile, swapped);
|
|
161
|
+
assert.ok(gate().some((x) => /og-card\.png \(the file is not the one --png wrote\)/.test(x)), gate().join('\n'));
|
|
162
|
+
fs.writeFileSync(pngFile, png);
|
|
163
|
+
|
|
164
|
+
const packFile = path.join(dir, 'config/branding.neutral.json');
|
|
165
|
+
const pack = JSON.parse(fs.readFileSync(packFile, 'utf8'));
|
|
166
|
+
pack.theme.ui.colors.bg = '#edeced';
|
|
167
|
+
fs.writeFileSync(packFile, JSON.stringify(pack, null, 2));
|
|
168
|
+
assert.ok(gate().some((x) => /apple-touch-icon\.png \(its source page changed/.test(x)), gate().join('\n'));
|
|
169
|
+
} finally {
|
|
170
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
@@ -88,7 +88,7 @@ test('the shared components, the logo and the naming rule are on the page', () =
|
|
|
88
88
|
}
|
|
89
89
|
assert.equal((PAGE.match(/data-ref="tones"[^>]*>(.*?)<\/div>/) || [])[1].match(/status-pill--/g).length, 6, 'six tones');
|
|
90
90
|
assert.match(PAGE, /<use href="\/modules\/ui-design\/public\/brand\/mark\.svg#mark"\/>/, 'the mark is drawn from its one source');
|
|
91
|
-
assert.match(PAGE, /<span class="ref-lockup__name"
|
|
91
|
+
assert.match(PAGE, /<span class="ref-lockup__name">\{\{productName\}\}<\/span>/, 'the wordmark is the pack\'s name, stamped at serve time (task 1005135)');
|
|
92
92
|
assert.match(PAGE, /Never "CloudBongos"/, 'the naming rule is read from DESIGN.md');
|
|
93
93
|
for (const f of ['mark-light.svg', 'mark-dark.svg', 'mark-mono.svg', 'favicon.svg']) assert.ok(PAGE.includes(`brand/${f}"`), `lists ${f}`);
|
|
94
94
|
});
|
|
@@ -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
|
-
|
|
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]
|
|
604
|
-
|
|
605
|
-
assert.
|
|
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)', () => {
|
package/tests/update_pill.mjs
CHANGED
|
@@ -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),
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// tests/wordmark_and_faces.mjs — the logo wordmark is the pack's name in lowercase, and
|
|
2
|
+
// no served page loads a retired face (task 1005135, goal 1000138).
|
|
3
|
+
//
|
|
4
|
+
// DESIGN.md "Naming": beside the mark the name is lowercase ("cloud bongos"), and
|
|
5
|
+
// platform code never hardcodes either spelling; it reads the pack. The UI check found
|
|
6
|
+
// the status page, the 404/500 pages and the API docs showing "Cloud Bongos" beside the
|
|
7
|
+
// mark, and /brand baking the neutral pack's name into its generated HTML. The sign-in
|
|
8
|
+
// error and confirming-access pages still loaded Cinzel and EB Garamond. This pins:
|
|
9
|
+
// 1. every wordmark beside the mark is the {{productName}} hole (stamped from the pack
|
|
10
|
+
// at serve time), never a typed-in name, and the rule that styles it lowercases it;
|
|
11
|
+
// 2. no served page source (the auth pages, the sign-in shell, the error page, the API
|
|
12
|
+
// docs, the status page, the hall, the landing) names Cinzel or Garamond, and the
|
|
13
|
+
// rendered auth pages carry the pack's own font links instead;
|
|
14
|
+
// 3. the error page declares no colour of its own: it reads the hall sheet's tier.
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
25
|
+
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/<!--[\s\S]*?-->/g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
|
|
26
|
+
|
|
27
|
+
const { errorPageTemplate } = require('../src/error-page.js');
|
|
28
|
+
const { clientBranding } = require('../src/branding.js');
|
|
29
|
+
const authRoutes = require('../src/bongos/routes/auth.js');
|
|
30
|
+
const signIn = require('../src/bongos/sign-in-pages.js');
|
|
31
|
+
|
|
32
|
+
// The CSS rule for a selector, from a sheet or an inline <style>. Throws when missing.
|
|
33
|
+
function ruleOf(css, selector) {
|
|
34
|
+
const esc = selector.replace(/[.*+?^${}()|[\]\\#]/g, '\\$&');
|
|
35
|
+
const m = stripComments(css).match(new RegExp(`(?:^|[}\\s,])${esc}\\s*\\{([^}]*)\\}`));
|
|
36
|
+
assert.ok(m, `a rule for ${selector}`);
|
|
37
|
+
return m[1];
|
|
38
|
+
}
|
|
39
|
+
const lowercased = (body) => /text-transform\s*:\s*lowercase/.test(body);
|
|
40
|
+
|
|
41
|
+
// Each wordmark beside the mark: [where, the markup that must carry the pack's hole, the
|
|
42
|
+
// sheet that styles it, the selector of its rule].
|
|
43
|
+
const WORDMARKS = [
|
|
44
|
+
['the status page', read('modules/status-ui/public/index.html'), /<span class="topbar__name">\{\{productName\}\}<\/span>/, read('modules/status-ui/public/style.css'), '.topbar__name'],
|
|
45
|
+
['the 404 page', errorPageTemplate(404), /<a class="name" href="\/">\{\{productName\}\}<\/a>/, errorPageTemplate(404), '.name'],
|
|
46
|
+
['the 500 page', errorPageTemplate(500), /<a class="name" href="\/">\{\{productName\}\}<\/a>/, errorPageTemplate(500), '.name'],
|
|
47
|
+
['the API docs', read('public-docs/index.html'), /<span class="wordmark">\{\{productName\}\}<\/span>/, read('public-docs/index.html'), '#topbar .wordmark'],
|
|
48
|
+
['/brand (bar)', read('modules/ui-design/public/brand.html'), /<a class="ref-head__brand"[^>]*>.*<span>\{\{productName\}\}<\/span><\/a>/, read('modules/ui-design/public/brand.css'), '.ref-head__brand'],
|
|
49
|
+
['/brand (lockup)', read('modules/ui-design/public/brand.html'), /<span class="ref-lockup__name">\{\{productName\}\}<\/span>/, read('modules/ui-design/public/brand.css'), '.ref-lockup'],
|
|
50
|
+
['the hall', read('modules/hall-ui/public/style.css'), /\.brand-mark\s*\{/, read('modules/hall-ui/public/style.css'), '.brand-mark'],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
test('every wordmark beside the mark is the pack\'s name, lowercased by its rule', () => {
|
|
54
|
+
for (const [where, markup, hole, css, selector] of WORDMARKS) {
|
|
55
|
+
assert.match(markup, hole, `${where}: the wordmark is the {{productName}} hole`);
|
|
56
|
+
assert.ok(lowercased(ruleOf(css, selector)), `${where}: ${selector} sets text-transform: lowercase`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('no typed-in product name beside the mark', () => {
|
|
61
|
+
// A capitalised (or any) literal in a wordmark element is exactly the regression.
|
|
62
|
+
const literal = /class="(?:topbar__name|name|wordmark|ref-lockup__name)"[^>]*>(?!\{\{)[^<]+</;
|
|
63
|
+
for (const [where, markup] of WORDMARKS) assert.doesNotMatch(markup, literal, `${where}: no literal name in the wordmark`);
|
|
64
|
+
assert.doesNotMatch(read('modules/ui-design/public/brand.html'), /ref-head__brand[^>]*>.*<span>(?!\{\{)[^<]+<\/span><\/a>/, '/brand bar: no literal name');
|
|
65
|
+
// the generator writes the hole, never the pack's name it happened to read
|
|
66
|
+
assert.doesNotMatch(read('modules/ui-design/scripts/gen-design-reference.js'), /productName\)?\.toLowerCase\(\)|product\.toLowerCase\(\)/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const RETIRED = /Cinzel|Garamond/i;
|
|
70
|
+
const SERVED = [
|
|
71
|
+
'src/bongos/routes/auth.js', 'src/bongos/sign-in-pages.js', 'src/bongos/routes/auth-google.js', 'src/error-page.js',
|
|
72
|
+
'public-docs/index.html', 'modules/status-ui/public/index.html', 'modules/status-ui/public/style.css',
|
|
73
|
+
'modules/ui-design/public/brand.html', 'modules/ui-design/public/brand.css',
|
|
74
|
+
'modules/hall-ui/public/style.css', 'modules/hall-ui/public/controls.css',
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
test('no served page source loads a retired face', () => {
|
|
78
|
+
for (const rel of SERVED) {
|
|
79
|
+
if (!fs.existsSync(path.join(ROOT, rel))) continue;
|
|
80
|
+
// read raw: a comment stripper over JS eats template strings (a `/*` in a URL glob),
|
|
81
|
+
// so these files name the retired faces nowhere at all, comments included
|
|
82
|
+
assert.doesNotMatch(read(rel), RETIRED, `${rel} names a retired face`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the auth pages carry the pack\'s faces and the shared sheet, and nothing else', () => {
|
|
87
|
+
const links = (clientBranding().theme && clientBranding().theme.fonts && clientBranding().theme.fonts.links) || [];
|
|
88
|
+
const pages = [
|
|
89
|
+
['sign-in error', authRoutes.renderAuthErrorPage('Sign-in failed', 'The sign-in did not complete.')],
|
|
90
|
+
['confirming access', authRoutes.renderAccessPendingPage('someone', '/api/bongos/auth/web/start')],
|
|
91
|
+
['sign-in chooser', signIn.renderSignInChooser({ githubHref: '/g', googleHref: '/o' })],
|
|
92
|
+
];
|
|
93
|
+
for (const [name, html] of pages) {
|
|
94
|
+
assert.ok(html.includes('<link rel="stylesheet" href="/builders/style.css">'), `${name}: the hall sheet`);
|
|
95
|
+
for (const href of links) assert.ok(html.includes(href.replace(/&/g, '&')), `${name}: the pack's font link ${href}`);
|
|
96
|
+
const own = html.match(/https:\/\/fonts\.googleapis\.com\/css2\?[^"]*/g) || [];
|
|
97
|
+
for (const href of own) assert.ok(links.some((l) => l.replace(/&/g, '&') === href || l === href), `${name}: ${href} is not the pack's`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('the error page declares no colour of its own', () => {
|
|
102
|
+
for (const status of [404, 500]) {
|
|
103
|
+
const html = errorPageTemplate(status);
|
|
104
|
+
const style = (html.match(/<style>([\s\S]*?)<\/style>/) || [])[1] || '';
|
|
105
|
+
assert.ok(style, 'the page has its layout rules');
|
|
106
|
+
const declarations = style.replace(/--[\w-]+\s*:[^;}]*/g, '');
|
|
107
|
+
assert.doesNotMatch(style, /--(bg|ink|accent|rule)[\w-]*\s*:/, 'no colour token is defined on the page');
|
|
108
|
+
assert.doesNotMatch(declarations, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'no typed-in colour');
|
|
109
|
+
assert.ok(html.includes('<link rel="stylesheet" href="/modules/hall-ui/public/style.css">'), 'the colours come from the shared sheet');
|
|
110
|
+
}
|
|
111
|
+
});
|
|
@@ -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
|
-
};
|