@bongos/core 1.23.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.3",
3
+ "version": "1.23.5",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10401,5 +10401,17 @@
10401
10401
  "id": "1003809",
10402
10402
  "text": "The module store now shows at most ten modules in each sub-category, and a module that lands eleventh tells its author why."
10403
10403
  }
10404
+ ],
10405
+ "1.23.4": [
10406
+ {
10407
+ "id": "1003811",
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
+ }
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
+ }
10404
10416
  ]
10405
10417
  }
@@ -31,6 +31,14 @@
31
31
  // model as the hall's Modules tab (src/module-loader/catalog.js), so the two
32
32
  // surfaces cannot disagree about who built what.
33
33
  //
34
+ // bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]
35
+ // Task 1003811 (criterion wa5-curated-listing) — the STORE from the terminal:
36
+ // what the catalog lists, in its order. It asks GET /store/modules, the same
37
+ // read the hall's "In the store" group makes, and never filters, ranks or caps
38
+ // anything itself — the quality floor, the ranking and the cap of ten per
39
+ // sub-category live in one place (src/bongos/module-listing.js), so the hall
40
+ // and the terminal cannot disagree about which modules are the best.
41
+ //
34
42
  // bongos module check <key>
35
43
  // ADR 0107 §4 — the submit-time pre-check a module must clear before it can
36
44
  // be upstreamed into the shared core catalog. REUSES (never re-types) the
@@ -58,6 +66,7 @@
58
66
  // bongos module new <key> scaffold modules/<key>/
59
67
  // bongos module new <key> --force overwrite an existing modules/<key>/
60
68
  // bongos module list [--catalog] browse the module catalog (author + origin credit)
69
+ // bongos module store [--sort <part>] [--max-cents N] [--max-credits N] what the store lists, best first
61
70
  // bongos upgrade [--no-store] pre-check enabled modules vs the core version + list store updates
62
71
  // bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
63
72
  // bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
@@ -703,7 +712,7 @@ async function gatherUpdates(rows, { request } = {}) {
703
712
  // A 404 here is a server whose core predates this route, not a missing module.
704
713
  if (res.status === 404) return { checked: false, reason: "this server's store does not answer update checks yet (its core is older)" };
705
714
  if (!res.ok) return { checked: false, reason: `the store answered ${storeError(res)}` };
706
- 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) || {} };
707
716
  }
708
717
 
709
718
  // Decide what is stale from rows + the store's answer (pure — tests drive it).
@@ -713,13 +722,22 @@ async function gatherUpdates(rows, { request } = {}) {
713
722
  // installed module the store has delisted: it keeps working, but no further
714
723
  // versions will come (ADR 0338 D2). Neither ever blocks — an update is news, and
715
724
  // what blocks `bongos upgrade` is still only an incompatible ENABLED module.
716
- function evaluateUpdates(rows = [], latest = {}, core) {
725
+ function evaluateUpdates(rows = [], latest = {}, core, withdrawnByKey = {}) {
717
726
  const { satisfies } = require('../../src/module-loader/semver');
718
727
  const updates = [];
719
728
  const delisted = [];
729
+ const withdrawn = [];
720
730
  for (const r of rows) {
721
731
  const store = latest[r.key];
722
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
+ }
723
741
  if (!store || !have) continue;
724
742
  if (store.status === 'delisted') { delisted.push({ key: r.key, installed: r.version, enabled: r.enabled }); continue; }
725
743
  const next = parseVersion(String(store.version));
@@ -728,7 +746,7 @@ function evaluateUpdates(rows = [], latest = {}, core) {
728
746
  try { fits = !!(core && store.core_version && satisfies(core, store.core_version)); } catch { fits = false; }
729
747
  updates.push({ key: r.key, installed: r.version, latest: store.version, coreVersion: store.core_version, enabled: r.enabled, fits });
730
748
  }
731
- return { updates, delisted };
749
+ return { updates, delisted, withdrawn };
732
750
  }
733
751
 
734
752
  function reportUpdates(found, verdict, { log, errlog }) {
@@ -737,7 +755,7 @@ function reportUpdates(found, verdict, { log, errlog }) {
737
755
  log(` not checked — ${found.reason}. The compatibility check above still stands.`);
738
756
  return;
739
757
  }
740
- const { updates, delisted } = evaluateUpdates(verdict.rows, found.latest, verdict.core);
758
+ const { updates, delisted, withdrawn } = evaluateUpdates(verdict.rows, found.latest, verdict.core, found.withdrawn);
741
759
  for (const u of updates) {
742
760
  const how = u.fits
743
761
  ? `run \`bongos module update ${u.key}\``
@@ -747,7 +765,17 @@ function reportUpdates(found, verdict, { log, errlog }) {
747
765
  for (const d of delisted) {
748
766
  errlog(` ! ${d.key.padEnd(18)} ${d.installed} — delisted in the store: it keeps working, but no further versions will come`);
749
767
  }
750
- 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.');
751
779
  }
752
780
 
753
781
  async function cmdUpgrade(args = [], { log = console.log, errlog = console.error, gather = gatherUpgrade, checkUpdates = gatherUpdates } = {}) {
@@ -1060,6 +1088,84 @@ function storeError(res) {
1060
1088
  return (err && (err.message || err.code)) || `HTTP ${res.status}`;
1061
1089
  }
1062
1090
 
1091
+ // ---------------------------------------------------------------------------
1092
+ // bongos module store — what the store lists (task 1003811)
1093
+ // ---------------------------------------------------------------------------
1094
+
1095
+ const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
1096
+
1097
+ // The query GET /store/modules takes, from argv. Returns { query } or { error }.
1098
+ // The server checks the values too; this only refuses what is not a value at all.
1099
+ function storeQuery(args) {
1100
+ const params = [];
1101
+ for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
1102
+ const i = args.indexOf(flag);
1103
+ if (i === -1) continue;
1104
+ const v = args[i + 1];
1105
+ if (!v || v.startsWith('--')) return { error: `${flag} needs a value` };
1106
+ params.push(`${param}=${encodeURIComponent(v)}`);
1107
+ }
1108
+ return { query: params.length ? `?${params.join('&')}` : '' };
1109
+ }
1110
+
1111
+ function formatPrice(price) {
1112
+ if (!price || price.free) return 'free';
1113
+ const parts = [];
1114
+ if (price.cents != null) parts.push(`$${(price.cents / 100).toFixed(2)}`);
1115
+ if (price.credits != null) parts.push(`${price.credits} credits`);
1116
+ return parts.join(' or ') || 'free';
1117
+ }
1118
+
1119
+ function formatPlace(category) {
1120
+ if (!category) return '';
1121
+ return category.label ? `${category.parent_title} › ${category.label}` : category.parent_title;
1122
+ }
1123
+
1124
+ // Pure renderer over the GET /store/modules body. It keeps the server's order and
1125
+ // adds nothing to it: listed in rank, then New, never ranked.
1126
+ function formatStore(body) {
1127
+ const out = [];
1128
+ out.push('bongos module store — what the store lists, best first');
1129
+ out.push(` floor ${body.floor} out of 100 · at most ${body.cap} per sub-category`);
1130
+ out.push(` ${body.ranking}`);
1131
+ if (body.sort) {
1132
+ const part = (body.sort_parts || []).find((x) => x.part === body.sort);
1133
+ out.push(` sorted by ${part ? part.title : body.sort}`);
1134
+ }
1135
+ const max = body.max_price;
1136
+ if (max) out.push(` at most ${[max.cents != null ? `$${(max.cents / 100).toFixed(2)}` : null, max.credits != null ? `${max.credits} credits` : null].filter(Boolean).join(' or ')}`);
1137
+ out.push('');
1138
+ const listed = body.listed || [];
1139
+ if (!listed.length) out.push(' Nothing is listed yet.');
1140
+ listed.forEach((m, i) => {
1141
+ out.push(` ${String(i + 1).padStart(2)}. ${m.key}@${m.version} ${m.overall}/100 ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
1142
+ });
1143
+ const fresh = body.new || [];
1144
+ if (fresh.length) {
1145
+ out.push('');
1146
+ out.push(' New — not ranked until projects have installed and used them:');
1147
+ for (const m of fresh) out.push(` ${m.key}@${m.version} ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
1148
+ }
1149
+ out.push('');
1150
+ out.push(' Why a module is not here: GET /store/modules/<key>/listing');
1151
+ return out.join('\n');
1152
+ }
1153
+
1154
+ async function cmdStore(args, { log = console.log, errlog = console.error, request } = {}) {
1155
+ const q = storeQuery(args);
1156
+ if (q.error) { errlog(`bongos module store: ${q.error}`); errlog(STORE_USAGE); return 2; }
1157
+ let call;
1158
+ try { call = request || storeRequest(); }
1159
+ catch (e) { errlog(`bongos module store: ${e.message}`); return 1; }
1160
+ let res;
1161
+ try { res = await call('GET', `/store/modules${q.query}`); }
1162
+ catch (e) { errlog(`bongos module store: the store could not be reached — ${e.message}`); return 1; }
1163
+ if (!res.ok) { errlog(`bongos module store: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
1164
+ if (args.includes('--json')) { log(JSON.stringify(res.data, null, 2)); return 0; }
1165
+ log(formatStore(res.data || {}));
1166
+ return 0;
1167
+ }
1168
+
1063
1169
  // Where install places a module: the INSTANCE's own modules/ root (ADR 0108 §1),
1064
1170
  // which the loader discovers. In a single checkout that is this repo's modules/.
1065
1171
  function instanceModulesDir() {
@@ -1319,6 +1425,8 @@ function printHelp(out = console.log) {
1319
1425
  out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
1320
1426
  out(' bongos module list [--catalog] [--search <text>] [--json]');
1321
1427
  out(' Browse the module catalog — what you can enable, with author + origin credit');
1428
+ out(' bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]');
1429
+ out(' What the store lists, best first: above the quality floor, ranked, ten per sub-category');
1322
1430
  out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
1323
1431
  out(' then list newer store versions of installed modules (never blocks)');
1324
1432
  out(' bongos module check <key> [--sign-off "Name <email>"]');
@@ -1338,6 +1446,7 @@ async function main(argv) {
1338
1446
  const sub = args[0];
1339
1447
  if (sub === 'new') return cmdNew(args.slice(1));
1340
1448
  if (sub === 'list' || sub === 'ls') return cmdList(args.slice(1));
1449
+ if (sub === 'store') return cmdStore(args.slice(1));
1341
1450
  if (sub === 'upgrade') return cmdUpgrade(args.slice(1));
1342
1451
  if (sub === 'check') return cmdCheck(args.slice(1));
1343
1452
  if (sub === 'submit') return cmdSubmit(args.slice(1));
@@ -1354,7 +1463,7 @@ async function main(argv) {
1354
1463
  }
1355
1464
 
1356
1465
  module.exports = {
1357
- main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
1466
+ main, cmdNew, cmdList, cmdStore, formatStore, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
1358
1467
  storeRequest,
1359
1468
  gatherCatalog, formatCatalog, ownProjectName,
1360
1469
  scaffoldModule, gatherUpgrade, evaluateUpgrade, gatherUpdates, evaluateUpdates, buildManifest,
@@ -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.3'; // 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,184 @@
1
+ // tests/module_store_cli.mjs — `bongos module store` and the hall read ONE catalog
2
+ // (task 1003811, criterion wa5-curated-listing). The terminal command asks the
3
+ // real GET /store/modules handler here — off the real router, with pool.js replaced
4
+ // in the require cache (the module_store_listing.mjs harness) — so the test pins
5
+ // what the task asks: the same query through the API and through the CLI returns
6
+ // the same modules in the same order, capped the same way. Two surfaces that drift
7
+ // on which modules are "the best" is worse than either being wrong alone.
8
+ //
9
+ // Run: node tests/module_store_cli.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+
15
+ const require = createRequire(import.meta.url);
16
+
17
+ // ---- the fake registry the route reads ----
18
+ const state = { modules: {}, versions: [], scores: [], prices: [], categories: {} };
19
+ const newestBy = (rows, keyOf, newer) => {
20
+ const out = new Map();
21
+ for (const r of rows) {
22
+ const k = keyOf(r);
23
+ if (!out.has(k) || newer(r, out.get(k))) out.set(k, r);
24
+ }
25
+ return [...out.values()];
26
+ };
27
+ const pool = {
28
+ async query(sql, params = []) {
29
+ if (/FROM store_module_categories c\b/.test(sql)) {
30
+ return { rows: params[0].filter((k) => state.categories[k]).map((k) => ({ module_key: k, ...state.categories[k] })) };
31
+ }
32
+ if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
33
+ return {
34
+ rows: state.versions
35
+ .filter((v) => state.modules[v.module_key] && state.modules[v.module_key].status === 'listed')
36
+ .filter((v) => !params[0] || params[0].includes(v.module_key))
37
+ .map((v) => ({ ...v, version_id: v.id, title: state.modules[v.module_key].title })),
38
+ };
39
+ }
40
+ if (/FROM store_module_prices/.test(sql)) {
41
+ return { rows: newestBy(state.prices.filter((p) => params[0].includes(p.module_key)), (p) => p.module_key, (a, b) => a.set_at > b.set_at) };
42
+ }
43
+ if (/FROM module_assessment_scores/.test(sql)) {
44
+ const ids = params[0].map(String);
45
+ return { rows: newestBy(state.scores.filter((s) => ids.includes(String(s.version_id))), (s) => s.version_id, (a, b) => a.computed_at > b.computed_at) };
46
+ }
47
+ return { rows: [] };
48
+ },
49
+ };
50
+ const poolPath = require.resolve('../src/bongos/pool.js');
51
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
52
+
53
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
54
+ const mod = require('../scripts/bongos/module.js');
55
+
56
+ function storeHandler() {
57
+ const layer = buildModulesRouter().stack.find((l) => l.route && l.route.path === '/store/modules' && l.route.methods.get);
58
+ const stack = layer.route.stack;
59
+ return stack[stack.length - 1].handle;
60
+ }
61
+
62
+ // The API, as a browser or the hall reads it.
63
+ async function apiGet(query) {
64
+ const res = {
65
+ statusCode: 200, body: undefined,
66
+ json(b) { this.body = b; return this; },
67
+ status(c) { this.statusCode = c; return this; },
68
+ set() { return this; },
69
+ fail(code, opts) { this.statusCode = opts.status; this.body = { error: { code, message: opts.message } }; return this; },
70
+ };
71
+ let nextErr = null;
72
+ await storeHandler()({ builder: { id: 0 }, query }, res, (err) => { nextErr = err; });
73
+ if (nextErr) throw nextErr;
74
+ return res;
75
+ }
76
+
77
+ // The CLI's request function, answered by the same handler: the URL the CLI
78
+ // builds is parsed back into the query the route reads.
79
+ const request = async (method, urlPath) => {
80
+ assert.equal(method, 'GET');
81
+ const url = new URL(urlPath, 'http://store.test');
82
+ assert.equal(url.pathname, '/store/modules');
83
+ const res = await apiGet(Object.fromEntries(url.searchParams));
84
+ return { ok: res.statusCode < 400, status: res.statusCode, data: res.body };
85
+ };
86
+
87
+ async function cliJson(args) {
88
+ const lines = [];
89
+ const errs = [];
90
+ const code = await mod.cmdStore([...args, '--json'], { log: (l) => lines.push(l), errlog: (l) => errs.push(l), request });
91
+ return { code, body: lines.length ? JSON.parse(lines.join('\n')) : null, errs };
92
+ }
93
+
94
+ const keys = (b) => b.listed.map((m) => m.key);
95
+
96
+ // Fifteen Stripe modules above 94 (the owner's example), one module in a proposed
97
+ // sub-category, one with no category, one New, and one under the floor.
98
+ function seed() {
99
+ state.modules = {}; state.versions = []; state.scores = []; state.prices = []; state.categories = {};
100
+ const add = (key, s, cat) => {
101
+ const id = state.versions.length + 1;
102
+ state.modules[key] = { status: 'listed', title: key };
103
+ state.versions.push({ id, module_key: key, version: '1.0.0', published_at: 't' });
104
+ state.scores.push({ id, version_id: id, computed_at: 1, overall: 70, security_passed: true, is_new: false, ...s });
105
+ if (cat) state.categories[key] = cat;
106
+ };
107
+ const stripe = { parent: 'money', sub_key: 'stripe-payments', label: 'Stripe payments', status: 'approved' };
108
+ for (let i = 0; i < 15; i++) add(`stripe-${String(i).padStart(2, '0')}`, { overall: 99 - i }, stripe);
109
+ add('waiting', { overall: 60 }, { parent: 'money', sub_key: 'stripe-billing', label: 'Stripe billing', status: 'proposed' });
110
+ add('loose', { overall: 88 });
111
+ add('fresh', { is_new: true, overall: null });
112
+ add('weak', { overall: 20 });
113
+ state.prices = [
114
+ { module_key: 'stripe-01', price_cents: 900, price_credits: null, set_at: 1 },
115
+ { module_key: 'loose', price_cents: null, price_credits: 40, set_at: 1 },
116
+ ];
117
+ }
118
+
119
+ test('the terminal shows what the API lists, in its order, capped the same way', async () => {
120
+ seed();
121
+ const api = (await apiGet({})).body;
122
+ const cli = await cliJson([]);
123
+ assert.equal(cli.code, 0);
124
+ assert.deepEqual(cli.body, api, 'the CLI hands back the catalog exactly as the API answers it');
125
+ assert.equal(keys(api).filter((k) => k.startsWith('stripe-')).length, 10, 'the cap of ten holds');
126
+ assert.ok(!keys(api).includes('stripe-10') && !keys(api).includes('weak'), 'the eleventh and the one under the floor are not listed');
127
+ assert.ok(keys(api).includes('waiting') && keys(api).includes('loose'), 'a proposed sub-category and no category are not capped');
128
+ assert.deepEqual(api.new.map((m) => m.key), ['fresh']);
129
+ });
130
+
131
+ test('the same query through both: a sort and a maximum price give the same modules in the same order', async () => {
132
+ seed();
133
+ for (const [args, query] of [
134
+ [['--sort', 'tests'], { sort: 'tests' }],
135
+ [['--max-cents', '500'], { max_cents: '500' }],
136
+ [['--max-credits', '0'], { max_credits: '0' }],
137
+ [['--sort', 'docs', '--max-cents', '1000', '--max-credits', '100'], { sort: 'docs', max_cents: '1000', max_credits: '100' }],
138
+ ]) {
139
+ const api = (await apiGet(query)).body;
140
+ const cli = await cliJson(args);
141
+ assert.equal(cli.code, 0, args.join(' '));
142
+ assert.deepEqual(keys(cli.body), keys(api), args.join(' '));
143
+ assert.deepEqual(cli.body.new.map((m) => m.key), api.new.map((m) => m.key), args.join(' '));
144
+ }
145
+ });
146
+
147
+ test('the text view keeps the API order: listed ranked, then New, and says the floor and the cap', async () => {
148
+ seed();
149
+ const api = (await apiGet({})).body;
150
+ const lines = [];
151
+ assert.equal(await mod.cmdStore([], { log: (l) => lines.push(l), errlog: () => {}, request }), 0);
152
+ const text = lines.join('\n');
153
+ assert.match(text, /floor 50 out of 100 · at most 10 per sub-category/);
154
+ const at = (k) => text.indexOf(`${k}@`);
155
+ const order = [...keys(api), ...api.new.map((m) => m.key)].map(at);
156
+ assert.ok(order.every((i) => i !== -1), 'every listed and New module is shown');
157
+ assert.deepEqual(order, [...order].sort((a, b) => a - b), 'in the order the API gave');
158
+ assert.equal(at('stripe-10'), -1, 'the eleventh is not shown');
159
+ assert.match(text, /stripe-00@1\.0\.0 {2}99\/100 {2}free {2}Money › Stripe payments/);
160
+ assert.match(text, /stripe-01@1\.0\.0 {2}98\/100 {2}\$9\.00/);
161
+ assert.match(text, /loose@1\.0\.0 {2}88\/100 {2}40 credits$/m, 'no category, no place');
162
+ assert.match(text, /waiting@1\.0\.0 {2}60\/100 {2}free {2}Money$/m, 'a proposed sub-category shows its parent only');
163
+ assert.match(text, /New — not ranked[\s\S]*fresh@1\.0\.0/);
164
+ });
165
+
166
+ test('a value the server refuses, a flag with no value, and no session are reported, not swallowed', async () => {
167
+ seed();
168
+ const bad = await cliJson(['--sort', 'security']);
169
+ assert.equal(bad.code, 2);
170
+ assert.match(bad.errs.join('\n'), /the store answered/);
171
+ const missing = await cliJson(['--max-cents']);
172
+ assert.equal(missing.code, 2);
173
+ assert.match(missing.errs.join('\n'), /--max-cents needs a value/);
174
+ const errs = [];
175
+ const offline = await mod.cmdStore([], { log: () => {}, errlog: (l) => errs.push(l), request: async () => { throw new Error('ECONNREFUSED'); } });
176
+ assert.equal(offline, 1);
177
+ assert.match(errs.join('\n'), /could not be reached — ECONNREFUSED/);
178
+ });
179
+
180
+ test('`bongos module store` is dispatched and documented in the help', async () => {
181
+ const out = [];
182
+ mod.printHelp((l) => out.push(l));
183
+ assert.match(out.join('\n'), /bongos module store \[--sort <part>\]/);
184
+ });