@bongos/core 1.20.44 → 1.20.46
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 +118 -73
- 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 +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +138 -3
- package/docs/api-reference.md +9 -2
- package/docs/copy-inventory.md +65 -29
- package/docs/copy-registry.json +418 -33
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -0
- package/docs/onboarding/slash-commands.md +12 -56
- package/docs/page-inventory.json +35 -4
- package/docs/page-readings.json +994 -959
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands-lib.js +142 -0
- package/modules/hall-ui/public/commands.css +98 -0
- package/modules/hall-ui/public/commands.html +65 -0
- package/modules/hall-ui/public/commands.js +52 -0
- package/modules/hall-ui/public/commands.states.json +14 -0
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.css +45 -0
- package/modules/hall-ui/public/modules.html +24 -2
- package/modules/hall-ui/public/modules.js +94 -2
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +3 -0
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/catalog.md +2 -0
- package/modules/hall-ui/records/commands.md +11 -0
- package/modules/specialities/skills.js +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/module-artifact.js +21 -1
- package/scripts/gds/module.js +15 -1
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/module-store.js +30 -1
- package/src/bongos/routes/cli-commands.js +49 -0
- package/src/bongos/routes/modules.js +91 -1
- package/src/bongos/routes.js +3 -0
- package/src/bongos/serve-internal.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/hall_catalog_world.mjs +1 -1
- package/tests/hall_commands.mjs +162 -0
- package/tests/hall_nav.mjs +3 -1
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/module_store_howto.mjs +244 -0
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
// the server end of `bongos module install` (task
|
|
33
33
|
// 1003785): take the entitlement, download the
|
|
34
34
|
// checked tarball, record the version placed.
|
|
35
|
+
// GET /store/modules/:key/versions/:version/howto
|
|
36
|
+
// the HOWTO.md that version shipped with, as text for
|
|
37
|
+
// the hall's how-to page (task 1004366, ADR 0347).
|
|
35
38
|
//
|
|
36
39
|
// Read is open to any signed-in builder (same gate as the atlas/primer pages);
|
|
37
40
|
// every write (enable/disable, submit) is metic+archon, same rank as
|
|
@@ -78,7 +81,7 @@ const moduleArtifact = require('../../../scripts/gds/module-artifact');
|
|
|
78
81
|
const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
|
|
79
82
|
const {
|
|
80
83
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
81
|
-
resolveAcquirable, versionArtifactFile,
|
|
84
|
+
resolveAcquirable, resolveReadable, publishedVersionSet, versionArtifactFile,
|
|
82
85
|
} = require('../module-store');
|
|
83
86
|
const { KEY_RE } = require('../../module-loader/manifest-schema');
|
|
84
87
|
const { parseVersion } = require('../../module-loader/semver');
|
|
@@ -146,8 +149,20 @@ function listModules() {
|
|
|
146
149
|
});
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
// How many checked how-tos one router keeps in memory (task 1004366). Each is one
|
|
153
|
+
// module's HOWTO.md text, so a few hundred is well under a megabyte; past the cap
|
|
154
|
+
// the oldest entry is dropped.
|
|
155
|
+
const HOWTO_CACHE_MAX = 200;
|
|
156
|
+
|
|
149
157
|
module.exports = function buildModulesRouter() {
|
|
150
158
|
const router = express.Router();
|
|
159
|
+
// Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
|
|
160
|
+
// router starts empty.
|
|
161
|
+
const howtoCache = new Map();
|
|
162
|
+
const rememberHowto = (k, v) => {
|
|
163
|
+
howtoCache.set(k, v);
|
|
164
|
+
if (howtoCache.size > HOWTO_CACHE_MAX) howtoCache.delete(howtoCache.keys().next().value);
|
|
165
|
+
};
|
|
151
166
|
|
|
152
167
|
// rank: any-builder — list every on-disk module with attribution + provenance (same read gate as the atlas/primer pages).
|
|
153
168
|
router.get('/modules', auth.requireBuilder, asyncHandler('GET /modules', async (_req, res) => {
|
|
@@ -160,11 +175,22 @@ module.exports = function buildModulesRouter() {
|
|
|
160
175
|
const sources = (resolvedSet && resolvedSet.sources) || {};
|
|
161
176
|
const overrideRows = await listOverrides();
|
|
162
177
|
const byKey = new Map(overrideRows.map((r) => [r.module_key, r]));
|
|
178
|
+
// Which on-disk modules are a published store version, so the row can link that
|
|
179
|
+
// version's how-to page (task 1004366). Failure-tolerant like the override read:
|
|
180
|
+
// a store that cannot be read costs the link, never the tab.
|
|
181
|
+
let published = new Set();
|
|
182
|
+
try { published = await publishedVersionSet(all.map((m) => m.key)); }
|
|
183
|
+
catch (e) { log.warn({ err: e.message }, 'store versions unreadable for the modules list'); }
|
|
163
184
|
for (const m of all) {
|
|
164
185
|
m.enabled_source = sources[m.key] || 'default';
|
|
165
186
|
m.override = byKey.has(m.key)
|
|
166
187
|
? { enabled: byKey.get(m.key).enabled, set_by: byKey.get(m.key).set_by_login || null, set_at: byKey.get(m.key).set_at }
|
|
167
188
|
: null;
|
|
189
|
+
// Only a module in THIS instance's tree: install places store modules there, so
|
|
190
|
+
// a core-shipped module that happens to share a store key@version never links.
|
|
191
|
+
m.store_howto = m.source === 'instance' && m.version && published.has(`${m.key}@${m.version}`)
|
|
192
|
+
? `/builders/modules?howto=${encodeURIComponent(m.key)}&version=${encodeURIComponent(String(m.version))}`
|
|
193
|
+
: null;
|
|
168
194
|
}
|
|
169
195
|
res.json({
|
|
170
196
|
modules: all,
|
|
@@ -475,6 +501,70 @@ module.exports = function buildModulesRouter() {
|
|
|
475
501
|
res.send(buf);
|
|
476
502
|
}));
|
|
477
503
|
|
|
504
|
+
// GET /api/bongos/store/modules/:key/versions/:version/howto — the how-to a version
|
|
505
|
+
// shipped with (task 1004366, ADR 0347 D1/D3), read from that version's own stored
|
|
506
|
+
// tarball after the install hash check, so it always matches the version and cannot
|
|
507
|
+
// change after publishing. Returns the markdown as TEXT, never HTML: the hall's
|
|
508
|
+
// Modules tab renders it through its one escaping markdown reader (md-reader.js),
|
|
509
|
+
// because the file is author-written and must be treated as untrusted input.
|
|
510
|
+
//
|
|
511
|
+
// rank: any-builder — no entitlement needed, since a buyer reads the how-to before
|
|
512
|
+
// deciding. The one exception is a delisted module: ADR 0338 D2 keeps it readable
|
|
513
|
+
// only for the people who already hold it, so there the caller's own entitlement
|
|
514
|
+
// is checked (own-scoped, like the reads above).
|
|
515
|
+
router.get('/store/modules/:key/versions/:version/howto', auth.requireBuilder,
|
|
516
|
+
asyncHandler('GET /store/modules/:key/versions/:version/howto', async (req, res) => {
|
|
517
|
+
const { key, version } = req.params;
|
|
518
|
+
if (!KEY_RE.test(key) || !parseVersion(version)) {
|
|
519
|
+
return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
|
|
520
|
+
}
|
|
521
|
+
const r = await resolveReadable(key, version);
|
|
522
|
+
if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
|
|
523
|
+
const delisted = r.module.status === 'delisted';
|
|
524
|
+
if (delisted) {
|
|
525
|
+
const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
|
|
526
|
+
if (!held.entitled) {
|
|
527
|
+
return res.fail('module_delisted', { status: 409, message: `"${key}" is delisted: its how-to stays readable only for people who already hold it (ADR 0338 D2).` });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// A published version never changes, so its checked how-to is cached by the
|
|
531
|
+
// tarball hash the registry holds: the inflate + hash check runs once per
|
|
532
|
+
// version per process, not on every page view.
|
|
533
|
+
const cacheKey = `${key}@${version}:${r.version.tarball_sha256}`;
|
|
534
|
+
let h = howtoCache.get(cacheKey);
|
|
535
|
+
if (!h) {
|
|
536
|
+
let buf;
|
|
537
|
+
try { buf = await fs.promises.readFile(versionArtifactFile(r.version)); }
|
|
538
|
+
catch (e) {
|
|
539
|
+
log.error({ key, version, err: e.message }, 'store tarball missing');
|
|
540
|
+
return res.fail('artifact_missing', { status: 500, message: `The store has no file for ${key} ${version}.` });
|
|
541
|
+
}
|
|
542
|
+
h = await moduleArtifact.readHowto(buf, { key });
|
|
543
|
+
if (!h.ok) {
|
|
544
|
+
log.error({ key, version, code: h.code }, 'stored tarball failed verification');
|
|
545
|
+
return res.fail('artifact_invalid', { status: 500, message: `The stored file for ${key} ${version} did not pass its hash check.` });
|
|
546
|
+
}
|
|
547
|
+
if (h.tarballSha256 !== r.version.tarball_sha256) {
|
|
548
|
+
log.error({ key, version }, 'stored tarball is not the one the registry recorded');
|
|
549
|
+
return res.fail('artifact_invalid', { status: 500, message: `The stored file for ${key} ${version} is not the one that was published.` });
|
|
550
|
+
}
|
|
551
|
+
rememberHowto(cacheKey, h);
|
|
552
|
+
}
|
|
553
|
+
if (h.text == null) {
|
|
554
|
+
return res.fail('howto_missing', { status: 404, message: `${key} ${version} was published before every module shipped a how-to, so it has none.` });
|
|
555
|
+
}
|
|
556
|
+
res.set('Cache-Control', 'no-store');
|
|
557
|
+
res.json({
|
|
558
|
+
module_key: key,
|
|
559
|
+
title: h.title,
|
|
560
|
+
version: h.version,
|
|
561
|
+
published_at: r.version.published_at,
|
|
562
|
+
delisted,
|
|
563
|
+
markdown: h.text,
|
|
564
|
+
artifact_url: h.artifactUrl,
|
|
565
|
+
});
|
|
566
|
+
}));
|
|
567
|
+
|
|
478
568
|
router.post('/store/modules/:key/acquired', auth.requireBuilder,
|
|
479
569
|
asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
|
|
480
570
|
const { key } = req.params;
|
package/src/bongos/routes.js
CHANGED
|
@@ -56,6 +56,8 @@ const buildSecurityRouter = require('./routes/security');
|
|
|
56
56
|
// the submit-for-review front door. Core (not module-owned): it manages
|
|
57
57
|
// EVERY module's enablement, including modules it must not import.
|
|
58
58
|
const buildModulesRouter = require('./routes/modules');
|
|
59
|
+
// The hall Commands page's CLI half (task 1004473): bin/bongos.js VERBS as data.
|
|
60
|
+
const buildCliCommandsRouter = require('./routes/cli-commands');
|
|
59
61
|
// The access-request ADMISSION routes carved to modules/onboarding/ (BV1.R81 /
|
|
60
62
|
// ADR 0093 §1); moduleLoader.mountModuleRoutes (below) mounts them via the
|
|
61
63
|
// onboarding module's route factory — no core require.
|
|
@@ -159,6 +161,7 @@ function buildGdsRouter() {
|
|
|
159
161
|
router.use(buildAuditLogRouter());
|
|
160
162
|
router.use(buildSecurityRouter());
|
|
161
163
|
router.use(buildModulesRouter());
|
|
164
|
+
router.use(buildCliCommandsRouter());
|
|
162
165
|
// access-requests (admission) is now mounted by the onboarding module via
|
|
163
166
|
// moduleLoader.mountModuleRoutes below (BV1.R81) — still BEFORE the public
|
|
164
167
|
// router, so its paths are byte-identical. The /sessions data plane is
|
|
@@ -1210,8 +1210,11 @@ function mountInternalSurfaces(app) {
|
|
|
1210
1210
|
// own room beside the studio, on the studio's tier. Its read (GET
|
|
1211
1211
|
// /copy-desk/pages/:pageId/draft) is any-builder like the .docx download, and
|
|
1212
1212
|
// every write is the page claim's holder only, checked server-side.
|
|
1213
|
+
// task 1004473: `commands`, the Commands page — every skill and every bongos CLI
|
|
1214
|
+
// verb. Both reads behind it are requireBuilder, so it sits on this tier;
|
|
1215
|
+
// reading the list grants nothing (ADR 0310 §1).
|
|
1213
1216
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1214
|
-
/^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1217
|
+
/^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|commands)(?:\.html)?\/?$/;
|
|
1215
1218
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1216
1219
|
// page that embeds them.
|
|
1217
1220
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
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.46'; // 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');
|
|
@@ -58,7 +58,7 @@ test('modules: the row is the oversight row — title with the mono key, clamped
|
|
|
58
58
|
assert.match(js, /<span class="ov-row__title">\$\{escapeHtml\(m\.title \|\| m\.key\)\}<span class="ov-mono">\$\{escapeHtml\(m\.key\)\}<\/span>/, 'the key rides inside the title in mono');
|
|
59
59
|
assert.match(js, /class="ov-row__sub mod-row__desc"/, 'the description is the sub line');
|
|
60
60
|
assert.match(js, /<details class="mod-more"/, 'a details disclosure opens the rest');
|
|
61
|
-
assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}<\/div>/, 'provenance, credit and
|
|
61
|
+
assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}<\/div>/, 'provenance, credit, upkeep and (for a store version, task 1004366) the how-to link are the facts line');
|
|
62
62
|
assert.match(js, /<div class="ov-row__right mod-row__right">\$\{rightHtml\(m\)\}<\/div>/, 'the right column is one function');
|
|
63
63
|
const css = read('modules.css');
|
|
64
64
|
assert.match(css, /\.mod-row__desc \{[^}]*-webkit-line-clamp: 2/, 'the description clamps to two lines');
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// tests/hall_commands.mjs — the hall's Commands page (task 1004473): one table
|
|
2
|
+
// of every skill and one of every bongos CLI verb, read from live sources.
|
|
3
|
+
//
|
|
4
|
+
// The done-when pins: every SKILL.md the instance has, and every bin/bongos.js
|
|
5
|
+
// VERBS entry, appears on the page EXACTLY once. They are checked against the
|
|
6
|
+
// real tree — the same installedSkills()+explainSkill() the
|
|
7
|
+
// /specialities/installed-skills route serves, and the same cliCommands() the
|
|
8
|
+
// /cli/commands route serves — rendered through the page's own lib.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/hall_commands.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import path from 'node:path';
|
|
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 lib = require('../modules/hall-ui/public/commands-lib.js');
|
|
23
|
+
const kit = require('../modules/hall-ui/public/hall-kit.js');
|
|
24
|
+
const skills = require('../modules/specialities/skills.js');
|
|
25
|
+
const { VERBS, GROUPS } = require('../bin/bongos.js');
|
|
26
|
+
const esc = kit.esc;
|
|
27
|
+
|
|
28
|
+
// Every SKILL.md on disk, by name: core .claude/skills/ plus every module's skills/.
|
|
29
|
+
function skillDirsOnDisk({ modules = () => true } = {}) {
|
|
30
|
+
const out = [];
|
|
31
|
+
const scan = (dir) => {
|
|
32
|
+
let names = [];
|
|
33
|
+
try { names = fs.readdirSync(dir); } catch { return; }
|
|
34
|
+
for (const n of names) if (fs.existsSync(path.join(dir, n, 'SKILL.md'))) out.push({ name: n, md: path.join(dir, n, 'SKILL.md') });
|
|
35
|
+
};
|
|
36
|
+
scan(path.join(ROOT, '.claude', 'skills'));
|
|
37
|
+
for (const key of fs.readdirSync(path.join(ROOT, 'modules'))) if (modules(key)) scan(path.join(ROOT, 'modules', key, 'skills'));
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// What the route hands the page: installedSkills() explained, exactly as
|
|
42
|
+
// GET /specialities/installed-skills builds it.
|
|
43
|
+
function servedSkills(isModuleEnabled) {
|
|
44
|
+
return [...skills.installedSkills({ coreRoot: ROOT, isModuleEnabled })]
|
|
45
|
+
.map(([name, entry]) => skills.explainSkill({ name, ...entry }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const count = (html, attr, value) => html.split(`${attr}="${value}"`).length - 1;
|
|
49
|
+
|
|
50
|
+
test('every SKILL.md in the tree appears on the page exactly once (every module on)', () => {
|
|
51
|
+
const html = lib.skillsTableHtml(lib.groupSkills(servedSkills(() => true)), { esc });
|
|
52
|
+
const onDisk = skillDirsOnDisk();
|
|
53
|
+
assert.ok(onDisk.length >= 48, `expected the real skill tree, found ${onDisk.length}`);
|
|
54
|
+
for (const { name } of onDisk) assert.equal(count(html, 'data-skill', name), 1, `/${name} should appear exactly once`);
|
|
55
|
+
assert.equal(count(html, 'data-skill', ''), 0, 'no nameless row');
|
|
56
|
+
assert.equal(html.split('data-skill="').length - 1, new Set(onDisk.map((s) => s.name)).size, 'no row that is not a SKILL.md');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a default instance shows its enabled modules\' skills and NOT a disabled module\'s', () => {
|
|
60
|
+
const defaults = (key) => {
|
|
61
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', key, 'module.json'), 'utf8')).default === true; } catch { return false; }
|
|
62
|
+
};
|
|
63
|
+
const html = lib.skillsTableHtml(lib.groupSkills(servedSkills(defaults)), { esc });
|
|
64
|
+
for (const { name } of skillDirsOnDisk({ modules: defaults })) assert.equal(count(html, 'data-skill', name), 1, `/${name} should appear exactly once`);
|
|
65
|
+
for (const { name } of skillDirsOnDisk({ modules: (k) => k === 'pixel-art' || k === 'design-styles' })) {
|
|
66
|
+
if (fs.existsSync(path.join(ROOT, '.claude', 'skills', name, 'SKILL.md'))) continue;
|
|
67
|
+
assert.equal(count(html, 'data-skill', name), 0, `/${name} ships in a default-off module, so it must not be listed`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('each row carries the SKILL.md plain / reach-for / cost words', () => {
|
|
72
|
+
const served = servedSkills(() => true);
|
|
73
|
+
const html = lib.skillsTableHtml(lib.groupSkills(served), { esc });
|
|
74
|
+
const ship = served.find((s) => s.name === 'builder-ship');
|
|
75
|
+
const fm = skills.parseFrontmatter(fs.readFileSync(path.join(ROOT, '.claude/skills/builder-ship/SKILL.md'), 'utf8'));
|
|
76
|
+
assert.equal(ship.does, fm.plain);
|
|
77
|
+
assert.equal(ship.reachFor, fm['reach-for']);
|
|
78
|
+
assert.equal(ship.cost, fm.cost);
|
|
79
|
+
for (const v of [fm.plain, fm['reach-for'], fm.cost]) assert.ok(html.includes(esc(v)), `page shows: ${v}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the "type it yourself" marker follows disable-model-invocation, skill by skill', () => {
|
|
83
|
+
const served = servedSkills(() => true);
|
|
84
|
+
const html = lib.skillsTableHtml(lib.groupSkills(served), { esc });
|
|
85
|
+
let typed = 0;
|
|
86
|
+
for (const { name, md } of skillDirsOnDisk()) {
|
|
87
|
+
const hidden = skills.parseFrontmatter(fs.readFileSync(md, 'utf8'))['disable-model-invocation'] === 'true';
|
|
88
|
+
if (hidden) typed++;
|
|
89
|
+
const row = html.slice(html.indexOf(`data-skill="${name}"`)).split('</tr>')[0];
|
|
90
|
+
assert.match(row, hidden ? /cmd-how--typed/ : /cmd-how--listed/, `/${name}: ${hidden ? 'typed-only' : 'shown to Claude'}`);
|
|
91
|
+
}
|
|
92
|
+
assert.ok(typed > 0, 'the tree has typed-only skills (task 1004471), so the marker is exercised');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a skill no group names still gets a row, under Everything else', () => {
|
|
96
|
+
const groups = lib.groupSkills([{ name: 'brand-new-skill', source: 'core', does: 'x' }, { name: 'builder-ship', source: 'core' }]);
|
|
97
|
+
assert.deepEqual(groups.map((g) => g.key), ['loop', 'other']);
|
|
98
|
+
assert.equal(lib.groupKeyOf({ name: 'otb-tile-generate', source: 'module:pixel-art' }), 'design', 'a module\'s skills land in its group');
|
|
99
|
+
const loop = lib.groupSkills([{ name: 'builder-ship' }, { name: 'builder-claim' }, { name: 'builder-start' }].map((x) => ({ ...x, source: 'core' })));
|
|
100
|
+
assert.deepEqual(loop[0].skills.map((x) => x.name), ['builder-start', 'builder-claim', 'builder-ship'], 'the loop reads in the order you work it');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('row text is escaped — a skill cannot inject markup into the hall', () => {
|
|
104
|
+
const html = lib.skillsTableHtml(lib.groupSkills([{ name: 'x', source: 'core', does: '<img src=x onerror=alert(1)>' }]), { esc });
|
|
105
|
+
assert.ok(!html.includes('<img'), html);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const { cliCommands } = require('../src/bongos/routes/cli-commands.js');
|
|
109
|
+
|
|
110
|
+
test('every bin/bongos.js VERBS entry appears exactly once, grouped like `bongos --help`', () => {
|
|
111
|
+
const groups = cliCommands();
|
|
112
|
+
const html = lib.cliTableHtml(groups, { esc });
|
|
113
|
+
for (const verb of Object.keys(VERBS)) assert.equal(count(html, 'data-verb', verb), 1, `bongos ${verb} should appear exactly once`);
|
|
114
|
+
assert.equal(html.split('data-verb="').length - 1, Object.keys(VERBS).length, 'no row that is not a verb');
|
|
115
|
+
assert.deepEqual(groups.map((g) => g.label), GROUPS.map(([, label]) => label), 'the help\'s group order');
|
|
116
|
+
for (const g of groups) for (const c of g.commands) assert.equal(c.summary, VERBS[c.verb].summary, `${c.verb}: the help line, verbatim`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a verb in a group GROUPS does not name is still listed', () => {
|
|
120
|
+
const groups = cliCommands({ GROUPS: [['work', 'Work']], VERBS: { a: { group: 'work', summary: 'A' }, b: { group: 'new', summary: 'B' } } });
|
|
121
|
+
assert.deepEqual(groups.map((g) => [g.key, g.commands.map((c) => c.verb)]), [['work', ['a']], ['other', ['b']]]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('GET /cli/commands serves the verbs to a signed-in builder and refuses a stranger', async () => {
|
|
125
|
+
const auth = require('../src/bongos/auth.js');
|
|
126
|
+
const express = require('express');
|
|
127
|
+
const build = require('../src/bongos/routes/cli-commands.js');
|
|
128
|
+
const serve = async (app, fn) => {
|
|
129
|
+
const server = http.createServer(app);
|
|
130
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
131
|
+
try { return await fn(`http://127.0.0.1:${server.address().port}`); } finally { server.close(); }
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const saved = auth.requireBuilder;
|
|
135
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { id: 1, rank: 'xenos' }; next(); };
|
|
136
|
+
let signedIn;
|
|
137
|
+
try {
|
|
138
|
+
const app = express();
|
|
139
|
+
app.use(build());
|
|
140
|
+
signedIn = await serve(app, async (base) => { const r = await fetch(`${base}/cli/commands`); return { status: r.status, body: await r.json() }; });
|
|
141
|
+
} finally { auth.requireBuilder = saved; }
|
|
142
|
+
assert.equal(signedIn.status, 200);
|
|
143
|
+
assert.deepEqual(signedIn.body.groups.flatMap((g) => g.commands.map((c) => c.verb)).sort(), Object.keys(VERBS).sort());
|
|
144
|
+
|
|
145
|
+
const gated = express();
|
|
146
|
+
gated.use((req, res, next) => { res.fail = (code, st) => res.status(typeof st === 'number' ? st : 401).json({ error: code }); next(); });
|
|
147
|
+
gated.use(build());
|
|
148
|
+
const stranger = await serve(gated, async (base) => (await fetch(`${base}/cli/commands`)).status);
|
|
149
|
+
assert.ok(stranger === 401 || stranger === 403, `no session → refused (got ${stranger})`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the page is wired: nav entry in Docs, sign-in gated, both reads, no hand-typed command', () => {
|
|
153
|
+
const page = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/commands.html'), 'utf8');
|
|
154
|
+
const js = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/commands.js'), 'utf8');
|
|
155
|
+
assert.match(js, /\/api\/bongos\/specialities\/installed-skills/);
|
|
156
|
+
assert.match(js, /\/api\/bongos\/cli\/commands/);
|
|
157
|
+
assert.ok(!/data-skill=|data-verb=|\/builder-ship|bongos claim/.test(page), 'the HTML names no command — the rows are generated');
|
|
158
|
+
const shell = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
159
|
+
assert.match(shell, /id: 'commands', label: 'Commands', icon: 'docs', href: '\/commands', gate: 'signed'/);
|
|
160
|
+
const serveInternal = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
|
|
161
|
+
assert.match(serveInternal.match(/const SIGNEDIN_BUILDERS_PAGE_RE =\s*(\/.*\/);/)[1], /\|commands\)/);
|
|
162
|
+
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -285,8 +285,10 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
285
285
|
// what made this group read as one real page plus a link into the home page.
|
|
286
286
|
assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster'], 'Community = everyone else, in this project');
|
|
287
287
|
assert.ok(!nav.flatMap((g) => g.items).some((i) => /^\/people\b/.test(i.href)), 'no nav item points at the deleted /people');
|
|
288
|
+
// task 1004473: 'commands' — every skill and CLI verb, read live — is reference
|
|
289
|
+
// material that reads the same for every builder, so it joins Docs.
|
|
288
290
|
assert.deepEqual(nav[3].items.map((i) => i.id),
|
|
289
|
-
['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
291
|
+
['primer', 'ranks', 'economy', 'commands', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
290
292
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
291
293
|
// 'project-settings' joins the Government group at the metic floor (task 1003280,
|
|
292
294
|
// goal 1000072) — it sits before 'government', which is the one archon-only item.
|
|
@@ -46,6 +46,9 @@ const ORACLE = {
|
|
|
46
46
|
'/builders/settings': 'requireBuilderPage',
|
|
47
47
|
'/builders/atlas': 'requireBuilderPage',
|
|
48
48
|
'/builders/modules': 'requireBuilderPage',
|
|
49
|
+
// task 1004473: the Commands page — its two reads (installed skills, CLI verbs)
|
|
50
|
+
// are requireBuilder, so sign-in is the whole gate.
|
|
51
|
+
'/builders/commands': 'requireBuilderPage',
|
|
49
52
|
'/builders/goals': 'requireBuilderPage',
|
|
50
53
|
// task 1003479: the goal map, on the goal board's tier. task 1004232 (sky part 4):
|
|
51
54
|
// the URL now serves THE PROJECT SKY (the same page as /thinking, in its whole-project
|
|
@@ -635,7 +635,7 @@ try {
|
|
|
635
635
|
assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
|
|
636
636
|
assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
|
|
637
637
|
assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
|
|
638
|
-
assert.match(HTML, /shell\.js\?v=2026-09-
|
|
638
|
+
assert.match(HTML, /shell\.js\?v=2026-09-30-commands/);
|
|
639
639
|
assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
|
|
640
640
|
for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
|
|
641
641
|
assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
|
|
@@ -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, /<script>alert\(1\)<\/script>/);
|
|
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
|
+
});
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '4e14115a3737db9a';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|