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

Every change on a shared board records who made it: an actor key, and a guest's HttpOnly cookie #92

open not picked up yet opened this
opened this

Give every change a name behind it, so undo, activity and attribution can tell one person's work from another's on a board they share.

What it is like today. logAction (src/db.ts:105) writes board_id and a payload and nothing else, so undo(boardId) takes the board's newest row whoever wrote it. The only server-side notion of a guest anywhere is callerKey(who, ip) in src/limits.ts, which exists for the write limit, and the x-tempo-client header src/routes.ts echoes back on events, which is client-chosen and trusted for nothing. src/session.ts is the ssh side of identity, not an HTTP session, and presence (pull #74) deliberately stores no id at all. So on a board two people and an agent share, the app cannot say whose change a change was — which is the missing piece under #43's label, #76's activity list and per-person undo.

Done looks like

  • [ ] src/actor.ts, one concept: actorKey(who, cookieHeader) returns { key, cookie? } — u:<forge id> for a signed-in caller (the id src/who.ts has already verified), g:<24 chars> for a guest, minting the guest id from crypto.getRandomValues when the request carries none.
  • [ ] The guest id rides an HttpOnly cookie: Path=/, SameSite=Lax, Max-Age=31536000, and Secure when FORGE_APP_URL is https. Set only on the two responses that already answer private, no-store (/ and /b/:id) — never on /static/, never on /b/:id/events.
  • [ ] Read and validated at the boundary: a cookie that does not match /^[a-z0-9]{24}$/ is ignored and replaced, and its value is never echoed into HTML or onto the event wire.
  • [ ] It is neither an authorization token nor a rate-limit key, and a comment says so where a reader would look: anyone holding a board's link can still edit it, and callerKey keeps keying guests by socket, because a cookie a client can drop would make the write limit free to evade.
  • [ ] action grows a nullable actor TEXT, added in place with the same conditional PRAGMA table_info migration owner_id uses (src/db.ts:62). logAction takes the actor and writes it inside the same transaction as the write that logged it.
  • [ ] Every write threads it through as a trailing optional argument — createCard, moveCard, editCard, deleteCard, createColumn, renameColumn, moveColumn, deleteColumn, renameBoard — so a caller with no actor passes nothing and stores NULL.
  • [ ] The three callers pass what they have: src/routes.ts and src/mcp.ts the request's actor key, src/tty.ts a u:<id> from ownerIdFromEnv and NULL for a terminal nobody signed into.
  • [ ] Nothing on the page changes and no response gets slower. The README gains two sentences about the cookie: what is in it (24 random characters, no name and nothing derived from a person) and what it is for.
  • [ ] Tests: the same cookie gives the same key across two requests and a missing one mints exactly one; a malformed cookie is ignored; each kind of write lands an action row carrying the caller's key; a row written before this change (actor NULL) is still read and undone by the existing undo.

Files

New src/actor.ts and src/actor.test.ts; src/db.ts (migration, logAction, write signatures); src/routes.ts; src/mcp.ts; src/tty.ts; src/server.test.ts; README.md.

Risks

  • It edits logAction and undo, which #43 is also rewriting — hence the dependency; land after it and thread the actor through its labels rather than around them.
  • A Set-Cookie must never reach a shared cache: both pages are already private, no-store with vary: x-forge-identity; keep them that way.
  • The migration runs against production's file, so the ALTER TABLE stays conditional and the column stays nullable.
  • The temptation this opens is treating the cookie as identity. It is a label on a change, nothing more.

Waits for #43.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs