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:
+21
-14
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user