Skip to content

Expose authenticated user to plugin route handlers via RouteContext #812

Description

@vikramrojo

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

Current behavior

packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts

const handleRequest: APIRoute = async ({ params, request, locals }) => {
  const { emdash, user } = locals;          // L25 — user is in scope
  
  const denied = requirePerm(user, permission);   // L47 — used for authz
  if (denied) return denied;
  
  const result = await emdash.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:

export interface RouteContext<TInput = unknown> extends PluginContext {
  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:

  1. 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).
  2. Read a session cookie directly. Forbidden — sandboxed plugins don't get the session secret, and even if they did this duplicates host logic.
  3. 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.

  1. packages/core/src/plugins/types.ts — add an optional field to RouteContext:

    import type { User } from "@emdash-cms/auth";
    
    export interface RouteContext<TInput = unknown> extends PluginContext {
      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;
    }
  2. packages/core/src/plugins/routes.ts — extend InvokeRouteOptions and pass user into the context:

    export interface InvokeRouteOptions {
      request: Request;
      body?: unknown;
      user?: User;            // new
    }
    
    const routeContext: RouteContext = {
      ...baseContext,
      input: validatedInput,
      request: options.request,
      requestMeta: extractRequestMeta(options.request, this.trustedProxyHeaders),
      user: options.user,     // new
    };
  3. 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.

  4. 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.
  • Changing the users? capability or RBAC. Caller identity is not the same as the read:users capability and should not be gated by it. (Related but distinct: Pluggable authorization layer — user RBAC is too coarse for multi-tenant/delegation scenarios #52.)
  • 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.
  • Changelog entry under "Plugin API".

Reference

https://github.com/emdash-cms/emdash/blob/46f065a8e8d531cc4be020fa9998305c7fcb62a8/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts#L68

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions