@bongos/core 1.19.663 → 1.19.665

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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.663",
3
+ "version": "1.19.665",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.663",
9
+ "version": "1.19.665",
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.19.663",
3
+ "version": "1.19.665",
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",
@@ -183,6 +183,17 @@ app.use((req, res, next) => {
183
183
  return;
184
184
  }
185
185
 
186
+ // The ship-time VISUAL is an image, not a fixture (task 1003829). Without
187
+ // this, every surface that renders one — the task record, a ship feed, the
188
+ // Studio's review card, where the picture IS the card — previews as a broken
189
+ // frame, so the one thing being reviewed is the one thing you cannot see.
190
+ // A generated stand-in; see scripts/hall-preview/task-visual.js.
191
+ if (/^task-visuals\/[A-Za-z0-9._-]+$/.test(apiPath) && req.method === 'GET') {
192
+ const png = require('./task-visual').placeholderPng();
193
+ res.set({ 'content-type': 'image/png', 'cache-control': 'no-store' });
194
+ return res.end(png);
195
+ }
196
+
186
197
  if (req.method !== 'GET') {
187
198
  return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
188
199
  }
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ // scripts/hall-preview/task-visual.js — a stand-in ship-time visual for the
3
+ // preview harness (task 1003829).
4
+ //
5
+ // WHY THIS EXISTS. Everything else in this harness is answered from a checked-in
6
+ // JSON fixture, but a ship-time visual is an IMAGE: the API stub answers
7
+ // /api/bongos/* with JSON, so a page that renders <img src="/api/bongos/
8
+ // task-visuals/task-1899-….png"> got a 404 body and a broken frame. The Studio's
9
+ // review card (task 1003829) makes that image the subject of the card, and a
10
+ // surface whose subject cannot be rendered cannot be reviewed — the whole point
11
+ // of this harness.
12
+ //
13
+ // It is a GENERATED placeholder, not a checked-in screenshot: a few flat bands
14
+ // at a real screenshot's aspect ratio, so a reviewer can judge the plate's size,
15
+ // fit, caption and crop without a binary in the repo. Deliberately obvious —
16
+ // nobody should mistake it for a real ship's visual.
17
+ //
18
+ // A ~40-line PNG encoder rather than a dependency, matching the kit's own
19
+ // png.js (a ~50-line DEcoder for the same reason). One IDAT, filter type 0.
20
+ const zlib = require('node:zlib');
21
+
22
+ function chunk(type, data) {
23
+ const len = Buffer.alloc(4);
24
+ len.writeUInt32BE(data.length);
25
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
26
+ const crc = Buffer.alloc(4);
27
+ crc.writeUInt32BE(crc32(body) >>> 0);
28
+ return Buffer.concat([len, body, crc]);
29
+ }
30
+
31
+ let CRC_TABLE = null;
32
+ function crc32(buf) {
33
+ if (!CRC_TABLE) {
34
+ CRC_TABLE = new Int32Array(256);
35
+ for (let n = 0; n < 256; n++) {
36
+ let c = n;
37
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
38
+ CRC_TABLE[n] = c;
39
+ }
40
+ }
41
+ let c = -1;
42
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
43
+ return c ^ -1;
44
+ }
45
+
46
+ // An RGB PNG from a per-pixel colour function. RGB (colour type 2), 8-bit,
47
+ // non-interlaced — the shape the kit's decoder also speaks.
48
+ function encodeRgb(w, h, at) {
49
+ const stride = w * 3;
50
+ const raw = Buffer.alloc((stride + 1) * h);
51
+ for (let y = 0; y < h; y++) {
52
+ const row = y * (stride + 1);
53
+ raw[row] = 0; // filter: none
54
+ for (let x = 0; x < w; x++) {
55
+ const [r, g, b] = at(x, y);
56
+ const p = row + 1 + x * 3;
57
+ raw[p] = r; raw[p + 1] = g; raw[p + 2] = b;
58
+ }
59
+ }
60
+ const ihdr = Buffer.alloc(13);
61
+ ihdr.writeUInt32BE(w, 0);
62
+ ihdr.writeUInt32BE(h, 4);
63
+ ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
64
+ return Buffer.concat([
65
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
66
+ chunk('IHDR', ihdr),
67
+ chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
68
+ chunk('IEND', Buffer.alloc(0)),
69
+ ]);
70
+ }
71
+
72
+ // The stand-in: a wide plate of flat bands with a paler inset, so its edges,
73
+ // aspect ratio and any crop are all visible at a glance. Cached — the harness
74
+ // may serve it on every paint.
75
+ let CACHED = null;
76
+ function placeholderPng() {
77
+ if (CACHED) return CACHED;
78
+ const W = 1200, H = 700;
79
+ const BANDS = [[38, 34, 30], [74, 66, 58], [176, 96, 62], [214, 200, 184], [246, 243, 238]];
80
+ CACHED = encodeRgb(W, H, (x, y) => {
81
+ const inset = x > W * 0.08 && x < W * 0.92 && y > H * 0.14 && y < H * 0.86;
82
+ const band = BANDS[Math.min(BANDS.length - 1, Math.floor((y / H) * BANDS.length))];
83
+ if (!inset) return band;
84
+ return band.map((c) => Math.min(255, Math.round(c + (255 - c) * 0.42)));
85
+ });
86
+ return CACHED;
87
+ }
88
+
89
+ module.exports = { placeholderPng, encodeRgb };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.663'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.665'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -197,6 +197,20 @@ module.exports = {
197
197
  // isConfigured/encrypt/decrypt/provisionMasterKey without reaching core.
198
198
  get secretBox() { return require('./bongos/secret-box'); },
199
199
 
200
+ // --- grantedScopeCoversRepos(scope) — does a scope string GitHub ACTUALLY
201
+ // GRANTED still reach private repos? Exposed for the provisioning module's
202
+ // create-route credential preflight (task 1002909), which must tell "that
203
+ // repo does not exist" apart from "it is private and your pre-ADR-0155 token
204
+ // cannot see it" — GitHub answers 404 to both.
205
+ //
206
+ // Shared rather than re-implemented because the subtlety is a trap:
207
+ // `public_repo` CONTAINS the substring "repo", so a naive match accepts
208
+ // exactly the stale token this guards against. auth-config.js is KERNEL
209
+ // (ADR 0091 §1), so this stays inside the kernel-imports-only rule — unlike
210
+ // db.getGithubToken / github-repos, which are domain and which a module
211
+ // therefore reaches via the exposed `pool` and its own client instead.
212
+ get grantedScopeCoversRepos() { return require('./bongos/auth-config').grantedScopeCoversRepos; },
213
+
200
214
  // --- KERNEL trust classifiers (ADR 0091 §1, KERNEL_FILES) — the deterministic
201
215
  // route→rank + protected-path matchers. They are KERNEL (the trust
202
216
  // boundary, never "called" by a domain), exposed so a module that runs the
@@ -28,15 +28,130 @@ const HTML = read('studio.html');
28
28
  const HTML_NC = HTML.replace(/<!--[\s\S]*?-->/g, '');
29
29
  const JS = read('studio.js');
30
30
  const CSS = read('studio.css');
31
+ // The file's comments legitimately NAME routes and rules it must not call (the
32
+ // header explains what the room does not do, and the allowlist documents each
33
+ // entry), so the route invariants below scan the CODE. Comments only — string
34
+ // contents are untouched, which is where a real route would live.
35
+ const JS_NC = JS.replace(/^\s*\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
31
36
 
32
- test('the Studio writes NOTHING — the goal\'s no-live-CMS rule, at the surface', () => {
33
- // Every craft act (coach the grader, remake copy, propose wording) happens on
34
- // the surface that owns it (the task record, the copy desk) as a claimed,
35
- // graded, shipped task. This room only READS and ROUTES. A POST/PATCH/DELETE
36
- // here would be the live-CMS write path the goal forbids (criterion
37
- // artist-edits-land-as-tasks) so it must not exist.
38
- const writes = JS.match(/\.request\(\s*['"](POST|PATCH|PUT|DELETE)['"]/g) || [];
39
- assert.deepEqual(writes, [], `studio.js must issue no writes; found ${writes.join(', ')}`);
37
+ // ---------------------------------------------------------------------------
38
+ // What the room may write — NARROWED, not dropped (task 1003829).
39
+ //
40
+ // This test used to assert studio.js issued NO write at all. Task 1003829
41
+ // relaxed that, deliberately and only this far: "The Studio's no-write posture
42
+ // is relaxed only for the artist's own verdict on their own queue — it still
43
+ // gates nothing and still holds no deploy." So the invariant becomes an
44
+ // ALLOWLIST. The thing goal 1000074 actually forbids a live CMS, a room that
45
+ // edits a shipped string or asset — is what the second test below pins, and it
46
+ // is the half that must never be relaxed again.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ test('the Studio writes ONLY the artist\'s verdict on their own review queue', () => {
50
+ // Each permitted write moves the REVIEW TASK (dismiss it, note on it, file
51
+ // its follow-up and close it against that). Every one of them is a route the
52
+ // board's ordinary triage already had; none of them touches the product.
53
+ const allowed = [
54
+ /^POST \$\{API\}\/tasks\/\$\{reviewId\}\/abandon$/, // looks good → dismissed
55
+ /^PATCH \$\{API\}\/tasks\/\$\{reviewId\}$/, // note it → the words, on the review
56
+ /^POST \$\{API\}\/tasks$/, // needs work→ the follow-up task
57
+ /^POST \$\{API\}\/tasks\/\$\{reviewId\}\/merge$/, // …and the review closed against it
58
+ ];
59
+ // Every write site, as "<VERB> <route expression>" — the verb from the
60
+ // request call, the route from the WRITE_ROUTES entry it names.
61
+ const routes = new Map(
62
+ [...JS.matchAll(/^\s*(\w+):\s*\([^)]*\)\s*=>\s*`([^`]+)`,/gm)].map((m) => [m[1], m[2]])
63
+ );
64
+ // EVERY write site in the file, with the URL argument it was given — so a
65
+ // write cannot dodge the allowlist by naming its URL inline. That totality is
66
+ // what makes this test a wall and not a sample.
67
+ const sites = [...JS_NC.matchAll(/\.request\(\s*['"](POST|PATCH|PUT|DELETE)['"]\s*,\s*([^,)]+)/g)]
68
+ .map(([, verb, arg]) => {
69
+ const named = /^WRITE_ROUTES\.(\w+)\(/.exec(arg.trim());
70
+ return { verb, arg: arg.trim(), route: named ? routes.get(named[1]) : null };
71
+ });
72
+ assert.equal(sites.length, 4, `exactly the four verdict writes; found ${sites.length}`);
73
+ for (const s of sites) {
74
+ assert.ok(s.route, `every write names a WRITE_ROUTES entry, never an inline URL: ${s.verb} ${s.arg}`);
75
+ const site = `${s.verb} ${s.route}`;
76
+ assert.ok(allowed.some((re) => re.test(site)), `write outside the allowlist: ${site}`);
77
+ }
78
+ });
79
+
80
+ test('the Studio still edits no live string and no asset — the no-live-CMS rule', () => {
81
+ // The half that is NOT relaxed (criterion artist-edits-land-as-tasks). A copy
82
+ // change or an asset change reaches people only as a claimed, graded, shipped
83
+ // task, so this room must never call the routes that would write one directly.
84
+ const forbidden = [
85
+ /copy-desk\/proposals/, // wording — belongs to the copy desk, under a claim
86
+ /copy-desk\/flags\/[^'"`]*\/close/, // someone else's flag verdict — the copy desk's own act
87
+ /\/visual\b/, // POST/DELETE /tasks/:id/visual — the asset itself
88
+ /\/confirm\b/, /\/ship\b/, /\/grade\b/, /\/claims\b/, // the ship pipeline: it gates nothing
89
+ ];
90
+ for (const re of forbidden) {
91
+ assert.ok(!re.test(JS_NC), `the Studio must not write ${re}`);
92
+ }
93
+ // "Needs work" files a TASK and says so: the follow-up is ordinary work that
94
+ // somebody claims, not an edit this room performs.
95
+ assert.match(JS, /never edits a live string or asset/, 'the follow-up brief says what the room did not do');
96
+ });
97
+
98
+ test('the verdict is offered only to a viewer the routes will accept', () => {
99
+ // The task 1003751 lesson: a card must not print a verb its route refuses.
100
+ // All four verdict routes floor at metic (modules/government/catalog.js), so
101
+ // the weights are drawn only above that floor and everyone else is told where
102
+ // the verdict is recorded instead. Cosmetic — ADR 0016, the server enforces.
103
+ assert.match(JS, /RANK_ORDER\.metic/, 'the offer is gated on the routes\' own floor');
104
+ assert.match(JS, /function canVerdict\(\)/, 'and the gate is one named predicate');
105
+ assert.match(JS, /if \(!canVerdict\(\)\)/, 'read both by the renderer and by the handler');
106
+ assert.match(JS, /studio-verdict__sealed/, 'a viewer below the floor gets a sentence, not three dead buttons');
107
+ });
108
+
109
+ test('the review card shows the reviewed work\'s picture, through the kit', () => {
110
+ // "the picture is the card" — and drawn by the kit's ONE full-size renderer
111
+ // rather than a third hand-rolled <figure> (the task's own instruction).
112
+ assert.match(JS, /kit\.taskVisualFigureHtml\(/, 'the plate comes from the kit renderer');
113
+ assert.ok(!/<figure/.test(JS), 'studio.js builds no <figure> of its own');
114
+ assert.match(HTML, /hall-kit\.js/, 'and the page loads the kit that owns it');
115
+ // The degrade: a review with no visual (or one that could not be read) draws
116
+ // nothing at all rather than an empty frame, so it still reads as a review.
117
+ assert.match(JS, /if \(!p\.shipped \|\| !kit/, 'no reviewed task, no kit → no plate');
118
+ // The reviewed task is read for the card in focus, once — not 50 times up front.
119
+ assert.match(JS, /p\.hydrated = true;/, 'the read is made once per review');
120
+ assert.match(JS, /\$\{API\}\/tasks\/\$\{encodeURIComponent\(p\.shippedId\)\}/, 'and it reads the SHIPPED task the review names');
121
+ });
122
+
123
+ test('a security_sensitive task\'s visual is suppressed, as the server suppresses it', () => {
124
+ // modules/lifecycle/task-visuals.js isPubliclyViewable makes a
125
+ // security_sensitive task's image the one visual the platform does not
126
+ // re-publish; the public feed's SQL agrees. The room agrees too — it keeps the
127
+ // id and drops the rest, so the card degrades to the copy it always showed.
128
+ assert.match(JS, /security_sensitive === true \? \{ id: t\.id \}/, 'the row is stripped to its id when it is sensitive');
129
+ });
130
+
131
+ test('a note APPENDS to a freshly-read brief — it can never blank one', () => {
132
+ // PATCH description REPLACES the field. Appending to a base that is stale, or
133
+ // that a narrowed list projection simply did not carry, would delete the
134
+ // cascade's brief instead of adding to it — so the base is re-read and the
135
+ // write is skipped entirely when that read fails.
136
+ assert.match(JS_NC, /const cur = await api\.request\('GET', `\$\{API\}\/tasks\/\$\{p\.taskId\}`\);/, 'the current brief is re-read');
137
+ assert.match(JS_NC, /if \(base === null\) \{/, 'and a failed re-read writes nothing');
138
+ assert.match(JS_NC, /description: `\$\{base\}\$\{block\}`/, 'the write is base + the note, in that order');
139
+ // Ordering, stated as code: the GET must precede the PATCH in the function.
140
+ const fn = /async function verdictNote[\s\S]*?\n \}/.exec(JS_NC)[0];
141
+ assert.ok(fn.indexOf("request('GET'") < fn.indexOf("request('PATCH'"), 'read before write');
142
+ });
143
+
144
+ test('the three weights are the whole verdict, and two of them require words', () => {
145
+ // The room asks for nothing else (the task's DONE WHEN): one field, three
146
+ // answers. "looks good" may be wordless; a note with no note and a follow-up
147
+ // with no brief are refused inline rather than filed empty.
148
+ assert.match(JS, /const VERDICTS = \{ good: verdictGood, note: verdictNote, work: verdictWork \};/, 'exactly three weights');
149
+ assert.match(JS, /if \(!said\) return verdictError\('Write what you saw first/, 'a note needs words');
150
+ assert.match(JS, /if \(!said\) return verdictError\('Write what needs work first/, 'a follow-up needs a brief');
151
+ // The follow-up is idempotent on (source, source_ref), so a double-click
152
+ // resolves to the same task instead of filing two.
153
+ assert.match(JS, /source_ref: `studio-needs-work-on-review-\$\{p\.taskId\}`/, 'the follow-up is keyed for idempotency');
154
+ assert.match(JS, /let busy = false;/, 'and one verdict runs at a time');
40
155
  });
41
156
 
42
157
  test('the reviews read is bounded and correct', () => {
@@ -88,6 +203,318 @@ test('the accent is spent only where the rule allows', () => {
88
203
  assert.ok(accentFills.length <= 1, `studio.css spends the accent on at most the lamp; found ${accentFills.length}`);
89
204
  });
90
205
 
206
+ // ---------------------------------------------------------------------------
207
+ // Boot the REAL studio.js under a DOM stub, with the REAL hall-kit.js beside it
208
+ // (the bootShell pattern below, pointed at the page script). The static-source
209
+ // assertions above pin the wiring; these render the card and read what it
210
+ // actually says, which is the only way to pin a DEGRADE — "no picture" and
211
+ // "picture suppressed" both have to produce a review that still reads as one.
212
+ // ---------------------------------------------------------------------------
213
+ // `onWrite` decides what each non-GET answers, so a test can drive the happy
214
+ // path AND the refusals. Default: every write succeeds, and POST /tasks mints
215
+ // NEW_TASK_ID so the "needs work" pair can be followed to its merge.
216
+ const NEW_TASK_ID = '2001';
217
+ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags = null, onWrite = null } = {}) {
218
+ // The REAL kit, not a stub — the plate's URL guard and escaping are its code,
219
+ // and a stub here would make the degrade tests below pass for the wrong reason
220
+ // (no kit also means no plate). It is node-require-safe by its own contract.
221
+ const kit = (await import(`file://${path.join(HALL, 'hall-kit.js').replace(/\\/g, '/')}`)).default;
222
+ assert.equal(typeof kit.taskVisualFigureHtml, 'function', 'the real kit loaded');
223
+ const els = new Map();
224
+ const el = (id) => {
225
+ if (!els.has(id)) els.set(id, { id, innerHTML: '', textContent: '', hidden: false, value: '' });
226
+ return els.get(id);
227
+ };
228
+ const calls = [];
229
+ const request = async (method, url, body) => {
230
+ calls.push({ method, url, body });
231
+ if (method !== 'GET') {
232
+ const custom = onWrite && onWrite({ method, url, body });
233
+ if (custom) return custom;
234
+ if (/\/tasks$/.test(url)) return { ok: true, data: { task: { id: NEW_TASK_ID } } };
235
+ return { ok: true, data: { ok: true } };
236
+ }
237
+ if (/\/me$/.test(url)) return { ok: true, data: { builder: { display_name: 'Artist A', rank } } };
238
+ if (/\/tasks\?discipline=artist/.test(url)) return { ok: true, data: { tasks: reviews } };
239
+ if (/\/copy-desk\/queue/.test(url)) return flags ? { ok: true, data: { flags } } : { ok: false, data: {} };
240
+ // GET /tasks/:id answers for BOTH kinds of row the room reads by id: the
241
+ // reviewed (shipped) task the card's picture comes from, and the review
242
+ // itself, which "note it" re-reads before appending. `shipped` is the
243
+ // per-test control for the first; the queue rows serve the second, as the
244
+ // real route would. An id in neither is a 404, which is its own test.
245
+ const m = /\/tasks\/(\d+)$/.exec(url);
246
+ if (m && shipped[m[1]]) return { ok: true, data: { task: shipped[m[1]] } };
247
+ const review = m && reviews.find((r) => String(r.id) === m[1]);
248
+ if (review) return { ok: true, data: { task: review } };
249
+ return { ok: false, data: {} };
250
+ };
251
+ const handlers = [];
252
+ const window = {
253
+ BongosClient: { createClient: () => ({ request }) },
254
+ OTB: {
255
+ escapeHtml: (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
256
+ $: (sel) => el(String(sel).replace(/^#/, '')),
257
+ toast: () => {},
258
+ errMessage: (d) => (d && d.error && d.error.message) || '',
259
+ },
260
+ OTBKit: kit,
261
+ scrollTo: () => {},
262
+ };
263
+ const document = { addEventListener: (t, h) => handlers.push({ t, h }) };
264
+ const sandbox = { window, document, console, setTimeout, clearTimeout, Date, encodeURIComponent };
265
+ sandbox.globalThis = sandbox;
266
+ vm.createContext(sandbox);
267
+ vm.runInContext(JS, sandbox, { filename: 'studio.js' });
268
+ const settle = async (n = 8) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); };
269
+ // The boot IIFE awaits /me, then the two queue reads, then paint() fires the
270
+ // lazy reviewed-task read which repaints.
271
+ await settle();
272
+ // Fire the page's OWN delegated click handler with the element it looks for,
273
+ // so the verdict flows are exercised rather than read. `say` fills the words
274
+ // field first, exactly as a person would.
275
+ const fire = async (attr, value, { say = null } = {}) => {
276
+ if (say !== null) el('studio-words').value = say;
277
+ const node = { dataset: { [attr]: value } };
278
+ const ev = { target: { closest: (sel) => (sel === `[data-${attr}]` ? node : null) } };
279
+ for (const { t, h } of handlers) if (t === 'click') h(ev);
280
+ await settle();
281
+ };
282
+ return {
283
+ focus: () => el('studio-focus').innerHTML,
284
+ err: () => ({ text: el('studio-verdict-err').textContent, hidden: el('studio-verdict-err').hidden }),
285
+ field: () => el('studio-words').value,
286
+ writes: () => calls.filter((c) => c.method !== 'GET'),
287
+ calls,
288
+ verdict: (kind, opts) => fire('verdict', kind, opts),
289
+ skip: () => fire('skip', ''),
290
+ settle,
291
+ };
292
+ }
293
+
294
+ const REVIEW = {
295
+ id: '1900',
296
+ title: 'Artist review: the copy and visuals that shipped in task 1899',
297
+ description: 'STEP 1 — coach the grader.',
298
+ status: 'backlog',
299
+ source: 'cascade',
300
+ source_ref: 'cascade-artist-review-on-ship-1899',
301
+ goal_id: '1000074',
302
+ version_id: 'BONGOS-V2',
303
+ created_at: '2026-09-04T12:30:00.000Z',
304
+ };
305
+ const VISUAL_URL = '/api/bongos/task-visuals/task-1899-0011223344556677.png';
306
+
307
+ test('a review WITH a visual shows the picture, and folds the steps behind the door', async () => {
308
+ const { focus, calls } = await bootStudio({
309
+ reviews: [REVIEW],
310
+ shipped: { 1899: { id: '1899', visual_url: VISUAL_URL, visual_alt: 'The new hero, dark mode', security_sensitive: false } },
311
+ });
312
+ const html = focus();
313
+ assert.match(html, /class="studio-plate"/, 'the plate is drawn');
314
+ assert.match(html, /src="\/api\/bongos\/task-visuals\/task-1899-0011223344556677\.png"/, 'from the reviewed task\'s own visual');
315
+ assert.match(html, /studio-plate__caption">The new hero, dark mode/, 'with visual_alt as the caption');
316
+ // With a picture on it the card is the picture, one line of framing, and the
317
+ // verdict — the steps describe what happens after the door, not before it.
318
+ assert.ok(!/studio-steps/.test(html), 'the two steps are not between the artist and their answer');
319
+ assert.match(html, /Say what you see/, 'one short line of framing instead');
320
+ assert.match(html, /data-verdict="good"/, 'and the three weights are there');
321
+ // ONE extra read for the card in focus, not one per queued review.
322
+ const reads = calls.filter((c) => /\/tasks\/\d+$/.test(c.url));
323
+ assert.equal(reads.length, 1, `one reviewed-task read; made ${reads.length}`);
324
+ });
325
+
326
+ test('a review with NO visual degrades to the copy it always showed', async () => {
327
+ const { focus } = await bootStudio({
328
+ reviews: [REVIEW],
329
+ shipped: { 1899: { id: '1899', visual_url: null, security_sensitive: false } },
330
+ });
331
+ const html = focus();
332
+ assert.ok(!/studio-plate/.test(html), 'no empty frame');
333
+ assert.ok(!/<img/.test(html), 'and no image at all');
334
+ assert.match(html, /studio-steps/, 'both craft steps are back');
335
+ assert.match(html, /coaches the grader on how it judged the work/, 'as is the full framing paragraph');
336
+ assert.match(html, /data-verdict="good"/, 'the verdict is still offered — this is still a review');
337
+ });
338
+
339
+ test('a security_sensitive reviewed task shows no picture, and still reads as a review', async () => {
340
+ const { focus } = await bootStudio({
341
+ reviews: [REVIEW],
342
+ shipped: { 1899: { id: '1899', visual_url: VISUAL_URL, visual_alt: 'the exploit', security_sensitive: true } },
343
+ });
344
+ const html = focus();
345
+ assert.ok(!/task-visuals/.test(html), 'the URL never reaches the page');
346
+ assert.ok(!/the exploit/.test(html), 'nor does the alt text');
347
+ assert.match(html, /studio-steps/, 'it degrades to the copy, exactly like a review with no visual');
348
+ });
349
+
350
+ test('a reviewed task that cannot be read leaves a working review card', async () => {
351
+ // A 404/403 on the reviewed task (it was merged, or the viewer cannot see it)
352
+ // must cost the artist nothing: the card is the one it always was.
353
+ const { focus } = await bootStudio({ reviews: [REVIEW], shipped: {} });
354
+ const html = focus();
355
+ assert.ok(!/studio-plate/.test(html));
356
+ assert.match(html, /studio-steps/);
357
+ assert.match(html, /The copy and visuals that shipped in task #1899/);
358
+ });
359
+
360
+ test('below the routes\' floor the card offers a sentence, not three refusals', async () => {
361
+ const { focus } = await bootStudio({
362
+ reviews: [REVIEW],
363
+ shipped: { 1899: { id: '1899', visual_url: VISUAL_URL, visual_alt: 'a', security_sensitive: false } },
364
+ rank: 'xenos',
365
+ });
366
+ const html = focus();
367
+ assert.ok(!/data-verdict=/.test(html), 'no weight is drawn');
368
+ assert.match(html, /studio-verdict__sealed/, 'the sentence is');
369
+ assert.match(html, /open it to dismiss it, note what you saw, or file the follow-up/);
370
+ // The picture is NOT rank-gated — looking is every craft's business.
371
+ assert.match(html, /studio-plate/, 'and the artist can still see the work');
372
+ });
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // The verdict flows, FIRED rather than read. The static assertions above pin
376
+ // the wiring and the allowlist; these click the page's own delegated handler
377
+ // and inspect what it actually sent and what state it left behind — the splice
378
+ // math in retire(), the double-submit guard, and the exact request bodies.
379
+ // ---------------------------------------------------------------------------
380
+ const SHIPPED_WITH_VISUAL = { 1899: { id: '1899', visual_url: VISUAL_URL, visual_alt: 'The new hero', security_sensitive: false } };
381
+ const three = () => [
382
+ { ...REVIEW, id: '1900', source_ref: 'cascade-artist-review-on-ship-1899' },
383
+ { ...REVIEW, id: '1901', source_ref: 'cascade-artist-review-on-ship-1898', created_at: '2026-09-03T00:00:00.000Z' },
384
+ { ...REVIEW, id: '1902', source_ref: 'cascade-artist-review-on-ship-1897', created_at: '2026-09-02T00:00:00.000Z' },
385
+ ];
386
+
387
+ test('FIRE looks good: one abandon, carrying who said so, and the stone is gone', async () => {
388
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
389
+ assert.match(s.focus(), /1 of 3/);
390
+ await s.verdict('good', { say: 'The hero lands.' });
391
+ const w = s.writes();
392
+ assert.equal(w.length, 1, `exactly one write; got ${JSON.stringify(w.map((c) => c.method + ' ' + c.url))}`);
393
+ assert.match(w[0].url, /\/tasks\/1900\/abandon$/, 'the REVIEW is abandoned, not the reviewed task');
394
+ assert.match(w[0].body.reason, /Reviewed in the studio by Artist A/, 'the reason names who judged it');
395
+ assert.match(w[0].body.reason, /The hero lands\./, 'and carries the optional words');
396
+ // retire() dropped it and the count re-read: 3 → 2, and the NEXT review is in
397
+ // focus rather than the list renumbering around a hole.
398
+ assert.match(s.focus(), /1 of 2/, 'the garden shrank and did not skip');
399
+ assert.match(s.focus(), /task #1898/, 'the next stone is the one that was behind it');
400
+ });
401
+
402
+ test('FIRE looks good with no words: still one abandon, no empty-words refusal', async () => {
403
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
404
+ await s.verdict('good');
405
+ assert.equal(s.writes().length, 1, '"looks good" is the one weight that needs no words');
406
+ assert.ok(!/undefined|null/.test(s.writes()[0].body.reason), 'and says nothing about absent words');
407
+ });
408
+
409
+ test('FIRE note it: the brief is re-read, appended, and the review STAYS in the garden', async () => {
410
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
411
+ await s.verdict('note', { say: 'The sub-line reads like a brochure.' });
412
+ const w = s.writes();
413
+ assert.equal(w.length, 1);
414
+ assert.equal(w[0].method, 'PATCH');
415
+ assert.match(w[0].url, /\/tasks\/1900$/);
416
+ // The cascade's own brief survives, with the note after it — the failure mode
417
+ // here is a PATCH that REPLACES the body, so assert both halves are present.
418
+ assert.match(w[0].body.description, /^STEP 1 — coach the grader\./, 'the original brief is still the start of the field');
419
+ assert.match(w[0].body.description, /ARTIST NOTE \(\d{4}-\d{2}-\d{2}, Artist A, from the studio\)/, 'the note is stamped and attributed');
420
+ assert.match(w[0].body.description, /The sub-line reads like a brochure\.$/, 'and ends with what was written');
421
+ // A note is not a dismissal.
422
+ assert.match(s.focus(), /1 of 3/, 'the review is still waiting');
423
+ assert.equal(s.field(), '', 'the field is cleared for the next thought');
424
+ assert.equal(s.err().hidden, true);
425
+ });
426
+
427
+ test('FIRE note it with no words: nothing is sent at all', async () => {
428
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
429
+ await s.verdict('note', { say: ' ' });
430
+ assert.deepEqual(s.writes(), [], 'no write');
431
+ assert.deepEqual(s.calls.filter((c) => /\/tasks\/1900$/.test(c.url)), [], 'and not even the re-read');
432
+ assert.match(s.err().text, /Write what you saw first/);
433
+ assert.equal(s.err().hidden, false);
434
+ });
435
+
436
+ test('FIRE needs work: a task in the reviewed work\'s goal, then the review closed against it', async () => {
437
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
438
+ await s.verdict('work', { say: 'The plate is muddy at 320.' });
439
+ const w = s.writes();
440
+ assert.equal(w.length, 2, 'the pair: create, then merge');
441
+ const [create, merge] = w;
442
+ assert.match(create.url, /\/tasks$/);
443
+ assert.equal(create.body.goal_id, 1000074, 'the follow-up lands in the reviewed work\'s goal, as a number');
444
+ assert.equal(create.body.version_id, 'BONGOS-V2');
445
+ assert.equal(create.body.discipline, 'artist');
446
+ assert.equal(create.body.source_ref, 'studio-needs-work-on-review-1900', 'keyed so a double-click cannot file twice');
447
+ assert.match(create.body.description, /The plate is muddy at 320\./, 'the artist\'s words are the brief');
448
+ assert.match(create.body.description, /never edits a live string or asset/, 'and the brief says this is ordinary work');
449
+ assert.match(merge.url, /\/tasks\/1900\/merge$/, 'the REVIEW is the one closed');
450
+ assert.equal(merge.body.into_task_id, Number(NEW_TASK_ID), 'against the task just filed');
451
+ assert.match(s.focus(), /1 of 2/, 'and the review left the garden');
452
+ });
453
+
454
+ test('FIRE needs work when the merge fails: the filed task is NAMED, not lost', async () => {
455
+ // The half-done case is the one that matters: the follow-up exists, so the
456
+ // artist must be told its id rather than left thinking nothing happened.
457
+ const s = await bootStudio({
458
+ reviews: three(),
459
+ shipped: SHIPPED_WITH_VISUAL,
460
+ onWrite: ({ url }) => (/\/merge$/.test(url) ? { ok: false, data: { error: { code: 'x', message: 'no' } } } : null),
461
+ });
462
+ await s.verdict('work', { say: 'Muddy.' });
463
+ assert.equal(s.writes().length, 2, 'both halves were attempted');
464
+ assert.match(s.err().text, new RegExp(`Task ${NEW_TASK_ID} was filed`), 'the id is in the message');
465
+ assert.match(s.focus(), /1 of 3/, 'and the review stays put rather than vanishing unclosed');
466
+ });
467
+
468
+ test('FIRE a refused abandon: the review stays, and the server\'s own sentence is shown', async () => {
469
+ const s = await bootStudio({
470
+ reviews: three(),
471
+ shipped: SHIPPED_WITH_VISUAL,
472
+ onWrite: () => ({ ok: false, data: { error: { code: 'rank_forbidden', message: 'metic or above' } } }),
473
+ });
474
+ await s.verdict('good');
475
+ assert.equal(s.err().text, 'metic or above', 'the route\'s message, not a generic one');
476
+ assert.match(s.focus(), /1 of 3/, 'nothing was retired on a refusal');
477
+ });
478
+
479
+ test('FIRE two clicks: the second is swallowed, so a double-click files once', async () => {
480
+ // The busy flag, exercised: the write is held open, both clicks land, and only
481
+ // one request is made. Without the guard "needs work" files two tasks.
482
+ let release;
483
+ const held = new Promise((r) => { release = r; });
484
+ const s = await bootStudio({
485
+ reviews: three(),
486
+ shipped: SHIPPED_WITH_VISUAL,
487
+ onWrite: async () => { await held; return { ok: true, data: { ok: true } }; },
488
+ });
489
+ const first = s.verdict('good');
490
+ await s.verdict('good');
491
+ assert.equal(s.writes().length, 1, 'the second click did not start a second write');
492
+ release({ ok: true, data: { ok: true } });
493
+ await first;
494
+ await s.settle();
495
+ assert.equal(s.writes().length, 1);
496
+ });
497
+
498
+ test('FIRE below the floor: a forged click writes nothing', async () => {
499
+ // The weights are not drawn for a xenos — but the handler is delegated, so
500
+ // canVerdict() is re-checked in runVerdict rather than trusted to the render.
501
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL, rank: 'xenos' });
502
+ await s.verdict('good', { say: 'fine' });
503
+ await s.verdict('work', { say: 'not fine' });
504
+ assert.deepEqual(s.writes(), [], 'the room sends nothing it was not allowed to offer');
505
+ });
506
+
507
+ test('FIRE skip: it advances and writes nothing, and the last skip ends quietly', async () => {
508
+ const s = await bootStudio({ reviews: three(), shipped: SHIPPED_WITH_VISUAL });
509
+ await s.skip();
510
+ assert.match(s.focus(), /2 of 3/);
511
+ await s.skip();
512
+ await s.skip();
513
+ assert.deepEqual(s.writes(), [], 'skipping is not a verdict');
514
+ // Nothing was tended, so the emptied room reads as quiet, not as finished.
515
+ assert.match(s.focus(), /Nothing waits right now|tended everything/);
516
+ });
517
+
91
518
  // Boot the real shell.js under a DOM stub (the hall_nav.mjs pattern) whose
92
519
  // #nav-studio records classList toggles, so applyCraftEmphasis is tested for
93
520
  // what it DOES, not just that it exists.
@@ -24,6 +24,7 @@ const PUBLISHED_SURFACE = [
24
24
  'requirePermission', // BV1.R105: the authority-atom gate module routes migrate onto (1.19.0; ADR 0151)
25
25
  'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
26
26
  'secretBox', // Gemini-key storage relocated to modules/art-pipeline + its 'art.getBuilderGeminiKeyRow' port (ADR 0102)
27
+ 'grantedScopeCoversRepos', // the create-route credential preflight's scope read (task 1002909); kernel (auth-config.js), unlike db/github-repos which the module reaches via `pool` + its own client
27
28
  'accountExistenceReadRateLimit', // task 1003339 (ADR 0209): the shared per-IP budget both account-existence probes spend from — one instance, not a factory, so the budget is genuinely shared
28
29
  'publicProjectFeedRateLimit', // task 1002327 (ADR 0252 §5.3): the per-IP ceiling on GET /projects/featured — the platform's most expensive anonymous read (a cold call fans out to up to 500 third-party origins). One instance for the same reason as its neighbour
29
30
  'routeRankCheck', 'permissionPathCheck', // BV1.R73: kernel trust classifiers (1.6.0)