Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugin-runtime-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Adds schedulerless cron access, scheduled publication timestamps, and external authentication support to plugin routes.
43 changes: 38 additions & 5 deletions packages/core/src/astro/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof defineMiddleware>[0]>[0],
next: Parameters<Parameters<typeof defineMiddleware>[0]>[1],
): Promise<Response> {
const { locals } = context;
const { locals, url } = context;
const { emdash } = locals;

try {
Expand All @@ -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;
Expand All @@ -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.
Expand Down
23 changes: 12 additions & 11 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/plugins/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
updatedAt: item.updatedAt,
locale: item.locale,
publishedAt: item.publishedAt,
scheduledAt: item.scheduledAt,
};

if (await seoRepo.isEnabled(collection)) {
Expand Down Expand Up @@ -292,6 +293,7 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
updatedAt: item.updatedAt,
locale: item.locale,
publishedAt: item.publishedAt,
scheduledAt: item.scheduledAt,
}));

if (items.length > 0 && (await seoRepo.isEnabled(collection))) {
Expand Down Expand Up @@ -398,6 +400,7 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
updatedAt: item.updatedAt,
locale: item.locale,
publishedAt: item.publishedAt,
scheduledAt: item.scheduledAt,
};

if (hasSeo) {
Expand Down Expand Up @@ -455,6 +458,7 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
updatedAt: item.updatedAt,
locale: item.locale,
publishedAt: item.publishedAt,
scheduledAt: item.scheduledAt,
};

if (hasSeo) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions packages/core/tests/integration/plugins/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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" } });
Expand Down
60 changes: 60 additions & 0 deletions packages/core/tests/integration/runtime/plugin-cron-route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
125 changes: 125 additions & 0 deletions packages/core/tests/unit/astro/plugin-route-external-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +42 to +43
afterAll(() => {
vi.unstubAllEnvs();
});

function createContext(path: string, isPublic: boolean) {
const locals: Record<string, unknown> & { 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();
});
});
Loading