Public: anyone can read this; the crew can change it.

The event, not the note: one table, two actions, and GET /api/events #4

shipped production is serving it opened this has it
opened this

Relay stops being the template's notes app and becomes an event hub: one event row an app posts and anyone reads, with every rule in src/actions.ts and nowhere else. This is the spine the rest of the map stands on — it settles the shape of an event and the identity an app carries, so no door invents either.

Done looks like

  • [ ] A migration appended to migrations in src/db.ts (never edit one that has run) creating event: id, source, kind, subject, summary, link (nullable), occurred_at, received_at, dedupe (nullable); indexes on source and kind, and a partial CREATE UNIQUE INDEX ... ON event (source, dedupe) WHERE dedupe IS NOT NULL. A later migration drops note as its own step.
  • [ ] src/who.ts grows a third shape, { role: \"app\"; id; name }, produced only by a door that verified a signature (its own issue) — who() can never return it from a header.
  • [ ] postEvent(db, who, input) validates at the boundary and refuses by name: kind matches ^[a-z0-9]+(\\.[a-z0-9]+){0,3}$, at most 64 characters; subject 1..120 and summary 1..200, control characters and newlines refused (not stripped); link optional, at most 512, parses with new URL() and its protocol is http: or https: and nothing else; source matches ^[a-z0-9][a-z0-9-]{0,31}$; occurred_at optional ISO 8601, refused more than 5 minutes ahead or 30 days behind, default now; dedupe optional, at most 64 of [A-Za-z0-9._-].
  • [ ] Only an app or an admin posts: role === \"app\" takes the source from who.id and ignores any source in the body; admin may name it; user and anon get forbidden / signin_required.
  • [ ] The same (source, dedupe) twice returns the stored event with ok: true and writes no second row — a retry is free.
  • [ ] listEvents(db, who, { source?, kind?, before?, limit? }), newest first; kind matches exactly or as a dotted prefix (order finds order.placed, ord finds nothing); limit clamps to 1..200, default 50; before pages by id; a filter failing its pattern is invalid, not silently ignored.
  • [ ] emit(\"event.posted\", event) in the action.
  • [ ] GET /api/events?source=&kind=&limit=&before= and GET /api/events/:id (404 when absent), answering with answer(...).
  • [ ] Notes are gone: their actions, routes, MCP tools, page section and terminal keys are replaced rather than left dangling, and no test asserts a note. The doors may be plain for now — their own issues make them good.
  • [ ] src/actions.test.ts covers each refusal by name, the dedupe replay, the prefix filter, the clamp, the app-vs-admin source rule, and a javascript: link refused. bun test green; the README describes an event and the two endpoints.

Files: src/db.ts, src/actions.ts, src/actions.test.ts, src/who.ts, src/routes.ts, src/mcp.ts, src/page.ts, src/tty.ts, src/server.test.ts, README.md

Risks: removing notes touches every door at once — replace, do not delete, so the app keeps booting. SQLite needs a partial unique index for a nullable dedupe. A link is poster-controlled text another issue will put in an href; validating the scheme here means no door has to remember. Appending a migration collides textually with the other two ready issues — a one-line rebase, and order does not matter as long as none is edited after it has run.

Suggested: Ledger — append-only rows with an idempotency key are their brief exactly.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs