Repository navigation
Description
projector: POST /links validates only subject/ref — a non-string note/createdBy throws out of the handler, relation is String()-coerced into garbage, and an empty subject is stored
Evidence (verified this run, main 9b689eb5)
pm-stack/projector/src/server.ts, the POST /links branch (bearer-gated): only subject and ref are type-checked —
const { subject, ref, relation = "relates", note, createdBy } = body ?? {};
if (typeof subject !== "string" || typeof ref !== "string" || !/^[\w.-]+\/[\w.-]+#\d+$/.test(ref)) {
return jsonResponse(400, { error: "subject (string) and ref (owner/repo#N) required" });
}
await store.upsertLink(db, { subject, ref, relation: String(relation), note, createdBy });note and createdBy go to the libsql bind unvalidated, relation is coerced with String(), and there is no try/catch around upsertLink (nor around handleRequest in serve(), server.ts fetch handler), so a driver throw escapes as Bun's bare 500 instead of this API's JSON error shape.
Probe (laptop, ~/git/fleet/probe-2026-10-31-laneB-projector/, real handleRequest + migrated libsql DB, sources md5-matched to main 9b689eb5, valid bearer token):
| body | observed |
|---|---|
control: {subject:"s:1", ref:"developing-today/code#1", relation:"tracks", note:"hi"} |
201, stored verbatim |
relation: ["a","b"] |
201; response echoes the raw array; stored relation is "a,b" |
relation: {x:1} |
201; stored relation is "[object Object]" |
note: {x:1} |
throws out of handleRequest: Error: SQLite3 can only bind numbers, strings, bigints, buffers, and null — in production an uncaught 500, no JSON error |
createdBy: ["a"] |
same uncaught throw |
note: 42 |
201; stored note reads back as "42.0" (numeric bind into the TEXT column) |
subject: "" |
201; a link with an empty subject is stored |
subject: 123 (control) |
400, as the existing check intends |
The garbage relations are durable: pmp_links is keyed (subject, ref, relation), so "a,b" / "[object Object]" rows pollute the registry and answer later GET /links / subject-join queries (getCommentsForSubject joins on links), and the caller's 201 tells it the link was recorded as sent.
Why it matters
This is the projector's only authenticated write API. A client bug or a hand-crafted request either (a) crashes the request with an uncaught driver error instead of the endpoint's own 400 JSON shape — the failure mode #1547/#2693 already removed from this server's read paths — or (b) silently stores a coerced relation/subject the caller never sent, corrupting the link registry the read model joins on. The handler already has the right validation shape for two fields; the remaining fields were never given it.
Acceptance criteria
-
POST /linksvalidates the whole body before any store call:relation,note, andcreatedBy, when present, must be strings (absent/null keep today's defaults); a non-emptysubjectis required. Any violation returns the endpoint's JSON400shape — nothing is stored, nothing throws. - No
String()coercion ofrelation: a valid string relation is stored and echoed verbatim; the 201 response echoes what was actually stored. - A store/driver failure on this path cannot escape
handleRequestas an uncaught throw (same JSON-error discipline as the read paths). - Tests (extending the projector suite, e.g.
test/limit.test.tsstyle or a links test): the probe matrix above — array/object relation, object note, array createdBy, numeric note, empty subject — each returns 400 with no row stored; the valid control returns 201 and round-trips verbatim. - No change to the bearer gate, the
refformat check, link keying, or the GET/DELETE link endpoints.
Activity
dezren39 commentedon Oct 31, 2026
🤖 Agent note · filed
dezren39 commentedon Oct 31, 2026
🤖 Design-architect pass 26 (2026-10-31) — agent note: this issue is adopted as part of Phase 1 of design #3068 ("parsed is not validated"). Triage: do NOT re-file, re-scope or split it — record the phase structure instead. The design records the rule this phase lands under: validate the shape you deref at the boundary, before any side effect; "object" means the exact plain-object vocabulary (opencode-route-proxy.mjs:659 idiom); failure is the surface's JSON 400 with the process alive; one shared probe matrix (null, array, scalar, {}, each relied-on field wrong-typed). Phase-specific decisions are on #3068 and in files/designs/code-parsed-is-not-validated.md (goal workspace).
5 remaining items
dezren39 commentedon Nov 1, 2026
agent-note:v1
state: picked_up
task: 5492 (small_fix, lane D)
worker: repo-worker-code
summary: Implementing the pinned fix for #2974 — whole-body validation in the POST /links branch of pm-stack/projector/src/server.ts (no String() coercion, guarded store call), with the triage probe matrix as tests.
next: laptop worktree, implement validation + tests, open PR.
Merge pull request #3520 from developing-today/queue/5492-2974-links-…
dezren39 commentedon Nov 1, 2026
agent-note:v1
state: done
task: 5492 (small_fix, lane D)
worker: repo-worker-code
summary: Fixed — PR #3520 merged (421664e). POST /links now validates the whole body before any store call (non-empty subject; string relation with default only when absent; string-or-null note/createdBy), passes relation uncoerced, and wraps upsertLink in try/catch returning a JSON 500.
tests: new links-validation.test.ts 8/8; full projector suite 53 pass / 0 fail. Codex review completed, no findings.
next: none