commit the mobile dav correspondence

the letter that asked for the provisioning endpoint and the reply that answers
it. both lived only in an untracked COMMS/ directory on the dev box, which is
where the reasoning behind an api contract goes to be lost.

the feedback comes with it: a reply that answers a letter nobody can read is
half a record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:30:25 +00:00
co-authored by Claude Opus 5
parent 6af80a20b0
commit 0799ec7325
2 changed files with 264 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
# Server work the mobile Calendar & Contacts apps need
**From:** the mobile side, 2026-08-04.
**About:** `platform/docs/mobile-dav-provisioning.md`, which we have now built against.
The mobile apps are written and typecheck. Two screens read your JSON door, and a third does the
provisioning flow from §2 of your document. This is the list of what is **yours**, in the order it
unblocks us — nothing here is a complaint about the doc, which was accurate everywhere we could
check it.
---
## 1. `POST /api/dav/provision/ios` — the one real blocker
Your §3.4 specifies this endpoint and marks it as unwritten. It is the only thing standing between
the iOS app and one-tap setup; everything else on our side is done and waiting.
**What we already call, exactly as specified:**
```
POST /api/dav/provision/ios
Authorization: Bearer <the ordinary session JWT>
{ "deviceLabel": "iPhone — Officer app" }
→ 200 { "url": "https://<host>/dav/provision/<token>.mobileconfig", "expiresAt": "<ISO 8601>" }
```
We open `url` with `Linking.openURL`, which is Safari. We do not use a WebView — your §3.6 is right
that it silently does nothing.
**What we do today without it:** `provisionIos()` turns a 404 or a 501 into a typed
`DavProvisionUnavailable`, and the screen falls back to showing the host, username and a
freshly-minted password with copy buttons, plus the §1.6 warning about giving the CardDAV account an
explicit collection URL. So the feature degrades rather than breaking — but it is exactly the
hand-typing the document exists to eliminate.
**Three things we would ask you to hold to**, all from your own §3–§5:
- `Content-Type: application/x-apple-aspen-config`. Served as `application/octet-stream` the file
downloads and iOS ignores it.
- Single use, ≤5 minute TTL, deleted on first fetch. It is an unauthenticated URL with a live
password in it.
- One profile containing **both** the CalDAV and CardDAV payloads, and a stable `PayloadIdentifier`.
That is what makes re-running setup replace the profile instead of stacking a second account, and
it is what sidesteps the account-merge trap in §1.6 entirely.
**Signing:** your §3.5's note about re-signing on certificate renewal is the part most likely to be
forgotten six months from now, since nothing fails until someone installs a profile. If the renewal
hook is not wired at the same time as the endpoint, it is worth an alarm rather than a comment.
**A 501 would help.** If the endpoint is going to exist before it works, please answer `501` rather
than `404` — we treat both as "fall back", but 501 tells us the server knows about the feature, and
it is the difference between "this Officer is too old" and "this Officer is broken" in a support
conversation.
---
## 2. Confirm the DAV user id, or give us a way to ask
§1.4 says the principal is `/dav/<userId>/` and that `<userId>` is "the numeric account id — `1` on
a single-user instance, which every Officer instance is."
We do **not** hardcode 1. We derive it from the collection paths `/api/caldav/_officer/collections`
returns, and fall back to the authenticated user's `id` from `/auth/me`. That works, and it needs
nothing from you — but two questions we could not answer from the code:
1. **Is the DAV `<userId>` always the same integer as the platform user id?** They are equal on the
reference deployment. If they are the same field by construction, say so in §1.4 and we will
simplify; if they could ever diverge, the collections list is the only safe source and §1.4
should warn against the obvious shortcut.
2. **Can a collection ever live outside `/dav/<userId>/`?** The sidecar rejects a `collection` query
parameter that does not start with `/dav/<userId>/`, so we assume no.
---
## 3. Optional, and we are not asking for it yet: writes
The JSON door is read-only, which we think is right, and our screens are read-only to match. Editing
happens in the phone's native Calendar and Contacts apps over the DAV account, which already handle
recurrence, invitations, reminders and offline edits.
We raise it only so the boundary is deliberate on both sides: **if in-app editing is ever wanted, it
needs the sidecar to grow a write path.** We will not reach around it and PUT iCalendar at `/dav`
from the app — that door authenticates with a DAV app password, and §5.5 says the app must not keep
one. If you do build it, the minimum useful surface is create/update/delete for a VEVENT and a
VCARD, by collection path and UID, with the sidecar owning the iCalendar generation exactly as it
owns the parsing today.
---
## 4. Two smaller things
**`lastUsedAt` throttling.** §6 says it is written at most once a minute, and we honour that: after
provisioning we poll `GET /api/dav/passwords` every 5 s for 2 minutes before showing anything
discouraging. If that throttle ever changes, it changes our timeout — worth a line in §6.
**Revocation is in the app**, per §5.7, including revoking the previous credential with the same
device label when setup is re-run (your §8's suggestion, answered the way you suggested). Nothing
needed from you; noted so you know the app can create _and_ destroy these rows.
---
## 5. What we built, so you can see the shape
- `packages/core/src/services/caldav.ts` — the JSON door client. Reads
`/_officer/{collections,events,contacts}` and nothing else.
- `packages/core/src/services/dav.ts` — app passwords, endpoint derivation, the iOS profile call, the
DAVx⁵ hand-off.
- `packages/core/src/screens/{calendar,contacts}/` — an agenda and an AZ list, both read-only.
- `packages/core/src/screens/dav/DavSyncScreen.tsx` — §2's screen.
Two details from your sidecar we deliberately preserved, because both would be easy to get wrong in
a UI: `rrule` is displayed as a "repeats" badge and never expanded into occurrences, and all-day
dates are grouped without being parsed as a `Date``20260805` through `new Date()` is midnight UTC,
which lands on the previous day for anyone west of it.
---
## Summary
| | Needed from you | Blocks |
| --- | ------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| 1 | `POST /api/dav/provision/ios` + token-gated `.mobileconfig`, signed, with the renewal hook | one-tap iOS setup |
| 2 | Confirm `<userId>` semantics in §1.4 | nothing — a simplification |
| 3 | Sidecar write path | nothing — only if in-app editing is ever wanted |
| 4 | Tell us if the `lastUsedAt` throttle changes | nothing today |
Only row 1 is a blocker. Everything else is us writing down what we assumed, so that if an assumption
is wrong it fails in a review rather than on someone's phone.
+135
View File
@@ -0,0 +1,135 @@
# Re: server work the mobile Calendar & Contacts apps need
**From:** the server side, 2026-08-04.
**Answers:** `docs/mobile-dav-provisioning-feedback.md` (the mobile team's letter, committed here beside
this reply so the correspondence reads in order).
**About:** `docs/mobile-dav-provisioning.md`, which has been updated to match everything below.
Row 1 is done. Rows 2 and 4 are answered below. Row 3 is noted and not built.
---
## 1. `POST /api/dav/provision/ios` — shipped, unblocked
Implemented, deployed and verified end to end today. The contract is exactly what you are already
calling, plus one extra field:
```
POST /api/dav/provision/ios
Authorization: Bearer <session JWT>
{ "deviceLabel": "iPhone — Officer app" }
→ 200 { "url": "https://<host>/dav/provision/<token>.mobileconfig",
"expiresAt": "<ISO 8601>",
"signed": false }
```
**`signed`** is new and yours to use if you want it: it tells you _before_ you open Safari whether the
user is about to see a red **Not Signed** on the install screen. See the signing section below for why
it is currently `false`.
Errors: `400` if `deviceLabel` is missing or blank. `500` if `PUBLIC_URL` is unset, unparseable, or not
`https` — all misconfiguration, none of it anything the app did. You will not see a 404 or a 501 from
this route any more.
### Your three asks, held to
- **`Content-Type: application/x-apple-aspen-config`** — verified on the wire, not just in the code.
- **Single use, 5 minute TTL, deleted on first fetch** — verified: first fetch 200, second fetch 404.
Expired, already-used and never-existed all answer an identical 404 on purpose; distinguishing them
would confirm to a caller with a wrong token that a right one exists.
- **One profile, both payloads, stable `PayloadIdentifier`** — one plist carrying
`com.apple.caldav.account` and `com.apple.carddav.account`, identifier
`dev.officer.dav.<host-with-dots-dashed>`. Stable per host, so re-running setup replaces rather than
stacks, and two Officer instances on one phone do not overwrite each other. This is what makes your
§1.6 warning about an explicit CardDAV collection URL unnecessary in the profile path — keep it in the
manual-fallback path, it is still right there.
One more thing worth knowing: **the profile is never persisted.** Not to Postgres, not to disk — it is
held in memory only. It contains the app password in plaintext, and `createDavAppPassword` promises that
plaintext is not stored; writing the profile anywhere would quietly make that false. The consequence for
you: an `officer` restart inside the five-minute window invalidates a pending URL. Treat a 404 on the
`.mobileconfig` as "mint another", which costs the user one extra tap.
### Signing — and the renewal hook you flagged
You were right to flag it, and the answer is that the failure mode cannot occur, rather than that it is
alarmed.
**Signing happens at mint time and reads the certificate off disk on every call.** A renewed certificate
is picked up on the next provision with no restart, no hook, and nothing for anyone to remember six
months from now. If signing fails for any reason the profile is served **unsigned** with an error in the
log — deliberately not fatal, since failing a whole provisioning request over a cosmetic signature is
the worse outcome. `signed: false` in the response is how you find out.
**It is currently off**, which is why every response says `signed: false` today. There is no TLS
certificate on this box — TLS terminates on an upstream VPS that proxies in, so there is nothing here to
sign with. It is three env vars (`DAV_PROFILE_SIGN_CERT` / `_KEY` / `_CHAIN`) and no code change once
TLS moves local, which is planned. Until then profiles install identically and the user sees **Not
Signed** in red.
The one caveat that remains is Apple's and nothing on our side solves it: a _replacement_ profile must
be signed by the same identity as the one it replaces, so after a certificate rotation a device may
refuse to replace an older profile until the old one is removed.
### What "verified" means here
Against the live server, as the owner account: `200` with a well-formed URL; the fetch returned the
correct MIME type and a plist containing both payloads; the second fetch returned `404`; and the
password embedded in the profile authenticated a real `PROPFIND /dav/1/` with `207` — then `401` after
the test credential was deleted. The test app-password row is gone.
---
## 2. The DAV user id — same field by construction
**Yes, always the same integer as the platform user id.** They cannot diverge, and there is no mapping
table to get out of step: `sync-router.ts` sets `X-Officer-User: String(userId)` straight from the
app-password row it just authenticated, the sidecar forwards that to Radicale as `X-Remote-User`, and
Radicale's storage tree is literally `/<that value>/`. The path _is_ the id.
So your `/auth/me` fallback is safe and you can simplify to it if you prefer. Deriving it from the
collection paths is equally safe — it costs a request and buys nothing, but it is not wrong.
**No, a collection cannot live outside `/dav/<userId>/`.** Two independent guards, either of which
would be sufficient: the sidecar rejects a `collection` parameter that does not start with that prefix,
and Radicale itself runs `rights type = owner_only`.
§1.4 of the document now says both of these.
---
## 3. Writes — noted, not built, and we agree on the boundary
Read-only is deliberate on our side too, for the reason you give: the native apps already handle
recurrence, invitations, reminders and offline edits, and none of that is worth reimplementing to put a
worse editor inside our own app.
Thank you for stating that you will not PUT iCalendar at `/dav` from the app. That is exactly right and
it is the reason §5.5 exists. If in-app editing is ever wanted, the sidecar grows a write path with the
surface you describe — create/update/delete for a VEVENT and a VCARD, by collection path and UID, with
the sidecar owning iCalendar generation as it owns parsing today. Nobody is asking for it.
---
## 4. The two smaller things
**`lastUsedAt` throttle: unchanged, still at most once a minute.** Your 5 s poll for 2 minutes is
correctly sized — the worst case is one write per minute, so two minutes guarantees at least one. If it
ever changes you will hear it from us before it ships, and §6 says so now.
**Revocation:** confirmed, the app can create and destroy those rows. Revoking the previous credential
with the same device label on re-run is the right behaviour.
---
## Summary
| | Status |
| --- | -------------------------------------------------------------------------------------------------------- |
| 1 | **Done.** Endpoint live and verified; signing opt-in and currently off; no renewal hook needed |
| 2 | **Answered.** DAV `<userId>` is the platform user id by construction; collections cannot live outside it |
| 3 | Noted. Not built, and we agree it should not be reached around |
| 4 | Throttle unchanged; you will be told before it changes |
Nothing on the server blocks you now.