feat(standalone): send session credentials on backend API calls

Backends with real auth (session cookies) need credentials:'include' on the
editor's cross-origin fetches — ts-rest clients, project file bytes/upload,
drift keepalive, lib item GET/PUT, sync-stack resolve. Cookie-less setups are
unaffected (the thin identity headers still ride along and same-site Lax
cookies simply don't exist). CDN/static-gallery fetches stay credential-less
(wildcard CORS rejects credentialed requests). The example backend's CORS now
sends allow-credentials — origin reflection was already exact, so this only
lets browsers accept those responses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TxciQ5VkNYUZesgMSEMUr
This commit is contained in:
Istvan Matejcsok 2026-07-06 17:37:44 +02:00
commit 840173c978
6 changed files with 29 additions and 3 deletions

View file

@ -150,7 +150,11 @@ async function project(scope: string): Promise<Project> {
async function main(): Promise<void> { async function main(): Promise<void> {
const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 * 1024 }); const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 * 1024 });
await app.register(cors, { await app.register(cors, {
// `true` REFLECTS the request origin (never the literal `*`), so it stays
// valid for the editor's credentialed fetches; allow-credentials is what
// lets the browser accept those responses (cookie-less callers unaffected).
origin: CORS_ORIGIN === "*" ? true : CORS_ORIGIN.split(","), origin: CORS_ORIGIN === "*" ? true : CORS_ORIGIN.split(","),
credentials: true,
}); });
app.get("/health", async () => ({ ok: true })); app.get("/health", async () => ({ ok: true }));

View file

@ -138,5 +138,11 @@ export function reportDriftBeacon(slug: string, body: DriftReportBody): void {
} catch { } catch {
/* fall through to keepalive fetch */ /* fall through to keepalive fetch */
} }
void fetch(url, { method: "POST", body: blob, keepalive: true }).catch(() => {}); // credentials: parity with the beacon path (beacons carry same-site cookies).
void fetch(url, {
method: "POST",
body: blob,
keepalive: true,
credentials: "include",
}).catch(() => {});
} }

View file

@ -12,4 +12,8 @@ import { API_BASE_URL, userSlug } from "./config";
export const client = initClient(contract, { export const client = initClient(contract, {
baseUrl: API_BASE_URL, baseUrl: API_BASE_URL,
baseHeaders: { [USER_HEADER]: userSlug() }, baseHeaders: { [USER_HEADER]: userSlug() },
// Send the backend's session cookie (same-site, different origin): backends
// with real auth resolve the user from it and ignore the thin header. On a
// cookie-less setup this changes nothing.
credentials: "include",
}); });

View file

@ -80,7 +80,12 @@ function remoteProjectSource(): ProjectSource {
return res.body; return res.body;
}, },
async fetchFileBytes(slug, relPath) { async fetchFileBytes(slug, relPath) {
const res = await fetch(fileUrl(slug, relPath)); // credentials: session-cookie auth (see contract-client.ts). The static
// gallery fetches below stay credential-less — a CDN's wildcard CORS
// rejects credentialed requests.
const res = await fetch(fileUrl(slug, relPath), {
credentials: "include",
});
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`); if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer()); return new Uint8Array(await res.arrayBuffer());
}, },
@ -93,6 +98,7 @@ function remoteProjectSource(): ProjectSource {
const res = await fetch(`${projectsBase()}/${encodeURIComponent(slug)}/files`, { const res = await fetch(`${projectsBase()}/${encodeURIComponent(slug)}/files`, {
method: "POST", method: "POST",
body: form, body: form,
credentials: "include",
}); });
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`); if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
}, },

View file

@ -29,6 +29,8 @@ export function remoteLibsSource(
const client = initClient(contract, { const client = initClient(contract, {
baseUrl: apiBase, baseUrl: apiBase,
baseHeaders: reqHeaders, baseHeaders: reqHeaders,
// Session-cookie auth (backends with real auth ignore the thin headers).
credentials: "include",
}); });
const enc = encodeURIComponent; const enc = encodeURIComponent;
@ -62,6 +64,7 @@ export function remoteLibsSource(
): Promise<string | null> { ): Promise<string | null> {
const res = await fetch(itemUrl(libId, kind, name), { const res = await fetch(itemUrl(libId, kind, name), {
headers: reqHeaders, headers: reqHeaders,
credentials: "include",
}); });
if (!res.ok) return null; if (!res.ok) return null;
return await res.text(); return await res.text();
@ -77,6 +80,7 @@ export function remoteLibsSource(
method: "PUT", method: "PUT",
headers: { ...reqHeaders, "Content-Type": "text/plain; charset=utf-8" }, headers: { ...reqHeaders, "Content-Type": "text/plain; charset=utf-8" },
body, body,
credentials: "include",
}); });
return res.ok; return res.ok;
}, },

View file

@ -95,7 +95,9 @@ async function resolveAndOpen(
}; };
const res = await fetch( const res = await fetch(
`${opts.apiBase}/api/scopes/${encodeURIComponent(opts.scope)}/libs/${encodeURIComponent(libId)}/sync-stack`, `${opts.apiBase}/api/scopes/${encodeURIComponent(opts.scope)}/libs/${encodeURIComponent(libId)}/sync-stack`,
{ method: "POST", headers }, // credentials: session-cookie auth; the layer descriptors this returns keep
// their own bearer-token channel (sync-client transport is cookie-free).
{ method: "POST", headers, credentials: "include" },
); );
if (!res.ok) throw new Error(`sync-stack resolve failed: HTTP ${res.status}`); if (!res.ok) throw new Error(`sync-stack resolve failed: HTTP ${res.status}`);
const body = (await res.json()) as { const body = (await res.json()) as {