@bongos/core 1.20.45 → 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.
@@ -97,6 +97,15 @@
97
97
  return `<span class="ov-fact">upkeep <b>${escapeHtml(status)}</b></span>`;
98
98
  }
99
99
 
100
+ // A module installed from the store links the how-to its version shipped with
101
+ // (task 1004366). The server sets store_howto only for a published key@version,
102
+ // and only to a /builders/modules?howto= path, so anything else is dropped.
103
+ function howtoFact(m) {
104
+ const href = m.store_howto;
105
+ if (typeof href !== 'string' || !href.startsWith('/builders/modules?howto=')) return '';
106
+ return `<a class="ov-fact mod-howto" href="${escapeHtml(href)}">how-to</a>`;
107
+ }
108
+
100
109
  // The lines the row's details disclosure opens onto: the facts the payload
101
110
  // carries that the card never rendered — what the module contributes, the
102
111
  // core it needs, who maintains it and what it hands off to.
@@ -143,7 +152,7 @@
143
152
  <div class="ov-row__main">
144
153
  <span class="ov-row__title">${escapeHtml(m.title || m.key)}<span class="ov-mono">${escapeHtml(m.key)}</span></span>
145
154
  ${desc ? `<p class="ov-row__sub mod-row__desc">${escapeHtml(desc)}</p>` : ''}
146
- <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}</div>
155
+ <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}${howtoFact(m)}</div>
147
156
  ${lines ? `<details class="mod-more"${openKeys.has(m.key) ? ' open' : ''}><summary aria-label="Details for ${escapeHtml(m.title || m.key)}"></summary>${lines}</details>` : ''}
148
157
  </div>
149
158
  <div class="ov-row__right mod-row__right">${rightHtml(m)}</div>
@@ -320,5 +329,88 @@
320
329
  }
321
330
  }
322
331
 
323
- load();
332
+ // ---- one store version's how-to (task 1004366, ADR 0347) ----
333
+ //
334
+ // ?howto=<key>&version=<X.Y.Z> opens the HOWTO.md that version shipped with in
335
+ // place of the catalog. A query, not a #/ route: the hall's hash namespace is
336
+ // frozen to the idea and blocker overlays.
337
+
338
+ // The author wrote this file, so a link in it may point anywhere. Only an absolute
339
+ // http(s) link, a mailto: or an in-page #anchor becomes an <a>; a relative path
340
+ // means nothing outside the author's repo, so it renders as its text.
341
+ function howtoLinkHref(href) {
342
+ return /^(https?:\/\/|mailto:|#)/i.test(String(href || '')) ? href : null;
343
+ }
344
+
345
+ // The same rule the manifest schema enforces on howto.artifactUrl (an https link on
346
+ // claude.ai), checked again here because this page writes it into an href.
347
+ function isClaudeArtifactUrl(u) {
348
+ try {
349
+ const url = new URL(String(u));
350
+ return url.protocol === 'https:' && (url.hostname === 'claude.ai' || url.hostname.endsWith('.claude.ai'));
351
+ } catch (_) { return false; }
352
+ }
353
+
354
+ function howtoFail(message) {
355
+ const doc = $('#howto-doc');
356
+ if (doc) { doc.removeAttribute('aria-busy'); doc.innerHTML = kit.emptyStateHtml(message); }
357
+ const print = $('#howto-print');
358
+ if (print) print.hidden = true;
359
+ }
360
+
361
+ async function loadHowto(key, version) {
362
+ const catalog = $('#core-scroll');
363
+ if (catalog) catalog.hidden = true;
364
+ const sub = $('#modules-sub');
365
+ if (sub) sub.hidden = true;
366
+ // A how-to is a document, so the page opts into the reading room (room.css
367
+ // scopes its column width and article sizes to body.room).
368
+ document.body.classList.add('room');
369
+ const view = $('#howto-view');
370
+ if (view) view.hidden = false;
371
+ const print = $('#howto-print');
372
+ if (print) print.addEventListener('click', () => window.print());
373
+
374
+ const res = await api.request('GET', `${API}/store/modules/${encodeURIComponent(key)}/versions/${encodeURIComponent(version)}/howto`);
375
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
376
+ const body = res.data || {};
377
+ if (!res.ok) {
378
+ const err = body.error || {};
379
+ howtoFail((typeof err === 'object' && err.message) || body.message || `Could not load the how-to (${res.status}).`);
380
+ return;
381
+ }
382
+ const title = String(body.title || key);
383
+ document.title = `${title} ${body.version} — how-to`;
384
+ const h = $('#howto-h');
385
+ if (h) h.textContent = `${title} — how to use it`;
386
+ const meta = $('#howto-meta');
387
+ if (meta) {
388
+ meta.innerHTML = `<span class="ov-mono">${escapeHtml(key)}</span> · version <span class="ov-mono">${escapeHtml(String(body.version))}</span>`
389
+ + (body.delisted ? ' · <span class="fact-pill fact-pill--warn">delisted</span>' : '');
390
+ }
391
+ const extra = $('#howto-extra');
392
+ if (extra && body.artifact_url && isClaudeArtifactUrl(body.artifact_url)) {
393
+ extra.href = body.artifact_url;
394
+ extra.hidden = false;
395
+ }
396
+ const doc = $('#howto-doc');
397
+ if (doc) {
398
+ // HTML comments are the scaffold's writing prompts (ADR 0347 D2); they are
399
+ // not part of the how-to, so they are removed rather than shown as text.
400
+ const md = String(body.markdown || '').replace(/<!--[\s\S]*?-->/g, '');
401
+ doc.innerHTML = window.OTBMdReader.parseMarkdown(md, howtoLinkHref).html;
402
+ doc.removeAttribute('aria-busy');
403
+ }
404
+ }
405
+
406
+ const params = new URLSearchParams(window.location.search);
407
+ const howtoKey = params.get('howto');
408
+ if (howtoKey) {
409
+ loadHowto(howtoKey, params.get('version') || '').catch((err) => {
410
+ console.error('[modules] how-to failed to load', err);
411
+ howtoFail('Could not load the how-to. Return to the modules list and try again.');
412
+ });
413
+ } else {
414
+ load();
415
+ }
324
416
  })();
@@ -8,3 +8,5 @@
8
8
  - **The island index is the map's keyboard and phone path.** The scene is a hover surface: SVG `<g>`s with mouse listeners, **no focusable element**, and at 390px thirty islands in 360px with nothing under them. `drawIndex` renders every node of the current view as a dense `.ov-row` under a `kit-collapse` (`#ab-index`) whose title is a disclosure button (`aria-expanded`/`aria-controls`) laying the SAME `detailHTML` inline; hovering a row lights the island, and **the index opens itself under 640px (`matchMedia`), where the list is the read.**
9
9
 
10
10
  Also gone, and the one rule in that list: `.ab-caption` takes the lede's `--measure`, never a fresh `90ch`. **Gotcha:** `gen-atlas.js` run from the MAIN checkout walks `.claude/worktrees/*` and emits thousands of context nodes — run it from a worktree, or copy a worktree's `atlas.json` over, before previewing the atlas there.
11
+
12
+ **A store version's how-to is a view of the Modules page, not a new page (task 1004366).** `/builders/modules?howto=<key>&version=<X.Y.Z>` hides the catalog and shows that version's `HOWTO.md` in the reading room's article card: the page adds `body.room` so `room.css`'s sizes apply, and the markdown goes through `md-reader.js` (author-written, so every byte is escaped; only absolute http(s), mailto and #anchor links become links). A query, not a `#/` route — the hash namespace is frozen to the idea and blocker overlays. "Save as PDF" is `window.print()` with an `@media print` block in `modules.css` that keeps only the document and prints in the system ink (`CanvasText`), because a literal colour is the token tier's. Harness fixture: `store__modules__howto.json`.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.45",
3
+ "version": "1.20.46",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.45",
9
+ "version": "1.20.46",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.45",
3
+ "version": "1.20.46",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8383,5 +8383,11 @@
8383
8383
  "id": "1004473",
8384
8384
  "text": "The Builders Hall now has a Commands page listing every slash command and every terminal command, with what each does, when to use it and what it costs, including the ones hidden from Claude's menu."
8385
8385
  }
8386
+ ],
8387
+ "1.20.46": [
8388
+ {
8389
+ "id": "1004366",
8390
+ "text": "Every store module version now has a readable how-to page in the hall, with a Save as PDF button and an optional link to a Claude page."
8391
+ }
8386
8392
  ]
8387
8393
  }
@@ -266,4 +266,24 @@ async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, in
266
266
  return out;
267
267
  }
268
268
 
269
- module.exports = { MAX_TARBALL_BYTES, HOWTO_FILE, HOWTO_SECTIONS, packModule, verifyModuleArtifact, deniedFiles, checkHowto };
269
+ // The how-to a published version shipped with (task 1004366): read from the version's
270
+ // own tarball, after the same hash check install runs, so the page always shows the
271
+ // exact file that version was published with. A version from before the gate may have
272
+ // none (text null). Text only — nothing in the module is run or rendered here.
273
+ // Returns { ok, text, artifactUrl, title, version, tarballSha256 } or verifyModuleArtifact's failure.
274
+ async function readHowto(tgz, { key }) {
275
+ const v = await verifyModuleArtifact(tgz, { key, includeFiles: true });
276
+ if (!v.ok) return v;
277
+ const file = v.files.find((f) => f.path === HOWTO_FILE);
278
+ const extra = v.moduleJson.howto || {};
279
+ return {
280
+ ok: true,
281
+ text: file ? file.buf.toString('utf8') : null,
282
+ artifactUrl: extra.artifactUrl || null,
283
+ title: v.moduleJson.title || key,
284
+ version: v.version,
285
+ tarballSha256: v.tarballSha256,
286
+ };
287
+ }
288
+
289
+ module.exports = { MAX_TARBALL_BYTES, HOWTO_FILE, HOWTO_SECTIONS, packModule, verifyModuleArtifact, deniedFiles, checkHowto, readHowto };
@@ -930,7 +930,7 @@ function storeRequest() {
930
930
  const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
931
931
  const session = readSessionToken();
932
932
  if (!session) throw new Error('no Bongos session — run `bongos setup` first');
933
- return async (method, urlPath, { json, binary = false } = {}) => {
933
+ const call = async (method, urlPath, { json, binary = false } = {}) => {
934
934
  const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
935
935
  method,
936
936
  headers: {
@@ -946,6 +946,18 @@ function storeRequest() {
946
946
  try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
947
947
  return { ok: res.ok, status: res.status, data };
948
948
  };
949
+ // Where the store lives, so install can print a full link to the how-to page.
950
+ call.apiBase = session.apiBase;
951
+ return call;
952
+ }
953
+
954
+ // The hall page that shows a version's HOWTO.md (task 1004366) — the Modules tab,
955
+ // opened on one version. null when the version shipped without one (published
956
+ // before ADR 0347's gate), so install never prints a link that would 404.
957
+ function howtoLink(key, version, files, apiBase) {
958
+ if (!files.some((f) => f.path === 'HOWTO.md')) return null;
959
+ const p = `/builders/modules?howto=${encodeURIComponent(key)}&version=${encodeURIComponent(version)}`;
960
+ return apiBase ? `${String(apiBase).replace(/\/+$/, '')}${p}` : p;
949
961
  }
950
962
 
951
963
  function storeError(res) {
@@ -1093,6 +1105,8 @@ async function cmdInstall(args, {
1093
1105
 
1094
1106
  if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'install' }))) return 1;
1095
1107
  log(`\n✓ Installed "${key}" ${got.described.version}. It is not switched on: enable it from the hall's Modules tab when you want it.`);
1108
+ const howto = howtoLink(key, got.described.version, got.verified.files, call.apiBase);
1109
+ if (howto) log(` How to use it: ${howto}`);
1096
1110
  return 0;
1097
1111
  }
1098
1112
 
@@ -144,6 +144,9 @@ const FIXTURE_FALLBACKS = [
144
144
  // the Approval queue's renders (task 1004323): a round's named visuals, one
145
145
  // fixture for any task id, as the editor read above serves any page.
146
146
  [/^tasks\/\d+\/visuals$/, 'tasks__visuals'],
147
+ // a store version's how-to page (task 1004366): one reading serves any key and
148
+ // version, since the Modules tab's how-to view shows one document.
149
+ [/^store\/modules\/[a-z0-9-]+\/versions\/[^/]+\/howto$/, 'store__modules__howto'],
147
150
  ];
148
151
 
149
152
  // CANNED WRITES (task 1004321). The tweak editor's whole flow is writes (the
@@ -179,6 +179,35 @@ async function resolveAcquirable(key, { version = null, db } = {}) {
179
179
  return { ok: true, module: mod, version: row, price: price || null, free: isFreePrice(price) };
180
180
  }
181
181
 
182
+ // Resolve ONE exact version for reading its how-to (task 1004366). Unlike
183
+ // resolveAcquirable this does not refuse a delisted module: ADR 0338 D2 keeps a
184
+ // delisted module readable for the people who already hold it, so it hands the
185
+ // caller the module's status and the route decides who may read on.
186
+ // Returns { ok: true, module, version } or { ok: false, status, code, message }.
187
+ async function resolveReadable(key, version, { db } = {}) {
188
+ const pool = db || require('./pool').pool;
189
+ const refuse = (status, code, message) => ({ ok: false, status, code, message });
190
+ const { rows: [mod] } = await pool.query(
191
+ 'SELECT module_key, title, author_id, status FROM store_modules WHERE module_key = $1', [key]);
192
+ if (!mod) return refuse(404, 'module_not_found', `The store has no module "${key}".`);
193
+ const { rows: [row] } = await pool.query(
194
+ `SELECT id, module_key, version, tarball_sha256, artifact_path, published_at
195
+ FROM store_module_versions WHERE module_key = $1 AND version = $2`, [key, version]);
196
+ if (!row) return refuse(404, 'version_not_found', `"${key}" has no published version ${version}.`);
197
+ return { ok: true, module: mod, version: row };
198
+ }
199
+
200
+ // Which of these (key, version) pairs are published store versions, as a Set of
201
+ // "key@version" — so the hall's Modules tab can link an installed module's how-to
202
+ // (task 1004366). One query for the whole list.
203
+ async function publishedVersionSet(keys, { db } = {}) {
204
+ if (!keys.length) return new Set();
205
+ const pool = db || require('./pool').pool;
206
+ const { rows } = await pool.query(
207
+ 'SELECT module_key, version FROM store_module_versions WHERE module_key = ANY($1)', [keys]);
208
+ return new Set(rows.map((r) => `${r.module_key}@${r.version}`));
209
+ }
210
+
182
211
  // The tarball on disk for a version row. artifact_path is relative to the store dir
183
212
  // (relativeArtifactPath), and artifactPath() re-validates key + version, so a row
184
213
  // can never point a read outside the store.
@@ -192,5 +221,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
192
221
 
193
222
  module.exports = {
194
223
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
195
- isFreePrice, resolveAcquirable, versionArtifactFile,
224
+ isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, versionArtifactFile,
196
225
  };
@@ -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/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.45'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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 upkeep are the facts line');
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');