Field session on a nested SvelteKit app surfaced a self-reinforcing leak:
localStorage is per-origin, two projects reused 127.0.0.1:5174, and a
React project's leftover cycling session was resumed inside the Svelte
project. Its checkpoints then materialized a ghost session in the new
project's durable store that kept reattaching after every discard, and a
stale adapter module 401'd on live.js, hiding the picker.
Four fixes:
- Server: only session-creating events (generate, steer) may mint a
journal. Progress events (checkpoints, mount acks, accept/discard) for
unknown ids are refused with 404 unknown_session and never enqueued, so
foreign browser state cannot create ghost sessions. Browser sends are
gated so progress never overtakes its own creating POST (the Go-time
checkpoint and generate are concurrent fetches; the first sweep caught
the out-of-order arrival breaking every SvelteKit flow). Steer
checkpoints now follow the steer event for the same reason.
- Browser: saved sessions carry the server's appRoot; a session stamped
by another project is dropped at load time. Unstamped legacy state is
caught by the unknown_session refusal, which clears local state and
re-arms the picker with an explanatory toast.
- SvelteKit adapter: the layout import carries a token-derived revision
query so a helper restart changes the module specifier and no Vite
client/SSR cache can serve an adapter with a rotated-out token;
live-inject --port reads the running helper's token from server.json
instead of writing an unauthenticated live.js URL; script load failures
log an actionable console error; and adapter removal is byte-exact
(the old regex swallowed the next line's indentation).
- live.mjs resolves surface briefs from appRoot, then contextRoot, then
repoRoot, matching context.mjs in nested-app repos.
Tests: server unknown-session rejection units, adapter revision/
byte-exact-removal units, and a foreign-session e2e scenario that seeds
another project's localStorage state and asserts it is cleared, no ghost
journal materializes, and picking still works.
AI-assisted (Claude Code).
Co-Authored-By: Claude Code <noreply@anthropic.com>
Two defense-in-depth layers close the P1 in issue #304, where any browser
tab on the machine could fetch /live.js, extract the embedded token, and
drive every token-gated route.
1. Loopback-restricted CORS. The shared handler replaced its wildcard
`Access-Control-Allow-Origin: *` with reflection gated on a strict
isLoopbackOrigin() that URL-parses the Origin (so localhost.evil.com and
127.0.0.1.evil.com fail) and accepts only http/https on localhost,
127.0.0.1, or [::1]. Reflection always pairs with `Vary: Origin` so a
cache never hands one origin's authorized response to another. Remote
origins get no ACAO header; origin-less callers (script tags, curl, the
agent's own fetches) are unaffected.
2. Token-gated /live.js. The handler now 401s unless `?token=` matches
state.token, so the bundle (which embeds the token) is no longer served
to unauthenticated local pages. The injected <script src> carries the
token: live.mjs passes --token to live-inject.mjs, which threads it
through every injection path (HTML/JSX tag, Nuxt plugin, SvelteKit root
component) via a shared buildLiveScriptSrc(). The token stays optional in
live-inject so static fixture tests keep their bare src.
Tests: new live-server integration cases for the 401 gate, remote-origin
denial, loopback reflection + Vary, and token-guarded routes under a
loopback Origin; e2e session harness now injects with the token.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>