@bongos/core 1.19.349 → 1.19.350

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.bongos-core.json +78 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0184-a-task-visual-inherits-its-summary-audience.md +49 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +208 -3
  10. package/docs/api-reference.md +10 -2
  11. package/docs/architecture.md +6 -1
  12. package/docs/copy-inventory.md +38 -38
  13. package/docs/copy-registry.json +40 -40
  14. package/docs/file-map.md +2 -1
  15. package/migrations/core_223_task_visual.sql +33 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
  17. package/modules/hall-ui/public/hall-kit.js +33 -0
  18. package/modules/hall-ui/public/hall-render.js +1 -0
  19. package/modules/hall-ui/public/profile.html +4 -1
  20. package/modules/hall-ui/public/profile.js +7 -1
  21. package/modules/hall-ui/public/style.css +29 -0
  22. package/modules/hall-ui/public/task.js +30 -0
  23. package/modules/lifecycle/db-analytics.js +3 -1
  24. package/modules/lifecycle/module.json +2 -1
  25. package/modules/lifecycle/routes/visuals.js +216 -0
  26. package/modules/lifecycle/task-visual-db.js +46 -0
  27. package/modules/lifecycle/task-visuals.js +270 -0
  28. package/modules/status-ui/public/status.js +33 -0
  29. package/modules/status-ui/public/style.css +14 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/scripts/gds/ship-flow.js +11 -1
  33. package/scripts/gds/ship-visual.js +68 -0
  34. package/scripts/gds/task.js +62 -0
  35. package/src/bongos/routes/public.js +8 -0
  36. package/src/bongos/routes.js +5 -1
  37. package/src/module-api.js +3 -1
  38. package/tests/task_visual_route_gate.mjs +151 -0
  39. package/tests/task_visuals.mjs +200 -0
@@ -0,0 +1,216 @@
1
+ // modules/lifecycle/routes/visuals.js — the ship-time task VISUAL, whole
2
+ // surface (task 1003109, goal 1000072 "Human legibility").
3
+ //
4
+ // NAMED `visuals.js`, NOT `task-visuals.js`, and that is load-bearing:
5
+ // tests/helpers.mjs:taskRoutesSource() concatenates `tasks.js` + every
6
+ // `task-*.js` in this directory as "the tasks-route family" (the carve products
7
+ // task-route-helpers.js / task-write-routes.js). A file called task-visuals.js
8
+ // sorts FIRST in that concatenation, so its `res.status(201)` body became the one
9
+ // tests/goal_advisory.mjs + tests/est_advisory.mjs inspect — and both failed on a
10
+ // route that was perfectly correct. Adding a `task-*.js` route file here silently
11
+ // hijacks those assertions; don't.
12
+ //
13
+ // POST /tasks/:id/visual attach or replace it (claim holder | Archon)
14
+ // DELETE /tasks/:id/visual take it back off (claim holder | Archon)
15
+ // GET /task-visuals/:name serve one (see the audience note)
16
+ //
17
+ // The twin of routes/bug-attachments.js, for the image a builder attaches to
18
+ // their own shipped task instead of one the bot pulled out of #bugs. Storage +
19
+ // screening live in ../task-visuals.js; all three routes live here rather than
20
+ // in the (already 1800-line) routes/tasks.js so the feature reads as one thing.
21
+ //
22
+ // rank: public — but CONDITIONALLY, and the condition is the whole point of the
23
+ // file, so read this before changing the mounting.
24
+ //
25
+ // A visual carries no audience of its own: it restates the task's
26
+ // value_summary in another medium, so it inherits that summary's audience
27
+ // exactly. For already-public work (shipped|confirmed, not security_sensitive)
28
+ // the summary is on the open web — the unauthenticated status dashboard renders
29
+ // it — so the image is served unauthenticated too. For anything else the image
30
+ // is served ONLY to an authenticated builder, via the very same
31
+ // auth.requireBuilder used everywhere else in the GDS.
32
+ //
33
+ // That is why the gate is applied INSIDE the handler rather than as a mounted
34
+ // middleware: the decision needs the task the filename names. The stored
35
+ // filename embeds the task id (task-<id>-<hex>.<ext>) precisely so this lookup
36
+ // is possible without a second table. It FAILS CLOSED at every step — an
37
+ // unparseable name, an absent task, or a task that is not already-public all
38
+ // fall through to requireBuilder, never past it.
39
+ //
40
+ // The audience predicate itself lives once, in ../task-visuals.js
41
+ // (isPubliclyViewable), and is mirrored in the public shipped-feed SQL.
42
+
43
+ const express = require('express');
44
+ const fs = require('node:fs/promises');
45
+ // Intra-module + the published doorway — a module never requires a core internal.
46
+ const api = require('../../../src/module-api');
47
+ const auth = api;
48
+ // The feature's own two queries — kept out of the 5.8k-line ../db.js under the
49
+ // shrink-only size ratchet; see the file header there.
50
+ const db = require('../task-visual-db');
51
+ const visuals = require('../task-visuals');
52
+ // The claim-holder gate the whole ship path uses (/grade /confirm /ship
53
+ // /publish-branch). Intra-module sibling — reused, never re-stated.
54
+ const { gateTaskOwnership } = require('./tasks');
55
+ const { validateOrRespond, parseId } = api;
56
+ const log = api.logger('lifecycle');
57
+
58
+ // Body caps for POST /tasks/:id/visual. Two layers, like /publish-branch: the
59
+ // JSON limit bounds the ENCODED base64 string, and visuals.screenImage re-checks
60
+ // the DECODED bytes against visuals.MAX_BYTES (4 MB).
61
+ const VISUAL_BODY_LIMIT = '8mb';
62
+ const VISUAL_B64_MAX = 7 * 1024 * 1024;
63
+
64
+ module.exports = function buildTaskVisualsRouter() {
65
+ const router = express.Router();
66
+
67
+ // rank: public — the WIDEST path this route can take, declared honestly. It is
68
+ // reached only for already-public work (see isPubliclyViewable); every other
69
+ // request falls through to auth.requireBuilder INSIDE the handler, because the
70
+ // decision needs the task the filename names. Read the file header before
71
+ // changing this — a mounted middleware cannot express a per-row audience.
72
+ // GET /api/bongos/task-visuals/:name — stream one stored visual.
73
+ router.get('/task-visuals/:name', async (req, res, next) => {
74
+ const name = req.params.name;
75
+ const filePath = visuals.resolveSafe(name);
76
+ // Refuse a malformed name BEFORE any lookup — this is the path-traversal
77
+ // wall, and it also means the id parsed below is always well-formed.
78
+ if (!filePath) return res.fail('bad_name', 400);
79
+
80
+ const taskId = visuals.taskIdFromName(name);
81
+ let task = null;
82
+ try {
83
+ task = await db.getTaskVisualAccess(taskId);
84
+ } catch (err) {
85
+ // A lookup failure must not become an open door. Log and fall through to
86
+ // the authenticated path — a builder still gets the image, the open web
87
+ // does not.
88
+ log.error({ taskId, err: err && err.message }, 'task-visual access lookup failed — falling back to the authenticated path');
89
+ }
90
+
91
+ const send = async () => {
92
+ try {
93
+ const data = await fs.readFile(filePath);
94
+ res.setHeader('Content-Type', visuals.contentTypeForName(name));
95
+ // Never let a browser sniff a stored byte-string into something
96
+ // executable on our own origin, and keep it inline (it renders in the
97
+ // hall) but explicitly not as a download.
98
+ res.setHeader('X-Content-Type-Options', 'nosniff');
99
+ res.setHeader('Content-Disposition', 'inline');
100
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
101
+ // A stored file is immutable — a replacement gets a NEW random name — so
102
+ // it can be cached hard. Public work caches shared; everything else stays
103
+ // private to the builder's own browser.
104
+ res.setHeader(
105
+ 'Cache-Control',
106
+ visuals.isPubliclyViewable(task) ? 'public, max-age=86400' : 'private, max-age=86400'
107
+ );
108
+ res.send(data);
109
+ } catch (err) {
110
+ if (err.code === 'ENOENT') return res.fail('not_found', 404);
111
+ log.error({ name, err: err && err.message }, 'task-visual read failed');
112
+ res.fail('read_failed', 500);
113
+ }
114
+ };
115
+
116
+ if (visuals.isPubliclyViewable(task)) return send();
117
+ // Not already-public → the ordinary builders-only GDS posture. requireBuilder
118
+ // owns its own 401/403 response; we only reach send() when it calls next().
119
+ return auth.requireBuilder(req, res, (err) => (err ? next(err) : send()));
120
+ });
121
+
122
+ // ==========================================================================
123
+ // Ship-time task VISUAL (task 1003109, goal 1000072 "Human legibility")
124
+ // --------------------------------------------------------------------------
125
+ // `value_summary` gives a reader the plain-language "what this means" line. A
126
+ // visual is that SAME claim in another medium — a screenshot of the surface
127
+ // that changed, a before/after, a diagram — so a non-engineer can SEE a change
128
+ // instead of reading about it (owner idea 1000400). Storage + screening live in
129
+ // ../task-visuals.js; the serve route above is what renders it back.
130
+ //
131
+ // ALWAYS OPTIONAL. Nothing on the ship path calls this, or blocks on it: the
132
+ // CLI uploads only when the builder passed --visual, and a failure there is a
133
+ // warning, never an abort. A task with no visual is a complete task.
134
+ //
135
+ // AUTH: requireBuilder + gateTaskOwnership — exactly the /grade /confirm /ship
136
+ // /publish-branch gate. Only the task's claim holder (or an Archon) may say
137
+ // what a task's shipped work looked like; anyone else attaching an image to
138
+ // someone else's ledger entry is the misattribution this closes.
139
+ const visualUpload = express.json({ limit: VISUAL_BODY_LIMIT });
140
+ // POST /tasks/:id/visual — attach (or replace) this task's ship-time visual.
141
+ router.post('/tasks/:id/visual', auth.requireBuilder, visualUpload, async (req, res) => {
142
+ const id = parseId(req, res, { code: 'bad_task_id' });
143
+ if (id === null) return;
144
+ if (validateOrRespond(req, res, {
145
+ image_b64: { required: true, type: 'string', maxLength: VISUAL_B64_MAX, minLength: 1 },
146
+ content_type: { required: true, type: 'string', maxLength: 100, minLength: 1 },
147
+ alt: { type: 'string', maxLength: visuals.MAX_ALT_LENGTH },
148
+ })) return;
149
+ if (!(await gateTaskOwnership(req, res, id))) return;
150
+
151
+ // The JSON `limit` bounds the ENCODED payload; base64 inflates ~33%, so the
152
+ // DECODED bytes are re-checked in screenImage (the publish-branch posture).
153
+ // Buffer.from never throws on malformed base64 — it drops what it can't
154
+ // decode — so there is no decode error to catch here: garbage in produces
155
+ // bytes that fail the magic-byte sniff below, which is the real gate.
156
+ const buf = Buffer.from(req.body.image_b64, 'base64');
157
+ const screened = visuals.screenImage(buf, req.body.content_type);
158
+ if (!screened.ok) {
159
+ const status = screened.reason === 'too_big' ? 413 : 400;
160
+ return res.fail(`visual_${screened.reason}`, status, {
161
+ max_bytes: visuals.MAX_BYTES,
162
+ accepted: Object.keys(visuals.EXT_BY_TYPE),
163
+ });
164
+ }
165
+
166
+ // Read the task (and its current visual) BEFORE writing, so a replaced image
167
+ // can be unlinked and a vanished task 404s rather than orphaning a file.
168
+ const before = await db.getTaskVisualAccess(id);
169
+ if (!before) return res.fail('task_not_found', 404);
170
+
171
+ try {
172
+ const stored = await visuals.persistImageBuffer(buf, {
173
+ taskId: id,
174
+ contentType: req.body.content_type,
175
+ });
176
+ if (!stored.ok) return res.fail(`visual_${stored.reason}`, 400);
177
+ const row = await db.setTaskVisual({
178
+ taskId: id,
179
+ visualUrl: stored.url,
180
+ visualAlt: visuals.normalizeAlt(req.body.alt),
181
+ });
182
+ // Replacing: drop the superseded file. Best-effort — a failed unlink leaves
183
+ // an unreferenced file, which is harmless; a failed RESPONSE would not be.
184
+ if (before.visual_url && before.visual_url !== stored.url) {
185
+ await visuals.deleteStored(before.visual_url);
186
+ }
187
+ return res.status(201).json({ ok: true, visual_url: row.visual_url, visual_alt: row.visual_alt, bytes: stored.bytes });
188
+ } catch (err) {
189
+ log.error({ taskId: id, err: err && err.message }, 'task-visual upload failed');
190
+ return res.fail('visual_store_failed', { status: 500, message: 'internal error' });
191
+ }
192
+ });
193
+
194
+ // DELETE /tasks/:id/visual — remove it again. The counterpart to "always
195
+ // optional": a builder who attached the wrong screenshot must be able to take
196
+ // it back, and an image is not prose (the write-once rule of task 1003102
197
+ // guards a written CLAIM, not the picture beside it). Same ownership gate as
198
+ // the POST. Idempotent — removing an absent visual is a success.
199
+ router.delete('/tasks/:id/visual', auth.requireBuilder, async (req, res) => {
200
+ const id = parseId(req, res, { code: 'bad_task_id' });
201
+ if (id === null) return;
202
+ if (!(await gateTaskOwnership(req, res, id))) return;
203
+ const before = await db.getTaskVisualAccess(id);
204
+ if (!before) return res.fail('task_not_found', 404);
205
+ try {
206
+ await db.setTaskVisual({ taskId: id, visualUrl: null, visualAlt: null });
207
+ if (before.visual_url) await visuals.deleteStored(before.visual_url);
208
+ return res.json({ ok: true, visual_url: null });
209
+ } catch (err) {
210
+ log.error({ taskId: id, err: err && err.message }, 'task-visual delete failed');
211
+ return res.fail('visual_delete_failed', { status: 500, message: 'internal error' });
212
+ }
213
+ });
214
+
215
+ return router;
216
+ };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/task-visual-db.js — the two queries behind a task's ship-time
4
+ // visual (task 1003109, goal 1000072 "Human legibility").
5
+ //
6
+ // WHY ITS OWN FILE. These belong logically in ../db.js with the rest of the
7
+ // lifecycle data layer — but that file is already ~5,800 lines, and the size
8
+ // ratchet (scripts/gds/fitness-ratchets.js, task 1003129) is shrink-only: an
9
+ // oversized file may not grow. A feature's data access going into a small,
10
+ // cohesive file next to the feature's storage lib is the shape that rule is
11
+ // asking for, so this is the rule working rather than an exception to it.
12
+ //
13
+ // Both helpers are deliberately tiny and status-blind: this layer stores and
14
+ // reads a URL. The ROUTES own who may WRITE it (the task's claim holder) and who
15
+ // may SEE it (whoever may already read the task's value_summary — the predicate
16
+ // is task-visuals.isPubliclyViewable).
17
+
18
+ // The published doorway — a module never requires a core internal.
19
+ const { pool } = require('../../src/module-api');
20
+
21
+ // Set (or clear) a task's visual. Returns the updated row, or null when the task
22
+ // does not exist. The caller reads the PREVIOUS visual_url through
23
+ // getTaskVisualAccess first (it already does, to authorize) and unlinks the
24
+ // superseded file after this returns.
25
+ async function setTaskVisual({ taskId, visualUrl = null, visualAlt = null }) {
26
+ const { rows } = await pool.query(
27
+ `UPDATE tasks SET visual_url = $2, visual_alt = $3, updated_at = now()
28
+ WHERE id = $1 RETURNING id, visual_url, visual_alt`,
29
+ [taskId, visualUrl, visualAlt]
30
+ );
31
+ return rows[0] ?? null;
32
+ }
33
+
34
+ // The minimum a serve route needs to decide a stored image's audience: does the
35
+ // task exist, is it already-public work, and is it security-sensitive. Kept to
36
+ // these four columns on purpose — a gate must not be able to leak anything else
37
+ // about a task it is about to refuse.
38
+ async function getTaskVisualAccess(taskId) {
39
+ const { rows } = await pool.query(
40
+ `SELECT id, status, security_sensitive, visual_url FROM tasks WHERE id = $1`,
41
+ [taskId]
42
+ );
43
+ return rows[0] ?? null;
44
+ }
45
+
46
+ module.exports = { setTaskVisual, getTaskVisualAccess };
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ // Task-visual storage — the OPTIONAL image a builder attaches to a shipped task
4
+ // (task 1003109, goal 1000072 "Human legibility", from owner idea 1000400).
5
+ //
6
+ // WHY. `value_summary` already gives a reader the plain-language "what this
7
+ // means" line at ship time. What a non-engineer still could not get is a PICTURE:
8
+ // a screenshot of the surface that changed, a before/after, a diagram. Today the
9
+ // only visual path is a Discord post, which sits outside the ledger and expires.
10
+ // This is that same claim in another medium — so it is stored beside the summary,
11
+ // rendered exactly where the summary is rendered, and is ALWAYS optional: a task
12
+ // with no visual is complete, and nothing on the ship path blocks on its absence.
13
+ //
14
+ // This module is the pure-ish storage core: where files live, how a filename is
15
+ // validated (path-traversal-safe), which task a stored name belongs to, and how
16
+ // one uploaded buffer is screened + written. No express, no db — it unit-tests
17
+ // with a temp dir and a Buffer.
18
+ //
19
+ // DELIBERATE DUPLICATION with modules/discord/bug-attachments.js (the ADR 0091 §2
20
+ // "boundary-forced duplication is allowed + documented" rule): that lib is the
21
+ // worked example this one follows, but it lives in a SIBLING module and ADR 0083
22
+ // forbids requiring one module from another. So the extension allowlist, the
23
+ // magic-byte sniff and the resolveSafe belt-and-braces are re-stated here rather
24
+ // than imported. Same shapes on both sides; keep them in sync. The one real
25
+ // difference is the INTAKE: bug-attachments DOWNLOADS from the Discord CDN (hence
26
+ // its host allowlist + SSRF guard), while a visual is UPLOADED as bytes by the
27
+ // shipping builder — so there is no outbound fetch here to defend.
28
+ //
29
+ // ANTI-ABUSE (the "must not become an arbitrary-file host" posture): an
30
+ // allowlisted extension only, a magic-byte sniff so a mislabeled content-type
31
+ // cannot smuggle a non-image onto disk, a per-image size cap, and a stored
32
+ // filename shape the serve route validates exactly. The serve route adds
33
+ // `nosniff` + `Content-Disposition: inline` so a stored byte-string can never be
34
+ // interpreted as script by a browser on our own origin.
35
+
36
+ const path = require('node:path');
37
+ const fs = require('node:fs/promises');
38
+ const fsSync = require('node:fs'); // readImageFile is the CLI's synchronous intake.
39
+ const crypto = require('node:crypto');
40
+
41
+ // ext ⇄ content-type for the formats we accept. SVG is deliberately ABSENT: it is
42
+ // an executable document (inline <script>), and these are served from our own
43
+ // origin.
44
+ const EXT_BY_TYPE = {
45
+ 'image/png': 'png',
46
+ 'image/jpeg': 'jpg',
47
+ 'image/gif': 'gif',
48
+ 'image/webp': 'webp',
49
+ };
50
+ const TYPE_BY_EXT = {
51
+ png: 'image/png',
52
+ jpg: 'image/jpeg',
53
+ gif: 'image/gif',
54
+ webp: 'image/webp',
55
+ };
56
+
57
+ const MAX_BYTES = 4 * 1024 * 1024; // 4 MB — a screenshot, not an asset library.
58
+ const MAX_ALT_LENGTH = 300; // the caption/alt text stored beside it.
59
+
60
+ // Stored filenames are SERVER-generated, never user-supplied: a fixed shape we
61
+ // can validate exactly, carrying the task id so the serve route can resolve the
62
+ // image's privacy posture from the task it belongs to without a second lookup
63
+ // table. This regex is the whole path-traversal defense — the serve route only
64
+ // ever opens a name that matches it.
65
+ const NAME_RE = /^task-(\d{1,12})-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
66
+
67
+ // Where visuals live. Outside the git tree (a `var/` dir) so a deploy's
68
+ // `git reset --hard` / pull never wipes them; gitignored. Env override for tests.
69
+ function visualsDir() {
70
+ return process.env.TASK_VISUALS_DIR
71
+ || path.join(__dirname, '..', '..', 'var', 'task-visuals');
72
+ }
73
+
74
+ // PURE — is this a well-formed, server-generated visual name?
75
+ function isSafeName(name) {
76
+ return typeof name === 'string' && NAME_RE.test(name);
77
+ }
78
+
79
+ // PURE — which task does a stored name belong to? Returns the numeric id, or
80
+ // null for any name that isn't well-formed. The serve route's privacy gate reads
81
+ // the task's status through this, so an unparseable name fails CLOSED.
82
+ function taskIdFromName(name) {
83
+ if (typeof name !== 'string') return null;
84
+ const m = NAME_RE.exec(name);
85
+ return m ? Number(m[1]) : null;
86
+ }
87
+
88
+ // PURE — resolve a name to an absolute path INSIDE visualsDir, or null if the
89
+ // name is unsafe or would escape the dir. Belt (regex) and braces (prefix check
90
+ // on the resolved path).
91
+ function resolveSafe(name, { dir = visualsDir() } = {}) {
92
+ if (!isSafeName(name)) return null;
93
+ const root = path.resolve(dir);
94
+ const p = path.resolve(root, name);
95
+ if (p !== path.join(root, name)) return null;
96
+ if (!p.startsWith(root + path.sep)) return null;
97
+ return p;
98
+ }
99
+
100
+ // PURE — the content-type to serve a stored file with (from its extension).
101
+ function contentTypeForName(name) {
102
+ const ext = String(name).split('.').pop().toLowerCase();
103
+ return TYPE_BY_EXT[ext] || 'application/octet-stream';
104
+ }
105
+
106
+ // PURE — confirm the bytes really are the claimed image format (magic bytes).
107
+ function sniffMatches(buf, ext) {
108
+ if (!buf || buf.length < 12) return false;
109
+ switch (ext) {
110
+ case 'png':
111
+ return buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
112
+ case 'jpg':
113
+ return buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
114
+ case 'gif':
115
+ return buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38;
116
+ case 'webp':
117
+ return (
118
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && // 'RIFF'
119
+ buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 // 'WEBP'
120
+ );
121
+ default:
122
+ return false;
123
+ }
124
+ }
125
+
126
+ // PURE — screen one uploaded buffer against the declared content-type. Returns
127
+ // { ok:true, ext } or { ok:false, reason } — never throws. Exported separately
128
+ // from the write so every refusal is pinned by a DB-free, fs-free unit test.
129
+ function screenImage(buf, contentType) {
130
+ const rawType = String(contentType || '').split(';')[0].trim().toLowerCase();
131
+ const ext = EXT_BY_TYPE[rawType];
132
+ if (!ext) return { ok: false, reason: 'bad_type', type: rawType };
133
+ if (!buf || buf.length === 0) return { ok: false, reason: 'empty' };
134
+ if (buf.length > MAX_BYTES) return { ok: false, reason: 'too_big', bytes: buf.length };
135
+ if (!sniffMatches(buf, ext)) return { ok: false, reason: 'sniff_mismatch' };
136
+ return { ok: true, ext };
137
+ }
138
+
139
+ // PURE — THE AUDIENCE PREDICATE. A visual has no audience of its own: it is the
140
+ // same claim as the task's value_summary in another medium, so it is viewable by
141
+ // whoever may already read that summary. Public work is `shipped`|`confirmed` —
142
+ // byte-for-byte the gate src/bongos/routes/public.js:projectPublicTask uses to
143
+ // decide whether a title/value_summary reaches the open web.
144
+ //
145
+ // The ONE deliberate narrowing: a security_sensitive task's image stays
146
+ // builders-only even once shipped. A summary line is a sentence its author chose;
147
+ // a screenshot shows whatever was on the screen, and on a security task that is
148
+ // the wrong thing to publish by default. Anything not public here is still served
149
+ // to any authenticated builder, so nothing is HIDDEN by this — only un-published.
150
+ //
151
+ // MIRRORED IN SQL: modules/lifecycle/db.js:recentShippedTasks nulls visual_url for
152
+ // a security_sensitive row on the public feed, so the feed and this gate agree
153
+ // (the feed never learns the flag itself). Change one, change the other.
154
+ function isPubliclyViewable(task) {
155
+ if (!task) return false;
156
+ if (task.security_sensitive === true) return false;
157
+ return task.status === 'shipped' || task.status === 'confirmed';
158
+ }
159
+
160
+ // PURE — the public URL a stored file is served at. One definition, so the write
161
+ // path and the docs cannot drift from the route.
162
+ //
163
+ // The CANONICAL prefix (src/bongos/api-prefix.js) is hard-coded rather than
164
+ // imported: this lib is deliberately core-free so it unit-tests with a Buffer and
165
+ // a temp dir, and the doorway does not publish the prefix. It is safe to hard-code
166
+ // because BOTH prefixes are permanent — '/api/gds' is a forever alias for shipped
167
+ // Dev Box binaries and the off-box status mirror — so a URL stored under either
168
+ // keeps resolving, and every renderer's guard accepts both.
169
+ const SERVE_PREFIX = '/api/bongos/task-visuals';
170
+ function urlForName(name) {
171
+ return `${SERVE_PREFIX}/${name}`;
172
+ }
173
+
174
+ // PURE — normalize the caption/alt text: trimmed, length-bounded, newlines
175
+ // flattened (it renders into an `alt` attribute). Empty → null.
176
+ function normalizeAlt(alt) {
177
+ const s = String(alt == null ? '' : alt).replace(/\s+/g, ' ').trim().slice(0, MAX_ALT_LENGTH);
178
+ return s || null;
179
+ }
180
+
181
+ // Read one image OFF DISK and screen it — the CLI intake (`ship.js --visual`,
182
+ // `bongos task visual`). It lives here, beside the screening it wraps, so the two
183
+ // CLI call sites cannot drift from each other or from the upload route: what a
184
+ // terminal accepts is exactly what the server accepts. Returns
185
+ // { ok:true, buf, contentType } or { ok:false, message } — the message is written
186
+ // for a person reading a terminal, not for a log.
187
+ function readImageFile(file) {
188
+ const resolved = path.resolve(String(file || ''));
189
+ let buf;
190
+ try {
191
+ if (!fsSync.statSync(resolved).isFile()) return { ok: false, message: 'that path is not a file.' };
192
+ buf = fsSync.readFileSync(resolved);
193
+ } catch (err) {
194
+ if (err && err.code === 'ENOENT') return { ok: false, message: 'no such file.' };
195
+ return { ok: false, message: err && err.message ? err.message : 'could not be read.' };
196
+ }
197
+ const rawExt = path.extname(resolved).slice(1).toLowerCase();
198
+ const ext = rawExt === 'jpeg' ? 'jpg' : rawExt;
199
+ const contentType = TYPE_BY_EXT[ext];
200
+ if (!contentType) {
201
+ return { ok: false, message: `unsupported file type "${rawExt || '(none)'}". Accepted: ${Object.keys(TYPE_BY_EXT).join(', ')}.` };
202
+ }
203
+ const screened = screenImage(buf, contentType);
204
+ if (!screened.ok) {
205
+ const why = {
206
+ too_big: `it is ${Math.round(buf.length / 1024)} KB — the cap is ${Math.round(MAX_BYTES / 1024 / 1024)} MB.`,
207
+ empty: 'the file is empty.',
208
+ sniff_mismatch: `the bytes are not really a ${ext} image (the extension and the contents disagree).`,
209
+ bad_type: `unsupported file type "${rawExt}".`,
210
+ }[screened.reason] || `it was refused (${screened.reason}).`;
211
+ return { ok: false, message: why };
212
+ }
213
+ return { ok: true, buf, contentType };
214
+ }
215
+
216
+ // Screen + write one uploaded image. Returns { ok:true, file, url, bytes } or
217
+ // { ok:false, reason } on a refusal (never throws on a refusal; an unexpected fs
218
+ // error propagates for the caller to wrap).
219
+ async function persistImageBuffer(buf, { taskId, contentType, dir = visualsDir() } = {}) {
220
+ const id = Number(taskId);
221
+ if (!Number.isInteger(id) || id <= 0 || String(id).length > 12) {
222
+ return { ok: false, reason: 'bad_task_id' };
223
+ }
224
+ const screened = screenImage(buf, contentType);
225
+ if (!screened.ok) return screened;
226
+ const file = `task-${id}-${crypto.randomBytes(8).toString('hex')}.${screened.ext}`;
227
+ await fs.mkdir(dir, { recursive: true });
228
+ await fs.writeFile(path.join(dir, file), buf, { mode: 0o640 });
229
+ return { ok: true, file, url: urlForName(file), bytes: buf.length };
230
+ }
231
+
232
+ // Best-effort delete of a stored file, by name or by its served URL. Used when a
233
+ // visual is REPLACED or removed, so a superseded image doesn't linger on disk.
234
+ // Never throws — an already-absent file is a success.
235
+ async function deleteStored(nameOrUrl, { dir = visualsDir() } = {}) {
236
+ const name = String(nameOrUrl == null ? '' : nameOrUrl).split('/').pop();
237
+ const p = resolveSafe(name, { dir });
238
+ if (!p) return false;
239
+ try {
240
+ await fs.unlink(p);
241
+ return true;
242
+ } catch (err) {
243
+ // An already-absent file IS the desired state. Any other failure leaves an
244
+ // unreferenced file on disk, which is inert — the caller is best-effort and
245
+ // the boolean is the whole report, so this stays a silent, logger-free lib.
246
+ if (err && err.code === 'ENOENT') return true;
247
+ return false;
248
+ }
249
+ }
250
+
251
+ module.exports = {
252
+ EXT_BY_TYPE,
253
+ TYPE_BY_EXT,
254
+ MAX_BYTES,
255
+ MAX_ALT_LENGTH,
256
+ visualsDir,
257
+ isSafeName,
258
+ taskIdFromName,
259
+ resolveSafe,
260
+ contentTypeForName,
261
+ sniffMatches,
262
+ screenImage,
263
+ readImageFile,
264
+ normalizeAlt,
265
+ urlForName,
266
+ SERVE_PREFIX,
267
+ isPubliclyViewable,
268
+ persistImageBuffer,
269
+ deleteStored,
270
+ };
@@ -912,6 +912,32 @@ let _shippedPage = 0;
912
912
  // in index.html is also pre-selected to "30" so the two stay aligned.
913
913
  let _shippedRange = '30';
914
914
 
915
+ // task 1003109: build the thumbnail node for one shipped row, or null when the
916
+ // row has no visual. Built with DOM nodes (not innerHTML) like the rest of this
917
+ // file, and the URL is matched against the exact shape the server mints before it
918
+ // reaches an href/src — this page renders UNAUTHENTICATED data on a public host,
919
+ // so it treats its own API's strings as data, not as markup.
920
+ const VISUAL_URL_RE = /^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
921
+ function shippedVisualEl(t) {
922
+ const url = String((t && t.visual_url) || '');
923
+ if (!VISUAL_URL_RE.test(url)) return null;
924
+ const alt = String((t && t.visual_alt) || `Visual for task ${(t && t.id) || ''}`).trim();
925
+ const a = document.createElement('a');
926
+ // `API` is '' on the droplet (same-origin) and the droplet's origin on the
927
+ // off-box GitHub Pages mirror — the same prefix every fetch on this page uses.
928
+ a.href = `${API}${url}`;
929
+ a.target = '_blank';
930
+ a.rel = 'noopener';
931
+ a.title = alt;
932
+ const img = document.createElement('img');
933
+ img.className = 'shipped__visual';
934
+ img.src = `${API}${url}`;
935
+ img.alt = alt;
936
+ img.loading = 'lazy';
937
+ a.appendChild(img);
938
+ return a;
939
+ }
940
+
915
941
  function renderShippedPage() {
916
942
  clear(els.shipped);
917
943
  const items = _shippedItems;
@@ -949,11 +975,18 @@ function renderShippedPage() {
949
975
  ver.textContent = t.version_id;
950
976
  title.appendChild(ver);
951
977
  title.append(t.value_summary || t.title);
978
+ // task 1003109 (goal 1000072): the optional ship-time VISUAL, beside the
979
+ // summary it restates. This surface is the whole reason the feature exists —
980
+ // an unauthenticated reader who does not read code, seeing what shipped. Only
981
+ // already-public, non-security-sensitive work carries one here (the server
982
+ // nulls the rest out of this feed), and a task without one draws nothing.
983
+ const thumb = shippedVisualEl(t);
952
984
  const meta = document.createElement('p');
953
985
  meta.className = 'shipped__meta';
954
986
  const by = t.display_name || t.github_login;
955
987
  meta.textContent = `${by ? `inscribed by ${by} · ` : ''}${fmtRelativeTime(t.shipped_at)}`;
956
988
  li.appendChild(title);
989
+ if (thumb) li.appendChild(thumb);
957
990
  li.appendChild(meta);
958
991
  els.shipped.appendChild(li);
959
992
  }
@@ -598,6 +598,20 @@ main {
598
598
  margin: 0;
599
599
  }
600
600
 
601
+ /* task 1003109: the optional ship-time visual on a public shipped row. Small on
602
+ purpose — this is a scannable feed, and the thumbnail links to the full-size
603
+ image for anyone who wants to look properly. Absent on most rows; nothing is
604
+ drawn when a task has no visual. */
605
+ .shipped__visual {
606
+ display: block;
607
+ width: 40px;
608
+ height: 40px;
609
+ object-fit: cover;
610
+ margin: 0 0 6px;
611
+ border: 1px solid var(--rule, rgba(0, 0, 0, 0.15));
612
+ border-radius: 3px;
613
+ }
614
+
601
615
  .shipped__version {
602
616
  font-family: var(--font-display);
603
617
  font-weight: 500;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.349",
3
+ "version": "1.19.350",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.349",
9
+ "version": "1.19.350",
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.349",
3
+ "version": "1.19.350",
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",