@bongos/core 1.20.45 → 1.20.47

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 (46) hide show
  1. package/.bongos-core.json +64 -49
  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 +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +85 -3
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +170 -161
  10. package/docs/copy-registry.json +333 -212
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/modules-contract.md +1 -0
  13. package/docs/page-inventory.json +4 -1
  14. package/docs/page-readings.json +96 -96
  15. package/modules/hall-ui/public/bulk-toolbar.js +291 -0
  16. package/modules/hall-ui/public/goals-page.js +29 -44
  17. package/modules/hall-ui/public/goals-render.js +33 -21
  18. package/modules/hall-ui/public/goals.html +12 -16
  19. package/modules/hall-ui/public/modules.css +45 -0
  20. package/modules/hall-ui/public/modules.html +23 -1
  21. package/modules/hall-ui/public/modules.js +94 -2
  22. package/modules/hall-ui/public/style.css +74 -3
  23. package/modules/hall-ui/public/work.css +4 -66
  24. package/modules/hall-ui/public/work.html +2 -1
  25. package/modules/hall-ui/public/work.js +17 -236
  26. package/modules/hall-ui/records/catalog.md +2 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/release-notes.json +12 -0
  30. package/scripts/gds/module-artifact.js +21 -1
  31. package/scripts/gds/module.js +15 -1
  32. package/scripts/hall-preview/server.js +3 -0
  33. package/src/bongos/module-store.js +30 -1
  34. package/src/bongos/routes/modules.js +91 -1
  35. package/src/module-api.js +1 -1
  36. package/tests/bulk_toolbar.mjs +202 -0
  37. package/tests/claim_action.mjs +13 -4
  38. package/tests/goal_category_ui.mjs +1 -0
  39. package/tests/goal_criterion_confirm_ui.mjs +2 -0
  40. package/tests/goal_forest_page.mjs +1 -0
  41. package/tests/goal_invite_consent.mjs +1 -1
  42. package/tests/goal_mine_lens.mjs +1 -0
  43. package/tests/hall_board_world.mjs +1 -1
  44. package/tests/hall_catalog_world.mjs +1 -1
  45. package/tests/hall_mine_lens.mjs +1 -0
  46. package/tests/module_store_howto.mjs +244 -0
@@ -0,0 +1,244 @@
1
+ // tests/module_store_howto.mjs — the store shows each version's how-to as a page
2
+ // (task 1004366, ADR 0347 D1/D3). Drives the real GET
3
+ // /store/modules/:key/versions/:version/howto handler off the real router against
4
+ // REAL packed tarballs in a temp store dir (no port, no real DB — pool.js is replaced
5
+ // in the require cache, the module_store_publish_route.mjs harness). Then the page
6
+ // half: the author's markdown renders through the hall's one escaping reader, the
7
+ // install message links the page, and the Modules list links an installed version.
8
+ //
9
+ // Run: node tests/module_store_howto.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 os from 'node:os';
16
+ import path from 'node:path';
17
+ import vm from 'node:vm';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const STORE = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-store-howto-'));
23
+ process.env.MODULE_STORE_DIR = STORE;
24
+
25
+ // The registry and the entitlements, as the fake pool sees them.
26
+ const state = { modules: {}, versions: [], held: new Set() };
27
+ const pool = {
28
+ async query(sql, params = []) {
29
+ if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
30
+ const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
31
+ }
32
+ if (/FROM store_module_versions WHERE module_key = \$1 AND version = \$2/.test(sql)) {
33
+ return { rows: state.versions.filter((v) => v.module_key === params[0] && v.version === params[1]) };
34
+ }
35
+ if (/FROM store_module_versions WHERE module_key = ANY/.test(sql)) {
36
+ return { rows: state.versions.filter((v) => params[0].includes(v.module_key)) };
37
+ }
38
+ if (/FROM module_entitlements/.test(sql)) {
39
+ const k = `${params[0]}|${params[2]}`;
40
+ return { rows: state.held.has(k) ? [{ module_key: params[0], holder_kind: params[1], holder_ref: params[2], revoked_at: null }] : [] };
41
+ }
42
+ return { rows: [] };
43
+ },
44
+ };
45
+ const poolPath = require.resolve('../src/bongos/pool.js');
46
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
47
+
48
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
49
+ const artifact = require('../scripts/gds/module-artifact.js');
50
+ const { cmdInstall } = require('../scripts/gds/module.js');
51
+
52
+ const sections = (tag) => artifact.HOWTO_SECTIONS.map((h) => `## ${h}\n${tag} — ${h}.\n`).join('\n');
53
+
54
+ // Pack a real version of "weather" and put it in the store the way publish does.
55
+ function publish(version, { howto, manifest = {}, status = 'listed' } = {}) {
56
+ const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-howto-src-'));
57
+ const dir = path.join(src, 'weather');
58
+ fs.mkdirSync(dir);
59
+ fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
60
+ key: 'weather', title: 'Weather', description: 'd', version, coreVersion: '^1.0.0', contributes: {}, ...manifest,
61
+ }));
62
+ if (howto != null) fs.writeFileSync(path.join(dir, 'HOWTO.md'), howto);
63
+ const { tgz } = artifact.packModule('weather', { modulesDir: src, modeOf: () => '644' });
64
+ fs.mkdirSync(path.join(STORE, 'weather'), { recursive: true });
65
+ fs.writeFileSync(path.join(STORE, 'weather', `weather-${version}.tgz`), tgz);
66
+ state.modules.weather = { module_key: 'weather', title: 'Weather', author_id: 42, status };
67
+ state.versions.push({ id: state.versions.length + 1, module_key: 'weather', version, tarball_sha256: require('node:crypto').createHash('sha256').update(tgz).digest('hex'), artifact_path: `weather/weather-${version}.tgz`, published_at: '2026-09-30T00:00:00Z' });
68
+ return tgz;
69
+ }
70
+
71
+ function handlerFor(router, method, p) {
72
+ const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
73
+ assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
74
+ const stack = layer.route.stack;
75
+ return { handle: stack[stack.length - 1].handle, names: stack.map((s) => s.name) };
76
+ }
77
+
78
+ async function invoke(handle, req) {
79
+ const res = {
80
+ statusCode: 200, body: undefined, headers: {},
81
+ json(b) { this.body = b; return this; },
82
+ status(c) { this.statusCode = c; return this; },
83
+ set(k, v) { this.headers[k] = v; return this; },
84
+ fail(code, opts) { this.statusCode = opts.status; this.body = { error: code, ...opts }; return this; },
85
+ };
86
+ let nextErr = null;
87
+ await handle(req, res, (err) => { nextErr = err; });
88
+ if (nextErr) throw nextErr;
89
+ return res;
90
+ }
91
+
92
+ const router = buildModulesRouter();
93
+ const howtoRoute = handlerFor(router, 'get', '/store/modules/:key/versions/:version/howto');
94
+ const read = (version, builderId = 7) => invoke(howtoRoute.handle, { params: { key: 'weather', version }, builder: { id: builderId } });
95
+
96
+ publish('0.9.0', { howto: null }); // published before the gate: no how-to
97
+ publish('1.0.0', { howto: sections('one') });
98
+ publish('2.0.0', { howto: sections('two'), manifest: { howto: { artifactUrl: 'https://claude.ai/artifact/abc' } } });
99
+
100
+ test('the route is open to any signed-in builder: requireBuilder and no permission gate', () => {
101
+ assert.deepEqual(howtoRoute.names.slice(0, 1), ['requireBuilder']);
102
+ assert.equal(howtoRoute.names.length, 2, 'builder gate, then the handler — no entitlement or rank gate for a listed module');
103
+ });
104
+
105
+ test('the page shows the version asked for, from its own tarball — not the latest', async () => {
106
+ const one = await read('1.0.0');
107
+ assert.equal(one.statusCode, 200, JSON.stringify(one.body));
108
+ assert.match(one.body.markdown, /one — What it does/);
109
+ assert.doesNotMatch(one.body.markdown, /two —/);
110
+ assert.equal(one.body.version, '1.0.0');
111
+ assert.equal(one.body.artifact_url, null);
112
+ const two = await read('2.0.0');
113
+ assert.match(two.body.markdown, /two — What it does/);
114
+ assert.equal(two.body.artifact_url, 'https://claude.ai/artifact/abc', 'the optional Claude page travels with its version');
115
+ assert.equal(one.headers['Cache-Control'], 'no-store');
116
+ });
117
+
118
+ test('a version published before the gate answers 404 howto_missing, not an empty page', async () => {
119
+ const r = await read('0.9.0');
120
+ assert.equal(r.statusCode, 404);
121
+ assert.equal(r.body.error, 'howto_missing');
122
+ });
123
+
124
+ test('an unknown version, a bad key and a loose version are refused', async () => {
125
+ assert.equal((await read('3.0.0')).body.error, 'version_not_found');
126
+ assert.equal((await invoke(howtoRoute.handle, { params: { key: 'Bad_Key', version: '1.0.0' }, builder: { id: 7 } })).statusCode, 400);
127
+ assert.equal((await read('^1.0.0')).statusCode, 400);
128
+ });
129
+
130
+ test('a checked how-to is cached: the second view reads no file', async () => {
131
+ const fresh = handlerFor(buildModulesRouter(), 'get', '/store/modules/:key/versions/:version/howto');
132
+ const view = () => invoke(fresh.handle, { params: { key: 'weather', version: '2.0.0' }, builder: { id: 7 } });
133
+ assert.equal((await view()).statusCode, 200);
134
+ const file = path.join(STORE, 'weather', 'weather-2.0.0.tgz');
135
+ fs.renameSync(file, file + '.away');
136
+ try {
137
+ const again = await view();
138
+ assert.equal(again.statusCode, 200, 'served from the cache, so the moved file is never read');
139
+ assert.match(again.body.markdown, /two — What it does/);
140
+ } finally { fs.renameSync(file + '.away', file); }
141
+ });
142
+
143
+ test('a stored tarball that no longer matches its hashes is never shown', async () => {
144
+ const file = path.join(STORE, 'weather', 'weather-1.0.0.tgz');
145
+ const good = fs.readFileSync(file);
146
+ const { readTar, buildTar } = require('../scripts/gds/artifact-format.js');
147
+ const entries = readTar(good);
148
+ entries.find((e) => e.name.endsWith('HOWTO.md')).buf = Buffer.from('## What it does\nswapped\n');
149
+ fs.writeFileSync(file, buildTar(entries));
150
+ try {
151
+ // A fresh router, so nothing is cached yet: the first read of a version checks it.
152
+ const fresh = handlerFor(buildModulesRouter(), 'get', '/store/modules/:key/versions/:version/howto');
153
+ const r = await invoke(fresh.handle, { params: { key: 'weather', version: '1.0.0' }, builder: { id: 7 } });
154
+ assert.equal(r.statusCode, 500);
155
+ assert.equal(r.body.error, 'artifact_invalid');
156
+ } finally { fs.writeFileSync(file, good); }
157
+ });
158
+
159
+ test('a delisted module\'s how-to still reads for people who hold it, and only for them (ADR 0338 D2)', async () => {
160
+ state.modules.weather.status = 'delisted';
161
+ try {
162
+ const stranger = await read('1.0.0', 7);
163
+ assert.equal(stranger.statusCode, 409);
164
+ assert.equal(stranger.body.error, 'module_delisted');
165
+ state.held.add('weather|8');
166
+ const holder = await read('1.0.0', 8);
167
+ assert.equal(holder.statusCode, 200, JSON.stringify(holder.body));
168
+ assert.equal(holder.body.delisted, true);
169
+ assert.match(holder.body.markdown, /one — What it does/);
170
+ } finally { state.modules.weather.status = 'listed'; state.held.clear(); }
171
+ });
172
+
173
+ // ---- the page: the author's markdown is untrusted input ----
174
+
175
+ function loadReader() {
176
+ const ctx = { window: {}, location: { href: 'https://builders.example.com/builders/modules', hostname: 'builders.example.com' }, URL };
177
+ vm.createContext(ctx);
178
+ vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/md-reader.js'), 'utf8'), ctx);
179
+ return ctx.window.OTBMdReader;
180
+ }
181
+ const MODULES_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/modules.js'), 'utf8');
182
+ // The page's own link rule, lifted out of modules.js so the test runs the real one.
183
+ const linkRule = MODULES_JS.match(/function howtoLinkHref\(href\) \{\n\s*return (.+);\n\s*\}/);
184
+ const howtoLinkHref = new Function('href', `return ${linkRule[1]};`);
185
+
186
+ test('author HTML renders as text: no tag, no handler and no script link survives', () => {
187
+ const MD = loadReader();
188
+ const evil = [
189
+ '## What it does',
190
+ '<script>alert(1)</script> <img src=x onerror=alert(2)>',
191
+ '[click](javascript:alert(3)) [rel](../secrets.md) [ok](https://example.org/a)',
192
+ '```', '</code><script>alert(4)</script>', '```',
193
+ ].join('\n');
194
+ const html = MD.parseMarkdown(evil, howtoLinkHref).html;
195
+ // Every tag in the output is one the reader writes itself; the author's are text.
196
+ const tags = html.match(/<\/?([a-z0-9]+)/gi).map((t) => t.replace(/[</]/g, '').toLowerCase());
197
+ assert.deepEqual([...new Set(tags)].sort(), ['a', 'code', 'h2', 'p', 'pre']);
198
+ assert.doesNotMatch(html, /href="javascript:/i);
199
+ assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
200
+ assert.match(html, /<a href="https:\/\/example\.org\/a"/, 'an absolute https link still works');
201
+ assert.doesNotMatch(html, /href="\.\.\/secrets\.md"/, 'a relative repo path is text, not a link');
202
+ });
203
+
204
+ test('modules.js renders the how-to only through the escaping reader, and checks the Claude link', () => {
205
+ assert.match(MODULES_JS, /OTBMdReader\.parseMarkdown\(md, howtoLinkHref\)/);
206
+ assert.doesNotMatch(MODULES_JS, /innerHTML\s*=\s*[^;]*body\.markdown/, 'the raw markdown is never assigned as HTML');
207
+ assert.match(MODULES_JS, /isClaudeArtifactUrl\(body\.artifact_url\)/);
208
+ assert.match(MODULES_JS, /window\.print\(\)/, 'Save as PDF is the browser\'s own print');
209
+ const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/modules.html'), 'utf8');
210
+ assert.match(html, /md-reader\.js/);
211
+ assert.match(html, /Also available as a Claude page/);
212
+ assert.match(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/modules.css'), 'utf8'), /@media print/);
213
+ });
214
+
215
+ // ---- the two links to the page ----
216
+
217
+ test('the Modules list links an on-disk module that is a published store version', async () => {
218
+ const list = handlerFor(router, 'get', '/modules');
219
+ const res = await invoke(list.handle, { builder: { id: 7 } });
220
+ assert.equal(res.statusCode, 200);
221
+ for (const m of res.body.modules) {
222
+ assert.equal(m.store_howto, null, `${m.key} is not a store version, so it carries no how-to link`);
223
+ }
224
+ });
225
+
226
+ test('install prints the how-to page link when the version ships one, and not otherwise', async () => {
227
+ const describe = (v) => ({ ok: true, module_key: 'weather', version: v.version, core_version: v.coreVersion, tarball_sha256: v.tarballSha256, tree_sha256: v.treeSha256, tarball_bytes: v.bytes });
228
+ async function run(version) {
229
+ const tgz = fs.readFileSync(path.join(STORE, 'weather', `weather-${version}.tgz`));
230
+ const v = await artifact.verifyModuleArtifact(tgz, { key: 'weather' });
231
+ const request = async (method, urlPath) => {
232
+ if (urlPath.endsWith('/acquire')) return { ok: true, status: 200, data: describe(v) };
233
+ if (urlPath.endsWith('/tarball')) return { ok: true, status: 200, buf: tgz };
234
+ return { ok: true, status: 200, data: { ok: true } };
235
+ };
236
+ request.apiBase = 'https://example.com/';
237
+ const out = [];
238
+ const code = await cmdInstall(['weather'], { log: (m) => out.push(m), errlog: (m) => out.push(m), modulesDir: fs.mkdtempSync(path.join(os.tmpdir(), 'mod-howto-dest-')), roots: [], request, core: '1.19.0' });
239
+ assert.equal(code, 0, out.join('\n'));
240
+ return out.join('\n');
241
+ }
242
+ assert.match(await run('1.0.0'), /How to use it: https:\/\/example\.com\/builders\/modules\?howto=weather&version=1\.0\.0/);
243
+ assert.doesNotMatch(await run('0.9.0'), /How to use it/);
244
+ });