fix: stop cross-project live session leakage and stale-adapter 401s

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>
This commit is contained in:
Paul Bakaus
2026-07-28 17:38:46 -07:00
co-authored by Claude Code
parent 69456364b2
commit a83d767cf9
11 changed files with 383 additions and 22 deletions
+6 -1
View File
@@ -32,10 +32,15 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
}));
}
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) {
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
`window.__IMPECCABLE_PORT__ = ${port};\n` +
// Project identity for browser-side session storage. localStorage is
// keyed by ORIGIN, and two projects routinely share a localhost port
// across time; saved sessions carry this value so a resume can tell a
// foreign project's leftovers from its own.
`window.__IMPECCABLE_APP_ROOT__ = ${JSON.stringify(appRoot)};\n` +
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
// Canonical command vocabulary (values + labels + icons). live-browser.js
// builds its action picker from this instead of an inline copy.
+12
View File
@@ -119,6 +119,18 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
persist(normalized.id, next, prior.nextSeq + 1);
return next;
},
/**
* True when a journal exists for the id in either root. appendEvent
* CREATES a journal for any id it is handed, so callers that should only
* ever touch existing sessions (browser checkpoints, mount acks) check
* here first — otherwise a stale id from another project's browser
* storage materializes a ghost session in this store.
*/
has(id) {
if (!id || typeof id !== 'string') return false;
return fs.existsSync(getJournalPath(rootDir, id))
|| fs.existsSync(getJournalPath(legacyRootDir, id));
},
/**
* Read-only. `live-status` and `live-resume` call this against a session a
* running server owns; writing the snapshot file here made every read a
+51 -11
View File
@@ -7,6 +7,7 @@
* actual live UI remains the shared plain-DOM browser chrome.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
@@ -14,6 +15,28 @@ export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
// Matches the import at ANY revision (or none). [ \t]* bounds only, never
// \s*: a greedy \s* after the statement swallowed the next line's
// indentation on removal, leaving a formatting scar in user layouts.
const SVELTE_ROOT_IMPORT_LINE_RE = /^[ \t]*import ImpeccableLiveRoot from '\$lib\/impeccable\/ImpeccableLiveRoot\.svelte(?:\?[^']*)?';[ \t]*\r?\n?/gm;
/**
* The import specifier carries a token-derived revision query. The adapter
* component embeds the helper token, and Vite (client AND SSR) can keep
* serving a stale compiled module after the file is rewritten on a helper
* restart; the browser then requests /live.js with a rotated-out token and
* gets a 401 with no picker. A changed specifier is a different module id,
* which no cache survives.
*/
export function svelteRootImportLine(rev) {
if (!rev) return SVELTE_ROOT_IMPORT;
return "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte?impeccable-live=" + rev + "';";
}
export function svelteAdapterRev(token) {
if (!token) return null;
return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 8);
}
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
@@ -50,7 +73,7 @@ export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, token, co
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
const after = patchSvelteLayout(before, { rev: svelteAdapterRev(token) });
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
@@ -94,15 +117,27 @@ export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null
};
}
export function patchSvelteLayout(content) {
export function patchSvelteLayout(content, { rev = null } = {}) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
const importLine = svelteRootImportLine(rev);
if (!out.includes(importLine)) {
// An import at an older revision is replaced in place, keeping its
// indentation; only a layout with no impeccable import gets an insert.
let replaced = false;
out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, (line) => {
if (replaced) return '';
replaced = true;
const indent = (line.match(/^[ \t]*/) || [''])[0];
return indent + importLine + '\n';
});
if (!replaced) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + importLine + out.slice(insertAt);
} else {
out = `<script>\n ${importLine}\n</script>\n\n` + out;
}
}
}
@@ -131,8 +166,8 @@ export function unpatchSvelteLayout(content) {
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, '');
out = out.replace(/<script>\s*<\/script>[ \t]*\r?\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
@@ -193,6 +228,11 @@ export function buildSvelteLiveRootComponent(port, token) {
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
script.onerror = () => console.error(
'[impeccable] live.js failed to load from ' + LIVE_URL
+ ' (helper down, or the token rotated while a stale adapter module was cached).'
+ ' Re-run the live boot, then reload this page.'
);
document.head.appendChild(script);
return () => {