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 inscrutableAuthorizationrequirement. We need a marketing-shaped welcome that explains what warp is. - Sister surface alignment.
warp#1358is shipping/changes(changelog-format EPIC) into the same frontend. Whether/changeslives 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, everygyrum-*wrapper) useAuthorization: Bearer wrp_…. Browsers must useHttpOnlycookies —localStorage-based bearer storage is XSS- exposed and prevents server-side smart-redirect (the layout server cannot readlocalStorage). The existingwarp_sessioncookie is a one-shot, JS-readable handoff that the SPA lifts intolocalStorage; 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/loginredirect-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
HttpOnlycookie eliminates an XSS vector on every operator's daily browser session. - The
Identityshape 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/secretsis 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_keysrow 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 readdocument.cookiefor 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.tssmart-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=boarddeprecation 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 perwarp#1442; warp keeps its bespoke middleware until at least one other product needs the same shape.
Alternatives considered
- Always-marketing at
/, kanban at/boardfor 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/welcomeor/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
localStoragestorage of a long-lived bearer is XSS-exposed and the server-side smart-redirect at/cannot readlocalStorage. 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 usesAuthorization: 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 (evenHttpOnly-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 underlyingwrp_…token. /changesunder/board. Rejected because changelogs are shareable artefacts (RSS, blog-style permalinks); putting them behind/boardblocks 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: