fix dav autodiscovery: well-known needs every method, /dav needs no cors

two bugs, both of which iOS reports as "Cannot connect using SSL" — a message
about TLS for a problem that has nothing to do with TLS. the certificate was
never involved.

the well-known routes were registered with .get. RFC 6764 §6 has the client
probe the well-known URI with the method it actually intends to use, and iOS
sends PROPFIND — which fell through to the SPA catch-all and 404'd. they
answered correctly in a browser, which is why they looked healthy.

hono's cors() answers every OPTIONS itself as a preflight and never calls the
route beneath it, so OPTIONS /dav/ returned a bare 204 with no DAV header.
OPTIONS is not a preflight to a DAV client — it is how the client asks what the
server can do, and iOS refuses an account whose server does not advertise
calendar-access. cors now skips the DAV paths entirely; a CalDAV client is not
a browser and has no origin to check.

verified from the public internet: PROPFIND on both well-known paths 301s to
/dav/, and an authenticated OPTIONS now returns
`DAV: 1, 2, 3, calendar-access, addressbook, extended-mkcol`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 11:08:50 +00:00
co-authored by Claude Opus 5
parent 16f5175205
commit c4fecc201b
+21 -6
View File
@@ -59,8 +59,7 @@ export type { HonoVariables };
export const honoServer = new Hono<{ Variables: HonoVariables }>();
honoServer.use(
cors({
const corsMiddleware = cors({
origin: (origin, c) => {
const host = c.req.header('host');
// TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a
@@ -70,8 +69,21 @@ honoServer.use(
},
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
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.
//
// 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';
honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
// Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes
// (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset.
@@ -99,8 +111,11 @@ honoServer.route('/dav', davSyncRouter);
// credential, so they must sit above every auth gate. Without them iOS in particular degrades to
// demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel
// worse than the commercial product it is replacing.
honoServer.get('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
honoServer.get('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
// `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually
// wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real
// client while looking perfectly healthy in a browser.
honoServer.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
honoServer.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
const protectedRouter = createRouter();
protectedRouter.use(bodyParser());