@bongos/core 1.21.31 → 1.21.33
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 +62 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +7 -0
- package/docs/api/openapi.json +218 -3
- package/docs/api-reference.md +6 -3
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +40 -38
- package/docs/copy-registry.json +64 -46
- package/docs/design/craft-mode-direction.md +69 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +14 -14
- package/modules/government/catalog.js +4 -0
- package/modules/government/migrations/government_024_version_focus_set.sql +19 -0
- package/modules/government/migrations/government_025_module_delist.sql +36 -0
- package/modules/hall-ui/public/modules.js +8 -1
- package/modules/hall-ui/public/roadmap.css +5 -0
- package/modules/hall-ui/public/roadmap.js +64 -5
- package/modules/lifecycle/routes/versions.js +20 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/module.js +21 -5
- package/src/bongos/module-store.js +31 -0
- package/src/bongos/project-settings.js +28 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/modules.js +35 -0
- package/src/module-api.js +3 -1
- package/tests/focus_version.mjs +96 -0
- package/tests/hall_catalog_world.mjs +1 -1
- package/tests/module_catalog.mjs +34 -6
- package/tests/module_store_delist.mjs +146 -0
package/tests/module_catalog.mjs
CHANGED
|
@@ -225,10 +225,11 @@ test('formatCatalog: a search with no hits says so instead of printing an empty
|
|
|
225
225
|
assert.ok(!/KEY\s+SOURCE/.test(out), 'no header row over an empty result');
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
-
test('cmdList: --json emits the rows + summary, and honours --catalog/--search', () => {
|
|
228
|
+
test('cmdList: --json emits the rows + summary, and honours --catalog/--search', async () => {
|
|
229
229
|
const lines = [];
|
|
230
|
-
const code = mod.cmdList(['--json', '--catalog', '--search', 'example world'], {
|
|
230
|
+
const code = await mod.cmdList(['--json', '--catalog', '--search', 'example world'], {
|
|
231
231
|
log: (s) => lines.push(s),
|
|
232
|
+
checkStore: async () => ({ checked: false, reason: 'offline' }),
|
|
232
233
|
gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
|
|
233
234
|
});
|
|
234
235
|
assert.equal(code, 0);
|
|
@@ -239,17 +240,18 @@ test('cmdList: --json emits the rows + summary, and honours --catalog/--search',
|
|
|
239
240
|
assert.deepEqual(payload.modules.map((m) => m.key), ['provisioning']);
|
|
240
241
|
});
|
|
241
242
|
|
|
242
|
-
test('cmdList: --search with no value is a usage error, not a silent empty listing', () => {
|
|
243
|
+
test('cmdList: --search with no value is a usage error, not a silent empty listing', async () => {
|
|
243
244
|
const errs = [];
|
|
244
|
-
const code = mod.cmdList(['--search'], { log: () => {}, errlog: (s) => errs.push(s), gather: () => { throw new Error('must not gather'); } });
|
|
245
|
+
const code = await mod.cmdList(['--search'], { log: () => {}, errlog: (s) => errs.push(s), gather: () => { throw new Error('must not gather'); } });
|
|
245
246
|
assert.equal(code, 2);
|
|
246
247
|
assert.match(errs.join('\n'), /usage: bongos module list/);
|
|
247
248
|
});
|
|
248
249
|
|
|
249
|
-
test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently dropped', () => {
|
|
250
|
+
test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently dropped', async () => {
|
|
250
251
|
const errs = [];
|
|
251
|
-
const code = mod.cmdList([], {
|
|
252
|
+
const code = await mod.cmdList([], {
|
|
252
253
|
log: () => {},
|
|
254
|
+
checkStore: async () => ({ checked: false, reason: 'offline' }),
|
|
253
255
|
errlog: (s) => errs.push(s),
|
|
254
256
|
gather: () => ({ rows: sample(), skipped: [{ key: 'rotted', reason: 'coreVersion "^0.1.0" not satisfied by core 1.19.0' }], errors: [], core: '1.19.0' }),
|
|
255
257
|
});
|
|
@@ -257,6 +259,32 @@ test('cmdList: a coreVersion-skipped module is surfaced on stderr, not silently
|
|
|
257
259
|
assert.match(errs.join('\n'), /rotted: not listed — coreVersion/);
|
|
258
260
|
});
|
|
259
261
|
|
|
262
|
+
test('cmdList: an installed store module the store delisted is marked, and only instance modules are asked about (task 1003797)', async () => {
|
|
263
|
+
const lines = [];
|
|
264
|
+
const asked = [];
|
|
265
|
+
const code = await mod.cmdList([], {
|
|
266
|
+
log: (s) => lines.push(s),
|
|
267
|
+
gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
|
|
268
|
+
checkStore: async (rows) => {
|
|
269
|
+
asked.push(...rows.map((r) => r.key));
|
|
270
|
+
return { checked: true, latest: Object.fromEntries(rows.map((r) => [r.key, { version: '9.9.9', status: 'delisted' }])) };
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
assert.equal(code, 0);
|
|
274
|
+
const instanceKeys = sample().filter((r) => r.source === 'instance').map((r) => r.key);
|
|
275
|
+
assert.deepEqual(asked, instanceKeys, 'a core module is never a store install, so it is not asked about');
|
|
276
|
+
const out = lines.join('\n');
|
|
277
|
+
assert.match(out, /DELISTED/);
|
|
278
|
+
assert.match(out, /your copy keeps working, but no more versions will come/);
|
|
279
|
+
const json = [];
|
|
280
|
+
await mod.cmdList(['--json'], {
|
|
281
|
+
log: (s) => json.push(s),
|
|
282
|
+
gather: () => ({ rows: sample(), skipped: [], errors: [], core: '1.19.0' }),
|
|
283
|
+
checkStore: async () => ({ checked: false, reason: 'offline' }),
|
|
284
|
+
});
|
|
285
|
+
assert.ok(JSON.parse(json.join('\n')).modules.every((m) => m.store_delisted === false), 'offline: nothing is marked');
|
|
286
|
+
});
|
|
287
|
+
|
|
260
288
|
// ---------------------------------------------------------------------------
|
|
261
289
|
// The live tree — the catalog must actually describe THIS checkout
|
|
262
290
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// tests/module_store_delist.mjs — a Metic+ person can take a module out of the store,
|
|
2
|
+
// and a delisted module actually leaves it, while every instance that already holds
|
|
3
|
+
// it keeps it (task 1003797, ADR 0338 D2). Drives the real POST
|
|
4
|
+
// /store/modules/:key/delist handler off the real router, and the real store reads
|
|
5
|
+
// behind acquire / download / update checks, against a stand-in pool (no port, no
|
|
6
|
+
// real DB — the module_store_reassess.mjs harness).
|
|
7
|
+
//
|
|
8
|
+
// Run: node tests/module_store_delist.mjs
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
|
|
20
|
+
const fresh = () => ({
|
|
21
|
+
modules: { weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed', delisted_at: null } },
|
|
22
|
+
versions: [{ id: 7, module_key: 'weather', version: '1.2.0', core_version: '^1.20.0', tree_sha256: 't', tarball_sha256: 'x', tarball_bytes: 10, artifact_path: 'weather/weather-1.2.0.tgz', published_at: '2026-09-30T00:00:00Z' }],
|
|
23
|
+
sql: [],
|
|
24
|
+
});
|
|
25
|
+
let state = fresh();
|
|
26
|
+
const query = async (sql, params = []) => {
|
|
27
|
+
state.sql.push(sql);
|
|
28
|
+
if (/^\s*UPDATE store_modules SET status = 'delisted'/.test(sql)) {
|
|
29
|
+
const m = state.modules[params[0]];
|
|
30
|
+
if (!m || m.status !== 'listed') return { rows: [] };
|
|
31
|
+
m.status = 'delisted'; m.delisted_at = '2026-10-01T00:00:00Z';
|
|
32
|
+
return { rows: [{ ...m }] };
|
|
33
|
+
}
|
|
34
|
+
if (/FROM store_modules WHERE module_key = ANY\(\$1\) AND status = 'delisted'/.test(sql)) {
|
|
35
|
+
return { rows: params[0].map((k) => state.modules[k]).filter((m) => m && m.status === 'delisted') };
|
|
36
|
+
}
|
|
37
|
+
if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
|
|
38
|
+
const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
|
|
39
|
+
}
|
|
40
|
+
if (/JOIN store_modules m/.test(sql)) {
|
|
41
|
+
return { rows: state.versions.filter((v) => params[0].includes(v.module_key)).map((v) => ({ ...v, status: state.modules[v.module_key].status })) };
|
|
42
|
+
}
|
|
43
|
+
if (/FROM store_module_versions WHERE module_key = \$1/.test(sql)) {
|
|
44
|
+
return { rows: state.versions.filter((v) => v.module_key === params[0]) };
|
|
45
|
+
}
|
|
46
|
+
return { rows: [] };
|
|
47
|
+
};
|
|
48
|
+
const pool = { connect: async () => ({ query, release() {} }), query };
|
|
49
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
50
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
|
|
51
|
+
|
|
52
|
+
const store = require('../src/bongos/module-store.js');
|
|
53
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
54
|
+
|
|
55
|
+
const PATH = '/store/modules/:key/delist';
|
|
56
|
+
function handlerFor(router) {
|
|
57
|
+
const layer = router.stack.find((l) => l.route && l.route.path === PATH && l.route.methods.post);
|
|
58
|
+
assert.ok(layer, `no POST ${PATH} on the router`);
|
|
59
|
+
const stack = layer.route.stack;
|
|
60
|
+
return { handle: stack[stack.length - 1].handle, depth: stack.length };
|
|
61
|
+
}
|
|
62
|
+
async function invoke(handle, { key = 'weather', body = {} } = {}) {
|
|
63
|
+
const res = {
|
|
64
|
+
statusCode: 200, body: undefined,
|
|
65
|
+
json(b) { this.body = b; return this; },
|
|
66
|
+
status(c) { this.statusCode = c; return this; },
|
|
67
|
+
set() { return this; },
|
|
68
|
+
fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
|
|
69
|
+
};
|
|
70
|
+
let nextErr = null;
|
|
71
|
+
await handle({ params: { key }, body, builder: { id: 3 } }, res, (err) => { nextErr = err; });
|
|
72
|
+
if (nextErr) throw nextErr;
|
|
73
|
+
return res;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { handle, depth } = handlerFor(buildModulesRouter());
|
|
77
|
+
|
|
78
|
+
test('the route is gated by its own Metic+ atom, granted to metic and archon, and pinned', () => {
|
|
79
|
+
assert.ok(depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
|
|
80
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
81
|
+
assert.match(src, /router\.post\('\/store\/modules\/:key\/delist', auth\.requireBuilder, auth\.requirePermission\('module\.delist'\)/);
|
|
82
|
+
const { byKey } = require('../modules/government/catalog.js');
|
|
83
|
+
const p = byKey('module.delist');
|
|
84
|
+
assert.equal(p.floor, 'metic');
|
|
85
|
+
assert.equal(p.system, false);
|
|
86
|
+
const mig = fs.readFileSync(path.join(ROOT, 'modules/government/migrations/government_025_module_delist.sql'), 'utf8');
|
|
87
|
+
for (const rank of ['metic', 'archon']) assert.match(mig, new RegExp(`SELECT '${rank}', unnest\\(ARRAY\\[\\s*'module\\.delist'`));
|
|
88
|
+
const pins = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
|
|
89
|
+
assert.match(pins, /'POST \/store\/modules\/:key\/delist': 'perm:module\.delist'/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('no reason, no delist — and only `reason` is accepted', async () => {
|
|
93
|
+
state = fresh();
|
|
94
|
+
for (const body of [{}, { reason: '' }, { reason: ' ' }]) {
|
|
95
|
+
const res = await invoke(handle, { body });
|
|
96
|
+
assert.equal(res.statusCode, 400);
|
|
97
|
+
assert.equal(res.body.error, 'reason_required');
|
|
98
|
+
}
|
|
99
|
+
assert.equal((await invoke(handle, { body: { reason: 'r', revoke: true } })).statusCode, 400, 'unknown fields are refused');
|
|
100
|
+
assert.equal(state.modules.weather.status, 'listed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a bad key is refused, an unknown module is 404, a second delist is 409', async () => {
|
|
104
|
+
state = fresh();
|
|
105
|
+
assert.equal((await invoke(handle, { key: 'Bad_Key', body: { reason: 'r' } })).statusCode, 400);
|
|
106
|
+
const missing = await invoke(handle, { key: 'nope', body: { reason: 'r' } });
|
|
107
|
+
assert.equal(missing.statusCode, 404);
|
|
108
|
+
assert.equal(missing.body.error, 'module_not_found');
|
|
109
|
+
assert.equal((await invoke(handle, { body: { reason: 'r' } })).statusCode, 200);
|
|
110
|
+
const again = await invoke(handle, { body: { reason: 'r' } });
|
|
111
|
+
assert.equal(again.statusCode, 409);
|
|
112
|
+
assert.equal(again.body.error, 'already_delisted');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a delisted module leaves the store: no new install, download, version or update', async () => {
|
|
116
|
+
state = fresh();
|
|
117
|
+
assert.equal((await store.resolveAcquirable('weather', { db: pool })).ok, true, 'installable before');
|
|
118
|
+
const res = await invoke(handle, { body: { reason: 'a security issue was reported' } });
|
|
119
|
+
assert.equal(res.statusCode, 200);
|
|
120
|
+
assert.equal(res.body.status, 'delisted');
|
|
121
|
+
assert.ok(res.body.delisted_at);
|
|
122
|
+
// acquire, tarball and acquired all go through resolveAcquirable.
|
|
123
|
+
const acq = await store.resolveAcquirable('weather', { db: pool });
|
|
124
|
+
assert.equal(acq.ok, false);
|
|
125
|
+
assert.equal(acq.code, 'module_delisted');
|
|
126
|
+
assert.equal((await store.resolveAcquirable('weather', { version: '1.2.0', db: pool })).code, 'module_delisted');
|
|
127
|
+
// `bongos upgrade` sees the status and tells the holder no more versions will come.
|
|
128
|
+
const latest = await store.latestVersions(['weather'], { db: pool });
|
|
129
|
+
assert.equal(latest.weather.status, 'delisted');
|
|
130
|
+
// The hall's Modules tab marks it.
|
|
131
|
+
assert.deepEqual([...(await store.delistedKeySet(['weather', 'other'], { db: pool }))], ['weather']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a delist never touches holders, entitlements or scores (ADR 0338 D2, ADR 0359)', async () => {
|
|
135
|
+
state = fresh();
|
|
136
|
+
await invoke(handle, { body: { reason: 'r' } });
|
|
137
|
+
assert.ok(state.sql.every((q) => !/module_entitlements|module_assessment_scores|module_assessment_signals/.test(q)),
|
|
138
|
+
'the delist writes no entitlement, score or signal row');
|
|
139
|
+
// An installed module loads from the instance's own disk: the loader never asks
|
|
140
|
+
// the store, so a delist cannot stop a held copy from mounting.
|
|
141
|
+
for (const f of fs.readdirSync(path.join(ROOT, 'src/module-loader'))) {
|
|
142
|
+
if (!f.endsWith('.js')) continue;
|
|
143
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/module-loader', f), 'utf8');
|
|
144
|
+
assert.doesNotMatch(src, /store_modules|delisted|\/store\/modules/, `${f} must not consult the store`);
|
|
145
|
+
}
|
|
146
|
+
});
|