caldav/carddav: officer-caldav sidecar and the /dav door

first two steps of docs/nextcloud-replacement.md — the half that has to work on
a phone, because that is the half that cannot be faked.

radicale is supervised by the sidecar rather than reimplemented. nextcloud does
not implement caldav either; it vendors sabre/dav. icalendar and vcard are a
weekend, but sync-collection, rrule expansion, vtimezone and ctag/etag are not,
and when they are subtly wrong a phone does not error — it silently stops
syncing, or silently duplicates every event.

two doors, because a browser should not speak dav:

  /dav/*        top-level, http basic against a scoped app password, every
                verb and every dav header forwarded verbatim. this is what
                davx5 and ios talk to. same reasoning as /api/vault being
                mounted outside protectedRouter.
  /api/caldav/* the ordinary sidecar proxy, for officer's own ui. json.

the shared proxy factory could not carry the dav door: it forwards three
headers and dav dies without Depth, and it derives the user from a jwt a phone
cannot hold. so it is a separate file, per that factory's own instruction never
to grow per-app logic.

new `dav_app_passwords` — a phone cannot do jwt, and the alternative is the
account password living in a phone's account manager. argon2, shown once,
revocable per device, and accepted ONLY by /dav.

.well-known/caldav and carddav redirect to the dav root. they are most of what
makes adding an account feel transparent, and they need naming explicitly in
server.tsx or the SPA `/*` fallback answers the phone with html.

verified end to end against the running stack: 401 + WWW-Authenticate
unauthenticated; 207 with calendar-access and addressbook advertised; MKCALENDAR,
PUT and GET of a real VEVENT; calendar-query and sync-collection REPORTs; MKCOL,
PUT and GET of a real vCard. X-Script-Name is set because radicale otherwise
generates hrefs at / and the client follows them into the SPA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 03:16:17 +00:00
co-authored by Claude Opus 5
parent 86d979046b
commit ccd104a28b
14 changed files with 955 additions and 0 deletions
+19
View File
@@ -30,6 +30,9 @@ import { photosRouter } from './api/photos/router';
import { walletRouter } from './api/wallet/router';
import { vpnRouter } from './api/vpn/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { caldavRouter } from './api/dav/sidecar-server';
import { davSyncRouter } from './api/dav/sync-router';
import { davRouter } from './api/dav/router';
import { notifyRouter } from './api/notify/router';
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
import { activityRouter } from './api/activity/router';
@@ -84,6 +87,20 @@ honoServer.route('/api/waitlist', waitlistRouter);
honoServer.route('/api/vault', vaultRouter);
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
// CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is:
// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a
// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see
// api/dav/sync-router.ts.
honoServer.route('/dav', davSyncRouter);
// Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of
// fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any
// 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));
const protectedRouter = createRouter();
protectedRouter.use(bodyParser());
protectedRouter.use(userMiddleware);
@@ -106,6 +123,8 @@ protectedRouter.route('/file-browser', fileBrowserRouter);
protectedRouter.route('/music', musicRouter);
protectedRouter.route('/slskd', slskdRouter);
protectedRouter.route('/terminal', terminalRouter);
protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI
protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level)
protectedRouter.route('/notify', notifyRouter);
protectedRouter.route('/headscale', headscaleRouter);
protectedRouter.route('/transmission', transmissionRouter);