From fcaa9abe746587d3634d23af1c9d32d3f000a353 Mon Sep 17 00:00:00 2001 From: kamine <123874176+kamine81@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:51:36 +0900 Subject: [PATCH 1/5] fix(auth): handle Astro v6 removal of locals.runtime.env in OAuth routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @astrojs/cloudflare v13+ (Astro v6) removed locals.runtime.env, causing OAuth login to fail silently with a generic error redirect. Resolution order for env vars: 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare 3. import.meta.env — Node.js / Vite dev server fallback Adds unit tests for all three resolution paths and the error cases. --- .changeset/lovely-ads-sleep.md | 5 + .../astro/routes/api/auth/oauth/[provider].ts | 29 +++- .../api/auth/oauth/[provider]/callback.ts | 28 +++- .../unit/auth/oauth-provider-route.test.ts | 134 ++++++++++++++++++ 4 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 .changeset/lovely-ads-sleep.md create mode 100644 packages/core/tests/unit/auth/oauth-provider-route.test.ts diff --git a/.changeset/lovely-ads-sleep.md b/.changeset/lovely-ads-sleep.md new file mode 100644 index 000000000..5bd524975 --- /dev/null +++ b/.changeset/lovely-ads-sleep.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes GitHub/Google OAuth login when using `@astrojs/cloudflare` v13+ (Astro v6). The `@astrojs/cloudflare` adapter removed `locals.runtime.env` in Astro v6; the OAuth initiation and callback routes now fall back to `cloudflare:workers` env when `locals.runtime.env` throws, then to `import.meta.env` for Node.js environments. 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 58e6ce174..72c48106c 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -87,12 +87,29 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { try { const url = new URL(request.url); - // Get OAuth providers from environment - // Access via locals.runtime for Cloudflare, or import.meta.env for Node - // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional - const runtimeLocals = locals as unknown as { runtime?: { env?: Record } }; - // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig - const env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + // Get OAuth providers from environment. + // Resolution order: + // 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare + // 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare (locals.runtime.env was removed) + // 3. import.meta.env — Node.js / Vite dev server fallback + let env: Record; + try { + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional + const runtimeLocals = locals as unknown as { runtime?: { env?: Record } }; + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig + env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + } catch { + // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead + try { + const { env: cfEnv } = await import("cloudflare:workers"); + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig + env = cfEnv as Record; + } catch { + // Not running on Cloudflare Workers — fall back to Vite's import.meta.env + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) + env = import.meta.env as Record; + } + } const providers = getOAuthConfig(env); if (!providers[provider]) { 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 c74994cd3..5b8f2890b 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 @@ -113,11 +113,29 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect } try { - // Get OAuth providers from environment - // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional - const runtimeLocals = locals as unknown as { runtime?: { env?: Record } }; - // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig - const env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + // Get OAuth providers from environment. + // Resolution order: + // 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare + // 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare (locals.runtime.env was removed) + // 3. import.meta.env — Node.js / Vite dev server fallback + let env: Record; + try { + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional + const runtimeLocals = locals as unknown as { runtime?: { env?: Record } }; + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig + env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + } catch { + // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead + try { + const { env: cfEnv } = await import("cloudflare:workers"); + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig + env = cfEnv as Record; + } catch { + // Not running on Cloudflare Workers — fall back to Vite's import.meta.env + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) + env = import.meta.env as Record; + } + } const providers = getOAuthConfig(env); if (!providers[provider]) { diff --git a/packages/core/tests/unit/auth/oauth-provider-route.test.ts b/packages/core/tests/unit/auth/oauth-provider-route.test.ts new file mode 100644 index 000000000..dad1b25cc --- /dev/null +++ b/packages/core/tests/unit/auth/oauth-provider-route.test.ts @@ -0,0 +1,134 @@ +/** + * OAuth provider route unit tests + * + * Covers the GET /_emdash/api/auth/oauth/[provider] handler, focusing on + * environment variable resolution across runtimes: + * - Astro v5 Cloudflare: locals.runtime.env + * - Astro v6 Cloudflare: cloudflare:workers (locals.runtime.env throws) + * - Node.js / Vite: import.meta.env + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "../../../src/astro/routes/api/auth/oauth/[provider].js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRedirect(captured: { url?: string }) { + return (url: string) => { + captured.url = url; + return new Response(null, { status: 302, headers: { Location: url } }); + }; +} + +function makeLocals(db: Kysely, runtime?: unknown) { + return { emdash: { db, config: {} }, ...(runtime !== undefined ? { runtime } : {}) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("OAuth provider route — environment resolution", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + vi.unstubAllEnvs(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + vi.unstubAllEnvs(); + }); + + it("redirects to GitHub authorization URL when env vars are in import.meta.env", async () => { + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "test-client-secret"); + + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github"), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("github.com/login/oauth/authorize"); + expect(captured.url).toContain("client_id=test-client-id"); + }); + + it("redirects to error page when provider is not configured", async () => { + // No env vars set + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github"), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=provider_not_configured"); + }); + + it("falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)", async () => { + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "test-client-secret"); + + // Simulate Astro v6: locals.runtime exists but .env getter throws + const runtimeWithThrowingEnv = { + get env(): never { + throw new Error( + "Astro.locals.runtime.env has been removed in Astro v6. " + + "Use 'import { env } from \"cloudflare:workers\"' instead.", + ); + }, + }; + + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github"), + locals: makeLocals(db, runtimeWithThrowingEnv) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + // Must redirect to GitHub OAuth, NOT to the oauth_error page + expect(response.status).toBe(302); + expect(captured.url).not.toContain("error=oauth_error"); + expect(captured.url).toContain("github.com/login/oauth/authorize"); + }); + + it("redirects to error page for an unknown provider", async () => { + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "unknown" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/unknown"), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=invalid_provider"); + }); + + it("redirects to error page when db is not available", async () => { + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github"), + locals: { emdash: null } as unknown as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=server_error"); + }); +}); From 33563bb2007965de7149ec87c47090519ff7d682 Mon Sep 17 00:00:00 2001 From: kamine <123874176+kamine81@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:14:23 +0900 Subject: [PATCH 2/5] fix: use variable-based dynamic import to prevent Rollup static analysis Rollup statically resolves string-literal dynamic imports at build time. Using a variable (`const cfWorkersModId = "cloudflare:workers"`) defers resolution to runtime, avoiding build failures in Node.js fixture builds. --- packages/core/src/astro/routes/api/auth/oauth/[provider].ts | 6 ++++-- .../src/astro/routes/api/auth/oauth/[provider]/callback.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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 72c48106c..a48b75b1e 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -99,9 +99,11 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); } catch { - // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead + // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. + // Variable prevents Rollup static analysis; the import resolves at runtime only on Cloudflare Workers. try { - const { env: cfEnv } = await import("cloudflare:workers"); + const cfWorkersModId = "cloudflare:workers"; + const { env: cfEnv } = await import(cfWorkersModId); // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig env = cfEnv as Record; } catch { 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 5b8f2890b..93d79ce1a 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 @@ -125,9 +125,11 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); } catch { - // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead + // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. + // Variable prevents Rollup static analysis; the import resolves at runtime only on Cloudflare Workers. try { - const { env: cfEnv } = await import("cloudflare:workers"); + const cfWorkersModId = "cloudflare:workers"; + const { env: cfEnv } = await import(cfWorkersModId); // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig env = cfEnv as Record; } catch { From a45ff0aae8f2d14076fd006bb9285e73f03fb847 Mon Sep 17 00:00:00 2001 From: kamine <123874176+kamine81@users.noreply.github.com> Date: Sat, 30 May 2026 06:43:26 +0900 Subject: [PATCH 3/5] fix(auth): handle Astro v6 removal of locals.runtime.env in OAuth routes @astrojs/cloudflare v13+ (Astro v6) removed locals.runtime.env, so the OAuth initiation and callback routes threw when reading env. Resolve env through a fallback chain: locals.runtime.env (Astro v5) -> cloudflare:workers (Astro v6) -> import.meta.env (Node). The cloudflare:workers import is a literal dynamic import; the core build preserves it as a runtime-only import, so no static-analysis workaround is needed. Matches the production-validated patch in kamine81/my-emdash-site#100. --- .../astro/routes/api/auth/oauth/[provider].ts | 29 +++++++++++++++---- .../api/auth/oauth/[provider]/callback.ts | 28 ++++++++++++++---- 2 files changed, 46 insertions(+), 11 deletions(-) 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 a9706888f..5a2a87a4b 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -93,12 +93,29 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { try { const url = new URL(request.url); - // Get OAuth providers from environment - // Access via locals.runtime for Cloudflare, or import.meta.env for Node - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional - const runtimeLocals = locals as unknown as { runtime?: { env?: Record } }; - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig - const env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + // Get OAuth providers from environment. + // Resolution order: + // 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare + // 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare (locals.runtime.env was removed) + // 3. import.meta.env — Node.js / Vite dev server fallback + let env: Record; + try { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional + const runtimeLocals = locals as unknown as { runtime?: { env?: Record } }; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig + env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + } catch { + // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. + try { + const { env: cfEnv } = await import("cloudflare:workers"); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig + env = cfEnv as Record; + } catch { + // Not running on Cloudflare Workers — fall back to Vite's import.meta.env + // eslint-disable-next-line typescript/no-unsafe-type-assertion + env = import.meta.env as Record; + } + } const providers = getOAuthConfig(env); if (!providers[provider]) { 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 e49c2acdc..8e9aaa056 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 @@ -115,11 +115,29 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect } try { - // Get OAuth providers from environment - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional - const runtimeLocals = locals as unknown as { runtime?: { env?: Record } }; - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig - const env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + // Get OAuth providers from environment. + // Resolution order: + // 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare + // 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare (locals.runtime.env was removed) + // 3. import.meta.env — Node.js / Vite dev server fallback + let env: Record; + try { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional + const runtimeLocals = locals as unknown as { runtime?: { env?: Record } }; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig + env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + } catch { + // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. + try { + const { env: cfEnv } = await import("cloudflare:workers"); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig + env = cfEnv as Record; + } catch { + // Not running on Cloudflare Workers — fall back to Vite's import.meta.env + // eslint-disable-next-line typescript/no-unsafe-type-assertion + env = import.meta.env as Record; + } + } const providers = getOAuthConfig(env); if (!providers[provider]) { From 7dcab0169eaa5f4a3373ef805605e2bc3ad84375 Mon Sep 17 00:00:00 2001 From: kamine <123874176+kamine81@users.noreply.github.com> Date: Sat, 30 May 2026 07:10:18 +0900 Subject: [PATCH 4/5] test(auth): cover Astro v6 env fallback in OAuth callback route The callback route ([provider]/callback.ts) received the same locals.runtime.env -> cloudflare:workers -> import.meta.env fallback as the initiation route, but had no test. Add a unit test mirroring the provider-route suite, including the regression case where the Astro v6 locals.runtime.env getter throws and must be caught (error=invalid_state, not error=oauth_error). --- .../unit/auth/oauth-callback-route.test.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 packages/core/tests/unit/auth/oauth-callback-route.test.ts diff --git a/packages/core/tests/unit/auth/oauth-callback-route.test.ts b/packages/core/tests/unit/auth/oauth-callback-route.test.ts new file mode 100644 index 000000000..0bec42f9d --- /dev/null +++ b/packages/core/tests/unit/auth/oauth-callback-route.test.ts @@ -0,0 +1,136 @@ +/** + * OAuth callback route unit tests + * + * Covers the GET /_emdash/api/auth/oauth/[provider]/callback handler, focusing on + * environment variable resolution across runtimes: + * - Astro v5 Cloudflare: locals.runtime.env + * - Astro v6 Cloudflare: cloudflare:workers (locals.runtime.env throws) + * - Node.js / Vite: import.meta.env + * + * The callback shares the exact env-resolution fallback added to the + * initiation route ([provider].ts); these tests guard against the Astro v6 + * regression where reading `locals.runtime.env` throws. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "../../../src/astro/routes/api/auth/oauth/[provider]/callback.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRedirect(captured: { url?: string }) { + return (url: string) => { + captured.url = url; + return new Response(null, { status: 302, headers: { Location: url } }); + }; +} + +function makeLocals(db: Kysely, runtime?: unknown) { + return { emdash: { db, config: {} }, ...(runtime !== undefined ? { runtime } : {}) }; +} + +/** Build a callback request with code + state so it gets past param validation. */ +function callbackRequest() { + return new Request( + "http://localhost:4321/_emdash/api/auth/oauth/github/callback?code=test-code&state=test-state", + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("OAuth callback route — environment resolution", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + vi.unstubAllEnvs(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + vi.unstubAllEnvs(); + }); + + it("resolves env from import.meta.env and reaches OAuth state validation", async () => { + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "test-client-secret"); + + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: callbackRequest(), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + // Provider is configured, so it reaches handleOAuthCallback, which rejects + // the unknown state — proving env resolved with the GitHub credentials. + expect(response.status).toBe(302); + expect(captured.url).not.toContain("error=provider_not_configured"); + expect(captured.url).toContain("error=invalid_state"); + }); + + it("redirects to provider_not_configured when env vars are absent", async () => { + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: callbackRequest(), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=provider_not_configured"); + }); + + it("falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)", async () => { + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "test-client-secret"); + + // Simulate Astro v6: locals.runtime exists but .env getter throws + const runtimeWithThrowingEnv = { + get env(): never { + throw new Error( + "Astro.locals.runtime.env has been removed in Astro v6. " + + "Use 'import { env } from \"cloudflare:workers\"' instead.", + ); + }, + }; + + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: callbackRequest(), + locals: makeLocals(db, runtimeWithThrowingEnv) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + // The thrown getter must be caught by the env fallback, NOT bubble up to the + // outer catch (which would yield error=oauth_error). With env resolved, the + // provider is configured and the flow reaches OAuth state validation. + expect(response.status).toBe(302); + expect(captured.url).not.toContain("error=oauth_error"); + expect(captured.url).not.toContain("error=provider_not_configured"); + expect(captured.url).toContain("error=invalid_state"); + }); + + it("redirects to invalid_callback when code or state is missing", async () => { + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github/callback"), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=invalid_callback"); + }); +}); From 594c7c680f5685aae9ef00aedf8cd3a7b1da7343 Mon Sep 17 00:00:00 2001 From: kamine <123874176+kamine81@users.noreply.github.com> Date: Sat, 30 May 2026 12:26:10 +0900 Subject: [PATCH 5/5] fix(auth): defer cloudflare:workers import so Node template builds resolve The literal `import("cloudflare:workers")` in the OAuth routes broke non-Cloudflare (Node adapter) template builds: Rollup statically resolves the specifier at build time and fails because the module only exists on workerd. Build the module id at runtime (`["cloudflare", "workers"].join(":")` + @vite-ignore) so neither this package's bundler nor the downstream Astro/Rollup template build resolves it statically. On Cloudflare the dynamic import is left as a runtime expression and resolves natively on workerd (verified: the adapter externalizes via a `/^cloudflare:/` resolveId filter, and the marketing-cloudflare build keeps the runtime import intact). On Node it throws and is caught, falling through to import.meta.env. --- .../core/src/astro/routes/api/auth/oauth/[provider].ts | 8 +++++++- .../astro/routes/api/auth/oauth/[provider]/callback.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) 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 5a2a87a4b..681249471 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -106,8 +106,14 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); } catch { // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. + // The module id is held in a variable so Rollup cannot statically resolve it: in the + // Node template builds the specifier does not exist, and a literal import would fail + // the build. It resolves at runtime only on Cloudflare Workers. try { - const { env: cfEnv } = await import("cloudflare:workers"); + // Built at runtime (not a string literal) so neither this package's bundler nor + // the downstream Astro/Rollup template build statically resolves "cloudflare:workers". + const cfWorkersModId = ["cloudflare", "workers"].join(":"); + const { env: cfEnv } = await import(/* @vite-ignore */ cfWorkersModId); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig env = cfEnv as Record; } catch { 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 8e9aaa056..860040f11 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 @@ -128,8 +128,14 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); } catch { // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. + // The module id is held in a variable so Rollup cannot statically resolve it: in the + // Node template builds the specifier does not exist, and a literal import would fail + // the build. It resolves at runtime only on Cloudflare Workers. try { - const { env: cfEnv } = await import("cloudflare:workers"); + // Built at runtime (not a string literal) so neither this package's bundler nor + // the downstream Astro/Rollup template build statically resolves "cloudflare:workers". + const cfWorkersModId = ["cloudflare", "workers"].join(":"); + const { env: cfEnv } = await import(/* @vite-ignore */ cfWorkersModId); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig env = cfEnv as Record; } catch {