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..6e4af7ef69 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -378,15 +378,28 @@ 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. 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) 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. + * + * Public routes are always allowed through. The catch-all handler still enforces + * the `plugins:manage` permission and CSRF for private invocations. */ 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 +420,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 +453,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..f56ded5827 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 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({ + 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..8945a94748 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))) { @@ -398,6 +400,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, + scheduledAt: item.scheduledAt, }; if (hasSeo) { @@ -455,6 +458,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces updatedAt: item.updatedAt, locale: item.locale, publishedAt: item.publishedAt, + scheduledAt: item.scheduledAt, }; if (hasSeo) { diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 981172f0c1..2ce3bbaefe 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, if set (e.g. scheduled items or scheduled draft changes). */ + 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..4fe332836e --- /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 database-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..0fe8016465 --- /dev/null +++ b/packages/core/tests/unit/astro/plugin-route-external-auth.test.ts @@ -0,0 +1,125 @@ +import { afterAll, 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")); +}); + +// 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: { + 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(); + }); +});