Description
A plugin registered via sandboxed: [somePlugin()] in astro.config.mjs (as opposed to installed through the marketplace/registry) can never have a route's public: true flag honored for any route other than the implicit admin route. Every other route falls back to requiring authentication regardless of what the plugin declares, silently breaking any public-facing API a config-declared sandboxed plugin defines.
Root cause (traced in src/emdash-runtime.ts): getPluginRouteMeta(pluginId, path) resolves route publicness in this order:
- Trusted (in-process,
plugins: []) plugins — reads route.public directly off the live resolved plugin object. Works correctly.
sandboxedRouteMetaCache — populated only from bundle.manifest.routes when a plugin is loaded from an R2-stored marketplace/registry bundle (see loadInstalledSandboxedPlugins, syncSandboxedSourcePlugins, loadMarketplacePluginsBypassed, syncMarketplacePluginsBypassed). Never populated for config-declared sandboxed plugins.
- An explicit
admin route carve-out (checks adminPages/adminWidgets presence).
- Fallback:
if (this.findSandboxedPlugin(pluginId)) return { public: false }; — this is what every non-admin route on a config-declared sandboxed plugin hits.
The manifest built for config-declared sandboxed plugins hardcodes the gap:
// EmDashRuntime.loadSandboxedPluginsFromEntries (approx L1893-1902)
const manifest: PluginManifest = {
id: entry.id,
version: entry.version,
capabilities: entry.capabilities ?? [],
allowedHosts: entry.allowedHosts ?? [],
storage: entry.storage ?? {},
hooks: [],
routes: [], // <-- always empty for config-declared sandboxed plugins
admin: {},
};
Since routes is always [] here, sandboxedRouteMetaCache is never populated for these plugins, and every one of their non-admin routes permanently resolves to { public: false } via the fallback — even though the plugin's own definePlugin({ routes: { myRoute: { public: true, handler } } }) correctly declares it public.
Steps to reproduce
- Create a standard-format sandboxed plugin with a public route:
// sandbox-entry.ts
export default definePlugin({
id: "demo",
version: "1.0.0",
routes: {
ping: { public: true, handler: async () => ({ ok: true }) },
},
});
- Register it via
astro.config.mjs:
emdash({ sandboxed: [demoPlugin()] })
curl http://localhost:4321/_emdash/api/plugins/demo/ping
- See
401 { "error": { "code": "UNAUTHORIZED", "message": "Authentication required" } } instead of { "data": { "ok": true } }.
Workaround: register the same plugin via plugins: [demoPlugin()] (trusted/in-process) instead — getPluginRouteMeta's trusted-plugin branch reads route.public directly and works correctly. This loses the isolate sandboxing for that plugin, though.
Found while building emdash-plugin-engagement, a plugin with several genuinely public consumer-facing routes (email subscribe/confirm/unsubscribe, a public leaderboard).
Environment
- emdash version: 0.29.0
- Node.js version: 24.15.0 (local dev; also reproduced with CI on Node 22)
- Runtime: Node (Astro dev server,
@astrojs/cloudflare adapter)
- OS: Windows 11
Logs / error output
$ curl -s -i http://localhost:4321/_emdash/api/plugins/engagement/leaderboard
HTTP/1.1 401 Unauthorized
content-type: application/json
{"error":{"code":"UNAUTHORIZED","message":"Authentication required"}}
Description
A plugin registered via
sandboxed: [somePlugin()]inastro.config.mjs(as opposed to installed through the marketplace/registry) can never have a route'spublic: trueflag honored for any route other than the implicitadminroute. Every other route falls back to requiring authentication regardless of what the plugin declares, silently breaking any public-facing API a config-declared sandboxed plugin defines.Root cause (traced in
src/emdash-runtime.ts):getPluginRouteMeta(pluginId, path)resolves route publicness in this order:plugins: []) plugins — readsroute.publicdirectly off the live resolved plugin object. Works correctly.sandboxedRouteMetaCache— populated only frombundle.manifest.routeswhen a plugin is loaded from an R2-stored marketplace/registry bundle (seeloadInstalledSandboxedPlugins,syncSandboxedSourcePlugins,loadMarketplacePluginsBypassed,syncMarketplacePluginsBypassed). Never populated for config-declared sandboxed plugins.adminroute carve-out (checksadminPages/adminWidgetspresence).if (this.findSandboxedPlugin(pluginId)) return { public: false };— this is what every non-admin route on a config-declared sandboxed plugin hits.The manifest built for config-declared sandboxed plugins hardcodes the gap:
Since
routesis always[]here,sandboxedRouteMetaCacheis never populated for these plugins, and every one of their non-admin routes permanently resolves to{ public: false }via the fallback — even though the plugin's owndefinePlugin({ routes: { myRoute: { public: true, handler } } })correctly declares it public.Steps to reproduce
astro.config.mjs:curl http://localhost:4321/_emdash/api/plugins/demo/ping401 { "error": { "code": "UNAUTHORIZED", "message": "Authentication required" } }instead of{ "data": { "ok": true } }.Workaround: register the same plugin via
plugins: [demoPlugin()](trusted/in-process) instead —getPluginRouteMeta's trusted-plugin branch readsroute.publicdirectly and works correctly. This loses the isolate sandboxing for that plugin, though.Found while building emdash-plugin-engagement, a plugin with several genuinely public consumer-facing routes (email subscribe/confirm/unsubscribe, a public leaderboard).
Environment
@astrojs/cloudflareadapter)Logs / error output