/**
* EmDash Astro Integration
*
* This integration:
* - Injects the admin shell route at /_emdash/admin/[...path].astro
* - Sets up REST API endpoints under /_emdash/api/*
* - Configures middleware to provide database and manifest
*
* NOTE: This file is for build-time only. Runtime utilities are in runtime.ts
* to avoid bundling Node.js-only code into the production build.
*/
import { createRequire } from "node:module";
import type { AstroIntegration, AstroIntegrationLogger } from "astro";
import { validateAllowedOrigins, validateOriginShape } from "../../auth/allowed-origins.js";
import { INTERNAL_MEDIA_PREFIX } from "../../media/normalize.js";
import type { ResolvedPlugin } from "../../plugins/types.js";
import { VERSION } from "../../version.js";
import { local } from "../storage/adapters.js";
import { notoSans } from "./font-provider.js";
import {
injectCoreRoutes,
injectBuiltinAuthRoutes,
injectAuthProviderRoutes,
injectMcpRoute,
} from "./routes.js";
import type { EmDashConfig } from "./runtime.js";
import { createViteConfig } from "./vite-config.js";
// Re-export runtime types and functions
export type {
EmDashConfig,
PluginDescriptor,
SandboxedPluginDescriptor,
ResolvedPlugin,
} from "./runtime.js";
export { getStoredConfig } from "./runtime.js";
/**
* Resolve the version of Astro the host project is building with, by reading
* `astro/package.json` from the project's own dependency tree. Surfaced to the
* admin and the registry install gate so a plugin's `env:astro` constraint can
* be evaluated against the real host version. Returns `undefined` if Astro
* can't be resolved (shouldn't happen in a real build, but never throw here).
*/
function resolveAstroVersion(): string | undefined {
try {
const require = createRequire(import.meta.url);
const pkg = require("astro/package.json") as { version?: unknown };
return typeof pkg.version === "string" ? pkg.version : undefined;
} catch {
return undefined;
}
}
/** Default storage: Local filesystem in .emdash directory */
const DEFAULT_STORAGE = local({
directory: "./.emdash/uploads",
baseUrl: "/_emdash/api/media/file",
});
interface ImageRemotePattern {
protocol?: "http" | "https";
hostname?: string;
pathname?: string;
}
/**
* Build `image.remotePatterns` entries so Astro will optimize EmDash media.
*
* Astro's image services only build a transform URL for sources allowed via
* `image.domains` / `image.remotePatterns` (relative URLs are never optimized —
* see `isRemoteAllowed`). We authorize the media sources automatically:
*
* 1. The storage adapter's public URL host (R2 custom domain, S3/CDN).
* 2. The site's own origin, scoped to the media proxy route
* (`/_emdash/api/media/file/**`), so same-origin proxied media is optimized.
* The components absolutize the media URL against this origin; EmDash's
* wrapped image endpoint then serves the bytes from storage (so the absolute
* URL is never fetched). Only registered when `siteUrl` is known at build.
* 3. In `astro dev` the dev-server origin isn't known at build time, so we
* register a host-agnostic pattern scoped to the media route. Dev-only.
*
* Returns an empty array when no source is statically known (production build,
* local storage, no `siteUrl`), in which case media renders as a plain `
`.
*
* @internal Exported for unit testing.
*/
export function buildImageRemotePatterns(
storage: { config?: unknown } | undefined,
siteUrl: string | undefined,
command: "dev" | "build" | "preview" | "sync",
): ImageRemotePattern[] {
const patterns: ImageRemotePattern[] = [];
const config = storage?.config;
const publicUrl =
config && typeof config === "object"
? (config as { publicUrl?: unknown }).publicUrl
: undefined;
if (typeof publicUrl === "string" && publicUrl) {
try {
const url = new URL(publicUrl);
// Only authorize http(s) hosts — a `file:`/`ftp:` URL is not a media
// origin Astro can fetch.
if (url.protocol === "http:" || url.protocol === "https:") {
const pattern: ImageRemotePattern = {
protocol: url.protocol === "http:" ? "http" : "https",
hostname: url.hostname,
};
// When the public URL has a path prefix (CDN sub-path), scope the
// pattern to it so we don't authorize the entire host. Media keys
// are appended as `${publicUrl}/${key}`, so the prefix is exact.
const prefix = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
if (prefix && prefix !== "/") {
pattern.pathname = `${prefix}/**`;
}
patterns.push(pattern);
}
} catch {
// ignore an unparseable public URL
}
}
if (siteUrl) {
try {
patterns.push({
hostname: new URL(siteUrl).hostname,
pathname: `${INTERNAL_MEDIA_PREFIX}**`,
});
} catch {
// ignore an unparseable site URL
}
}
if (command === "dev") {
patterns.push({ pathname: `${INTERNAL_MEDIA_PREFIX}**` });
}
return patterns;
}
/**
* Stock image endpoints EmDash may safely replace with its storage-backed
* wrapper. Our wrapper delegates non-EmDash images to the platform's transform
* endpoint, so we only override endpoints whose transform we can delegate to.
*/
const OVERRIDABLE_IMAGE_ENDPOINTS = new Set([
"astro/assets/endpoint/generic",
"astro/assets/endpoint/node",
"astro/assets/endpoint/dev",
"@astrojs/cloudflare/image-transform-endpoint",
]);
/**
* Stock endpoints that deliberately don't transform (the user opted into
* passthrough). We leave these untouched -- and without a warning, since it's a
* supported choice, not a custom endpoint. Overriding would route non-EmDash
* images through a transformer the passthrough setup doesn't provide.
*/
const PASSTHROUGH_IMAGE_ENDPOINTS = new Set(["@astrojs/cloudflare/image-passthrough-endpoint"]);
/**
* Decide which image endpoint to install (if any). EmDash wraps Astro's image
* endpoint so EmDash media bytes load from storage; the wrapper delegates other
* images back to the platform's stock endpoint.
*
* Returns `{ entrypoint }` to install, `{ warn }` to skip with a warning (a
* custom endpoint we can't delegate to), or `{}` to skip silently (opted out).
*
* @internal Exported for unit testing.
*/
export function resolveImageEndpoint(opts: {
imagesDisabled: boolean;
currentEntrypoint: string | undefined;
isCloudflare: boolean;
}): { entrypoint?: string; warn?: string } {
if (opts.imagesDisabled) return {};
const current = opts.currentEntrypoint;
if (current === undefined || OVERRIDABLE_IMAGE_ENDPOINTS.has(current)) {
return {
entrypoint: opts.isCloudflare
? "@emdash-cms/cloudflare/image-endpoint"
: "emdash/image-endpoint",
};
}
// A deliberate passthrough setup: leave it alone, no warning.
if (PASSTHROUGH_IMAGE_ENDPOINTS.has(current)) return {};
return {
warn:
`A custom image.endpoint (${current}) is configured; EmDash will not wrap ` +
`it, so storage-backed media may render unoptimized.`,
};
}
// Terminal formatting
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;
/** Print the EmDash startup banner */
function printBanner(_logger: AstroIntegrationLogger): void {
const banner = `
${bold(cyan("— E M D A S H —"))} ${dim(`v${VERSION}`)}
`;
console.log(banner);
}
/**
* Print dev-server route info with absolute (clickable) URLs, including the
* dev-bypass shortcut that skips passkey auth. Dev only -- the dev-bypass
* endpoint returns 403 in production.
*/
function printDevServerInfo(baseUrl: string, mcpEnabled: boolean): void {
const devBypassUrl = `${baseUrl}/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin`;
console.log(`\n ${dim("›")} Admin UI ${cyan(`${baseUrl}/_emdash/admin`)}`);
if (mcpEnabled) {
console.log(` ${dim("›")} MCP server ${cyan(`${baseUrl}/_emdash/api/mcp`)}`);
}
console.log(` ${dim("›")} Dev bypass ${cyan(devBypassUrl)}`);
console.log(` ${dim("Skips passkey setup/auth and signs you in as a dev admin")}`);
console.log("");
}
/**
* Create the EmDash Astro integration
*/
export function emdash(config: EmDashConfig = {}): AstroIntegration {
// Apply defaults
const resolvedConfig: EmDashConfig = {
...config,
storage: config.storage ?? DEFAULT_STORAGE,
};
// Validate marketplace URL
if (resolvedConfig.marketplace) {
const url = resolvedConfig.marketplace;
try {
const parsed = new URL(url);
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.protocol !== "https:" && !isLocalhost) {
throw new Error(
`Marketplace URL must use HTTPS (got ${parsed.protocol}). ` +
`Only localhost URLs are allowed over HTTP.`,
);
}
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`Invalid marketplace URL: "${url}"`, { cause: e });
}
throw e;
}
if (!resolvedConfig.sandboxRunner) {
throw new Error(
"Marketplace requires `sandboxRunner` to be configured. " +
"Marketplace plugins run in sandboxed V8 isolates.",
);
}
}
// Validate siteUrl if provided in astro.config.mjs.
// Env-var fallback (EMDASH_SITE_URL / SITE_URL) is handled at runtime by
// getPublicOrigin() in api/public-url.ts — NOT here — so Docker images built
// without a domain can pick it up at container start via process.env.
if (resolvedConfig.siteUrl) {
const raw = resolvedConfig.siteUrl;
try {
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`siteUrl must be http or https (got ${parsed.protocol})`);
}
// Always store origin-normalized value (no path) — security invariant L-1
resolvedConfig.siteUrl = parsed.origin;
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`Invalid siteUrl: "${raw}"`, { cause: e });
}
throw e;
}
}
// Validate config.allowedOrigins shape at startup (per-entry rules: parseable,
// http(s), no trailing dots, no empty labels). The siteUrl-dependent rules
// (Rule A: requires siteUrl; Rule B: must be a subdomain of siteUrl) are
// deferred to runtime when config.siteUrl is absent — EMDASH_SITE_URL may
// supply it post-build, just like the env-var fallback for siteUrl above.
// When config.siteUrl IS present, run the full validator here for fail-fast.
if (resolvedConfig.allowedOrigins?.length) {
const tagged = resolvedConfig.allowedOrigins.map((origin) => ({
origin,
source: "config.allowedOrigins" as const,
}));
resolvedConfig.allowedOrigins = resolvedConfig.siteUrl
? validateAllowedOrigins(resolvedConfig.siteUrl, tagged)
: validateOriginShape(tagged);
}
// Plugin descriptors from config
const pluginDescriptors = resolvedConfig.plugins ?? [];
const sandboxedDescriptors = resolvedConfig.sandboxed ?? [];
// Validate all plugin descriptors
for (const descriptor of [...pluginDescriptors, ...sandboxedDescriptors]) {
// Standard-format plugins can't use features that require trusted mode
if (descriptor.format === "standard") {
if (descriptor.adminEntry) {
throw new Error(
`Plugin "${descriptor.id}" is standard format but declares adminEntry. ` +
`Standard plugins use Block Kit for admin UI, not React components. ` +
`Remove adminEntry or change format to "native".`,
);
}
if (descriptor.componentsEntry) {
throw new Error(
`Plugin "${descriptor.id}" is standard format but declares componentsEntry. ` +
`Portable Text block components require native format. ` +
`Remove componentsEntry or change format to "native".`,
);
}
}
}
// Validate: non-standard plugins cannot be placed in sandboxed: []
for (const descriptor of sandboxedDescriptors) {
if (descriptor.format !== "standard") {
throw new Error(
`Plugin "${descriptor.id}" uses the native format and cannot be placed in ` +
`\`sandboxed: []\`. Native plugins can only run in \`plugins: []\`. ` +
`To sandbox this plugin, convert it to the standard format.`,
);
}
}
// Resolved plugins (populated at build time by importing entrypoints)
let _resolvedPlugins: ResolvedPlugin[] = [];
// Serialize config for virtual module (database/storage/auth - plugins handled separately)
// i18n is populated in astro:config:setup from astroConfig.i18n
const serializableConfig: Record = {
database: resolvedConfig.database,
storage: resolvedConfig.storage,
auth: resolvedConfig.auth,
authProviders: resolvedConfig.authProviders,
marketplace: resolvedConfig.marketplace,
experimental: resolvedConfig.experimental,
siteUrl: resolvedConfig.siteUrl,
trustedProxyHeaders: resolvedConfig.trustedProxyHeaders,
maxUploadSize: resolvedConfig.maxUploadSize,
admin: resolvedConfig.admin,
};
// Determine auth mode for route injection
// Check if auth is an AuthDescriptor (has entrypoint) indicating external auth
const useExternalAuth = !!(resolvedConfig.auth && "entrypoint" in resolvedConfig.auth);
// Captured in astro:config:setup so the astro:server:setup hook can tell
// whether we're running `astro dev` (where the dev-bypass shortcut applies).
let astroCommand: "dev" | "build" | "preview" | "sync" | undefined;
return {
name: "emdash",
hooks: {
"astro:config:setup": ({
injectRoute,
addMiddleware,
logger,
updateConfig,
config: astroConfig,
command,
}) => {
astroCommand = command;
printBanner(logger);
// Capture the host's Astro version so the runtime can expose it
// to the admin and the registry install gate for `env:astro`
// constraint checks.
const astroVersion = resolveAstroVersion();
if (astroVersion !== undefined) {
serializableConfig.astroVersion = astroVersion;
}
// Extract i18n config from Astro config
// Astro locales can be strings OR { path, codes } objects — normalize to paths
if (astroConfig.i18n) {
const routing = astroConfig.i18n.routing;
serializableConfig.i18n = {
defaultLocale: astroConfig.i18n.defaultLocale,
locales: astroConfig.i18n.locales.map((l) => (typeof l === "string" ? l : l.path)),
fallback: astroConfig.i18n.fallback,
prefixDefaultLocale:
typeof routing === "object" ? (routing.prefixDefaultLocale ?? false) : false,
};
}
// Disable Astro's built-in checkOrigin -- EmDash's own CSRF
// layer (checkPublicCsrf in api/csrf.ts) handles origin
// validation with dual-origin support: it accepts both the
// internal origin AND the public origin from getPublicOrigin(),
// which resolves siteUrl from config or env vars at runtime.
// Astro's check can't do this because allowedDomains is baked
// at build time, which breaks Docker deployments where the
// domain is only known at container start via EMDASH_SITE_URL.
//
// When siteUrl is known at build time, also set allowedDomains
// so Astro.url reflects the public origin (helps user template
// code that reads Astro.url directly).
const securityConfig: Record = {
checkOrigin: false,
...(resolvedConfig.siteUrl
? {
allowedDomains: [{ hostname: new URL(resolvedConfig.siteUrl).hostname }],
}
: {}),
};
// Inject default Noto Sans font for the admin UI.
// Uses the Astro Font API so fonts are downloaded at build time
// and self-hosted (no runtime CDN requests).
//
// The admin CSS references var(--font-emdash) with a system font
// fallback. Users can add extra script coverage (Arabic, CJK, etc.)
// by passing fonts.scripts in the emdash() config. The custom
// notoSans provider resolves all script families from Google Fonts
// under a single font-family name, so they stack via unicode-range.
const fontsConfig = resolvedConfig.fonts;
const emdashFonts =
fontsConfig === false
? []
: [
{
provider: notoSans({
scripts: fontsConfig?.scripts,
}),
name: "Noto Sans",
cssVariable: "--font-emdash",
weights: ["100 900" as const],
styles: ["normal" as const, "italic" as const],
subsets: [
"latin" as const,
"latin-ext" as const,
"cyrillic" as const,
"cyrillic-ext" as const,
"devanagari" as const,
"greek" as const,
"greek-ext" as const,
"vietnamese" as const,
],
fallbacks: ["ui-sans-serif", "system-ui", "sans-serif"],
},
];
// Authorize media sources so Astro's image service builds transform
// URLs for them (it won't optimize an un-allowed source). `updateConfig`
// merges arrays, so user-configured remotePatterns are preserved.
const imageRemotePatterns = buildImageRemotePatterns(
resolvedConfig.storage,
resolvedConfig.siteUrl,
command,
);
// Wrap Astro's image endpoint so EmDash media bytes load straight from
// storage (Access-safe) instead of over HTTP. Skip when the user opts
// out or has a custom endpoint we can't delegate back to.
const { entrypoint: imageEndpoint, warn: imageEndpointWarning } = resolveImageEndpoint({
imagesDisabled: resolvedConfig.images === false,
currentEntrypoint: astroConfig.image?.endpoint?.entrypoint,
isCloudflare: astroConfig.adapter?.name === "@astrojs/cloudflare",
});
if (imageEndpointWarning) logger.warn(imageEndpointWarning);
const imageConfig: Record = {};
if (imageRemotePatterns.length) imageConfig.remotePatterns = imageRemotePatterns;
if (imageEndpoint) imageConfig.endpoint = { entrypoint: imageEndpoint };
updateConfig({
security: securityConfig,
...(Object.keys(imageConfig).length ? { image: imageConfig } : {}),
// fonts is a valid AstroConfig key but may not be in the
// type definition for the minimum supported Astro version
...({ fonts: emdashFonts } as Record),
vite: createViteConfig(
{
serializableConfig,
resolvedConfig,
pluginDescriptors,
astroConfig,
},
command,
),
});
// Inject all core routes
injectCoreRoutes(injectRoute, { srcDir: astroConfig.srcDir });
// Inject routes from pluggable auth providers (authProviders config)
if (resolvedConfig.authProviders?.length) {
injectAuthProviderRoutes(injectRoute, resolvedConfig.authProviders);
}
// Inject passkey/oauth/magic-link routes unless transparent external auth is active
if (!useExternalAuth) {
injectBuiltinAuthRoutes(injectRoute);
}
// Inject MCP endpoint (always on — bearer-token-only, no cost if unused)
if (resolvedConfig.mcp !== false) {
injectMcpRoute(injectRoute);
}
// In playground mode, inject the playground middleware FIRST.
// It sets up a per-session DO database in ALS before anything
// else runs, so the runtime init middleware sees a real DB.
if (resolvedConfig.playground) {
addMiddleware({
entrypoint: resolvedConfig.playground.middlewareEntrypoint,
order: "pre",
});
}
// Add middleware to provide database and manifest
addMiddleware({
entrypoint: "emdash/middleware",
order: "pre",
});
// Add redirect middleware (runs after runtime init, before setup/auth)
addMiddleware({
entrypoint: "emdash/middleware/redirect",
order: "pre",
});
// Skip setup and auth in playground mode -- the playground middleware
// handles session creation and injects an anonymous admin user.
if (!resolvedConfig.playground) {
addMiddleware({
entrypoint: "emdash/middleware/setup",
order: "pre",
});
addMiddleware({
entrypoint: "emdash/middleware/auth",
order: "pre",
});
}
// Add request context middleware (runs after auth, on ALL routes)
// Sets up ALS-based context for query functions (edit mode, preview)
addMiddleware({
entrypoint: "emdash/middleware/request-context",
order: "pre",
});
// Route info is printed with absolute, clickable URLs once the
// dev server is listening (see astro:server:setup), since the
// port isn't known yet here. Nothing useful to print for build.
},
"astro:server:setup": ({ server, logger }) => {
// Print route info with absolute, clickable URLs once the server
// is listening. Only in `astro dev` -- the dev-bypass shortcut is
// dev-only and the port is unknown until now.
if (astroCommand === "dev") {
server.httpServer?.once("listening", () => {
const address = server.httpServer?.address();
if (!address || typeof address === "string") return;
let host = address.address;
if (host === "::1" || host === "::" || host === "0.0.0.0") {
host = "localhost";
} else if (address.family === "IPv6") {
host = `[${host}]`;
}
printDevServerInfo(`http://${host}:${address.port}`, resolvedConfig.mcp !== false);
});
}
// Generate types once the server is listening.
// The endpoint returns the types content; we write the file here
// (in Node) because workerd has no real filesystem access.
server.httpServer?.once("listening", async () => {
const { writeFile, readFile } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const address = server.httpServer?.address();
if (!address || typeof address === "string") return;
const port = address.port;
const typegenUrl = `http://localhost:${port}/_emdash/api/typegen`;
const outputPath = resolve(process.cwd(), "emdash-env.d.ts");
try {
const response = await fetch(typegenUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const body = await response.text().catch(() => "");
logger.warn(`Typegen failed: ${response.status} ${body.slice(0, 200)}`);
return;
}
const { data: result } = (await response.json()) as {
data: {
types: string;
hash: string;
collections: number;
};
};
// Only write if content changed
let needsWrite = true;
try {
const existing = await readFile(outputPath, "utf-8");
if (existing === result.types) needsWrite = false;
} catch {
// File doesn't exist yet
}
if (needsWrite) {
await writeFile(outputPath, result.types, "utf-8");
logger.info(`Generated emdash-env.d.ts (${result.collections} collections)`);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.warn(`Typegen failed: ${msg}`);
}
});
},
"astro:build:done": ({ logger }) => {
logger.info("Build complete");
},
},
};
}
export default emdash;