From 872b71ed2b5ac796612e74524238504abfc1ba90 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:27:45 +0200 Subject: [PATCH] feat(plugins): expose authenticated caller to route handlers as ctx.user The plugin API catch-all resolves and authorizes the caller, then drops it before dispatch, leaving plugins no safe way to know who is calling a private route. Thread the validated user through handlePluginApiRoute into RouteContext (native format) and routeCtx.user (standard format, in-process and worker sandboxes alike) as the read-only UserInfo shape. Public routes and machine tokens with no bound user receive undefined; the catch-all only forwards the caller after private-route auth, so an ambient admin session is never bound to a public route. Fixes #812 --- .changeset/plugin-route-caller.md | 7 ++ .../your-first-native-plugin.mdx | 2 +- .../plugins/creating-plugins/api-routes.mdx | 32 +++++++ packages/cloudflare/src/sandbox/wrapper.ts | 5 +- .../api/plugins/[pluginId]/[...path].ts | 8 +- packages/core/src/astro/types.ts | 6 +- packages/core/src/emdash-runtime.ts | 27 +++++- packages/core/src/plugin-types.ts | 8 ++ .../core/src/plugins/adapt-sandbox-entry.ts | 3 +- packages/core/src/plugins/routes.ts | 36 +++++++- packages/core/src/plugins/sandbox/types.ts | 7 +- packages/core/src/plugins/types.ts | 12 +++ .../unit/astro/plugin-api-route-auth.test.ts | 30 +++++++ .../unit/plugins/adapt-sandbox-entry.test.ts | 37 ++++++++ .../core/tests/unit/plugins/routes.test.ts | 87 +++++++++++++++++++ packages/workerd/src/sandbox/wrapper.ts | 9 +- 16 files changed, 303 insertions(+), 13 deletions(-) create mode 100644 .changeset/plugin-route-caller.md diff --git a/.changeset/plugin-route-caller.md b/.changeset/plugin-route-caller.md new file mode 100644 index 0000000000..ff7f01d65f --- /dev/null +++ b/.changeset/plugin-route-caller.md @@ -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`. diff --git a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx index e5d42847fa..fb16456f36 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx @@ -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 diff --git a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx index e8d182fd96..1e622bc60a 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -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. +### 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. @@ -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 { diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index fae6d00877..f0c186eee8 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -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); } } `; diff --git a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts index bdd852aa82..25791bd4f5 100644 --- a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts +++ b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts @@ -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"; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index f3cd045a5e..cdee6476fd 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -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, @@ -394,12 +396,14 @@ export interface EmDashHandlers { handleRevisionRestore: (revisionId: string, callerUserId: string) => Promise; - // 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; // Public-only plugin API route handler for SSR page components. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index a7c1898796..80e0f5b7a3 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -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"; @@ -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"; @@ -3314,7 +3320,13 @@ 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, @@ -3322,6 +3334,11 @@ export class EmDashRuntime { }; } + // 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); @@ -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 { @@ -3604,6 +3621,7 @@ export class EmDashRuntime { plugin: SandboxedPluginInstance, path: string, request: Request, + user?: UserInfo, ): Promise<{ success: boolean; data?: unknown; @@ -3626,6 +3644,7 @@ export class EmDashRuntime { method: request.method, headers, meta, + user, }); return { success: true, data: result }; } catch (error) { diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 2950f0194c..690cce482d 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -83,6 +83,7 @@ import type { PluginContext, UninstallEvent, UninstallHandler, + UserInfo, } from "./plugins/types.js"; /** @@ -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; } /** diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 760b4e42c0..256f985293 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -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); }, }; diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 24b02a58df..5d954a959d 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -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 @@ -70,6 +70,34 @@ export interface RouteResult { 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 */ @@ -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; } /** @@ -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 diff --git a/packages/core/src/plugins/sandbox/types.ts b/packages/core/src/plugins/sandbox/types.ts index e95545353d..4573221c35 100644 --- a/packages/core/src/plugins/sandbox/types.ts +++ b/packages/core/src/plugins/sandbox/types.ts @@ -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. @@ -133,6 +133,11 @@ export interface SerializedRequest { headers: Record; /** 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; } /** diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 981172f0c1..4f857cf7d0 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1160,6 +1160,18 @@ export interface RouteContext 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; } /** diff --git a/packages/core/tests/unit/astro/plugin-api-route-auth.test.ts b/packages/core/tests/unit/astro/plugin-api-route-auth.test.ts index b5f66440e6..a24b779d93 100644 --- a/packages/core/tests/unit/astro/plugin-api-route-auth.test.ts +++ b/packages/core/tests/unit/astro/plugin-api-route-auth.test.ts @@ -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, + ); + }); +}); diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index d0451f39a3..44513e441a 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -366,6 +366,43 @@ describe("adaptSandboxEntry", () => { expect(pluginCtx).not.toHaveProperty("requestMeta"); }); + it("passes the authenticated caller into routeCtx.user, not pluginCtx (#812)", async () => { + const standardHandler = vi.fn().mockResolvedValue({ ok: true }); + + const def: SandboxedPlugin = { + routes: { + whoami: { handler: standardHandler }, + }, + }; + const result = adaptSandboxEntry(def, createDescriptor()); + + const caller = { + id: "u1", + email: "a@b.c", + name: "A", + role: 2, + createdAt: "2026-01-01T00:00:00.000Z", + }; + const mockCtx = { + input: {}, + request: new Request("http://localhost/test"), + requestMeta: { ip: null, userAgent: null, referer: null, geo: null }, + user: caller, + plugin: { id: "test-plugin", version: "1.0.0" }, + kv: {} as any, + storage: {} as any, + log: {} as any, + site: { name: "", url: "", locale: "en" }, + url: (p: string) => p, + }; + + await result.routes.whoami.handler(mockCtx as any); + + const [routeCtx, pluginCtx] = standardHandler.mock.calls[0]; + expect(routeCtx.user).toEqual(caller); + expect(pluginCtx).not.toHaveProperty("user"); + }); + it("preserves public flag on routes", () => { const def: SandboxedPlugin = { routes: { diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 1187cd54ef..c27432d2e2 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -19,6 +19,7 @@ import { PluginRouteRegistry, PluginRouteError, createRouteRegistry, + toRouteCallerInfo, } from "../../../src/plugins/routes.js"; import type { ResolvedPlugin } from "../../../src/plugins/types.js"; @@ -129,6 +130,48 @@ describe("PluginRouteError", () => { }); }); +describe("toRouteCallerInfo (#812)", () => { + it("maps the host user to the plugin-facing UserInfo shape", () => { + const info = toRouteCallerInfo({ + id: "u1", + email: "a@b.c", + name: null, + role: 4, + createdAt: new Date("2026-01-02T03:04:05.000Z"), + }); + + expect(info).toEqual({ + id: "u1", + email: "a@b.c", + name: null, + role: 4, + createdAt: "2026-01-02T03:04:05.000Z", + }); + }); + + it("passes string createdAt through and strips extra fields", () => { + // Extra host-side fields (avatarUrl, data, …) must not leak to plugins. + const hostUser = { + id: "u1", + email: "a@b.c", + name: "A", + role: 2, + createdAt: "2026-01-01T00:00:00.000Z", + avatarUrl: "https://x/y.png", + data: { secret: true }, + }; + const info = toRouteCallerInfo(hostUser); + + expect(info).toEqual({ + id: "u1", + email: "a@b.c", + name: "A", + role: 2, + createdAt: "2026-01-01T00:00:00.000Z", + }); + }); +}); + describe("PluginRouteHandler", () => { describe("getRouteMeta", () => { it("returns null for non-existent route", () => { @@ -334,6 +377,50 @@ describe("PluginRouteHandler", () => { expect(result.data).toEqual({ hasEmail: true, hasSend: true }); }); + it("exposes the authenticated caller as ctx.user (#812)", async () => { + const plugin = createTestPlugin({ + routes: { + whoami: { + handler: async (ctx) => ({ user: ctx.user ?? null }), + }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + const caller = { + id: "user-1", + email: "author@example.com", + name: "Author", + role: 2, + createdAt: "2026-01-01T00:00:00.000Z", + }; + const result = await handler.invoke("whoami", { + request: new Request("http://test.com/whoami"), + user: caller, + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ user: caller }); + }); + + it("leaves ctx.user undefined when no caller is provided (#812)", async () => { + const plugin = createTestPlugin({ + routes: { + whoami: { + handler: async (ctx) => ({ user: ctx.user ?? null }), + }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + const result = await handler.invoke("whoami", { + request: new Request("http://test.com/whoami"), + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ user: null }); + }); + it("surfaces an actionable error when a handler reads the consumed request body (#1293)", async () => { // EmDash parses the body once and exposes it as ctx.input; the same // Request is then handed to the handler with its stream already spent. diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts index ce2fd9b26d..9a16315263 100644 --- a/packages/workerd/src/sandbox/wrapper.ts +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -413,8 +413,15 @@ export default { } try { + // user: authenticated caller for private routes, resolved by + // the host before dispatch (#812). const result = await handler( - { input, request: serializedRequest, requestMeta: serializedRequest?.meta }, + { + input, + request: serializedRequest, + requestMeta: serializedRequest?.meta, + user: serializedRequest?.user, + }, ctx, ); return Response.json(result);