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:
@@ -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 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 `<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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user