Skip to content

fix(core): honor siteUrl in /.well-known OAuth discovery on the anonymous fast path#2030

Open
swissky wants to merge 1 commit into
emdash-cms:mainfrom
swissky:fix/well-known-discovery-site-url
Open

fix(core): honor siteUrl in /.well-known OAuth discovery on the anonymous fast path#2030
swissky wants to merge 1 commit into
emdash-cms:mainfrom
swissky:fix/well-known-discovery-site-url

Conversation

@swissky

@swissky swissky commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

siteUrl never reached /.well-known/oauth-protected-resource (and /.well-known/oauth-authorization-server): these routes are root-level and unauthenticated — exactly how MCP clients fetch them — so they land on the middleware's anonymous fast path, where locals.emdash carries handlers but no config. getPublicOrigin() then fell through to url.origin, which is http:// behind Cloudflare's proxy, and MCP clients refused to attach because the discovery document advertised non-https URLs.

The fix: both routes now fall back to the build-time virtual:emdash/config (which carries the origin-normalized siteUrl) when locals.emdash?.config is absent. This also covers the case where anonymous-path runtime init fails and locals.emdash is never set at all. Verified the symptom on a real Workers deployment before the fix (curl .../.well-known/oauth-protected-resource"resource":"http://…").

Also included, from the same report (#2016):

  • Corrects the public-url.ts comment: process.env is readable on Workers when nodejs_compat_populate_process_env is enabled — the old comment pointed people away from the one mechanism that worked.
  • Documents the Workers env-var setup (nodejs_compat_populate_process_env + vars) in the siteUrl reference, and clarifies that siteUrl doesn't change Astro.url in user templates (use Astro's site option for self-built canonical/OG URLs).

Deliberately not included: the issue's suggestion to pass protocol into security.allowedDomains. The Cloudflare adapter passes request.url straight through and never runs Astro's forwarded-header validation, so that change wouldn't affect Astro.url on Workers — and on the Node path it could regress proxy setups that send only Host (no x-forwarded-host), since a protocol pattern makes host validation fail for the internal http hop. Analysis posted on the issue.

Closes #2016

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/... (n/a — bug fix)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Fable

Screenshots / test output

New regression tests in packages/core/tests/unit/astro/well-known-public-origin.test.ts (named reproduces #2016): siteUrl honored with config-less locals.emdash, with locals.emdash entirely absent, and locals-config precedence preserved. Full emdash package suite:

Test Files  356 passed | 1 skipped (357)
     Tests  4723 passed | 3 skipped (4726)

…mous fast path

The middleware's anonymous fast path attaches locals.emdash without
`config`, and the root-level /.well-known routes are public by design —
so MCP clients always hit them with locals.emdash?.config undefined,
getPublicOrigin() fell through to url.origin, and behind Cloudflare's
proxy the discovery document advertised http:// (clients refuse to
attach). Fall back to the build-time virtual config, which carries the
origin-normalized siteUrl.

Also corrects the public-url.ts comment (process.env IS readable on
Workers with nodejs_compat_populate_process_env) and documents the
Workers env-var setup in the siteUrl reference.

Fixes emdash-cms#2016
@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b81333b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2030

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2030

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2030

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2030

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2030

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2030

emdash

npm i https://pkg.pr.new/emdash@2030

create-emdash

npm i https://pkg.pr.new/create-emdash@2030

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2030

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2030

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2030

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2030

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2030

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2030

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2030

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2030

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2030

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2030

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2030

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2030

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2030

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2030

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2030

commit: b81333b

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach: This is the right fix for the right problem, and it fits EmDash's architecture.

The two OAuth discovery routes (/.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server/_emdash) are registered at the site root (confirmed in routes.ts), so they land on the middleware's anonymous fast path. I traced middleware.ts: on that path locals.emdash is set to a handlers-only object (handlePublicPluginApiRoute, collectPageMetadata, collectPageFragments, getPublicMediaUrl, storage) with no config property — and if runtime init fails, locals.emdash is never set at all. Previously getPublicOrigin(url, locals.emdash?.config) therefore saw undefined for config and fell through getEnvSiteUrl()url.origin, which is http:// behind Cloudflare's proxy. The fix falls back to the build-time virtual:emdash/config, whose default export the integration serializes with the origin-normalized siteUrl (confirmed in index.tsserializableConfig.siteUrlgenerateConfigModule). This is idiomatic — the middleware already imports the same virtual module — and ?? correctly preserves runtime-config precedence (on the full path locals.emdash.config === virtualConfig, so the left operand wins anyway).

What I checked:

  • Verified the anonymous fast path omits config and that runtime-init failure leaves locals.emdash unset — both cases are now covered by the ?? virtualConfig fallback.
  • Traced every getPublicOrigin call-site in the tree. All other callers are either under /_emdash/ (full middleware path → config present) or in PUBLIC_RUNTIME_ROUTES (sitemap/robots → full path). The two well-known routes are the only root-level public callers, so the fix is precisely scoped with no sibling inconsistency left behind.
  • Confirmed the env-var fallback (EMDASH_SITE_URL) still works via getPublicOrigin's internal chain, so the change is additive, not a regression for env-var-only deployments (with the documented nodejs_compat_populate_process_env caveat on Workers).
  • Conventions: // @ts-ignore - virtual module matches AGENTS.md; .js internal imports preserved; the siteUrl?: string addition to VirtualConfig lines up with the already-serialized value; a changeset is present with proper release-notes tone; reproducing tests cover the three relevant scenarios (fast path, init-failed, precedence).
  • Docs/comment edits (public-url.ts, configuration.mdx) accurately describe the Workers env-var mechanism and the siteUrlAstro.url distinction.

Conclusion: Clean, well-targeted bug fix with reproducing tests, a changeset, and accurate documentation. No issues found.

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach judgment

This is the right fix for the right problem, and it fits EmDash's architecture cleanly. The root cause is real and well-scoped: /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server/_emdash are root-level, unauthenticated routes, so unauthenticated MCP clients land on the middleware's anonymous fast path (the !isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie branch in packages/core/src/astro/middleware.ts). On that path locals.emdash is populated with handlers but no config — so locals.emdash?.config is undefined and getPublicOrigin() fell through getEnvSiteUrl() (empty on Workers) to url.origin, which is http:// behind Cloudflare's TLS-terminating proxy. MCP clients then refuse the discovery document.

The fix — getPublicOrigin(url, locals.emdash?.config ?? virtualConfig) — is idiomatic: the middleware itself already uses the build-time virtual:emdash/config as the canonical config source (getConfig() returns virtualConfig directly), and the full-init path sets locals.emdash.config to that same virtualConfig object. So the fallback restores the intended resolution order (config.siteUrl → env var → url.origin) on the one path that was accidentally dropping config.siteUrl. The decision to deliberately exclude the issue's protocol-into-allowedDomains suggestion is defensible and is reasoned on the linked issue.

What I verified

  • siteUrl actually reaches the virtual module. packages/core/src/astro/integration/index.ts serializes siteUrl: resolvedConfig.siteUrl into serializableConfig (pre-existing), and resolvedConfig.siteUrl is origin-normalized at startup (parsed.origin), so the build-time fallback value is the correct public origin — no path contamination into ${origin}/_emdash/....
  • No precedence regression. When siteUrl is unset at build time, virtualConfig.siteUrl is undefined and getPublicOrigin falls through to getEnvSiteUrl()url.origin, identical to prior behavior. When set, config.siteUrl correctly takes precedence over the env var, matching the documented order used everywhere else (passkeys, etc.) — the well-known routes were the only place the env var could silently override build-time siteUrl; this fix makes them consistent.
  • virtualConfig is a sound fallback in all branches. On the full-init path locals.emdash.config === virtualConfig, so ?? virtualConfig is a no-op there; it only matters on the fast path (and the locals.emdash entirely-absent / runtime-init-failed cases), which is exactly the target.
  • Scope is complete for these routes. robots.txt / sitemap*.xml use getPublicOrigin(url, emdash?.config) too but are in PUBLIC_RUNTIME_ROUTES, so they take the full-init path where config is always set (and additionally fall back to a DB-stored settings.url). The /_emdash/.well-known/auth route is under /_emdash (full path). The two OAuth routes are the only affected ones.
  • Type accuracy. Adding siteUrl?: string to VirtualConfig in virtual-modules.d.ts correctly types the new read; the @ts-ignore import pattern matches the rest of the codebase.
  • Tests are meaningful, not false confidence. well-known-public-origin.test.ts mocks virtual:emdash/config with a real siteUrl and covers the fast path (locals.emdash present without config), the absent-locals.emdash case, and runtime-config precedence. They exercise the actual route handlers and assert the advertised resource/issuer/authorization_servers URLs, not a mocked return value. Existing discovery-endpoints.test.ts (no siteUrl in the vitest stub {} → falls to url.origin) remains compatible since the stub leaves virtualConfig.siteUrl undefined.
  • Changeset present ("emdash": patch), satisfying the changeset convention for a published-package change.

Conclusion

No bugs, regressions, security issues, or convention violations found. The comment fix in public-url.ts (Workers process.env is empty by default but readable with nodejs_compat_populate_process_env) is accurate, and the docs additions are correct. Clean merge.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-review No maintainer or bot review yet labels Jul 14, 2026

@khoinguyenpham04 khoinguyenpham04 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything looks good to me! I only left a couple of non-blocking suggestions.

Thanks for tackling this issue :)

Comment on lines +22 to +24
// Same fallback as oauth-protected-resource.ts: this route is public and
// root-level, so it lands on the anonymous fast path where locals.emdash
// carries no config (#2016).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we shorten this while keeping the reason for the fallback?

Suggested change
// Same fallback as oauth-protected-resource.ts: this route is public and
// root-level, so it lands on the anonymous fast path where locals.emdash
// carries no config (#2016).
// Anonymous discovery requests omit runtime config; use build-time config
// so `siteUrl` can override the proxy's internal origin.

Comment on lines +24 to +28
// Anonymous requests outside /_emdash take the middleware fast path,
// which attaches locals.emdash WITHOUT config — and MCP clients hit this
// route unauthenticated by design. Fall back to the build-time config so
// `siteUrl` still reaches discovery (#2016); behind Cloudflare's proxy
// url.origin is http:// and clients refuse to attach.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this comment more concise? Maybe:

Suggested change
// Anonymous requests outside /_emdash take the middleware fast path,
// which attaches locals.emdash WITHOUT config — and MCP clients hit this
// route unauthenticated by design. Fall back to the build-time config so
// `siteUrl` still reaches discovery (#2016); behind Cloudflare's proxy
// url.origin is http:// and clients refuse to attach.
// The anonymous fast path omits runtime config, so fall back to build-time config.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core area/docs bot:review Trigger an emdashbot code review on this PR review/approved Approved; no new commits since size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

siteUrl does not reach /.well-known/oauth-protected-resource — MCP discovery advertises http:// on Cloudflare

2 participants