writeonce/docs/stories/porch/03-sessions.md
shoney.arickathil 01df75245f docs(porch): give the framework its own story track, iterations 1-8
- docs/stories/porch/ — a TRACK folder, not a status folder: status still
  lives only in frontmatter. Adds `track: porch` so a query over
  docs/stories/ can tell a porch 3 from a language 3
- 00-story.md carries the sequence, the dependency graph, and a table of
  what the track explicitly does NOT own (binding -> 29, cache -> 18,
  proxy -> 38, metrics -> 30, TLS/templates -> doctrine)
- eight iterations, each with phases, per-phase tasks, Given/When/Then
  criteria, out-of-scope and the forks a spec must settle:
  1 store-backed middleware (limiter + idempotency — needs nothing new,
    first on purpose so the store pattern is proven cheaply)
  2 randomness + cookies (phase A is language-track: a CSPRNG builtin;
    `Resp.headers` being a map cannot emit two Set-Cookie lines)
  3 sessions   4 CSRF   5 routing/response ergonomics (independent)
  6 streaming core (the seam 7 and 8 wait on; chunked-request refusal
    must survive)   7 SSE + compression   8 static + lifecycle hooks
- language iteration 39 -> status: hold, retitled superseded, with a row
  mapping each of its goals to the porch iteration that took it. Kept, not
  deleted: the Fiber study cites it and its randomness argument is what
  this track is built on
- board gains a porch section; board-views gains porch and both-track
  Dataview queries; porch README and the Fiber study §7 point at the track
- no code blocks in any story (plans carry concept and actions in words);
  linkcheck 0 broken / 0 anchors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:17:33 +02:00

6.5 KiB

track iteration status
porch 3 refine

porch 3 — sessions: server-side state, revocable, durable

Part of Story — porch, the writeonce web framework. Source: the Fiber parity study §2. Needs iteration 2 (a random session id and a signed cookie to carry it) and inherits the store convention from iteration 1.

Goals

  • A session is a @table row keyed by a random id; the cookie carries only the id. The alternative — a signed cookie carrying the whole payload — needs no store but cannot be revoked, and revocation is not optional for a real login. This is the fork iteration 1's store convention exists to answer.
  • Both timeouts, because they answer different questions. Idle timeout bounds "how long since you did anything"; absolute timeout bounds "how long since you authenticated". Fiber ships both (IdleTimeout, AbsoluteTimeout) and a session with only the first never expires for an active attacker.
  • Durability is the differentiator. Fiber's default store is in-memory: a restart logs everyone out. porch's sessions ride the WAL, so they survive — and the gate proves it, because an unexercised durability claim is not a claim.
  • Revocation that works. Logout, and "log out everywhere for this principal" — the second is what a password change needs, and it is nearly free once sessions are rows with an owner column.

Phases

Phase A — the session table and its lifecycle

  • The @table class: random id, principal, created-at, last-seen, and whatever payload shape fork 1 settles. Secondary index on principal, because "revoke all for this user" is an equality probe and that is the only index shape the engine has.
  • Create, touch, expire and delete, with expiry evaluated on access (the lazy discipline iteration 1 established — there is still no timer).
  • Verify: rows replay across a restart; an expired row is pruned when touched.

Phase B — the middleware

  • A Session middleware whose before reads the signed cookie, loads the row, checks both timeouts, and attaches identity. Decide whether it sets req.principal or writes into req.ctx — principal is the field the framework already means for identity, so it should be that unless the session carries more than identity.
  • Rotate the id on privilege change (login especially). Session fixation is the attack that a login which reuses the pre-login id walks straight into.
  • A missing, expired, unknown or tampered cookie all end at the same place — unauthenticated — but for distinguishable reasons, because debugging a login loop without that distinction is miserable.
  • Verify: each of those four cases behaves; the id changes across login.

Phase C — login, logout, revoke-all

  • The three flows end to end in a sample, using porch's existing auth pieces (bearer_token, basic_credentials, ct_eq) for the credential check — sessions are about keeping identity, not establishing it.
  • Logout deletes the row and clears the cookie with matching attributes.
  • Revoke-all deletes by principal and is proven with two concurrent sessions.
  • Verify: after logout the old cookie is inert even though it is still well-signed — the point of server-side state.

Phase D — the gate and the ledger

  • Extend scripts/site-accept.sh rather than only the storefront: the site has an admin route that currently checks a bearer token per request, which is exactly the thing sessions replace.
  • Ledger rows, status board entry, and the standup questions answered.
  • Verify: just web-app, just site, just linkcheck green.

Acceptance Criteria

  • Given a valid session cookie, when a request arrives, then identity is attached and the row's last-seen advances.
  • Given a session whose signature is valid but whose row was deleted, when it is presented, then the request is unauthenticated — a well-formed cookie is not authority.
  • Given an idle timeout of T, when a session is unused for longer than T, then it is rejected and its row pruned on that access.
  • Given an absolute timeout of A, when a session is continuously active past A, then it is still rejected — the criterion an idle-only implementation fails.
  • Given an anonymous session id, when the user logs in, then the id is rotated and the pre-login id is inert.
  • Given active sessions and a SIGTERM plus restart, when the same cookies return, then the users are still logged in, replayed from the WAL.
  • Given two sessions for one principal, when revoke-all runs, then both are inert and other principals are untouched.
  • Given logout, when the cleared cookie is compared to the one that set it, then the attributes match, so the browser actually drops it.

Out Of Scope

  • Flash messages and a general session bag. A typed session with a known shape first; an untyped map<Text,Text> payload is a decision to defer, not a default to adopt.
  • OAuth, OIDC, SSO, "log in with X". Every one needs an outbound socket, which does not exist — language iteration 38.
  • Remember-me tokens — a second, longer-lived credential class with its own rotation story. Its own slice.
  • CSRF — iteration 4. Sessions make CSRF possible to do properly; they do not provide it.
  • Sliding-window renewal of the absolute timeout. That is not what absolute means.

Info

Forks the spec must settle:

  1. What does the session row carry beyond identity? Just principal, or a payload? A payload wants a shape, and without generics the shape is either a declared class per app or Text. The framework's cache already stores Text and that is the compromise this repo dislikes most — so leaning: identity plus declared columns, and apps that want more keep their own table keyed by session id.
  2. Idle-timeout writes on every request. Touching last-seen means a WAL write per request, which turns every read into a durable write. Options: write at a coarser granularity, or accept the cost and say so with a number from just db-bench. This is a real performance fork, not a detail.
  3. Does Session set req.principal or req.ctx? principal is the framework's declared home for identity and auth middleware already writes it, so two writers of one field need a documented precedence.