Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and VAULT_STORE_KEY are no longer written by the setup script. data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/, capabilities/ and dockers/ as fixed names under it. The direction used to run the other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in app-store/paths.ts — which meant three environment variables that had to agree with each other and with the tree on disk. Eight files re-read process.env.DATA_PATH independently, each with its own `?? cwd()/data` fallback. They import the one value now, which is what made removing it safe: otherwise each would have derived its own and drifted. Three things this turned up. The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a comment asserting "__dirname is the repo root — this file sits beside ecosystem.config.cjs", which stopped being true when these files moved into ecosystem-files/. It walks up to the platform's package.json now, which holds wherever the file lives. That was a live bug before this change and a load-bearing one after it, since cwd now decides where the install is. assertInstallLayout joins the other two boot assertions. A wrong cwd does not error — it computes a plausible root somewhere else and writes managed homes and agent runs into it, so the install looks empty and the data looks lost with nothing naming the cause. It throws before serve(), first of the three, because a wrong answer there makes the other two check the wrong files. getOwnerHomeDir captures homedir() once at module load rather than per call. Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME when set rather than reading passwd, and user-instance.ts assigns process.env.HOME on its way to spawning an agent. A lazy read would have returned the owner's home on the first call and a member's afterwards. data-path.ts imports only node builtins, so it is evaluated before any of that runs. JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script does not boot — jwt.ts throws at module load without one. That is the agreed sequencing: they move to the SQLite store (docs/secret-store.md), and writing them here meanwhile would create a second origin for a secret the store then has to be reconciled with. Said plainly in .env.example and in lib/env.sh rather than left to be discovered. Not typechecked: node_modules is empty here and installs are frozen. Every edited file parses under `bun build --no-bundle`; the profile loads and pins the right cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx is not the pinned resolution and reformatted unrelated unions and line wraps in six files, so those were reverted and the edits re-applied by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
243 lines
10 KiB
TypeScript
243 lines
10 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';
|
|
|
|
// 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'}`;
|
|
|
|
/** 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',
|
|
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'));
|