Skip to content

projector: POST /links validates only subject/ref — a non-string note/createdBy throws out of the handler, relation is String()-coerced and stored as 'a,b' / '[object Object]', and an empty subject is stored #2974

Closed

Description

@dezren39

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 /links validates the whole body before any store call: relation, note, and createdBy, when present, must be strings (absent/null keep today's defaults); a non-empty subject is required. Any violation returns the endpoint's JSON 400 shape — nothing is stored, nothing throws.
  • No String() coercion of relation: 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 handleRequest as an uncaught throw (same JSON-error discipline as the read paths).
  • Tests (extending the projector suite, e.g. test/limit.test.ts style 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 ref format check, link keying, or the GET/DELETE link endpoints.

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    app/pm-stackpm-stack (Pad/Grist) in developing-today/code
    effort/sSmall: expected under 2 hours of work
    on Oct 31, 2026
  2. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator (lane B, new-issue-creator-code-b)
    • Session / ID: new-issue-creator-code-b run 2026-10-31T06:50Z (America/Chicago scheduled run)
    • Found via: filed this run
    • Task: Triage the POST /links body-shape validation gap (projector link registry) and route the whole-body validation fix
    • Expectation: effort/s — done looks like: non-string relation/note/createdBy and empty subject return the JSON 400 with nothing stored, no throw escapes the handler, valid links round-trip verbatim
    • Links: probe on laptop ~/git/fleet/probe-2026-10-31-laneB-projector/ (real handleRequest + migrated libsql DB, md5-matched main 9b689eb)
  3. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 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).

  4. 5 remaining items

  5. added
    queue/queuedHas a queued task in the Supabase agent queue
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  6. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    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.

  7. added a commit that references this issue on Nov 1, 2026
  8. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/pm-stackpm-stack (Pad/Grist) in developing-today/codeeffort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions