@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.
- package/.bongos-core.json +78 -38
- 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/module.json +2 -1
- 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-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 +3 -1
- package/tests/task_visual_route_gate.mjs +151 -0
- package/tests/task_visuals.mjs +200 -0
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -15,6 +15,8 @@ const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-m
|
|
|
15
15
|
const { matchOne } = require('../../src/bongos/path-match');
|
|
16
16
|
const { runShipGate } = require('./sandbox-stage');
|
|
17
17
|
const { captureSurpriseLearning, promptSurprise } = require('./ship-surprise.js');
|
|
18
|
+
// task 1003109: the optional `--visual` leg of a ship (goal 1000072).
|
|
19
|
+
const shipVisual = require('./ship-visual.js');
|
|
18
20
|
const { gitOk } = require('./ship-git.js');
|
|
19
21
|
const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight } = require('./ship-preflight-steps.js');
|
|
20
22
|
const { applyBaseOverride, computeChangedFiles, computeGraderBaseline, runSmokeAsync } = require('./ship-grade.js');
|
|
@@ -35,7 +37,7 @@ async function shipMain() {
|
|
|
35
37
|
const args = process.argv.slice(2);
|
|
36
38
|
const taskId = Number(args[0]);
|
|
37
39
|
if (!Number.isFinite(taskId)) {
|
|
38
|
-
console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
|
|
40
|
+
console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--visual <image>] [--visual-alt "caption"] [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
|
|
39
41
|
console.error(' prefer the -file forms for prose: a shell command-substitutes backticks in an inline value before node sees it (learning 1000197).');
|
|
40
42
|
process.exit(2);
|
|
41
43
|
}
|
|
@@ -94,6 +96,10 @@ async function shipMain() {
|
|
|
94
96
|
console.log(`--base: grading ${res.sha.slice(0, 9)}...HEAD — the task's real committed diff (work already on main). Graded via the rank-open path; no --allow-empty needed.`);
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// task 1003109: screen --visual BEFORE any heavy work, so bad input aborts with
|
|
100
|
+
// the claim STILL ACTIVE (the --base posture above). null when none was asked for.
|
|
101
|
+
const visual = shipVisual.fromArgs(args);
|
|
102
|
+
|
|
97
103
|
const me = noteServerReward(await (await cliClient()).me.getMe());
|
|
98
104
|
if (!me.ok) {
|
|
99
105
|
console.error('failed to load profile:', me.data);
|
|
@@ -456,6 +462,10 @@ async function shipMain() {
|
|
|
456
462
|
|
|
457
463
|
const taskStatus = r.data.taskStatus || 'shipped (legacy response)';
|
|
458
464
|
console.log(`Task #${r.data.taskId} is now: ${taskStatus}`);
|
|
465
|
+
// task 1003109: attach the optional visual now the claim has resolved (the upload
|
|
466
|
+
// gate is "the task's last claim holder" — still us). Best-effort: it prints its
|
|
467
|
+
// own warning, and a failed picture never fails a successful ship.
|
|
468
|
+
await shipVisual.upload({ taskId, visual, client: await cliClient(), errorLine: apiErrorLine });
|
|
459
469
|
// Claim CONSUMED, task at 'completed' — a crash from here needs fatalRecoveryLines.
|
|
460
470
|
markShipProgress('resolved', taskId);
|
|
461
471
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/ship-visual.js — the `--visual` leg of a ship (task 1003109, goal
|
|
4
|
+
// 1000072 "Human legibility").
|
|
5
|
+
//
|
|
6
|
+
// A shipped task can carry an optional IMAGE alongside its value summary: a
|
|
7
|
+
// screenshot of the surface that changed, a before/after, a diagram — so a reader
|
|
8
|
+
// who does not read code can SEE what shipped instead of reading about it (owner
|
|
9
|
+
// idea 1000400). ship.js owns the ship; this file owns the picture.
|
|
10
|
+
//
|
|
11
|
+
// Its own file, not another 60 lines inside the 4,600-line ship.js, because the
|
|
12
|
+
// size ratchet (fitness-ratchets.js, task 1003129) is shrink-only — and because
|
|
13
|
+
// the two things really are separable: the ship must not care whether a visual
|
|
14
|
+
// came along.
|
|
15
|
+
//
|
|
16
|
+
// THE ONE RULE HERE: a visual never costs a builder their ship. Bad input is
|
|
17
|
+
// caught BEFORE the claim resolves (a usage error, claim intact); a failed upload
|
|
18
|
+
// happens AFTER it and is a warning, never a failure.
|
|
19
|
+
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { arg } = require('./cli-lib');
|
|
22
|
+
// Reading + screening is the SERVER's own lib, so a terminal refuses exactly what
|
|
23
|
+
// the upload route would refuse.
|
|
24
|
+
const taskVisuals = require('../../modules/lifecycle/task-visuals');
|
|
25
|
+
|
|
26
|
+
// Parse + screen `--visual <file> [--visual-alt "caption"]` from an argv slice.
|
|
27
|
+
// Returns null when no visual was asked for, or { buf, contentType, alt, name }.
|
|
28
|
+
// EXITS (code 2) on bad input: this runs before any heavy work, so a wrong path
|
|
29
|
+
// costs the builder a retyped command, not a claim. Pure enough to reason about;
|
|
30
|
+
// the exit is the deliberate CLI edge.
|
|
31
|
+
function fromArgs(args) {
|
|
32
|
+
const file = arg('--visual', args) || '';
|
|
33
|
+
if (!file) return null;
|
|
34
|
+
const read = taskVisuals.readImageFile(file);
|
|
35
|
+
if (!read.ok) {
|
|
36
|
+
console.error(`Ship aborted: --visual ${file} — ${read.message}`);
|
|
37
|
+
console.error('Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const visual = { ...read, alt: arg('--visual-alt', args) || '', name: path.basename(file) };
|
|
41
|
+
console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — uploaded after the claim resolves.`);
|
|
42
|
+
return visual;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Upload the screened visual to the task. BEST-EFFORT BY DESIGN: called AFTER the
|
|
46
|
+
// claim has resolved, so a failure here must never fail the ship — the work
|
|
47
|
+
// landed, only the picture did not. Returns true when it stuck; prints a
|
|
48
|
+
// one-line, actionable warning and returns false when it did not.
|
|
49
|
+
async function upload({ taskId, visual, client, errorLine }) {
|
|
50
|
+
if (!visual) return false;
|
|
51
|
+
const r = await client.tasks.postTasksIdVisual({
|
|
52
|
+
id: taskId,
|
|
53
|
+
body: {
|
|
54
|
+
image_b64: visual.buf.toString('base64'),
|
|
55
|
+
content_type: visual.contentType,
|
|
56
|
+
...(visual.alt ? { alt: visual.alt } : {}),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
if (r.ok) {
|
|
60
|
+
console.log("Visual attached — it renders wherever this task's value summary does.");
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
console.warn(`Note: the visual did not upload (${errorLine(r.data, r.status)}). The ship is unaffected — re-attach any time with:`);
|
|
64
|
+
console.warn(` node scripts/gds/task.js visual ${taskId} <image> [--alt "caption"]`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { fromArgs, upload };
|
package/scripts/gds/task.js
CHANGED
|
@@ -11,11 +11,16 @@
|
|
|
11
11
|
// [--priority 3] [--version <id>] [--goal <id>] [--criteria C1,C2]
|
|
12
12
|
// [--touches a/,b/] [--est-minutes N] [--credits N] [--ready] [--json]
|
|
13
13
|
// bongos task show <id> [--json]
|
|
14
|
+
// bongos task visual <id> <image> [--alt "caption"] attach/replace the ship-time visual
|
|
15
|
+
// bongos task visual <id> --remove take it back off
|
|
14
16
|
//
|
|
15
17
|
// Task CREATION is Archon-gated SERVER-SIDE (creating work shapes scope, ADR 0016). This
|
|
16
18
|
// CLI adds no authority — a lower rank gets a clear 403 message pointing at idea capture.
|
|
17
19
|
|
|
18
20
|
const { cliClient, requireSession, arg, hasFlag } = require('./cli-lib');
|
|
21
|
+
// task 1003109: the ship-time VISUAL. The CLI screens the file with the SERVER's
|
|
22
|
+
// own constants, so what it accepts is exactly what the upload route accepts.
|
|
23
|
+
const taskVisuals = require('../../modules/lifecycle/task-visuals');
|
|
19
24
|
|
|
20
25
|
// Valid enums (mirrors the server's task-classifier vocab; a friendly client-side check
|
|
21
26
|
// so an obvious typo fails before the round-trip — the server stays authoritative).
|
|
@@ -77,6 +82,8 @@ function usage() {
|
|
|
77
82
|
console.error(' [--priority 3] [--version <id>] [--goal <id>] [--criteria C1,C2]');
|
|
78
83
|
console.error(' [--touches a/,b/] [--est-minutes N] [--credits N] [--ready] [--json]');
|
|
79
84
|
console.error(' bongos task show <id> [--json]');
|
|
85
|
+
console.error(' bongos task visual <id> <image> [--alt "caption"] attach or replace the ship-time visual');
|
|
86
|
+
console.error(' bongos task visual <id> --remove remove it again');
|
|
80
87
|
console.error(`\n kinds: ${VALID_KINDS.join(' | ')}`);
|
|
81
88
|
console.error(` disciplines: ${VALID_DISCIPLINES.join(' | ')}`);
|
|
82
89
|
}
|
|
@@ -149,6 +156,60 @@ async function cmdShow(args) {
|
|
|
149
156
|
if (task.description) console.log(`\n${task.description}`);
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
|
|
160
|
+
// ---- bongos task visual (task 1003109, goal 1000072) ------------------------
|
|
161
|
+
//
|
|
162
|
+
// A shipped task can carry a VISUAL alongside its value summary — a screenshot of
|
|
163
|
+
// the surface that changed, a before/after, a diagram — so a reader who does not
|
|
164
|
+
// read code can SEE what changed. `ship.js --visual` is the usual path; this is
|
|
165
|
+
// the after-the-fact one: attach the picture you forgot, replace the wrong one,
|
|
166
|
+
// or take it back off. Same server gate either way — the task's claim holder (or
|
|
167
|
+
// an Archon). Always optional: no task needs one.
|
|
168
|
+
async function cmdVisual(args) {
|
|
169
|
+
const id = Number(args[0]);
|
|
170
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
171
|
+
console.error('usage: bongos task visual <id> <image> [--alt "caption"]');
|
|
172
|
+
console.error(' bongos task visual <id> --remove');
|
|
173
|
+
process.exit(2);
|
|
174
|
+
}
|
|
175
|
+
const api = await cliClient();
|
|
176
|
+
|
|
177
|
+
if (hasFlag('--remove', args)) {
|
|
178
|
+
const r = await api.tasks.deleteTasksIdVisual({ id });
|
|
179
|
+
if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
|
|
180
|
+
if (!r.ok) { console.error(`visual remove failed (${r.status}):`, r.data); process.exit(1); }
|
|
181
|
+
console.log(`✓ Task #${id} no longer carries a visual.`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The image is POSITIONAL — `visual <id> <image>`. Deliberately not "the first
|
|
186
|
+
// arg that isn't a flag": with `--alt "a caption" shot.png` that heuristic
|
|
187
|
+
// picks up the caption as the filename.
|
|
188
|
+
const file = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
189
|
+
if (!file) {
|
|
190
|
+
console.error('usage: bongos task visual <id> <image> [--alt "caption"]');
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
// Reading + screening is the SERVER's own lib (shared with ship.js --visual),
|
|
194
|
+
// so a terminal refuses exactly what the upload route would refuse.
|
|
195
|
+
const visual = taskVisuals.readImageFile(file);
|
|
196
|
+
if (!visual.ok) {
|
|
197
|
+
console.error(`${file} — ${visual.message}`);
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const alt = arg('--alt', args) || '';
|
|
202
|
+
const r = await api.tasks.postTasksIdVisual({
|
|
203
|
+
id,
|
|
204
|
+
body: { image_b64: visual.buf.toString('base64'), content_type: visual.contentType, ...(alt ? { alt } : {}) },
|
|
205
|
+
});
|
|
206
|
+
if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
|
|
207
|
+
if (r.status === 403) { console.error(`Only task #${id}'s claim holder (or an Archon) may attach its visual.`); process.exit(1); }
|
|
208
|
+
if (!r.ok) { console.error(`visual upload failed (${r.status}):`, r.data); process.exit(1); }
|
|
209
|
+
console.log(`✓ Task #${id} now carries a visual (${Math.round(visual.buf.length / 1024)} KB).`);
|
|
210
|
+
console.log(' It renders wherever this task\'s value summary does.');
|
|
211
|
+
}
|
|
212
|
+
|
|
152
213
|
async function main() {
|
|
153
214
|
const args = process.argv.slice(2);
|
|
154
215
|
const sub = args[0];
|
|
@@ -156,6 +217,7 @@ async function main() {
|
|
|
156
217
|
await requireSession();
|
|
157
218
|
if (sub === 'new') return cmdNew(args.slice(1));
|
|
158
219
|
if (sub === 'show') return cmdShow(args.slice(1));
|
|
220
|
+
if (sub === 'visual') return cmdVisual(args.slice(1));
|
|
159
221
|
console.error(`task: unknown subcommand "${sub}"\n`); usage(); process.exit(2);
|
|
160
222
|
}
|
|
161
223
|
|
|
@@ -43,6 +43,14 @@ function projectPublicTask(task) {
|
|
|
43
43
|
payload.title = task.title;
|
|
44
44
|
payload.value_summary = task.value_summary ?? null;
|
|
45
45
|
payload.shipped_at = task.shipped_at ?? null;
|
|
46
|
+
// task 1003109: the ship-time VISUAL travels with the summary it restates —
|
|
47
|
+
// one claim, one audience, in both media. The extra `security_sensitive`
|
|
48
|
+
// narrowing is the serve route's own gate (task-visuals.isPubliclyViewable);
|
|
49
|
+
// publishing a URL this projection would then refuse to serve would be a
|
|
50
|
+
// broken image, so the two agree here.
|
|
51
|
+
const visualIsPublic = task.security_sensitive !== true;
|
|
52
|
+
payload.visual_url = visualIsPublic ? (task.visual_url ?? null) : null;
|
|
53
|
+
payload.visual_alt = visualIsPublic ? (task.visual_alt ?? null) : null;
|
|
46
54
|
}
|
|
47
55
|
return payload;
|
|
48
56
|
}
|
package/src/bongos/routes.js
CHANGED
|
@@ -86,7 +86,11 @@ function buildGdsRouter() {
|
|
|
86
86
|
req.path === '/memory/sync' ||
|
|
87
87
|
req.path === '/sessions' ||
|
|
88
88
|
req.path === '/llm-cache/store' ||
|
|
89
|
-
/^\/tasks\/\d+\/publish-branch$/.test(req.path)
|
|
89
|
+
/^\/tasks\/\d+\/publish-branch$/.test(req.path) ||
|
|
90
|
+
// task 1003109: the ship-time task visual — a base64 image up to ~5.3 MB
|
|
91
|
+
// encoded (4 MB decoded). Same dynamic-:id pattern as publish-branch above;
|
|
92
|
+
// modules/lifecycle/routes/task-visuals.js mounts its own 8 MB parser.
|
|
93
|
+
/^\/tasks\/\d+\/visual$/.test(req.path)
|
|
90
94
|
) return next();
|
|
91
95
|
return globalJson(req, res, next);
|
|
92
96
|
});
|
package/src/module-api.js
CHANGED
|
@@ -1128,8 +1128,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1128
1128
|
// landed since 1.19.347 with no explicit bump. run 32802746363. (task 1002620)
|
|
1129
1129
|
// 1.19.349 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1130
1130
|
// landed since 1.19.348 with no explicit bump. run 32803276871. (task 1002620)
|
|
1131
|
+
// 1.19.350 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1132
|
+
// landed since 1.19.349 with no explicit bump. run 32810944115. (task 1002620)
|
|
1131
1133
|
// ---------------------------------------------------------------------------
|
|
1132
|
-
const CORE_VERSION = '1.19.
|
|
1134
|
+
const CORE_VERSION = '1.19.350'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1133
1135
|
|
|
1134
1136
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1135
1137
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// tests/task_visual_route_gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// Route-level proof of the ONE rule behind ship-time task visuals (task 1003109,
|
|
4
|
+
// ADR 0184): a visual has no audience of its own — it inherits the audience of the
|
|
5
|
+
// value_summary it restates. Already-public work (shipped|confirmed, not
|
|
6
|
+
// security_sensitive) serves unauthenticated; EVERYTHING else falls through to
|
|
7
|
+
// auth.requireBuilder and never past it.
|
|
8
|
+
//
|
|
9
|
+
// The unit tests in tests/task_visuals.mjs pin the predicate. This pins the WIRING
|
|
10
|
+
// — that the handler actually consults it, in the right order, and fails closed on
|
|
11
|
+
// every miss. It runs the real Express router with the data layer and the auth gate
|
|
12
|
+
// stubbed at the module boundary; no DB, no network, no session.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/task_visual_route_gate.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import fs from 'node:fs/promises';
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
const TMP = path.join(os.tmpdir(), `otb-visualroute-${process.pid}`);
|
|
25
|
+
process.env.TASK_VISUALS_DIR = TMP;
|
|
26
|
+
|
|
27
|
+
const express = require('express');
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
const visualDb = require('../modules/lifecycle/task-visual-db.js');
|
|
30
|
+
const visuals = require('../modules/lifecycle/task-visuals.js');
|
|
31
|
+
|
|
32
|
+
// ---- stubs at the module boundary -----------------------------------------
|
|
33
|
+
// The route reads `db.getTaskVisualAccess` and `auth.requireBuilder` off these
|
|
34
|
+
// module objects at CALL time, so replacing the properties is enough — no loader
|
|
35
|
+
// tricks, and the router under test is the real one.
|
|
36
|
+
let taskRow = null; // what the "DB" says about the task
|
|
37
|
+
let lookupThrows = false;
|
|
38
|
+
visualDb.getTaskVisualAccess = async () => {
|
|
39
|
+
if (lookupThrows) throw new Error('simulated DB outage');
|
|
40
|
+
return taskRow;
|
|
41
|
+
};
|
|
42
|
+
let authCalls = 0;
|
|
43
|
+
api.requireBuilder = (req, res) => {
|
|
44
|
+
authCalls++;
|
|
45
|
+
// Stand in for the real gate's deny path: it owns its own response, so reaching
|
|
46
|
+
// here means the request was NOT served publicly.
|
|
47
|
+
res.status(401).json({ error: 'unauthenticated' });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const buildRouter = require('../modules/lifecycle/routes/visuals.js');
|
|
51
|
+
|
|
52
|
+
// res.fail is composed at the kernel level in production; supply the same shape.
|
|
53
|
+
const app = express();
|
|
54
|
+
app.use((req, res, next) => {
|
|
55
|
+
res.fail = (code, status) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code });
|
|
56
|
+
next();
|
|
57
|
+
});
|
|
58
|
+
app.use(buildRouter());
|
|
59
|
+
|
|
60
|
+
const server = http.createServer(app);
|
|
61
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
62
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
63
|
+
|
|
64
|
+
// A real stored file, so a successful gate actually streams bytes.
|
|
65
|
+
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
66
|
+
const stored = await visuals.persistImageBuffer(PNG, { taskId: 4242, contentType: 'image/png', dir: TMP });
|
|
67
|
+
const NAME = stored.file;
|
|
68
|
+
|
|
69
|
+
let passed = 0, failed = 0;
|
|
70
|
+
function t(name, fn) {
|
|
71
|
+
return Promise.resolve().then(fn)
|
|
72
|
+
.then(() => { passed++; console.log(` PASS ${name}`); })
|
|
73
|
+
.catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
|
|
74
|
+
}
|
|
75
|
+
async function get(name) {
|
|
76
|
+
authCalls = 0;
|
|
77
|
+
const res = await fetch(`${base}/task-visuals/${name}`);
|
|
78
|
+
return { status: res.status, authCalls, buf: Buffer.from(await res.arrayBuffer()), headers: res.headers };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('Task-visual serve gate (task 1003109 / ADR 0184):');
|
|
82
|
+
|
|
83
|
+
await t('a SHIPPED task serves its visual to an unauthenticated caller', async () => {
|
|
84
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
|
|
85
|
+
const r = await get(NAME);
|
|
86
|
+
assert.equal(r.status, 200);
|
|
87
|
+
assert.equal(r.authCalls, 0, 'public work must not be sent through the auth gate');
|
|
88
|
+
assert.deepEqual(r.buf, PNG);
|
|
89
|
+
assert.equal(r.headers.get('content-type'), 'image/png');
|
|
90
|
+
assert.equal(r.headers.get('x-content-type-options'), 'nosniff');
|
|
91
|
+
assert.equal(r.headers.get('content-disposition'), 'inline');
|
|
92
|
+
assert.match(r.headers.get('cache-control'), /^public,/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await t('a CONFIRMED task is public too — the same set as a public value_summary', async () => {
|
|
96
|
+
taskRow = { id: 4242, status: 'confirmed', security_sensitive: false };
|
|
97
|
+
assert.equal((await get(NAME)).status, 200);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await t('IN-FLIGHT work never serves publicly — it falls through to requireBuilder', async () => {
|
|
101
|
+
for (const status of ['backlog', 'ready', 'active', 'completed', 'abandoned']) {
|
|
102
|
+
taskRow = { id: 4242, status, security_sensitive: false };
|
|
103
|
+
const r = await get(NAME);
|
|
104
|
+
assert.equal(r.status, 401, status);
|
|
105
|
+
assert.equal(r.authCalls, 1, `${status} must reach the auth gate`);
|
|
106
|
+
assert.equal(r.buf.length && r.buf.includes(PNG), false, `${status} must not leak bytes`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await t('a SHIPPED but security-sensitive task stays builders-only (the one narrowing)', async () => {
|
|
111
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: true };
|
|
112
|
+
const r = await get(NAME);
|
|
113
|
+
assert.equal(r.status, 401);
|
|
114
|
+
assert.equal(r.authCalls, 1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await t('an ABSENT task fails closed — no public serve, no crash', async () => {
|
|
118
|
+
taskRow = null;
|
|
119
|
+
const r = await get(NAME);
|
|
120
|
+
assert.equal(r.status, 401);
|
|
121
|
+
assert.equal(r.authCalls, 1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await t('a DB outage fails closed rather than opening the door', async () => {
|
|
125
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
|
|
126
|
+
lookupThrows = true;
|
|
127
|
+
const r = await get(NAME);
|
|
128
|
+
lookupThrows = false;
|
|
129
|
+
assert.equal(r.status, 401, 'a lookup failure must not be read as "public"');
|
|
130
|
+
assert.equal(r.authCalls, 1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await t('a malformed name is refused BEFORE any lookup (the traversal wall)', async () => {
|
|
134
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
|
|
135
|
+
for (const bad of ['..%2F..%2Fetc%2Fpasswd', 'screenshot.png', 'task-4242-0011.png', 'task-4242-0011223344556677.svg']) {
|
|
136
|
+
const r = await get(bad);
|
|
137
|
+
assert.equal(r.status, 400, bad);
|
|
138
|
+
assert.equal(r.authCalls, 0, `${bad} must not even reach the task lookup`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t('a well-formed name with no file on disk is a 404, not a 200', async () => {
|
|
143
|
+
taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
|
|
144
|
+
const r = await get('task-4242-ffffffffffffffff.png');
|
|
145
|
+
assert.equal(r.status, 404);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
server.close();
|
|
149
|
+
await fs.rm(TMP, { recursive: true, force: true });
|
|
150
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
151
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/task_visuals.mjs
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for ship-time task visuals (task 1003109, goal 1000072 "Human
|
|
4
|
+
// legibility"): filename safety / path-traversal, the task-id a stored name
|
|
5
|
+
// belongs to, the upload screening (type allowlist, size cap, magic-byte sniff),
|
|
6
|
+
// the AUDIENCE predicate that decides whether an image is public, alt-text
|
|
7
|
+
// normalization, and persist/delete against a temp dir. No network, no DB.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/task_visuals.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
// Point storage at a throwaway dir BEFORE requiring the module.
|
|
19
|
+
const TMP = path.join(os.tmpdir(), `otb-taskvisual-${process.pid}`);
|
|
20
|
+
process.env.TASK_VISUALS_DIR = TMP;
|
|
21
|
+
const tv = require('../modules/lifecycle/task-visuals.js');
|
|
22
|
+
const kit = require('../modules/hall-ui/public/hall-kit.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0, failed = 0;
|
|
25
|
+
function t(name, fn) {
|
|
26
|
+
return Promise.resolve().then(fn)
|
|
27
|
+
.then(() => { passed++; console.log(` PASS ${name}`); })
|
|
28
|
+
.catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Minimal real headers, long enough for the sniff's 12-byte floor.
|
|
32
|
+
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
33
|
+
const JPG = Buffer.from('ffd8ffe000104a46494600010101', 'hex');
|
|
34
|
+
const GIF = Buffer.from('4749463839610100010080000000', 'hex');
|
|
35
|
+
const WEBP = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('WEBPVP8 ')]);
|
|
36
|
+
const NOT_AN_IMAGE = Buffer.from('<?php system($_GET[0]); ?> ');
|
|
37
|
+
|
|
38
|
+
console.log('Ship-time task visuals (task 1003109):');
|
|
39
|
+
|
|
40
|
+
// ---- isSafeName / resolveSafe / taskIdFromName -----------------------------
|
|
41
|
+
await t('isSafeName: accepts a server-generated name, rejects junk + traversal', () => {
|
|
42
|
+
assert.equal(tv.isSafeName('task-1003109-0011223344556677.png'), true);
|
|
43
|
+
assert.equal(tv.isSafeName('task-1-aabbccddeeff0011.webp'), true);
|
|
44
|
+
// Not the minted shape: user-chosen names, wrong hex length, bad extension.
|
|
45
|
+
assert.equal(tv.isSafeName('screenshot.png'), false);
|
|
46
|
+
assert.equal(tv.isSafeName('task-1003109-00112233.png'), false);
|
|
47
|
+
assert.equal(tv.isSafeName('task-1003109-0011223344556677.svg'), false);
|
|
48
|
+
assert.equal(tv.isSafeName('task-1003109-0011223344556677.png.php'), false);
|
|
49
|
+
// Traversal, absolute paths, and a name that merely CONTAINS a valid one.
|
|
50
|
+
assert.equal(tv.isSafeName('../task-1003109-0011223344556677.png'), false);
|
|
51
|
+
assert.equal(tv.isSafeName('/etc/passwd'), false);
|
|
52
|
+
assert.equal(tv.isSafeName('a/task-1003109-0011223344556677.png'), false);
|
|
53
|
+
assert.equal(tv.isSafeName(null), false);
|
|
54
|
+
assert.equal(tv.isSafeName(42), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await t('resolveSafe: stays inside the dir, and returns null for anything unsafe', () => {
|
|
58
|
+
const p = tv.resolveSafe('task-7-0011223344556677.png', { dir: TMP });
|
|
59
|
+
assert.equal(p, path.join(path.resolve(TMP), 'task-7-0011223344556677.png'));
|
|
60
|
+
assert.equal(tv.resolveSafe('../../etc/passwd', { dir: TMP }), null);
|
|
61
|
+
assert.equal(tv.resolveSafe('task-7-0011223344556677.png/../../x', { dir: TMP }), null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await t('taskIdFromName: reads the owning task, and fails closed on a bad name', () => {
|
|
65
|
+
assert.equal(tv.taskIdFromName('task-1003109-0011223344556677.png'), 1003109);
|
|
66
|
+
// Fails CLOSED: the serve route looks the task up through this, and a null id
|
|
67
|
+
// resolves to no task, which resolves to "not public" — never to open access.
|
|
68
|
+
assert.equal(tv.taskIdFromName('../secrets.png'), null);
|
|
69
|
+
assert.equal(tv.taskIdFromName(''), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---- screening -------------------------------------------------------------
|
|
73
|
+
await t('screenImage: accepts each allowlisted format with matching bytes', () => {
|
|
74
|
+
assert.deepEqual(tv.screenImage(PNG, 'image/png'), { ok: true, ext: 'png' });
|
|
75
|
+
assert.deepEqual(tv.screenImage(JPG, 'image/jpeg'), { ok: true, ext: 'jpg' });
|
|
76
|
+
assert.deepEqual(tv.screenImage(GIF, 'image/gif'), { ok: true, ext: 'gif' });
|
|
77
|
+
assert.deepEqual(tv.screenImage(WEBP, 'image/webp'), { ok: true, ext: 'webp' });
|
|
78
|
+
// A content-type with parameters still resolves.
|
|
79
|
+
assert.deepEqual(tv.screenImage(PNG, 'image/png; charset=binary'), { ok: true, ext: 'png' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await t('screenImage: refuses a type outside the allowlist (SVG is executable)', () => {
|
|
83
|
+
assert.equal(tv.screenImage(PNG, 'image/svg+xml').ok, false);
|
|
84
|
+
assert.equal(tv.screenImage(PNG, 'text/html').ok, false);
|
|
85
|
+
assert.equal(tv.screenImage(PNG, 'application/octet-stream').ok, false);
|
|
86
|
+
assert.equal(tv.screenImage(PNG, '').ok, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await t('screenImage: a mislabeled content-type cannot smuggle a non-image in', () => {
|
|
90
|
+
const r = tv.screenImage(NOT_AN_IMAGE, 'image/png');
|
|
91
|
+
assert.equal(r.ok, false);
|
|
92
|
+
assert.equal(r.reason, 'sniff_mismatch');
|
|
93
|
+
// Right family, wrong format: JPEG bytes claiming to be a PNG.
|
|
94
|
+
assert.equal(tv.screenImage(JPG, 'image/png').reason, 'sniff_mismatch');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await t('screenImage: refuses empty and over-cap payloads', () => {
|
|
98
|
+
assert.equal(tv.screenImage(Buffer.alloc(0), 'image/png').reason, 'empty');
|
|
99
|
+
const huge = Buffer.concat([PNG, Buffer.alloc(tv.MAX_BYTES)]);
|
|
100
|
+
const r = tv.screenImage(huge, 'image/png');
|
|
101
|
+
assert.equal(r.ok, false);
|
|
102
|
+
assert.equal(r.reason, 'too_big');
|
|
103
|
+
assert.equal(r.bytes, huge.length);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---- the audience predicate ------------------------------------------------
|
|
107
|
+
await t('isPubliclyViewable: already-public work only', () => {
|
|
108
|
+
assert.equal(tv.isPubliclyViewable({ status: 'shipped' }), true);
|
|
109
|
+
assert.equal(tv.isPubliclyViewable({ status: 'confirmed' }), true);
|
|
110
|
+
// In-flight work is builders-only, exactly like its value_summary.
|
|
111
|
+
for (const status of ['backlog', 'ready', 'active', 'completed', 'abandoned']) {
|
|
112
|
+
assert.equal(tv.isPubliclyViewable({ status }), false, status);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('isPubliclyViewable: fails closed, and narrows on security_sensitive', () => {
|
|
117
|
+
assert.equal(tv.isPubliclyViewable(null), false);
|
|
118
|
+
assert.equal(tv.isPubliclyViewable(undefined), false);
|
|
119
|
+
assert.equal(tv.isPubliclyViewable({}), false);
|
|
120
|
+
// The one deliberate narrowing vs value_summary: a shipped security task's
|
|
121
|
+
// screenshot stays builders-only.
|
|
122
|
+
assert.equal(tv.isPubliclyViewable({ status: 'shipped', security_sensitive: true }), false);
|
|
123
|
+
assert.equal(tv.isPubliclyViewable({ status: 'shipped', security_sensitive: false }), true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- alt text --------------------------------------------------------------
|
|
127
|
+
await t('normalizeAlt: flattens, trims, bounds — and empty becomes null', () => {
|
|
128
|
+
assert.equal(tv.normalizeAlt(' before and\n after '), 'before and after');
|
|
129
|
+
assert.equal(tv.normalizeAlt(''), null);
|
|
130
|
+
assert.equal(tv.normalizeAlt(' '), null);
|
|
131
|
+
assert.equal(tv.normalizeAlt(null), null);
|
|
132
|
+
assert.equal(tv.normalizeAlt('x'.repeat(1000)).length, tv.MAX_ALT_LENGTH);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---- persist / delete ------------------------------------------------------
|
|
136
|
+
await t('persistImageBuffer: writes a name the serve route accepts, under the right task', async () => {
|
|
137
|
+
const r = await tv.persistImageBuffer(PNG, { taskId: 1003109, contentType: 'image/png', dir: TMP });
|
|
138
|
+
assert.equal(r.ok, true);
|
|
139
|
+
assert.equal(tv.isSafeName(r.file), true);
|
|
140
|
+
assert.equal(tv.taskIdFromName(r.file), 1003109);
|
|
141
|
+
assert.equal(r.url, `/api/bongos/task-visuals/${r.file}`);
|
|
142
|
+
// Both prefixes are permanent aliases, so a URL minted under either resolves.
|
|
143
|
+
assert.equal(kit.isTaskVisualUrl(r.url), true);
|
|
144
|
+
assert.equal(r.bytes, PNG.length);
|
|
145
|
+
const onDisk = await fs.readFile(path.join(TMP, r.file));
|
|
146
|
+
assert.deepEqual(onDisk, PNG);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t('persistImageBuffer: two writes never collide (a replace keeps the old file distinct)', async () => {
|
|
150
|
+
const a = await tv.persistImageBuffer(PNG, { taskId: 5, contentType: 'image/png', dir: TMP });
|
|
151
|
+
const b = await tv.persistImageBuffer(PNG, { taskId: 5, contentType: 'image/png', dir: TMP });
|
|
152
|
+
assert.notEqual(a.file, b.file);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('persistImageBuffer: refuses without writing anything', async () => {
|
|
156
|
+
const before = (await fs.readdir(TMP)).length;
|
|
157
|
+
assert.equal((await tv.persistImageBuffer(NOT_AN_IMAGE, { taskId: 1, contentType: 'image/png', dir: TMP })).reason, 'sniff_mismatch');
|
|
158
|
+
assert.equal((await tv.persistImageBuffer(PNG, { taskId: 0, contentType: 'image/png', dir: TMP })).reason, 'bad_task_id');
|
|
159
|
+
assert.equal((await tv.persistImageBuffer(PNG, { taskId: 'x', contentType: 'image/png', dir: TMP })).reason, 'bad_task_id');
|
|
160
|
+
assert.equal((await fs.readdir(TMP)).length, before);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('deleteStored: removes by name or URL, is idempotent, refuses a traversal', async () => {
|
|
164
|
+
const r = await tv.persistImageBuffer(PNG, { taskId: 9, contentType: 'image/png', dir: TMP });
|
|
165
|
+
assert.equal(await tv.deleteStored(r.url, { dir: TMP }), true);
|
|
166
|
+
// Idempotent — removing an already-absent visual is a success, so a replace
|
|
167
|
+
// whose old file was already swept doesn't error.
|
|
168
|
+
assert.equal(await tv.deleteStored(r.url, { dir: TMP }), true);
|
|
169
|
+
assert.equal(await tv.deleteStored('../../etc/passwd', { dir: TMP }), false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---- the client-side URL guard --------------------------------------------
|
|
173
|
+
await t('hall-kit: only a server-minted visual URL is ever rendered', () => {
|
|
174
|
+
assert.equal(kit.isTaskVisualUrl('/api/gds/task-visuals/task-1003109-0011223344556677.png'), true);
|
|
175
|
+
assert.equal(kit.isTaskVisualUrl('/api/bongos/task-visuals/task-1-aabbccddeeff0011.webp'), true);
|
|
176
|
+
// Anything that is not that exact shape renders nothing — including an
|
|
177
|
+
// absolute URL to another origin, and a javascript: payload.
|
|
178
|
+
assert.equal(kit.isTaskVisualUrl('https://evil.example/x.png'), false);
|
|
179
|
+
assert.equal(kit.isTaskVisualUrl('javascript:alert(1)'), false);
|
|
180
|
+
assert.equal(kit.isTaskVisualUrl('/api/gds/task-visuals/../../secrets'), false);
|
|
181
|
+
assert.equal(kit.isTaskVisualUrl(''), false);
|
|
182
|
+
assert.equal(kit.isTaskVisualUrl(null), false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await t('hall-kit: no visual renders nothing at all — never a placeholder', () => {
|
|
186
|
+
assert.equal(kit.shipVisualThumbHtml({ id: 1 }), '');
|
|
187
|
+
assert.equal(kit.shipVisualThumbHtml({ id: 1, visual_url: null }), '');
|
|
188
|
+
assert.equal(kit.shipVisualThumbHtml(null), '');
|
|
189
|
+
const html = kit.shipVisualThumbHtml({ id: 1, visual_url: '/api/gds/task-visuals/task-1-0011223344556677.png', visual_alt: 'a "before" shot' });
|
|
190
|
+
assert.ok(html.includes('ship-visual-thumb'));
|
|
191
|
+
// The caption is attacker-adjacent text (a builder wrote it) — it must be escaped.
|
|
192
|
+
assert.ok(html.includes('"before"'));
|
|
193
|
+
assert.ok(!html.includes('"before"'));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---- cleanup ---------------------------------------------------------------
|
|
197
|
+
await fs.rm(TMP, { recursive: true, force: true });
|
|
198
|
+
|
|
199
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
200
|
+
process.exit(failed ? 1 : 0);
|