Skip to content

Sandboxed plugins declared via astro.config (sandboxed: []) never get public route metadata — every non-admin route requires auth #2078

Description

@marcusbellamyshaw-cell

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:

  1. Trusted (in-process, plugins: []) plugins — reads route.public directly off the live resolved plugin object. Works correctly.
  2. 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.
  3. An explicit admin route carve-out (checks adminPages/adminWidgets presence).
  4. 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

  1. 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 }) },
      },
    });
  2. Register it via astro.config.mjs:
    emdash({ sandboxed: [demoPlugin()] })
  3. curl http://localhost:4321/_emdash/api/plugins/demo/ping
  4. 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"}}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions