# 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. `user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true: since 2026-08-07 `calendar` is a **grantable** capability, so an app password can belong to a member and the column decides whose collection tree Radicale serves. It is load-bearing. --- ## 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) │ └── / │ ├── / │ └── / └── 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** — still out of scope, but the reason weakened on 2026-08-07. Members can now hold `calendar`, so there is somebody to share with; what is missing is any notion of one account granting another access to its own collection. Revisit on a concrete need. - **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 1–2 are the whole of the "transparent on the phone" requirement. If the night runs out, it should run out after 2, not before.