From db41fe68ec4ccd37a12a3297abef8faaf57db93b Mon Sep 17 00:00:00 2001 From: ttmx Date: Mon, 13 Jul 2026 16:29:51 +0100 Subject: [PATCH 1/7] feat(core): extend plugin runtime capabilities Provide database-backed cron access without an in-process scheduler, expose scheduled publication timestamps through content access, and authenticate private plugin routes with external providers. --- .changeset/plugin-runtime-capabilities.md | 5 + packages/core/src/astro/middleware/auth.ts | 24 +++- packages/core/src/emdash-runtime.ts | 23 ++-- packages/core/src/plugins/context.ts | 2 + packages/core/src/plugins/types.ts | 2 + .../integration/plugins/capabilities.test.ts | 16 +++ .../runtime/plugin-cron-route.test.ts | 60 +++++++++ .../astro/plugin-route-external-auth.test.ts | 119 ++++++++++++++++++ 8 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 .changeset/plugin-runtime-capabilities.md create mode 100644 packages/core/tests/integration/runtime/plugin-cron-route.test.ts create mode 100644 packages/core/tests/unit/astro/plugin-route-external-auth.test.ts diff --git a/.changeset/plugin-runtime-capabilities.md b/.changeset/plugin-runtime-capabilities.md new file mode 100644 index 0000000000..7728f6d6d8 --- /dev/null +++ b/.changeset/plugin-runtime-capabilities.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Adds schedulerless cron access, scheduled publication timestamps, and external authentication support to plugin routes. diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index ff925c0a7e..f5caeadf20 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -386,7 +386,7 @@ async function handlePluginRouteAuth( context: Parameters[0]>[0], next: Parameters[0]>[1], ): Promise { - const { locals } = context; + const { locals, url } = context; const { emdash } = locals; try { @@ -407,11 +407,20 @@ async function handlePluginRouteAuth( }, ); } - // "none" — no token presented, try session auth below. + // "none" — no token presented, try external/session auth below. } catch (error) { console.error("Plugin route bearer auth error:", error); } + const authMode = getAuthMode(emdash?.config); + if ( + authMode.type === "external" && + !import.meta.env.DEV && + !isPublicPluginApiRoute(url.pathname, emdash) + ) { + return handleExternalAuth(context, next, authMode, true); + } + try { // Try session auth (sets locals.user if session exists) const { session } = context; @@ -431,6 +440,17 @@ async function handlePluginRouteAuth( return next(); } +function isPublicPluginApiRoute(pathname: string, emdash: EmDashHandlers | undefined): boolean { + const prefix = "/_emdash/api/plugins/"; + const route = pathname.slice(prefix.length); + const slashIndex = route.indexOf("/"); + if (slashIndex <= 0 || !emdash?.getPluginRouteMeta) return false; + + return ( + emdash.getPluginRouteMeta(route.slice(0, slashIndex), route.slice(slashIndex))?.public === true + ); +} + /** * Soft auth check for public routes with edit mode cookie. * Checks the session and sets locals.user if valid, but never blocks the request. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ecf6fa0c82..e643c09b08 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -718,12 +718,11 @@ export class EmDashRuntime { // pipelineFactoryOptions), so the merge only adds emailPipeline. newPipeline.setContextFactory({ emailPipeline: this.email }); } - if (this.cronScheduler) { - const scheduler = this.cronScheduler; - newPipeline.setContextFactory({ - cronReschedule: () => scheduler.reschedule(), - }); - } + newPipeline.setContextFactory({ + // Plugin schedules remain database-backed when no in-process scheduler + // exists; an external trigger is responsible for invoking due tasks. + cronReschedule: () => this.cronScheduler?.reschedule(), + }); // Update the email pipeline to use the new hook pipeline if (this.email) { @@ -1491,6 +1490,12 @@ export class EmDashRuntime { await phase("rt.cron", "Cron init (recovery deferred post-response)", async () => { try { cronExecutor = new CronExecutor(resolveDb, invokeCronHook); + // Plugin schedules are always D1-backed. On long-lived runtimes this + // callback also wakes the timer; on Cloudflare the external Cron Trigger + // drives execution, so rescheduling is intentionally a no-op. + pipeline.setContextFactory({ + cronReschedule: () => cronScheduler?.reschedule(), + }); // Recover stale locks from previous crashes. Pure bookkeeping // against the _emdash_cron_tasks table — no request needs the @@ -1549,11 +1554,6 @@ export class EmDashRuntime { await maybeRunScheduledBackup(db, storage ?? undefined); }); - // Add cron reschedule callback (merges with existing factory options) - pipeline.setContextFactory({ - cronReschedule: () => cronScheduler?.reschedule(), - }); - // start() is void on the timer scheduler but the interface // allows a promise (alarm-backed schedulers); we don't block on it. void scheduler.start(); @@ -3336,6 +3336,7 @@ export class EmDashRuntime { db: this.db, storage: this.storage ?? undefined, emailPipeline: this.email ?? undefined, + cronReschedule: () => this.cronScheduler?.reschedule(), trustedProxyHeaders: getTrustedProxyHeaders(this.config), }); routeRegistry.register(trustedPlugin); diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index ffa6d50536..c0b6cc4485 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -252,6 +252,7 @@ export function createContentAccess(db: Kysely): ContentAccess { updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, + scheduledAt: item.scheduledAt, }; if (await seoRepo.isEnabled(collection)) { @@ -292,6 +293,7 @@ export function createContentAccess(db: Kysely): ContentAccess { updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, + scheduledAt: item.scheduledAt, })); if (items.length > 0 && (await seoRepo.isEnabled(collection))) { diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 981172f0c1..dd0ba955c8 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -213,6 +213,8 @@ export interface ContentItem { createdAt: string; updatedAt: string; publishedAt: string | null; + /** Scheduled publication time, when status is `scheduled`. */ + scheduledAt?: string | null; } export interface ContentListWhere { diff --git a/packages/core/tests/integration/plugins/capabilities.test.ts b/packages/core/tests/integration/plugins/capabilities.test.ts index 3f0e2be388..287493f196 100644 --- a/packages/core/tests/integration/plugins/capabilities.test.ts +++ b/packages/core/tests/integration/plugins/capabilities.test.ts @@ -126,6 +126,7 @@ describe("Capability Enforcement Integration (v2)", () => { created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), published_at TEXT, + scheduled_at TEXT, deleted_at TEXT, version INTEGER DEFAULT 1, locale TEXT NOT NULL DEFAULT 'en', @@ -169,6 +170,21 @@ describe("Capability Enforcement Integration (v2)", () => { expect(result.hasMore).toBe(false); }); + it("includes the scheduled publication time", async () => { + await sql` + UPDATE ec_posts + SET status = 'scheduled', scheduled_at = '2026-12-01T12:00:00.000Z' + WHERE id = 'post-1' + `.execute(db); + const access = createContentAccess(db); + const result = await access.list("posts"); + + expect(result.items.find((item) => item.id === "post-1")?.scheduledAt).toBe( + "2026-12-01T12:00:00.000Z", + ); + expect((await access.get("posts", "post-1"))?.scheduledAt).toBe("2026-12-01T12:00:00.000Z"); + }); + it("narrows list results by where.status", async () => { const access = createContentAccess(db); const result = await access.list("posts", { where: { status: "published" } }); diff --git a/packages/core/tests/integration/runtime/plugin-cron-route.test.ts b/packages/core/tests/integration/runtime/plugin-cron-route.test.ts new file mode 100644 index 0000000000..c42dd83126 --- /dev/null +++ b/packages/core/tests/integration/runtime/plugin-cron-route.test.ts @@ -0,0 +1,60 @@ +import { randomUUID } from "node:crypto"; + +import Database from "better-sqlite3"; +import { SqliteDialect } from "kysely"; +import { describe, expect, it } from "vitest"; + +import { EmDashRuntime, type RuntimeDependencies } from "../../../src/emdash-runtime.js"; +import { definePlugin } from "../../../src/plugins/define-plugin.js"; + +function createDeps(onActivate: (hasCron: boolean) => void): RuntimeDependencies { + const entrypoint = `test-plugin-cron-route-${randomUUID()}`; + return { + config: { database: { entrypoint, config: {}, type: "sqlite" } }, + plugins: [ + definePlugin({ + id: "cron-route", + version: "1.0.0", + routes: { + status: { handler: async (ctx) => ({ hasCron: !!ctx.cron }) }, + }, + hooks: { + "plugin:activate": { + handler: async (_event, ctx) => onActivate(!!ctx.cron), + }, + }, + }), + ], + createDialect: () => new SqliteDialect({ database: new Database(":memory:") }), + createScheduler: null, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; +} + +describe("EmDashRuntime.handlePluginApiRoute — cron", () => { + it("provides D1-backed cron access without an in-process scheduler", async () => { + let activateHasCron = false; + const runtime = await EmDashRuntime.create( + createDeps((hasCron) => { + activateHasCron = hasCron; + }), + ); + try { + const result = await runtime.handlePluginApiRoute( + "cron-route", + "GET", + "/status", + new Request("http://test.local/_emdash/api/plugins/cron-route/status"), + ); + expect(result).toMatchObject({ success: true, data: { hasCron: true } }); + + await runtime.setPluginStatus("cron-route", "inactive"); + await runtime.setPluginStatus("cron-route", "active"); + expect(activateHasCron).toBe(true); + } finally { + await runtime.stopCron(); + } + }); +}); diff --git a/packages/core/tests/unit/astro/plugin-route-external-auth.test.ts b/packages/core/tests/unit/astro/plugin-route-external-auth.test.ts new file mode 100644 index 0000000000..e2054ca905 --- /dev/null +++ b/packages/core/tests/unit/astro/plugin-route-external-auth.test.ts @@ -0,0 +1,119 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("astro:middleware", () => ({ + defineMiddleware: (handler: unknown) => handler, +})); + +const { authenticate, getUserByEmail } = vi.hoisted(() => ({ + authenticate: vi.fn(async () => ({ + email: "admin@example.com", + name: "Admin", + role: 50, + subject: "access-user", + })), + getUserByEmail: vi.fn(async () => ({ + id: "user-1", + email: "admin@example.com", + name: "Admin", + role: 50, + disabled: false, + })), +})); + +vi.mock("virtual:emdash/auth", () => ({ authenticate }), { virtual: true }); +vi.mock("virtual:emdash/config", () => ({ default: {} }), { virtual: true }); +vi.mock("@emdash-cms/auth/adapters/kysely", () => ({ + createKyselyAdapter: () => ({ + getUserByEmail, + getUserById: vi.fn(async () => null), + }), +})); +vi.mock("../../../src/astro/session-user.js", () => ({ + resolveSessionUser: vi.fn(async () => null), +})); + +let onRequest: typeof import("../../../src/astro/middleware/auth.js").onRequest; + +beforeAll(async () => { + vi.stubEnv("DEV", false); + ({ onRequest } = await import("../../../src/astro/middleware/auth.js")); +}); + +function createContext(path: string, isPublic: boolean) { + const locals: Record & { user?: { id: string; email: string } } = { + emdash: { + db: {}, + config: { + auth: { + type: "cloudflare-access", + entrypoint: "@emdash-cms/cloudflare/auth", + config: { teamDomain: "example.cloudflareaccess.com" }, + }, + }, + getPluginRouteMeta: vi.fn(() => ({ public: isPublic })), + }, + }; + const url = new URL(path, "https://example.com"); + const session = { get: vi.fn(async () => null), set: vi.fn() }; + + return { + locals, + session, + context: { + request: new Request(url, { + headers: { "Cf-Access-Jwt-Assertion": "access-jwt" }, + }), + url, + locals, + session, + redirect: vi.fn(), + }, + }; +} + +describe("external auth on plugin API routes", () => { + beforeEach(() => { + authenticate.mockClear(); + getUserByEmail.mockClear(); + }); + + it("authenticates a private plugin route with the configured provider", async () => { + const { context, locals, session } = createContext( + "/_emdash/api/plugins/ai-search/config", + false, + ); + + const response = await onRequest(context as never, async () => + locals.user ? new Response("ok") : new Response("Authentication required", { status: 401 }), + ); + + expect(response.status).toBe(200); + expect(authenticate).toHaveBeenCalledOnce(); + expect(locals.user).toMatchObject({ id: "user-1", email: "admin@example.com" }); + expect(session.set).toHaveBeenCalledWith("user", { id: "user-1" }); + }); + + it("returns an opaque response when external authentication fails", async () => { + authenticate.mockRejectedValueOnce(new Error("sensitive provider details")); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const { context } = createContext("/_emdash/api/plugins/ai-search/config", false); + + try { + const response = await onRequest(context as never, async () => new Response("ok")); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Authentication failed"); + } finally { + consoleError.mockRestore(); + } + }); + + it("leaves explicitly public plugin routes unauthenticated", async () => { + const { context } = createContext("/_emdash/api/plugins/ai-search/query", true); + + const response = await onRequest(context as never, async () => new Response("ok")); + + expect(response.status).toBe(200); + expect(authenticate).not.toHaveBeenCalled(); + }); +}); From f3445d432c1f9d050967a1e1e45e1fe4d0afff8f Mon Sep 17 00:00:00 2001 From: ttmx Date: Thu, 16 Jul 2026 09:52:03 +0100 Subject: [PATCH 2/7] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/core/src/emdash-runtime.ts | 2 +- packages/core/src/plugins/types.ts | 2 +- .../core/tests/integration/runtime/plugin-cron-route.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index e643c09b08..f56ded5827 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1490,7 +1490,7 @@ export class EmDashRuntime { await phase("rt.cron", "Cron init (recovery deferred post-response)", async () => { try { cronExecutor = new CronExecutor(resolveDb, invokeCronHook); - // Plugin schedules are always D1-backed. On long-lived runtimes this + // Plugin schedules are always database-backed. On long-lived runtimes this // callback also wakes the timer; on Cloudflare the external Cron Trigger // drives execution, so rescheduling is intentionally a no-op. pipeline.setContextFactory({ diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index dd0ba955c8..2ce3bbaefe 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -213,7 +213,7 @@ export interface ContentItem { createdAt: string; updatedAt: string; publishedAt: string | null; - /** Scheduled publication time, when status is `scheduled`. */ + /** Scheduled publication time, if set (e.g. scheduled items or scheduled draft changes). */ scheduledAt?: string | null; } diff --git a/packages/core/tests/integration/runtime/plugin-cron-route.test.ts b/packages/core/tests/integration/runtime/plugin-cron-route.test.ts index c42dd83126..4fe332836e 100644 --- a/packages/core/tests/integration/runtime/plugin-cron-route.test.ts +++ b/packages/core/tests/integration/runtime/plugin-cron-route.test.ts @@ -34,7 +34,7 @@ function createDeps(onActivate: (hasCron: boolean) => void): RuntimeDependencies } describe("EmDashRuntime.handlePluginApiRoute — cron", () => { - it("provides D1-backed cron access without an in-process scheduler", async () => { + it("provides database-backed cron access without an in-process scheduler", async () => { let activateHasCron = false; const runtime = await EmDashRuntime.create( createDeps((hasCron) => { From 85b4def82636acb5dc408ba1f499c9b6ddb3b3e2 Mon Sep 17 00:00:00 2001 From: ttmx Date: Thu, 16 Jul 2026 10:00:22 +0100 Subject: [PATCH 3/7] fix(core): surface scheduledAt in plugin write API and correct plugin-auth docs --- packages/core/src/astro/middleware/auth.ts | 7 ++++--- packages/core/src/plugins/context.ts | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index f5caeadf20..209db92ff0 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -378,9 +378,10 @@ async function handleEmDashAuth( } /** - * Soft auth for plugin routes: resolve user from Bearer token or session if present, - * but never block unauthenticated requests. The catch-all handler checks route - * metadata to decide whether auth is required (public vs private routes). + * Plugin-route auth: resolve the user from Bearer token, external provider, or + * session if present. Public routes are always allowed through; private routes + * are blocked when credentials are invalid or absent. The catch-all handler enforces + * the `plugins:manage` permission and CSRF for private invocations. */ async function handlePluginRouteAuth( context: Parameters[0]>[0], diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index c0b6cc4485..8945a94748 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -400,6 +400,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, + scheduledAt: item.scheduledAt, }; if (hasSeo) { @@ -457,6 +458,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, + scheduledAt: item.scheduledAt, }; if (hasSeo) { From 65d4b98da28f09310119e6892c7b57a81652bd8c Mon Sep 17 00:00:00 2001 From: ttmx Date: Thu, 16 Jul 2026 11:11:46 +0100 Subject: [PATCH 4/7] docs(core): clarify plugin-route auth ordering per mode --- packages/core/src/astro/middleware/auth.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 209db92ff0..5a2f58b94a 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -378,9 +378,11 @@ async function handleEmDashAuth( } /** - * Plugin-route auth: resolve the user from Bearer token, external provider, or - * session if present. Public routes are always allowed through; private routes - * are blocked when credentials are invalid or absent. The catch-all handler enforces + * Plugin-route auth. A Bearer token is tried first in all modes: a valid token + * authenticates, an invalid one returns 401. With no token, private routes under + * external-auth mode (non-DEV) are hard-authenticated via `handleExternalAuth` + * (401 on failure); every other case falls back to soft session auth and never + * blocks. Public routes are always allowed through. The catch-all handler enforces * the `plugins:manage` permission and CSRF for private invocations. */ async function handlePluginRouteAuth( From 065123ed9d59658f159d3c3e00743793c3da0a7d Mon Sep 17 00:00:00 2001 From: ttmx Date: Thu, 16 Jul 2026 11:18:49 +0100 Subject: [PATCH 5/7] docs(core): explain plugin-route auth flow step by step --- packages/core/src/astro/middleware/auth.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 5a2f58b94a..9b79ead5b1 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -378,11 +378,20 @@ async function handleEmDashAuth( } /** - * Plugin-route auth. A Bearer token is tried first in all modes: a valid token - * authenticates, an invalid one returns 401. With no token, private routes under - * external-auth mode (non-DEV) are hard-authenticated via `handleExternalAuth` - * (401 on failure); every other case falls back to soft session auth and never - * blocks. Public routes are always allowed through. The catch-all handler enforces + * Plugin-route auth. Resolves the user in three steps, stopping at the first that + * applies: + * + * 1. Bearer token (all modes). A valid token authenticates; an invalid/expired one + * returns 401 (we never silently downgrade a bad token to anonymous). + * 2. External provider — only for a *private* route in production external-auth + * mode. Here `handleExternalAuth` is the sole authority (the provider, e.g. + * Cloudflare Access, gates every request and EmDash mints no session of its + * own), so it hard-blocks with 401 on failure. Session auth is deliberately + * NOT a fallback in this case — there is no EmDash session to fall back to. + * 3. Session — everything else (non-external mode, DEV, and all public routes). + * This is soft: it sets `locals.user` if a session exists but never blocks. + * + * Public routes are always allowed through. The catch-all handler still enforces * the `plugins:manage` permission and CSRF for private invocations. */ async function handlePluginRouteAuth( From 07297c58c12457f5bc3cd537fa45b73ac917b354 Mon Sep 17 00:00:00 2001 From: ttmx Date: Thu, 16 Jul 2026 16:36:48 +0100 Subject: [PATCH 6/7] test(core): restore env stubs after plugin-route external-auth test Adds an afterAll cleanup (vi.unstubAllEnvs) so the DEV env stub can't leak into other test files sharing the Vitest worker. --- .../tests/unit/astro/plugin-route-external-auth.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/tests/unit/astro/plugin-route-external-auth.test.ts b/packages/core/tests/unit/astro/plugin-route-external-auth.test.ts index e2054ca905..0fe8016465 100644 --- a/packages/core/tests/unit/astro/plugin-route-external-auth.test.ts +++ b/packages/core/tests/unit/astro/plugin-route-external-auth.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("astro:middleware", () => ({ defineMiddleware: (handler: unknown) => handler, @@ -39,6 +39,12 @@ beforeAll(async () => { ({ onRequest } = await import("../../../src/astro/middleware/auth.js")); }); +// Restore env stubs so `import.meta.env.DEV` does not leak into other test +// files sharing this Vitest worker. +afterAll(() => { + vi.unstubAllEnvs(); +}); + function createContext(path: string, isPublic: boolean) { const locals: Record & { user?: { id: string; email: string } } = { emdash: { From ae8d4ed46d4a519b3e0fbac1d7580db9c0a2460b Mon Sep 17 00:00:00 2001 From: ttmx Date: Thu, 16 Jul 2026 16:49:01 +0100 Subject: [PATCH 7/7] docs(core): correct plugin-route external-auth session comment External auth does persist an EmDash session (session.set("user", ...)) so public pages can identify the user; the comment now says that session is deliberately not consulted as a fallback on private plugin routes, rather than implying no session exists. --- packages/core/src/astro/middleware/auth.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 9b79ead5b1..6e4af7ef69 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -384,10 +384,11 @@ async function handleEmDashAuth( * 1. Bearer token (all modes). A valid token authenticates; an invalid/expired one * returns 401 (we never silently downgrade a bad token to anonymous). * 2. External provider — only for a *private* route in production external-auth - * mode. Here `handleExternalAuth` is the sole authority (the provider, e.g. - * Cloudflare Access, gates every request and EmDash mints no session of its - * own), so it hard-blocks with 401 on failure. Session auth is deliberately - * NOT a fallback in this case — there is no EmDash session to fall back to. + * mode. Here `handleExternalAuth` is the sole authority: the provider (e.g. + * Cloudflare Access) is re-verified on every request, so it hard-blocks with + * 401 on failure. It does persist an EmDash session (so public pages can + * identify the user), but on these routes that session is deliberately NOT + * consulted as a fallback — the provider check is authoritative every time. * 3. Session — everything else (non-external mode, DEV, and all public routes). * This is soft: it sets `locals.user` if a session exists but never blocks. *