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
+45
View File
@@ -0,0 +1,45 @@
import { createRouter } from '../../create-router';
import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb';
import * as errors from '../../custom-errors';
// Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else
// under /api — this is the owner administering their devices from a logged-in browser, which is a
// completely different act from a phone syncing (that is /dav, see sync-router.ts).
//
// The plaintext credential exists for exactly one response and is never stored, so POST is the only
// place it appears. There is deliberately no "show me it again" endpoint: if it is lost, revoke the
// row and mint another. That is cheaper than any design where the secret can be read back.
export const davRouter = createRouter();
davRouter.get('/passwords', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
});
davRouter.post('/passwords', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { label?: string } | undefined;
const label = body?.label?.trim();
if (!label) throw errors.BAD_REQUEST('label is required');
const { entry, password } = await createDavAppPassword(user.id, label);
// `password` is returned once, here. Nothing else in the system can produce it again.
return ctx.json({ entry, password, username: user.email });
});
davRouter.post('/passwords/:id/revoke', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
if (!(await revokeDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
return ctx.json({ ok: true });
});
davRouter.delete('/passwords/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
if (!(await deleteDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
return ctx.json({ ok: true });
});
+17
View File
@@ -0,0 +1,17 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// The officer-caldav sidecar has TWO consumers in the platform and they need different things, so the
// port capture lives here and both import it.
//
// • `caldavRouter` — the ordinary JSON door for Officer's own UI. Behind userMiddleware, forwarded by
// the shared factory with `X-Officer-User`, exactly like every other sidecar.
// • `davSyncRouter` — the DAV door for phones. Cannot use the factory: it forwards only three headers
// (`create-proxy.ts:88`) and DAV dies without `Depth`, and it derives the user from a platform JWT
// that a DAV client has no way to hold.
//
// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app
// logic, so the DAV-specific half is a separate file rather than a branch inside it.
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav' });
export const caldavRouter = proxy.router;
export const getCaldavUrl = proxy.getHttpUrl;
+125
View File
@@ -0,0 +1,125 @@
import { createRouter } from '../../create-router';
import { getUserByEmail, verifyDavAppPassword } from 'officerdb';
import { getCaldavUrl } from './sidecar-server';
// The DAV door: `/dav/*`, mounted TOP-LEVEL, outside protectedRouter.
//
// WHY IT IS NOT UNDER /api AND NOT BEHIND userMiddleware. DAVx5, iOS Calendar, macOS and Thunderbird
// speak CalDAV over HTTPS with HTTP Basic auth on every single request. They cannot log into Officer,
// cannot hold a 30-day JWT, cannot refresh one and cannot answer a passkey challenge. This is the same
// situation `/api/vault` is in — "the Bitwarden client carries its own bearer token, not a platform
// session JWT, so userMiddleware would 401 it" (hono.ts) — with a different credential type.
//
// The credential is a DAV app password (`dav_app_passwords`), scoped to this mount and nothing else.
// An app password is NOT a session: userMiddleware must never learn to accept one, or a device that
// syncs a calendar would also reach the wallet.
//
// Everything is forwarded verbatim to the caldav sidecar, which forwards it to Radicale. The platform
// authenticates and moves bytes; it does not parse a single line of iCalendar. That restraint is the
// same one create-proxy.ts documents, and for the same reason.
export const davSyncRouter = createRouter();
const unauthorized = () =>
new Response('Unauthorized', {
status: 401,
// Without this header a DAV client will not prompt for credentials at all — it just fails. The
// realm string is what the phone shows in its password dialog.
headers: { 'WWW-Authenticate': 'Basic realm="Officer DAV", charset="UTF-8"' },
});
type BasicCreds = { username: string; password: string };
function parseBasic(header: string | undefined): BasicCreds | null {
if (!header?.startsWith('Basic ')) return null;
let decoded: string;
try {
decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
} catch {
return null;
}
// Split on the FIRST colon only — a generated password never contains one, but an email local-part
// legally can, and splitting greedily would break those accounts in a way that looks like a wrong
// password.
const at = decoded.indexOf(':');
if (at < 0) return null;
return { username: decoded.slice(0, at), password: decoded.slice(at + 1) };
}
// Same list the sidecar forwards, and for the same reason: `Depth` decides whether PROPFIND returns a
// collection, its children or the whole tree, so dropping it makes every calendar look empty.
const REQUEST_HEADERS = [
'depth',
'content-type',
'content-length',
'if',
'if-match',
'if-none-match',
'destination',
'overwrite',
'lock-token',
'timeout',
'prefer',
'user-agent',
] as const;
const RESPONSE_HEADERS = [
'content-type',
'dav',
'allow',
'etag',
'last-modified',
'location',
'lock-token',
'preference-applied',
'vary',
] as const;
davSyncRouter.all('/*', async (ctx) => {
const creds = parseBasic(ctx.req.header('authorization'));
if (!creds) return unauthorized();
const user = await getUserByEmail(creds.username);
if (!user) return unauthorized();
const userId = await verifyDavAppPassword(user.id, creds.password);
if (!userId) return unauthorized();
const base = getCaldavUrl();
if (!base) return new Response('caldav sidecar not available', { status: 503 });
const url = new URL(ctx.req.url);
// The sidecar mounts DAV at /dav too, so the prefix is preserved rather than stripped — Radicale
// generates absolute hrefs in its multistatus responses, and if the path the client sees differs
// from the path the server thinks it is at, every href points somewhere that 404s.
const target = `${base}${url.pathname}${url.search}`;
const headers: Record<string, string> = {};
for (const name of REQUEST_HEADERS) {
const value = ctx.req.header(name);
if (value) headers[name] = value;
}
headers['X-Officer-User'] = String(userId);
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(target, {
method,
headers,
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
});
} catch (err) {
console.error('[dav] proxy fetch failed', { path: url.pathname, error: String(err) });
return new Response('caldav sidecar unreachable', { status: 502 });
}
const out = new Headers();
for (const name of RESPONSE_HEADERS) {
const value = upstream.headers.get(name);
if (value) out.set(name, value);
}
return new Response(upstream.body, { status: upstream.status, headers: out });
});
+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);
+204
View File
@@ -0,0 +1,204 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { startRadicale, davPaths } from './radicale';
// The officer-caldav sidecar. Owns the whole CalDAV/CardDAV contract: it supervises Radicale, owns the
// collection storage under DATA_PATH/dav, and exposes two very different doors.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its mount prefix before forwarding.
//
// ANY /dav/* the DAV door. Forwarded verbatim to Radicale, including PROPFIND, REPORT,
// MKCOL, MKCALENDAR, COPY, MOVE, LOCK and UNLOCK, and every DAV header.
// Reached from the platform's TOP-LEVEL /dav mount, which authenticates a DAV
// app password over HTTP Basic. This is the door phones use.
// GET /_health is Radicale up, and does it answer.
// GET /_officer/* the JSON door for Officer's own web UI (see collections.ts).
//
// WHY TWO DOORS. A browser should not speak DAV — rendering a month view out of multistatus XML is a
// lot of machinery, and it would make the web UI as fragile as the protocol. The collections are on
// local disk here, so this sidecar can serve the UI plain JSON over the same data while DAV stays the
// machine-facing interface. Same split officer-email already uses.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
const DATA_PATH = process.env.DATA_PATH ?? `${process.cwd()}/data`;
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
// Where the platform exposes the DAV door publicly. It is a fixed contract between hono.ts's top-level
// mount and this sidecar, not configuration — Radicale has to be told it so the hrefs it generates are
// the ones the client can actually fetch.
const DAV_MOUNT = '/dav';
const paths = davPaths(DATA_PATH);
const radicale = startRadicale({ ...paths, port: getFreePort() });
const port = getFreePort();
// Headers a DAV exchange cannot survive without. `Depth` alone decides whether a PROPFIND returns the
// collection, its children, or the whole tree — drop it and every client sees an empty calendar. The
// rest carry conditional writes (If-Match is how a client avoids clobbering a concurrent edit),
// COPY/MOVE targets, and lock tokens.
const DAV_REQUEST_HEADERS = [
'depth',
'content-type',
'content-length',
'if',
'if-match',
'if-none-match',
'destination',
'overwrite',
'lock-token',
'timeout',
'prefer',
'user-agent',
] as const;
// Sent back untouched. `DAV` and `Allow` are how a client discovers what the server supports; getting
// them wrong makes iOS decide the account is not a calendar at all.
const DAV_RESPONSE_HEADERS = [
'content-type',
'dav',
'allow',
'etag',
'last-modified',
'location',
'lock-token',
'preference-applied',
'vary',
] as const;
async function forwardToRadicale(req: Request, subpath: string, userId: string): Promise<Response> {
const url = new URL(req.url);
const target = `${radicale.url}${subpath}${url.search}`;
const headers: Record<string, string> = {};
for (const name of DAV_REQUEST_HEADERS) {
const value = req.headers.get(name);
if (value) headers[name] = value;
}
// Radicale is configured with `type = http_x_remote_user` and does no authentication of its own. It
// binds loopback, so this header is only settable from inside this process.
headers['X-Remote-User'] = userId;
// Radicale generates ABSOLUTE hrefs in its multistatus bodies, and it builds them from where it
// thinks it is mounted. Without this it answers a PROPFIND on /dav/ with
// `<current-user-principal><href>/1/</href>`, the client dutifully requests /1/, and the platform
// serves it the SPA — an HTML page where a calendar was expected. The client does not report a
// useful error for that; the account just appears to have no calendars in it.
headers['X-Script-Name'] = DAV_MOUNT;
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(target, {
method: req.method,
headers,
body: hasBody ? await req.arrayBuffer() : undefined,
});
} catch (err) {
// Path only — a DAV body is the owner's calendar and address book content.
console.error('[caldav] radicale unreachable', { path: subpath, error: String(err) });
return new Response('caldav upstream unreachable', { status: 502 });
}
const out = new Headers();
for (const name of DAV_RESPONSE_HEADERS) {
const value = upstream.headers.get(name);
if (value) out.set(name, value);
}
return new Response(upstream.body, { status: upstream.status, headers: out });
}
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// A contact photo or a calendar with years of history arrives as one PUT.
maxRequestBodySize: 32 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return new Response('missing or invalid X-Officer-User', { status: 401 });
}
if (url.pathname === '/_health') {
if (!radicale.isRunning()) {
return Response.json({ ok: false, running: false, error: 'radicale not running' }, { status: 503 });
}
try {
const probe = await fetch(`${radicale.url}/`, {
method: 'PROPFIND',
headers: { 'X-Remote-User': String(userId), Depth: '0' },
signal: AbortSignal.timeout(5000),
});
// Anything that answers the DAV handshake is healthy; 207 Multi-Status is the expected reply.
return Response.json({ ok: probe.status < 500, running: true, status: probe.status });
} catch (err) {
return Response.json({ ok: false, running: true, error: String(err) }, { status: 502 });
}
}
if (url.pathname === '/dav' || url.pathname.startsWith('/dav/')) {
const subpath = url.pathname.slice('/dav'.length) || '/';
return forwardToRadicale(req, subpath, String(userId));
}
return new Response('not found', { status: 404 });
},
});
console.log(`[caldav] listening on 127.0.0.1:${port}; radicale on ${radicale.url}; storage ${paths.storagePath}`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'caldav',
capabilities: ['caldav'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'caldav:server', port });
console.log(`[caldav] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[caldav] ${signal} received, shutting down...`);
radicale.stop();
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+112
View File
@@ -0,0 +1,112 @@
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
// Supervises Radicale, the CalDAV/CardDAV server this sidecar fronts.
//
// WHY A REAL SERVER AND NOT OUR OWN. See docs/nextcloud-replacement.md. The short version: iCalendar and
// vCard are easy, but `sync-collection` REPORT, RRULE expansion, VTIMEZONE and ctag/etag semantics are
// not — and when they are subtly wrong a phone does not error, it silently stops syncing or silently
// duplicates every event. Radicale has been getting that right since 2008.
//
// AUTH. Radicale runs with `type = http_x_remote_user`, meaning it trusts an `X-Remote-User` header
// completely and does no authentication of its own. That is safe here for exactly the reason every
// other sidecar's `X-Officer-User` is safe: it binds LOOPBACK ONLY, so nothing but this sidecar can
// reach it, and this sidecar is only reachable through the platform, which is the thing that
// authenticates. If Radicale is ever bound to a non-loopback interface this becomes an open door —
// hence the explicit hostname below rather than a default.
export type RadicaleHandle = {
port: number;
url: string;
stop: () => void;
isRunning: () => boolean;
};
type StartParams = {
/** Where collections live. Created if absent. */
storagePath: string;
/** Where the generated config is written. Never hand-edit it; it is rewritten on every boot. */
configPath: string;
port: number;
};
// Radicale's own config format. Regenerated at every boot on purpose: the config is derived state, and
// a hand-edit that silently disagreed with what this file thinks is true would be very hard to debug.
const configFor = (storagePath: string, port: number) => `# GENERATED by officer-caldav on every boot.
# Hand edits are lost. See src/servers/sidecar/caldav/radicale.ts.
[server]
hosts = 127.0.0.1:${port}
# The platform terminates TLS; this hop is loopback.
ssl = False
[auth]
# Trusts X-Remote-User outright. Safe ONLY because of the loopback bind above — see the note in
# radicale.ts before changing either.
type = http_x_remote_user
[storage]
type = multifilesystem
filesystem_folder = ${storagePath}
[rights]
# The owner is the only principal that exists; single-user is a platform invariant.
type = owner_only
[logging]
level = warning
`;
export function startRadicale({ storagePath, configPath, port }: StartParams): RadicaleHandle {
mkdirSync(storagePath, { recursive: true });
writeFileSync(configPath, configFor(storagePath, port));
let child: ChildProcess | null = null;
let stopped = false;
let restarts = 0;
const launch = () => {
if (stopped) return;
child = spawn('radicale', ['--config', configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
// Radicale must not inherit the platform's environment wholesale — Bun auto-loads .env into every
// process started in the platform directory, and there is no reason for a calendar server to hold
// an Anthropic key or a database URL.
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' },
});
child.stdout?.on('data', (buf: Buffer) => process.stdout.write(`[radicale] ${buf}`));
child.stderr?.on('data', (buf: Buffer) => process.stderr.write(`[radicale] ${buf}`));
child.on('exit', (code, signal) => {
child = null;
if (stopped) return;
// Backoff, capped. A crash loop should be visible in the logs rather than a busy spin, and the
// sidecar itself stays up so /_health can keep reporting the truth.
restarts += 1;
const delay = Math.min(30_000, 500 * 2 ** Math.min(restarts, 6));
console.error(`[caldav] radicale exited (code=${code} signal=${signal}); restarting in ${delay}ms`);
setTimeout(launch, delay);
});
};
launch();
return {
port,
url: `http://127.0.0.1:${port}`,
isRunning: () => child !== null,
stop: () => {
stopped = true;
child?.kill('SIGTERM');
child = null;
},
};
}
/** Resolve the paths this sidecar owns under DATA_PATH. The platform never opens any of them. */
export function davPaths(dataPath: string) {
const root = join(dataPath, 'dav');
return { root, storagePath: join(root, 'collections'), configPath: join(root, 'radicale.conf') };
}
+3
View File
@@ -75,6 +75,9 @@ export type SidecarEvent =
| { type: 'invoiceshelf:server'; port: number }
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'photos:server'; port: number }
// CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect.
// One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI).
| { type: 'caldav:server'; port: number }
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'wallet:server'; port: number }
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect