From 0799ec732591fbc0623f011dcadda252cc67eaf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 14:30:25 +0000 Subject: [PATCH] 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 --- docs/mobile-dav-provisioning-feedback.md | 129 ++++++++++++++++++++++ docs/mobile-dav-provisioning-reply.md | 135 +++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 docs/mobile-dav-provisioning-feedback.md create mode 100644 docs/mobile-dav-provisioning-reply.md diff --git a/docs/mobile-dav-provisioning-feedback.md b/docs/mobile-dav-provisioning-feedback.md new file mode 100644 index 00000000..3d45c204 --- /dev/null +++ b/docs/mobile-dav-provisioning-feedback.md @@ -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 + { "deviceLabel": "iPhone — Officer app" } + +→ 200 { "url": "https:///dav/provision/.mobileconfig", "expiresAt": "" } +``` + +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//` and that `` 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 `` 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//`?** The sidecar rejects a `collection` query + parameter that does not start with `/dav//`, 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 A–Z 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 `` 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. diff --git a/docs/mobile-dav-provisioning-reply.md b/docs/mobile-dav-provisioning-reply.md new file mode 100644 index 00000000..dd13b38c --- /dev/null +++ b/docs/mobile-dav-provisioning-reply.md @@ -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 + { "deviceLabel": "iPhone — Officer app" } + +→ 200 { "url": "https:///dav/provision/.mobileconfig", + "expiresAt": "", + "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.`. 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 `//`. 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//`.** 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 `` 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.