You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Expose authenticated user to plugin route handlers via RouteContext
Summary
The catch-all plugin API route resolves the authenticated user, uses it to enforce permissions, and then drops it before invoking the plugin's handler. Plugin route handlers therefore have no first-class way to know which user is making the request, even on private routes that EmDash has already authenticated. Please add the resolved user to RouteContext so plugin authors can write per-user logic safely. It is referenced in #721
consthandleRequest: APIRoute=async({ params, request, locals })=>{const{ emdash, user }=locals;// L25 — user is in scope…constdenied=requirePerm(user,permission);// L47 — used for authzif(denied)returndenied;…constresult=awaitemdash.handlePluginApiRoute(pluginId,method,`/${path}`,request,// L68 — user is NOT forwarded);};
Downstream, PluginRouteHandler.invoke() (packages/core/src/plugins/routes.ts:64) builds the route context from baseContext + input + request + requestMeta. baseContext comes from PluginContextFactory.createContext(plugin) and contains plugin/storage/kv/log/site/url and the optional users? capability — none of which represent the caller.
RouteContext itself (packages/core/src/plugins/types.ts:1068) confirms the omission:
exportinterfaceRouteContext<TInput=unknown>extendsPluginContext{input: TInput;request: Request;requestMeta: RequestMeta;// no user}
The host has the user. The plugin needs the user. The wire between them is cut.
Note: PluginContext.users? (gated by read:users) lets a plugin look up users by id — it does not tell the plugin who is calling. They solve different problems.
Why this matters
A plugin that wants to do anything user-scoped on a private route currently has three bad options:
Read the caller from the request body. Trivially spoofable. The host already authenticated the caller; the plugin can't re-validate without re-implementing session decoding (which it can't, by design).
Read a session cookie directly. Forbidden — sandboxed plugins don't get the session secret, and even if they did this duplicates host logic.
Build a parallel "who am I" round-trip. Plugins would call back into a host endpoint to re-resolve the user the host just resolved one frame up. Wasteful and racy.
Concrete use case driving this ticket — Wondo's Stripe Connect plugin (wondo-stripe):
The Connected Account is per-author (user:{userId}:stripe in plugin KV; story payouts route via story.author).
The onboarding admin route ("create my Connected Account / give me a fresh Account Link") needs to scope every operation to the calling user. It must not accept userId from the request body, because any authenticated session would then be able to start, complete, or mutate onboarding for another user.
This pattern (plugin admin routes that act on behalf of the calling author/editor) is general — payments, integrations, per-user API keys, OAuth connections, plugin-managed preferences — every one of them needs the caller. The current shape forces this logic out of plugins and into bespoke first-party routes, which is the opposite of what a plugin system is for.
Proposed change
Make the resolved caller available on RouteContext. Purely additive.
packages/core/src/plugins/types.ts — add an optional field to RouteContext:
importtype{User}from"@emdash-cms/auth";exportinterfaceRouteContext<TInput=unknown>extendsPluginContext{input: TInput;request: Request;requestMeta: RequestMeta;/** * Authenticated caller, if the route is private. * `undefined` for public routes (which skip auth entirely) * and for token-authed requests where no user is bound to the token. */user?: User;}
packages/core/src/plugins/routes.ts — extend InvokeRouteOptions and pass user into the context:
packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts — forward user (already in scope) to handlePluginApiRoute. The handler signature gains a user parameter; it's already gated to private routes by the existing requirePerm / requireScope checks earlier in the same function, so the value passed here is the validated caller.
PluginRouteRegistry.invoke and EmDashHandlers.handlePluginApiRoute — thread the user through.
Semantics
Private routes (default): ctx.user is the authenticated caller. Always present, because the route would have already returned 401/403 otherwise.
Public routes (public: true): ctx.user is undefined. Public routes intentionally skip auth, so no caller is bound.
Token-authed requests: ctx.user is whatever locals.user resolves to under token auth. If a token is not bound to a user (machine token), ctx.user is undefined and ctx.requestMeta / locals.tokenScopes remain the only signals — document this.
Backward compatibility
Additive only. Plugins that don't read ctx.user are unaffected. No breaking change to existing route handlers, no migration, no flag.
Type considerations
User lives in @emdash-cms/auth. @emdash-cms/core already depends on it transitively (the catch-all route imports requirePerm from #api/authorize.js, which uses User). Importing the type directly into plugins/types.ts should be fine; if it introduces a cycle, expose a minimal RouteCallerUser shape ({ id, email, role } plus what plugins reasonably need) and keep the full User server-internal.
Out of scope (intentionally)
Exposing the caller to non-route hooks (e.g. content:beforeUpdate). Those have a different "who triggered this" model and deserve a separate discussion. This ticket is scoped to RouteContext.
A "current user" helper on PluginContext itself. Routes are the right surface — hooks can fire without an HTTP request at all (cron, system events) and don't have a caller in the same sense.
Acceptance
RouteContext.user is typed and documented.
Private route handlers receive the authenticated caller.
Public route handlers receive undefined.
At least one integration test: private route returns ctx.user.id; public route returns null.
Expose authenticated
userto plugin route handlers viaRouteContextSummary
The catch-all plugin API route resolves the authenticated user, uses it to enforce permissions, and then drops it before invoking the plugin's handler. Plugin route handlers therefore have no first-class way to know which user is making the request, even on private routes that EmDash has already authenticated. Please add the resolved
usertoRouteContextso plugin authors can write per-user logic safely. It is referenced in #721Current behavior
packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].tsDownstream,
PluginRouteHandler.invoke()(packages/core/src/plugins/routes.ts:64) builds the route context frombaseContext + input + request + requestMeta.baseContextcomes fromPluginContextFactory.createContext(plugin)and contains plugin/storage/kv/log/site/url and the optionalusers?capability — none of which represent the caller.RouteContextitself (packages/core/src/plugins/types.ts:1068) confirms the omission:The host has the user. The plugin needs the user. The wire between them is cut.
Why this matters
A plugin that wants to do anything user-scoped on a private route currently has three bad options:
Concrete use case driving this ticket — Wondo's Stripe Connect plugin (
wondo-stripe):user:{userId}:stripein plugin KV; story payouts route viastory.author).userIdfrom the request body, because any authenticated session would then be able to start, complete, or mutate onboarding for another user.This pattern (plugin admin routes that act on behalf of the calling author/editor) is general — payments, integrations, per-user API keys, OAuth connections, plugin-managed preferences — every one of them needs the caller. The current shape forces this logic out of plugins and into bespoke first-party routes, which is the opposite of what a plugin system is for.
Proposed change
Make the resolved caller available on
RouteContext. Purely additive.packages/core/src/plugins/types.ts— add an optional field toRouteContext:packages/core/src/plugins/routes.ts— extendInvokeRouteOptionsand passuserinto the context:packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts— forwarduser(already in scope) tohandlePluginApiRoute. The handler signature gains auserparameter; it's already gated to private routes by the existingrequirePerm/requireScopechecks earlier in the same function, so the value passed here is the validated caller.PluginRouteRegistry.invokeandEmDashHandlers.handlePluginApiRoute— thread theuserthrough.Semantics
ctx.useris the authenticated caller. Always present, because the route would have already returned 401/403 otherwise.public: true):ctx.userisundefined. Public routes intentionally skip auth, so no caller is bound.ctx.useris whateverlocals.userresolves to under token auth. If a token is not bound to a user (machine token),ctx.userisundefinedandctx.requestMeta/locals.tokenScopesremain the only signals — document this.Backward compatibility
Additive only. Plugins that don't read
ctx.userare unaffected. No breaking change to existing route handlers, no migration, no flag.Type considerations
Userlives in@emdash-cms/auth.@emdash-cms/corealready depends on it transitively (the catch-all route importsrequirePermfrom#api/authorize.js, which usesUser). Importing the type directly intoplugins/types.tsshould be fine; if it introduces a cycle, expose a minimalRouteCallerUsershape ({ id, email, role }plus what plugins reasonably need) and keep the fullUserserver-internal.Out of scope (intentionally)
content:beforeUpdate). Those have a different "who triggered this" model and deserve a separate discussion. This ticket is scoped toRouteContext.users?capability or RBAC. Caller identity is not the same as theread:userscapability and should not be gated by it. (Related but distinct: Pluggable authorization layer — user RBAC is too coarse for multi-tenant/delegation scenarios #52.)PluginContextitself. Routes are the right surface — hooks can fire without an HTTP request at all (cron, system events) and don't have a caller in the same sense.Acceptance
RouteContext.useris typed and documented.undefined.ctx.user.id; public route returnsnull.Reference
packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts(L25 hasuser, L68 drops it)https://github.com/emdash-cms/emdash/blob/46f065a8e8d531cc4be020fa9998305c7fcb62a8/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts#L68