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> {
const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 * 1024 });
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(","),
credentials: true,
});
app.get("/health", async () => ({ ok: true }));

View file

@ -138,5 +138,11 @@ export function reportDriftBeacon(slug: string, body: DriftReportBody): void {
} catch {
/* 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, {
baseUrl: API_BASE_URL,
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;
},
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}`);
return new Uint8Array(await res.arrayBuffer());
},
@ -93,6 +98,7 @@ function remoteProjectSource(): ProjectSource {
const res = await fetch(`${projectsBase()}/${encodeURIComponent(slug)}/files`, {
method: "POST",
body: form,
credentials: "include",
});
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
},

View file

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

View file

@ -95,7 +95,9 @@ async function resolveAndOpen(
};
const res = await fetch(
`${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}`);
const body = (await res.json()) as {