Files
platform/src/servers/plugins/public-paths.ts
T
pastilhasandClaude Opus 5 e0df28ff5d a first-party plugin may claim a root path the platform reserved
caldav is not only /api/caldav. four of its surfaces are top-level and above the
account gate on purpose, because DAVx5, iOS and Thunderbird send HTTP Basic on
every request and have nowhere to put a JWT: /dav, the ios profile under it, and
the two .well-known discovery paths a client probes before it has any credential.

the mechanism for that already existed — unauthenticatedRootPaths, exclusive
claims, first come wins. what stopped caldav using it was RESERVED_ROOT, and
what that list was recording is worth being exact about: `dav` and `.well-known`
are in it because the PLATFORM serves them today, not because they are dangerous
in themselves. once caldav is a plugin, the thing the reservation protects IS
the plugin, and keeping it would mean the platform permanently owning routes for
a feature it no longer contains.

so a reserved name is claimable by a first-party plugin and refused to everyone
else. the owner drew that line today: fine for a plugin we wrote, and letting a
stranger's plugin claim /dav is a question that needs thinking about properly
rather than falling out of a flag. /api stays refused to us too — everything of
officer's lives under it and protocolHeaders is the only sanctioned way in.

.well-known needed two segments, which the single-segment rule refuses. allowed
by NAME rather than by pattern, and the reason is in the same namespace:
acme-challenge is how this machine gets its TLS certificates, so a plugin owning
it could take the domain. caldav and carddav are on the list; nothing else is,
for us as much as for anyone.

the OPTIONS carve-out moved with it. hono's cors() answers every OPTIONS as a
preflight and never calls the route beneath — which to a DAV client is not a
preflight but the question "what can you do", answered by a header iOS refuses
the account without. that was a literal list of four DAV paths; it asks the claim
registry now, so it follows whoever owns the route rather than naming it here.

verified: server boots (totality passes), /.well-known/caldav still 301s and
/dav/ still asks for basic auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:22:27 +00:00

178 lines
7.0 KiB
TypeScript

import {
mountPrefix,
publicPathProblem,
rootPathProblem,
FIRST_PARTY_PUBLISHER,
type DiscoveredPlugin,
} from './manifest';
// Which plugin paths are served ABOVE the account gate.
//
// The declaration side of this — `unauthenticatedPaths` in a manifest, `publicPathProblem` validating it —
// existed from the day the field was designed. Nothing read it: no plugin had needed one yet (kiosk
// declares none), so the field validated, documented itself, and did nothing. Vault is the first real
// user, and this is the half that was missing.
//
// ── What a plugin is trusted with, and what it is not ──
//
// NOT trusted: which paths. A declared path is resolved against the plugin's OWN mount and re-checked
// here at every mount, so a plugin cannot name `/auth`, `/users`, or another plugin's routes however it
// edits its manifest. `publicPathProblem` already refuses `..`, `//`, mid-path wildcards and the whole
// plugin; this adds the containment check that makes the promise structural rather than syntactic.
//
// Trusted: what those paths DO. Authorising them is the plugin's job — it asked for them to be public, so
// whatever credential they check is the plugin's to define. The platform's job is to be exact about which
// ones, and to say so out loud at boot.
/** A resolved entry: the absolute path pattern, and who asked for it. */
export type PublicPath = { pattern: string; appName: string };
let publicPaths: PublicPath[] = [];
/**
* Resolve a plugin's declared paths against its own mount.
*
* Returns the absolute patterns and the reasons any were refused. Refusal is per-path rather than
* per-plugin: one bad entry must not quietly take the good ones with it, and the caller logs what it lost.
*/
export function resolvePluginPublicPaths(plugin: DiscoveredPlugin): { paths: string[]; refused: string[] } {
const declared = plugin.manifest.unauthenticatedPaths ?? [];
const base = `/api${mountPrefix(plugin)}`;
const paths: string[] = [];
const refused: string[] = [];
for (const path of declared) {
const problem = publicPathProblem(path);
if (problem) {
refused.push(`${plugin.appName}: ${problem}`);
continue;
}
const absolute = `${base}${path}`;
// Containment, re-checked rather than assumed. `publicPathProblem` already makes escaping hard; this
// makes it impossible, and it is cheap. A path that does not sit under the plugin's own mount is the
// one failure that would matter, so it is checked where the mount is actually known.
if (absolute !== base && !absolute.startsWith(`${base}/`)) {
refused.push(`${plugin.appName}: "${path}" resolves outside the plugin's own mount`);
continue;
}
paths.push(absolute);
}
return { paths, refused };
}
/**
* Replace the whole set. Called from `refreshPluginMounts` alongside `setPluginPermissions`, and for the
* same reason: install, uninstall, enable and disable are all one recomputation rather than a diff.
*/
export function setPluginPublicPaths(entries: PublicPath[]): void {
publicPaths = entries;
}
/** Everything currently public, for logging and for the plugins API to show. */
export function listPluginPublicPaths(): PublicPath[] {
return publicPaths;
}
/**
* Is this request path served above the account gate?
*
* A trailing `/*` matches the prefix and everything under it; anything else is exact. Deliberately not a
* regex: the patterns come from a manifest, and the set of shapes `publicPathProblem` allows is small
* enough to match literally — which is one fewer place for a pattern to mean more than it looks like.
*/
export function isPluginPublicPath(path: string): boolean {
for (const { pattern } of publicPaths) {
if (pattern.endsWith('/*')) {
const prefix = pattern.slice(0, -2);
if (path === prefix || path.startsWith(`${prefix}/`)) return true;
} else if (path === pattern) {
return true;
}
}
return false;
}
// ── Top-level claims: root paths, and the header that hands `/api/*` to a plugin ──
//
// Everything above is about a plugin's own mount. This is about the platform's namespace, which a plugin
// may reach into only for a protocol it does not control and cannot move — see `unauthenticatedRootPaths`
// in manifest.ts for the full argument. Kept in the same file because it is the same question ("what does
// a plugin serve without a session") and splitting it would let the two drift.
export type RootClaim = { segment: string; appName: string };
let rootClaims: RootClaim[] = [];
let protocolHeaders: { header: string; appName: string }[] = [];
/**
* Resolve a plugin's root claims, refusing collisions against a set already taken.
*
* `taken` is threaded in rather than read from module state so the caller can resolve every plugin
* against one accumulating set — first come, first served, deterministic in install order, and the loser
* is told by name rather than silently overwritten.
*/
export function resolvePluginRootPaths(
plugin: DiscoveredPlugin,
taken: Set<string>,
): { segments: string[]; refused: string[] } {
const segments: string[] = [];
const refused: string[] = [];
// Provenance decides whether a RESERVED name may be claimed. A first-party plugin can take one back
// from the platform — which is what caldav owning `/dav` is — and a third-party plugin cannot.
const firstParty = plugin.manifest.publisher === FIRST_PARTY_PUBLISHER;
for (const path of plugin.manifest.unauthenticatedRootPaths ?? []) {
const problem = rootPathProblem(path, firstParty);
if (problem) {
refused.push(`${plugin.appName}: ${problem}`);
continue;
}
const segment = path.slice(1);
if (taken.has(segment)) {
refused.push(`${plugin.appName}: "/${segment}" is already claimed by another plugin`);
continue;
}
taken.add(segment);
segments.push(segment);
}
return { segments, refused };
}
export function setPluginRootClaims(claims: RootClaim[]): void {
rootClaims = claims;
}
export function listPluginRootClaims(): RootClaim[] {
return rootClaims;
}
/** Does this top-level path belong to a plugin? Segment match, so `/identityx` never counts. */
export function pluginForRootPath(path: string): string | null {
for (const { segment, appName } of rootClaims) {
if (path === `/${segment}` || path.startsWith(`/${segment}/`)) return appName;
}
return null;
}
export function setPluginProtocolHeaders(entries: { header: string; appName: string }[]): void {
protocolHeaders = entries;
}
export function listPluginProtocolHeaders(): { header: string; appName: string }[] {
return protocolHeaders;
}
/**
* Which plugin, if any, claims this `/api/*` request by header.
*
* ROUTING, not authentication: a forged header reaches the plugin's own public router, which demands
* whatever credential it demands. The worst outcome for a caller who sets it is losing access to
* Officer's API for that request.
*/
export function pluginForProtocolHeaders(headers: Headers): string | null {
for (const { header, appName } of protocolHeaders) {
if (headers.has(header)) return appName;
}
return null;
}