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>
136 lines
7.2 KiB
Markdown
136 lines
7.2 KiB
Markdown
# 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.
|