CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
16 KiB
Markdown
247 lines
16 KiB
Markdown
# 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)
|
||
│ └── <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** — 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.
|