@bongos/core 1.20.69 → 1.20.71
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 +48 -28
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +50 -3
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/module-assess-score.js +111 -0
- package/scripts/gds/module-assess-security.js +1 -0
- package/scripts/gds/module-assess-tests.js +1 -0
- package/scripts/gds/module-assess-version.js +82 -0
- package/scripts/gds/module.js +85 -7
- package/scripts/gds/upgrade.js +1 -1
- package/src/bongos/module-store.js +25 -1
- package/src/bongos/routes/modules.js +33 -1
- package/src/module-api.js +1 -1
- package/tests/module_assess_score.mjs +276 -0
- package/tests/module_cli.mjs +7 -7
- package/tests/module_upgrade_updates.mjs +256 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/module-assess-version.js — re-assess a store version when it is
|
|
3
|
+
// published: record Tests as pending, run the Security gate, compose the score
|
|
4
|
+
// (task 1003794; ADR 0343; tables from core_265).
|
|
5
|
+
//
|
|
6
|
+
// WHY. A new version is a new thing to judge, so the store's publish route queues
|
|
7
|
+
// this after it answers the author. Tests is recorded `pending`, and only when the
|
|
8
|
+
// version has no Tests result yet: a published module's tests run only in the
|
|
9
|
+
// separate test environment, never on the control plane (module-assess-tests.js).
|
|
10
|
+
// The Security gate reads the tarball and asks npm about the declared dependencies
|
|
11
|
+
// — it never runs the module's code, so it is safe here. Then the score is
|
|
12
|
+
// composed (module-assess-score.js). Install, Reliability and Tester feedback
|
|
13
|
+
// arrive later from their own tasks; each recompose picks them up.
|
|
14
|
+
//
|
|
15
|
+
// The route goes through queueAssessment, so the web process runs one assessment
|
|
16
|
+
// at a time (the gate's npm resolve + audit can take two minutes) and holds at most
|
|
17
|
+
// QUEUE_MAX waiting; past that a publish is logged as not assessed, and the CLI
|
|
18
|
+
// below re-runs it. This file is apart from the composer so the signal CLIs can
|
|
19
|
+
// recompose without a require cycle (composer <- security, tests, this).
|
|
20
|
+
//
|
|
21
|
+
// node scripts/gds/module-assess-version.js <store_module_versions.id> assess + print
|
|
22
|
+
|
|
23
|
+
const { recomposeScore } = require('./module-assess-score');
|
|
24
|
+
|
|
25
|
+
// Re-assess one version (on publish): Tests pending, the Security gate, then the
|
|
26
|
+
// score. `recordSecurity` is injectable so tests need no npm. Never throws for a
|
|
27
|
+
// failed measurement — that is recorded as the signal's outcome — and resolves
|
|
28
|
+
// { ok: false, code } only when the version does not exist.
|
|
29
|
+
async function assessVersion(versionId, { db, recordSecurity } = {}) {
|
|
30
|
+
const pool = db || require('../../src/bongos/pool').pool;
|
|
31
|
+
const { rows: [ver] } = await pool.query(
|
|
32
|
+
'SELECT id, module_key, version FROM store_module_versions WHERE id = $1', [versionId]);
|
|
33
|
+
if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
|
|
34
|
+
|
|
35
|
+
// Only a version with no Tests result yet: a re-assess must never hide a real
|
|
36
|
+
// score behind a newer "pending".
|
|
37
|
+
await pool.query(
|
|
38
|
+
`INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
|
|
39
|
+
SELECT $1::bigint, 'tests', 'pending', NULL, NULL, $2::text, '{}'::jsonb
|
|
40
|
+
WHERE NOT EXISTS (SELECT 1 FROM module_assessment_signals WHERE version_id = $1::bigint AND part = 'tests')`,
|
|
41
|
+
[ver.id, 'waiting for the separate test environment: a published module\'s tests never run on the control plane']);
|
|
42
|
+
const record = recordSecurity || require('./module-assess-security').recordSecuritySignal;
|
|
43
|
+
const security = await record(ver.id, { db: pool });
|
|
44
|
+
const composed = await recomposeScore(ver.id, { db: pool });
|
|
45
|
+
return { ok: true, version: ver, security: security && security.signal, score: composed.score };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// One assessment at a time in this process, at most QUEUE_MAX waiting. Resolves
|
|
49
|
+
// at once with whether the version was queued; a failed assessment is logged and
|
|
50
|
+
// never stops the next one. `run` is injectable for tests.
|
|
51
|
+
const QUEUE_MAX = 20;
|
|
52
|
+
let tail = Promise.resolve();
|
|
53
|
+
let waiting = 0;
|
|
54
|
+
function queueAssessment(versionId, { log = console, run = (id) => module.exports.assessVersion(id) } = {}) {
|
|
55
|
+
if (waiting >= QUEUE_MAX) {
|
|
56
|
+
log.warn({ versionId }, `assessment queue full (${QUEUE_MAX} waiting): version not assessed; run module-assess-version.js ${versionId}`);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
waiting += 1;
|
|
60
|
+
tail = tail.then(() => run(versionId))
|
|
61
|
+
.catch((e) => { log.error({ versionId, err: e && e.message }, 'assessment after publish failed'); })
|
|
62
|
+
.finally(() => { waiting -= 1; });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function main(argv) {
|
|
67
|
+
const id = argv[0];
|
|
68
|
+
if (!/^\d+$/.test(String(id || ''))) {
|
|
69
|
+
console.error('usage: node scripts/gds/module-assess-version.js <store_module_versions.id>');
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
const res = await assessVersion(id);
|
|
73
|
+
if (!res.ok) { console.error(res.message); return 1; }
|
|
74
|
+
console.log(`version ${id}: ${res.score.formula}`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (require.main === module) {
|
|
79
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { assessVersion, queueAssessment, QUEUE_MAX };
|
package/scripts/gds/module.js
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
// bongos module new <key> scaffold modules/<key>/
|
|
59
59
|
// bongos module new <key> --force overwrite an existing modules/<key>/
|
|
60
60
|
// bongos module list [--catalog] browse the module catalog (author + origin credit)
|
|
61
|
-
// bongos upgrade
|
|
61
|
+
// bongos upgrade [--no-store] pre-check enabled modules vs the core version + list store updates
|
|
62
62
|
// bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
|
|
63
63
|
// bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
|
|
64
64
|
// bongos module update <key> [--version X.Y.Z] move an installed store module forward
|
|
@@ -371,10 +371,10 @@ function gatherUpgrade({ modulesDir = MODULES_DIR, core = coreVersion(), env = p
|
|
|
371
371
|
|
|
372
372
|
const rows = [];
|
|
373
373
|
for (const { key, manifest } of loaded) {
|
|
374
|
-
rows.push({ key, coreVersion: manifest.coreVersion, enabled: enabled[key] === true, satisfied: true, ...upkeep(manifest) });
|
|
374
|
+
rows.push({ key, version: manifest.version || null, coreVersion: manifest.coreVersion, enabled: enabled[key] === true, satisfied: true, ...upkeep(manifest) });
|
|
375
375
|
}
|
|
376
376
|
for (const { key, manifest, reason } of skippedFull) {
|
|
377
|
-
rows.push({ key, coreVersion: manifest ? manifest.coreVersion : null, enabled: enabled[key] === true, satisfied: false, reason, ...upkeep(manifest) });
|
|
377
|
+
rows.push({ key, version: (manifest && manifest.version) || null, coreVersion: manifest ? manifest.coreVersion : null, enabled: enabled[key] === true, satisfied: false, reason, ...upkeep(manifest) });
|
|
378
378
|
}
|
|
379
379
|
rows.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
380
380
|
return { rows, errors, core };
|
|
@@ -663,7 +663,76 @@ function cmdNew(args, { log = console.log, errlog = console.error } = {}) {
|
|
|
663
663
|
return 0;
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
-
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// Available module updates — the store half of `bongos upgrade` (task 1003787)
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
// Ask the store for the newest version of every module here that carries an exact
|
|
671
|
+
// version (the I/O half). A key the store does not know is absent from the answer,
|
|
672
|
+
// so a module built here, or one the core ships, simply has nothing to report.
|
|
673
|
+
// Never throws: no session or an unreachable store comes back as
|
|
674
|
+
// { checked: false, reason }, because the compatibility pre-check must work
|
|
675
|
+
// offline and must never fail on the store's account.
|
|
676
|
+
async function gatherUpdates(rows, { request } = {}) {
|
|
677
|
+
const keys = rows.filter((r) => r.version && parseVersion(String(r.version))).map((r) => r.key);
|
|
678
|
+
if (!keys.length) return { checked: true, latest: {} };
|
|
679
|
+
let call;
|
|
680
|
+
try { call = request || storeRequest(); }
|
|
681
|
+
catch (e) { return { checked: false, reason: e.message }; }
|
|
682
|
+
let res;
|
|
683
|
+
try { res = await call('GET', `/store/modules/latest?keys=${keys.map(encodeURIComponent).join(',')}`); }
|
|
684
|
+
catch (e) { return { checked: false, reason: `the store could not be reached — ${e.message}` }; }
|
|
685
|
+
// A 404 here is a server whose core predates this route, not a missing module.
|
|
686
|
+
if (res.status === 404) return { checked: false, reason: "this server's store does not answer update checks yet (its core is older)" };
|
|
687
|
+
if (!res.ok) return { checked: false, reason: `the store answered ${storeError(res)}` };
|
|
688
|
+
return { checked: true, latest: (res.data && res.data.latest) || {} };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Decide what is stale from rows + the store's answer (pure — tests drive it).
|
|
692
|
+
// UPDATES = a module whose store version is newer than the one installed; `fits`
|
|
693
|
+
// says whether that newer version can mount on this core (when it cannot, the
|
|
694
|
+
// update waits for the core — it is not something to run now). DELISTED = an
|
|
695
|
+
// installed module the store has delisted: it keeps working, but no further
|
|
696
|
+
// versions will come (ADR 0338 D2). Neither ever blocks — an update is news, and
|
|
697
|
+
// what blocks `bongos upgrade` is still only an incompatible ENABLED module.
|
|
698
|
+
function evaluateUpdates(rows = [], latest = {}, core) {
|
|
699
|
+
const { satisfies } = require('../../src/module-loader/semver');
|
|
700
|
+
const updates = [];
|
|
701
|
+
const delisted = [];
|
|
702
|
+
for (const r of rows) {
|
|
703
|
+
const store = latest[r.key];
|
|
704
|
+
const have = r.version && parseVersion(String(r.version));
|
|
705
|
+
if (!store || !have) continue;
|
|
706
|
+
if (store.status === 'delisted') { delisted.push({ key: r.key, installed: r.version, enabled: r.enabled }); continue; }
|
|
707
|
+
const next = parseVersion(String(store.version));
|
|
708
|
+
if (!next || cmp(next, have) <= 0) continue;
|
|
709
|
+
let fits = false;
|
|
710
|
+
try { fits = !!(core && store.core_version && satisfies(core, store.core_version)); } catch { fits = false; }
|
|
711
|
+
updates.push({ key: r.key, installed: r.version, latest: store.version, coreVersion: store.core_version, enabled: r.enabled, fits });
|
|
712
|
+
}
|
|
713
|
+
return { updates, delisted };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function reportUpdates(found, verdict, { log, errlog }) {
|
|
717
|
+
log('\nModule updates (from the store):');
|
|
718
|
+
if (!found.checked) {
|
|
719
|
+
log(` not checked — ${found.reason}. The compatibility check above still stands.`);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const { updates, delisted } = evaluateUpdates(verdict.rows, found.latest, verdict.core);
|
|
723
|
+
for (const u of updates) {
|
|
724
|
+
const how = u.fits
|
|
725
|
+
? `run \`bongos module update ${u.key}\``
|
|
726
|
+
: `needs core ${u.coreVersion}; this core is ${verdict.core}, so it waits until the core moves`;
|
|
727
|
+
log(` ↑ ${u.key.padEnd(18)} ${u.installed} → ${u.latest} — ${how}`);
|
|
728
|
+
}
|
|
729
|
+
for (const d of delisted) {
|
|
730
|
+
errlog(` ! ${d.key.padEnd(18)} ${d.installed} — delisted in the store: it keeps working, but no further versions will come`);
|
|
731
|
+
}
|
|
732
|
+
if (!updates.length && !delisted.length) log(' every installed store module is on its newest version.');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function cmdUpgrade(args = [], { log = console.log, errlog = console.error, gather = gatherUpgrade, checkUpdates = gatherUpdates } = {}) {
|
|
667
736
|
const data = gather();
|
|
668
737
|
const verdict = evaluateUpgrade(data);
|
|
669
738
|
|
|
@@ -701,6 +770,11 @@ function cmdUpgrade(_args, { log = console.log, errlog = console.error, gather =
|
|
|
701
770
|
errlog(` ! ${verdict.undeclared.length} module(s) declare no \`maintenance\` block — nobody is on the hook for them as the core advances (ADR 0166): ${verdict.undeclared.map((r) => r.key).join(', ')}`);
|
|
702
771
|
}
|
|
703
772
|
|
|
773
|
+
// What is stale here, beside what is broken: the store's newer versions of the
|
|
774
|
+
// modules installed from it. Reported either way, and it never changes the exit
|
|
775
|
+
// code. `--no-store` skips the read (offline, or a CI pre-check).
|
|
776
|
+
if (!args.includes('--no-store')) reportUpdates(await checkUpdates(verdict.rows), verdict, { log, errlog });
|
|
777
|
+
|
|
704
778
|
if (!verdict.ok) {
|
|
705
779
|
errlog(`\nREFUSING: ${verdict.blocking.length} enabled module(s) incompatible with core ${verdict.core}: ${verdict.blocking.map((b) => b.key).join(', ')}.`);
|
|
706
780
|
errlog(`Widen each module's coreVersion to a range that includes ${verdict.core} (or hold the core upgrade) before proceeding.`);
|
|
@@ -1200,7 +1274,8 @@ function printHelp(out = console.log) {
|
|
|
1200
1274
|
out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
|
|
1201
1275
|
out(' bongos module list [--catalog] [--search <text>] [--json]');
|
|
1202
1276
|
out(' Browse the module catalog — what you can enable, with author + origin credit');
|
|
1203
|
-
out(' bongos upgrade
|
|
1277
|
+
out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
|
|
1278
|
+
out(' then list newer store versions of installed modules (never blocks)');
|
|
1204
1279
|
out(' bongos module check <key> [--sign-off "Name <email>"]');
|
|
1205
1280
|
out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
|
|
1206
1281
|
out(' bongos module submit <key> [--sign-off "Name <email>"]');
|
|
@@ -1236,7 +1311,7 @@ async function main(argv) {
|
|
|
1236
1311
|
module.exports = {
|
|
1237
1312
|
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
1238
1313
|
gatherCatalog, formatCatalog, ownProjectName,
|
|
1239
|
-
scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
|
|
1314
|
+
scaffoldModule, gatherUpgrade, evaluateUpgrade, gatherUpdates, evaluateUpdates, buildManifest,
|
|
1240
1315
|
formatMaintenanceHint, isPastRemoveAfter,
|
|
1241
1316
|
checkModulePublishability, recordSignOff, readSignOff, DCO_STATEMENT,
|
|
1242
1317
|
listModuleFiles, packageModuleForSubmission,
|
|
@@ -1245,8 +1320,11 @@ module.exports = {
|
|
|
1245
1320
|
};
|
|
1246
1321
|
|
|
1247
1322
|
if (require.main === module) {
|
|
1323
|
+
// Set the exit code and drain, never a hard exit: install, update and upgrade end
|
|
1324
|
+
// after a store request, and a hard exit there aborts natively on Windows + Node 24
|
|
1325
|
+
// (task 1002734).
|
|
1248
1326
|
Promise.resolve(main(process.argv)).then(
|
|
1249
|
-
(code) => process.
|
|
1327
|
+
(code) => { process.exitCode = code; },
|
|
1250
1328
|
(e) => { console.error(e); process.exit(1); }
|
|
1251
1329
|
);
|
|
1252
1330
|
}
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -1495,5 +1495,5 @@ module.exports = {
|
|
|
1495
1495
|
};
|
|
1496
1496
|
|
|
1497
1497
|
if (require.main === module) {
|
|
1498
|
-
main().then((code) => process.exit(code)).catch((e) => { process.stderr.write(`✖ ${e.message}\n`); process.exit(1); });
|
|
1498
|
+
main().then((code) => { if (arg('--to', process.argv.slice(2))) process.exit(code); else process.exitCode = code; }).catch((e) => { process.stderr.write(`✖ ${e.message}\n`); process.exit(1); }); // bare pre-check drains: it ends after a store read (task 1003787), and a hard exit there aborts on Windows (task 1002734)
|
|
1499
1499
|
}
|
|
@@ -208,6 +208,30 @@ async function publishedVersionSet(keys, { db } = {}) {
|
|
|
208
208
|
return new Set(rows.map((r) => `${r.module_key}@${r.version}`));
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// The newest published version of each of these modules, for `bongos upgrade`'s
|
|
212
|
+
// "updates available" report (task 1003787). Read-only: unlike resolveAcquirable it
|
|
213
|
+
// takes no entitlement and refuses nothing, so a delisted module still answers,
|
|
214
|
+
// with its status, and the caller says it gets no further versions (ADR 0338 D2).
|
|
215
|
+
// A key the store does not know is simply absent. One query for the whole list.
|
|
216
|
+
// Returns { [key]: { version, core_version, status } }.
|
|
217
|
+
async function latestVersions(keys, { db } = {}) {
|
|
218
|
+
if (!keys.length) return {};
|
|
219
|
+
const pool = db || require('./pool').pool;
|
|
220
|
+
const { rows } = await pool.query(
|
|
221
|
+
`SELECT v.module_key, v.version, v.core_version, m.status
|
|
222
|
+
FROM store_module_versions v JOIN store_modules m ON m.module_key = v.module_key
|
|
223
|
+
WHERE v.module_key = ANY($1)`, [keys]);
|
|
224
|
+
const out = {};
|
|
225
|
+
for (const r of rows) {
|
|
226
|
+
const next = parseVersion(r.version);
|
|
227
|
+
if (!next) continue;
|
|
228
|
+
const have = out[r.module_key];
|
|
229
|
+
if (have && cmp(parseVersion(have.version), next) >= 0) continue;
|
|
230
|
+
out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status };
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
211
235
|
// The tarball on disk for a version row. artifact_path is relative to the store dir
|
|
212
236
|
// (relativeArtifactPath), and artifactPath() re-validates key + version, so a row
|
|
213
237
|
// can never point a read outside the store.
|
|
@@ -221,5 +245,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
|
221
245
|
|
|
222
246
|
module.exports = {
|
|
223
247
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
224
|
-
isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, versionArtifactFile,
|
|
248
|
+
isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
225
249
|
};
|
|
@@ -78,10 +78,11 @@ const modulesLib = require('../../modules');
|
|
|
78
78
|
const moduleCli = require('../../../scripts/gds/module');
|
|
79
79
|
const moduleSubmissions = require('../module-submissions');
|
|
80
80
|
const moduleArtifact = require('../../../scripts/gds/module-artifact');
|
|
81
|
+
const moduleAssessVersion = require('../../../scripts/gds/module-assess-version');
|
|
81
82
|
const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
|
|
82
83
|
const {
|
|
83
84
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
84
|
-
resolveAcquirable, resolveReadable, publishedVersionSet, versionArtifactFile,
|
|
85
|
+
resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
85
86
|
} = require('../module-store');
|
|
86
87
|
const { KEY_RE } = require('../../module-loader/manifest-schema');
|
|
87
88
|
const { parseVersion } = require('../../module-loader/semver');
|
|
@@ -154,6 +155,11 @@ function listModules() {
|
|
|
154
155
|
// the oldest entry is dropped.
|
|
155
156
|
const HOWTO_CACHE_MAX = 200;
|
|
156
157
|
|
|
158
|
+
// How many module keys one GET /store/modules/latest may ask about (task 1003787).
|
|
159
|
+
// An instance installs a handful; the cap keeps the query string and the ANY()
|
|
160
|
+
// list bounded.
|
|
161
|
+
const STORE_LATEST_MAX_KEYS = 200;
|
|
162
|
+
|
|
157
163
|
module.exports = function buildModulesRouter() {
|
|
158
164
|
const router = express.Router();
|
|
159
165
|
// Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
|
|
@@ -388,6 +394,12 @@ module.exports = function buildModulesRouter() {
|
|
|
388
394
|
|
|
389
395
|
console.log(`[gds] module "${key}" ${v.version} published to the store by builder ${req.builder.id}`);
|
|
390
396
|
res.status(201).json({ ok: true, created_module: result.created, version: result.version });
|
|
397
|
+
// A new version is a new thing to judge (task 1003794, ADR 0343): queue its
|
|
398
|
+
// assessment after answering, since the Security gate asks npm about its
|
|
399
|
+
// dependencies and can take a minute. The queue runs one at a time and is
|
|
400
|
+
// capped; a failure is logged, never the author's error, and
|
|
401
|
+
// `node scripts/gds/module-assess-version.js <id>` re-runs it.
|
|
402
|
+
setImmediate(() => moduleAssessVersion.queueAssessment(result.version.id, { log }));
|
|
391
403
|
}));
|
|
392
404
|
|
|
393
405
|
// GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
|
|
@@ -565,6 +577,26 @@ module.exports = function buildModulesRouter() {
|
|
|
565
577
|
});
|
|
566
578
|
}));
|
|
567
579
|
|
|
580
|
+
// The newest published version of each named module — what `bongos upgrade`
|
|
581
|
+
// compares an instance's installed store modules against to report the updates
|
|
582
|
+
// available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS.
|
|
583
|
+
//
|
|
584
|
+
// rank: any-builder — a read of public store metadata (version numbers and the
|
|
585
|
+
// core range each needs, the same facts the how-to page shows). It takes no
|
|
586
|
+
// entitlement and changes nothing, unlike acquire; a key the store does not know
|
|
587
|
+
// is simply absent from the answer.
|
|
588
|
+
router.get('/store/modules/latest', auth.requireBuilder,
|
|
589
|
+
asyncHandler('GET /store/modules/latest', async (req, res) => {
|
|
590
|
+
const raw = typeof req.query.keys === 'string' ? req.query.keys : '';
|
|
591
|
+
const keys = [...new Set(raw.split(',').map((k) => k.trim()).filter(Boolean))];
|
|
592
|
+
if (!keys.length || keys.length > STORE_LATEST_MAX_KEYS || !keys.every((k) => KEY_RE.test(k))) {
|
|
593
|
+
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).` });
|
|
594
|
+
}
|
|
595
|
+
const latest = await latestVersions(keys);
|
|
596
|
+
res.set('Cache-Control', 'no-store');
|
|
597
|
+
res.json({ latest });
|
|
598
|
+
}));
|
|
599
|
+
|
|
568
600
|
router.post('/store/modules/:key/acquired', auth.requireBuilder,
|
|
569
601
|
asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
|
|
570
602
|
const { key } = req.params;
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.71'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// tests/module_assess_score.mjs — composing a store version's assessment signals
|
|
2
|
+
// into its one overall score, and re-assessing a version when it is published
|
|
3
|
+
// (task 1003794; ADR 0343 D2/D3/D5, ADR 0347 D5; tables from core_265). No real
|
|
4
|
+
// DB and no npm: the composer is pure, the writers run against a fake pool that
|
|
5
|
+
// keeps core_265's append-only rows in memory, and the Security gate is injected.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/module_assess_score.mjs
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const { composeScore, recomposeScore } = require('../scripts/gds/module-assess-score.js');
|
|
18
|
+
const { assessVersion, queueAssessment, QUEUE_MAX } = require('../scripts/gds/module-assess-version.js');
|
|
19
|
+
|
|
20
|
+
const sig = (id, part, outcome, score = null) => ({ id, part, outcome, score });
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// composeScore — the rule, pure
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
test('overall is the plain average of the scored averaged parts, rounded', () => {
|
|
27
|
+
const s = composeScore({
|
|
28
|
+
security: sig(1, 'security', 'passed'),
|
|
29
|
+
tests: sig(2, 'tests', 'scored', 80),
|
|
30
|
+
install: sig(3, 'install', 'scored', 95),
|
|
31
|
+
reliability: sig(4, 'reliability', 'scored', 70),
|
|
32
|
+
});
|
|
33
|
+
assert.equal(s.overall, 82, '(80 + 95 + 70) / 3 = 81.67 → 82');
|
|
34
|
+
assert.equal(s.security_passed, true);
|
|
35
|
+
assert.equal(s.is_new, false);
|
|
36
|
+
assert.deepEqual(s.signal_ids, [1, 2, 3, 4]);
|
|
37
|
+
assert.match(s.formula, /Security passed; overall 82 = average of Tests 80, Install 95, Reliability 70; left out \(no data\): Tester feedback/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a part with no data is left out, never a zero (D3)', () => {
|
|
41
|
+
const s = composeScore({
|
|
42
|
+
security: sig(1, 'security', 'passed'),
|
|
43
|
+
tests: sig(2, 'tests', 'scored', 60),
|
|
44
|
+
install: sig(3, 'install', 'no_data'),
|
|
45
|
+
reliability: sig(4, 'reliability', 'pending'),
|
|
46
|
+
tester_feedback: sig(5, 'tester_feedback', 'not_scored'),
|
|
47
|
+
});
|
|
48
|
+
assert.equal(s.overall, 60, 'only Tests counts');
|
|
49
|
+
assert.deepEqual(s.signal_ids, [1, 2], 'the score names only the rows it was composed from');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('Security is a gate, never averaged in (D2): a failed gate keeps its parts visible', () => {
|
|
53
|
+
const s = composeScore({ security: sig(1, 'security', 'failed'), tests: sig(2, 'tests', 'scored', 100) });
|
|
54
|
+
assert.equal(s.security_passed, false);
|
|
55
|
+
assert.equal(s.overall, 100, 'the gate does not lower the number — it keeps the version off the listing');
|
|
56
|
+
assert.match(s.formula, /Security FAILED \(not listed, whatever the score\)/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a Security check that has not run, or could not, is NULL — not a pass', () => {
|
|
60
|
+
assert.equal(composeScore({}).security_passed, null);
|
|
61
|
+
assert.equal(composeScore({ security: sig(1, 'security', 'not_scored') }).security_passed, null);
|
|
62
|
+
assert.match(composeScore({}).formula, /Security not checked yet/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('Docs is shown with the parts and never averaged (ADR 0347 D5)', () => {
|
|
66
|
+
const s = composeScore({ tests: sig(2, 'tests', 'scored', 50), docs: sig(9, 'docs', 'scored', 100) });
|
|
67
|
+
assert.equal(s.overall, 50);
|
|
68
|
+
assert.equal(s.signal_ids.includes(9), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('no averaged part with data → overall NULL, labelled New', () => {
|
|
72
|
+
const s = composeScore({ security: sig(1, 'security', 'passed'), tests: sig(2, 'tests', 'pending') });
|
|
73
|
+
assert.equal(s.overall, null);
|
|
74
|
+
assert.equal(s.is_new, true);
|
|
75
|
+
assert.match(s.formula, /no overall score yet: no averaged part has data; .*labelled New/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('New until Install or Reliability is scored (D5) — Tests and Tester feedback alone do not lift it', () => {
|
|
79
|
+
assert.equal(composeScore({ tests: sig(1, 'tests', 'scored', 90), tester_feedback: sig(2, 'tester_feedback', 'scored', 90) }).is_new, true);
|
|
80
|
+
assert.equal(composeScore({ reliability: sig(1, 'reliability', 'scored', 40) }).is_new, false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('price is never an input: nothing in the composer reads one (D6)', () => {
|
|
84
|
+
for (const f of ['module-assess-score.js', 'module-assess-version.js']) {
|
|
85
|
+
const src = fs.readFileSync(new URL(`../scripts/gds/${f}`, import.meta.url), 'utf8').replace(/\/\/.*$/gm, '');
|
|
86
|
+
assert.doesNotMatch(src, /price|credits|cents/i, f);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// recomposeScore / assessVersion — against an append-only fake
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function fakeDb({ versions = [{ id: 7, module_key: 'weather', version: '1.0.0' }] } = {}) {
|
|
95
|
+
const signals = [];
|
|
96
|
+
const scores = [];
|
|
97
|
+
let clock = 0;
|
|
98
|
+
const db = {
|
|
99
|
+
signals, scores,
|
|
100
|
+
async query(sql, params = []) {
|
|
101
|
+
if (/^SELECT id, module_key, version FROM store_module_versions WHERE id/.test(sql)) {
|
|
102
|
+
return { rows: versions.filter((v) => String(v.id) === String(params[0])) };
|
|
103
|
+
}
|
|
104
|
+
if (/SELECT DISTINCT ON \(part\)/.test(sql)) {
|
|
105
|
+
const cur = {};
|
|
106
|
+
for (const s of signals.filter((x) => String(x.version_id) === String(params[0]))) {
|
|
107
|
+
if (!cur[s.part] || s.t > cur[s.part].t || (s.t === cur[s.part].t && s.id > cur[s.part].id)) cur[s.part] = s;
|
|
108
|
+
}
|
|
109
|
+
return { rows: Object.values(cur) };
|
|
110
|
+
}
|
|
111
|
+
if (/FROM module_assessment_scores/.test(sql) && /^SELECT/.test(sql.trim())) {
|
|
112
|
+
const mine = scores.filter((x) => String(x.version_id) === String(params[0]) && x.kind === 'computed');
|
|
113
|
+
return { rows: mine.length ? [mine[mine.length - 1]] : [] };
|
|
114
|
+
}
|
|
115
|
+
if (/INSERT INTO module_assessment_scores/.test(sql)) {
|
|
116
|
+
const row = { id: scores.length + 1, version_id: params[0], kind: 'computed', overall: params[1], security_passed: params[2], is_new: params[3], formula: params[4], signal_ids: params[5].map(String) };
|
|
117
|
+
scores.push(row); return { rows: [row] };
|
|
118
|
+
}
|
|
119
|
+
if (/INSERT INTO module_assessment_signals/.test(sql) && /'pending'/.test(sql)) {
|
|
120
|
+
if (signals.some((s) => String(s.version_id) === String(params[0]) && s.part === 'tests')) return { rows: [] };
|
|
121
|
+
signals.push({ id: signals.length + 1, version_id: params[0], part: 'tests', outcome: 'pending', score: null, reason: params[1], t: ++clock });
|
|
122
|
+
return { rows: [] };
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`unexpected SQL: ${sql.slice(0, 80)}`);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
db.add = (versionId, part, outcome, score = null) => {
|
|
128
|
+
const row = { id: signals.length + 1, version_id: versionId, part, outcome, score, t: ++clock };
|
|
129
|
+
signals.push(row); return row;
|
|
130
|
+
};
|
|
131
|
+
return db;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('recompose reads the NEWEST signal per part, so a re-run moves the score', async () => {
|
|
135
|
+
const db = fakeDb();
|
|
136
|
+
db.add(7, 'security', 'passed');
|
|
137
|
+
db.add(7, 'tests', 'scored', 40);
|
|
138
|
+
const first = await recomposeScore(7, { db });
|
|
139
|
+
assert.equal(first.written, true);
|
|
140
|
+
assert.equal(first.score.overall, 40);
|
|
141
|
+
db.add(7, 'tests', 'scored', 90);
|
|
142
|
+
const second = await recomposeScore(7, { db });
|
|
143
|
+
assert.equal(second.score.overall, 90);
|
|
144
|
+
assert.equal(db.scores.length, 2, 'append-only: the history keeps the 40');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a recompose that would repeat the current score writes nothing', async () => {
|
|
148
|
+
const db = fakeDb();
|
|
149
|
+
db.add(7, 'tests', 'scored', 70);
|
|
150
|
+
await recomposeScore(7, { db });
|
|
151
|
+
const again = await recomposeScore(7, { db });
|
|
152
|
+
assert.equal(again.written, false);
|
|
153
|
+
assert.equal(db.scores.length, 1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('assessVersion on publish: Tests pending, the Security gate, then the score — and the version is New', async () => {
|
|
157
|
+
const db = fakeDb();
|
|
158
|
+
const seen = [];
|
|
159
|
+
const recordSecurity = async (id, { db: d }) => { seen.push(id); const row = d.add(id, 'security', 'passed'); return { ok: true, signal: row }; };
|
|
160
|
+
const res = await assessVersion(7, { db, recordSecurity });
|
|
161
|
+
assert.equal(res.ok, true);
|
|
162
|
+
assert.deepEqual(seen, [7]);
|
|
163
|
+
const tests = db.signals.find((s) => s.part === 'tests');
|
|
164
|
+
assert.equal(tests.outcome, 'pending', 'a published module\'s tests never run on the control plane');
|
|
165
|
+
assert.match(tests.reason, /separate test environment/);
|
|
166
|
+
assert.equal(res.score.security_passed, true);
|
|
167
|
+
assert.equal(res.score.overall, null);
|
|
168
|
+
assert.equal(res.score.is_new, true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('re-assessing never hides a real Tests score behind a newer "pending"', async () => {
|
|
172
|
+
const db = fakeDb();
|
|
173
|
+
db.add(7, 'tests', 'scored', 85);
|
|
174
|
+
const res = await assessVersion(7, { db, recordSecurity: async (id, { db: d }) => ({ ok: true, signal: d.add(id, 'security', 'passed') }) });
|
|
175
|
+
assert.equal(db.signals.filter((s) => s.part === 'tests').length, 1);
|
|
176
|
+
assert.equal(res.score.overall, 85);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('assessVersion on an unknown version writes nothing', async () => {
|
|
180
|
+
const db = fakeDb({ versions: [] });
|
|
181
|
+
const res = await assessVersion(99, { db, recordSecurity: async () => { throw new Error('must not run'); } });
|
|
182
|
+
assert.equal(res.ok, false);
|
|
183
|
+
assert.equal(res.code, 'version_not_found');
|
|
184
|
+
assert.equal(db.signals.length + db.scores.length, 0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// the queue — one assessment at a time, capped
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
const quietLog = () => { const lines = []; return { lines, warn: (o, m) => lines.push(['warn', m]), error: (o, m) => lines.push(['error', m, o.err]) }; };
|
|
192
|
+
const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
|
|
193
|
+
|
|
194
|
+
test('the queue runs one assessment at a time, in order', async () => {
|
|
195
|
+
const log = quietLog();
|
|
196
|
+
const order = [];
|
|
197
|
+
const g = gate();
|
|
198
|
+
const run = async (id) => { order.push(`start ${id}`); if (id === 1) await g.p; order.push(`end ${id}`); };
|
|
199
|
+
queueAssessment(1, { log, run });
|
|
200
|
+
queueAssessment(2, { log, run });
|
|
201
|
+
await new Promise((r) => setImmediate(r));
|
|
202
|
+
assert.deepEqual(order, ['start 1'], 'the second waits while the first runs');
|
|
203
|
+
g.open();
|
|
204
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
205
|
+
assert.deepEqual(order, ['start 1', 'end 1', 'start 2', 'end 2']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('a failed assessment is logged and never stops the next one', async () => {
|
|
209
|
+
const log = quietLog();
|
|
210
|
+
const ran = [];
|
|
211
|
+
queueAssessment(5, { log, run: async () => { throw new Error('npm unreachable'); } });
|
|
212
|
+
queueAssessment(6, { log, run: async (id) => { ran.push(id); } });
|
|
213
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
214
|
+
assert.deepEqual(ran, [6]);
|
|
215
|
+
assert.deepEqual(log.lines, [['error', 'assessment after publish failed', 'npm unreachable']]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test(`past ${QUEUE_MAX} waiting, a publish is logged as not assessed instead of piling up`, async () => {
|
|
219
|
+
const log = quietLog();
|
|
220
|
+
const g = gate();
|
|
221
|
+
const accepted = [];
|
|
222
|
+
for (let i = 0; i < QUEUE_MAX + 3; i += 1) accepted.push(queueAssessment(100 + i, { log, run: () => g.p }));
|
|
223
|
+
assert.equal(accepted.filter(Boolean).length, QUEUE_MAX);
|
|
224
|
+
assert.equal(log.lines.filter(([lvl]) => lvl === 'warn').length, 3);
|
|
225
|
+
assert.match(log.lines[0][1], /queue full .*module-assess-version\.js/);
|
|
226
|
+
g.open();
|
|
227
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
228
|
+
assert.equal(queueAssessment(200, { log, run: async () => {} }), true, 'room again once it drains');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// the publish route triggers it
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
test('publishing a version re-assesses that version, after the author has their answer', async () => {
|
|
236
|
+
const STORE = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-assess-score-store-'));
|
|
237
|
+
process.env.MODULE_STORE_DIR = STORE;
|
|
238
|
+
const state = { modules: {}, versions: [] };
|
|
239
|
+
const client = {
|
|
240
|
+
async query(sql, params = []) {
|
|
241
|
+
if (/^SELECT module_key, author_id, status FROM store_modules/.test(sql)) { const m = state.modules[params[0]]; return { rows: m ? [m] : [] }; }
|
|
242
|
+
if (/^INSERT INTO store_modules/.test(sql)) { state.modules[params[0]] = { module_key: params[0], author_id: params[3], status: 'listed' }; return { rows: [] }; }
|
|
243
|
+
if (/^SELECT version FROM store_module_versions/.test(sql)) return { rows: [] };
|
|
244
|
+
if (/^\s*INSERT INTO store_module_versions/.test(sql)) { const row = { id: 41, module_key: params[0], version: params[1] }; state.versions.push(row); return { rows: [row] }; }
|
|
245
|
+
return { rows: [] };
|
|
246
|
+
},
|
|
247
|
+
release() {},
|
|
248
|
+
};
|
|
249
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
250
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => client, query: client.query } } };
|
|
251
|
+
const score = require('../scripts/gds/module-assess-version.js');
|
|
252
|
+
const real = score.queueAssessment;
|
|
253
|
+
const calls = [];
|
|
254
|
+
score.queueAssessment = (id) => { calls.push(id); return true; };
|
|
255
|
+
try {
|
|
256
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
257
|
+
const artifact = require('../scripts/gds/module-artifact.js');
|
|
258
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-assess-score-src-'));
|
|
259
|
+
fs.mkdirSync(path.join(src, 'weather'));
|
|
260
|
+
fs.writeFileSync(path.join(src, 'weather', 'module.json'), JSON.stringify({ key: 'weather', title: 'Weather', description: 'd', version: '1.0.0', coreVersion: '^1.0.0', contributes: {} }));
|
|
261
|
+
fs.writeFileSync(path.join(src, 'weather', 'HOWTO.md'), artifact.HOWTO_SECTIONS.map((h) => `## ${h}\nSome text.\n`).join('\n'));
|
|
262
|
+
const { tgz } = artifact.packModule('weather', { modulesDir: src, modeOf: () => '644' });
|
|
263
|
+
|
|
264
|
+
const router = buildModulesRouter();
|
|
265
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/store/modules/:key/versions' && l.route.methods.post);
|
|
266
|
+
const handle = layer.route.stack[layer.route.stack.length - 1].handle;
|
|
267
|
+
const res = { statusCode: 200, body: null, json(b) { this.body = b; return this; }, status(c) { this.statusCode = c; return this; }, fail(code, o) { this.statusCode = o.status; this.body = { error: code }; return this; } };
|
|
268
|
+
await handle({ params: { key: 'weather' }, body: tgz, builder: { id: 42 } }, res, (e) => { if (e) throw e; });
|
|
269
|
+
assert.equal(res.statusCode, 201, JSON.stringify(res.body));
|
|
270
|
+
assert.deepEqual(calls, [], 'the author is answered first — the gate can take a minute');
|
|
271
|
+
await new Promise((r) => setImmediate(r));
|
|
272
|
+
assert.deepEqual(calls, [41], 'then the new version is queued for assessment');
|
|
273
|
+
} finally {
|
|
274
|
+
score.queueAssessment = real;
|
|
275
|
+
}
|
|
276
|
+
});
|