@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.
- package/.bongos-core.json +82 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0184-a-task-visual-inherits-its-summary-audience.md +49 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +208 -3
- package/docs/api-reference.md +10 -2
- package/docs/architecture.md +6 -1
- package/docs/copy-inventory.md +38 -38
- package/docs/copy-registry.json +40 -40
- package/docs/file-map.md +2 -1
- package/migrations/core_223_task_visual.sql +33 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/hall-ui/public/hall-kit.js +33 -0
- package/modules/hall-ui/public/hall-render.js +1 -0
- package/modules/hall-ui/public/profile.html +4 -1
- package/modules/hall-ui/public/profile.js +7 -1
- package/modules/hall-ui/public/style.css +29 -0
- package/modules/hall-ui/public/task.js +30 -0
- package/modules/lifecycle/db-analytics.js +3 -1
- package/modules/lifecycle/github-push.js +51 -4
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/tasks.js +22 -2
- package/modules/lifecycle/routes/visuals.js +216 -0
- package/modules/lifecycle/task-visual-db.js +46 -0
- package/modules/lifecycle/task-visuals.js +270 -0
- package/modules/status-ui/public/status.js +33 -0
- package/modules/status-ui/public/style.css +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship-flow.js +11 -1
- package/scripts/gds/ship-land.js +24 -2
- package/scripts/gds/ship-visual.js +68 -0
- package/scripts/gds/task.js +62 -0
- package/src/bongos/routes/public.js +8 -0
- package/src/bongos/routes.js +5 -1
- package/src/module-api.js +5 -1
- package/tests/ship_publish_error.mjs +86 -2
- package/tests/task_visual_route_gate.mjs +151 -0
- 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
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|