Decisions

ADR-146: warp public-identity architecture (route ownership, smart-redirect, cookie+Bearer coexistence)

Warp today is a tools-only kanban: the home page (`/`) renders the board, `/login` renders a 350-line three-path login form, and every other route (`/items/:id`, `/keys`, `/dashboard`, `/direction`, `/export`) is gated…

#146

ADR-146: warp public-identity architecture (route ownership, smart-redirect, cookie+Bearer coexistence)

Status: Proposed Date: 2026-05-06

Context

Warp today is a tools-only kanban: the home page (/) renders the board, /login renders a 350-line three-path login form, and every other route (/items/:id, /keys, /dashboard, /direction, /export) is gated behind a Bearer token in localStorage. There is no marketing surface, no logged-out home, and no canonical "where do I send a phone-using stakeholder" URL. The trigger is concrete: an operator on the road needed to share warp with a colleague from a phone, and there is no shareable URL that does not require pasting a wrp_… token first. EPIC warp#1442 locks Phase 0 (this ADR + W0.2 mobile-auth-UX), Phase 1 (route implementation), and Phase 2 (cookie session + middleware coexistence) behind this single route-ownership decision.

The forces at play:

  • Operator power-user reflex. A logged-in operator types warp.gyrum.ai/ and expects work, not a marketing pitch. Always-marketing breaks the daily reflex of every existing user.
  • Discoverability for newcomers. A logged-out visitor at / currently sees a kanban with empty state and an inscrutable Authorization requirement. We need a marketing-shaped welcome that explains what warp is.
  • Sister surface alignment. warp#1358 is shipping /changes (changelog-format EPIC) into the same frontend. Whether /changes lives under / (public marketing tree) or under /board (operator-tools tree) determines whether anonymous users can read it — a SEO + sharing decision, not a layout one.
  • Bearer + Cookie coexistence. CLI agents (warp-claim, every gyrum-* wrapper) use Authorization: Bearer wrp_…. Browsers must use HttpOnly cookies — localStorage-based bearer storage is XSS- exposed and prevents server-side smart-redirect (the layout server cannot read localStorage). The existing warp_session cookie is a one-shot, JS-readable handoff that the SPA lifts into localStorage; this is exactly the wrong shape for a session and exists only because the previous architecture had no server-side route layer.
  • Backwards-compat. Existing bookmarks, dashboards, and gyrum-* scripts deep-link to / expecting the kanban. The transition cannot break those overnight.

The auth surface is already in place (api/internal/auth/auth.go:90, the Middleware(lookup, allowed…) factory) and is well-shaped for an either-or extension. The Phase 1 work is route addition and a +layout.server.ts; the Phase 2 work is one new code path on the existing middleware.

Decision

Warp adopts a smart-redirect public-identity architecture with the following route table, locked at this ADR for Phase 1 + Phase 2 implementation.

Route table

Path Logged-out Logged-in Indexable
/ Marketing welcome (what warp is, who it's for, "go to board" CTA). 302 → /board (smart-redirect). Yes
/?legacy=board Marketing welcome (query is a no-op when logged-out). Renders kanban inline (backwards-compat for existing bookmarks). No
/board 302 → /login?next=/board. Kanban (the new canonical for operator work). No
/board/items/:id etc. 302 → /login?next=/board/items/:id. Item detail. No
/changes (warp#1358) Public changelog (humans + RSS). Public changelog (no redirect — operators read changelogs too). Yes
/login Login form (existing 3-path UX). 302 → / (or next if present). No
/login?next=<path> Login form, post-success redirects to next. 302 → next. No
/dashboard, /keys, /direction, /export, /items/:id 302 → /login?next=<path> (during transition; see Consequences). Existing behaviour. No

/changes and any future public-but-not-board surface (e.g. status page, public roadmap) lives under /, not /board — these are marketing-tree paths whose audience includes anonymous visitors. Operator-only surfaces live under /board. The principle is "if a logged-out visitor can usefully read it, it lives in the public tree."

Smart-redirect logic

The decision of whether to redirect //board is made server-side in +layout.server.ts by reading the warp_session cookie. No /whoami round-trip — the cookie either exists (and is JWT-valid against the server's signing key) or does not. This keeps the / request a single round-trip even on the cold path.

The existence check is intentionally cheap: signature-verify only, no DB lookup, no scope-check. A signed-but-revoked cookie will fail at the next API call (and the Identity middleware will 401, which the SPA can handle by clearing the cookie and 302-ing to /login). The home-page redirect is allowed to be optimistic; correctness lives at the API boundary.

Cookie + Bearer coexistence

The auth.Middleware factory at auth.go:90 is extended to accept either:

  • Authorization: Bearer wrp_… (existing CLI / agent path, unchanged).
  • Cookie: warp_session=<jwt> (new browser path).

Resolution order on conflict: Bearer header wins. If both are present (e.g. a developer testing the CLI from a logged-in browser session), the explicit Authorization is the deliberate signal. If only one is present, that one is used. If neither is present, the existing 401 path applies.

The cookie is a JWT (not the raw wrp_… token) signed with a server-side key. The JWT carries {key_id, scope, label, exp} — the same fields Identity already exposes — so the middleware's post-verification path is identical regardless of which credential arrived. The JWT lifetime is 7 days, refreshed on every authenticated request; the underlying warp_api_keys row is the source of truth for revocation, checked on cookie issuance and on every Bearer lookup, but not on every cookie-bearing request (that's the speed-vs-revocation tradeoff this ADR signs up to operate).

The cookie shape is not the existing one-shot handoff cookie. The existing cookie at github_oauth.go:411 is HttpOnly=false, Max-Age=60, JS-readable by design. The new session cookie is:

  • Name: warp_session (same name; the handoff cookie is removed in Phase 2 and replaced by this one).
  • HttpOnly: true (XSS-resistant; JS can no longer read it).
  • Secure: true.
  • SameSite: Lax (allows /login redirect-back; blocks cross-site POSTs).
  • Path: /.
  • Max-Age: 7 * 24 * 3600 (7 days, refreshed per request).

The handoff-cookie → localStorage path is removed. The SPA stops reading document.cookie and stops writing localStorage.warp_token for browser sessions; the cookie is set by the /api/v1/auth/{magic-link,github}/callback handler on success and is the sole browser credential. CLI tools continue to mint and use wrp_… tokens via Authorization: Bearer — the localStorage path remains for gyrum-* wrappers that still expect to paste a token.

/login?next=<path> contract

next is validated server-side: must be a same-origin path (starts with /, no ://, no leading //, no \). Invalid next values are silently dropped and the post-login redirect falls back to /. This closes the open-redirect class of vulnerability without forcing the SPA to handle the validation.

Robots / SEO

A repo-level robots.txt allows indexing of / and /changes only; every other path is Disallow-listed. The +layout.svelte adds <meta name="robots" content="noindex"> for any logged-in route as a defence-in-depth.

Backwards-compat path

Existing bookmarks to / get the marketing welcome (logged-out) or a 302 to /board (logged-in). The ?legacy=board query parameter forces the old kanban-at-root behaviour for users with deep-linked dashboards or scripts; it is a transition affordance, not a permanent surface. The query param is documented in the W2 release note and removed in a follow-up ADR no earlier than 90 days post-merge (tracked via a meta:incomplete-structure warp ticket filed at Phase 2 close).

Consequences

Easier:

  • A phone-using stakeholder can hit warp.gyrum.ai/ and see what warp is. The trigger that birthed this EPIC is solved.
  • /changes (warp#1358) lands in a defined tree without re-litigating the marketing-vs-tools split.
  • Server-side smart-redirect collapses the "logged-in user sees empty kanban for a beat" UX bug to a single 302 with no flash.
  • The HttpOnly cookie eliminates an XSS vector on every operator's daily browser session.
  • The Identity shape is unchanged across cookie + Bearer paths, so handler code and scope-based RBAC stay identical.

Harder:

  • Two credential paths means two test matrices for every authentication-touching change. The Phase 2 implementation must add cookie-path tests beside every existing Bearer-path test in auth_test.go, middleware_test.go, and the per-handler tests. Coverage gate stays at 80%.
  • The JWT signing key becomes operational state: rotated on a schedule, never logged, redacted from any dump. The Phase 2 work signs up to operate this; gyrum-go's pkg/secrets is the canonical store. Out-of-scope for this ADR but noted as Phase 2 acceptance.
  • The 7-day cookie lifetime means a revoked warp_api_keys row stays cookie-usable until expiry. The mitigation is a revocation list checked at cookie-decode time (Phase 2 work); the alternative — DB lookup per request — was rejected for latency.
  • gyrum-* wrappers that read document.cookie for the handoff token break at Phase 2 cutover. Audit tracked as a Phase 2 precondition: grep -r 'document.cookie\|warp_session' gyrum-go/ must be clean (modulo this ADR's own references) before merge.

What we sign up to operate:

  • The marketing surface at / — copy, image assets, accessibility, i18n posture (English-only at launch; W0.2 ADR may revisit).
  • The +layout.server.ts smart-redirect — a server-side render step on every / hit. Latency budget: <50ms p99 (the cookie signature-verify is microseconds; the budget is for SvelteKit's layout server overhead).
  • The JWT signing key rotation runbook — quarterly rotation, dual-key acceptance window during rotation, key versioning embedded in the JWT header.
  • The ?legacy=board deprecation timer — 90 days post-Phase-2-merge, tracked as a Phase 2 follow-up warp ticket, NOT this one.

What this ADR explicitly leaves to W0.2 and Phase 1+2:

  • Mobile login UX (W0.2 — warp#1444, sister-shipping in parallel).
  • Marketing copy and visual design (Phase 1 design ticket, post-W0.2).
  • Account surface routes (/account, /account/keys) — Phase 3, not Phase 1+2; deferred until shape of cookie auth is in production.
  • Refactor of cookie + Bearer middleware into gyrum-go/pkg/auth — out of EPIC scope per warp#1442; warp keeps its bespoke middleware until at least one other product needs the same shape.

Alternatives considered

  • Always-marketing at /, kanban at /board for everyone. Rejected because operators check warp dozens of times per day; an extra click on the most-trafficked URL is friction the fleet would hate. The smart-redirect achieves the marketing goal without taxing the power-user reflex.
  • Always-kanban at /, marketing at /welcome or /about. Rejected because the marketing surface is the answer to "I sent someone a warp link, what do they see?" Hiding it behind a non-discoverable path defeats the purpose. The shareable URL has to be /.
  • Bearer-only, no cookie. Rejected because localStorage storage of a long-lived bearer is XSS-exposed and the server-side smart-redirect at / cannot read localStorage. Either the server doesn't smart-redirect (and we lose the operator UX) or the server reads a cookie (and we are here).
  • Cookie-only, drop Bearer. Rejected because every CLI tool, every gyrum-* wrapper, every cron-shaped agent uses Authorization: Bearer wrp_…. Forcing a cookie jar onto headless callers is a much larger refactor than extending the middleware.
  • Session-cookie storing the raw wrp_… token. Rejected because the raw token is the long-lived secret; an XSS-exfiltrated cookie (even HttpOnly-ed via a server-side bug) would compromise the underlying API key. The JWT is a short-lived derivation that revocation lists can shorten further without rotating the underlying wrp_… token.
  • /changes under /board. Rejected because changelogs are shareable artefacts (RSS, blog-style permalinks); putting them behind /board blocks the anonymous read path that makes them useful to non-operators. The marketing-tree placement aligns with warp#1358's product-mapping ADR.
  • Client-side smart-redirect. Rejected because it produces a flash of marketing content before the JS resolves the redirect, defeating the operator-reflex goal. Server-side is one round-trip; client-side is two plus a layout flash.

Supersedes: none Superseded by: {{leave blank until a later ADR reverses this one}}

Sister-links:

  • warp#1442 — parent EPIC (warp public identity Phase 0–3).
  • warp#1444 — W0.2 mobile auth UX ADR (sibling, sister-shipping).
  • warp#1358 — changelog/title-format EPIC (/changes route ownership aligned here).
  • ADR-135 — product-to-repos mapping (warp's products.yaml entry mirrors this route table).