{"version":3,"file":"request-context.mjs","names":[],"sources":["../../../src/astro/middleware/request-context.ts"],"sourcesContent":["/**\n * EmDash Request Context Middleware\n *\n * Sets up AsyncLocalStorage-based request context for query functions.\n * Skips ALS entirely for logged-out users with no CMS signals (fast path).\n *\n * Handles:\n * - Preview tokens: _preview query param with signed HMAC token\n * - Edit mode: emdash-edit-mode cookie (for visual editing)\n * - Toolbar injection: floating pill for authenticated editors\n * - Client toolbar mode (`toolbar: \"client\"`): cache-identical HTML with a\n *   client-side bootstrap pill and an `_edit` query param for fresh editor\n *   renders (Discussion #1742)\n */\n\nimport type { APIContext } from \"astro\";\nimport { defineMiddleware } from \"astro:middleware\";\n// @ts-ignore - virtual module\nimport virtualConfig from \"virtual:emdash/config\";\n\nimport { resolveSecretsCached } from \"#config/secrets.js\";\n\nimport { verifyPreviewToken, parseContentId } from \"../../preview/tokens.js\";\nimport { getRequestContext, runWithContext } from \"../../request-context.js\";\nimport { EDIT_PARAM, renderToolbarBootstrap } from \"../../visual-editing/toolbar-bootstrap.js\";\nimport { renderToolbar } from \"../../visual-editing/toolbar.js\";\n\ntype ToolbarMode = \"server\" | \"client\" | false;\n\nconst toolbarMode: ToolbarMode = virtualConfig?.toolbar ?? \"server\";\n\n/** Astro's route-cache handle. EmDash requires Astro 6+, so it's always present. */\ntype RouteCache = APIContext[\"cache\"];\n\n/**\n * Opt the current request out of Astro's route cache (e.g. Workers Cache on\n * Cloudflare). `Cache-Control` headers do NOT cover this: the adapter derives\n * the shared-cache TTL from the route-cache options (on Cloudflare via\n * `Cloudflare-CDN-Cache-Control`), so session-specific responses must\n * explicitly disable it or they get stored in the shared cache and served to\n * anonymous visitors without ever invoking the middleware again. With no cache\n * provider configured this is a no-op (`NoopAstroCache`/`DisabledAstroCache`).\n */\nfunction optOutOfRouteCache(cache: RouteCache): void {\n\tcache.set(false);\n}\n\n/**\n * Inject HTML before `</body>` if the response is an HTML page with a body\n * end tag. Does not touch cache headers — callers decide whether the result\n * is still shareable. `injected` tells the caller whether anything changed.\n */\nasync function injectBeforeBodyEnd(\n\tresponse: Response,\n\thtmlToInject: string,\n): Promise<{ response: Response; injected: boolean }> {\n\tconst contentType = response.headers.get(\"content-type\");\n\tif (!contentType?.includes(\"text/html\")) return { response, injected: false };\n\n\tconst html = await response.text();\n\tif (!html.includes(\"</body>\")) {\n\t\t// Body already consumed — rebuild the response unchanged.\n\t\treturn { response: new Response(html, response), injected: false };\n\t}\n\n\tconst injected = html.replace(\"</body>\", `${htmlToInject}</body>`);\n\treturn {\n\t\tresponse: new Response(injected, {\n\t\t\tstatus: response.status,\n\t\t\theaders: response.headers,\n\t\t}),\n\t\tinjected: true,\n\t};\n}\n\n/**\n * Inject toolbar HTML into a response if it's an HTML page.\n * Returns the original response if not HTML.\n */\nasync function injectToolbar(\n\tresponse: Response,\n\ttoolbarHtml: string,\n\trouteCache: RouteCache,\n): Promise<Response> {\n\tconst result = await injectBeforeBodyEnd(response, toolbarHtml);\n\tif (result.injected) {\n\t\t// Toolbar-injected HTML is session-specific (its presence reveals an\n\t\t// active editor session); it must never be stored in a shared CDN cache\n\t\t// and served to anonymous visitors. Mirrors the preview branch's guard\n\t\t// (#1398). `Cache-Control` covers browsers/downstream proxies; the\n\t\t// route-cache opt-out covers the shared edge cache, which ignores\n\t\t// `Cache-Control`.\n\t\tresult.response.headers.set(\"Cache-Control\", \"private, no-store\");\n\t\toptOutOfRouteCache(routeCache);\n\t}\n\treturn result.response;\n}\n\n/**\n * Inject the client-toolbar bootstrap script. Identical for every visitor, so\n * cache headers and route-cache options are left untouched and the response\n * stays fully shareable.\n */\nasync function injectBootstrap(response: Response): Promise<Response> {\n\tconst result = await injectBeforeBodyEnd(response, renderToolbarBootstrap());\n\treturn result.response;\n}\n\n/**\n * Redirect an `_edit` URL to its canonical form (same URL without the param).\n * Applied when the requester is not an authenticated editor, so a shared\n * `?_edit` link degrades gracefully for everyone else (Discussion #1742).\n */\nfunction redirectToCanonical(url: URL): Response {\n\tconst canonical = new URL(url);\n\tcanonical.searchParams.delete(EDIT_PARAM);\n\treturn new Response(null, {\n\t\tstatus: 302,\n\t\theaders: {\n\t\t\tLocation: canonical.pathname + canonical.search + canonical.hash,\n\t\t\t// Header-following caches (Fastly, Varnish, browsers) must not store\n\t\t\t// the redirect — a cached 302 would bounce editors back to the\n\t\t\t// canonical URL. The route-cache opt-out at the call site covers the\n\t\t\t// Workers Cache, which ignores Cache-Control.\n\t\t\t\"Cache-Control\": \"private, no-store\",\n\t\t},\n\t});\n}\n\nexport const onRequest = defineMiddleware(async (context, next) => {\n\tconst { cookies, url } = context;\n\n\t// Skip /_emdash routes (admin has its own UI, no rendering context needed)\n\tif (url.pathname.startsWith(\"/_emdash\")) {\n\t\treturn next();\n\t}\n\n\t// Check for authenticated editor (role >= 30)\n\tconst { user } = context.locals;\n\tconst isEditor = !!user && user.role >= 30;\n\n\t// Playground mode: the playground middleware (from @premium-cms/cloudflare) stashes\n\t// the per-session DO database on locals.__playgroundDb. We set it via ALS here\n\t// (same module instance as the loader) so getDb() picks it up correctly.\n\t//\n\t// `dbIsIsolated: true` tells schema-derived caches (manifest, taxonomy defs,\n\t// byline/term existence probes) to bypass module-scope memoization — each\n\t// playground session is its own database with its own schema, so a cached\n\t// value from another session would be wrong.\n\tconst playgroundDb = context.locals.__playgroundDb;\n\tif (playgroundDb) {\n\t\t// Check if playground user has toggled edit mode on\n\t\tconst hasEditCookie = cookies.get(\"emdash-edit-mode\")?.value === \"true\";\n\t\treturn runWithContext({ editMode: hasEditCookie, db: playgroundDb, dbIsIsolated: true }, () =>\n\t\t\tnext(),\n\t\t);\n\t}\n\n\t// Fast path: check for CMS signals before doing any work\n\tconst hasEditCookie = cookies.get(\"emdash-edit-mode\")?.value === \"true\";\n\tconst hasPreviewToken = url.searchParams.has(\"_preview\");\n\t// `_edit` requests a fresh (never cached) editor render; only meaningful in\n\t// client toolbar mode where public HTML is otherwise identical for everyone.\n\tconst hasEditParam = toolbarMode === \"client\" && url.searchParams.has(EDIT_PARAM);\n\n\tif (hasEditParam) {\n\t\t// `_edit` URLs are their own cache key. Never store them in the route\n\t\t// cache — a cached anonymous redirect would bounce editors back to the\n\t\t// canonical URL, and a cached editor render must never be shared.\n\t\toptOutOfRouteCache(context.cache);\n\n\t\t// A non-editor (anonymous, logged-out, or insufficient role) opening an\n\t\t// `_edit` URL is sent to the canonical URL — shared `?_edit` links\n\t\t// degrade gracefully and never prime a cache entry with page content.\n\t\tif (!isEditor) {\n\t\t\treturn redirectToCanonical(url);\n\t\t}\n\t}\n\n\t// No CMS signals and not an editor → skip everything (zero overhead in\n\t// server mode; client mode injects the identical-for-everyone bootstrap)\n\tif (!hasEditCookie && !hasPreviewToken && !isEditor) {\n\t\tif (toolbarMode === \"client\") {\n\t\t\treturn injectBootstrap(await next());\n\t\t}\n\t\treturn next();\n\t}\n\n\t// Determine edit mode: cookie AND authenticated editor\n\tconst editMode = hasEditCookie && isEditor;\n\n\t// Read locale from Astro's i18n routing\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Astro context includes currentLocale when i18n is configured\n\tconst locale = (context as { currentLocale?: string }).currentLocale;\n\n\tconst routeCache = context.cache;\n\n\t// Verify preview token if present.\n\t// The preview secret is resolved via `resolveSecretsCached`: env wins,\n\t// otherwise a DB-stored value is read (or generated on first need).\n\t// `emdash.db` is set by the runtime middleware which runs first; the\n\t// only path where it's missing is a runtime-init failure.\n\tlet preview: { collection: string; id: string } | undefined;\n\tif (hasPreviewToken) {\n\t\tconst db = context.locals.emdash?.db;\n\t\tif (db) {\n\t\t\tconst { previewSecret } = await resolveSecretsCached(db);\n\t\t\tconst result = await verifyPreviewToken({ url, secret: previewSecret });\n\t\t\tif (result.valid) {\n\t\t\t\tconst { collection, id } = parseContentId(result.payload.cid);\n\t\t\t\tpreview = { collection, id };\n\t\t\t}\n\t\t} else {\n\t\t\tconsole.warn(\n\t\t\t\t\"[emdash] Preview token present but EmDash runtime not initialized; preview disabled.\",\n\t\t\t);\n\t\t}\n\t}\n\n\t// If we have CMS signals, wrap in ALS context\n\tconst needsContext = hasEditCookie || hasPreviewToken;\n\n\tif (needsContext) {\n\t\t// Merge with any outer ALS context (e.g. the per-request D1 session db\n\t\t// set by the runtime middleware). `storage.run()` replaces the store\n\t\t// wholesale, so without the spread the outer `db` would be lost and\n\t\t// loaders would fall back to the singleton non-session dialect.\n\t\tconst parent = getRequestContext();\n\t\treturn runWithContext({ ...parent, editMode, preview, locale }, async () => {\n\t\t\tlet response = await next();\n\n\t\t\t// Preview responses must not be cached -- draft content could leak past token expiry.\n\t\t\t// Clone the response before modifying headers — the original may be immutable.\n\t\t\t// `Cache-Control` only governs browsers/downstream proxies; the shared\n\t\t\t// edge cache follows the route-cache options, so opt out of those too —\n\t\t\t// otherwise the draft response is stored in the shared cache and served\n\t\t\t// on cache hits without token verification until TTL/purge. Opt out for\n\t\t\t// any request carrying a `_preview` param (valid or not): those URLs are\n\t\t\t// per-token, so cached copies are useless at best and drafts at worst.\n\t\t\tif (hasPreviewToken) {\n\t\t\t\toptOutOfRouteCache(routeCache);\n\t\t\t}\n\t\t\tif (preview) {\n\t\t\t\tresponse = new Response(response.body, response);\n\t\t\t\tresponse.headers.set(\"Cache-Control\", \"private, no-store\");\n\t\t\t}\n\n\t\t\t// Inject toolbar for authenticated editors. Preview and edit-mode\n\t\t\t// responses are session-specific (`private, no-store` + route-cache\n\t\t\t// opt-out) in every toolbar mode, so the server toolbar is safe to\n\t\t\t// inject here even in client mode.\n\t\t\tif (isEditor && toolbarMode !== false) {\n\t\t\t\tconst toolbarHtml = renderToolbar({\n\t\t\t\t\teditMode,\n\t\t\t\t\tisPreview: !!preview,\n\t\t\t\t});\n\t\t\t\treturn injectToolbar(response, toolbarHtml, routeCache);\n\t\t\t}\n\n\t\t\t// Stale edit cookie without a session (client mode): still serve the\n\t\t\t// shareable bootstrap variant.\n\t\t\tif (toolbarMode === \"client\" && !isEditor && !preview) {\n\t\t\t\treturn injectBootstrap(response);\n\t\t\t}\n\n\t\t\treturn response;\n\t\t});\n\t}\n\n\t// Editor without preview/edit-mode signals.\n\tif (isEditor) {\n\t\tif (toolbarMode === false) {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Client mode: without the `_edit` param the response must stay\n\t\t// byte-identical to the anonymous variant (plus the same bootstrap\n\t\t// script), so shared caches serve one entry for everyone. The bootstrap\n\t\t// pill is the editor's entry point into the fresh `_edit` render.\n\t\tif (toolbarMode === \"client\" && !hasEditParam) {\n\t\t\treturn injectBootstrap(await next());\n\t\t}\n\n\t\t// Server mode, or an `_edit` request in client mode: inject the full\n\t\t// toolbar (response becomes `private, no-store` and route-cache\n\t\t// opted out).\n\t\tconst response = await next();\n\t\tconst toolbarHtml = renderToolbar({\n\t\t\teditMode: false,\n\t\t\tisPreview: false,\n\t\t});\n\t\treturn injectToolbar(response, toolbarHtml, routeCache);\n\t}\n\n\treturn next();\n});\n\nexport default onRequest;\n"],"mappings":"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"}