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
178 lines
7.0 KiB
TypeScript
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;
|
|
}
|