@bongos/core 1.19.1041 → 1.19.1043
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 +127 -87
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +14 -0
- package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +1 -0
- package/docs/adr/0340-every-hall-takes-its-update-from-settings-and-deploy-is-the-platform-halls-page.md +64 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +109 -7
- package/docs/api-reference.md +9 -2
- package/docs/copy-inventory.md +79 -59
- package/docs/copy-registry.json +248 -68
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +12 -0
- package/docs/modules-contract.md +4 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/palette.js +1 -0
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-software-update.js +194 -0
- package/modules/hall-ui/public/settings.css +39 -0
- package/modules/hall-ui/public/settings.html +24 -2
- package/modules/hall-ui/public/settings.js +5 -0
- package/modules/hall-ui/public/settings.states.json +2 -1
- package/modules/hall-ui/public/shell.js +25 -39
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +4 -0
- package/modules/hall-ui/public/task.css +6 -0
- package/modules/hall-ui/public/task.html +2 -2
- package/modules/hall-ui/public/task.js +17 -0
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/panel-family.md +9 -0
- package/modules/hall-ui/records/record-family.md +2 -0
- package/modules/npm-release/module.json +8 -2
- package/modules/npm-release/public/task-widget.js +79 -0
- package/modules/npm-release/public/work.js +8 -3
- package/modules/npm-release/routes/task-where.js +48 -0
- package/modules/npm-release/work.js +78 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/main-audit.js +204 -7
- package/scripts/gds/seam-wiring-guard.js +4 -2
- package/scripts/hall-preview/server.js +14 -1
- package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
- package/src/bongos/routes/core-update.js +65 -3
- package/src/bongos/serve-internal.js +35 -4
- package/src/bongos/software-update.js +127 -0
- package/src/module-api.js +10 -1
- package/src/module-loader/loader.js +13 -3
- package/tests/core_upgrade_owner_door.mjs +27 -18
- package/tests/hall_record_world.mjs +23 -1
- package/tests/helpers.mjs +92 -0
- package/tests/main_audit_attestation.mjs +238 -0
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +27 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/npm_release_page.mjs +11 -1
- package/tests/npm_release_where.mjs +323 -0
- package/tests/npm_release_work.mjs +9 -94
- package/tests/software_update.mjs +449 -0
|
@@ -17,11 +17,21 @@
|
|
|
17
17
|
// press the button is a worse experience than showing nothing.
|
|
18
18
|
// The gate costs its real audience nothing: an owner holds the atom on their own
|
|
19
19
|
// instance by construction.
|
|
20
|
+
//
|
|
21
|
+
// GET /software-update (task 1004296) is the exception, and deliberately so: it is the
|
|
22
|
+
// Settings panel's read, and the owner asked for the version and what's new to be shown in
|
|
23
|
+
// EVERY builder's Settings, like a phone's Software Update screen. It discloses nothing the
|
|
24
|
+
// public GET /version does not already publish (the running core) or npm does not (what is
|
|
25
|
+
// newer, and its release notes), so the vulnerability-window reason above does not apply to
|
|
26
|
+
// it — and it offers no act itself. Who may press Update is still the door's decision; this
|
|
27
|
+
// only says, cosmetically, whether the viewer holds the atom the door checks.
|
|
20
28
|
|
|
21
29
|
const express = require('express');
|
|
22
30
|
const { asyncHandler } = require('./_helpers');
|
|
23
31
|
const auth = require('../auth');
|
|
24
32
|
const { readCoreUpdate, readCoreUpgrades } = require('../core-update');
|
|
33
|
+
const { readSoftwareUpdate } = require('../software-update');
|
|
34
|
+
const seams = require('../../module-seams');
|
|
25
35
|
const { loadIdpConfig } = require('../auth-config');
|
|
26
36
|
|
|
27
37
|
// WHERE A HOSTED PROJECT'S OWNER TAKES THE UPGRADE (task 1004174, ADR 0339): the hub's
|
|
@@ -30,7 +40,9 @@ const { loadIdpConfig } = require('../auth-config');
|
|
|
30
40
|
// no capability. Both halves are already here on a federated project: provisioning
|
|
31
41
|
// writes the hub origin and this project's hub client id, which IS its slug (ADR 0246),
|
|
32
42
|
// into web.env. null when this instance is not federated with a hub — a self-hosted
|
|
33
|
-
// instance, or the hub itself — so the hall offers no link rather than a wrong one.
|
|
43
|
+
// instance, or the hub itself — so the hall offers no link rather than a wrong one. Since
|
|
44
|
+
// task 1004296 it is the Settings panel's Update button that carries it (updateDoor below),
|
|
45
|
+
// not the banner or the rail.
|
|
34
46
|
// Only the origin and the client id are read; the client secret never leaves auth-config.
|
|
35
47
|
function hubDeployUrl(config) {
|
|
36
48
|
if (!config || typeof config.origin !== 'string' || !config.clientId) return null;
|
|
@@ -38,6 +50,35 @@ function hubDeployUrl(config) {
|
|
|
38
50
|
return `${config.origin.replace(/\/+$/, '')}/deploy?project=${encodeURIComponent(String(config.clientId))}`;
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
// WHERE THIS PROJECT'S UPDATE BUTTON GOES (task 1004296). The act itself never happens in
|
|
54
|
+
// Settings: it is the deploy door's two-step confirm and auto-rollback (ADR 0293), and that
|
|
55
|
+
// door is the provisioning module's — so only a hall that runs provisioning has one.
|
|
56
|
+
// local — this hall runs the door: its own /deploy.
|
|
57
|
+
// hub — a hosted project: the hub's door for this project (ADR 0339 D3).
|
|
58
|
+
// cli — neither: a self-hosted instance, upgraded on its server with `bongos upgrade`.
|
|
59
|
+
function updateDoor({ provisioning, idp }) {
|
|
60
|
+
if (provisioning) return { kind: 'local', url: '/deploy' };
|
|
61
|
+
const url = hubDeployUrl(idp);
|
|
62
|
+
return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function provisioningEnabled() {
|
|
66
|
+
try { return require('../../modules').isModuleEnabled('provisioning'); } catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Does the viewer hold `core.pin.move`? COSMETIC — it decides whether the panel draws the
|
|
70
|
+
// Update button, never whether an update may happen; the door re-checks per request. The
|
|
71
|
+
// same read GET /me's nav permissions make, and the same fail-closed reading: authority that
|
|
72
|
+
// cannot be established is "no".
|
|
73
|
+
async function holdsCorePin(req) {
|
|
74
|
+
try {
|
|
75
|
+
const government = seams.resolveOptional('government');
|
|
76
|
+
if (!government || typeof government.resolveBuilderPermissions !== 'function') return false;
|
|
77
|
+
const held = (await government.resolveBuilderPermissions(req.builder && req.builder.id)) || [];
|
|
78
|
+
return held.includes('core.pin.move');
|
|
79
|
+
} catch { return false; }
|
|
80
|
+
}
|
|
81
|
+
|
|
41
82
|
function safeIdpConfig() {
|
|
42
83
|
try { return loadIdpConfig(); } catch { return null; }
|
|
43
84
|
}
|
|
@@ -49,7 +90,12 @@ function upgradesLimit(raw) {
|
|
|
49
90
|
return Math.min(100, Math.max(1, n));
|
|
50
91
|
}
|
|
51
92
|
|
|
52
|
-
function buildCoreUpdateRouter({
|
|
93
|
+
function buildCoreUpdateRouter({
|
|
94
|
+
upgrades = readCoreUpgrades,
|
|
95
|
+
softwareUpdate = readSoftwareUpdate,
|
|
96
|
+
canUpdate = holdsCorePin,
|
|
97
|
+
doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig() }),
|
|
98
|
+
} = {}) {
|
|
53
99
|
const router = express.Router();
|
|
54
100
|
|
|
55
101
|
router.get(
|
|
@@ -67,7 +113,9 @@ function buildCoreUpdateRouter({ upgrades = readCoreUpgrades } = {}) {
|
|
|
67
113
|
// that cannot name its own core reports `no_answer` and renders no banner.
|
|
68
114
|
let running = null;
|
|
69
115
|
try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
|
|
70
|
-
|
|
116
|
+
// No `deploy_url` any more (task 1004296): the banner on a hall without a door of its
|
|
117
|
+
// own links to Settings → Software update, and the hand-off lives on GET /software-update.
|
|
118
|
+
res.json({ ok: true, update: await readCoreUpdate({ running }) });
|
|
71
119
|
}, { errorCode: 'core_update_failed', message: false }),
|
|
72
120
|
);
|
|
73
121
|
|
|
@@ -89,6 +137,20 @@ function buildCoreUpdateRouter({ upgrades = readCoreUpgrades } = {}) {
|
|
|
89
137
|
}, { errorCode: 'core_upgrades_unreadable', message: false }),
|
|
90
138
|
);
|
|
91
139
|
|
|
140
|
+
// GET /software-update — Settings → Software update (task 1004296): this project's core,
|
|
141
|
+
// the newest release its channel allows, what the versions between them carry, and where
|
|
142
|
+
// this project's Update button leads. Any signed-in builder (see the header).
|
|
143
|
+
router.get(
|
|
144
|
+
'/software-update',
|
|
145
|
+
auth.requireBuilder,
|
|
146
|
+
asyncHandler('GET /software-update', async (req, res) => {
|
|
147
|
+
let running = null;
|
|
148
|
+
try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
|
|
149
|
+
const [update, can] = await Promise.all([softwareUpdate({ running }), canUpdate(req)]);
|
|
150
|
+
res.json({ ok: true, update, door: updateDoor(doorInputs()), can_update: can === true });
|
|
151
|
+
}, { errorCode: 'software_update_failed', message: false }),
|
|
152
|
+
);
|
|
153
|
+
|
|
92
154
|
return router;
|
|
93
155
|
}
|
|
94
156
|
|
|
@@ -443,14 +443,17 @@ function clientBundleScriptTag() {
|
|
|
443
443
|
// module's widget is never injected (it vanishes); contributeHallWidget is the second
|
|
444
444
|
// gate (it no-ops a widget whose module is off). `scripts` is the loader projection
|
|
445
445
|
// [{ key, src }]; default reads the live enabled set. Pure + exported for the test.
|
|
446
|
-
function injectModuleHallWidgets(html, scripts = hallWidgetScripts({ isEnabled: isModuleEnabled })) {
|
|
446
|
+
function injectModuleHallWidgets(html, scripts = hallWidgetScripts({ isEnabled: isModuleEnabled }), consumer = 'builders') {
|
|
447
447
|
if (!Array.isArray(scripts) || scripts.length === 0) return html;
|
|
448
448
|
const tags = scripts
|
|
449
449
|
.map((s) => `<script src="${s.src}" defer></script>`)
|
|
450
450
|
.join('');
|
|
451
|
-
// Anchor on
|
|
452
|
-
//
|
|
453
|
-
|
|
451
|
+
// Anchor on the consuming page script's <script> (builders.js on the index, task.js on
|
|
452
|
+
// a task's page — task 1004301) regardless of its ?v= stamp, so the module widgets
|
|
453
|
+
// always land immediately before the consumer: both are `defer`, so they run in
|
|
454
|
+
// document order, and a widget's listener exists before the page first announces.
|
|
455
|
+
const name = /^[a-z-]+$/.test(consumer) ? consumer : 'builders';
|
|
456
|
+
const anchor = html.match(new RegExp(`<script src="\\/builders\\/${name}\\.js[^"]*"[^>]*><\\/script>`));
|
|
454
457
|
if (anchor) return html.replace(anchor[0], tags + anchor[0]);
|
|
455
458
|
return html.includes('</body>') ? html.replace('</body>', `${tags}</body>`) : html + tags;
|
|
456
459
|
}
|
|
@@ -975,6 +978,23 @@ function mountInternalSurfaces(app) {
|
|
|
975
978
|
return res.redirect(301, `${isBuildersHost(req) ? '' : '/builders'}/people${qs}`);
|
|
976
979
|
});
|
|
977
980
|
|
|
981
|
+
// /deploy EXISTS ONLY WHERE THE DOOR DOES (task 1004296). The owner, 2026-09-25: /deploy is
|
|
982
|
+
// "only for bongos builders hall itself, not for all production ones". Every datum and
|
|
983
|
+
// both acts on that page are provisioning-module routes, so a hall that does not run the
|
|
984
|
+
// module — a hosted project, a self-hosted instance — was serving a page with nothing
|
|
985
|
+
// behind it. It now answers with Settings → Software update, where every hall reads its
|
|
986
|
+
// version and takes its update. Like the /scouting redirect above it carries no gate: the
|
|
987
|
+
// redirect discloses nothing, and Settings' own wall applies the moment it is followed.
|
|
988
|
+
app.use((req, res, next) => {
|
|
989
|
+
if (isModuleSurfaceHost(req)) return next();
|
|
990
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
991
|
+
if (!DEPLOY_PAGE_RE.test(path.posix.normalize(req.path))) return next();
|
|
992
|
+
let door = false;
|
|
993
|
+
try { door = isModuleEnabled('provisioning'); } catch { door = false; }
|
|
994
|
+
if (door) return next();
|
|
995
|
+
return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/settings#software-update`);
|
|
996
|
+
});
|
|
997
|
+
|
|
978
998
|
app.use((req, res, next) => {
|
|
979
999
|
if (isModuleSurfaceHost(req)) return next();
|
|
980
1000
|
if (!req.path.startsWith('/builders')) return next();
|
|
@@ -1053,6 +1073,17 @@ function mountInternalSurfaces(app) {
|
|
|
1053
1073
|
if (buildersHtml['index.html']) {
|
|
1054
1074
|
buildersHtml['index.html'] = injectModuleHallWidgets(buildersHtml['index.html']);
|
|
1055
1075
|
}
|
|
1076
|
+
// task 1004301: the same seam for a task's own page. A module that ships
|
|
1077
|
+
// public/task-widget.js is loaded just before task.js, and fills the slot task.js
|
|
1078
|
+
// renders and announces (`otb:task-shown`). The page names no module; with none
|
|
1079
|
+
// enabled, nothing is injected and the slot stays empty.
|
|
1080
|
+
if (buildersHtml['task.html']) {
|
|
1081
|
+
buildersHtml['task.html'] = injectModuleHallWidgets(
|
|
1082
|
+
buildersHtml['task.html'],
|
|
1083
|
+
hallWidgetScripts({ isEnabled: isModuleEnabled, file: 'task-widget.js' }),
|
|
1084
|
+
'task',
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1056
1087
|
|
|
1057
1088
|
// Serve enabled modules' browser assets at /modules/<key>/public/* — the URL the
|
|
1058
1089
|
// loader's hallWidgetScripts projection points at. Mounted BEFORE the /builders
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/software-update.js — Settings → Software update (task 1004296): this project's
|
|
4
|
+
// running core, the newest release its update channel lets it take, and what is in the
|
|
5
|
+
// versions between the two.
|
|
6
|
+
//
|
|
7
|
+
// The owner, 2026-09-25: "each builders hall settings has a version description where they
|
|
8
|
+
// can pull the latest release. Just like iOS does." So this is the one place any project —
|
|
9
|
+
// the platform included — reads its version and what an update would bring. It is the CORE's,
|
|
10
|
+
// not a module's, because every project installs releases.
|
|
11
|
+
//
|
|
12
|
+
// WHAT'S NEW COMES FROM THE PACKAGE'S OWN RELEASE NOTES, never from the changelog. Every CI
|
|
13
|
+
// version's changelog line reads "carrier for merges landed since X", which names no work;
|
|
14
|
+
// release-notes.json (scripts/gds/release-notes.js, task 1004218) is the one per-version
|
|
15
|
+
// list of what landed, and the newest package carries the whole history. The running core's
|
|
16
|
+
// own copy stops at its own version, so an update's notes always come off the registry
|
|
17
|
+
// (package-registry.js: streamed, cached, fail-soft).
|
|
18
|
+
//
|
|
19
|
+
// THE CHANNEL RULE IS NOT RESTATED HERE, as in core-update.js: `resolveChannelTarget` is the
|
|
20
|
+
// upgrade runner's own function, so this never offers a version the runner would refuse.
|
|
21
|
+
// No channel is passed, exactly as the banner passes none: an instance does not store its
|
|
22
|
+
// own channel, and the runner's default is what it would get.
|
|
23
|
+
//
|
|
24
|
+
// A READING, NEVER A GUESS. `reading` is 'ok' only when the registry answered; "up to date"
|
|
25
|
+
// is `offered: null` on an ok reading and nothing else. Notes that could not be read say so,
|
|
26
|
+
// and a version whose notes are missing is shown as missing rather than as empty.
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
CORE_PKG,
|
|
30
|
+
DEFAULT_CHANNEL,
|
|
31
|
+
normalizeChannel,
|
|
32
|
+
resolveChannelTarget,
|
|
33
|
+
compareSemver,
|
|
34
|
+
} = require('../../scripts/gds/update-channel.js');
|
|
35
|
+
const { readRegistry } = require('./package-registry');
|
|
36
|
+
|
|
37
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
38
|
+
// The same bound the deploy page puts on a line: a release note is a sentence, and one that
|
|
39
|
+
// came off the network is re-checked before it is drawn.
|
|
40
|
+
const TEXT_MAX = 400;
|
|
41
|
+
// A project months behind can be hundreds of versions short. The panel lists the newest
|
|
42
|
+
// ones and counts the rest; every one of them is still in the update.
|
|
43
|
+
const VERSIONS_MAX = 40;
|
|
44
|
+
|
|
45
|
+
function boundText(text) {
|
|
46
|
+
const t = String(text || '').trim();
|
|
47
|
+
return t.length > TEXT_MAX ? `${t.slice(0, TEXT_MAX - 1)}…` : t;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One version's entries, re-checked; null when the notes do not list the version at all. */
|
|
51
|
+
function entriesFor(notes, version) {
|
|
52
|
+
const list = notes && Object.prototype.hasOwnProperty.call(notes, version) ? notes[version] : undefined;
|
|
53
|
+
if (!Array.isArray(list)) return null;
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const e of list) {
|
|
56
|
+
const text = boundText(e && e.text);
|
|
57
|
+
if (!text) continue;
|
|
58
|
+
const id = e && /^\d{1,12}$/.test(String(e.id == null ? '' : e.id)) ? String(e.id) : null;
|
|
59
|
+
out.push({ id, text });
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The panel's whole answer, from one registry reading. PURE — no clock, no I/O.
|
|
66
|
+
*
|
|
67
|
+
* `registry` and `notes` are package-registry.js's `readRegistry` result. Versions in the
|
|
68
|
+
* update are the published ones above `running` and at or below `offered`, newest first.
|
|
69
|
+
*/
|
|
70
|
+
function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt }) {
|
|
71
|
+
const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
|
|
72
|
+
const base = { checked_at: checkedAt, running: running || null, channel: ch };
|
|
73
|
+
if (!running || !VERSION_RE.test(String(running))) {
|
|
74
|
+
return { ...base, reading: 'no_answer', error: 'this project could not name the version it runs', offered: null, newest: null, whats_new: null };
|
|
75
|
+
}
|
|
76
|
+
if (!registry || !registry.ok) {
|
|
77
|
+
return { ...base, reading: 'no_answer', error: (registry && registry.error) || 'the registry could not be read', offered: null, newest: null, whats_new: null };
|
|
78
|
+
}
|
|
79
|
+
const offered = resolveChannelTarget({ installed: running, channel: ch, available: registry.versions }) || null;
|
|
80
|
+
const published = registry.published || {};
|
|
81
|
+
const answer = {
|
|
82
|
+
...base,
|
|
83
|
+
reading: 'ok',
|
|
84
|
+
error: null,
|
|
85
|
+
offered,
|
|
86
|
+
offered_published_at: offered ? published[offered] || null : null,
|
|
87
|
+
newest: registry.newest || null,
|
|
88
|
+
whats_new: null,
|
|
89
|
+
};
|
|
90
|
+
if (!offered) return answer;
|
|
91
|
+
|
|
92
|
+
const inUpdate = registry.versions
|
|
93
|
+
.filter((v) => compareSemver(v, running) > 0 && compareSemver(v, offered) <= 0)
|
|
94
|
+
.sort((a, b) => compareSemver(b, a));
|
|
95
|
+
const shown = inUpdate.slice(0, VERSIONS_MAX);
|
|
96
|
+
const ok = !!(notes && notes.ok);
|
|
97
|
+
answer.whats_new = {
|
|
98
|
+
ok,
|
|
99
|
+
error: ok ? null : ((notes && notes.error) || 'the release notes could not be read'),
|
|
100
|
+
count: inUpdate.length,
|
|
101
|
+
more: inUpdate.length - shown.length,
|
|
102
|
+
versions: shown.map((version) => ({
|
|
103
|
+
version,
|
|
104
|
+
published_at: published[version] || null,
|
|
105
|
+
entries: ok ? entriesFor(notes.notes, version) : null,
|
|
106
|
+
})),
|
|
107
|
+
};
|
|
108
|
+
return answer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What GET /api/bongos/software-update answers — and this file's one export.
|
|
113
|
+
*
|
|
114
|
+
* `get`/`now`/`store` pass through to the registry reader, so a test drives the real
|
|
115
|
+
* packument parsing, the real tarball stream and the real cache rather than a stub.
|
|
116
|
+
*/
|
|
117
|
+
async function readSoftwareUpdate({ running, channel, get, now = Date.now, store } = {}) {
|
|
118
|
+
const t = now();
|
|
119
|
+
const { registry, notes } = await readRegistry({
|
|
120
|
+
pkg: CORE_PKG, get, now, store,
|
|
121
|
+
// Nothing to describe when nothing newer is published: skip the download.
|
|
122
|
+
haveNotesFor: (newest) => !VERSION_RE.test(String(running || '')) || compareSemver(newest, running) <= 0,
|
|
123
|
+
});
|
|
124
|
+
return softwareUpdateVerdict({ running, channel, registry, notes, checkedAt: new Date(t).toISOString() });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { readSoftwareUpdate };
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1043'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -494,6 +494,15 @@ module.exports = {
|
|
|
494
494
|
responsibilityFor,
|
|
495
495
|
ROLE_RESPONSIBILITIES,
|
|
496
496
|
|
|
497
|
+
// --- readPackageRegistry({ pkg, haveNotesFor, get, now, store }) — what the npm
|
|
498
|
+
// registry says about a package: its versions, their publish dates, the
|
|
499
|
+
// `latest` label, and the release notes the newest version was packed with
|
|
500
|
+
// (task 1004296). The core owns it because Settings → Software update needs
|
|
501
|
+
// it in every hall; a module that publishes a package reads the same answer
|
|
502
|
+
// and the same process-local cache through here. Fail-soft: it resolves
|
|
503
|
+
// `{ registry, notes }` with `ok:false` and a sentence, never a throw.
|
|
504
|
+
readPackageRegistry: (...a) => require('./bongos/package-registry').readRegistry(...a),
|
|
505
|
+
|
|
497
506
|
// --- logging
|
|
498
507
|
logger,
|
|
499
508
|
};
|
|
@@ -213,15 +213,25 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
|
|
|
213
213
|
// never injected), and contributeHallWidget itself no-ops a stale widget whose module
|
|
214
214
|
// is off. No-op (empty list) until a module ships a hall widget — same SAFE-BY-
|
|
215
215
|
// CONSTRUCTION posture as every other seam here. Returns [{ key, src }].
|
|
216
|
-
|
|
216
|
+
//
|
|
217
|
+
// ONE SEAM, ONE FILE PER PAGE (task 1004301). `file` names which page's widget is being
|
|
218
|
+
// asked for: hall-widget.js for the hall index (the default, and the only caller until
|
|
219
|
+
// then), task-widget.js for a task's own page. Same declaration (uiSections), same
|
|
220
|
+
// enabled-only asset mount, same "a disabled module simply is not there" — so a module
|
|
221
|
+
// adds a line to the task page without the page's own code ever naming it. The name is
|
|
222
|
+
// matched against PAGE_WIDGET_FILES rather than trusted, so no caller can turn this into
|
|
223
|
+
// a way to serve an arbitrary file.
|
|
224
|
+
const PAGE_WIDGET_FILES = Object.freeze(['hall-widget.js', 'task-widget.js']);
|
|
225
|
+
function hallWidgetScripts({ isEnabled, file = 'hall-widget.js' } = {}, opts) {
|
|
226
|
+
if (!PAGE_WIDGET_FILES.includes(file)) return [];
|
|
217
227
|
const out = [];
|
|
218
228
|
for (const { key, dir, manifest } of discovered(opts).loaded) {
|
|
219
229
|
if (typeof isEnabled === 'function' && !isEnabled(key)) continue;
|
|
220
230
|
const sections = (manifest.contributes && manifest.contributes.uiSections) || [];
|
|
221
231
|
if (sections.length === 0) continue;
|
|
222
|
-
const scriptPath = path.join(dir, 'public',
|
|
232
|
+
const scriptPath = path.join(dir, 'public', file);
|
|
223
233
|
if (!fs.existsSync(scriptPath)) continue; // declarative-only uiSections (e.g. status-page section)
|
|
224
|
-
out.push({ key, src: `/modules/${key}/public
|
|
234
|
+
out.push({ key, src: `/modules/${key}/public/${file}` });
|
|
225
235
|
}
|
|
226
236
|
return out;
|
|
227
237
|
}
|
|
@@ -342,8 +342,9 @@ await t('deploy_url is the hub origin plus this project\'s slug, and null withou
|
|
|
342
342
|
const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
343
343
|
|
|
344
344
|
// The real shell.js in a DOM stub (the tests/core_update_banner.mjs harness), with the
|
|
345
|
-
// /core-update answer carrying a deploy_url
|
|
346
|
-
|
|
345
|
+
// /core-update answer optionally carrying a deploy_url — an older server still sends one —
|
|
346
|
+
// and elements that remember their attributes.
|
|
347
|
+
function boot({ provisioning, deployUrl, pathname = '/settings', hash = '' }) {
|
|
347
348
|
const els = new Map();
|
|
348
349
|
const inserted = [];
|
|
349
350
|
const makeEl = () => ({
|
|
@@ -372,7 +373,7 @@ function boot({ provisioning, deployUrl }) {
|
|
|
372
373
|
addEventListener() {}, dispatchEvent() {},
|
|
373
374
|
get cookie() { return ''; }, set cookie(_v) {},
|
|
374
375
|
};
|
|
375
|
-
const location = { hostname: 'h', origin: 'https://h', pathname
|
|
376
|
+
const location = { hostname: 'h', origin: 'https://h', pathname, hash, assign() {}, replace() {} };
|
|
376
377
|
const update = { running: '1.19.871', latest: '1.19.1033', behind: true, reading: 'ok' };
|
|
377
378
|
const windowObj = {
|
|
378
379
|
__BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
|
|
@@ -401,40 +402,48 @@ function boot({ provisioning, deployUrl }) {
|
|
|
401
402
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
402
403
|
const HUB = 'https://cloudbongos.com/deploy?project=hoodify-finance';
|
|
403
404
|
|
|
404
|
-
|
|
405
|
+
// Task 1004296 moved the hand-off: a hall without a door of its own sends its owner to
|
|
406
|
+
// Settings → Software update, whose Update button carries the hub address (GET
|
|
407
|
+
// /software-update's `door`). The banner and the rail no longer link off-site.
|
|
408
|
+
await t('on a hosted project, the banner\'s Upgrade goes to Settings → Software update, not off-site', async () => {
|
|
405
409
|
const b = boot({ provisioning: false, deployUrl: HUB });
|
|
406
410
|
await settle();
|
|
407
411
|
assert.ok(b.banner(), 'the banner renders');
|
|
408
|
-
assert.
|
|
409
|
-
assert.
|
|
412
|
+
assert.match(b.banner().innerHTML, /href="\/settings#software-update"/, 'Upgrade opens the Software update panel');
|
|
413
|
+
assert.ok(!b.banner().innerHTML.includes(HUB), 'never the hub address directly, even when an older server sends one');
|
|
414
|
+
assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/, 'never the local /deploy, which this hall no longer has');
|
|
410
415
|
});
|
|
411
416
|
|
|
412
|
-
await t('on a hosted project, the rail\'s Deploy item
|
|
417
|
+
await t('on a hosted project, the rail\'s Deploy item stays hidden — it is the platform hall\'s page', async () => {
|
|
413
418
|
const b = boot({ provisioning: false, deployUrl: HUB });
|
|
414
419
|
await settle();
|
|
415
420
|
const item = b.deployItem();
|
|
416
|
-
assert.equal(item.getAttribute('href'),
|
|
417
|
-
assert.equal(
|
|
418
|
-
assert.equal(item.hidden, false, 'an Archon on their own project sees it');
|
|
421
|
+
assert.equal(item.getAttribute('href'), null, 'not re-pointed at the hub');
|
|
422
|
+
assert.equal(item.dataset.module, 'provisioning', 'its module gate stays, so a hall without the door hides it');
|
|
419
423
|
});
|
|
420
424
|
|
|
421
|
-
await t('a
|
|
422
|
-
for (const url of [null, 'javascript:alert(1)',
|
|
425
|
+
await t('a hall with no door of its own and no hub still links to Software update', async () => {
|
|
426
|
+
for (const url of [null, 'javascript:alert(1)', 42]) {
|
|
423
427
|
const b = boot({ provisioning: false, deployUrl: url });
|
|
424
428
|
await settle();
|
|
425
|
-
assert.
|
|
426
|
-
assert.doesNotMatch(b.banner().innerHTML, /
|
|
427
|
-
const item = b.deployItem();
|
|
428
|
-
assert.equal(item.getAttribute('href'), null, `the rail is not re-pointed for ${JSON.stringify(url)}`);
|
|
429
|
-
assert.equal(item.dataset.module, 'provisioning', 'and it keeps its module gate, so a hosted project still hides it');
|
|
429
|
+
assert.match(b.banner().innerHTML, /href="\/settings#software-update"/, `Software update for ${JSON.stringify(url)}`);
|
|
430
|
+
assert.doesNotMatch(b.banner().innerHTML, /javascript:/);
|
|
430
431
|
}
|
|
431
432
|
});
|
|
432
433
|
|
|
433
|
-
await t('on the
|
|
434
|
+
await t('on the Software update panel itself, the banner points rather than linking to the page it is on', async () => {
|
|
435
|
+
const b = boot({ provisioning: false, pathname: '/settings', hash: '#software-update' });
|
|
436
|
+
await settle();
|
|
437
|
+
assert.doesNotMatch(b.banner().innerHTML, /core-update-notice__action/, 'no link to where you already are');
|
|
438
|
+
assert.match(b.banner().innerHTML, /core-update-notice__hint/);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await t('on the platform, the local door wins: the banner links /deploy and the rail keeps it', async () => {
|
|
434
442
|
const b = boot({ provisioning: true, deployUrl: HUB });
|
|
435
443
|
await settle();
|
|
436
444
|
assert.match(b.banner().innerHTML, /href="\/deploy"/);
|
|
437
445
|
assert.ok(!b.banner().innerHTML.includes(HUB));
|
|
446
|
+
assert.ok(!b.banner().innerHTML.includes('software-update'), 'the platform takes its update at its own door');
|
|
438
447
|
const item = b.deployItem();
|
|
439
448
|
assert.notEqual(item.getAttribute('href'), HUB, 'the rail keeps the local /deploy');
|
|
440
449
|
assert.equal(item.dataset.module, 'provisioning', 'and its module gate, which the platform satisfies');
|
|
@@ -305,6 +305,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
305
305
|
const requests = [];
|
|
306
306
|
const writes = [];
|
|
307
307
|
const toasts = [];
|
|
308
|
+
const dispatched = []; // task 1004301: what the page announced (otb:task-shown)
|
|
308
309
|
const els = new Map();
|
|
309
310
|
const makeEl = () => ({
|
|
310
311
|
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
@@ -326,6 +327,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
326
327
|
querySelectorAll() { return []; },
|
|
327
328
|
createElement() { return makeEl(); },
|
|
328
329
|
addEventListener(type, fn) { if (type === 'DOMContentLoaded') onReady = fn; },
|
|
330
|
+
dispatchEvent(e) { dispatched.push(e); return true; },
|
|
329
331
|
};
|
|
330
332
|
const client = {
|
|
331
333
|
request: async (method, p, opts) => {
|
|
@@ -393,7 +395,8 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
393
395
|
confirm: () => true,
|
|
394
396
|
navigator: { clipboard: { writeText: async () => {} } },
|
|
395
397
|
};
|
|
396
|
-
|
|
398
|
+
class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
|
|
399
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams, CustomEvent };
|
|
397
400
|
sandbox.globalThis = sandbox;
|
|
398
401
|
vm.createContext(sandbox);
|
|
399
402
|
if (withReader) vm.runInContext(MD_SRC, sandbox, { filename: 'md-reader.js' });
|
|
@@ -407,6 +410,8 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
407
410
|
requests,
|
|
408
411
|
writes,
|
|
409
412
|
toasts,
|
|
413
|
+
dispatched,
|
|
414
|
+
win: windowObj,
|
|
410
415
|
};
|
|
411
416
|
}
|
|
412
417
|
|
|
@@ -1052,3 +1057,20 @@ test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim"
|
|
|
1052
1057
|
`the server's own sentence must reach the toast, not a generic fallback — got: ${r.toasts.join(' | ')}`);
|
|
1053
1058
|
assert.equal(task.goal_id, 1000071, 'a refused PATCH must not move the task locally');
|
|
1054
1059
|
});
|
|
1060
|
+
|
|
1061
|
+
// task 1004301 — a slot a MODULE may fill, and the announcement that tells it which task.
|
|
1062
|
+
// The page names no module: it draws an empty #task-contrib under the lifecycle rail and
|
|
1063
|
+
// announces each render, and a module's task-widget.js does the rest.
|
|
1064
|
+
test('the task page renders an empty module slot and announces every render', async () => {
|
|
1065
|
+
const r = renderTask({ ...BASE_TASK });
|
|
1066
|
+
r.boot();
|
|
1067
|
+
for (let i = 0; i < 5; i++) await settle();
|
|
1068
|
+
const html = r.html();
|
|
1069
|
+
assert.match(html, /<div class="tk-contrib" id="task-contrib" aria-live="polite"><\/div>/, 'an empty slot');
|
|
1070
|
+
assert.ok(html.indexOf('id="task-contrib"') > html.indexOf('class="tk-rail"'), 'under the lifecycle rail');
|
|
1071
|
+
const shown = r.dispatched.filter((e) => e.type === 'otb:task-shown');
|
|
1072
|
+
assert.equal(shown.length, 1);
|
|
1073
|
+
assert.deepEqual({ ...shown[0].detail }, { id: '1003305', status: 'shipped' }, 'only the id and the status travel');
|
|
1074
|
+
assert.deepEqual({ ...r.win.OTBTaskShown }, { id: '1003305', status: 'shipped' }, 'left on window for a widget that loads late');
|
|
1075
|
+
assert.doesNotMatch(read('task.js'), /npm-release/, 'the core page names no module');
|
|
1076
|
+
});
|
package/tests/helpers.mjs
CHANGED
|
@@ -73,6 +73,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
73
73
|
import { createRequire } from 'node:module';
|
|
74
74
|
import { execFileSync } from 'node:child_process';
|
|
75
75
|
import vm from 'node:vm';
|
|
76
|
+
import zlib from 'node:zlib';
|
|
77
|
+
import { EventEmitter } from 'node:events';
|
|
78
|
+
import { Readable } from 'node:stream';
|
|
76
79
|
|
|
77
80
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
78
81
|
|
|
@@ -1012,3 +1015,92 @@ export function macShapedTools(binDir) {
|
|
|
1012
1015
|
'',
|
|
1013
1016
|
].join('\n'), { mode: 0o755 });
|
|
1014
1017
|
}
|
|
1018
|
+
|
|
1019
|
+
// ---- a fake npm registry (task 1002622; shared since task 1004296) -------------------
|
|
1020
|
+
//
|
|
1021
|
+
// A real gzipped tar built by hand, a packument, and an `https.get` stand-in that answers
|
|
1022
|
+
// in 777-byte chunks — so a reader under test runs its real gunzip, tar walk and JSON parse,
|
|
1023
|
+
// with headers landing across chunk boundaries. Used by tests/npm_release_work.mjs and
|
|
1024
|
+
// tests/software_update.mjs.
|
|
1025
|
+
|
|
1026
|
+
function tarHeader(name, size, type = '0') {
|
|
1027
|
+
const h = Buffer.alloc(512, 0);
|
|
1028
|
+
h.write(name.slice(0, 100), 0, 'utf8');
|
|
1029
|
+
h.write('0000644\0', 100, 'ascii');
|
|
1030
|
+
h.write('0000000\0', 108, 'ascii');
|
|
1031
|
+
h.write('0000000\0', 116, 'ascii');
|
|
1032
|
+
h.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'ascii');
|
|
1033
|
+
h.write('00000000000\0', 136, 'ascii');
|
|
1034
|
+
h.write(' ', 148, 'ascii');
|
|
1035
|
+
h.write(type, 156, 'ascii');
|
|
1036
|
+
h.write('ustar\0', 257, 'ascii');
|
|
1037
|
+
h.write('00', 263, 'ascii');
|
|
1038
|
+
let sum = 0;
|
|
1039
|
+
for (const b of h) sum += b;
|
|
1040
|
+
h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii');
|
|
1041
|
+
return h;
|
|
1042
|
+
}
|
|
1043
|
+
function tarEntry(name, data, type = '0') {
|
|
1044
|
+
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
1045
|
+
const pad = Buffer.alloc((512 - (body.length % 512)) % 512, 0);
|
|
1046
|
+
return Buffer.concat([tarHeader(name, body.length, type), body, pad]);
|
|
1047
|
+
}
|
|
1048
|
+
function paxEntry(path) {
|
|
1049
|
+
// A pax record is "<len> <key>=<value>\n", where <len> counts its own digits too.
|
|
1050
|
+
const rec = (k, v) => {
|
|
1051
|
+
const body = ` ${k}=${v}\n`;
|
|
1052
|
+
let n = body.length + 1;
|
|
1053
|
+
while (String(n).length + body.length !== n) n = String(n).length + body.length;
|
|
1054
|
+
return `${n}${body}`;
|
|
1055
|
+
};
|
|
1056
|
+
return tarEntry('PaxHeader/x', rec('path', path), 'x');
|
|
1057
|
+
}
|
|
1058
|
+
/** A gzipped npm-shaped tarball. `notes` null leaves release-notes.json out. */
|
|
1059
|
+
export function npmTarball({ notes, padBytes = 40000, pax = false, pkg = '@bongos/core' } = {}) {
|
|
1060
|
+
const parts = [
|
|
1061
|
+
tarEntry('package/package.json', JSON.stringify({ name: pkg })),
|
|
1062
|
+
// A large file BEFORE the notes, so the reader has to skip across many chunks.
|
|
1063
|
+
tarEntry('package/big.js', Buffer.alloc(padBytes, 97)),
|
|
1064
|
+
];
|
|
1065
|
+
if (notes) {
|
|
1066
|
+
const json = JSON.stringify(notes);
|
|
1067
|
+
if (pax) parts.push(paxEntry('package/release-notes.json'), tarEntry('package/SHORTNAME', json));
|
|
1068
|
+
else parts.push(tarEntry('package/release-notes.json', json));
|
|
1069
|
+
}
|
|
1070
|
+
parts.push(Buffer.alloc(1024, 0));
|
|
1071
|
+
return zlib.gzipSync(Buffer.concat(parts));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// ---- a fake registry -----------------------------------------------------------------
|
|
1075
|
+
|
|
1076
|
+
export function npmPackument({ versions, latest, times, pkg = '@bongos/core', origin = 'https://registry.npmjs.org' }) {
|
|
1077
|
+
const doc = { name: pkg, 'dist-tags': { latest }, versions: {}, time: {} };
|
|
1078
|
+
for (const v of versions) {
|
|
1079
|
+
doc.versions[v] = { version: v, dist: { tarball: `${origin}/${pkg}/-/${pkg.split('/').pop()}-${v}.tgz` } };
|
|
1080
|
+
doc.time[v] = times[v];
|
|
1081
|
+
}
|
|
1082
|
+
return doc;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** `get` answering from `routes` (url → { status, body }), recording every URL asked for. */
|
|
1086
|
+
export function fakeHttpsGet(routes) {
|
|
1087
|
+
const asked = [];
|
|
1088
|
+
const get = (url, opts, cb) => {
|
|
1089
|
+
asked.push(url);
|
|
1090
|
+
const req = new EventEmitter();
|
|
1091
|
+
req.destroy = () => {};
|
|
1092
|
+
setImmediate(() => {
|
|
1093
|
+
const r = routes[url];
|
|
1094
|
+
if (!r) { req.emit('error', new Error(`no route for ${url}`)); return; }
|
|
1095
|
+
const body = Buffer.isBuffer(r.body) ? r.body : Buffer.from(typeof r.body === 'string' ? r.body : JSON.stringify(r.body));
|
|
1096
|
+
// Small chunks, so a tar header lands across a chunk boundary.
|
|
1097
|
+
const chunks = [];
|
|
1098
|
+
for (let i = 0; i < body.length; i += 777) chunks.push(body.subarray(i, i + 777));
|
|
1099
|
+
const res = Readable.from(chunks);
|
|
1100
|
+
res.statusCode = r.status || 200;
|
|
1101
|
+
cb(res);
|
|
1102
|
+
});
|
|
1103
|
+
return req;
|
|
1104
|
+
};
|
|
1105
|
+
return { get, asked };
|
|
1106
|
+
}
|