Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/plugin-route-caller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"emdash": minor
"@emdash-cms/cloudflare": minor
"@emdash-cms/sandbox-workerd": minor
---

Adds the authenticated caller to plugin route handlers. Private plugin API routes now receive the requesting user as `ctx.user` (native format) / `routeCtx.user` (standard format) — `{ id, email, name, role, createdAt }` — so plugins can implement per-user logic without trusting a user id from the request body. Public routes and machine tokens with no bound user receive `undefined`.
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Key details of this configuration:
- **`entrypoint` is the package's main export.** EmDash imports it at runtime and calls the default export to construct the resolved plugin.
- **`options` flow descriptor → `createPlugin`.** Anything the user passes when registering the plugin (`analyticsPlugin({ enabled: false })`) is preserved on the descriptor and forwarded to `createPlugin`. Sandboxed plugins don't have this surface — they read settings from KV instead.
- **`id`, `version`, and `capabilities` appear twice.** Once on the descriptor, once on `definePlugin()`. They should match. The descriptor's copy is what `astro.config.mjs` sees at build time; the `definePlugin()` copy is what runs at request time.
- **Native route handlers take a single argument** — `(ctx: RouteContext)` where `ctx.input`, `ctx.request`, and `ctx.requestMeta` are merged with the regular `PluginContext` properties. This is the opposite of standard format's two-argument shape. See [API routes](/plugins/creating-plugins/api-routes/) for the full surface (everything else is identical).
- **Native route handlers take a single argument** — `(ctx: RouteContext)` where `ctx.input`, `ctx.request`, `ctx.requestMeta`, and `ctx.user` (the authenticated caller on private routes) are merged with the regular `PluginContext` properties. This is the opposite of standard format's two-argument shape. See [API routes](/plugins/creating-plugins/api-routes/) for the full surface (everything else is identical).

## Plugin id rules

Expand Down
32 changes: 32 additions & 0 deletions docs/src/content/docs/plugins/creating-plugins/api-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ routes: {
Public routes skip authentication, scope, and CSRF entirely. Anyone on the internet can call them. Use `public: true` only for endpoints that need to accept external traffic — webhooks, public-facing search endpoints — and validate the input carefully.
</Aside>

### The authenticated caller

On private routes, `routeCtx.user` is the authenticated user making the request — resolved and authorized by EmDash before your handler runs, so you can trust it for per-user logic (per-user API keys, OAuth connections, plugin-managed preferences):

```typescript
routes: {
"connect/start": {
handler: async (routeCtx, ctx) => {
// Never read the acting user from the request body — any authenticated
// session could impersonate another user that way. Use routeCtx.user.
const caller = routeCtx.user;
if (!caller) throw new Error("No caller bound");
await ctx.kv.set(`user:${caller.id}:connection`, { startedAt: Date.now() });
return { userId: caller.id };
},
},
},
```

`routeCtx.user` is `undefined` on public routes (they skip auth, so no caller is bound — even when the visitor happens to have an admin session) and for token-authed requests where the token isn't bound to a user (machine tokens). The shape matches the `UserInfo` returned by `ctx.users`: `{ id, email, name, role, createdAt }` — no sensitive fields.

Note that caller identity is separate from the `users:read` capability: `routeCtx.user` tells you **who is calling** and is always available on private routes, while `ctx.users` is a user-directory **lookup** that requires the capability.

## Input validation

`input` accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or query string (GET/DELETE), validates it, and passes the typed result to your handler as `routeCtx.input`. Invalid input returns a 400 before your handler runs.
Expand Down Expand Up @@ -359,6 +382,15 @@ interface SandboxedRouteContext {
input: unknown; // narrow with the `input` Zod schema at the route level
request: SandboxedRequest;
requestMeta?: unknown;
user?: UserInfo; // authenticated caller on private routes; undefined on public routes
}

interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}

interface PluginContext {
Expand Down
5 changes: 3 additions & 2 deletions packages/cloudflare/src/sandbox/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,9 @@ export default class PluginEntrypoint extends WorkerEntrypoint {
throw new Error(\`Route \${routeName} handler is not a function\`);
}

// Execute the route handler with input, request metadata, and context
return handler({ input, request: serializedRequest, requestMeta: serializedRequest.meta }, ctx);
// Execute the route handler with input, request metadata, the
// authenticated caller (private routes only, #812), and context
return handler({ input, request: serializedRequest, requestMeta: serializedRequest.meta, user: serializedRequest.user }, ctx);
}
}
`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ const handleRequest: APIRoute = async ({ params, request, locals }) => {
}
}

const result = await emdash.handlePluginApiRoute(pluginId, method, `/${path}`, request);
// Forward the caller only for private routes: it was validated by the
// requirePerm/requireScope checks above, so plugins can trust ctx.user.
// Public routes intentionally skip auth, so no caller is bound even when
// an ambient session happens to exist (#812).
const caller = routeMeta.public ? undefined : user;

const result = await emdash.handlePluginApiRoute(pluginId, method, `/${path}`, request, caller);

if (!result.success) {
const code = result.error?.code ?? "PLUGIN_ERROR";
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import type { Element } from "@emdash-cms/blocks";
import type { Kysely } from "kysely";

import type { RouteCallerInput } from "../plugins/routes.js";

// Re-export core types
export type {
ContentItem,
Expand Down Expand Up @@ -394,12 +396,14 @@ export interface EmDashHandlers {

handleRevisionRestore: (revisionId: string, callerUserId: string) => Promise<HandlerResponse>;

// Plugin API route handler
// Plugin API route handler. `user` is the authenticated caller for
// private routes, exposed to plugin handlers as `ctx.user` (#812).
handlePluginApiRoute: (
pluginId: string,
method: string,
path: string,
request: Request,
user?: RouteCallerInput | null,
) => Promise<HandlerResponse>;

// Public-only plugin API route handler for SSR page components.
Expand Down
27 changes: 23 additions & 4 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import type {
PageFragmentContribution,
PortableTextBlockConfig,
FieldWidgetConfig,
UserInfo,
} from "./plugins/types.js";
import type { FieldType } from "./schema/types.js";
import { hashString } from "./utils/hash.js";
Expand Down Expand Up @@ -174,7 +175,12 @@ import {
} from "./plugins/hooks.js";
import { normalizeManifestRoute } from "./plugins/manifest-schema.js";
import { extractRequestMeta, sanitizeHeadersForSandbox } from "./plugins/request-meta.js";
import { PluginRouteRegistry, type RouteMeta } from "./plugins/routes.js";
import {
PluginRouteRegistry,
toRouteCallerInfo,
type RouteCallerInput,
type RouteMeta,
} from "./plugins/routes.js";
import type { CronScheduler } from "./plugins/scheduler/types.js";
import { PluginStateRepository } from "./plugins/state.js";
import { normalizeRegistryConfig } from "./registry/config.js";
Expand Down Expand Up @@ -3314,14 +3320,25 @@ export class EmDashRuntime {
return null;
}

async handlePluginApiRoute(pluginId: string, _method: string, path: string, request: Request) {
async handlePluginApiRoute(
pluginId: string,
_method: string,
path: string,
request: Request,
user?: RouteCallerInput | null,
) {
if (!this.isPluginEnabled(pluginId)) {
return {
success: false,
error: { code: "NOT_FOUND", message: `Plugin not enabled: ${pluginId}` },
};
}

// Authenticated caller for `ctx.user`. Undefined for public routes
// (the catch-all only forwards the caller after private-route auth)
// and for machine tokens with no bound user (#812).
const caller = user ? toRouteCallerInfo(user) : undefined;

// Check trusted (configured) plugins first — this must match the
// resolution order in getPluginRouteMeta to avoid auth/execution mismatches.
const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId);
Expand All @@ -3343,13 +3360,13 @@ export class EmDashRuntime {
// No body or not JSON
}

return routeRegistry.invoke(pluginId, routeKey, { request, body });
return routeRegistry.invoke(pluginId, routeKey, { request, body, user: caller });
}

// Check sandboxed (marketplace) plugins second
const sandboxedPlugin = this.findSandboxedPlugin(pluginId);
if (sandboxedPlugin) {
return this.handleSandboxedRoute(sandboxedPlugin, path, request);
return this.handleSandboxedRoute(sandboxedPlugin, path, request, caller);
}

return {
Expand Down Expand Up @@ -3604,6 +3621,7 @@ export class EmDashRuntime {
plugin: SandboxedPluginInstance,
path: string,
request: Request,
user?: UserInfo,
): Promise<{
success: boolean;
data?: unknown;
Expand All @@ -3626,6 +3644,7 @@ export class EmDashRuntime {
method: request.method,
headers,
meta,
user,
});
return { success: true, data: result };
} catch (error) {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/plugin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import type {
PluginContext,
UninstallEvent,
UninstallHandler,
UserInfo,
} from "./plugins/types.js";

/**
Expand Down Expand Up @@ -175,6 +176,13 @@ export interface SandboxedRouteContext {
input: unknown;
request: SandboxedRequest;
requestMeta?: unknown;
/**
* Authenticated caller, if the route is private. Resolved and
* authorized by the host before dispatch — trust it over any user id
* in the request body. `undefined` for public routes and for machine
* tokens with no bound user.
*/
user?: UserInfo;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/plugins/adapt-sandbox-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,9 @@ export function adaptSandboxEntry(
input: ctx.input,
request: requestShape,
requestMeta: ctx.requestMeta,
user: ctx.user,
};
const { input: _, request: __, requestMeta: ___, ...pluginCtx } = ctx;
const { input: _, request: __, requestMeta: ___, user: ____, ...pluginCtx } = ctx;
return handler(routeCtx, pluginCtx);
},
};
Expand Down
36 changes: 35 additions & 1 deletion packages/core/src/plugins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { PluginContextFactory, type PluginContextFactoryOptions } from "./context.js";
import { extractRequestMeta } from "./request-meta.js";
import type { ResolvedPlugin, RouteContext, PluginRoute } from "./types.js";
import type { ResolvedPlugin, RouteContext, PluginRoute, UserInfo } from "./types.js";

/**
* Body-reading methods on `Request`. EmDash parses the request body once before
Expand Down Expand Up @@ -70,6 +70,34 @@ export interface RouteResult<T = unknown> {
status: number;
}

/**
* Host-side user shape accepted when dispatching a plugin route. Structurally
* matches `User` from `@emdash-cms/auth` so hosts can pass `locals.user`
* directly without the plugin layer depending on the auth package.
*/
export interface RouteCallerInput {
id: string;
email: string;
name: string | null;
role: number;
createdAt: Date | string;
}

/**
* Convert the host's authenticated user into the read-only `UserInfo` shape
* exposed to plugins as `ctx.user`. Strips sensitive/irrelevant fields and
* keeps the value structured-clone-safe for sandboxed plugins.
*/
export function toRouteCallerInfo(user: RouteCallerInput): UserInfo {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
createdAt: typeof user.createdAt === "string" ? user.createdAt : user.createdAt.toISOString(),
};
}

/**
* Route invocation options
*/
Expand All @@ -78,6 +106,11 @@ export interface InvokeRouteOptions {
request: Request;
/** Request body (already parsed) */
body?: unknown;
/**
* Authenticated caller resolved by the host, exposed to the handler as
* `ctx.user`. Undefined for public routes and unbound machine tokens.
*/
user?: UserInfo;
}

/**
Expand Down Expand Up @@ -141,6 +174,7 @@ export class PluginRouteHandler {
// (#1293). Metadata extraction uses the original request (headers only).
request: guardConsumedRequestBody(options.request),
requestMeta: extractRequestMeta(options.request, this.trustedProxyHeaders),
user: options.user,
};

// Execute handler
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/plugins/sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import type { Kysely } from "kysely";

import type { Database } from "../../database/types.js";
import type { PluginManifest, RequestMeta } from "../types.js";
import type { PluginManifest, RequestMeta, UserInfo } from "../types.js";

/**
* Resource limits for sandboxed plugins.
Expand Down Expand Up @@ -133,6 +133,11 @@ export interface SerializedRequest {
headers: Record<string, string>;
/** Normalized request metadata extracted before RPC serialization */
meta: RequestMeta;
/**
* Authenticated caller for private routes, resolved by the host before
* dispatch. Undefined for public routes and unbound machine tokens (#812).
*/
user?: UserInfo;
}

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,18 @@ export interface RouteContext<TInput = unknown> extends PluginContext {
request: Request;
/** Normalized request metadata (IP, user agent, geo) */
requestMeta: RequestMeta;
/**
* Authenticated caller, if the route is private. The host has already
* authenticated and authorized this user before dispatch, so the value
* is trustworthy — unlike a user id read from the request body.
*
* `undefined` for public routes (which skip auth entirely) and for
* token-authed requests where no user is bound to the token.
*
* Not gated by the `read:users` capability: this is the caller's own
* identity for the current request, not a user directory lookup.
*/
user?: UserInfo;
}

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/core/tests/unit/astro/plugin-api-route-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,33 @@ describe("plugin API catch-all auth (#1853)", () => {
expect(handlePluginApiRoute).toHaveBeenCalledOnce();
});
});

describe("plugin API catch-all caller forwarding (#812)", () => {
it("forwards the authenticated caller for private routes", async () => {
const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, false);
const res = await invoke(POST, "POST", locals);
expect(res.status).toBe(200);
expect(handlePluginApiRoute).toHaveBeenCalledWith(
"demo",
"POST",
"/updateHomeConfig",
expect.any(Request),
expect.objectContaining({ id: "u1", role: Role.ADMIN }),
);
});

it("does not forward a caller on public routes, even with an ambient session", async () => {
// Public routes skip auth entirely — a logged-in user browsing the site
// must not be bound as the caller of a public plugin route.
const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, true);
const res = await invoke(GET, "GET", locals, { csrf: false });
expect(res.status).toBe(200);
expect(handlePluginApiRoute).toHaveBeenCalledWith(
"demo",
"GET",
"/updateHomeConfig",
expect.any(Request),
undefined,
);
});
});
Loading
Loading