Files
platform/docs/plugin-unauthenticated-paths.md
T
pastilhasandClaude Opus 5 2f31d464be plugins: declare unauthenticated paths, validated; mounting designed and measured
half of the primitive kiosk needs. the manifest field and its validator are in;
the mounting is documented and not wired, so nothing serves anything publicly
today.

paths are relative to the plugin's own mount and refused rather than corrected:
no leading-slash-missing, no .., no //, no query or fragment, wildcard only as a
trailing /*, and never the bare / or /*. the plugin is trusted with what those
paths DO, never with which paths.

the mounting was measured rather than reasoned about, and two obvious designs
are wrong:

mounting the public router at the plugin prefix gives correct fall-through but
lets the router serve any route it defines - the manifest becomes documentation
rather than enforcement.

adding a use('*') guard blocks that leak and BREAKS the plugin's authenticated
api: a use('*') middleware always matches, so the parent never continues.
measured - an authenticated route 404'd with a valid token.

what works is registering only the declared patterns, delegating into a
pre-mounted app. undeclared paths never register a handler, so they fall through
naturally. enforcement by registration rather than by checking, which is why a
plugin cannot widen it.

docs/plugin-unauthenticated-paths.md has the probe results and the six
remaining steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:19:48 +00:00

4.8 KiB

Letting a plugin serve a path without a session

Design proven by measurement on 2026-08-18, half implemented. The manifest field and its validator are in; the mounting is not. Everything below was tested against Hono rather than reasoned about — the probes are reproduced because two of the three obvious designs are wrong in ways that only show up when run.

Why

Some integrations receive requests that structurally cannot carry a session. Kiosk is the first: iOS installd fetches an install manifest and a binary itself, and an enrolling device posts its identifier from the OS. Neither is our code, so neither can set a header, and the platform's ?token= escape hatch does not help.

Today a plugin cannot serve such a path at all: UNPROTECTED_API_PREFIXES is a hardcoded list and plugin routers mount only on protectedRouter, behind userMiddleware.

The rejected alternative was putting an ofk_ API key in the URL. That is authority over the whole platform — files, terminal, vault — in a string that lands in a manifest, a server log and a phone's install history.

What is built

PluginManifest.unauthenticatedPaths?: string[], and publicPathProblem() in plugins/manifest.ts, wired into validateManifest. Paths are relative to the plugin's own mount and refused — never corrected — if they start without /, contain .., //, a query or fragment, use a wildcard anywhere but as a trailing /*, or are the bare / or /*.

The plugin is trusted with what those paths do, never with which paths. Authorising them is its own job; Kiosk mints a per-user key it can rotate, scoped to one plugin.

What is not built: the mounting

Three designs, and the measurements that killed two of them.

1. Mount the public router at the plugin prefix, before /api. Fall-through works — an undeclared path reaches the gate and 401s correctly. But the plugin's public router can define any route it likes, and whatever it defines is served publicly. The manifest becomes documentation rather than enforcement.

2. Same, plus a use('*') guard that 404s undeclared paths. Blocks the leak — and breaks the plugin's authenticated API. A use('*') middleware always matches, so it handles every request under the prefix and the parent never continues. Measured: /api/kiosk/secret returned 404 even with a valid token, where it should have reached the protected router.

3. Register ONLY the declared patterns, delegating into a pre-mounted app. ← correct

const mounted = new Hono();
mounted.route(`/api${plugin.prefix}`, plugin.publicRouter); // so sub-routes see paths they recognise
for (const pattern of declaredPatterns) {
  app.all(pattern, (ctx) => mounted.fetch(ctx.req.raw)); // BEFORE app.route('/api', protectedRouter)
}

Undeclared paths never register a public handler, so they never match and fall through naturally. Measured, all four cases correct:

request token result
/api/kiosk/artefact/a (declared) none 200 from the public router
/api/kiosk/secret (plugin defined it; undeclared) none 401 — falls to the gate, does not leak
/api/kiosk/secret valid 200 from the protected router
/api/kiosk/apps valid 200 — ordinary authenticated route unaffected

Enforcement is by registration, not by checking, which is why a plugin cannot widen it.

Remaining work

  1. api/public.ts in discovery (discover.ts), loaded only when unauthenticatedPaths is declared — a separate file from api/router.ts so the two surfaces cannot be confused by whoever reads either.
  2. MountedPlugin gains publicRouter and the absolute declared patterns; mount.ts builds them from mountPrefix + each declared path, so the plugin never supplies an absolute path.
  3. buildHonoApp: design 3 above, registered before app.route('/api', protectedRouter).
  4. Boot-time re-check. validateManifest runs at install; a file edited afterwards must not widen the surface. Re-validate where assertPermissionTotality already refuses to boot.
  5. Surface it in the install dialog the way osDependencies is — the owner should be told, in those words, that a plugin wants to serve something without a login.
  6. Tests that bite: an undeclared route the plugin defines must not be reachable, and the plugin's authenticated routes must keep working. Both are regressions that would otherwise be silent.

Nothing here is wired, so no plugin serves anything publicly today and the platform behaves exactly as before.