@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.
@@ -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
  }
@@ -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
- if (!updates.length && !delisted.length) log(' every installed store module is on its newest version.');
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
- const latest = await latestVersions(keys, { channelsFor: await projectChannels() });
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.4'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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, &quot;update&quot; &lt;now&gt;/, '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
+ });