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>
This commit is contained in:
2026-08-18 03:19:48 +00:00
co-authored by Claude Opus 5
parent f6658bd092
commit 2f31d464be
2 changed files with 150 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# 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**
```ts
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.