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
+244
View File
@@ -0,0 +1,244 @@
# Replacing NextCloud
Written 2026-08-04. Decision document for the three things NextCloud is actually used for here:
**calendar, contacts, and file sync.** Everything else NextCloud ships is out of scope and stays out.
`nextcloud:33` is currently running as a container on this machine (`172.20.0.24:80`, behind NPM). The
goal is to retire it, not to sit beside it.
---
## The rule this document is written against
The owner's ask: _"as long as the experience for the user, be it on the platform web UI or in the
phone, be as easy and transparent as NextCloud provides."_
That is the acceptance test, and it has a specific consequence that drives almost every decision
below: **the phone is the hard part, and the phone does not speak Officer.** DAVx5, iOS Calendar and
Thunderbird speak CalDAV/CardDAV over HTTPS with HTTP Basic auth and `.well-known` autodiscovery.
They cannot log into Officer, cannot hold a 30-day JWT, and cannot be persuaded to. Any design that
does not produce a plain, standards-compliant DAV endpoint fails the acceptance test no matter how
good the web UI is.
---
## Decision 1 — do not implement CalDAV/CardDAV
**NextCloud does not implement CalDAV either.** It vendors `sabre/dav` and writes storage backends
and a UI on top. "Reimplementing NextCloud" honestly described is "adopting a DAV library and owning
the storage and the UI" — which is exactly the sidecar shape this platform already has eight
instances of.
The formats are the easy half and are genuinely a weekend:
| Thing | Difficulty | Why |
| ------------------------------- | ---------- | -------------------------------------------------------------- |
| Parse/emit iCalendar (RFC 5545) | Easy | Mature libraries; the grammar is small |
| Parse/emit vCard (RFC 6350) | Easy | Same |
| `PROPFIND` / `REPORT` semantics | Hard | Depth handling, property discovery, partial responses |
| `RRULE` expansion | Hard | Leap years, DST, `BYSETPOS`, `EXDATE`, infinite series |
| `VTIMEZONE` | Hard | Clients ship conflicting tz databases |
| `sync-collection` REPORT | Hard | Sync tokens, tombstones, truncation |
| ctag / etag / `If-Match` | Hard | Get it wrong and clients silently duplicate or drop events |
| Per-client quirks | Hardest | iOS, DAVx5 and Thunderbird each want slightly different things |
The failure mode of getting these wrong is not a crash. It is a phone that **silently stops syncing**,
or worse, silently duplicates every event. You find out weeks later, from your calendar being wrong.
That is a bad thing to own for a feature whose entire value is that you can trust it.
**Decision: run a proven DAV server as a sidecar, supervised and proxied, exactly like `officer-slskd`
and `officer-invoiceshelf` already are. Own the storage location, the auth, and the UI. Do not own
the protocol.**
### Which server
| Option | Language | Verdict |
| ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Radicale** | Python | **Chosen.** Small, file-backed, no database, 15+ years old, the standard recommendation for exactly this job. Critically it has an auth mode that trusts a header (`http_x_remote_user`), which is precisely how every other sidecar here is designed to be fronted. |
| Baïkal | PHP | `sabre/dav` behind a web UI. Means adding PHP-FPM to the machine for one service. Heavier for no benefit — we are not using its UI. |
| Xandikos | Python | Git-backed, elegant, but a smaller install base and less battle-tested against iOS specifically. Reconsider only if Radicale disappoints. |
| Write our own | — | Rejected above. |
Radicale is a good citizen of this architecture _because_ of the header auth: it lets the sidecar be
the only thing that decides who the user is, which is the same contract `X-Officer-User` already
encodes everywhere else.
---
## Decision 2 — two front doors, not one
This is the part that is easy to get wrong, so it is stated explicitly.
```
phone / Thunderbird Officer web UI
(DAVx5, iOS, macOS) (React panel apps)
│ │
│ CalDAV over HTTPS │ JSON over HTTPS
│ HTTP Basic (app password) │ platform JWT
▼ ▼
┌───────────────────────────┐ ┌──────────────────────────┐
│ officer: /dav/* │ │ officer: /api/caldav/* │
│ mounted TOP-LEVEL, │ │ protectedRouter, │
│ Basic-auth gate, │ │ createSidecarProxy │
│ forwards verbatim │ │ (X-Officer-User) │
└───────────┬───────────────┘ └────────────┬─────────────┘
│ │
└──────────────► officer-caldav ◄───────────┘
(Bun sidecar)
supervises + fronts Radicale
DATA_PATH/dav/collections/…
```
**Why not one door.** The browser should not speak DAV. `PROPFIND` and multistatus XML in React is a
lot of machinery to render a month view, and it would make the web UI as fragile as the protocol. The
sidecar already has the collections on local disk; it can serve the UI a small JSON API over the same
data and keep DAV as the machine-facing interface. This is the same split `officer-email` uses — the
sidecar owns the store and serves both a sync path and a UI path.
**Why the DAV door is mounted top-level.** There is precedent in this codebase and it is exactly
analogous: `/api/vault` is mounted outside `protectedRouter` because "the Bitwarden client carries its
own bearer token, not a platform session JWT, so userMiddleware would 401 it" (`hono.ts:80-84`). DAV
clients are the same situation with a different credential type.
**Why `createSidecarProxy` cannot carry the DAV door.** Three concrete blockers, all in
`src/servers/sidecar/create-proxy.ts`:
1. It forwards only `content-type`, `range` and `if-none-match` (line 88). DAV needs `Depth`,
`If-Match`, `Destination`, `Overwrite`, `Lock-Token` and `Prefer` — dropping `Depth` alone breaks
`PROPFIND` completely.
2. It sets `X-Officer-User` from `ctx.get('user')` (line 94), which requires `userMiddleware`, which
requires a JWT.
3. `router.all()` needs to route `PROPFIND`, `REPORT`, `MKCOL`, `MKCALENDAR`, `COPY`, `MOVE`, `LOCK`
and `UNLOCK`. **Verified 2026-08-04: Hono's `all()` matches non-standard verbs**, and `.on([...])`
works too if an explicit list is ever wanted. This was the riskiest assumption in the design, so it
was tested against a throwaway Bun server before anything was built on it.
The proxy factory's own comment says it "must never grow per-app logic". Respect that: the DAV door is
its own small file, not a branch inside the shared factory.
---
## Decision 3 — app passwords, because phones cannot do JWT
A new credential type is unavoidable. NextCloud solved this the same way and for the same reason.
- New table `dav_app_passwords`: `id`, `user_id`, `label`, `password_hash`, `last_used_at`,
`created_at`, `revoked_at`.
- Generated server-side, high entropy, **shown exactly once**, stored only as a hash.
- Scoped to DAV. It is not a general Officer credential and must never be accepted by
`userMiddleware`.
- Revocable individually from the UI, with `last_used_at` shown so a stale device is visible.
Rationale for not reusing the account password: it ends up typed into a phone, stored in that phone's
account manager in recoverable form, and synced to whatever backs that phone up. One password per
device, revocable per device, is the whole point.
The single-user invariant holds — every app password belongs to the owner. `user_id` is there for
referential integrity, not multi-tenancy.
---
## Decision 4 — autodiscovery is not optional
This is most of the "just works" the acceptance test is about. Typing a bare domain and having the
phone find the calendars is the difference between transparent and fiddly.
- `GET /.well-known/caldav``301` to the DAV root
- `GET /.well-known/carddav``301` to the DAV root
- Both mounted at the very top of the app, above every auth gate — clients probe them unauthenticated
first.
- The DAV root must answer `PROPFIND` with `current-user-principal` so the client can walk to the
principal, then to the home sets, then to the collections. Radicale does this correctly; the
platform's job is only to not break it in transit.
If `.well-known` is missing, iOS in particular degrades to demanding a full path, which is exactly the
kind of thing that makes a self-hosted setup feel worse than the commercial one.
---
## Decision 5 — storage layout
```
DATA_PATH/dav/
├── collections/ # Radicale's collection root (its own on-disk format — do not hand-edit)
│ └── <user>/
│ ├── <calendar-uuid>/
│ └── <addressbook-uuid>/
└── radicale.conf # generated by the sidecar at boot, never hand-written
```
Under `DATA_PATH`, so it is inside the existing backup story rather than beside it. The sidecar owns
this directory entirely; the platform never opens it, the same way it never opens the email sidecar's
SQLite stores.
---
## What is deliberately NOT being built
Naming these now so they do not creep in later:
- **iTIP/iMIP scheduling** — sending invitations and processing RSVPs by email. Genuinely complex, and
a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only
on a concrete need.
- **Sharing, ACLs, federation** — single-user is a hard invariant of this platform. There is nobody
to share with.
- **Reimplementing RRULE on the server.** The sidecar stores what the client sends. Expansion happens
where it is displayed, using a library.
- **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the
compatibility layer.
- **Migrating data automatically.** Export from the running NextCloud as `.ics`/`.vcf` and import
once. A one-time manual step is cheaper and safer than a migration tool used exactly once.
---
## File sync — the harder half
Separated because the answer is genuinely different, and because conflating them is how this becomes
a six-month project.
**Sync is not a mount.** WebDAV gives a remote filesystem: nothing works offline, everything is slow
on mobile data, and a dropped connection is a failed save. What NextCloud's desktop client actually
provides is a local-first replica with change detection, conflict handling and background transfer.
That is the thing being asked for, and it is a hard, well-studied problem.
**Recommendation: Syncthing, run as `officer-syncthing`, not a reimplementation.**
| Option | Verdict |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Syncthing** | Mature, open source, peer-to-peer, has real Android and desktop clients, handles conflicts explicitly, no server-side account model to build. Officer's job becomes: supervise it, own its config, expose a UI, and point the file browser at the synced folder. |
| WebDAV from the sidecar | Solves "see my files remotely", does not solve "have my files offline". Complementary at best, not a substitute. Cheap to add later since a DAV server is already in the architecture. |
| Reimplement NextCloud's sync protocol | Chunked upload, ETag bookkeeping, a local state database, conflict resolution, and a client on three platforms. This is the single largest thing on the whole list. Not defensible when Syncthing exists. |
**Known gap, stated honestly:** Syncthing's iOS story is weak — iOS background execution rules make a
true always-on sync client hard, which is precisely why NextCloud's own iOS app is a manual-ish
experience too. If iOS file sync is a hard requirement, the realistic answer is a WebDAV mount for
browsing plus explicit upload from the mobile app, and that is a decision for the owner rather than
something to be assumed.
**Officer's deliverable for file sync** is therefore: run and supervise Syncthing, own its
configuration, replicate its UI (folders, devices, status, conflicts) as a panel app, and surface the
synced tree in the existing file browser. Not a new sync engine.
---
## Build order
Sequenced so that each step is independently useful and the riskiest assumption is tested first.
1. **`officer-caldav` sidecar** — supervise Radicale on a loopback port, generate its config,
register with the platform. _Proves the sidecar shape._
2. **`/dav/*` door + app passwords + `.well-known`** — the phone path.
**_This is the step that must be proven with a real phone._** Everything downstream is comfort;
this is the acceptance test.
3. **JSON read API on the sidecar** — list calendars/address books, list events in a range, list
contacts. Enough for a read-only UI.
4. **Calendar + contacts panel apps** — month/week/agenda, contact list and detail. Upstream parity
first.
5. **Write path from the UI** — create/edit/delete, going through the sidecar so DAV clients see the
same data.
6. **File sync** — separate effort, starting from the Syncthing decision above.
Steps 12 are the whole of the "transparent on the phone" requirement. If the night runs out, it
should run out after 2, not before.
+10
View File
@@ -92,6 +92,16 @@ module.exports = {
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
watch: false,
},
// Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the
// collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic
// against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's;
// the platform authenticates and forwards. See docs/nextcloud-replacement.md.
{
name: 'officer-caldav',
script: 'bun',
args: 'run src/servers/sidecar/caldav/index.ts',
watch: false,
},
// The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from
// /photos/settings and stored encrypted in `photos_config` — read here, never from the environment,
// because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too.
+8
View File
@@ -163,6 +163,14 @@ export {
recordPhotosProbe,
} from './queries/photos';
export type { PhotosAccount, PhotosCredentials } from './queries/photos';
export {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
verifyDavAppPassword,
} from './queries/dav';
export type { DavAppPassword, DavAppPasswordView } from './queries/dav';
export {
getServiceConnection,
getServiceCredentials,
+113
View File
@@ -0,0 +1,113 @@
import { and, desc, eq, isNull } from 'drizzle-orm';
import argon2 from 'argon2';
import { randomBytes } from 'node:crypto';
import { db } from '../db';
import { davAppPasswords } from '../schema/dav';
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
/** What the UI is allowed to see: everything except the hash. */
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
const view = (row: DavAppPassword): DavAppPasswordView => {
const { passwordHash: _hash, ...rest } = row;
return rest;
};
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
// into a phone by hand. Grouped into blocks of four for the same reason.
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
function generateSecret(): string {
const bytes = randomBytes(20);
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
// still fits in five readable blocks.
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
}
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
const rows = await db
.select()
.from(davAppPasswords)
.where(eq(davAppPasswords.userId, userId))
.orderBy(desc(davAppPasswords.createdAt));
return rows.map(view);
}
/**
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
* value is the only chance the owner gets to see it.
*/
export async function createDavAppPassword(
userId: number,
label: string,
): Promise<{ entry: DavAppPasswordView; password: string }> {
const password = generateSecret();
const [row] = await db
.insert(davAppPasswords)
.values({
userId,
label,
passwordHash: await argon2.hash(password),
hint: password.slice(0, 8),
})
.returning();
if (!row) throw new Error('failed to create dav app password');
return { entry: view(row), password };
}
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
const [row] = await db
.update(davAppPasswords)
.set({ revokedAt: new Date() })
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
.returning();
return !!row;
}
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
const [row] = await db
.delete(davAppPasswords)
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
.returning();
return !!row;
}
/**
* Verify a Basic-auth credential, returning the owning user id.
*
* Every live credential is tried, because the username a DAV client sends is the account email, not a
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
* UI should encourage deleting devices that are gone rather than accumulating them.
*
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
* the column exists to answer "is this device still around", not to be an access log.
*/
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
const rows = await db
.select()
.from(davAppPasswords)
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
for (const row of rows) {
let ok = false;
try {
ok = await argon2.verify(row.passwordHash, password);
} catch {
ok = false; // a corrupt hash must not take the whole login path down
}
if (!ok) continue;
const now = Date.now();
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
await db
.update(davAppPasswords)
.set({ lastUsedAt: new Date(now) })
.where(eq(davAppPasswords.id, row.id));
}
return row.userId;
}
return null;
}
@@ -0,0 +1,46 @@
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
//
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
// credential a phone could carry is the account password itself — which then lives in that phone's
// account manager in recoverable form, and in whatever backs the phone up.
//
// So: one password per device, revocable per device, hashed with argon2 like the account password and
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
//
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
// this table — an app password is not a session, and a device that syncs a calendar has no business
// reaching the wallet or the vault.
//
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
// noticeable at all.
export const davAppPasswords = pgTable(
'dav_app_passwords',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
label: text('label').notNull(),
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
passwordHash: text('password_hash').notNull(),
/**
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
* authenticate with, and it is the only way for the owner to match a row in this list against the
* password saved on a device they are looking at.
*/
hint: text('hint').notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
// being free and it being a sequential scan on every PROPFIND.
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
);
@@ -1,6 +1,7 @@
export * from './auth';
export * from './chat-events';
export * from './dashboards';
export * from './dav';
export * from './email';
export * from './headscale';
export * from './invoiceshelf';
+8
View File
@@ -212,6 +212,14 @@ const server = serve({
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
'/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'),
'/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
// and the phone gets an HTML page where it expected a redirect.
'/.well-known/caldav': honoServer.fetch,
'/.well-known/carddav': honoServer.fetch,
'/dav': honoServer.fetch,
'/dav/*': honoServer.fetch,
'/': officerWeb,
'/*': officerWeb,
'/api': honoServer.fetch,
+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