@bongos/core 1.23.4 → 1.23.5
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 +40 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +111 -5
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +26 -24
- package/docs/copy-registry.json +43 -25
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-readings.json +8 -8
- package/migrations/core_281_module_version_withdraw_note.sql +25 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/modules.js +10 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/bongos/module.js +24 -5
- package/src/bongos/module-store.js +42 -1
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/modules.js +45 -3
- package/src/module-api.js +1 -1
- package/tests/hall_catalog_world.mjs +1 -1
- package/tests/module_store_withdraw.mjs +201 -0
package/release-notes.json
CHANGED
|
@@ -10407,5 +10407,11 @@
|
|
|
10407
10407
|
"id": "1003811",
|
|
10408
10408
|
"text": "Builders can now browse the module store from the terminal with bongos module store, and it always shows the same modules in the same order as the hall."
|
|
10409
10409
|
}
|
|
10410
|
+
],
|
|
10411
|
+
"1.23.5": [
|
|
10412
|
+
{
|
|
10413
|
+
"id": "1004895",
|
|
10414
|
+
"text": "A broken module version can now be pulled back from the store, so new installs and updates stop getting it while projects that already have it are told."
|
|
10415
|
+
}
|
|
10410
10416
|
]
|
|
10411
10417
|
}
|
package/scripts/bongos/module.js
CHANGED
|
@@ -712,7 +712,7 @@ async function gatherUpdates(rows, { request } = {}) {
|
|
|
712
712
|
// A 404 here is a server whose core predates this route, not a missing module.
|
|
713
713
|
if (res.status === 404) return { checked: false, reason: "this server's store does not answer update checks yet (its core is older)" };
|
|
714
714
|
if (!res.ok) return { checked: false, reason: `the store answered ${storeError(res)}` };
|
|
715
|
-
return { checked: true, latest: (res.data && res.data.latest) || {} };
|
|
715
|
+
return { checked: true, latest: (res.data && res.data.latest) || {}, withdrawn: (res.data && res.data.withdrawn) || {} };
|
|
716
716
|
}
|
|
717
717
|
|
|
718
718
|
// Decide what is stale from rows + the store's answer (pure — tests drive it).
|
|
@@ -722,13 +722,22 @@ async function gatherUpdates(rows, { request } = {}) {
|
|
|
722
722
|
// installed module the store has delisted: it keeps working, but no further
|
|
723
723
|
// versions will come (ADR 0338 D2). Neither ever blocks — an update is news, and
|
|
724
724
|
// what blocks `bongos upgrade` is still only an incompatible ENABLED module.
|
|
725
|
-
function evaluateUpdates(rows = [], latest = {}, core) {
|
|
725
|
+
function evaluateUpdates(rows = [], latest = {}, core, withdrawnByKey = {}) {
|
|
726
726
|
const { satisfies } = require('../../src/module-loader/semver');
|
|
727
727
|
const updates = [];
|
|
728
728
|
const delisted = [];
|
|
729
|
+
const withdrawn = [];
|
|
729
730
|
for (const r of rows) {
|
|
730
731
|
const store = latest[r.key];
|
|
731
732
|
const have = r.version && parseVersion(String(r.version));
|
|
733
|
+
// WITHDRAWN (task 1004895) = the store took back the exact version installed
|
|
734
|
+
// here. It keeps working; the update below, when there is one, is the way off it.
|
|
735
|
+
const taken = have && (withdrawnByKey[r.key] || [])
|
|
736
|
+
.map((w) => (typeof w === 'string' ? { version: w, note: null } : w))
|
|
737
|
+
.find((w) => w && w.version === String(r.version));
|
|
738
|
+
if (taken) {
|
|
739
|
+
withdrawn.push({ key: r.key, installed: r.version, latest: store ? store.version : null, enabled: r.enabled, note: taken.note || null });
|
|
740
|
+
}
|
|
732
741
|
if (!store || !have) continue;
|
|
733
742
|
if (store.status === 'delisted') { delisted.push({ key: r.key, installed: r.version, enabled: r.enabled }); continue; }
|
|
734
743
|
const next = parseVersion(String(store.version));
|
|
@@ -737,7 +746,7 @@ function evaluateUpdates(rows = [], latest = {}, core) {
|
|
|
737
746
|
try { fits = !!(core && store.core_version && satisfies(core, store.core_version)); } catch { fits = false; }
|
|
738
747
|
updates.push({ key: r.key, installed: r.version, latest: store.version, coreVersion: store.core_version, enabled: r.enabled, fits });
|
|
739
748
|
}
|
|
740
|
-
return { updates, delisted };
|
|
749
|
+
return { updates, delisted, withdrawn };
|
|
741
750
|
}
|
|
742
751
|
|
|
743
752
|
function reportUpdates(found, verdict, { log, errlog }) {
|
|
@@ -746,7 +755,7 @@ function reportUpdates(found, verdict, { log, errlog }) {
|
|
|
746
755
|
log(` not checked — ${found.reason}. The compatibility check above still stands.`);
|
|
747
756
|
return;
|
|
748
757
|
}
|
|
749
|
-
const { updates, delisted } = evaluateUpdates(verdict.rows, found.latest, verdict.core);
|
|
758
|
+
const { updates, delisted, withdrawn } = evaluateUpdates(verdict.rows, found.latest, verdict.core, found.withdrawn);
|
|
750
759
|
for (const u of updates) {
|
|
751
760
|
const how = u.fits
|
|
752
761
|
? `run \`bongos module update ${u.key}\``
|
|
@@ -756,7 +765,17 @@ function reportUpdates(found, verdict, { log, errlog }) {
|
|
|
756
765
|
for (const d of delisted) {
|
|
757
766
|
errlog(` ! ${d.key.padEnd(18)} ${d.installed} — delisted in the store: it keeps working, but no further versions will come`);
|
|
758
767
|
}
|
|
759
|
-
|
|
768
|
+
for (const w of withdrawn) {
|
|
769
|
+
// Update only moves forward, so an OLDER served version is no way off it: until a
|
|
770
|
+
// fixed version is published, say so rather than name a command that refuses.
|
|
771
|
+
const newer = w.latest && parseVersion(String(w.latest))
|
|
772
|
+
&& cmp(parseVersion(String(w.latest)), parseVersion(String(w.installed))) > 0;
|
|
773
|
+
const next = newer
|
|
774
|
+
? `move to ${w.latest} with \`bongos module update ${w.key}\``
|
|
775
|
+
: 'no newer version is out yet; `bongos upgrade` will offer the fix once one is published';
|
|
776
|
+
errlog(` ! ${w.key.padEnd(18)} ${w.installed} — withdrawn in the store${w.note ? ` (${w.note})` : ''}: it keeps working here, but ${next}`);
|
|
777
|
+
}
|
|
778
|
+
if (!updates.length && !delisted.length && !withdrawn.length) log(' every installed store module is on its newest version.');
|
|
760
779
|
}
|
|
761
780
|
|
|
762
781
|
async function cmdUpgrade(args = [], { log = console.log, errlog = console.error, gather = gatherUpgrade, checkUpdates = gatherUpdates } = {}) {
|
|
@@ -383,6 +383,47 @@ async function latestVersions(keys, { db, channelsFor = null } = {}) {
|
|
|
383
383
|
return out;
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
// Withdraw one version (task 1004895, criterion wa5-staged-rollout): the store
|
|
387
|
+
// stops handing it out. Staged rollout catches a bad version on the tester channel;
|
|
388
|
+
// this is the way back once one has reached general. It is one channel row,
|
|
389
|
+
// 'withdrawn' (core_276), which servedChannels never serves, so acquire, install,
|
|
390
|
+
// update and the update check all stop offering it with no other change. Like a
|
|
391
|
+
// delist it reaches nothing a project already holds: an installed copy keeps
|
|
392
|
+
// working (ADR 0338 D2), and `bongos upgrade` and the hall tell its holder, with
|
|
393
|
+
// `note` — the reason the person gave, kept on the row (core_281). The insert only
|
|
394
|
+
// lands while the version is not already withdrawn, so two people withdrawing at
|
|
395
|
+
// once write one row. Returns { ok: true, channel } or { ok: false, status, code, message }.
|
|
396
|
+
const WITHDRAWN_CHANNEL = 'withdrawn';
|
|
397
|
+
|
|
398
|
+
async function withdrawVersion(versionId, builderId, note, { db } = {}) {
|
|
399
|
+
const pool = db || require('./pool').pool;
|
|
400
|
+
const { rowCount } = await pool.query(
|
|
401
|
+
`INSERT INTO store_module_version_channels (version_id, channel, reason, set_by, note)
|
|
402
|
+
SELECT $1, '${WITHDRAWN_CHANNEL}', 'withdrawn_by_person', $2, $3
|
|
403
|
+
WHERE (SELECT c.channel FROM store_module_version_channels c
|
|
404
|
+
WHERE c.version_id = $1 ORDER BY c.set_at DESC, c.id DESC LIMIT 1)
|
|
405
|
+
IS DISTINCT FROM '${WITHDRAWN_CHANNEL}'`, [versionId, builderId, note]);
|
|
406
|
+
if (rowCount === 1) return { ok: true, channel: WITHDRAWN_CHANNEL };
|
|
407
|
+
return { ok: false, status: 409, code: 'already_withdrawn', message: 'This version is already withdrawn: the store does not hand it out.' };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Which versions of these module keys are withdrawn, as { key: [{ version, note }] }
|
|
411
|
+
// — so the update check and the hall can tell a project that the version it runs
|
|
412
|
+
// was taken back, and why. `note` is the reason from the withdrawing row.
|
|
413
|
+
async function withdrawnVersions(keys, { db } = {}) {
|
|
414
|
+
if (!keys.length) return {};
|
|
415
|
+
const pool = db || require('./pool').pool;
|
|
416
|
+
const { rows } = await pool.query(
|
|
417
|
+
`SELECT v.module_key, v.version,
|
|
418
|
+
(SELECT c.note FROM store_module_version_channels c
|
|
419
|
+
WHERE c.version_id = v.id ORDER BY c.set_at DESC, c.id DESC LIMIT 1) AS note
|
|
420
|
+
FROM store_module_versions v
|
|
421
|
+
WHERE v.module_key = ANY($1) AND ${currentChannelSql('v.id')} = '${WITHDRAWN_CHANNEL}'`, [keys]);
|
|
422
|
+
const out = {};
|
|
423
|
+
for (const r of rows) (out[r.module_key] = out[r.module_key] || []).push({ version: r.version, note: r.note || null });
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
386
427
|
// Delist a module (task 1003797, ADR 0338 D2): it leaves the catalog, takes no new
|
|
387
428
|
// acquisitions or versions, and serves no further downloads — every read above
|
|
388
429
|
// already refuses on status 'delisted', so this one UPDATE is the whole act. It
|
|
@@ -427,5 +468,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
|
427
468
|
module.exports = {
|
|
428
469
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
429
470
|
isFreePrice, servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
430
|
-
delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
|
|
471
|
+
delistModule, delistedKeySet, withdrawVersion, withdrawnVersions, storeDir, storeDirFor, migrateLegacyStore,
|
|
431
472
|
};
|
|
@@ -253,6 +253,9 @@ const EXPECTED_RANKS = {
|
|
|
253
253
|
// Releasing a version the promotion gate held on tester (task 1003804) hands it to
|
|
254
254
|
// every project; the owner put it on the delist atom (2026-10-23), so it is pinned.
|
|
255
255
|
'POST /store/modules/:key/versions/:version/release': 'perm:module.delist',
|
|
256
|
+
// Withdrawing a version (task 1004895) stops it being handed to every project, the
|
|
257
|
+
// same reach as a delist, so it rides the delist atom and is pinned.
|
|
258
|
+
'POST /store/modules/:key/versions/:version/withdraw': 'perm:module.delist',
|
|
256
259
|
};
|
|
257
260
|
|
|
258
261
|
// The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
|
|
@@ -97,7 +97,7 @@ const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } =
|
|
|
97
97
|
const {
|
|
98
98
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
99
99
|
servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
100
|
-
delistModule, delistedKeySet, migrateLegacyStore,
|
|
100
|
+
delistModule, delistedKeySet, withdrawVersion, withdrawnVersions, migrateLegacyStore,
|
|
101
101
|
} = require('../module-store');
|
|
102
102
|
const {
|
|
103
103
|
isOptedOut, setReporting, installReportError, errorCountError, recordInstallReport, recordErrorCount,
|
|
@@ -272,6 +272,11 @@ module.exports = function buildModulesRouter() {
|
|
|
272
272
|
let delistedKeys = new Set();
|
|
273
273
|
try { delistedKeys = await delistedKeySet(all.filter((m) => m.source === 'instance').map((m) => m.key)); }
|
|
274
274
|
catch (e) { log.warn({ err: e.message }, 'store status unreadable for the modules list'); }
|
|
275
|
+
// Which installed store modules run a version the store took back (task 1004895),
|
|
276
|
+
// so the row says so, with the reason. Same tolerance.
|
|
277
|
+
let withdrawn = {};
|
|
278
|
+
try { withdrawn = await withdrawnVersions(all.filter((m) => m.source === 'instance').map((m) => m.key)); }
|
|
279
|
+
catch (e) { log.warn({ err: e.message }, 'withdrawn versions unreadable for the modules list'); }
|
|
275
280
|
for (const m of all) {
|
|
276
281
|
m.enabled_source = sources[m.key] || 'default';
|
|
277
282
|
m.override = byKey.has(m.key)
|
|
@@ -283,6 +288,9 @@ module.exports = function buildModulesRouter() {
|
|
|
283
288
|
? `/builders/modules?howto=${encodeURIComponent(m.key)}&version=${encodeURIComponent(String(m.version))}`
|
|
284
289
|
: null;
|
|
285
290
|
m.store_delisted = m.source === 'instance' && delistedKeys.has(m.key);
|
|
291
|
+
const taken = m.source === 'instance' && m.version
|
|
292
|
+
? (withdrawn[m.key] || []).find((w) => w.version === String(m.version)) : null;
|
|
293
|
+
m.store_withdrawn = taken ? { version: taken.version, note: taken.note } : null;
|
|
286
294
|
// what of this module still waits for a restart to match its toggle (task 1004463)
|
|
287
295
|
m.restart_pending = restartPendingFor(m.key, m.enabled === true);
|
|
288
296
|
}
|
|
@@ -637,6 +645,35 @@ module.exports = function buildModulesRouter() {
|
|
|
637
645
|
res.json({ ok: true, module_key: key, version, channel: out.channel, reason: out.reason });
|
|
638
646
|
}));
|
|
639
647
|
|
|
648
|
+
// POST /api/bongos/store/modules/:key/versions/:version/withdraw — a Metic+ person
|
|
649
|
+
// takes one version back (task 1004895, criterion wa5-staged-rollout). The store
|
|
650
|
+
// stops handing it out: acquire, install, update and the update check skip it, and
|
|
651
|
+
// the newest version still served is offered instead. A project that already runs
|
|
652
|
+
// it keeps it, and `bongos upgrade` and the hall's Modules tab tell it the version
|
|
653
|
+
// was withdrawn, with the reason. Withdrawing the only served version is allowed;
|
|
654
|
+
// acquire then says nothing can be handed out. A reason is required: it is kept on
|
|
655
|
+
// the channel row (core_281) and shown to holders, and the audit middleware also
|
|
656
|
+
// records it with who asked. The permission is module.delist, the one that already
|
|
657
|
+
// guards delist and release.
|
|
658
|
+
router.post('/store/modules/:key/versions/:version/withdraw', auth.requireBuilder, auth.requirePermission('module.delist'),
|
|
659
|
+
asyncHandler('POST /store/modules/:key/versions/:version/withdraw', async (req, res) => {
|
|
660
|
+
if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
|
|
661
|
+
const { key, version } = req.params;
|
|
662
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
663
|
+
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
664
|
+
}
|
|
665
|
+
const reason = String(req.body?.reason || '').trim();
|
|
666
|
+
if (!reason) {
|
|
667
|
+
return res.fail('reason_required', { status: 400, message: 'Say why this version is being taken back, in reason. Projects that already run it keep it.' });
|
|
668
|
+
}
|
|
669
|
+
const r = await resolveReadable(key, version);
|
|
670
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
671
|
+
const out = await withdrawVersion(r.version.id, req.builder.id, reason);
|
|
672
|
+
if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
|
|
673
|
+
log.info({ versionId: r.version.id, builderId: req.builder.id, reason }, `${key} ${version} withdrawn from the store`);
|
|
674
|
+
res.json({ ok: true, module_key: key, version, channel: out.channel, reason });
|
|
675
|
+
}));
|
|
676
|
+
|
|
640
677
|
// GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
|
|
641
678
|
// read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
|
|
642
679
|
// like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
|
|
@@ -832,9 +869,14 @@ module.exports = function buildModulesRouter() {
|
|
|
832
869
|
if (!keys.length || keys.length > STORE_LATEST_MAX_KEYS || !keys.every((k) => KEY_RE.test(k))) {
|
|
833
870
|
return res.fail('bad_request', { status: 400, message: `keys is a comma-separated list of 1 to ${STORE_LATEST_MAX_KEYS} module keys (lowercase kebab-case).` });
|
|
834
871
|
}
|
|
835
|
-
|
|
872
|
+
// Which of these keys have versions taken back (task 1004895), so a project
|
|
873
|
+
// running one is told, with the reason; a key with none is absent.
|
|
874
|
+
const [latest, withdrawn] = await Promise.all([
|
|
875
|
+
latestVersions(keys, { channelsFor: await projectChannels() }),
|
|
876
|
+
withdrawnVersions(keys),
|
|
877
|
+
]);
|
|
836
878
|
res.set('Cache-Control', 'no-store');
|
|
837
|
-
res.json({ latest });
|
|
879
|
+
res.json({ latest, withdrawn });
|
|
838
880
|
}));
|
|
839
881
|
|
|
840
882
|
// The store catalog under the quality floor (task 1003807, ADR 0343 D2/D5): each
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.23.
|
|
84
|
+
const CORE_VERSION = '1.23.5'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -58,7 +58,7 @@ test('modules: the row is the oversight row — title with the mono key, clamped
|
|
|
58
58
|
assert.match(js, /<span class="ov-row__title">\$\{escapeHtml\(m\.title \|\| m\.key\)\}<span class="ov-mono">\$\{escapeHtml\(m\.key\)\}<\/span>/, 'the key rides inside the title in mono');
|
|
59
59
|
assert.match(js, /class="ov-row__sub mod-row__desc"/, 'the description is the sub line');
|
|
60
60
|
assert.match(js, /<details class="mod-more"/, 'a details disclosure opens the rest');
|
|
61
|
-
assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{categoryFact\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, (task 1003784) the category, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill and (task 1004463) the waits-for-a-restart pill are the facts line');
|
|
61
|
+
assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{categoryFact\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{withdrawnFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, (task 1003784) the category, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill, (task 1004895) the withdrawn pill and (task 1004463) the waits-for-a-restart pill are the facts line');
|
|
62
62
|
assert.match(js, /<div class="ov-row__right mod-row__right">\$\{rightHtml\(m\)\}<\/div>/, 'the right column is one function');
|
|
63
63
|
const css = read('modules.css');
|
|
64
64
|
assert.match(css, /\.mod-row__desc \{[^}]*-webkit-line-clamp: 2/, 'the description clamps to two lines');
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// tests/module_store_withdraw.mjs — a Metic+ person can take one version back, and
|
|
2
|
+
// the store stops handing it out while every project that runs it keeps it (task
|
|
3
|
+
// 1004895, criterion wa5-staged-rollout). Drives the real POST
|
|
4
|
+
// /store/modules/:key/versions/:version/withdraw handler and the real update-check
|
|
5
|
+
// route off the real router, the real store reads behind acquire, and the real
|
|
6
|
+
// `bongos upgrade` report, against a stand-in pool that keeps the channel history
|
|
7
|
+
// (no port, no real DB — the module_store_delist.mjs harness).
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/module_store_withdraw.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
const version = (id, v) => ({ id, module_key: 'weather', version: v, core_version: '^1.20.0', tree_sha256: 't', tarball_sha256: 'x', tarball_bytes: 10, artifact_path: `weather/weather-${v}.tgz`, published_at: 't' });
|
|
22
|
+
const fresh = () => ({
|
|
23
|
+
modules: { weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed' } },
|
|
24
|
+
versions: [version(1, '1.0.0'), version(2, '1.1.0')],
|
|
25
|
+
// The channel HISTORY; the current channel is the newest row per version.
|
|
26
|
+
channels: [
|
|
27
|
+
{ version_id: 1, channel: 'general', reason: 'predates_staged_rollout', set_by: null },
|
|
28
|
+
{ version_id: 2, channel: 'general', reason: 'promoted_after_7_days', set_by: null },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
let state = fresh();
|
|
32
|
+
const current = (versionId) => {
|
|
33
|
+
const rows = state.channels.filter((c) => c.version_id === Number(versionId));
|
|
34
|
+
return rows.length ? rows[rows.length - 1].channel : null;
|
|
35
|
+
};
|
|
36
|
+
const query = async (sql, params = []) => {
|
|
37
|
+
if (/^\s*INSERT INTO store_module_version_channels/.test(sql)) {
|
|
38
|
+
assert.match(sql, /IS DISTINCT FROM 'withdrawn'/, 'the insert only lands while the version is not already withdrawn');
|
|
39
|
+
if (current(params[0]) === 'withdrawn') return { rowCount: 0, rows: [] };
|
|
40
|
+
state.channels.push({ version_id: Number(params[0]), channel: 'withdrawn', reason: 'withdrawn_by_person', set_by: params[1], note: params[2] });
|
|
41
|
+
return { rowCount: 1, rows: [] };
|
|
42
|
+
}
|
|
43
|
+
if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
|
|
44
|
+
const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
|
|
45
|
+
}
|
|
46
|
+
if (/FROM store_module_versions WHERE module_key = \$1 AND version = \$2/.test(sql)) {
|
|
47
|
+
return { rows: state.versions.filter((v) => v.module_key === params[0] && v.version === params[1]) };
|
|
48
|
+
}
|
|
49
|
+
if (/FROM store_module_versions WHERE module_key = \$1/.test(sql)) {
|
|
50
|
+
return { rows: state.versions.filter((v) => v.module_key === params[0]).map((v) => ({ ...v, channel: current(v.id) })) };
|
|
51
|
+
}
|
|
52
|
+
if (/AS note\s+FROM store_module_versions v/.test(sql)) {
|
|
53
|
+
const noteOf = (id) => state.channels.filter((c) => c.version_id === id).at(-1).note;
|
|
54
|
+
return { rows: state.versions.filter((v) => params[0].includes(v.module_key) && current(v.id) === 'withdrawn').map((v) => ({ ...v, note: noteOf(v.id) })) };
|
|
55
|
+
}
|
|
56
|
+
if (/JOIN store_modules m/.test(sql)) {
|
|
57
|
+
return { rows: state.versions.filter((v) => params[0].includes(v.module_key)).map((v) => ({ ...v, status: state.modules[v.module_key].status, channel: current(v.id) })) };
|
|
58
|
+
}
|
|
59
|
+
return { rows: [] };
|
|
60
|
+
};
|
|
61
|
+
const pool = { connect: async () => ({ query, release() {} }), query };
|
|
62
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
63
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
|
|
64
|
+
|
|
65
|
+
const store = require('../src/bongos/module-store.js');
|
|
66
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
67
|
+
const mod = require('../scripts/bongos/module.js');
|
|
68
|
+
|
|
69
|
+
function handlerFor(method, p) {
|
|
70
|
+
const layer = buildModulesRouter().stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
71
|
+
assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
|
|
72
|
+
const stack = layer.route.stack;
|
|
73
|
+
return { handle: stack[stack.length - 1].handle, depth: stack.length };
|
|
74
|
+
}
|
|
75
|
+
async function invoke(handle, req) {
|
|
76
|
+
const res = {
|
|
77
|
+
statusCode: 200, body: undefined,
|
|
78
|
+
json(b) { this.body = b; return this; },
|
|
79
|
+
status(c) { this.statusCode = c; return this; },
|
|
80
|
+
set() { return this; },
|
|
81
|
+
fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
|
|
82
|
+
};
|
|
83
|
+
let nextErr = null;
|
|
84
|
+
await handle({ builder: { id: 3 }, query: {}, body: {}, ...req }, res, (err) => { nextErr = err; });
|
|
85
|
+
if (nextErr) throw nextErr;
|
|
86
|
+
return res;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const WITHDRAW = '/store/modules/:key/versions/:version/withdraw';
|
|
90
|
+
const withdraw = (version, reason = 'crashes on start') =>
|
|
91
|
+
invoke(handlerFor('post', WITHDRAW).handle, { params: { key: 'weather', version }, body: { reason } });
|
|
92
|
+
|
|
93
|
+
test('the route is gated by module.delist, the permission behind delist and release, and pinned', () => {
|
|
94
|
+
assert.ok(handlerFor('post', WITHDRAW).depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
|
|
95
|
+
const rank = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
|
|
96
|
+
assert.match(rank, /'POST \/store\/modules\/:key\/versions\/:version\/withdraw': 'perm:module\.delist'/);
|
|
97
|
+
const catalog = fs.readFileSync(path.join(ROOT, 'modules/government/catalog.js'), 'utf8');
|
|
98
|
+
assert.match(catalog, /key: 'module\.delist'[^\n]*\/withdraw/, 'the permission says what it guards');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a reason is required, and an unknown version or a bad path is refused', async () => {
|
|
102
|
+
state = fresh();
|
|
103
|
+
assert.equal((await withdraw('1.1.0', ' ')).statusCode, 400);
|
|
104
|
+
assert.equal((await withdraw('9.9.9')).statusCode, 404);
|
|
105
|
+
const bad = await invoke(handlerFor('post', WITHDRAW).handle, { params: { key: 'Weather', version: '1.1.0' }, body: { reason: 'x' } });
|
|
106
|
+
assert.equal(bad.statusCode, 400);
|
|
107
|
+
assert.equal(state.channels.length, 2, 'nothing was written');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('withdrawing writes one withdrawn row naming who did it; a second time is refused', async () => {
|
|
111
|
+
state = fresh();
|
|
112
|
+
const first = await withdraw('1.1.0');
|
|
113
|
+
assert.equal(first.statusCode, 200);
|
|
114
|
+
assert.deepEqual(first.body, { ok: true, module_key: 'weather', version: '1.1.0', channel: 'withdrawn', reason: 'crashes on start' });
|
|
115
|
+
assert.deepEqual(state.channels.at(-1), { version_id: 2, channel: 'withdrawn', reason: 'withdrawn_by_person', set_by: 3, note: 'crashes on start' },
|
|
116
|
+
'the person\'s reason is kept on the row (core_281), the machine code beside it');
|
|
117
|
+
const again = await withdraw('1.1.0');
|
|
118
|
+
assert.equal(again.statusCode, 409);
|
|
119
|
+
assert.equal(again.body.error, 'already_withdrawn');
|
|
120
|
+
assert.equal(state.channels.length, 3, 'one row, however many times it is asked');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('the store stops handing it out: acquire offers the newest version still served, and refuses the withdrawn one by name', async () => {
|
|
124
|
+
state = fresh();
|
|
125
|
+
await withdraw('1.1.0');
|
|
126
|
+
const general = store.servedChannels(false);
|
|
127
|
+
const newest = await store.resolveAcquirable('weather', { channels: general });
|
|
128
|
+
assert.equal(newest.ok, true);
|
|
129
|
+
assert.equal(newest.version.version, '1.0.0', 'the withdrawn 1.1.0 is skipped');
|
|
130
|
+
const exact = await store.resolveAcquirable('weather', { version: '1.1.0', channels: store.servedChannels(true) });
|
|
131
|
+
assert.equal(exact.ok, false, 'not even a tester project is handed it');
|
|
132
|
+
assert.match(exact.message, /1\.1\.0 was withdrawn: it is no longer handed out/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('withdrawing the only served version leaves nothing to hand out, and acquire says so', async () => {
|
|
136
|
+
state = fresh();
|
|
137
|
+
await withdraw('1.0.0');
|
|
138
|
+
await withdraw('1.1.0');
|
|
139
|
+
const r = await store.resolveAcquirable('weather', { channels: store.servedChannels(false) });
|
|
140
|
+
assert.equal(r.ok, false);
|
|
141
|
+
assert.equal(r.code, 'no_versions_on_channel');
|
|
142
|
+
assert.match(r.message, /every version was withdrawn/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('the update check names withdrawn versions beside the newest served one', async () => {
|
|
146
|
+
state = fresh();
|
|
147
|
+
await withdraw('1.1.0');
|
|
148
|
+
const res = await invoke(handlerFor('get', '/store/modules/latest').handle, { query: { keys: 'weather' } });
|
|
149
|
+
assert.equal(res.statusCode, 200);
|
|
150
|
+
assert.equal(res.body.latest.weather.version, '1.0.0');
|
|
151
|
+
assert.deepEqual(res.body.withdrawn, { weather: [{ version: '1.1.0', note: 'crashes on start' }] });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the hall\'s module list marks an installed module whose version was withdrawn, with the reason', async () => {
|
|
155
|
+
state = fresh();
|
|
156
|
+
await withdraw('1.1.0', 'security flaw, update now');
|
|
157
|
+
const routes = require('../src/bongos/routes/modules.js');
|
|
158
|
+
const realList = routes.listModules;
|
|
159
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
160
|
+
assert.match(src, /m\.store_withdrawn = taken \? \{ version: taken\.version, note: taken\.note \} : null;/, 'the row carries version and note');
|
|
161
|
+
assert.equal(typeof realList, 'function');
|
|
162
|
+
const hall = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/modules.js'), 'utf8');
|
|
163
|
+
const fn = hall.match(/function withdrawnFact\(m\) \{[\s\S]*?\n \}/)[0];
|
|
164
|
+
const withdrawnFact = new Function('escapeHtml', `${fn}; return withdrawnFact;`)((t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])));
|
|
165
|
+
assert.equal(withdrawnFact({ store_withdrawn: null }), '', 'nothing to say when the version was not taken back');
|
|
166
|
+
const pill = withdrawnFact({ store_withdrawn: { version: '1.1.0', note: 'security flaw, "update" <now>' } });
|
|
167
|
+
assert.match(pill, /withdrawn · update when a fix is out/);
|
|
168
|
+
assert.match(pill, /Taken back from the store: security flaw, "update" <now>/, 'the reason is shown, escaped');
|
|
169
|
+
assert.match(hall, /\$\{delistedFact\(m\)\}\$\{withdrawnFact\(m\)\}/, 'and it is rendered on the row');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const row = (key, version) => ({ key, version, enabled: true, coreVersion: '^1.0.0', satisfied: true });
|
|
173
|
+
function sink() { const s = { out: [], err: [] }; s.log = (l) => s.out.push(l); s.errlog = (l) => s.err.push(l); return s; }
|
|
174
|
+
|
|
175
|
+
test('bongos upgrade tells a project its version was withdrawn, and names the way off only when one exists', async () => {
|
|
176
|
+
const run = async (installed, latest, withdrawn) => {
|
|
177
|
+
const s = sink();
|
|
178
|
+
const code = await mod.cmdUpgrade([], {
|
|
179
|
+
...s,
|
|
180
|
+
gather: () => ({ core: '1.21.0', errors: [], rows: [row('weather', installed)] }),
|
|
181
|
+
checkUpdates: async () => ({ checked: true, latest: { weather: { version: latest, core_version: '^1.0.0', status: 'listed' } }, withdrawn }),
|
|
182
|
+
});
|
|
183
|
+
return { code, err: s.err.join('\n'), out: s.out.join('\n') };
|
|
184
|
+
};
|
|
185
|
+
const fixed = await run('1.1.0', '1.2.0', { weather: [{ version: '1.1.0', note: 'crashes on start' }] });
|
|
186
|
+
assert.equal(fixed.code, 0, 'a withdrawal is news, never a refusal');
|
|
187
|
+
assert.match(fixed.err, /weather\s+1\.1\.0\s+— withdrawn in the store \(crashes on start\): it keeps working here, but move to 1\.2\.0 with `bongos module update weather`/);
|
|
188
|
+
const stuck = await run('1.1.0', '1.0.0', { weather: ['1.1.0'] }); // an older server's plain list still works
|
|
189
|
+
assert.match(stuck.err, /withdrawn in the store: it keeps working here, but no newer version is out yet/, 'update only moves forward, so the older 1.0.0 is not offered');
|
|
190
|
+
assert.doesNotMatch(stuck.out, /every installed store module is on its newest version/);
|
|
191
|
+
const other = await run('1.0.0', '1.0.0', { weather: ['1.1.0'] });
|
|
192
|
+
assert.doesNotMatch(other.err, /withdrawn/, 'a project on a version that was NOT withdrawn hears nothing');
|
|
193
|
+
const older = await run('1.0.0', '1.0.0', undefined);
|
|
194
|
+
assert.equal(older.code, 0, 'a server that predates withdraw answers no withdrawn map, and that is fine');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('evaluateUpdates: a withdrawn install is reported, whether or not an update exists', () => {
|
|
198
|
+
const { withdrawn, updates } = mod.evaluateUpdates([row('weather', '1.1.0')], { weather: { version: '1.2.0', core_version: '^1.0.0', status: 'listed' } }, '1.21.0', { weather: [{ version: '1.1.0', note: 'crashes' }] });
|
|
199
|
+
assert.deepEqual(withdrawn, [{ key: 'weather', installed: '1.1.0', latest: '1.2.0', enabled: true, note: 'crashes' }]);
|
|
200
|
+
assert.equal(updates.length, 1, 'the update is still listed: it is the way off');
|
|
201
|
+
});
|