@bongos/core 1.21.31 → 1.21.32

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.
@@ -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.32",
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.32",
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.32",
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,11 @@
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
+ }
8945
8951
  ]
8946
8952
  }
@@ -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
  };
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.32'; // 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
+ });