diff --git a/.changeset/site-url-reverse-proxy.md b/.changeset/site-url-reverse-proxy.md
new file mode 100644
index 000000000..1abf1c12a
--- /dev/null
+++ b/.changeset/site-url-reverse-proxy.md
@@ -0,0 +1,11 @@
+---
+"emdash": minor
+---
+
+Adds `siteUrl` config option to fix reverse-proxy origin mismatch. Replaces `passkeyPublicOrigin` with a single setting that covers all origin-dependent features: passkeys, CSRF, OAuth, auth redirects, MCP discovery, snapshots, sitemap, robots.txt, and JSON-LD.
+
+Supports `EMDASH_SITE_URL` / `SITE_URL` environment variables for container deployments where the domain is only known at runtime.
+
+Disables Astro's `security.checkOrigin` (EmDash's own CSRF layer handles origin validation with dual-origin support and runtime siteUrl resolution). When `siteUrl` is set in config, also sets `security.allowedDomains` so `Astro.url` reflects the public origin in templates.
+
+**Breaking:** `passkeyPublicOrigin` is removed. Rename to `siteUrl` in your `astro.config.mjs`.
diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs
index d77553265..fe222d948 100644
--- a/demos/simple/astro.config.mjs
+++ b/demos/simple/astro.config.mjs
@@ -30,8 +30,8 @@ export default defineConfig({
baseUrl: "/_emdash/api/media/file",
}),
plugins: [auditLogPlugin()],
- // HTTPS reverse proxy: uncomment so passkey verify matches browser origin
- // passkeyPublicOrigin: "https://emdash.local:8443",
+ // HTTPS reverse proxy: uncomment so all origin-dependent features match browser
+ // siteUrl: "https://emdash.local:8443",
}),
],
devToolbar: { enabled: false },
diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx
index e1d537604..c2f911ef5 100644
--- a/docs/src/content/docs/reference/configuration.mdx
+++ b/docs/src/content/docs/reference/configuration.mdx
@@ -200,13 +200,13 @@ Use Cloudflare Access as the authentication provider instead of passkeys.
magic links, and self-signup are disabled.
-### `passkeyPublicOrigin`
+### `siteUrl`
-**Optional.** Pass a full **browser-facing origin** (scheme + host + optional port, **no path**) so WebAuthn **`rpId`** and **`origin`** match what the user’s browser sends in `clientData.origin`.
+**Optional.** The public browser-facing origin for the site (scheme + host + optional port, **no path**).
-By default, passkeys follow **`Astro.url`** / **`request.url`**. Behind a **TLS-terminating reverse proxy**, the app often still sees **`http://`** on the internal hop while the tab is **`https://`**, or the reconstructed host does not match the public name — which breaks passkey verification. Set `passkeyPublicOrigin` to the origin users type in the address bar (for example `https://cms.example.com` or `https://cms.example.com:8443`).
+Behind a **TLS-terminating reverse proxy**, `Astro.url` returns the internal address (`http://localhost:4321`) instead of the public one (`https://cms.example.com`). This breaks passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data. Set `siteUrl` to fix all of these at once.
-The integration **validates** this value at load time: it must be a valid URL with **`http:`** or **`https:`** protocol and is normalized to **`origin`**.
+The integration **validates** this value at load time: it must be a valid URL with **`http:`** or **`https:`** protocol and is normalized to **origin** (path is stripped).
```js
emdash({
@@ -215,17 +215,23 @@ emdash({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
- passkeyPublicOrigin: "https://cms.example.com",
+ siteUrl: "https://cms.example.com",
});
```
-#### Reverse proxy and passkeys
+When `siteUrl` is not set in config, EmDash checks environment variables in order: `EMDASH_SITE_URL`, then `SITE_URL`. This is useful for container deployments where the public URL is set at runtime.
+
+
+ `siteUrl` replaces `passkeyPublicOrigin` (removed). If you were using `passkeyPublicOrigin`, rename it to `siteUrl` -- it now covers passkeys and all other origin-dependent features.
+
+
+#### Reverse proxy setup
Astro only reflects **`X-Forwarded-*`** when the public host is allowed. Configure [**`security.allowedDomains`**](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) for the hostname (and schemes) your users hit. In **`astro dev`**, add matching **`vite.server.allowedHosts`** so Vite accepts the proxy **`Host`** header.
-Prefer fixing **`allowedDomains`** (and forwarded headers) first; use **`passkeyPublicOrigin`** when the reconstructed URL **still** diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays **`http://`**).
+Prefer fixing **`allowedDomains`** (and forwarded headers) first; use **`siteUrl`** when the reconstructed URL **still** diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays **`http://`**).
-With TLS in front, binding the dev server to loopback (**`astro dev --host 127.0.0.1`**) is often enough: the proxy connects locally while **`passkeyPublicOrigin`** matches the public HTTPS origin.
+With TLS in front, binding the dev server to loopback (**`astro dev --host 127.0.0.1`**) is often enough: the proxy connects locally while **`siteUrl`** matches the public HTTPS origin.
Your reverse proxy should forward a **port-aware** `Host` / `X-Forwarded-Host` when you use non-default ports. If the proxy strips the port, **`rpId`** and Astro’s rebuilt URL can be wrong.
@@ -255,7 +261,7 @@ export default defineConfig({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
- passkeyPublicOrigin: "https://cms.example.com",
+ siteUrl: "https://cms.example.com",
}),
],
});
@@ -435,6 +441,7 @@ EmDash respects these environment variables:
| Variable | Description |
| ------------------------- | ---------------------------------------- |
+| `EMDASH_SITE_URL` | Public browser-facing origin (falls back to `SITE_URL`) |
| `EMDASH_DATABASE_URL` | Override database URL |
| `EMDASH_AUTH_SECRET` | Secret for passkey authentication |
| `EMDASH_PREVIEW_SECRET` | Secret for preview token generation |
diff --git a/packages/core/src/api/csrf.ts b/packages/core/src/api/csrf.ts
index f8e433434..d190c01e3 100644
--- a/packages/core/src/api/csrf.ts
+++ b/packages/core/src/api/csrf.ts
@@ -15,15 +15,24 @@ import { apiError } from "./error.js";
*
* State-changing requests (POST/PUT/DELETE) to public endpoints must either:
* 1. Include the X-EmDash-Request: 1 header (custom header blocked cross-origin), OR
- * 2. Have an Origin header matching the request origin
+ * 2. Have an Origin header matching the request origin (or the configured public origin)
*
* This prevents cross-origin form submissions (which can't set custom headers)
* and cross-origin fetch (blocked by CORS unless allowed). Same-origin requests
* always include a matching Origin header.
*
* Returns a 403 Response if the check fails, or null if allowed.
+ *
+ * @param request The incoming request
+ * @param url The request URL (internal origin)
+ * @param publicOrigin The public-facing origin from config.siteUrl. Must be
+ * `undefined` when absent — never `null` or `""` (security invariant H-1a).
*/
-export function checkPublicCsrf(request: Request, url: URL): Response | null {
+export function checkPublicCsrf(
+ request: Request,
+ url: URL,
+ publicOrigin?: string,
+): Response | null {
// Custom header present — browser blocks cross-origin custom headers
const csrfHeader = request.headers.get("X-EmDash-Request");
if (csrfHeader === "1") return null;
@@ -33,7 +42,9 @@ export function checkPublicCsrf(request: Request, url: URL): Response | null {
if (origin) {
try {
const originUrl = new URL(origin);
+ // Accept if Origin matches either the internal or public origin
if (originUrl.origin === url.origin) return null;
+ if (publicOrigin && originUrl.origin === publicOrigin) return null;
} catch {
// Malformed Origin — fall through to reject
}
diff --git a/packages/core/src/api/public-url.ts b/packages/core/src/api/public-url.ts
new file mode 100644
index 000000000..cb5afb9b7
--- /dev/null
+++ b/packages/core/src/api/public-url.ts
@@ -0,0 +1,84 @@
+/**
+ * Public URL helpers for reverse-proxy deployments.
+ *
+ * Behind a TLS-terminating proxy the internal request URL
+ * (`http://localhost:4321`) differs from the browser-facing origin
+ * (`https://mysite.example.com`). These pure helpers resolve the
+ * correct public origin from config, falling back to the request URL.
+ *
+ * Workers-safe: no Node.js imports.
+ */
+
+import type { EmDashConfig } from "../astro/integration/runtime.js";
+
+/**
+ * Resolve siteUrl from runtime environment variables.
+ *
+ * Uses process.env (not import.meta.env) because Vite statically replaces
+ * import.meta.env at build time, baking out any env vars not present during
+ * the build. Container deployments set env vars at runtime, so we must read
+ * process.env which Vite leaves untouched.
+ *
+ * On Cloudflare Workers process.env is unavailable (returns undefined),
+ * so the fallback chain continues to url.origin.
+ *
+ * Caches after first call.
+ */
+let _envSiteUrl: string | undefined | null = null;
+
+/** @internal Reset cached env value — test-only. */
+export function _resetEnvSiteUrlCache(): void {
+ _envSiteUrl = null;
+}
+
+function getEnvSiteUrl(): string | undefined {
+ if (_envSiteUrl !== null) return _envSiteUrl || undefined;
+ try {
+ // process.env is available on Node.js; undefined on Workers
+ const value =
+ (typeof process !== "undefined" && process.env?.EMDASH_SITE_URL) ||
+ (typeof process !== "undefined" && process.env?.SITE_URL) ||
+ "";
+ if (value) {
+ const parsed = new URL(value);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ _envSiteUrl = "";
+ return undefined;
+ }
+ _envSiteUrl = parsed.origin;
+ } else {
+ _envSiteUrl = "";
+ }
+ } catch {
+ _envSiteUrl = "";
+ }
+ return _envSiteUrl || undefined;
+}
+
+/**
+ * Return the public-facing origin for the site.
+ *
+ * Resolution order:
+ * 1. `config.siteUrl` (set in astro.config.mjs, origin-normalized at startup)
+ * 2. `EMDASH_SITE_URL` or `SITE_URL` env var (resolved at runtime for containers)
+ * 3. `url.origin` (internal request URL — correct when no proxy)
+ *
+ * @param url The request URL (`new URL(request.url)` or `Astro.url`)
+ * @param config The EmDash config (from `locals.emdash?.config`)
+ * @returns Origin string, e.g. `"https://mysite.example.com"`
+ */
+export function getPublicOrigin(url: URL, config?: EmDashConfig): string {
+ return config?.siteUrl || getEnvSiteUrl() || url.origin;
+}
+
+/**
+ * Build a full public URL by appending a path to the public origin.
+ *
+ * @param url The request URL
+ * @param config The EmDash config
+ * @param path Path to append (must start with `/`)
+ * @returns Full URL string, e.g. `"https://mysite.example.com/_emdash/admin/login"`
+ */
+export function getPublicUrl(url: URL, config: EmDashConfig | undefined, path: string): string {
+ return `${getPublicOrigin(url, config)}${path}`;
+}
diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts
index 3ed69a2d2..217686a4c 100644
--- a/packages/core/src/astro/integration/index.ts
+++ b/packages/core/src/astro/integration/index.ts
@@ -90,17 +90,22 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
}
}
- if (resolvedConfig.passkeyPublicOrigin) {
- const raw = resolvedConfig.passkeyPublicOrigin;
+ // 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(`passkeyPublicOrigin must be http or https (got ${parsed.protocol})`);
+ throw new Error(`siteUrl must be http or https (got ${parsed.protocol})`);
}
- resolvedConfig.passkeyPublicOrigin = parsed.origin;
+ // 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 passkeyPublicOrigin: "${raw}"`, { cause: e });
+ throw new Error(`Invalid siteUrl: "${raw}"`, { cause: e });
}
throw e;
}
@@ -152,7 +157,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
storage: resolvedConfig.storage,
auth: resolvedConfig.auth,
marketplace: resolvedConfig.marketplace,
- passkeyPublicOrigin: resolvedConfig.passkeyPublicOrigin,
+ siteUrl: resolvedConfig.siteUrl,
};
// Determine auth mode for route injection
@@ -184,8 +189,26 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
};
}
- // Update Vite config with virtual modules and other settings
+ // 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 }] }
+ : {}),
+ };
updateConfig({
+ security: securityConfig,
vite: createViteConfig(
{
serializableConfig,
diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts
index 7b000d738..2b25d6867 100644
--- a/packages/core/src/astro/integration/runtime.ts
+++ b/packages/core/src/astro/integration/runtime.ts
@@ -263,17 +263,19 @@ export interface EmDashConfig {
marketplace?: string;
/**
- * Public browser origin for passkey verification (rpId + expected WebAuthn origin).
+ * Public browser-facing origin for the site.
*
* Use when `Astro.url` / `request.url` do not match what users open — common with a
* **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop
- * while the browser uses `https://`, which breaks WebAuthn origin checks.
+ * while the browser uses `https://`, which breaks WebAuthn, CSRF, OAuth, and redirect URLs.
*
* Set to the full origin users type in the address bar (no path), e.g.
- * `https://emdash.local:8443`. Prefer fixing `security.allowedDomains` for the proxy first;
- * use this when the reconstructed URL still diverges from the browser.
+ * `https://mysite.example.com`. When not set, falls back to environment variables
+ * `EMDASH_SITE_URL` > `SITE_URL`, then to the request URL's origin.
+ *
+ * Replaces `passkeyPublicOrigin` (which only fixed passkeys).
*/
- passkeyPublicOrigin?: string;
+ siteUrl?: string;
/**
* Enable playground mode for ephemeral "try EmDash" sites.
diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts
index 76228d1c6..9fbd4f2da 100644
--- a/packages/core/src/astro/middleware/auth.ts
+++ b/packages/core/src/astro/middleware/auth.ts
@@ -20,6 +20,7 @@ import { authenticate as virtualAuthenticate } from "virtual:emdash/auth";
import { checkPublicCsrf } from "../../api/csrf.js";
import { apiError } from "../../api/error.js";
+import { getPublicOrigin } from "../../api/public-url.js";
/** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */
const MW_CACHE_HEADERS = {
@@ -134,7 +135,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (isPublicApiRoute) {
const method = context.request.method.toUpperCase();
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
- const csrfError = checkPublicCsrf(context.request, url);
+ const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
+ const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
if (csrfError) return csrfError;
}
return next();
@@ -148,7 +150,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (isPluginRoute) {
const method = context.request.method.toUpperCase();
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
- const csrfError = checkPublicCsrf(context.request, url);
+ const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
+ const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
if (csrfError) return csrfError;
}
return handlePluginRouteAuth(context, next);
@@ -192,8 +195,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
};
// Add WWW-Authenticate header on MCP endpoint 401s to trigger OAuth discovery
if (url.pathname === "/_emdash/api/mcp") {
+ const origin = getPublicOrigin(url, context.locals.emdash?.config);
headers["WWW-Authenticate"] =
- `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
+ `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
}
return new Response(
JSON.stringify({ error: { code: "INVALID_TOKEN", message: "Invalid or expired token" } }),
@@ -589,15 +593,16 @@ async function handlePasskeyAuth(
const headers: Record = { ...MW_CACHE_HEADERS };
// Add WWW-Authenticate on MCP endpoint 401s to trigger OAuth discovery
if (url.pathname === "/_emdash/api/mcp") {
+ const origin = getPublicOrigin(url, emdash?.config);
headers["WWW-Authenticate"] =
- `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
+ `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
}
return Response.json(
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
{ status: 401, headers },
);
}
- const loginUrl = new URL("/_emdash/admin/login", url.origin);
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
loginUrl.searchParams.set("redirect", url.pathname);
return context.redirect(loginUrl.toString());
}
@@ -615,7 +620,8 @@ async function handlePasskeyAuth(
{ status: 401, headers: MW_CACHE_HEADERS },
);
}
- return context.redirect("/_emdash/admin/login");
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
+ return context.redirect(loginUrl.toString());
}
// Check if user is disabled
@@ -624,7 +630,7 @@ async function handlePasskeyAuth(
if (isApiRoute) {
return apiError("ACCOUNT_DISABLED", "Account disabled", 403);
}
- const loginUrl = new URL("/_emdash/admin/login", url.origin);
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
loginUrl.searchParams.set("error", "account_disabled");
return context.redirect(loginUrl.toString());
}
diff --git a/packages/core/src/astro/routes/api/auth/invite/complete.ts b/packages/core/src/astro/routes/api/auth/invite/complete.ts
index 8333afb4c..410018d9c 100644
--- a/packages/core/src/astro/routes/api/auth/invite/complete.ts
+++ b/packages/core/src/astro/routes/api/auth/invite/complete.ts
@@ -15,6 +15,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { inviteCompleteBody } from "#api/schemas.js";
import { createChallengeStore } from "#auth/challenge-store.js";
import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -22,7 +23,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
export const POST: APIRoute = async ({ request, locals, session }) => {
const { emdash } = locals;
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -38,7 +38,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
const url = new URL(request.url);
const options = new OptionsRepository(emdash.db);
const siteName = (await options.get("emdash:site_title")) ?? undefined;
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
+ const siteUrl = getPublicOrigin(url, emdash?.config);
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
// Verify the passkey registration response
const challengeStore = createChallengeStore(emdash.db);
diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts
index d8150d5c6..58e6ce174 100644
--- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts
+++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts
@@ -10,6 +10,7 @@ export const prerender = false;
import { createAuthorizationUrl, type OAuthConsumerConfig } from "@emdash-cms/auth";
+import { getPublicOrigin } from "#api/public-url.js";
import { createOAuthStateStore } from "#auth/oauth-state-store.js";
type ProviderName = "github" | "google";
@@ -101,7 +102,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
}
const config: OAuthConsumerConfig = {
- baseUrl: `${url.origin}/_emdash`,
+ baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`,
providers,
};
diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts
index 7c69cd613..c74994cd3 100644
--- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts
+++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts
@@ -17,6 +17,7 @@ import {
} from "@emdash-cms/auth";
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
+import { getPublicOrigin } from "#api/public-url.js";
import { createOAuthStateStore } from "#auth/oauth-state-store.js";
type ProviderName = "github" | "google";
@@ -126,7 +127,7 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect
}
const config: OAuthConsumerConfig = {
- baseUrl: `${url.origin}/_emdash`,
+ baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`,
providers,
canSelfSignup: async (email: string) => {
// Extract domain from email
diff --git a/packages/core/src/astro/routes/api/auth/passkey/options.ts b/packages/core/src/astro/routes/api/auth/passkey/options.ts
index 51fb533b4..b228798c1 100644
--- a/packages/core/src/astro/routes/api/auth/passkey/options.ts
+++ b/packages/core/src/astro/routes/api/auth/passkey/options.ts
@@ -15,6 +15,7 @@ import { generateAuthenticationOptions } from "@emdash-cms/auth/passkey";
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseOptionalBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { passkeyOptionsBody } from "#api/schemas.js";
import { createChallengeStore, cleanupExpiredChallenges } from "#auth/challenge-store.js";
import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -23,7 +24,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
export const POST: APIRoute = async ({ request, locals }) => {
const { emdash } = locals;
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -63,7 +63,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
const url = new URL(request.url);
const options = new OptionsRepository(emdash.db);
const siteName = (await options.get("emdash:site_title")) ?? undefined;
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
+ const siteUrl = getPublicOrigin(url, emdash?.config);
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
// Generate authentication options
const challengeStore = createChallengeStore(emdash.db);
diff --git a/packages/core/src/astro/routes/api/auth/passkey/register/options.ts b/packages/core/src/astro/routes/api/auth/passkey/register/options.ts
index b4b2befa8..376101014 100644
--- a/packages/core/src/astro/routes/api/auth/passkey/register/options.ts
+++ b/packages/core/src/astro/routes/api/auth/passkey/register/options.ts
@@ -13,6 +13,7 @@ import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseOptionalBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { passkeyRegisterOptionsBody } from "#api/schemas.js";
import { createChallengeStore } from "#auth/challenge-store.js";
import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -22,7 +23,6 @@ const MAX_PASSKEYS = 10;
export const POST: APIRoute = async ({ request, locals }) => {
const { emdash, user } = locals;
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -53,7 +53,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
const url = new URL(request.url);
const optionsRepo = new OptionsRepository(emdash.db);
const siteName = (await optionsRepo.get("emdash:site_title")) ?? undefined;
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
+ const siteUrl = getPublicOrigin(url, emdash?.config);
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
// Generate registration options
const challengeStore = createChallengeStore(emdash.db);
diff --git a/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts b/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts
index 56eb123f4..367b32f4b 100644
--- a/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts
+++ b/packages/core/src/astro/routes/api/auth/passkey/register/verify.ts
@@ -13,6 +13,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa
import { apiError, apiSuccess } from "#api/error.js";
import { isParseError, parseBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { passkeyRegisterVerifyBody } from "#api/schemas.js";
import { createChallengeStore } from "#auth/challenge-store.js";
import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -31,7 +32,6 @@ interface PasskeyResponse {
export const POST: APIRoute = async ({ request, locals }) => {
const { emdash, user } = locals;
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -59,7 +59,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
const url = new URL(request.url);
const optionsRepo = new OptionsRepository(emdash.db);
const siteName = (await optionsRepo.get("emdash:site_title")) ?? undefined;
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
+ const siteUrl = getPublicOrigin(url, emdash?.config);
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
// Verify the registration response
const challengeStore = createChallengeStore(emdash.db);
diff --git a/packages/core/src/astro/routes/api/auth/passkey/verify.ts b/packages/core/src/astro/routes/api/auth/passkey/verify.ts
index f8949bffe..fd13ec497 100644
--- a/packages/core/src/astro/routes/api/auth/passkey/verify.ts
+++ b/packages/core/src/astro/routes/api/auth/passkey/verify.ts
@@ -13,6 +13,7 @@ import { authenticateWithPasskey } from "@emdash-cms/auth/passkey";
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { passkeyVerifyBody } from "#api/schemas.js";
import { createChallengeStore } from "#auth/challenge-store.js";
import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -20,7 +21,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
export const POST: APIRoute = async ({ request, locals, session }) => {
const { emdash } = locals;
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -34,7 +34,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
const url = new URL(request.url);
const options = new OptionsRepository(emdash.db);
const siteName = (await options.get("emdash:site_title")) ?? undefined;
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
+ const siteUrl = getPublicOrigin(url, emdash?.config);
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
// Authenticate with passkey
const adapter = createKyselyAdapter(emdash.db);
diff --git a/packages/core/src/astro/routes/api/auth/signup/complete.ts b/packages/core/src/astro/routes/api/auth/signup/complete.ts
index 1af7d6786..2ff865a17 100644
--- a/packages/core/src/astro/routes/api/auth/signup/complete.ts
+++ b/packages/core/src/astro/routes/api/auth/signup/complete.ts
@@ -15,6 +15,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { signupCompleteBody } from "#api/schemas.js";
import { createChallengeStore } from "#auth/challenge-store.js";
import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -22,7 +23,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
export const POST: APIRoute = async ({ request, locals, session }) => {
const { emdash } = locals;
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -38,7 +38,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
const url = new URL(request.url);
const options = new OptionsRepository(emdash.db);
const siteName = (await options.get("emdash:site_title")) ?? undefined;
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
+ const siteUrl = getPublicOrigin(url, emdash?.config);
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
// Verify the passkey registration response
const challengeStore = createChallengeStore(emdash.db);
diff --git a/packages/core/src/astro/routes/api/oauth/authorize.ts b/packages/core/src/astro/routes/api/oauth/authorize.ts
index 2fad3f06e..2da1b15a7 100644
--- a/packages/core/src/astro/routes/api/oauth/authorize.ts
+++ b/packages/core/src/astro/routes/api/oauth/authorize.ts
@@ -22,6 +22,7 @@ import {
validateRedirectUri,
} from "#api/handlers/oauth-authorization.js";
import { lookupOAuthClient, validateClientRedirectUri } from "#api/handlers/oauth-clients.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { VALID_SCOPES } from "#auth/api-tokens.js";
export const prerender = false;
@@ -40,13 +41,17 @@ function generateCsrfToken(): string {
}
/** Build the Set-Cookie header value for the CSRF token. */
-function csrfCookieHeader(token: string, request: Request): string {
+function csrfCookieHeader(token: string, request: Request, siteUrl?: string): string {
// SameSite=Strict prevents cross-site form submission.
// HttpOnly: the token value is embedded in the form hidden field server-side,
// so JS never needs to read the cookie. HttpOnly adds defense-in-depth.
- // Secure is only set over HTTPS — omitting it on localhost allows the cookie
- // to be sent over plain HTTP during development.
- const secure = new URL(request.url).protocol === "https:" ? "; Secure" : "";
+ // Secure is set when:
+ // - siteUrl is configured and uses https (proxy case — request may be http internally), OR
+ // - the actual request is over https (non-proxy case, preserve existing behavior — H-2)
+ const isSecure = siteUrl
+ ? siteUrl.startsWith("https:")
+ : new URL(request.url).protocol === "https:";
+ const secure = isSecure ? "; Secure" : "";
return `${CSRF_COOKIE_NAME}=${token}; Path=/_emdash/api/oauth/authorize; HttpOnly; SameSite=Strict${secure}`;
}
@@ -130,7 +135,7 @@ export const GET: APIRoute = async ({ url, request, locals }) => {
// If not authenticated, redirect to login with return URL
if (!user) {
- const loginUrl = new URL("/_emdash/admin/login", url.origin);
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
loginUrl.searchParams.set("redirect", url.pathname + url.search);
return Response.redirect(loginUrl.toString(), 302);
}
@@ -169,7 +174,7 @@ export const GET: APIRoute = async ({ url, request, locals }) => {
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
- "Set-Cookie": csrfCookieHeader(csrfToken, request),
+ "Set-Cookie": csrfCookieHeader(csrfToken, request, getPublicOrigin(url, emdash?.config)),
},
});
};
diff --git a/packages/core/src/astro/routes/api/oauth/device/code.ts b/packages/core/src/astro/routes/api/oauth/device/code.ts
index b4d0395a6..74e62a7fc 100644
--- a/packages/core/src/astro/routes/api/oauth/device/code.ts
+++ b/packages/core/src/astro/routes/api/oauth/device/code.ts
@@ -13,6 +13,7 @@ import { z } from "zod";
import { apiError, handleError, unwrapResult } from "#api/error.js";
import { handleDeviceCodeRequest } from "#api/handlers/device-flow.js";
import { isParseError, parseBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
export const prerender = false;
@@ -41,7 +42,10 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
}
// Build the verification URI — device page lives inside the admin SPA
- const verificationUri = new URL("/_emdash/admin/device", url.origin).toString();
+ const verificationUri = new URL(
+ "/_emdash/admin/device",
+ getPublicOrigin(url, emdash?.config),
+ ).toString();
const result = await handleDeviceCodeRequest(emdash.db, body, verificationUri);
return unwrapResult(result);
diff --git a/packages/core/src/astro/routes/api/setup/admin-verify.ts b/packages/core/src/astro/routes/api/setup/admin-verify.ts
index ca9bd74b0..b8197ffad 100644
--- a/packages/core/src/astro/routes/api/setup/admin-verify.ts
+++ b/packages/core/src/astro/routes/api/setup/admin-verify.ts
@@ -14,6 +14,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { setupAdminVerifyBody } from "#api/schemas.js";
import { createChallengeStore } from "#auth/challenge-store.js";
import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -21,7 +22,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
export const POST: APIRoute = async ({ request, locals }) => {
const { emdash } = locals;
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -58,7 +58,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Get passkey config
const url = new URL(request.url);
const siteName = (await options.get("emdash:site_title")) ?? undefined;
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
+ const siteUrl = getPublicOrigin(url, emdash?.config);
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
// Verify the registration response
const challengeStore = createChallengeStore(emdash.db);
diff --git a/packages/core/src/astro/routes/api/setup/admin.ts b/packages/core/src/astro/routes/api/setup/admin.ts
index fe091cba1..6827e728a 100644
--- a/packages/core/src/astro/routes/api/setup/admin.ts
+++ b/packages/core/src/astro/routes/api/setup/admin.ts
@@ -13,6 +13,7 @@ import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseBody } from "#api/parse.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { setupAdminBody } from "#api/schemas.js";
import { createChallengeStore } from "#auth/challenge-store.js";
import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -20,7 +21,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
export const POST: APIRoute = async ({ request, locals }) => {
const { emdash } = locals;
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -57,7 +57,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Get passkey config
const url = new URL(request.url);
const siteName = (await options.get("emdash:site_title")) ?? undefined;
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
+ const siteUrl = getPublicOrigin(url, emdash?.config);
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
// Generate registration options
const challengeStore = createChallengeStore(emdash.db);
diff --git a/packages/core/src/astro/routes/api/setup/dev-bypass.ts b/packages/core/src/astro/routes/api/setup/dev-bypass.ts
index d8a915551..d3adabb1a 100644
--- a/packages/core/src/astro/routes/api/setup/dev-bypass.ts
+++ b/packages/core/src/astro/routes/api/setup/dev-bypass.ts
@@ -24,6 +24,7 @@ import { ulid } from "ulidx";
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { escapeHtml } from "#api/escape.js";
import { handleApiTokenCreate } from "#api/handlers/api-tokens.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { isSafeRedirect } from "#api/redirect.js";
import { runMigrations } from "#db/migrations/runner.js";
import { OptionsRepository } from "#db/repositories/options.js";
@@ -120,7 +121,7 @@ async function handleDevBypass(context: Parameters[0]): Promise {
+export const POST: APIRoute = async ({ request, url, locals }) => {
const { emdash } = locals;
if (!emdash?.db) {
@@ -89,7 +90,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Store the canonical site URL from the setup request.
// This is trusted because setup runs on the real domain.
- const siteUrl = new URL(request.url).origin;
+ const siteUrl = getPublicOrigin(url, emdash.config);
await options.set("emdash:site_url", siteUrl);
if (useExternalAuth) {
diff --git a/packages/core/src/astro/routes/api/snapshot.ts b/packages/core/src/astro/routes/api/snapshot.ts
index b634b60bb..555f92d5f 100644
--- a/packages/core/src/astro/routes/api/snapshot.ts
+++ b/packages/core/src/astro/routes/api/snapshot.ts
@@ -16,6 +16,7 @@ import {
parsePreviewSignatureHeader,
verifyPreviewSignature,
} from "#api/handlers/snapshot.js";
+import { getPublicOrigin } from "#api/public-url.js";
export const prerender = false;
@@ -65,7 +66,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
const includeDrafts = url.searchParams.get("drafts") === "true";
const snapshot = await generateSnapshot(emdash.db, {
includeDrafts,
- origin: url.origin,
+ origin: getPublicOrigin(url, emdash.config),
});
return apiSuccess(snapshot);
diff --git a/packages/core/src/astro/routes/api/themes/preview.ts b/packages/core/src/astro/routes/api/themes/preview.ts
index d0d81e898..5213567b8 100644
--- a/packages/core/src/astro/routes/api/themes/preview.ts
+++ b/packages/core/src/astro/routes/api/themes/preview.ts
@@ -11,6 +11,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, apiSuccess } from "#api/error.js";
+import { getPublicOrigin } from "#api/public-url.js";
export const prerender = false;
@@ -52,7 +53,7 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
return apiError("INVALID_REQUEST", "previewUrl must use HTTPS", 400);
}
- const source = url.origin;
+ const source = getPublicOrigin(url, emdash?.config);
const ttl = 3600; // 1 hour
const exp = Math.floor(Date.now() / 1000) + ttl;
diff --git a/packages/core/src/astro/routes/api/well-known/oauth-authorization-server.ts b/packages/core/src/astro/routes/api/well-known/oauth-authorization-server.ts
index a4616bb18..2c0d08455 100644
--- a/packages/core/src/astro/routes/api/well-known/oauth-authorization-server.ts
+++ b/packages/core/src/astro/routes/api/well-known/oauth-authorization-server.ts
@@ -9,12 +9,13 @@
import type { APIRoute } from "astro";
+import { getPublicOrigin } from "#api/public-url.js";
import { VALID_SCOPES } from "#auth/api-tokens.js";
export const prerender = false;
-export const GET: APIRoute = async ({ url }) => {
- const origin = url.origin;
+export const GET: APIRoute = async ({ url, locals }) => {
+ const origin = getPublicOrigin(url, locals.emdash?.config);
const issuer = `${origin}/_emdash`;
return Response.json(
diff --git a/packages/core/src/astro/routes/api/well-known/oauth-protected-resource.ts b/packages/core/src/astro/routes/api/well-known/oauth-protected-resource.ts
index b141929d9..820745e2d 100644
--- a/packages/core/src/astro/routes/api/well-known/oauth-protected-resource.ts
+++ b/packages/core/src/astro/routes/api/well-known/oauth-protected-resource.ts
@@ -13,12 +13,13 @@
import type { APIRoute } from "astro";
+import { getPublicOrigin } from "#api/public-url.js";
import { VALID_SCOPES } from "#auth/api-tokens.js";
export const prerender = false;
-export const GET: APIRoute = async ({ url }) => {
- const origin = url.origin;
+export const GET: APIRoute = async ({ url, locals }) => {
+ const origin = getPublicOrigin(url, locals.emdash?.config);
return Response.json(
{
diff --git a/packages/core/src/astro/routes/robots.txt.ts b/packages/core/src/astro/routes/robots.txt.ts
index b87670ac0..467206d40 100644
--- a/packages/core/src/astro/routes/robots.txt.ts
+++ b/packages/core/src/astro/routes/robots.txt.ts
@@ -10,6 +10,7 @@
import type { APIRoute } from "astro";
+import { getPublicOrigin } from "#api/public-url.js";
import { getSiteSettingsWithDb } from "#settings/index.js";
export const prerender = false;
@@ -29,7 +30,10 @@ export const GET: APIRoute = async ({ locals, url }) => {
try {
const settings = await getSiteSettingsWithDb(emdash.db);
- const siteUrl = (settings.url || url.origin).replace(TRAILING_SLASH_RE, "");
+ const siteUrl = (settings.url || getPublicOrigin(url, emdash?.config)).replace(
+ TRAILING_SLASH_RE,
+ "",
+ );
const sitemapUrl = `${siteUrl}/sitemap.xml`;
// Use custom robots.txt if configured
diff --git a/packages/core/src/astro/routes/sitemap.xml.ts b/packages/core/src/astro/routes/sitemap.xml.ts
index 9fb853515..9bcfee541 100644
--- a/packages/core/src/astro/routes/sitemap.xml.ts
+++ b/packages/core/src/astro/routes/sitemap.xml.ts
@@ -13,6 +13,7 @@
import type { APIRoute } from "astro";
import { handleSitemapData } from "#api/handlers/seo.js";
+import { getPublicOrigin } from "#api/public-url.js";
import { getSiteSettingsWithDb } from "#settings/index.js";
export const prerender = false;
@@ -37,7 +38,10 @@ export const GET: APIRoute = async ({ locals, url }) => {
try {
// Determine site URL from settings or request origin
const settings = await getSiteSettingsWithDb(emdash.db);
- const siteUrl = (settings.url || url.origin).replace(TRAILING_SLASH_RE, "");
+ const siteUrl = (settings.url || getPublicOrigin(url, emdash?.config)).replace(
+ TRAILING_SLASH_RE,
+ "",
+ );
const result = await handleSitemapData(emdash.db);
diff --git a/packages/core/src/auth/passkey-config.ts b/packages/core/src/auth/passkey-config.ts
index d37391625..a17b2eadc 100644
--- a/packages/core/src/auth/passkey-config.ts
+++ b/packages/core/src/auth/passkey-config.ts
@@ -17,21 +17,17 @@ export interface PasskeyConfig {
*
* @param url The request URL (typically `new URL(Astro.request.url)` or `new URL(request.url)`)
* @param siteName Optional site name for rpName (defaults to hostname from `url` or public origin)
- * @param passkeyPublicOrigin Optional browser-facing origin (see `EmDashConfig.passkeyPublicOrigin`).
+ * @param siteUrl Optional browser-facing origin (see `EmDashConfig.siteUrl`).
* When set, **origin** and **rpId** are taken from this URL so they match WebAuthn `clientData.origin`.
- * @throws If `passkeyPublicOrigin` is non-empty but not parseable by `new URL()`.
+ * @throws If `siteUrl` is non-empty but not parseable by `new URL()`.
*/
-export function getPasskeyConfig(
- url: URL,
- siteName?: string,
- passkeyPublicOrigin?: string,
-): PasskeyConfig {
- if (passkeyPublicOrigin) {
+export function getPasskeyConfig(url: URL, siteName?: string, siteUrl?: string): PasskeyConfig {
+ if (siteUrl) {
let publicUrl: URL;
try {
- publicUrl = new URL(passkeyPublicOrigin);
+ publicUrl = new URL(siteUrl);
} catch (e) {
- throw new Error(`Invalid passkeyPublicOrigin: "${passkeyPublicOrigin}"`, { cause: e });
+ throw new Error(`Invalid siteUrl: "${siteUrl}"`, { cause: e });
}
return {
rpName: siteName || publicUrl.hostname,
diff --git a/packages/core/src/page/context.ts b/packages/core/src/page/context.ts
index b1f12e75a..f1cb1595b 100644
--- a/packages/core/src/page/context.ts
+++ b/packages/core/src/page/context.ts
@@ -31,6 +31,8 @@ interface PageContextFields {
};
/** Site name for structured data and og:site_name */
siteName?: string;
+ /** Public-facing site URL (origin) for structured data */
+ siteUrl?: string;
}
/** Input with Astro global -- used in .astro files */
@@ -89,5 +91,6 @@ export function createPublicPageContext(input: CreatePublicPageContextInput): Pu
seo: input.seo,
articleMeta: input.articleMeta,
siteName: input.siteName,
+ siteUrl: input.siteUrl,
};
}
diff --git a/packages/core/src/page/jsonld.ts b/packages/core/src/page/jsonld.ts
index 2ad3dd162..789484364 100644
--- a/packages/core/src/page/jsonld.ts
+++ b/packages/core/src/page/jsonld.ts
@@ -77,12 +77,16 @@ export function buildWebSiteJsonLd(page: PublicPageContext): Record {
});
});
+ describe("dual-origin matching (reverse proxy)", () => {
+ it("accepts Origin matching public origin when behind proxy", () => {
+ const request = makeRequest("POST", {
+ Origin: "https://mysite.example.com",
+ });
+ // Internal URL is http, public is https — proxy scenario
+ const url = new URL("http://localhost:4321/_emdash/api/comments/posts/abc");
+ expect(checkPublicCsrf(request, url, "https://mysite.example.com")).toBeNull();
+ });
+
+ it("still accepts Origin matching internal origin when publicOrigin is set", () => {
+ const request = makeRequest("POST", {
+ Origin: "http://localhost:4321",
+ });
+ const url = new URL("http://localhost:4321/_emdash/api/comments/posts/abc");
+ expect(checkPublicCsrf(request, url, "https://mysite.example.com")).toBeNull();
+ });
+
+ it("rejects Origin matching neither internal nor public", () => {
+ const request = makeRequest("POST", {
+ Origin: "http://evil.com",
+ });
+ const url = new URL("http://localhost:4321/_emdash/api/comments/posts/abc");
+ const response = checkPublicCsrf(request, url, "https://mysite.example.com");
+ expect(response).not.toBeNull();
+ expect(response!.status).toBe(403);
+ });
+
+ it("unchanged behavior when publicOrigin is undefined", () => {
+ const request = makeRequest("POST", {
+ Origin: "http://example.com",
+ });
+ expect(checkPublicCsrf(request, makeUrl(), undefined)).toBeNull();
+ });
+ });
+
describe("allows requests without Origin header", () => {
it("allows POST without any Origin (non-browser client)", () => {
const request = makeRequest("POST");
diff --git a/packages/core/tests/unit/api/public-url.test.ts b/packages/core/tests/unit/api/public-url.test.ts
new file mode 100644
index 000000000..78a8524e1
--- /dev/null
+++ b/packages/core/tests/unit/api/public-url.test.ts
@@ -0,0 +1,125 @@
+import { afterEach, beforeEach, describe, it, expect } from "vitest";
+
+import {
+ getPublicOrigin,
+ getPublicUrl,
+ _resetEnvSiteUrlCache,
+} from "../../../src/api/public-url.js";
+import type { EmDashConfig } from "../../../src/astro/integration/runtime.js";
+
+// Snapshot env vars we'll mutate, and restore after every test.
+const origEmdashSiteUrl = process.env.EMDASH_SITE_URL;
+const origSiteUrl = process.env.SITE_URL;
+
+afterEach(() => {
+ _resetEnvSiteUrlCache();
+ // Restore original env state (delete if originally absent)
+ if (origEmdashSiteUrl === undefined) delete process.env.EMDASH_SITE_URL;
+ else process.env.EMDASH_SITE_URL = origEmdashSiteUrl;
+ if (origSiteUrl === undefined) delete process.env.SITE_URL;
+ else process.env.SITE_URL = origSiteUrl;
+});
+
+// Ensure clean state before every test (no cache, no test env vars).
+beforeEach(() => {
+ _resetEnvSiteUrlCache();
+ delete process.env.EMDASH_SITE_URL;
+ delete process.env.SITE_URL;
+});
+
+describe("getPublicOrigin()", () => {
+ it("returns config.siteUrl when set", () => {
+ const url = new URL("http://localhost:4321/admin");
+ const config: EmDashConfig = { siteUrl: "https://mysite.example.com" };
+ expect(getPublicOrigin(url, config)).toBe("https://mysite.example.com");
+ });
+
+ it("returns url.origin when config has no siteUrl", () => {
+ const url = new URL("http://localhost:4321/admin");
+ const config: EmDashConfig = {};
+ expect(getPublicOrigin(url, config)).toBe("http://localhost:4321");
+ });
+
+ it("returns url.origin when config is undefined", () => {
+ const url = new URL("https://example.com:8443/setup");
+ expect(getPublicOrigin(url)).toBe("https://example.com:8443");
+ });
+
+ it("returns url.origin when config.siteUrl is undefined", () => {
+ const url = new URL("http://127.0.0.1:4321/api");
+ expect(getPublicOrigin(url, { siteUrl: undefined })).toBe("http://127.0.0.1:4321");
+ });
+
+ it("does not return empty string siteUrl (falsy)", () => {
+ const url = new URL("http://localhost:4321/x");
+ // Empty string should fall through to url.origin
+ expect(getPublicOrigin(url, { siteUrl: "" })).toBe("http://localhost:4321");
+ });
+});
+
+describe("getPublicOrigin() env var fallback", () => {
+ it("falls back to EMDASH_SITE_URL when config has no siteUrl", () => {
+ process.env.EMDASH_SITE_URL = "https://env.example.com";
+ const url = new URL("http://localhost:4321/x");
+ expect(getPublicOrigin(url, {})).toBe("https://env.example.com");
+ });
+
+ it("falls back to SITE_URL when EMDASH_SITE_URL is absent", () => {
+ process.env.SITE_URL = "https://site-url.example.com";
+ const url = new URL("http://localhost:4321/x");
+ expect(getPublicOrigin(url, {})).toBe("https://site-url.example.com");
+ });
+
+ it("prefers EMDASH_SITE_URL over SITE_URL", () => {
+ process.env.EMDASH_SITE_URL = "https://emdash.example.com";
+ process.env.SITE_URL = "https://site.example.com";
+ const url = new URL("http://localhost:4321/x");
+ expect(getPublicOrigin(url, {})).toBe("https://emdash.example.com");
+ });
+
+ it("normalizes env var to origin (strips path)", () => {
+ process.env.EMDASH_SITE_URL = "https://env.example.com/some/path";
+ const url = new URL("http://localhost:4321/x");
+ expect(getPublicOrigin(url, {})).toBe("https://env.example.com");
+ });
+
+ it("falls through to url.origin when env var is invalid URL", () => {
+ process.env.EMDASH_SITE_URL = "not-a-url";
+ const url = new URL("http://localhost:4321/x");
+ expect(getPublicOrigin(url, {})).toBe("http://localhost:4321");
+ });
+
+ it("config.siteUrl takes precedence over env var", () => {
+ process.env.EMDASH_SITE_URL = "https://env.example.com";
+ const url = new URL("http://localhost:4321/x");
+ const config: EmDashConfig = { siteUrl: "https://config.example.com" };
+ expect(getPublicOrigin(url, config)).toBe("https://config.example.com");
+ });
+
+ it("cache is invalidated by _resetEnvSiteUrlCache()", () => {
+ process.env.EMDASH_SITE_URL = "https://first.example.com";
+ const url = new URL("http://localhost:4321/x");
+ expect(getPublicOrigin(url, {})).toBe("https://first.example.com");
+
+ _resetEnvSiteUrlCache();
+ process.env.EMDASH_SITE_URL = "https://second.example.com";
+ expect(getPublicOrigin(url, {})).toBe("https://second.example.com");
+ });
+});
+
+describe("getPublicUrl()", () => {
+ it("builds full URL from siteUrl + path", () => {
+ const url = new URL("http://localhost:4321/x");
+ const config: EmDashConfig = { siteUrl: "https://mysite.example.com" };
+ expect(getPublicUrl(url, config, "/_emdash/admin/login")).toBe(
+ "https://mysite.example.com/_emdash/admin/login",
+ );
+ });
+
+ it("builds full URL from request origin when no siteUrl", () => {
+ const url = new URL("http://localhost:4321/x");
+ expect(getPublicUrl(url, undefined, "/_emdash/admin/login")).toBe(
+ "http://localhost:4321/_emdash/admin/login",
+ );
+ });
+});
diff --git a/packages/core/tests/unit/auth/discovery-endpoints.test.ts b/packages/core/tests/unit/auth/discovery-endpoints.test.ts
index fa9960221..10c9cad90 100644
--- a/packages/core/tests/unit/auth/discovery-endpoints.test.ts
+++ b/packages/core/tests/unit/auth/discovery-endpoints.test.ts
@@ -16,9 +16,10 @@ import { VALID_SCOPES } from "../../../src/auth/api-tokens.js";
/** Minimal mock of what the route handlers actually use from the Astro context. */
function mockContext(origin = "https://example.com") {
- return { url: new URL("/.well-known/test", origin) } as Parameters<
- typeof getProtectedResource
- >[0];
+ return {
+ url: new URL("/.well-known/test", origin),
+ locals: { emdash: undefined },
+ } as unknown as Parameters[0];
}
describe("Protected Resource Metadata (RFC 9728)", () => {
diff --git a/packages/core/tests/unit/auth/passkey-config.test.ts b/packages/core/tests/unit/auth/passkey-config.test.ts
index bf942fb14..1b6d65cc9 100644
--- a/packages/core/tests/unit/auth/passkey-config.test.ts
+++ b/packages/core/tests/unit/auth/passkey-config.test.ts
@@ -28,7 +28,7 @@ describe("passkey-config", () => {
expect(config.origin).toBe("http://emdash.local:8080");
});
- it("HTTPS listener on proxy with HTTP upstream: passkeyPublicOrigin aligns origin with browser", () => {
+ it("HTTPS listener on proxy with HTTP upstream: siteUrl aligns origin with browser", () => {
const urlAstroSeesFromForwardedHttp = urlAfterTrustedProxy(
"/_emdash/api/setup/admin",
"emdash.local:8080",
@@ -43,11 +43,9 @@ describe("passkey-config", () => {
});
describe("getPasskeyConfig()", () => {
- it("throws when passkeyPublicOrigin is not a valid URL", () => {
+ it("throws when siteUrl is not a valid URL", () => {
const url = new URL("http://localhost:4321/admin");
- expect(() => getPasskeyConfig(url, "Site", "::not-a-url")).toThrow(
- "Invalid passkeyPublicOrigin",
- );
+ expect(() => getPasskeyConfig(url, "Site", "::not-a-url")).toThrow("Invalid siteUrl");
});
it("extracts rpId from localhost URL", () => {
@@ -139,7 +137,7 @@ describe("passkey-config", () => {
expect(fromServer.origin).not.toBe(fromBrowser.origin);
});
- it("passkeyPublicOrigin overrides origin and rpId (TLS termination and loopback request URL)", () => {
+ it("siteUrl overrides origin and rpId (TLS termination and loopback request URL)", () => {
const fromForwardedHttp = getPasskeyConfig(
new URL("http://emdash.local:8443/_emdash/api/setup/admin"),
"My Site",
diff --git a/skills/building-emdash-site/references/configuration.md b/skills/building-emdash-site/references/configuration.md
index e86280603..bf976bedc 100644
--- a/skills/building-emdash-site/references/configuration.md
+++ b/skills/building-emdash-site/references/configuration.md
@@ -32,9 +32,31 @@ export default defineConfig({
});
```
-### Reverse proxy and passkeys
+### Reverse proxy
-Passkey `rpId` / `origin` follow Astro `context.url`, which only reflects `X-Forwarded-*` when you declare **allowed public hosts** ([`security.allowedDomains`](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains)). In dev, add matching **`vite.server.allowedHosts`** or Vite rejects the proxy `Host`. Use **`emdash({ passkeyPublicOrigin: "https://…" })`** when the browser origin and reconstructed URL still disagree (common with TLS termination). With TLS terminated in front, **`astro dev --host 127.0.0.1`** (loopback) is usually enough: the proxy reaches the dev server locally while **`passkeyPublicOrigin`** matches the browser’s HTTPS origin—without opening the Node port on the LAN.
+When behind a TLS-terminating reverse proxy, `Astro.url` returns the internal address (e.g. `http://localhost:4321`) instead of the public one (`https://mysite.example.com`). This breaks passkeys, CSRF, OAuth, redirects, and more.
+
+**Step 1:** Declare allowed public hosts via [`security.allowedDomains`](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) so Astro reconstructs the URL from `X-Forwarded-*` headers. In dev, add matching **`vite.server.allowedHosts`** or Vite rejects the proxy `Host`.
+
+**Step 2:** If the reconstructed URL still disagrees with the browser (common with TLS termination), set **`siteUrl`**:
+
+```javascript
+emdash({
+ siteUrl: "https://mysite.example.com",
+ // ...
+});
+```
+
+Or via environment variable (useful for container deployments):
+
+```bash
+EMDASH_SITE_URL=https://mysite.example.com
+# or: SITE_URL=https://mysite.example.com
+```
+
+`siteUrl` replaces `passkeyPublicOrigin` (which only fixed passkeys). It applies to passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data.
+
+With TLS terminated in front, **`astro dev --host 127.0.0.1`** (loopback) is usually enough: the proxy reaches the dev server locally while **`siteUrl`** matches the browser’s HTTPS origin -- without opening the Node port on the LAN.
### Cloudflare (D1 + R2)