@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.
- package/.bongos-core.json +62 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +7 -0
- package/docs/api/openapi.json +218 -3
- package/docs/api-reference.md +6 -3
- package/docs/architecture.md +1 -0
- package/docs/copy-inventory.md +40 -38
- package/docs/copy-registry.json +64 -46
- package/docs/design/craft-mode-direction.md +69 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +14 -14
- package/modules/government/catalog.js +4 -0
- package/modules/government/migrations/government_024_version_focus_set.sql +19 -0
- package/modules/government/migrations/government_025_module_delist.sql +36 -0
- package/modules/hall-ui/public/modules.js +8 -1
- package/modules/hall-ui/public/roadmap.css +5 -0
- package/modules/hall-ui/public/roadmap.js +64 -5
- package/modules/lifecycle/routes/versions.js +20 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/module.js +21 -5
- package/src/bongos/module-store.js +31 -0
- package/src/bongos/project-settings.js +28 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/modules.js +35 -0
- package/src/module-api.js +3 -1
- package/tests/focus_version.mjs +96 -0
- package/tests/hall_catalog_world.mjs +1 -1
- package/tests/module_catalog.mjs +34 -6
- 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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
}
|
package/scripts/gds/module.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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');
|