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>
81 lines
4.8 KiB
Markdown
81 lines
4.8 KiB
Markdown
# 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.
|