Committed before restarting, deliberately: this changes the registration wire, and the restart that proves it also bounces officer-claude-code, which is what the session doing the work runs on. An uncommitted 25-file protocol change is worse to inherit than one marked unverified. `capabilities: ['music']` → `handles: ['music']` on the registration message, across 20 sidecars, both plugins, the connector, the registry and the protocol type. findSidecarByCapability → findSidecarHandling, waitForCapability → waitForHandler. The name: a sidecar already has `handleCommand`, so the list is literally what it handles. `provider` was rejected — 390 existing uses and it already means websocket door. `serves` was rejected — collides with HTTP serving, which sidecars also do. THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting `handles` reads `undefined` from a sidecar sending `capabilities`, registers it with an empty list, and every sendCommand finds nobody — chat, terminal and music all fail with "No sidecar handling X is connected". So the whole estate has to restart together; there is no rolling upgrade. If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is in-memory and nothing about this touches the database, so a revert is complete. Found a fifth meaning of the word on the way, correctly named and untouched: the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences). That is now five — permissions, the item store, routing keys, Lightning wallet features, and terminfo. tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar.
242 lines
9.9 KiB
TypeScript
242 lines
9.9 KiB
TypeScript
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
|
import { createSidecarConnector } from '../connect';
|
|
import { startRadicale, davPaths } from './radicale';
|
|
import { listCollections, listEvents, listContacts } from './collections';
|
|
import { DATA_PATH } from '../../data-path';
|
|
import { API_URL } from '../../officer-url.mjs';
|
|
|
|
// 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.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** 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));
|
|
}
|
|
|
|
// The JSON door. Same data, no XML: a month view built out of multistatus responses would make the
|
|
// web UI as fragile as the protocol.
|
|
if (url.pathname.startsWith('/_officer/')) {
|
|
// Radicale generates hrefs prefixed with /dav (X-Script-Name), and the UI hands those back
|
|
// verbatim as collection ids — so strip the prefix again on the way in.
|
|
const fetcher = (path: string, init: RequestInit) =>
|
|
fetch(`${radicale.url}${path.replace(/^\/dav/, '')}`, {
|
|
...init,
|
|
headers: {
|
|
...(init.headers as Record<string, string>),
|
|
'X-Remote-User': String(userId),
|
|
// Same prefix the phone-facing door sends, so the collection ids the UI receives here are
|
|
// the same strings a DAV client would see. Without it the UI gets /1/work/ and the phone
|
|
// gets /dav/1/work/ for the same collection, and nothing downstream can match them up.
|
|
'X-Script-Name': DAV_MOUNT,
|
|
},
|
|
});
|
|
|
|
if (url.pathname === '/_officer/collections') {
|
|
return Response.json({ collections: await listCollections(fetcher, String(userId)) });
|
|
}
|
|
|
|
const path = url.searchParams.get('collection');
|
|
// Confine reads to this user's own tree. The header already scopes Radicale, but a path from a
|
|
// query string is attacker-shaped input and should not be handed on unchecked.
|
|
if (!path || !path.startsWith(`/dav/${userId}/`)) {
|
|
return Response.json({ error: 'collection is required' }, { status: 400 });
|
|
}
|
|
|
|
if (url.pathname === '/_officer/events') {
|
|
return Response.json({ events: await listEvents(fetcher, path) });
|
|
}
|
|
if (url.pathname === '/_officer/contacts') {
|
|
return Response.json({ contacts: await listContacts(fetcher, path) });
|
|
}
|
|
}
|
|
|
|
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',
|
|
handles: ['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'));
|