Description
adaptSandboxEntry (src/plugins/adapt-sandbox-entry.ts) is used to run a standard-format plugin (one exporting definePlugin({ hooks, routes }) as its default export) trusted/in-process via plugins: [somePlugin()]. When wrapping a plugin's route handler, it always invokes it with the old two-argument SandboxedPlugin calling convention:
// adapt-sandbox-entry.ts (~L219-225)
const routeCtx = { input: ctx.input, request: requestShape, requestMeta: ctx.requestMeta };
const { input: _, request: __, requestMeta: ___, ...pluginCtx } = ctx;
return handler(routeCtx, pluginCtx);
But the public, exported, type-checked route handler contract from emdash's own types is single-argument:
// from `emdash`'s exported types (index-BXOCyLj0.d.mts)
interface PluginRoute<TInput = unknown> {
handler: (ctx: RouteContext<TInput>) => Promise<unknown>;
}
A plugin author who writes routes: { myRoute: { handler: async (ctx: RouteContext) => { ... ctx.storage.foo ... } } } — which is what the exported RouteContext/definePlugin types require and what tsc accepts — gets ctx = routeCtx (the adapter's first positional argument) at runtime when run trusted, since JS silently drops the second argument for a function declared with one parameter. routeCtx only has { input, request, requestMeta } — no storage, email, kv, cron, users, etc. — so any handler that touches those throws Cannot read properties of undefined (reading '<whatever>').
This means every standard-format plugin using the public single-arg RouteContext API is broken the moment it's run trusted (plugins: []) if any route handler touches anything beyond ctx.input/ctx.request/ctx.requestMeta.
Related to (but distinct from) #2078 — that one is about sandboxed (sandboxed: []) route auth metadata never being populated for config-declared plugins. Between the two, there is currently no way to run a standard-format plugin with public routes that touch ctx.storage/ctx.email/etc. correctly: sandboxed: [] 401s every non-admin route, and plugins: [] throws on any route handler using more than the request-shaped fields.
Steps to reproduce
- Define a standard-format plugin with a route that reads
ctx.storage:
export default definePlugin({
id: "demo",
version: "1.0.0",
storage: { things: { indexes: [] } },
routes: {
ping: {
public: true,
handler: async (ctx: RouteContext) => {
const items = await ctx.storage.things!.query({ limit: 1 });
return { ok: true, count: items.items.length };
},
},
},
});
- Register it trusted:
emdash({ plugins: [demoPlugin()] }).
curl http://localhost:4321/_emdash/api/plugins/demo/ping
- Server log shows:
TypeError: Cannot read properties of undefined (reading 'query') (or whichever field is accessed), thrown from inside the plugin's own handler at the line touching ctx.storage, because ctx at runtime is the adapter's routeCtx (missing everything but input/request/requestMeta), not the full PluginContext.
Found while trying to work around #2078 by running emdash-plugin-engagement trusted instead of sandboxed — its leaderboard route (ctx.storage.points!.query(...)) throws exactly this way.
Environment
- emdash version: 0.29.0
- Node.js version: 24.15.0 (local dev)
- Runtime: Node (Astro dev server,
@astrojs/cloudflare adapter)
- OS: Windows 11
Logs / error output
[ERROR] [vite] [plugin:engagement] Route handler failed: TypeError: Cannot read properties of undefined (reading 'points')
at handler (.../emdash-plugin-engagement/dist/sandbox-entry.js:301:44)
at Object.handler (.../emdash/src/plugins/adapt-sandbox-entry.ts:225:13)
at PluginRouteHandler.invoke (.../emdash/src/plugins/routes.ts:148:31)
Description
adaptSandboxEntry(src/plugins/adapt-sandbox-entry.ts) is used to run a standard-format plugin (one exportingdefinePlugin({ hooks, routes })as its default export) trusted/in-process viaplugins: [somePlugin()]. When wrapping a plugin's route handler, it always invokes it with the old two-argumentSandboxedPlugincalling convention:But the public, exported, type-checked route handler contract from
emdash's own types is single-argument:A plugin author who writes
routes: { myRoute: { handler: async (ctx: RouteContext) => { ... ctx.storage.foo ... } } }— which is what the exportedRouteContext/definePlugintypes require and whattscaccepts — getsctx = routeCtx(the adapter's first positional argument) at runtime when run trusted, since JS silently drops the second argument for a function declared with one parameter.routeCtxonly has{ input, request, requestMeta }— nostorage,email,kv,cron,users, etc. — so any handler that touches those throwsCannot read properties of undefined (reading '<whatever>').This means every standard-format plugin using the public single-arg
RouteContextAPI is broken the moment it's run trusted (plugins: []) if any route handler touches anything beyondctx.input/ctx.request/ctx.requestMeta.Related to (but distinct from) #2078 — that one is about sandboxed (
sandboxed: []) route auth metadata never being populated for config-declared plugins. Between the two, there is currently no way to run a standard-format plugin with public routes that touchctx.storage/ctx.email/etc. correctly:sandboxed: []401s every non-admin route, andplugins: []throws on any route handler using more than the request-shaped fields.Steps to reproduce
ctx.storage:emdash({ plugins: [demoPlugin()] }).curl http://localhost:4321/_emdash/api/plugins/demo/pingTypeError: Cannot read properties of undefined (reading 'query')(or whichever field is accessed), thrown from inside the plugin's own handler at the line touchingctx.storage, becausectxat runtime is the adapter'srouteCtx(missing everything butinput/request/requestMeta), not the fullPluginContext.Found while trying to work around #2078 by running emdash-plugin-engagement trusted instead of sandboxed — its
leaderboardroute (ctx.storage.points!.query(...)) throws exactly this way.Environment
@astrojs/cloudflareadapter)Logs / error output