@bongos/core 1.23.4 → 1.23.6

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.
Files changed (41) hide show
  1. package/.bongos-core.json +67 -42
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +9 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +2 -1
  8. package/docs/api/openapi.json +204 -7
  9. package/docs/api-reference.md +4 -2
  10. package/docs/copy-inventory.md +26 -24
  11. package/docs/copy-registry.json +43 -25
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/modules-contract.md +2 -0
  14. package/docs/page-readings.json +8 -8
  15. package/migrations/core_281_module_version_withdraw_note.sql +25 -0
  16. package/modules/government/catalog.js +1 -1
  17. package/modules/hall-ui/public/modules.js +10 -1
  18. package/modules/onboarding/routes/access-requests.js +10 -9
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +16 -0
  22. package/scripts/bongos/login.js +13 -14
  23. package/scripts/bongos/module-store-cli.js +182 -0
  24. package/scripts/bongos/module.js +60 -134
  25. package/scripts/gds/module-store-cli.js +6 -0
  26. package/src/bongos/auth-admission.js +3 -3
  27. package/src/bongos/middleware/rate-limit.js +1 -1
  28. package/src/bongos/module-listing.js +12 -2
  29. package/src/bongos/module-store.js +90 -1
  30. package/src/bongos/route-rank-check.js +6 -0
  31. package/src/bongos/routes/auth.js +7 -11
  32. package/src/bongos/routes/modules.js +76 -3
  33. package/src/module-api.js +1 -1
  34. package/tests/account_existence_rate_limit.mjs +7 -3
  35. package/tests/application_lifecycle.mjs +4 -3
  36. package/tests/bongos_login.mjs +15 -4
  37. package/tests/hall_catalog_world.mjs +1 -1
  38. package/tests/invite_expiry_db.mjs +1 -1
  39. package/tests/module_store_cli.mjs +1 -1
  40. package/tests/module_store_price.mjs +207 -0
  41. package/tests/module_store_withdraw.mjs +201 -0
@@ -0,0 +1,201 @@
1
+ // tests/module_store_withdraw.mjs — a Metic+ person can take one version back, and
2
+ // the store stops handing it out while every project that runs it keeps it (task
3
+ // 1004895, criterion wa5-staged-rollout). Drives the real POST
4
+ // /store/modules/:key/versions/:version/withdraw handler and the real update-check
5
+ // route off the real router, the real store reads behind acquire, and the real
6
+ // `bongos upgrade` report, against a stand-in pool that keeps the channel history
7
+ // (no port, no real DB — the module_store_delist.mjs harness).
8
+ //
9
+ // Run: node tests/module_store_withdraw.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ const version = (id, v) => ({ id, module_key: 'weather', version: v, core_version: '^1.20.0', tree_sha256: 't', tarball_sha256: 'x', tarball_bytes: 10, artifact_path: `weather/weather-${v}.tgz`, published_at: 't' });
22
+ const fresh = () => ({
23
+ modules: { weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed' } },
24
+ versions: [version(1, '1.0.0'), version(2, '1.1.0')],
25
+ // The channel HISTORY; the current channel is the newest row per version.
26
+ channels: [
27
+ { version_id: 1, channel: 'general', reason: 'predates_staged_rollout', set_by: null },
28
+ { version_id: 2, channel: 'general', reason: 'promoted_after_7_days', set_by: null },
29
+ ],
30
+ });
31
+ let state = fresh();
32
+ const current = (versionId) => {
33
+ const rows = state.channels.filter((c) => c.version_id === Number(versionId));
34
+ return rows.length ? rows[rows.length - 1].channel : null;
35
+ };
36
+ const query = async (sql, params = []) => {
37
+ if (/^\s*INSERT INTO store_module_version_channels/.test(sql)) {
38
+ assert.match(sql, /IS DISTINCT FROM 'withdrawn'/, 'the insert only lands while the version is not already withdrawn');
39
+ if (current(params[0]) === 'withdrawn') return { rowCount: 0, rows: [] };
40
+ state.channels.push({ version_id: Number(params[0]), channel: 'withdrawn', reason: 'withdrawn_by_person', set_by: params[1], note: params[2] });
41
+ return { rowCount: 1, rows: [] };
42
+ }
43
+ if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
44
+ const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
45
+ }
46
+ if (/FROM store_module_versions WHERE module_key = \$1 AND version = \$2/.test(sql)) {
47
+ return { rows: state.versions.filter((v) => v.module_key === params[0] && v.version === params[1]) };
48
+ }
49
+ if (/FROM store_module_versions WHERE module_key = \$1/.test(sql)) {
50
+ return { rows: state.versions.filter((v) => v.module_key === params[0]).map((v) => ({ ...v, channel: current(v.id) })) };
51
+ }
52
+ if (/AS note\s+FROM store_module_versions v/.test(sql)) {
53
+ const noteOf = (id) => state.channels.filter((c) => c.version_id === id).at(-1).note;
54
+ return { rows: state.versions.filter((v) => params[0].includes(v.module_key) && current(v.id) === 'withdrawn').map((v) => ({ ...v, note: noteOf(v.id) })) };
55
+ }
56
+ if (/JOIN store_modules m/.test(sql)) {
57
+ return { rows: state.versions.filter((v) => params[0].includes(v.module_key)).map((v) => ({ ...v, status: state.modules[v.module_key].status, channel: current(v.id) })) };
58
+ }
59
+ return { rows: [] };
60
+ };
61
+ const pool = { connect: async () => ({ query, release() {} }), query };
62
+ const poolPath = require.resolve('../src/bongos/pool.js');
63
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
64
+
65
+ const store = require('../src/bongos/module-store.js');
66
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
67
+ const mod = require('../scripts/bongos/module.js');
68
+
69
+ function handlerFor(method, p) {
70
+ const layer = buildModulesRouter().stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
71
+ assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
72
+ const stack = layer.route.stack;
73
+ return { handle: stack[stack.length - 1].handle, depth: stack.length };
74
+ }
75
+ async function invoke(handle, req) {
76
+ const res = {
77
+ statusCode: 200, body: undefined,
78
+ json(b) { this.body = b; return this; },
79
+ status(c) { this.statusCode = c; return this; },
80
+ set() { return this; },
81
+ fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
82
+ };
83
+ let nextErr = null;
84
+ await handle({ builder: { id: 3 }, query: {}, body: {}, ...req }, res, (err) => { nextErr = err; });
85
+ if (nextErr) throw nextErr;
86
+ return res;
87
+ }
88
+
89
+ const WITHDRAW = '/store/modules/:key/versions/:version/withdraw';
90
+ const withdraw = (version, reason = 'crashes on start') =>
91
+ invoke(handlerFor('post', WITHDRAW).handle, { params: { key: 'weather', version }, body: { reason } });
92
+
93
+ test('the route is gated by module.delist, the permission behind delist and release, and pinned', () => {
94
+ assert.ok(handlerFor('post', WITHDRAW).depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
95
+ const rank = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
96
+ assert.match(rank, /'POST \/store\/modules\/:key\/versions\/:version\/withdraw': 'perm:module\.delist'/);
97
+ const catalog = fs.readFileSync(path.join(ROOT, 'modules/government/catalog.js'), 'utf8');
98
+ assert.match(catalog, /key: 'module\.delist'[^\n]*\/withdraw/, 'the permission says what it guards');
99
+ });
100
+
101
+ test('a reason is required, and an unknown version or a bad path is refused', async () => {
102
+ state = fresh();
103
+ assert.equal((await withdraw('1.1.0', ' ')).statusCode, 400);
104
+ assert.equal((await withdraw('9.9.9')).statusCode, 404);
105
+ const bad = await invoke(handlerFor('post', WITHDRAW).handle, { params: { key: 'Weather', version: '1.1.0' }, body: { reason: 'x' } });
106
+ assert.equal(bad.statusCode, 400);
107
+ assert.equal(state.channels.length, 2, 'nothing was written');
108
+ });
109
+
110
+ test('withdrawing writes one withdrawn row naming who did it; a second time is refused', async () => {
111
+ state = fresh();
112
+ const first = await withdraw('1.1.0');
113
+ assert.equal(first.statusCode, 200);
114
+ assert.deepEqual(first.body, { ok: true, module_key: 'weather', version: '1.1.0', channel: 'withdrawn', reason: 'crashes on start' });
115
+ assert.deepEqual(state.channels.at(-1), { version_id: 2, channel: 'withdrawn', reason: 'withdrawn_by_person', set_by: 3, note: 'crashes on start' },
116
+ 'the person\'s reason is kept on the row (core_281), the machine code beside it');
117
+ const again = await withdraw('1.1.0');
118
+ assert.equal(again.statusCode, 409);
119
+ assert.equal(again.body.error, 'already_withdrawn');
120
+ assert.equal(state.channels.length, 3, 'one row, however many times it is asked');
121
+ });
122
+
123
+ test('the store stops handing it out: acquire offers the newest version still served, and refuses the withdrawn one by name', async () => {
124
+ state = fresh();
125
+ await withdraw('1.1.0');
126
+ const general = store.servedChannels(false);
127
+ const newest = await store.resolveAcquirable('weather', { channels: general });
128
+ assert.equal(newest.ok, true);
129
+ assert.equal(newest.version.version, '1.0.0', 'the withdrawn 1.1.0 is skipped');
130
+ const exact = await store.resolveAcquirable('weather', { version: '1.1.0', channels: store.servedChannels(true) });
131
+ assert.equal(exact.ok, false, 'not even a tester project is handed it');
132
+ assert.match(exact.message, /1\.1\.0 was withdrawn: it is no longer handed out/);
133
+ });
134
+
135
+ test('withdrawing the only served version leaves nothing to hand out, and acquire says so', async () => {
136
+ state = fresh();
137
+ await withdraw('1.0.0');
138
+ await withdraw('1.1.0');
139
+ const r = await store.resolveAcquirable('weather', { channels: store.servedChannels(false) });
140
+ assert.equal(r.ok, false);
141
+ assert.equal(r.code, 'no_versions_on_channel');
142
+ assert.match(r.message, /every version was withdrawn/);
143
+ });
144
+
145
+ test('the update check names withdrawn versions beside the newest served one', async () => {
146
+ state = fresh();
147
+ await withdraw('1.1.0');
148
+ const res = await invoke(handlerFor('get', '/store/modules/latest').handle, { query: { keys: 'weather' } });
149
+ assert.equal(res.statusCode, 200);
150
+ assert.equal(res.body.latest.weather.version, '1.0.0');
151
+ assert.deepEqual(res.body.withdrawn, { weather: [{ version: '1.1.0', note: 'crashes on start' }] });
152
+ });
153
+
154
+ test('the hall\'s module list marks an installed module whose version was withdrawn, with the reason', async () => {
155
+ state = fresh();
156
+ await withdraw('1.1.0', 'security flaw, update now');
157
+ const routes = require('../src/bongos/routes/modules.js');
158
+ const realList = routes.listModules;
159
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
160
+ assert.match(src, /m\.store_withdrawn = taken \? \{ version: taken\.version, note: taken\.note \} : null;/, 'the row carries version and note');
161
+ assert.equal(typeof realList, 'function');
162
+ const hall = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/modules.js'), 'utf8');
163
+ const fn = hall.match(/function withdrawnFact\(m\) \{[\s\S]*?\n \}/)[0];
164
+ const withdrawnFact = new Function('escapeHtml', `${fn}; return withdrawnFact;`)((t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])));
165
+ assert.equal(withdrawnFact({ store_withdrawn: null }), '', 'nothing to say when the version was not taken back');
166
+ const pill = withdrawnFact({ store_withdrawn: { version: '1.1.0', note: 'security flaw, "update" <now>' } });
167
+ assert.match(pill, /withdrawn · update when a fix is out/);
168
+ assert.match(pill, /Taken back from the store: security flaw, &quot;update&quot; &lt;now&gt;/, 'the reason is shown, escaped');
169
+ assert.match(hall, /\$\{delistedFact\(m\)\}\$\{withdrawnFact\(m\)\}/, 'and it is rendered on the row');
170
+ });
171
+
172
+ const row = (key, version) => ({ key, version, enabled: true, coreVersion: '^1.0.0', satisfied: true });
173
+ function sink() { const s = { out: [], err: [] }; s.log = (l) => s.out.push(l); s.errlog = (l) => s.err.push(l); return s; }
174
+
175
+ test('bongos upgrade tells a project its version was withdrawn, and names the way off only when one exists', async () => {
176
+ const run = async (installed, latest, withdrawn) => {
177
+ const s = sink();
178
+ const code = await mod.cmdUpgrade([], {
179
+ ...s,
180
+ gather: () => ({ core: '1.21.0', errors: [], rows: [row('weather', installed)] }),
181
+ checkUpdates: async () => ({ checked: true, latest: { weather: { version: latest, core_version: '^1.0.0', status: 'listed' } }, withdrawn }),
182
+ });
183
+ return { code, err: s.err.join('\n'), out: s.out.join('\n') };
184
+ };
185
+ const fixed = await run('1.1.0', '1.2.0', { weather: [{ version: '1.1.0', note: 'crashes on start' }] });
186
+ assert.equal(fixed.code, 0, 'a withdrawal is news, never a refusal');
187
+ assert.match(fixed.err, /weather\s+1\.1\.0\s+— withdrawn in the store \(crashes on start\): it keeps working here, but move to 1\.2\.0 with `bongos module update weather`/);
188
+ const stuck = await run('1.1.0', '1.0.0', { weather: ['1.1.0'] }); // an older server's plain list still works
189
+ assert.match(stuck.err, /withdrawn in the store: it keeps working here, but no newer version is out yet/, 'update only moves forward, so the older 1.0.0 is not offered');
190
+ assert.doesNotMatch(stuck.out, /every installed store module is on its newest version/);
191
+ const other = await run('1.0.0', '1.0.0', { weather: ['1.1.0'] });
192
+ assert.doesNotMatch(other.err, /withdrawn/, 'a project on a version that was NOT withdrawn hears nothing');
193
+ const older = await run('1.0.0', '1.0.0', undefined);
194
+ assert.equal(older.code, 0, 'a server that predates withdraw answers no withdrawn map, and that is fine');
195
+ });
196
+
197
+ test('evaluateUpdates: a withdrawn install is reported, whether or not an update exists', () => {
198
+ const { withdrawn, updates } = mod.evaluateUpdates([row('weather', '1.1.0')], { weather: { version: '1.2.0', core_version: '^1.0.0', status: 'listed' } }, '1.21.0', { weather: [{ version: '1.1.0', note: 'crashes' }] });
199
+ assert.deepEqual(withdrawn, [{ key: 'weather', installed: '1.1.0', latest: '1.2.0', enabled: true, note: 'crashes' }]);
200
+ assert.equal(updates.length, 1, 'the update is still listed: it is the way off');
201
+ });