# 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 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.
