Files
platform/docs/nextcloud-replacement.md
T
pastilhasandClaude Opus 5 ccd104a28b 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>
2026-08-04 03:16:17 +00:00

16 KiB
Raw Blame History

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/caldav301 to the DAV root
  • GET /.well-known/carddav301 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.