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
This commit is contained in:
2026-08-27 04:22:27 +00:00
co-authored by Claude Opus 5
parent 6b5c87da10
commit e0df28ff5d
4 changed files with 126 additions and 23 deletions
+21 -14
View File
@@ -25,7 +25,7 @@ import { dashboardsRouter } from './api/dashboards';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { devProxyRouter } from './api/dev-proxy/router';
import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { isPluginPublicPath, pluginForProtocolHeaders, pluginForRootPath } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { sendoffRouter } from './api/sendoff/router';
@@ -104,17 +104,21 @@ const corsMiddleware = cors({
allowHeaders: ['Content-Type', 'Authorization'],
});
// The DAV doors are the one place CORS must NOT run, because hono's cors() answers every OPTIONS itself
// as a preflight — 204, no body, no DAV headers — and never calls the route beneath it.
// A plugin's TOP-LEVEL paths are the one place CORS must NOT run, because hono's cors() answers every
// OPTIONS itself as a preflight — 204, no body, none of the route's own headers — and never calls the
// route beneath it.
//
// OPTIONS is not a preflight to a DAV client. It is how the client asks what the server can do, and the
// answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook` header. iOS sends it during account
// setup and refuses the account when it is missing, reporting the failure as "Cannot connect using SSL" —
// a message about TLS for a problem that has nothing to do with TLS, which is how this cost an evening.
// The case that taught this is DAV. OPTIONS is not a preflight to a DAV client: it is how the client asks
// what the server can do, and the answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook`
// header. iOS sends it during account setup and refuses the account when it is missing, reporting the
// failure as "Cannot connect using SSL" — a message about TLS for a problem that has nothing to do with
// TLS, which is how this cost an evening.
//
// Nothing is lost by skipping it: a CalDAV client is not a browser and has no origin to check.
const isDavPath = (path: string) =>
path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav';
// Nothing is lost by skipping it. A top-level path exists because something that is NOT a browser has to
// reach it — a DAV client, a phone, a protocol handler — and those have no origin to check. This was
// `isDavPath`, a literal list of four paths, until caldav became a plugin and took them with it; asking
// the claim registry means the carve-out follows whoever owns the route instead of naming it here.
const isPluginRootPath = (path: string) => pluginForRootPath(path) !== null;
// The mount table, as DATA rather than forty statements.
//
@@ -195,7 +199,7 @@ export const UNPROTECTED_API_PREFIXES: string[] = [
export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: HonoVariables }> {
const app = new Hono<{ Variables: HonoVariables }>();
app.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
app.use((ctx, next) => (isPluginRootPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
/*
A plugin's PUBLIC router, mounted above everything — the gate, `userMiddleware`, the whole platform API.
@@ -318,21 +322,24 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
app.route('/api', protectedRouter);
// Every error is JSON, shape `{ error: string }` — success is already JSON, so a text/plain failure
// was the one response a client had to special-case. An OBJECT returnValue passes through untouched:
// callers rely on its fields, and wrapping it would break them.
app.onError((error, ctx) => {
if (error instanceof CustomError) {
if (error.returnValue) {
if (typeof error.returnValue === 'string') {
return ctx.text(error.returnValue, error.statusCode);
return ctx.json({ error: error.returnValue }, error.statusCode);
} else {
return ctx.json(error.returnValue, error.statusCode);
}
}
return ctx.text(error.message, error.statusCode);
return ctx.json({ error: error.message }, error.statusCode);
}
console.error('Unexpected error:', error.message);
console.log(error.stack);
return ctx.text('Internal Server Error', 500);
return ctx.json({ error: 'Internal Server Error' }, 500);
});
return app;