@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/.bongos-core.json +45 -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 +4 -0
- package/docs/modules-contract.md +3 -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 +12 -0
- package/scripts/bongos/module.js +115 -6
- 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_cli.mjs +184 -0
- package/tests/module_store_withdraw.mjs +201 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
}
|
package/scripts/bongos/module.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,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
|
+
});
|