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

245 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.