Files
platform/docs/mobile-dav-provisioning-reply.md
pastilhasandClaude Opus 5 0799ec7325 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>
2026-08-04 14:30:25 +00:00

7.2 KiB

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.