@bongos/core 1.23.5 → 1.23.7

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 (36) hide show
  1. package/.bongos-core.json +66 -36
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -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 +96 -5
  9. package/docs/api-reference.md +3 -2
  10. package/docs/module-api-changelog.md +4 -0
  11. package/docs/modules-contract.md +3 -0
  12. package/modules/onboarding/routes/access-requests.js +10 -9
  13. package/package-lock.json +2 -2
  14. package/package.json +1 -1
  15. package/release-notes.json +16 -0
  16. package/scripts/bongos/login.js +13 -14
  17. package/scripts/bongos/module-remove.js +122 -0
  18. package/scripts/bongos/module-store-cli.js +182 -0
  19. package/scripts/bongos/module.js +42 -127
  20. package/scripts/gds/module-remove.js +6 -0
  21. package/scripts/gds/module-store-cli.js +6 -0
  22. package/src/bongos/auth-admission.js +3 -3
  23. package/src/bongos/middleware/rate-limit.js +1 -1
  24. package/src/bongos/module-listing.js +12 -2
  25. package/src/bongos/module-store.js +49 -1
  26. package/src/bongos/route-rank-check.js +3 -0
  27. package/src/bongos/routes/auth.js +7 -11
  28. package/src/bongos/routes/modules.js +32 -1
  29. package/src/module-api.js +1 -1
  30. package/tests/account_existence_rate_limit.mjs +7 -3
  31. package/tests/application_lifecycle.mjs +4 -3
  32. package/tests/bongos_login.mjs +15 -4
  33. package/tests/invite_expiry_db.mjs +1 -1
  34. package/tests/module_remove.mjs +121 -0
  35. package/tests/module_store_cli.mjs +1 -1
  36. package/tests/module_store_price.mjs +207 -0
@@ -0,0 +1,121 @@
1
+ // tests/module_remove.mjs — `bongos module remove <key>` takes a store module off a
2
+ // project without losing anything (task 1004896, goal 1000091). Runs the real
3
+ // cmdRemove against a throwaway instance folder on disk and a stand-in store: no
4
+ // port, no real DB, no session.
5
+ //
6
+ // Run: node tests/module_remove.mjs
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { test } from 'node:test';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const { cmdRemove } = require('../scripts/bongos/module-remove.js');
17
+ const mod = require('../scripts/bongos/module.js');
18
+
19
+ function instance() {
20
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-remove-'));
21
+ const dir = path.join(root, 'modules', 'weather');
22
+ fs.mkdirSync(dir, { recursive: true });
23
+ fs.writeFileSync(path.join(dir, 'module.json'), '{"key":"weather","version":"1.2.0"}');
24
+ fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports = {};');
25
+ return { root, dir };
26
+ }
27
+ const row = (over = {}) => ({ key: 'weather', source: 'instance', enabled: false, version: '1.2.0', ...over });
28
+
29
+ // A stand-in store: GET /modules answers the hall's switch, GET .../listing whether
30
+ // the store knows the key. Every call is recorded, so a test can prove nothing was written.
31
+ function store({ hall = false, known = true, reachable = true } = {}) {
32
+ const calls = [];
33
+ const request = async (method, url) => {
34
+ calls.push(`${method} ${url}`);
35
+ if (!reachable) throw new Error('ECONNREFUSED');
36
+ if (url === '/modules') return { ok: true, status: 200, data: { modules: hall === null ? [] : [{ key: 'weather', enabled: hall }] } };
37
+ if (url === '/store/modules/weather/listing') {
38
+ return known ? { ok: true, status: 200, data: { module_key: 'weather', listed: true } } : { ok: false, status: 404, data: { error: 'module_not_found' } };
39
+ }
40
+ return { ok: false, status: 500, data: null };
41
+ };
42
+ return { request, calls };
43
+ }
44
+
45
+ async function run(args, { rows = [row()], st = store(), root } = {}) {
46
+ const out = [];
47
+ const err = [];
48
+ const code = await cmdRemove(args, {
49
+ log: (l) => out.push(l), errlog: (l) => err.push(l),
50
+ gather: () => ({ rows }), request: st.request, storeError: (r) => `HTTP ${r.status}`,
51
+ instanceRoot: root, now: () => new Date('2026-10-26T12:00:00Z'),
52
+ });
53
+ return { code, out: out.join('\n'), err: err.join('\n') };
54
+ }
55
+
56
+ test('a store module that is switched off is moved aside, not deleted, and the output says how to undo it', async () => {
57
+ const { root, dir } = instance();
58
+ const st = store();
59
+ const r = await run(['weather'], { st, root });
60
+ assert.equal(r.code, 0, r.err);
61
+ assert.equal(fs.existsSync(dir), false, 'modules/weather/ is gone from the project');
62
+ const parked = path.join(root, 'var', 'removed-modules', 'weather-1.2.0-20261026T120000Z');
63
+ assert.equal(fs.readFileSync(path.join(parked, 'index.js'), 'utf8'), 'module.exports = {};', 'every file kept, byte for byte');
64
+ assert.equal(fs.readFileSync(path.join(root, 'var', 'removed-modules', '.gitignore'), 'utf8'), '*\n', 'the parked folder never dirties the instance tree');
65
+ assert.match(r.out, /Removed "weather" 1\.2\.0/);
66
+ assert.match(r.out, /move it back to modules\/weather\/ to undo/);
67
+ assert.match(r.out, /database tables were left in place/, 'owner decision 2026-10-26: keep the tables');
68
+ assert.match(r.out, /without buying it again/, 'the entitlement is untouched');
69
+ assert.ok(st.calls.every((c) => c.startsWith('GET ')), 'nothing on the server is written');
70
+ });
71
+
72
+ test('a module switched on in config is refused, and nothing moves', async () => {
73
+ const { root, dir } = instance();
74
+ const r = await run(['weather'], { rows: [row({ enabled: true })], root });
75
+ assert.equal(r.code, 1);
76
+ assert.match(r.err, /is switched on\. Switch it off first/);
77
+ assert.ok(fs.existsSync(dir));
78
+ });
79
+
80
+ test('a module switched on only in the hall is refused too', async () => {
81
+ const { root, dir } = instance();
82
+ const r = await run(['weather'], { st: store({ hall: true }), root });
83
+ assert.equal(r.code, 1);
84
+ assert.match(r.err, /switched on in the hall's Modules tab/);
85
+ assert.ok(fs.existsSync(dir));
86
+ });
87
+
88
+ test('a key the store does not know is the project\'s own code: refused, nothing moved', async () => {
89
+ const { root, dir } = instance();
90
+ const r = await run(['weather'], { st: store({ known: false }), root });
91
+ assert.equal(r.code, 1);
92
+ assert.match(r.err, /this project's own code, not a store copy/);
93
+ assert.ok(fs.existsSync(dir));
94
+ });
95
+
96
+ test('a core module, a module not installed, and a bad key are refused', async () => {
97
+ const { root, dir } = instance();
98
+ const core = await run(['weather'], { rows: [row({ source: 'core' })], root });
99
+ assert.equal(core.code, 1);
100
+ assert.match(core.err, /ships with the core/);
101
+ const absent = await run(['rain'], { rows: [], root });
102
+ assert.equal(absent.code, 1);
103
+ assert.match(absent.err, /not installed in this project/);
104
+ for (const args of [[], ['--force'], ['Weather']]) assert.equal((await run(args, { root })).code, 2, args.join(' '));
105
+ assert.ok(fs.existsSync(dir));
106
+ });
107
+
108
+ test('an unreachable store refuses rather than guessing, and nothing moves', async () => {
109
+ const { root, dir } = instance();
110
+ const r = await run(['weather'], { st: store({ reachable: false }), root });
111
+ assert.equal(r.code, 1);
112
+ assert.match(r.err, /could not be reached/);
113
+ assert.ok(fs.existsSync(dir));
114
+ });
115
+
116
+ test('`bongos module remove` is dispatched and documented in the help', () => {
117
+ const out = [];
118
+ mod.printHelp((l) => out.push(l));
119
+ assert.match(out.join('\n'), /bongos module remove <key>/);
120
+ assert.equal(typeof mod.cmdRemove, 'function');
121
+ });
@@ -158,7 +158,7 @@ test('the text view keeps the API order: listed ranked, then New, and says the f
158
158
  assert.equal(at('stripe-10'), -1, 'the eleventh is not shown');
159
159
  assert.match(text, /stripe-00@1\.0\.0 {2}99\/100 {2}free {2}Money › Stripe payments/);
160
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');
161
+ assert.match(text, /loose@1\.0\.0 {2}88\/100 {2}40 credits \(not yet buyable\)$/m, 'no category, no place; paid is not yet buyable (task 1004894)');
162
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
163
  assert.match(text, /New — not ranked[\s\S]*fresh@1\.0\.0/);
164
164
  });
@@ -0,0 +1,207 @@
1
+ // tests/module_store_price.mjs — a store module's author sets its price (task
2
+ // 1004894, criterion wa5-module-economy). Drives the real POST
3
+ // /store/modules/:key/price handler off the real router, the real setPrice and
4
+ // isFreePrice behind it, the real catalog read, and `bongos module price`, against a
5
+ // stand-in pool that keeps the price history (no port, no real DB — the
6
+ // module_store_delist.mjs harness).
7
+ //
8
+ // Run: node tests/module_store_price.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 os from 'node:os';
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 fresh = () => ({
22
+ modules: {
23
+ weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed' },
24
+ gone: { module_key: 'gone', title: 'Gone', author_id: 42, status: 'delisted' },
25
+ },
26
+ prices: [],
27
+ setAt: 0,
28
+ });
29
+ let state = fresh();
30
+ const newestPrice = (key) => state.prices.filter((p) => p.module_key === key).at(-1) || null;
31
+ const query = async (sql, params = []) => {
32
+ if (/^INSERT INTO store_module_prices/.test(sql)) {
33
+ const [module_key, price_cents, price_credits, set_by] = params;
34
+ state.prices.push({ module_key, price_cents, price_credits, set_by, set_at: ++state.setAt });
35
+ return { rows: [] };
36
+ }
37
+ if (/FROM store_module_prices\s+WHERE module_key = \$1/.test(sql)) {
38
+ const p = newestPrice(params[0]); return { rows: p ? [p] : [] };
39
+ }
40
+ if (/FROM store_module_prices\s+WHERE module_key = ANY/.test(sql)) {
41
+ return { rows: params[0].map(newestPrice).filter(Boolean) };
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/.test(sql)) {
47
+ return { rows: [{ id: 1, module_key: params[0], version: '1.0.0', channel: 'general' }] };
48
+ }
49
+ if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
50
+ return { rows: [{ module_key: 'weather', title: 'Weather', version_id: 1, version: '1.0.0', published_at: 't' }] };
51
+ }
52
+ if (/FROM module_assessment_scores/.test(sql)) {
53
+ return { rows: [{ version_id: 1, overall: 80, security_passed: true, is_new: false }] };
54
+ }
55
+ return { rows: [] };
56
+ };
57
+ const pool = { connect: async () => ({ query, release() {} }), query };
58
+ const poolPath = require.resolve('../src/bongos/pool.js');
59
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
60
+
61
+ const store = require('../src/bongos/module-store.js');
62
+ const listing = require('../src/bongos/module-listing.js');
63
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
64
+ const mod = require('../scripts/bongos/module.js');
65
+
66
+ const PRICE = '/store/modules/:key/price';
67
+ function handlerFor() {
68
+ const layer = buildModulesRouter().stack.find((l) => l.route && l.route.path === PRICE && l.route.methods.post);
69
+ assert.ok(layer, `no POST ${PRICE} on the router`);
70
+ const stack = layer.route.stack;
71
+ return { handle: stack[stack.length - 1].handle, depth: stack.length };
72
+ }
73
+ async function setPrice(body, { key = 'weather', builderId = 42 } = {}) {
74
+ const res = {
75
+ statusCode: 200, body: undefined,
76
+ json(b) { this.body = b; return this; },
77
+ status(c) { this.statusCode = c; return this; },
78
+ set() { return this; },
79
+ fail(code, opts, details) {
80
+ const o = typeof opts === 'number' ? { status: opts, details } : (opts || {});
81
+ this.statusCode = o.status; this.body = { error: { code, message: o.message } }; return this;
82
+ },
83
+ };
84
+ let nextErr = null;
85
+ await handlerFor().handle({ params: { key }, body, builder: { id: builderId } }, res, (err) => { nextErr = err; });
86
+ if (nextErr) throw nextErr;
87
+ return res;
88
+ }
89
+
90
+ test('the route rides the publish atom and is pinned', () => {
91
+ assert.ok(handlerFor().depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
92
+ const rank = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
93
+ assert.match(rank, /'POST \/store\/modules\/:key\/price': 'perm:module\.submit'/);
94
+ });
95
+
96
+ test('only the author may set the price; a stranger and a delisted module are refused', async () => {
97
+ state = fresh();
98
+ const stranger = await setPrice({ cents: 500 }, { builderId: 7 });
99
+ assert.equal(stranger.statusCode, 403);
100
+ assert.equal(stranger.body.error.code, 'not_module_author');
101
+ const gone = await setPrice({ cents: 500 }, { key: 'gone' });
102
+ assert.equal(gone.statusCode, 409);
103
+ const missing = await setPrice({ cents: 500 }, { key: 'nope' });
104
+ assert.equal(missing.statusCode, 404);
105
+ assert.equal(state.prices.length, 0, 'nothing was written');
106
+ });
107
+
108
+ test('a price must be whole numbers of 0 or more, and name at least one currency', async () => {
109
+ state = fresh();
110
+ for (const body of [{}, { cents: null, credits: null }, { cents: -1 }, { cents: 1.5 }, { credits: '10' }, { cents: 1e12 }]) {
111
+ const r = await setPrice(body);
112
+ assert.equal(r.statusCode, 400, JSON.stringify(body));
113
+ assert.ok(['bad_price', 'validation_failed'].includes(r.body.error.code), r.body.error.code);
114
+ }
115
+ assert.equal(state.prices.length, 0);
116
+ });
117
+
118
+ test('each change appends a row naming who set it, and the newest is what acquire reads', async () => {
119
+ state = fresh();
120
+ const first = await setPrice({ cents: 900 });
121
+ assert.equal(first.statusCode, 200);
122
+ assert.deepEqual(first.body, { ok: true, module_key: 'weather', previous: null, price: { cents: 900, credits: null } });
123
+ const second = await setPrice({ cents: 500, credits: 40 });
124
+ assert.deepEqual(second.body.previous, { cents: 900, credits: null });
125
+ assert.equal(state.prices.length, 2, 'the history is kept');
126
+ assert.equal(state.prices[1].set_by, 42);
127
+ const acq = await store.resolveAcquirable('weather', {});
128
+ assert.equal(acq.ok, true);
129
+ assert.deepEqual([acq.price.price_cents, acq.price.price_credits], [500, 40]);
130
+ assert.equal(acq.free, false);
131
+ await setPrice({ credits: 0 });
132
+ assert.equal((await store.resolveAcquirable('weather', {})).free, true, '0 in either currency is free');
133
+ });
134
+
135
+ test('the catalog shows the price, and marks a paid module not yet buyable until buying opens', async () => {
136
+ state = fresh();
137
+ let cat = await listing.listCatalog({});
138
+ assert.equal(cat.listed[0].price.free, true);
139
+ assert.equal(cat.listed[0].buyable, true, 'a free module can be had today');
140
+ await setPrice({ cents: 900 });
141
+ cat = await listing.listCatalog({});
142
+ assert.deepEqual(cat.listed[0].price, { cents: 900, credits: null, free: false });
143
+ assert.equal(cat.listed[0].buyable, false, 'buying arrives with task 1003788');
144
+ });
145
+
146
+ test('bongos module price: sends the price, says what it was and now is, and that paid is not yet buyable', async () => {
147
+ const sent = [];
148
+ const request = async (method, url, opts) => {
149
+ sent.push({ method, url, json: opts && opts.json });
150
+ return { ok: true, status: 200, data: { ok: true, previous: { cents: 0, credits: null }, price: { cents: 900, credits: 40 } } };
151
+ };
152
+ const out = [];
153
+ assert.equal(await mod.cmdPrice(['weather', '--cents', '900', '--credits', '40'], { log: (l) => out.push(l), errlog: () => {}, request }), 0);
154
+ assert.deepEqual(sent, [{ method: 'POST', url: '/store/modules/weather/price', json: { cents: 900, credits: 40 } }]);
155
+ assert.match(out.join('\n'), /"weather" now costs \$9\.00 or 40 credits \(was free\)/);
156
+ assert.match(out.join('\n'), /not yet buyable/);
157
+ });
158
+
159
+ test('bongos module price: refuses no key, no amount and a non-number before asking the store', async () => {
160
+ const request = async () => { throw new Error('must not be called'); };
161
+ for (const args of [[], ['--cents', '5'], ['weather'], ['weather', '--cents', 'five'], ['weather', '--credits', '-3']]) {
162
+ const errs = [];
163
+ assert.equal(await mod.cmdPrice(args, { log: () => {}, errlog: (l) => errs.push(l), request }), 2, args.join(' '));
164
+ assert.match(errs.join('\n'), /usage: bongos module price/);
165
+ }
166
+ });
167
+
168
+ test('bongos module price: the store refusing is reported, not swallowed', async () => {
169
+ const errs = [];
170
+ const request = async () => ({ ok: false, status: 403, data: { error: { code: 'not_module_author', message: 'Only the author of "weather" may set its price.' } } });
171
+ assert.equal(await mod.cmdPrice(['weather', '--cents', '100'], { log: () => {}, errlog: (l) => errs.push(l), request }), 1);
172
+ assert.match(errs.join('\n'), /Only the author of "weather" may set its price/);
173
+ });
174
+
175
+ test('the price history is the audit: who set each price and when, and what it replaced', async () => {
176
+ state = fresh();
177
+ await setPrice({ cents: 900 });
178
+ await setPrice({ cents: 500 }, { builderId: 42 });
179
+ const rows = state.prices.filter((r) => r.module_key === 'weather');
180
+ assert.deepEqual(rows.map((r) => [r.price_cents, r.set_by]), [[900, 42], [500, 42]], 'every change is a row naming who set it');
181
+ assert.ok(rows[1].set_at > rows[0].set_at, 'in order, so the row before is what it changed from');
182
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/module-store.js'), 'utf8');
183
+ assert.match(src, /FROM store_modules WHERE module_key = \$1 FOR UPDATE/, 'one transaction, the module row locked');
184
+ });
185
+
186
+ test('bongos module publish --cents/--credits sets the price after the upload, and checks it before packing', async () => {
187
+ const packed = { tgz: Buffer.from('x'), sidecar: { module_version: '1.0.0', core_version: '^1.0.0', file_count: 1, tree_sha256: 'a'.repeat(64), tarball: { name: 'x.tgz' } } };
188
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-price-'));
189
+ fs.mkdirSync(path.join(dir, 'weather'));
190
+ const artifact = require('../scripts/bongos/module-artifact.js');
191
+ fs.writeFileSync(path.join(dir, 'weather', artifact.HOWTO_FILE), artifact.HOWTO_SECTIONS.map((h) => `## ${h}\n\nText.\n`).join('\n'));
192
+ const sent = [];
193
+ const priceRequest = async (method, url, opts) => { sent.push([method, url, opts.json]); return { ok: true, status: 200, data: { previous: null, price: opts.json } }; };
194
+ const upload = async () => ({ ok: true, status: 201, data: { version: { version: '1.0.0' }, channel: 'tester' } });
195
+ const out = [];
196
+ const code = await mod.cmdPublish(['--cents', '900', 'weather'], { log: (l) => out.push(l), errlog: (l) => out.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest });
197
+ assert.equal(code, 0, out.join('\n'));
198
+ assert.deepEqual(sent, [['POST', '/store/modules/weather/price', { cents: 900, credits: null }]], 'the key is found even after a valued flag');
199
+ let packedAtAll = false;
200
+ const bad = await mod.cmdPublish(['weather', '--cents', 'nine'], { log: () => {}, errlog: () => {}, modulesDir: dir, pack: () => { packedAtAll = true; return packed; }, upload, priceRequest });
201
+ assert.equal(bad, 2);
202
+ assert.equal(packedAtAll, false, 'a bad price refuses before anything is packed or sent');
203
+ const failing = async () => ({ ok: false, status: 403, data: { error: { message: 'Only the author' } } });
204
+ const errs = [];
205
+ assert.equal(await mod.cmdPublish(['weather', '--credits', '40'], { log: () => {}, errlog: (l) => errs.push(l), modulesDir: dir, pack: () => packed, upload, priceRequest: failing }), 1);
206
+ assert.match(errs.join('\n'), /The version is published, but its price was not set\. Run `bongos module price weather --credits 40`/);
207
+ });