/** * EmDash Request Context Middleware * * Sets up AsyncLocalStorage-based request context for query functions. * Skips ALS entirely for logged-out users with no CMS signals (fast path). * * Handles: * - Preview tokens: _preview query param with signed HMAC token * - Edit mode: emdash-edit-mode cookie (for visual editing) * - Toolbar injection: floating pill for authenticated editors */ import { defineMiddleware } from "astro:middleware"; import { resolveSecretsCached } from "#config/secrets.js"; import { verifyPreviewToken, parseContentId } from "../../preview/tokens.js"; import { getRequestContext, runWithContext } from "../../request-context.js"; import { renderToolbar } from "../../visual-editing/toolbar.js"; /** * Inject toolbar HTML into a response if it's an HTML page. * Returns the original response if not HTML. */ async function injectToolbar(response: Response, toolbarHtml: string): Promise { const contentType = response.headers.get("content-type"); if (!contentType?.includes("text/html")) return response; const html = await response.text(); if (!html.includes("")) return new Response(html, response); const injected = html.replace("", `${toolbarHtml}`); const result = new Response(injected, { status: response.status, headers: response.headers, }); // Toolbar-injected HTML is session-specific (its presence reveals an active // editor session); it must never be stored in a shared CDN cache and served // to anonymous visitors. Mirrors the preview branch's guard (#1398). result.headers.set("Cache-Control", "private, no-store"); return result; } export const onRequest = defineMiddleware(async (context, next) => { const { cookies, url } = context; // Skip /_emdash routes (admin has its own UI, no rendering context needed) if (url.pathname.startsWith("/_emdash")) { return next(); } // Check for authenticated editor (role >= 30) const { user } = context.locals; const isEditor = !!user && user.role >= 30; // Playground mode: the playground middleware (from @emdash-cms/cloudflare) stashes // the per-session DO database on locals.__playgroundDb. We set it via ALS here // (same module instance as the loader) so getDb() picks it up correctly. // // `dbIsIsolated: true` tells schema-derived caches (manifest, taxonomy defs, // byline/term existence probes) to bypass module-scope memoization — each // playground session is its own database with its own schema, so a cached // value from another session would be wrong. const playgroundDb = context.locals.__playgroundDb; if (playgroundDb) { // Check if playground user has toggled edit mode on const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true"; return runWithContext({ editMode: hasEditCookie, db: playgroundDb, dbIsIsolated: true }, () => next(), ); } // Fast path: check for CMS signals before doing any work const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true"; const hasPreviewToken = url.searchParams.has("_preview"); // No CMS signals and not an editor → skip everything (zero overhead) if (!hasEditCookie && !hasPreviewToken && !isEditor) { return next(); } // Determine edit mode: cookie AND authenticated editor const editMode = hasEditCookie && isEditor; // Read locale from Astro's i18n routing // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Astro context includes currentLocale when i18n is configured const locale = (context as { currentLocale?: string }).currentLocale; // Verify preview token if present. // The preview secret is resolved via `resolveSecretsCached`: env wins, // otherwise a DB-stored value is read (or generated on first need). // `emdash.db` is set by the runtime middleware which runs first; the // only path where it's missing is a runtime-init failure. let preview: { collection: string; id: string } | undefined; if (hasPreviewToken) { const db = context.locals.emdash?.db; if (db) { const { previewSecret } = await resolveSecretsCached(db); const result = await verifyPreviewToken({ url, secret: previewSecret }); if (result.valid) { const { collection, id } = parseContentId(result.payload.cid); preview = { collection, id }; } } else { console.warn( "[emdash] Preview token present but EmDash runtime not initialized; preview disabled.", ); } } // If we have CMS signals, wrap in ALS context const needsContext = hasEditCookie || hasPreviewToken; if (needsContext) { // Merge with any outer ALS context (e.g. the per-request D1 session db // set by the runtime middleware). `storage.run()` replaces the store // wholesale, so without the spread the outer `db` would be lost and // loaders would fall back to the singleton non-session dialect. const parent = getRequestContext(); return runWithContext({ ...parent, editMode, preview, locale }, async () => { let response = await next(); // Preview responses must not be cached -- draft content could leak past token expiry. // Clone the response before modifying headers — the original may be immutable. if (preview) { response = new Response(response.body, response); response.headers.set("Cache-Control", "private, no-store"); } // Inject toolbar for authenticated editors if (isEditor) { const toolbarHtml = renderToolbar({ editMode, isPreview: !!preview, }); return injectToolbar(response, toolbarHtml); } return response; }); } // Editor without CMS signals — no ALS needed, but inject toolbar if (isEditor) { const response = await next(); const toolbarHtml = renderToolbar({ editMode: false, isPreview: false, }); return injectToolbar(response, toolbarHtml); } return next(); }); export default onRequest;