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.
+70
View File
@@ -128,6 +128,44 @@ export type PluginManifest = {
permissions: PluginPermission[];
/**
* Paths under this plugin's own mount that are served ABOVE the account gate.
*
* ── Why this exists ──
*
* Some integrations receive requests that structurally cannot carry a session. Kiosk is the first:
* iOS `installd` fetches an install manifest and a binary ITSELF — not the app, not the browser — and a
* device enrolling posts its identifier from the OS, not from a page. None of those has anywhere to put
* a bearer token, and the platform's own `?token=` escape hatch does not help because none of them is
* our code.
*
* The alternative was smuggling a full-power credential through a URL, which is strictly worse: an
* `ofk_` key is authority over the whole platform — files, terminal, vault — and it would end up in a
* manifest, a server log and a phone's install history.
*
* So this is the honest model. These paths really are public, and saying so lets the platform enforce
* exactly which ones rather than having a plugin invent its own bypass.
*
* ── What a plugin is trusted with, and what it is not ──
*
* NOT trusted: which paths. `validateManifest` refuses anything that could reach beyond the plugin's
* own mount, and `assertPluginPublicPaths` re-checks at boot, so a plugin cannot widen its surface by
* editing a file after review.
*
* Trusted: what those paths do. Authorising them is the plugin's job — Kiosk uses a per-user key it
* mints and can rotate, which is a credential scoped to one plugin and one purpose.
*
* ── Rules, each refused rather than corrected ──
*
* - must start with `/`, and is relative to the plugin's mount (`/artefact/…`, never `/api/kiosk/…`)
* - no `..`, no `//`, no query or fragment
* - a wildcard only as a trailing `/*`, never bare `*` and never mid-path
* - a bare `/` or `/*` is refused: that is the whole plugin, which is not a declaration
*
* Absent means the plugin serves nothing without a session, which is what every plugin today wants.
*/
unauthenticatedPaths?: string[];
/**
* Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
*
@@ -194,6 +232,28 @@ export type DiscoveredPlugin = {
*/
export const FIRST_PARTY_PUBLISHER = 'officerdev';
/**
* Check one declared public path, returning a reason it is refused or null.
*
* Pure and exported so it can be tested directly. Every rule here exists to make one sentence true: **a
* declared path can only ever reach code inside this plugin's own mount.** A plugin that could escape it
* would be declaring the platform's routes public, which is a different and much worse thing than
* declaring its own.
*/
export function publicPathProblem(path: unknown): string | null {
if (typeof path !== 'string' || !path) return 'must be a non-empty string';
if (!path.startsWith('/')) return `"${path}" must start with "/" and be relative to the plugin's mount`;
if (path.includes('..')) return `"${path}" may not contain ".."`;
if (path.includes('//')) return `"${path}" may not contain "//"`;
if (path.includes('?') || path.includes('#')) return `"${path}" may not contain a query or fragment`;
// A wildcard is allowed only as the final segment. `/a/*/b` and `/a*` are both refused: the first is a
// pattern this does not need, and the second is the kind of thing that looks narrow and is not.
const star = path.indexOf('*');
if (star !== -1 && !path.endsWith('/*')) return `"${path}" may only use a wildcard as a trailing "/*"`;
if (path === '/' || path === '/*') return 'the whole plugin cannot be public — declare the paths you mean';
return null;
}
export function mountPrefix(plugin: { appName: string; manifest: { publisher: string } }): string {
const { appName } = plugin;
return plugin.manifest.publisher === FIRST_PARTY_PUBLISHER
@@ -285,6 +345,16 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
for (const field of ['version', 'platform', 'label', 'summary', 'color'] as const) {
if (typeof manifest[field] !== 'string' || !manifest[field]) problems.push(`${field} is required`);
}
if (manifest.unauthenticatedPaths !== undefined) {
if (!Array.isArray(manifest.unauthenticatedPaths)) {
problems.push('unauthenticatedPaths must be an array of paths');
} else {
for (const [i, path] of manifest.unauthenticatedPaths.entries()) {
const problem = publicPathProblem(path);
if (problem) problems.push(`unauthenticatedPaths[${i}] ${problem}`);
}
}
}
if (!Array.isArray(manifest.permissions)) {
problems.push('permissions must be an array (use [] when the plugin gates nothing)');
} else {