mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
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:
co-authored by
Claude Code
parent
69456364b2
commit
a83d767cf9
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user