Files
pad/web
xarmian f465d4c51e fix(server): a malformed before_id is a 400, not a 500 (BUG-2774) (#1205)
* fix(server): a malformed before_id is a 400, not a 500 (BUG-2774)

before_id went from the query string into the cursor predicate unchecked.
Postgres refuses a text parameter that is not valid UTF-8 or that carries a
NUL (SQLSTATE 22021/22P05), so the store call errored and the handler answered
500 — the server announcing its own failure for a client's bad input, and
SQLSTATE noise in the logs for an input problem. SQLite accepts the same bytes
and matches nothing, so the identical request was a 200 there; the failure mode
diverged by dialect from one line of unvalidated input.

validCursorID rejects exactly what the DATABASE rejects rather than what an id
should look like, and deliberately carries NO length or format bound: the
structured kinds' ids come from the item's own fields blob, nothing validates
them on write, and an imported artifact may carry any string — so a cap could
only ever fire on a legitimate cursor, while the cost of an over-long one is a
single indexed comparison against a parameter the URL length limit already
bounds. Its comment says so, because the obvious review question is why there
isn't one.

Tests: three malformed shapes reachable from a plain URL (%FF, an embedded NUL,
invalid bytes mid-string) plus three controls — a UUID, a structured note id,
and a long non-ASCII id — without which "reject every before_id" would pass and
paging would be dead rather than honest. A store-level test pins the PREMISE
where it is real: on Postgres the query itself fails, on SQLite it succeeds and
returns nothing, and both halves are asserted in the one place that sees both,
each with a message saying what it means if the backend's behaviour has moved.

Verified on Postgres 17 (private container, not the shared port). The handler
legs fail with the validation removed.

Refs: BUG-2774

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(server): never emit a cursor the handler would refuse (codex round 1)

The validation had a second direction I had not closed. A structured entry's id
comes from the item's fields blob, which nothing validates on write, so a JSON
\u0000 escape arrives as a real NUL on SQLite — Postgres's jsonb refuses it at
the door, which is why this is a one-backend hazard. That id became the entry's
id, the entry's id became next_before_id, and the client sending it back got a
400 from the validation this unit just added: the server handing out a cursor
it then refuses, wedging paging on that item.

Such an id now takes the positional fallback that empty and duplicate ids
already take — the id has to be usable as a CURSOR, not merely unique. One
condition on an existing branch rather than new machinery.

Its test uses two notes, one NUL-bearing and one clean, so it distinguishes
'replaced the unusable id' from 'stopped using raw ids at all'. Verified
against the unmutated branch: the NUL id is emitted verbatim and the leg fails.

Refs: BUG-2774

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test: assert the envelope, the emitted cursor, and split the dialect premise (codex round 3)

Three test weaknesses, all mine.

Status-only assertions: a bare 400 or the wrong code would have passed, and
clients branch on the code rather than the number. The legs now decode the
envelope and require invalid_cursor with a non-empty message.

The emitted-cursor test claimed to cover next_before_id and did not: at
limit=50 the fixture emits no cursor at all, so it only checked entry ids. A
third note and a truncating limit make the NUL-bearing one the LAST kept entry,
which is what puts it in the cursor — and the test now asserts the cursor is
valid AND that paging with it returns 200. The mutant (emitted ids unchecked)
still dies, now on the thing the test is named for.

The dialect test is CHARACTERIZATION and now says so: it passes with or without
this change, because it describes the backends rather than the handler, and
that is its job — it is the premise the 400 rests on. Its 'both halves in one
place' claim was also false in an unconfigured run, where NewPostgres skipped
the whole test and the SQLite half never ran either. Split into subtests: the
SQLite half runs everywhere, the Postgres half skips alone.

Refs: BUG-2774

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(server): a one-sided cursor is a 400, not a silent no-op (codex round 4)

before_id alone could never match anything: the id is the tie-break AT the
cursor instant, and before defaults to now+1m, which no row shares. So it was
accepted, ignored, and the caller paged from the beginning believing they had
a cursor — the accepted-and-does-nothing shape, which is the worst answer of
the three available.

The other direction stays supported deliberately, and has a control leg saying
so: before alone is the external-client case the "g" sentinel exists for, and
rejecting the pair symmetrically would break something the handler goes out of
its way to serve.

In scope for this unit rather than a separate filing because it is the same
parameter and the same class of answer — and because BUG-2765's PR body
documented "both fields or neither" as the contract, which until now nothing
enforced.

Codex's other round-4 finding — a structured entry id can collide with a
comment/activity/version id, because the dedupe map holds only structured ids —
is real, pre-existing, and a cross-source id decision rather than a validation.
Filed as BUG-2783.

Refs: BUG-2774, BUG-2783

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs: the cursor contract is asymmetric, and now says so (codex round 6)

BUG-2765's prose said "both fields, or neither" in the handler doc comment and
the TS client. This unit then enforced it in one direction only — deliberately,
because `before` alone is the external-client shape the id sentinel exists to
serve, while `before_id` alone matches nothing and silently pages from the
beginning. The slogan was mine and the asymmetry is mine; leaving both in place
would have left a reader to discover the difference from a 400.

Both sites now state which one-sided form is accepted, which is refused, and
why each.

Refs: BUG-2774

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-25 19:17:49 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00

Pad Web UI

SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.

Development

npm install
npm run dev          # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build        # Production build to build/
npm run check        # Type checking with svelte-check

When developing, run the Go backend separately with make dev from the project root.

Building for Production

Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.

Stack

  • Svelte 5 with runes ($state, $derived, $effect)
  • SvelteKit 2 with adapter-static (SPA mode)
  • Tiptap block editor with markdown round-trip
  • svelte-dnd-action for drag-and-drop in board/list views
  • SSE for real-time updates
  • TypeScript throughout

Structure

src/
  routes/                    SvelteKit pages
    +layout.svelte           App shell (sidebar + main)
    +page.svelte             Landing/redirect
    [workspace]/
      +page.svelte           Dashboard (collections, phases, activity)
      +layout.svelte         SSE connection per workspace
      [collection]/
        +page.svelte         Collection view (board/list)
      [collection]/[item]/
        +page.svelte         Item detail + editor
      conventions/            Purpose-built conventions page
      playbooks/              Purpose-built playbooks page
      settings/               Workspace settings
  lib/
    api/client.ts            HTTP API client
    components/
      layout/                Sidebar, navigation
      editor/                Tiptap editor, raw markdown editor
      fields/                FieldEditor, relation picker
      items/                 ItemCard, ItemDetail
      collections/           BoardView, ListView
      common/                StatusBadge, badges, modals
      search/                CommandPalette
    stores/                  Svelte 5 reactive stores
      workspace.svelte.ts    Workspace state
      collections.svelte.ts  Collection + item state
      ui.svelte.ts           Sidebar, mobile state
    types/index.ts           TypeScript types and constants
  app.css                    Global styles and design tokens