@bongos/core 1.21.31 → 1.21.33

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 (35) hide show
  1. package/.bongos-core.json +62 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +7 -0
  8. package/docs/api/openapi.json +218 -3
  9. package/docs/api-reference.md +6 -3
  10. package/docs/architecture.md +1 -0
  11. package/docs/copy-inventory.md +40 -38
  12. package/docs/copy-registry.json +64 -46
  13. package/docs/design/craft-mode-direction.md +69 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +14 -14
  16. package/modules/government/catalog.js +4 -0
  17. package/modules/government/migrations/government_024_version_focus_set.sql +19 -0
  18. package/modules/government/migrations/government_025_module_delist.sql +36 -0
  19. package/modules/hall-ui/public/modules.js +8 -1
  20. package/modules/hall-ui/public/roadmap.css +5 -0
  21. package/modules/hall-ui/public/roadmap.js +64 -5
  22. package/modules/lifecycle/routes/versions.js +20 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/release-notes.json +16 -0
  26. package/scripts/gds/module.js +21 -5
  27. package/src/bongos/module-store.js +31 -0
  28. package/src/bongos/project-settings.js +28 -0
  29. package/src/bongos/route-rank-check.js +3 -0
  30. package/src/bongos/routes/modules.js +35 -0
  31. package/src/module-api.js +3 -1
  32. package/tests/focus_version.mjs +96 -0
  33. package/tests/hall_catalog_world.mjs +1 -1
  34. package/tests/module_catalog.mjs +34 -6
  35. package/tests/module_store_delist.mjs +146 -0
@@ -0,0 +1,36 @@
1
+ -- government_025_module_delist.sql — grant the new `module.delist` atom to Metic
2
+ -- and Archon (task 1003797, ADR 0338 D2).
3
+ --
4
+ -- WHAT IT IS. A Metic+ person may take a module out of the store. It leaves the
5
+ -- catalog and can no longer be newly installed, updated or downloaded. Instances
6
+ -- that already hold it keep it, and it keeps working: a delist never revokes an
7
+ -- entitlement or reaches into an installed copy (ADR 0338 D2), and it never
8
+ -- changes a score (ADR 0359). A reason is required and the audit log records it.
9
+ --
10
+ -- WHY THIS FILE EXISTS. RANK_SEED is derived from the catalog's floors, and the
11
+ -- drift guard (tests/government_seed.mjs) compares it against the union of the
12
+ -- run-once seed plus every later grant migration, per rank. A metic-floor key is
13
+ -- held by metic AND archon, so both are granted here.
14
+ --
15
+ -- Additive only: one new permission key granted to two ranks; nothing is revoked.
16
+ -- Approved by the owner (Will, Archon) on 2026-10-01.
17
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
18
+ --
19
+ -- The `SELECT '<rank>', unnest(ARRAY[…])` shape is load-bearing: the drift guard
20
+ -- scans for it literally.
21
+
22
+ BEGIN;
23
+
24
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
25
+ SELECT 'metic', unnest(ARRAY[
26
+ 'module.delist'
27
+ ])
28
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
29
+
30
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
31
+ SELECT 'archon', unnest(ARRAY[
32
+ 'module.delist'
33
+ ])
34
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
35
+
36
+ COMMIT;
@@ -106,6 +106,13 @@
106
106
  return `<a class="ov-fact mod-howto" href="${escapeHtml(href)}">how-to</a>`;
107
107
  }
108
108
 
109
+ // A store module this instance holds that the store has since delisted (task
110
+ // 1003797, ADR 0338 D2): it keeps working here, but no more versions will come.
111
+ function delistedFact(m) {
112
+ if (m.store_delisted !== true) return '';
113
+ return '<span class="fact-pill fact-pill--warn" title="Taken out of the store. Your copy keeps working, but it will get no more updates.">delisted · no more updates</span>';
114
+ }
115
+
109
116
  // The lines the row's details disclosure opens onto: the facts the payload
110
117
  // carries that the card never rendered — what the module contributes, the
111
118
  // core it needs, who maintains it and what it hands off to.
@@ -152,7 +159,7 @@
152
159
  <div class="ov-row__main">
153
160
  <span class="ov-row__title">${escapeHtml(m.title || m.key)}<span class="ov-mono">${escapeHtml(m.key)}</span></span>
154
161
  ${desc ? `<p class="ov-row__sub mod-row__desc">${escapeHtml(desc)}</p>` : ''}
155
- <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}${howtoFact(m)}</div>
162
+ <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}${howtoFact(m)}${delistedFact(m)}</div>
156
163
  ${lines ? `<details class="mod-more"${openKeys.has(m.key) ? ' open' : ''}><summary aria-label="Details for ${escapeHtml(m.title || m.key)}"></summary>${lines}</details>` : ''}
157
164
  </div>
158
165
  <div class="ov-row__right mod-row__right">${rightHtml(m)}</div>
@@ -237,3 +237,8 @@ a.rm-goal__t:hover { color:var(--accent-ink); text-decoration:underline; }
237
237
  .rm-goal__dot { display:none; }
238
238
  .rm-goal__t { flex:1 0 100%; white-space:normal; }
239
239
  }
240
+ /* Focus star (task 1004551): ink-faint when off, accent when on. */
241
+ .rm-star { margin-left:auto; font-size:17px; line-height:1; color:var(--ink-faint); background:none; border:0; padding:0 2px; }
242
+ button.rm-star { cursor:pointer; }
243
+ button.rm-star:hover, button.rm-star:focus-visible { color:var(--accent); }
244
+ .rm-star--on { color:var(--accent); }
@@ -147,6 +147,59 @@
147
147
  return `<a class="rm-gmore" href="${tasksLink(v.id)}" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}">View tasks ${arrowSvg}</a>`;
148
148
  }
149
149
 
150
+ // The focus star (task 1004551). The focus version is the project's default — chosen by
151
+ // an Archon here, stored server-side, never inferred. Filled = current focus. Archons
152
+ // get a button that sets/clears it; everyone else gets the same star read-only. The
153
+ // button is a convenience: PUT /versions/focus refuses non-Archons on the server.
154
+ const focus = { id: null, canSet: false };
155
+ const FOCUS_FAIL = 'Could not change the focus version. Reload and try again.';
156
+ function starHtml(v) {
157
+ const on = focus.id === v.id;
158
+ const glyph = on ? '★' : '☆';
159
+ const base = `class="rm-star${on ? ' rm-star--on' : ''}" data-vid="${escapeHtml(v.id)}"`;
160
+ if (focus.canSet) {
161
+ const tip = on ? 'Clear the focus version.' : 'Make this the project’s focus version.';
162
+ return `<button type="button" ${base} aria-pressed="${on}" title="${tip}" aria-label="${tip}">${glyph}</button>`;
163
+ }
164
+ const tip = (on ? 'This is the project’s focus version. ' : 'Not the focus version. ') + 'Only an Archon can set the focus.';
165
+ return `<span ${base} role="img" title="${tip}" aria-label="${tip}">${glyph}</span>`;
166
+ }
167
+
168
+ // Never throws except to hand a sign-in redirect back to boot(); a failed focus read
169
+ // just means no star is lit.
170
+ async function loadFocus() {
171
+ try {
172
+ const f = await getJSON(`${API}/versions/focus`);
173
+ focus.id = f.focus_version_id || null;
174
+ focus.canSet = f.can_set === true;
175
+ } catch (err) {
176
+ if (err && err.message === 'redirecting to auth') throw err;
177
+ focus.id = null; focus.canSet = false;
178
+ }
179
+ }
180
+
181
+ // Flip the stars in place: the PUT already answered with the new focus, so there is
182
+ // nothing to refetch.
183
+ function paintStars() {
184
+ document.querySelectorAll('.rm-star').forEach((el) => {
185
+ const tmp = document.createElement('div');
186
+ tmp.innerHTML = starHtml({ id: el.dataset.vid });
187
+ el.replaceWith(tmp.firstElementChild);
188
+ });
189
+ }
190
+
191
+ async function toggleFocus(vid) {
192
+ const next = focus.id === vid ? null : vid;
193
+ const res = await api.request('PUT', `${API}/versions/focus`, { body: { version_id: next } });
194
+ if (!res.ok) {
195
+ const e = res.data && res.data.error;
196
+ toast((e && e.message) || FOCUS_FAIL);
197
+ return;
198
+ }
199
+ focus.id = res.data.focus_version_id || null;
200
+ paintStars();
201
+ }
202
+
150
203
  // ---- card renderers -------------------------------------------------------
151
204
 
152
205
  function featCard(v) {
@@ -155,7 +208,7 @@
155
208
  <div class="rm-featgrid__l">
156
209
  <div class="rm-feat-top">
157
210
  <div class="rt">
158
- <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
211
+ <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span>${starHtml(v)}</div>
159
212
  <h3 class="rm-name">${escapeHtml(v.name)}</h3>
160
213
  <p class="rm-desc rm-desc--clamp">${escapeHtml(v.desc)}</p>
161
214
  </div>
@@ -176,7 +229,7 @@
176
229
  return `<article class="rm-card">
177
230
  <div class="rm-feat-top">
178
231
  <div class="rt">
179
- <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
232
+ <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span>${starHtml(v)}</div>
180
233
  <h3 class="rm-name">${escapeHtml(v.name)}</h3>
181
234
  <p class="rm-desc rm-desc--clamp">${escapeHtml(v.desc)}</p>
182
235
  </div>
@@ -218,7 +271,7 @@
218
271
  return `<article class="rm-card rm-card--staged" style="grid-column:1 / -1">
219
272
  <div class="rm-featgrid">
220
273
  <div class="rm-featgrid__l">
221
- <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
274
+ <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span>${starHtml(v)}</div>
222
275
  <h3 class="rm-name">${escapeHtml(v.name)}</h3>
223
276
  <p class="rm-desc rm-desc--clamp">${escapeHtml(v.desc)}</p>
224
277
  <div class="rm-staged-state">
@@ -236,7 +289,7 @@
236
289
  return `<article class="rm-card rm-card--parked" style="grid-column:1 / -1">
237
290
  <div class="rm-feat-top">
238
291
  <div class="rt">
239
- <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
292
+ <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span>${starHtml(v)}</div>
240
293
  <h3 class="rm-name">${escapeHtml(v.name)}</h3>
241
294
  <p class="rm-desc rm-desc--clamp">${escapeHtml(v.desc)}</p>
242
295
  </div>
@@ -381,9 +434,10 @@
381
434
  async function render() {
382
435
  const root = $('#rm-root');
383
436
  const [progress, versions] = await Promise.all([
437
+ loadFocus(),
384
438
  getJSON(`${API}/public/progress`).then((d) => d.progress || []),
385
439
  getJSON(`${API}/public/versions`).then((d) => d.versions || []).catch(() => []),
386
- ]);
440
+ ]).then(([, p, v]) => [p, v]);
387
441
  const all = mergeVersions(progress, versions);
388
442
 
389
443
  // Goal summaries only for versions we actually show goals on (everything but
@@ -442,5 +496,10 @@
442
496
  }
443
497
  }
444
498
 
499
+ document.addEventListener('click', (e) => {
500
+ const b = e.target.closest && e.target.closest('button.rm-star');
501
+ if (b) toggleFocus(b.dataset.vid).catch(() => toast(FOCUS_FAIL));
502
+ });
503
+
445
504
  boot();
446
505
  })();
@@ -45,6 +45,26 @@ module.exports = function buildVersionsRouter() {
45
45
  res.json({ versions: all.slice(offset, offset + limit), page: api.pageMeta(limit, offset, all.length) });
46
46
  });
47
47
 
48
+ // The project's focus version (task 1004551) — the one the Roadmap stars. Read: any signed-in builder. Write: Archon only,
49
+ // enforced here on the server, not just by the button hiding. No row = no focus.
50
+ // rank: any-builder — read-only; returns { focus_version_id: string|null, can_set }.
51
+ // can_set tells the page whether to draw a button — a hint only; PUT is the gate.
52
+ router.get('/versions/focus', auth.requireBuilder, asyncHandler('GET /versions/focus', async (req, res) => {
53
+ const rank = String((req.builder && req.builder.rank) || '').toLowerCase();
54
+ res.json({ focus_version_id: await api.projectSettings.getFocusVersionId(), can_set: rank === 'archon' });
55
+ }, { message: false }));
56
+
57
+ // rank: archon (atom version.focus.set, system) — body { version_id } sets the focus; { version_id: null } clears it.
58
+ // An unknown version id is refused rather than stored.
59
+ router.put('/versions/focus', auth.requireBuilder, auth.requirePermission('version.focus.set'), asyncHandler('PUT /versions/focus', async (req, res) => {
60
+ if (validateOrRespond(req, res, { version_id: { type: 'string', maxLength: 64 } })) return;
61
+ const raw = (req.body || {}).version_id;
62
+ if (raw === undefined || (raw !== null && !raw.trim())) return res.fail('bad_version_id', { status: 400, message: 'Pick a version to star, or send null to clear the focus.' });
63
+ if (raw !== null && !(await db.getVersion(raw.trim()))) return res.fail('unknown_version', { status: 404, message: `There is no version "${raw.trim()}" to star.` });
64
+ const focus = await api.projectSettings.setFocusVersionId(raw === null ? null : raw.trim(), req.builder && req.builder.id);
65
+ res.json({ ok: true, focus_version_id: focus });
66
+ }, { message: false }));
67
+
48
68
  // rank: public — read-only progress aggregate; mirrors /public/progress.
49
69
  router.get('/versions/progress', async (_req, res) => {
50
70
  const progress = await db.versionProgress();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.31",
3
+ "version": "1.21.33",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.31",
9
+ "version": "1.21.33",
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.21.31",
3
+ "version": "1.21.33",
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",
@@ -8942,5 +8942,21 @@
8942
8942
  "id": "1004550",
8943
8943
  "text": "Setting up a new project is quicker: the final check screen now takes one click to accept, with less text and no per-row confirm buttons."
8944
8944
  }
8945
+ ],
8946
+ "1.21.32": [
8947
+ {
8948
+ "id": "1004551",
8949
+ "text": "An Archon can star a version on the Roadmap as the project's focus; it persists across reloads, and everyone else sees the star read-only (the server refuses non-Archons)."
8950
+ }
8951
+ ],
8952
+ "1.21.33": [
8953
+ {
8954
+ "id": "1003797",
8955
+ "text": "Senior builders can now take a module out of the store, with a reason. Nobody can newly install or update it, but anyone who already has it keeps it working and is told no more updates will come."
8956
+ },
8957
+ {
8958
+ "id": "1004494",
8959
+ "text": "The look for each craft mode is decided. Artistry, Ideating and Governing each get their own colour (moonlight, star gold, city blue) on the mode switch, the tab you are on and the top bar, the mode's name beside the page titl"
8960
+ }
8945
8961
  ]
8946
8962
  }
@@ -433,7 +433,7 @@ function clip(s, n) {
433
433
 
434
434
  // Pure renderer — every input arrives as a parameter (the tests pass catalog
435
435
  // rows straight in), so it touches no disk and no config.
436
- function formatCatalog(rows, { catalogOnly = false, query = '', core = null } = {}) {
436
+ function formatCatalog(rows, { catalogOnly = false, query = '', core = null, delisted = new Set() } = {}) {
437
437
  const scoped = catalogOnly ? catalog.catalogRows(rows) : rows;
438
438
  const shown = catalog.searchCatalog(scoped, query);
439
439
  const counts = catalog.summarize(rows);
@@ -458,11 +458,14 @@ function formatCatalog(rows, { catalogOnly = false, query = '', core = null } =
458
458
  // An inferred credit is marked so it never reads as something the author
459
459
  // typed — the catalog's whole job is honest attribution (ADR 0107 §2).
460
460
  const suffix = r.credit.inferred ? '*' : '';
461
- const upkeep = (r.maintenance && r.maintenance.status) || 'undeclared';
461
+ const upkeep = ((r.maintenance && r.maintenance.status) || 'undeclared') + (delisted.has(r.key) ? ' · DELISTED' : '');
462
462
  out.push(` ${pad(mark, 5)}${pad(clip(r.key, 19), 20)}${pad(clip(r.version || '—', 8), 9)}${pad(r.source, 10)}${pad(clip((r.credit.author || '—') + suffix, 19), 20)}${pad(clip((r.credit.origin || '—') + suffix, 25), 26)}${pad(clip(r.license || '—', 11), 12)}${upkeep}`);
463
463
  }
464
464
  out.push('');
465
465
  out.push(' [x] enabled here · [ ] available, not enabled');
466
+ if (shown.some((r) => delisted.has(r.key))) {
467
+ out.push(' DELISTED = taken out of the store: your copy keeps working, but no more versions will come.');
468
+ }
466
469
  if (shown.some((r) => r.credit.inferred)) {
467
470
  out.push(` * credit not declared in module.json — shown as the platform (${catalog.CORE_PROJECT_NAME}), which ships it.`);
468
471
  }
@@ -470,7 +473,18 @@ function formatCatalog(rows, { catalogOnly = false, query = '', core = null } =
470
473
  return out.join('\n');
471
474
  }
472
475
 
473
- function cmdList(args, { log = console.log, errlog = console.error, gather = gatherCatalog } = {}) {
476
+ // Which installed store modules the store has delisted (task 1003797). Only modules
477
+ // built into this instance's tree can be store installs, so a core checkout asks
478
+ // nothing. Fail-soft like the upgrade check: offline, or no session, lists as before.
479
+ async function gatherDelisted(rows, { checkStore = gatherUpdates } = {}) {
480
+ const held = rows.filter((r) => r.source === 'instance');
481
+ if (!held.length) return new Set();
482
+ const found = await checkStore(held);
483
+ if (!found.checked) return new Set();
484
+ return new Set(Object.keys(found.latest).filter((k) => found.latest[k] && found.latest[k].status === 'delisted'));
485
+ }
486
+
487
+ async function cmdList(args, { log = console.log, errlog = console.error, gather = gatherCatalog, checkStore = gatherUpdates } = {}) {
474
488
  const catalogOnly = args.includes('--catalog');
475
489
  const json = args.includes('--json');
476
490
  const sIdx = args.indexOf('--search');
@@ -484,14 +498,16 @@ function cmdList(args, { log = console.log, errlog = console.error, gather = gat
484
498
  try { data = gather(); }
485
499
  catch (e) { errlog(`bongos module list: ${e.message}`); return 1; }
486
500
 
501
+ const delisted = await gatherDelisted(data.rows, { checkStore });
502
+
487
503
  if (json) {
488
504
  const scoped = catalogOnly ? catalog.catalogRows(data.rows) : data.rows;
489
- const modules = catalog.searchCatalog(scoped, query);
505
+ const modules = catalog.searchCatalog(scoped, query).map((m) => ({ ...m, store_delisted: delisted.has(m.key) }));
490
506
  log(JSON.stringify({ core: data.core, summary: catalog.summarize(data.rows), modules }, null, 2));
491
507
  return 0;
492
508
  }
493
509
 
494
- log(formatCatalog(data.rows, { catalogOnly, query, core: data.core }));
510
+ log(formatCatalog(data.rows, { catalogOnly, query, core: data.core, delisted }));
495
511
  for (const s of data.skipped) {
496
512
  errlog(` ! ${s.key}: not listed — ${s.reason}`);
497
513
  }
@@ -232,6 +232,36 @@ async function latestVersions(keys, { db } = {}) {
232
232
  return out;
233
233
  }
234
234
 
235
+ // Delist a module (task 1003797, ADR 0338 D2): it leaves the catalog, takes no new
236
+ // acquisitions or versions, and serves no further downloads — every read above
237
+ // already refuses on status 'delisted', so this one UPDATE is the whole act. It
238
+ // touches nothing else: no entitlement is revoked, no installed copy is reached,
239
+ // and no score row is written (a delist is not a score, ADR 0359). One-way here;
240
+ // there is no relist route yet.
241
+ // Returns { ok: true, module } or { ok: false, status, code, message }.
242
+ async function delistModule(key, { db } = {}) {
243
+ const pool = db || require('./pool').pool;
244
+ const { rows: [mod] } = await pool.query(
245
+ `UPDATE store_modules SET status = 'delisted', delisted_at = now()
246
+ WHERE module_key = $1 AND status = 'listed'
247
+ RETURNING module_key, title, status, delisted_at`, [key]);
248
+ if (mod) return { ok: true, module: mod };
249
+ const { rows: [have] } = await pool.query(
250
+ 'SELECT module_key, status, delisted_at FROM store_modules WHERE module_key = $1', [key]);
251
+ if (!have) return { ok: false, status: 404, code: 'module_not_found', message: `The store has no module "${key}".` };
252
+ return { ok: false, status: 409, code: 'already_delisted', message: `"${key}" is already delisted.` };
253
+ }
254
+
255
+ // Which of these module keys the store has delisted, as a Set — so the hall's
256
+ // Modules tab can mark an installed store module that will get no more versions.
257
+ async function delistedKeySet(keys, { db } = {}) {
258
+ if (!keys.length) return new Set();
259
+ const pool = db || require('./pool').pool;
260
+ const { rows } = await pool.query(
261
+ "SELECT module_key FROM store_modules WHERE module_key = ANY($1) AND status = 'delisted'", [keys]);
262
+ return new Set(rows.map((r) => r.module_key));
263
+ }
264
+
235
265
  // The tarball on disk for a version row. artifact_path is relative to the store dir
236
266
  // (relativeArtifactPath), and artifactPath() re-validates key + version, so a row
237
267
  // can never point a read outside the store.
@@ -246,4 +276,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
246
276
  module.exports = {
247
277
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
248
278
  isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
279
+ delistModule, delistedKeySet,
249
280
  };
@@ -48,6 +48,7 @@ const { logger } = require('./logger');
48
48
  // cannot collide with this one.
49
49
  const SETTING_KEYS = {
50
50
  ROT_DAYS: 'attention.rot_days',
51
+ FOCUS_VERSION: 'roadmap.focus_version',
51
52
  };
52
53
 
53
54
  // The last-resort rot timer, in days. 30 because that is the shortest window in
@@ -176,6 +177,31 @@ function describeRotTimer(resolved) {
176
177
  return `${base} — ignoring ${r.invalid.where}="${r.invalid.raw}" (${r.invalid.reason})`;
177
178
  }
178
179
 
180
+ // getFocusVersionId() — the version the project is focused on, or null.
181
+ // NO ROW (or an empty value) MEANS NO FOCUS: the focus is chosen by an Archon in the
182
+ // UI (task 1004551), never inferred in code, so absence is an answer and a default is
183
+ // never seeded. Whether the id still names a real version is the caller's concern —
184
+ // the write path refuses unknown ids, and a reader of a since-deleted version simply
185
+ // finds no card to star.
186
+ async function getFocusVersionId() {
187
+ const row = await getSetting(SETTING_KEYS.FOCUS_VERSION);
188
+ return row && row.value ? String(row.value) : null;
189
+ }
190
+
191
+ // setFocusVersionId(versionId, builderId) — set the focus, or clear it with null.
192
+ // Clearing deletes the row rather than storing '' so "never set" and "cleared" are the
193
+ // same state, as the null-is-meaningful rule on getSetting requires.
194
+ async function setFocusVersionId(versionId, builderId = null) {
195
+ if (versionId == null || versionId === '') {
196
+ await pool.query('DELETE FROM project_settings WHERE key = $1', [SETTING_KEYS.FOCUS_VERSION]);
197
+ // The row is gone, so updated_by cannot say who cleared it; the log line does.
198
+ logger.info(`project focus version cleared by builder ${builderId == null ? 'unknown' : builderId}`);
199
+ return null;
200
+ }
201
+ await setSetting(SETTING_KEYS.FOCUS_VERSION, versionId, builderId);
202
+ return String(versionId);
203
+ }
204
+
179
205
  // parseDays is exported for the tests only — the range check and the default are
180
206
  // asserted against the SAME constants the resolver reads, because a test that
181
207
  // re-typed 30 or 365 would be the second copy of the number this file exists to
@@ -190,4 +216,6 @@ module.exports = {
190
216
  setSetting,
191
217
  resolveRotDays,
192
218
  describeRotTimer,
219
+ getFocusVersionId,
220
+ setFocusVersionId,
193
221
  };
@@ -240,6 +240,9 @@ const EXPECTED_RANKS = {
240
240
  // Re-running a store version's assessment (task 1003796, ADR 0359) spends a Docs
241
241
  // grader call and re-opens the Security gate, so it is pinned to its own atom.
242
242
  'POST /store/modules/:key/versions/:version/reassess': 'perm:module.assessment.rerun',
243
+ // Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
244
+ // it for every instance, so it is pinned to its own atom.
245
+ 'POST /store/modules/:key/delist': 'perm:module.delist',
243
246
  };
244
247
 
245
248
  // The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
@@ -35,6 +35,9 @@
35
35
  // GET /store/modules/:key/versions/:version/howto
36
36
  // the HOWTO.md that version shipped with, as text for
37
37
  // the hall's how-to page (task 1004366, ADR 0347).
38
+ // POST /store/modules/:key/delist
39
+ // Metic+ takes a module out of the store; holders
40
+ // keep it (task 1003797, ADR 0338 D2).
38
41
  //
39
42
  // Read is open to any signed-in builder (same gate as the atlas/primer pages);
40
43
  // every write (enable/disable, submit) is metic+archon, same rank as
@@ -85,6 +88,7 @@ const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } =
85
88
  const {
86
89
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
87
90
  resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
91
+ delistModule, delistedKeySet,
88
92
  } = require('../module-store');
89
93
  const { KEY_RE } = require('../../module-loader/manifest-schema');
90
94
  const { parseVersion } = require('../../module-loader/semver');
@@ -204,6 +208,11 @@ module.exports = function buildModulesRouter() {
204
208
  let published = new Set();
205
209
  try { published = await publishedVersionSet(all.map((m) => m.key)); }
206
210
  catch (e) { log.warn({ err: e.message }, 'store versions unreadable for the modules list'); }
211
+ // Which installed store modules the store has delisted (task 1003797), so the row
212
+ // says it keeps working but gets no more versions (ADR 0338 D2). Same tolerance.
213
+ let delistedKeys = new Set();
214
+ try { delistedKeys = await delistedKeySet(all.filter((m) => m.source === 'instance').map((m) => m.key)); }
215
+ catch (e) { log.warn({ err: e.message }, 'store status unreadable for the modules list'); }
207
216
  for (const m of all) {
208
217
  m.enabled_source = sources[m.key] || 'default';
209
218
  m.override = byKey.has(m.key)
@@ -214,6 +223,7 @@ module.exports = function buildModulesRouter() {
214
223
  m.store_howto = m.source === 'instance' && m.version && published.has(`${m.key}@${m.version}`)
215
224
  ? `/builders/modules?howto=${encodeURIComponent(m.key)}&version=${encodeURIComponent(String(m.version))}`
216
225
  : null;
226
+ m.store_delisted = m.source === 'instance' && delistedKeys.has(m.key);
217
227
  }
218
228
  res.json({
219
229
  modules: all,
@@ -446,6 +456,31 @@ module.exports = function buildModulesRouter() {
446
456
  res.status(202).json({ ok: true, queued: true, module_key: key, version, version_id: r.version.id });
447
457
  }));
448
458
 
459
+ // POST /api/bongos/store/modules/:key/delist — a Metic+ person takes a module out
460
+ // of the store (task 1003797, ADR 0338 D2). It leaves the catalog, cannot be newly
461
+ // acquired, takes no new versions and serves no further downloads: every store
462
+ // read already refuses a delisted key, so this flips the one status. What it never
463
+ // does: revoke an entitlement, reach into an instance that already holds the
464
+ // module (that copy keeps working; `bongos upgrade` and the hall tell the holder
465
+ // no more versions will come), or touch a score (ADR 0359). A reason is required;
466
+ // the audit middleware records it with who asked. There is no relist route yet.
467
+ router.post('/store/modules/:key/delist', auth.requireBuilder, auth.requirePermission('module.delist'),
468
+ asyncHandler('POST /store/modules/:key/delist', async (req, res) => {
469
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
470
+ const { key } = req.params;
471
+ if (!KEY_RE.test(key)) {
472
+ return res.fail('bad_module_key', { status: 400, message: 'A module key is lowercase kebab-case.' });
473
+ }
474
+ const reason = String(req.body?.reason || '').trim();
475
+ if (!reason) {
476
+ return res.fail('reason_required', { status: 400, message: 'Say why this module is leaving the store, in reason. People who already hold it keep it.' });
477
+ }
478
+ const r = await delistModule(key);
479
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
480
+ log.info({ key, builderId: req.builder.id }, `${key} delisted from the store`);
481
+ res.json({ ok: true, module_key: key, status: r.module.status, delisted_at: r.module.delisted_at });
482
+ }));
483
+
449
484
  // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
450
485
  // read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
451
486
  // like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
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.21.31'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.33'; // 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');
@@ -444,6 +444,8 @@ module.exports = {
444
444
  get KEYS() { return require('./bongos/project-settings').SETTING_KEYS; },
445
445
  get get() { return require('./bongos/project-settings').getSetting; },
446
446
  get set() { return require('./bongos/project-settings').setSetting; },
447
+ get getFocusVersionId() { return require('./bongos/project-settings').getFocusVersionId; },
448
+ get setFocusVersionId() { return require('./bongos/project-settings').setFocusVersionId; },
447
449
  get describeRotTimer() { return require('./bongos/project-settings').describeRotTimer; },
448
450
  // The RANGE CHECK, shared rather than re-typed. The settings page validates a
449
451
  // submitted timer with the very parser the resolver reads it back through, so the
@@ -0,0 +1,96 @@
1
+ // tests/focus_version.mjs — the roadmap focus star (task 1004551): the settings accessor,
2
+ // the Archon-only write route and the any-builder read, run against a fake pool and fake
3
+ // requests. No DB needed.
4
+ //
5
+ // Run: node --test tests/focus_version.mjs
6
+
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+
12
+ process.env.NODE_ENV = 'test';
13
+ const require = createRequire(import.meta.url);
14
+
15
+ // A one-table fake pool, installed before project-settings loads.
16
+ const store = new Map();
17
+ const poolPath = require.resolve('../src/bongos/pool.js');
18
+ require(poolPath);
19
+ require.cache[poolPath].exports.pool = {
20
+ async query(sql, p) {
21
+ if (/^\s*SELECT/i.test(sql)) { const v = store.get(p[0]); return { rows: v == null ? [] : [{ key: p[0], value: v }] }; }
22
+ if (/^\s*INSERT/i.test(sql)) { store.set(p[0], p[1]); return { rows: [{ key: p[0], value: p[1] }] }; }
23
+ if (/^\s*DELETE/i.test(sql)) { store.delete(p[0]); return { rows: [] }; }
24
+ throw new Error(`unexpected sql: ${sql}`);
25
+ },
26
+ };
27
+ const ps = require('../src/bongos/project-settings.js');
28
+ const db = require('../modules/lifecycle/db.js');
29
+ const router = require('../modules/lifecycle/routes/versions.js')();
30
+
31
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
32
+ db.getVersion = async (id) => (id === 'BONGOS-V2' ? { id } : null);
33
+
34
+ // The route's own handler is the LAST layer on its stack (after the auth middleware).
35
+ function handler(method, path) {
36
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
37
+ assert.ok(layer, `${method} ${path} is registered`);
38
+ return { chain: layer.route.stack, last: layer.route.stack[layer.route.stack.length - 1].handle };
39
+ }
40
+ async function call(method, path, req) {
41
+ const out = { status: 200, body: null };
42
+ const res = {
43
+ status(n) { out.status = n; return this; },
44
+ json(b) { out.body = b; return this; },
45
+ fail(code, o) { const a = typeof o === 'number' ? { status: o } : (o || {}); out.status = a.status; out.body = { error: { code, message: a.message || code } }; return this; },
46
+ };
47
+ await handler(method, path).last({ body: {}, builder: { id: 9, rank: 'archon' }, ...req }, res, (e) => { throw e; });
48
+ return out;
49
+ }
50
+
51
+ test('no row means no focus', async () => {
52
+ store.clear();
53
+ assert.equal(await ps.getFocusVersionId(), null);
54
+ });
55
+
56
+ test('PUT stores a known version and GET returns it', async () => {
57
+ store.clear();
58
+ const put = await call('put', '/versions/focus', { body: { version_id: 'BONGOS-V2' } });
59
+ assert.equal(put.body.focus_version_id, 'BONGOS-V2');
60
+ assert.equal(store.get(ps.SETTING_KEYS.FOCUS_VERSION), 'BONGOS-V2');
61
+ const got = await call('get', '/versions/focus', {});
62
+ assert.deepEqual(got.body, { focus_version_id: 'BONGOS-V2', can_set: true });
63
+ });
64
+
65
+ test('PUT with null clears the focus (the row is gone, not blank)', async () => {
66
+ store.set(ps.SETTING_KEYS.FOCUS_VERSION, 'BONGOS-V2');
67
+ const put = await call('put', '/versions/focus', { body: { version_id: null } });
68
+ assert.equal(put.body.focus_version_id, null);
69
+ assert.equal(store.has(ps.SETTING_KEYS.FOCUS_VERSION), false);
70
+ });
71
+
72
+ test('an unknown version is refused with a readable message and nothing is stored', async () => {
73
+ store.clear();
74
+ const put = await call('put', '/versions/focus', { body: { version_id: 'NOPE' } });
75
+ assert.equal(put.status, 404);
76
+ assert.match(put.body.error.message, /NOPE/);
77
+ assert.equal(store.size, 0);
78
+ });
79
+
80
+ test('a missing or blank version_id is refused, not treated as a clear', async () => {
81
+ for (const body of [{}, { version_id: ' ' }]) {
82
+ const put = await call('put', '/versions/focus', { body });
83
+ assert.equal(put.status, 400);
84
+ }
85
+ });
86
+
87
+ test('a non-Archon reads the focus but is told it cannot set it', async () => {
88
+ store.set(ps.SETTING_KEYS.FOCUS_VERSION, 'BONGOS-V2');
89
+ const got = await call('get', '/versions/focus', { builder: { id: 4, rank: 'metic' } });
90
+ assert.deepEqual(got.body, { focus_version_id: 'BONGOS-V2', can_set: false });
91
+ });
92
+
93
+ test('the write route carries the Archon-only permission gate; the read route does not', () => {
94
+ assert.match(src('modules/lifecycle/routes/versions.js'), /router\.put\('\/versions\/focus',\s*auth\.requireBuilder,\s*auth\.requirePermission\('version\.focus\.set'\)/);
95
+ assert.doesNotMatch(src('modules/lifecycle/routes/versions.js').match(/router\.get\('\/versions\/focus',[^\n]*/)[0], /requireRank/);
96
+ });
@@ -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\)\}\$\{howtoFact\(m\)\}<\/div>/, 'provenance, credit, upkeep and (for a store version, task 1004366) the how-to link are the facts line');
61
+ assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}<\/div>/, 'provenance, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill 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');