/** * Local-dev editor session for live-connected static frontends. * * `bun dev` renders the site on localhost while every `/_emdash/*` request is * proxied to the deployed backend. Authentication cannot happen on localhost * itself — passkeys are origin-bound to the backend's domain and magic-link * emails carry absolute URLs to it — so editors sign in on the backend's own * origin and the preview-session handoff transfers the session here: the * sign-in pill links to `/_emdash/preview-session/start?to=`, * the worker authenticates (or sends the visitor to the admin login first), * mints a single-use ticket, and the finish leg — reached back through the * dev proxy — sets this host's own session cookie. * * With that cookie present, this middleware asks the backend's `auth/me` who * the editor is and splices the same visual-editing toolbar the platform * splices on preview hosts. Registered only for `staticFrontend` projects * under `astro dev`. */ import { defineMiddleware } from "astro:middleware"; // @ts-ignore - virtual module import virtualConfig from "virtual:emdash/config"; import { cookieHas, EDIT_MODE_COOKIE, injectToolbarHtml, renderToolbar, TOOLBAR_MIN_ROLE, } from "../../visual-editing/index.js"; const SESSION_COOKIE = "astro-session"; const VERDICT_TTL_MS = 30_000; const TRAILING_SLASHES = /\/+$/; const verdicts = new Map(); function cookieValue(header: string | null, name: string): string { if (!header) return ""; for (const part of header.split(";")) { const eq = part.indexOf("="); if (eq === -1) continue; if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); } return ""; } /** The live backend's origin — set only in snapshot-live (live-connected) mode. */ function backendOrigin(): string { const database = (virtualConfig as { database?: { entrypoint?: string; config?: unknown } }) ?.database; if (!database?.entrypoint?.endsWith("/snapshot-live")) return ""; const cfg = (database.config ?? {}) as { url?: string }; return typeof cfg.url === "string" ? cfg.url.replace(TRAILING_SLASHES, "") : ""; } async function isEditor(backend: string, session: string): Promise { const cached = verdicts.get(session); if (cached && cached.exp > Date.now()) return cached.editor; let editor = false; try { const me = await fetch(`${backend}/_emdash/api/auth/me`, { headers: { cookie: `${SESSION_COOKIE}=${session}`, accept: "application/json", "X-EmDash-Request": "1", }, }); if (me.ok) { const body = (await me.json()) as { data?: { role?: unknown } } | null; editor = typeof body?.data?.role === "number" && body.data.role >= TOOLBAR_MIN_ROLE; } } catch { // backend unreachable — treat as signed out } verdicts.set(session, { editor, exp: Date.now() + VERDICT_TTL_MS }); return editor; } function signInPill(startUrl: string): string { return ` `; } export const onRequest = defineMiddleware(async (context, next) => { if (!import.meta.env.DEV) return next(); const { request, url } = context; if (request.method !== "GET" || url.pathname.startsWith("/_emdash")) return next(); // Astro's dev server hands prerendered-page middleware a Request with no // accept header, so only an explicitly non-HTML accept opts out here; the // content-type check below is the real page gate. const accept = request.headers.get("accept"); if (accept && !accept.includes("text/html") && !accept.includes("*/*")) return next(); const backend = backendOrigin(); if (!backend) return next(); const cookie = request.headers.get("cookie"); const session = cookieValue(cookie, SESSION_COOKIE); const response = await next(); if (!(response.headers.get("content-type") ?? "").includes("text/html")) return response; if (session && (await isEditor(backend, session))) { const toolbar = renderToolbar({ editMode: cookieHas(cookie, EDIT_MODE_COOKIE, "true"), isPreview: true, }); const out = new Response(injectToolbarHtml(await response.text(), toolbar), response); out.headers.set("cache-control", "private, no-store"); out.headers.delete("content-length"); return out; } const startUrl = `${backend}/_emdash/preview-session/start?to=${encodeURIComponent(url.href)}`; const out = new Response(injectToolbarHtml(await response.text(), signInPill(startUrl)), response); out.headers.delete("content-length"); return out; }); export default onRequest;