@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
@@ -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.47'; // 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');
@@ -0,0 +1,202 @@
1
+ // tests/bulk_toolbar.mjs — the hall's shared bulk toolbar (task 1003141).
2
+ //
3
+ // One toolbar, two boards: /work and the goal board build the SAME one from
4
+ // modules/hall-ui/public/bulk-toolbar.js. This runs the real module (it is
5
+ // node-require-safe) against a stub page, so what it proves is behaviour — a
6
+ // release selection really enables Bulk release, a cycle really disables the
7
+ // sequential prompt — rather than that two files agree on a spelling.
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ROOT = path.resolve(import.meta.dirname, '..');
16
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
17
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
18
+ const { topoOrder, buildBulkParallelPrompt, buildBulkSequentialPrompt, makeBulkToolbar } = require(path.join(PUB, 'bulk-toolbar.js'));
19
+
20
+ const loc = { hostname: 'builders.example.com', origin: 'https://builders.example.com' };
21
+
22
+ test('topoOrder puts a dependency first and ignores edges outside the selection', () => {
23
+ const edges = new Map([[2, [1]], [1, [99]], [3, []]]);
24
+ const order = topoOrder([2, 1, 3], edges);
25
+ assert.ok(order.indexOf(1) < order.indexOf(2), 'a dependency precedes its dependent');
26
+ assert.equal(order.length, 3);
27
+ });
28
+
29
+ test('topoOrder answers null for a cycle', () => {
30
+ assert.equal(topoOrder([1, 2], new Map([[1, [2]], [2, [1]]])), null);
31
+ });
32
+
33
+ test('the prompts name every task and the hall link', () => {
34
+ const tasks = [{ id: 5, title: 'Five' }, { id: 6, title: 'Six' }];
35
+ const par = buildBulkParallelPrompt(tasks, loc);
36
+ assert.match(par, /2 Bongos tasks IN PARALLEL/);
37
+ assert.match(par, /Session 2 — task 6 \(Six\) — https:\/\/builders\.example\.com#\/task\/6/);
38
+ const seq = buildBulkSequentialPrompt(tasks, { hostname: 'example.com', origin: 'https://example.com' });
39
+ assert.match(seq, /IN ORDER/);
40
+ assert.match(seq, /1\. Task 5 \(Five\) — https:\/\/example\.com\/builders#\/task\/5/);
41
+ });
42
+
43
+ // --- the factory, against a stub page --------------------------------------
44
+
45
+ function stubPage(present) {
46
+ const els = {};
47
+ for (const id of present) els[id] = { hidden: true, disabled: false, textContent: '', title: '', listeners: {}, addEventListener(t, fn) { this.listeners[t] = fn; } };
48
+ globalThis.document = { getElementById: (id) => els[id] || null };
49
+ globalThis.window = { confirm: () => true };
50
+ globalThis.location = loc;
51
+ return els;
52
+ }
53
+
54
+ function build(els, over = {}) {
55
+ const calls = { claimMany: [], releaseMany: [], copied: [], toasts: [] };
56
+ const selection = { claim: new Set(), release: new Map() };
57
+ const tasks = new Map([[1, { id: 1, title: 'One' }], [2, { id: 2, title: 'Two' }]]);
58
+ const bar = makeBulkToolbar({
59
+ ids: { bar: 'bar', count: 'count', claim: 'claim', release: 'release', parallel: 'parallel', sequential: 'sequential', clear: 'clear' },
60
+ selection,
61
+ findTask: (id) => tasks.get(id),
62
+ claimAction: {
63
+ validateMany: async () => ({ ok: true, parallelSafe: true, byId: new Map() }),
64
+ claimMany: async (ids) => { calls.claimMany.push(ids); return { ok: true }; },
65
+ releaseMany: async (e) => { calls.releaseMany.push(e); return { ok: true }; },
66
+ },
67
+ getJSON: async () => ({ task: { dependencies: [] } }),
68
+ API: '/api/bongos',
69
+ copyText: async (t) => { calls.copied.push(t); return true; },
70
+ toast: (m) => calls.toasts.push(m),
71
+ curLabel: () => 'credits',
72
+ describeReason: (r) => r,
73
+ onClear: () => { calls.cleared = true; },
74
+ ...over,
75
+ });
76
+ return { bar, selection, calls };
77
+ }
78
+ const settle = () => new Promise((r) => setTimeout(r, 1000));
79
+
80
+ test('nothing selected hides the bar', () => {
81
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
82
+ const { bar } = build(els);
83
+ els.bar.hidden = false;
84
+ bar.render();
85
+ assert.equal(els.bar.hidden, true);
86
+ });
87
+
88
+ test('a release selection alone enables Bulk release and leaves claim off', () => {
89
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
90
+ const { bar, selection } = build(els);
91
+ selection.release.set('77', '9');
92
+ bar.render();
93
+ assert.equal(els.bar.hidden, false);
94
+ assert.equal(els.count.textContent, '1 to release');
95
+ assert.equal(els.release.disabled, false);
96
+ assert.equal(els.claim.disabled, true);
97
+ });
98
+
99
+ test('two ready tasks enable both prompts once the checks come back, and the prompts copy', async () => {
100
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
101
+ const { bar, selection, calls } = build(els);
102
+ selection.claim.add('1'); selection.claim.add('2');
103
+ bar.render();
104
+ assert.equal(els.parallel.disabled, true, 'disabled while the dry run is pending');
105
+ await settle();
106
+ assert.equal(els.parallel.disabled, false);
107
+ assert.equal(els.sequential.disabled, false);
108
+ assert.equal(els.claim.textContent, 'Bulk claim (2)');
109
+ bar.wire();
110
+ await els.parallel.listeners.click();
111
+ await els.sequential.listeners.click();
112
+ assert.equal(calls.copied.length, 2);
113
+ assert.match(calls.copied[0], /IN PARALLEL/);
114
+ assert.match(calls.copied[1], /IN ORDER/);
115
+ });
116
+
117
+ test('a dependency cycle disables the sequential prompt with the reason', async () => {
118
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
119
+ const edges = { 1: [2], 2: [1] };
120
+ const { bar, selection } = build(els, { getJSON: async (p) => ({ task: { dependencies: edges[Number(p.split('/').pop())].map((id) => ({ id })) } }) });
121
+ selection.claim.add('1'); selection.claim.add('2');
122
+ bar.render();
123
+ await settle();
124
+ assert.equal(els.sequential.disabled, true);
125
+ assert.match(els.sequential.title, /cycle/);
126
+ });
127
+
128
+ test('a page without the optional buttons simply has no such feature', async () => {
129
+ const els = stubPage(['bar', 'count', 'claim', 'clear']);
130
+ const { bar, selection } = build(els);
131
+ selection.claim.add('1'); selection.claim.add('2');
132
+ assert.doesNotThrow(() => bar.render());
133
+ assert.doesNotThrow(() => bar.wire());
134
+ assert.equal(els.claim.textContent, 'Bulk claim (2)');
135
+ });
136
+
137
+ test('Bulk claim and Bulk release go through the shared action and empty their selection', async () => {
138
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
139
+ const { bar, selection, calls } = build(els);
140
+ selection.claim.add('1'); selection.release.set('77', '9');
141
+ bar.wire();
142
+ await els.claim.listeners.click();
143
+ assert.deepEqual(calls.claimMany, [[1]]);
144
+ assert.equal(selection.claim.size, 0);
145
+ await els.release.listeners.click();
146
+ assert.deepEqual(calls.releaseMany, [[['77', '9']]]);
147
+ assert.equal(selection.release.size, 0);
148
+ });
149
+
150
+ test('Clear empties both selections and lets the page repaint its cards', () => {
151
+ const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
152
+ const { bar, selection, calls } = build(els);
153
+ selection.claim.add('1'); selection.release.set('77', '9');
154
+ bar.clearSelection();
155
+ assert.equal(selection.claim.size + selection.release.size, 0);
156
+ assert.equal(calls.cleared, true);
157
+ assert.equal(els.bar.hidden, true);
158
+ });
159
+
160
+ // --- the two boards use it, and neither keeps a private copy -----------------
161
+
162
+ test('both boards build the shared toolbar and carry all five buttons', () => {
163
+ const work = read('work.html');
164
+ const goals = read('goals.html');
165
+ for (const k of ['claim', 'release', 'parallel', 'sequential', 'clear']) {
166
+ assert.match(work, new RegExp(`id="bulk-${k}-btn"`), `work.html has bulk-${k}-btn`);
167
+ assert.match(goals, new RegExp(`id="goal-bulk-${k}-btn"`), `goals.html has goal-bulk-${k}-btn`);
168
+ }
169
+ assert.match(read('work.js'), /OTBBulkToolbar\.makeBulkToolbar\(/);
170
+ assert.match(read('goals-render.js'), /OTBBulkToolbar\.makeBulkToolbar\(/);
171
+ for (const [page, scripts] of [['work.html', ['bulk-toolbar.js', 'work.js']], ['goals.html', ['bulk-toolbar.js', 'goals-render.js']]]) {
172
+ const html = read(page);
173
+ const at = (n) => html.indexOf(`src="/builders/${n}`);
174
+ assert.ok(at(scripts[0]) > -1 && at(scripts[0]) < at(scripts[1]), `${page}: bulk-toolbar.js loads before ${scripts[1]}`);
175
+ }
176
+ });
177
+
178
+ test('no board keeps a private copy of the toolbar logic', () => {
179
+ for (const f of ['work.js', 'goals-page.js', 'goals-render.js']) {
180
+ const src = read(f);
181
+ assert.doesNotMatch(src, /function topoOrder|function buildBulk(Parallel|Sequential)Prompt|function scheduleValidate|function doBulk(Claim|Release)|function doGoalBulkClaim/, `${f} re-grew toolbar logic`);
182
+ }
183
+ });
184
+
185
+ test('the goal board offers a release checkbox on the viewer\'s own cards, and it styles', () => {
186
+ const render = read('goals-render.js');
187
+ assert.match(render, /class="wip__select" data-claim-id=/, 'a mine-active card carries the release checkbox');
188
+ assert.match(read('goals-page.js'), /closest\('\.task__select, \.wip__select'\)/, 'the card list listens for it');
189
+ assert.match(read('style.css'), /\n\.wip__select \{/, '.wip__select is in the shared sheet the goal board loads');
190
+ });
191
+
192
+ test('goals.html no longer borrows work.css, and work.css no longer holds what it lent', () => {
193
+ assert.doesNotMatch(read('goals.html'), /work\.css/, 'the goal board is off the board-only sheet');
194
+ const work = read('work.css');
195
+ for (const sel of ['.claim-count-line', '.wip__release', '.wip__select']) {
196
+ assert.ok(!new RegExp(`^${sel.replace('.', '\\.')}(\\s|:|,|\\{)`, 'm').test(work), `work.css must not restate ${sel}`);
197
+ }
198
+ const style = read('style.css');
199
+ for (const sel of ['.claim-count-line', '.wip__release', '.wip__select']) {
200
+ assert.ok(new RegExp(`^${sel.replace('.', '\\.')} \\{`, 'm').test(style), `style.css carries ${sel}`);
201
+ }
202
+ });
@@ -264,8 +264,8 @@ await test('both boards CONSTRUCT the shared action, not just its error text', (
264
264
 
265
265
  await test('every claim/release entry point routes through it', () => {
266
266
  const sites = {
267
- 'work.js': ['doClaim', 'doRelease', 'doBulkClaim', 'doBulkRelease'],
268
- 'goals-page.js': ['doGoalClaim', 'doGoalRelease', 'doGoalBulkClaim'],
267
+ 'work.js': ['doClaim', 'doRelease'],
268
+ 'goals-page.js': ['doGoalClaim', 'doGoalRelease'],
269
269
  };
270
270
  for (const [f, fns] of Object.entries(sites)) {
271
271
  const src = read('modules', 'hall-ui', 'public', f);
@@ -278,6 +278,15 @@ await test('every claim/release entry point routes through it', () => {
278
278
  `${f}: ${fn} still maps its own refusal`);
279
279
  }
280
280
  }
281
+ // Both boards' BULK claim/release live in the shared toolbar now (task 1003141),
282
+ // built over the same action — so the same rule is checked there, once.
283
+ const bulk = read('modules', 'hall-ui', 'public', 'bulk-toolbar.js');
284
+ for (const fn of ['doBulkClaim', 'doBulkRelease']) {
285
+ const m = bulk.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
286
+ assert.ok(m, `bulk-toolbar.js: ${fn} not found`);
287
+ assert.match(m[0], /claimAction\.(claimMany|releaseMany)\(/, `bulk-toolbar.js: ${fn} does not go through the shared action`);
288
+ assert.doesNotMatch(m[0], /friendlyClaimError\(/, `bulk-toolbar.js: ${fn} maps its own refusal`);
289
+ }
281
290
  });
282
291
 
283
292
  await test('no call site reloads on top of the action\'s own refresh', async () => {
@@ -287,7 +296,7 @@ await test('no call site reloads on top of the action\'s own refresh', async ()
287
296
  // twice. work.js's doBulkClaim is the correct shape: redraw the toolbar,
288
297
  // never re-fetch. A finally that awaits a loader is the tell.
289
298
  const src = read('modules', 'hall-ui', 'public', 'goals-page.js');
290
- for (const fn of ['doGoalClaim', 'doGoalRelease', 'doGoalBulkClaim']) {
299
+ for (const fn of ['doGoalClaim', 'doGoalRelease']) {
291
300
  const m = src.match(new RegExp(`async function ${fn}\\([^)]*\\) \\{[\\s\\S]*?\\n \\}`));
292
301
  assert.ok(m, `${fn} not found`);
293
302
  assert.doesNotMatch(m[0], /finally \{[\s\S]*?await loadGoalTasks\(/,
@@ -393,7 +402,7 @@ await test('the cache-bust moved on every page whose script changed', () => {
393
402
  ]) {
394
403
  const html = read('modules', 'hall-ui', 'public', page);
395
404
  for (const s of scripts) {
396
- assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-26-claimaction1'), `${page}: ${s} not busted`);
405
+ assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-30-bulktoolbar'), `${page}: ${s} not busted`);
397
406
  }
398
407
  }
399
408
  });
@@ -126,6 +126,7 @@ async function renderList() {
126
126
  // task 1003458: the page loads claim-action.js before this script; the stub
127
127
  // must mirror that, or the destructure of window.OTBClaimAction throws.
128
128
  vm.runInContext(read('modules/hall-ui/public/claim-action.js'), sandbox, { filename: 'claim-action.js' });
129
+ vm.runInContext(read('modules/hall-ui/public/bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
129
130
  vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
130
131
  vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
131
132
  vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
@@ -157,6 +157,8 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
157
157
  // must mirror that, or the destructure of window.OTBClaimAction throws.
158
158
  vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
159
159
  vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
160
+ // task 1003141: goals-render.js builds the shared bulk toolbar at boot.
161
+ vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/bulk-toolbar.js'), 'utf8'), sandbox, { filename: 'bulk-toolbar.js' });
160
162
  vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
161
163
  vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
162
164
  vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
@@ -80,6 +80,7 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
80
80
  // task 1003458: the page loads claim-action.js before this script; the stub
81
81
  // must mirror that, or the destructure of window.OTBClaimAction throws.
82
82
  vm.runInContext(read('claim-action.js'), sandbox, { filename: 'claim-action.js' });
83
+ vm.runInContext(read('bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
83
84
  vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
84
85
 
85
86
  const state = { current: 7, forest: null, forestError: false };
@@ -171,7 +171,7 @@ function harness({ pendingRequests = [] } = {}) {
171
171
  vm.createContext(sandbox);
172
172
  // task 1003458: claim-action.js sits between task-lib and goals-render on the
173
173
  // real page (goals.html); the stub mirrors that order.
174
- for (const f of ['hall-kit.js', 'task-lib.js', 'claim-action.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
174
+ for (const f of ['hall-kit.js', 'task-lib.js', 'claim-action.js', 'bulk-toolbar.js', 'goals-lib.js', 'goal-forest-render.js', 'goals-render.js']) {
175
175
  vm.runInContext(read(P + f), sandbox, { filename: f });
176
176
  }
177
177
 
@@ -150,6 +150,7 @@ async function boot({
150
150
  // task 1003458: the page loads claim-action.js before this script; the stub
151
151
  // must mirror that, or the destructure of window.OTBClaimAction throws.
152
152
  vm.runInContext(hall('claim-action.js'), sandbox, { filename: 'claim-action.js' });
153
+ vm.runInContext(hall('bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
153
154
  vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
154
155
  vm.runInContext(GOALS_PAGE_SRC, sandbox, { filename: 'goals-page.js' });
155
156
  for (let i = 0; i < 30; i += 1) await new Promise((r) => setTimeout(r, 0));
@@ -80,7 +80,7 @@ test('.task IS the panel — --bg-card at the full radius, with no drawn edge at
80
80
  // gets missed: "Git" came back 17x24 after a height-only fix.
81
81
  for (const sel of ['.task__select', '.wip__select']) {
82
82
  test(`${sel} is a 24px target with a 14px drawn box`, () => {
83
- const css = sel.includes('wip') ? workCss : styleCss;
83
+ const css = styleCss; // .wip__select joined .task__select here when the goal board borrowed it (task 1003141)
84
84
  const box = rule(css, sel);
85
85
  assert.ok(box, `${sel} has a rule`);
86
86
  assert.match(box, /width:\s*24px/, 'the HIT AREA is 24px — this is what the floor measures');
@@ -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');
@@ -204,6 +204,7 @@ function bootWorkBoard({ signedIn = true, goalsFail = false } = {}) {
204
204
  // task 1003458: the page loads claim-action.js before this script; the stub
205
205
  // must mirror that, or the destructure of window.OTBClaimAction throws.
206
206
  vm.runInContext(fs.readFileSync(path.join(HALL, 'claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
207
+ vm.runInContext(fs.readFileSync(path.join(HALL, 'bulk-toolbar.js'), 'utf8'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
207
208
  vm.runInContext(WORK_JS, sandbox, { filename: 'work.js' });
208
209
  (documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
209
210