@bongos/core 1.19.348 → 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 (43) hide show
  1. package/.bongos-core.json +82 -42
  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/github-push.js +51 -4
  25. package/modules/lifecycle/module.json +2 -1
  26. package/modules/lifecycle/routes/tasks.js +22 -2
  27. package/modules/lifecycle/routes/visuals.js +216 -0
  28. package/modules/lifecycle/task-visual-db.js +46 -0
  29. package/modules/lifecycle/task-visuals.js +270 -0
  30. package/modules/status-ui/public/status.js +33 -0
  31. package/modules/status-ui/public/style.css +14 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/scripts/gds/ship-flow.js +11 -1
  35. package/scripts/gds/ship-land.js +24 -2
  36. package/scripts/gds/ship-visual.js +68 -0
  37. package/scripts/gds/task.js +62 -0
  38. package/src/bongos/routes/public.js +8 -0
  39. package/src/bongos/routes.js +5 -1
  40. package/src/module-api.js +5 -1
  41. package/tests/ship_publish_error.mjs +86 -2
  42. package/tests/task_visual_route_gate.mjs +151 -0
  43. package/tests/task_visuals.mjs +200 -0
@@ -130,6 +130,23 @@ function scrubCredential(s) {
130
130
  .replace(/(ghs_|ghp_|github_pat_)[A-Za-z0-9_]+/g, '$1***');
131
131
  }
132
132
 
133
+ // How much scrubbed stderr travels with a failure (task 1003222). Sized to hold a
134
+ // real git failure whole — the 'remote:' lines that name the cause arrive LAST, so a
135
+ // short cap deletes precisely the part worth reading. It is a payload bound, nothing
136
+ // more; when it bites, truncateLoudly says so rather than ending mid-word.
137
+ const STDERR_MAX = 8000;
138
+
139
+ // Truncate in a way the reader can SEE. A silent clip is worse than no message: it
140
+ // looks like the complete text, which is how 'error: failed' got mistaken for the
141
+ // whole story. Keeps the TAIL as well as the head, because git's useful line is last.
142
+ function truncateLoudly(text, max = STDERR_MAX) {
143
+ const s = String(text || '');
144
+ if (s.length <= max) return s;
145
+ const head = Math.floor(max * 0.4);
146
+ const tail = max - head;
147
+ return `${s.slice(0, head)}\n\n…[${s.length - max} characters omitted — full text in the server log]…\n\n${s.slice(-tail)}`;
148
+ }
149
+
133
150
  // Default git seam: execFile arg-array, shell:false, stdout captured. On failure
134
151
  // throws GIT_FAILED. We never echo the (possibly credentialed) argv, but DO attach
135
152
  // the credential-scrubbed stderr + the caller's step label (task 1547) so a real
@@ -149,9 +166,30 @@ async function defaultExec(args, opts = {}) {
149
166
  return stdout;
150
167
  } catch (e) {
151
168
  // Identify the failing step by the caller's label, never by echoing argv.
152
- // Surface the scrubbed stderr (truncated) so the cause is loud in the log.
153
- const stderr = scrubCredential(e && (e.stderr || e.message)).trim().slice(0, 500);
154
- throw err('GIT_FAILED', { git_step: step || (args && args[0]) || null, stderr });
169
+ //
170
+ // THE STDERR IS NOT CLIPPED SHORT ANY MORE (task 1003222). It used to be cut at
171
+ // 500 chars here and again at 200 on the way to the client, so a builder saw
172
+ // 'error: failed' — clipped mid-word, naming neither cause nor remedy — while the
173
+ // line that mattered ('remote: fatal error in commit_refs') and the exit code
174
+ // existed only server-side. That cost hours on the blocker-1000117 outage AND
175
+ // produced a WRONG first diagnosis, because the real message was never in the
176
+ // room. git puts the useful line LAST (the 'remote:' lines follow the progress
177
+ // chatter), which is exactly what a head-truncation throws away.
178
+ //
179
+ // The cap is a payload guard, not an editorial decision: generous enough that a
180
+ // real push failure fits whole, and when it does bite the text SAYS SO instead of
181
+ // ending mid-word and reading like the whole message.
182
+ const raw = scrubCredential(e && (e.stderr || e.message)).trim();
183
+ const stderr = truncateLoudly(raw, STDERR_MAX);
184
+ throw err('GIT_FAILED', {
185
+ git_step: step || (args && args[0]) || null,
186
+ stderr,
187
+ stderr_bytes: raw.length,
188
+ // The exit code distinguishes failure SHAPES that read identically in prose —
189
+ // 128 (git said no) vs 1 (a ref was rejected) vs null (killed by the timeout).
190
+ exit_code: (e && typeof e.code === 'number') ? e.code : null,
191
+ timed_out: !!(e && e.killed),
192
+ });
155
193
  }
156
194
  }
157
195
 
@@ -456,7 +494,14 @@ async function openOrFindPullRequest({ branch, title, body }, deps = {}) {
456
494
  });
457
495
  if (!createRes.ok) {
458
496
  const detail = await createRes.text().catch(() => '');
459
- throw err('PR_CREATE_FAILED', { status: createRes.status, detail: detail.slice(0, 300) });
497
+ // Same reasoning as GIT_FAILED's stderr (task 1003222): GitHub's refusal body
498
+ // names the actual rule that refused ('No commits between main and X', a
499
+ // protected-branch rule, a validation error naming the field) and a 300-char
500
+ // clip regularly cut it off. Scrubbed, because a body can echo a request header.
501
+ throw err('PR_CREATE_FAILED', {
502
+ status: createRes.status,
503
+ detail: truncateLoudly(scrubCredential(detail), STDERR_MAX),
504
+ });
460
505
  }
461
506
  const pr = await createRes.json();
462
507
  return { number: pr.number, url: pr.html_url, node_id: pr.node_id };
@@ -1360,6 +1405,8 @@ module.exports = {
1360
1405
  prReferencesTask,
1361
1406
  findTaskPullRequest,
1362
1407
  scrubCredential,
1408
+ truncateLoudly, // task 1003222 — exported so its behaviour is testable, not just its effect
1409
+ STDERR_MAX,
1363
1410
  isAncestorOfDefaultBranch,
1364
1411
  resolveToken,
1365
1412
  getInstallationToken,
@@ -18,7 +18,8 @@
18
18
  "gate-approvals",
19
19
  "analytics",
20
20
  "lifecycle",
21
- "help-requests"
21
+ "help-requests",
22
+ "visuals"
22
23
  ],
23
24
  "pollers": [
24
25
  "publish-reconciler"
@@ -979,7 +979,12 @@ module.exports = function buildTasksRouter() {
979
979
  if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
980
980
  if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
981
981
  if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
982
- if (e.code === 'PR_CREATE_FAILED') return res.fail('pr_create_failed', 502, { detail: e.detail });
982
+ // The GitHub HTTP status travels WITH the body (task 1003222). 422 ('no commits
983
+ // between'), 403 (a protected-branch rule) and 404 (the App cannot see the repo)
984
+ // are three different remedies, and the body alone does not always separate them.
985
+ if (e.code === 'PR_CREATE_FAILED') {
986
+ return res.fail('pr_create_failed', 502, { detail: e.detail, github_status: e.status ?? null });
987
+ }
983
988
  // task 1547: GIT_FAILED now carries WHICH git step failed + the credential-
984
989
  // scrubbed stderr (github-push.defaultExec). Log both so the next push outage
985
990
  // is a one-line diagnosis, not a multi-hour opaque-`publish_failed` mystery
@@ -990,7 +995,22 @@ module.exports = function buildTasksRouter() {
990
995
  e.git_step ? `step=${e.git_step}` : '',
991
996
  e.stderr ? `stderr=${e.stderr}` : ''
992
997
  );
993
- return res.fail('publish_failed', 500, { ...(e.git_step ? { step: e.git_step } : {}), ...(e.stderr ? { reason: String(e.stderr).slice(0, 200) } : {}) });
998
+ // NO SECOND TRUNCATION (task 1003222). This line used to clip the already-
999
+ // shortened stderr to 200 chars, so the builder got 'error: failed' — cut
1000
+ // mid-word, naming neither cause nor remedy — while the real remote message sat
1001
+ // in a log they could not reach. A publish failure must be diagnosable from
1002
+ // where the builder is STANDING; that is the whole point of the task.
1003
+ //
1004
+ // `reason` keeps its name and position so existing readers (ship.js prints it)
1005
+ // keep working; it is simply no longer amputated. Everything here is already
1006
+ // credential-scrubbed at the throw site by github-push.scrubCredential.
1007
+ return res.fail('publish_failed', 500, {
1008
+ ...(e.git_step ? { step: e.git_step } : {}),
1009
+ ...(e.stderr ? { reason: e.stderr } : {}),
1010
+ ...(e.stderr_bytes != null ? { reason_bytes: e.stderr_bytes } : {}),
1011
+ ...(e.exit_code != null ? { git_exit_code: e.exit_code } : {}),
1012
+ ...(e.timed_out ? { timed_out: true } : {}),
1013
+ });
994
1014
  } finally {
995
1015
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
996
1016
  }
@@ -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 };