diff --git a/docs/mobile-dav-provisioning.md b/docs/mobile-dav-provisioning.md new file mode 100644 index 00000000..c727d697 --- /dev/null +++ b/docs/mobile-dav-provisioning.md @@ -0,0 +1,557 @@ +# One-tap calendar & contacts setup — handoff for the mobile developers + +**Audience:** whoever builds the Officer iOS and Android apps. +**Goal:** the user opens the Officer app, taps one button, and their phone's native Calendar and +Contacts apps start syncing with their Officer server. No typing a server URL. No typing a password. + +**Status of this document:** the server side described in §1 is built, deployed and verified against a +real iPhone. Everything in §3 (iOS) and §4 (Android) is a specification for work that has **not** been +written yet — the payload keys, intent names and install flows are researched and cited, not +implemented. Where something is unverified I say so explicitly rather than rounding it up to fact. + +--- + +## 0. The honest summary, before you read 4000 words + +There is no "one button" on either platform, if "one button" means silent. Neither iOS nor Android +lets a normal app create a system calendar/contacts account without the user seeing something. + +What is achievable: + +| | Best case | Taps after your button | +| ----------- | -------------------------------------------------------------------- | ----------------------------------------------------------- | +| **iOS** | Serve a signed `.mobileconfig`, open it in Safari | ~5 (Allow → Settings → Install → passcode → Install → Done) | +| **Android** | Fire an explicit Intent into DAVx⁵ with URL + credentials pre-filled | ~3 (Next → pick collections → Create) | + +Both are a large improvement over the status quo, which is the user hand-typing a URL, an email and a +32-character generated password into a settings screen, on a phone keyboard, twice (once for +calendar, once for contacts). That is what we are eliminating. + +Read §5 before you ship either one — the credential handling is the part that can actually hurt. + +--- + +## 1. The server contract (built, live, verified) + +Base URL: the Officer instance's `PUBLIC_URL`. On the reference deployment that is +`https://officer.pastilhas.dev`. **Do not hardcode it** — the app already knows its server; use the +same base it uses for the REST API. + +### 1.1 Two doors, deliberately different + +Officer exposes DAV twice, and confusing them is the fastest way to waste a day: + +| Path | Auth | Who uses it | +| ------------------------ | ---------------------------- | -------------------------------------------------- | +| `/dav/*` | HTTP Basic, on every request | Phones, Thunderbird, any CalDAV/CardDAV client | +| `/api/caldav/_officer/*` | Platform session JWT | Officer's own web UI (returns JSON, not iCalendar) | +| `/api/dav/passwords` | Platform session JWT | Managing the credentials the first row uses | + +`/dav` is mounted **top level**, outside the auth middleware, because a CalDAV client cannot hold a +30-day JWT, cannot refresh one, and cannot answer a passkey challenge. It authenticates with Basic on +literally every request. See `src/servers/api/dav/sync-router.ts` for the implementation and the +reasoning. + +The mobile app itself talks to the **third** row — the JSON door — to mint the credential. It never +speaks DAV. + +### 1.2 The credential model + +- **Username** = the account's email address. (Officer is single-user; there is exactly one.) +- **Password** = a **DAV app password**, not the login password. + +DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is +returned exactly once, at creation, and is not readable again by anything**. There is deliberately no +"show it to me again" endpoint. If it is lost, revoke the row and mint another — that is cheaper than +any design where the secret can be read back. + +This matters for your flow: **you cannot build a "re-send my config" button that reuses an existing +password.** Every provisioning action mints a new one. + +### 1.3 App-password API + +All four are behind the normal session auth — same `Authorization: Bearer ` your app already +sends. Source: `src/servers/api/dav/router.ts`. + +``` +GET /api/dav/passwords + → { passwords: [{ id, label, createdAt, lastUsedAt, revokedAt }, ...] } + +POST /api/dav/passwords body: { label: string } (required, non-empty) + → { entry: {...}, password: "", username: "" } + +POST /api/dav/passwords/:id/revoke → { ok: true } +DELETE /api/dav/passwords/:id → { ok: true } +``` + +**Label it with the device.** `"Pixel 9"`, `"iPhone 15 — Officer app"`. The label is the only thing +that lets the owner revoke the right one later from the web UI +(Settings → Integrations → DAV app passwords). A label of `"mobile"` on four devices is useless. + +### 1.4 URLs the clients need + +``` +Base / discovery root https:///dav/ +Principal https:///dav// +A collection https:///dav/// +``` + +`` is the numeric account id — `1` on a single-user instance, which every Officer instance is. +Collections on the reference deployment are `personal`, `work` (calendars) and `contacts` (address +book), but **these are user-created and you must not hardcode them.** Discover them: + +- **For your own UI** (e.g. showing the user what will be synced): `GET /api/caldav/_officer/collections` + returns `[{ path, displayName, kind: 'calendar' | 'addressbook', color }]`. +- **For the clients**: don't. Give DAVx⁵ and iOS the _base_ URL and let them do RFC 6764 discovery. + That is what it is for. + +### 1.5 Autodiscovery — works, with one caveat + +``` +GET|PROPFIND /.well-known/caldav → 301 → /dav/ +GET|PROPFIND /.well-known/carddav → 301 → /dav/ +OPTIONS /dav/ → DAV: 1, 2, 3, calendar-access, addressbook, extended-mkcol +``` + +Registered with `.all`, not `.get` — RFC 6764 §6 has the client probe the well-known URI with the +method it actually intends to use, and iOS sends `PROPFIND`. (These were GET-only until 2026-08-03 and +answered 404 to every real client while looking perfectly healthy in a browser. Test with the method +the client uses.) + +CORS is deliberately **not** applied to `/dav` or the well-known paths, because Hono's `cors()` +answers every `OPTIONS` itself as a preflight — 204, no `DAV:` header — and never reaches the route +beneath. iOS refuses an account whose server does not advertise `calendar-access`, and reports the +failure as _"Cannot connect using SSL"_, which is a message about TLS for a problem that has nothing to +do with TLS. Don't reintroduce it. + +### 1.6 The iOS account-merge trap (verified the hard way) + +**iOS keys accounts by `server + username`.** If a CalDAV account already exists for +`officer.example.com` / `owner@example.com`, adding a _CardDAV_ account with the same pair is silently +folded into the existing calendar account — no error, no new account, contacts simply never appear. + +Manual workaround is to give the CardDAV account the explicit collection URL +(`https://host/dav/1/contacts/`) so the pair differs. **A configuration profile sidesteps this +entirely** — one profile containing both a CalDAV and a CardDAV payload creates both accounts +correctly. That is a real argument for the profile approach over any "deep link into Settings" idea. + +### 1.7 Debugging + +Every proxied DAV request is logged server-side: + +``` +pm2 logs officer | grep '\[dav\]' +[dav] PROPFIND /dav/1/contacts/ -> 207 (user=1, ua=iOS/18.5 (22F76) accountsd/1.0) +``` + +Failed auth logs a warning (`no such user X` / `bad app password for X`). A request with no +credentials at all is not logged — that is the normal opening move of every client. If you are +debugging a phone that "won't connect", ask for this log first; the `ua` string tells you which +daemon on the phone is (or is not) talking. + +--- + +## 2. What you're building, in both apps + +A screen — call it **Sync calendar & contacts** — that: + +1. Explains in one sentence what will happen. +2. Has one primary button. +3. On tap: mints an app password labelled with the device, hands it to the platform mechanism, and + walks the user through the residual taps with real screenshots. +4. Afterwards, shows sync status and a **Remove / revoke** action. + +Point 4 is not optional. You are creating a long-lived credential on the user's device; the app that +created it is the only reasonable place to destroy it. + +--- + +## 3. iOS — configuration profile + +### 3.1 Mechanism + +Apple's configuration profile format (`.mobileconfig`) is a signed XML plist. Two payload types matter, +and Apple's own documentation confirms **both allow manual (non-MDM) installation on iOS, with no +supervision and no user-approved MDM required**: + +- `com.apple.caldav.account` — https://developer.apple.com/documentation/devicemanagement/caldav +- `com.apple.carddav.account` — https://developer.apple.com/documentation/devicemanagement/carddav + +### 3.2 Exact payload keys + +Verified against Apple's device-management reference. Note `HostName`, not `AccountHostName`; +capital `N` in `HostName`; lowercase `n` in `Username`. + +**CalDAV** (`com.apple.caldav.account`): + +| Key | Type | Required | +| -------------------------- | ------- | ----------------------------------------------------- | +| `CalDAVHostName` | string | **yes** | +| `CalDAVUsername` | string | required for non-interactive install | +| `CalDAVPassword` | string | no ("only use this in encrypted profiles" — see §5.2) | +| `CalDAVPort` | integer | no | +| `CalDAVUseSSL` | boolean | no, defaults `true` | +| `CalDAVPrincipalURL` | string | no — "the base URL to the user's calendar" | +| `CalDAVAccountDescription` | string | no — this is the name the user sees | + +**CardDAV** (`com.apple.carddav.account`): identical shape — `CardDAVHostName` (required), +`CardDAVUsername`, `CardDAVPassword`, `CardDAVPort`, `CardDAVUseSSL`, `CardDAVPrincipalURL`, +`CardDAVAccountDescription`. + +Top level: `PayloadType` must be the literal string `Configuration`; `PayloadVersion` must be integer +`1` (it is the _format_ version, not yours); `PayloadIdentifier` (reverse-DNS — this is what decides +replace-vs-add on reinstall); `PayloadUUID`; `PayloadContent` (array). Each entry in `PayloadContent` +carries its own `PayloadType`/`PayloadVersion`/`PayloadIdentifier`/`PayloadUUID`, and its identifier +must be unique within the profile. + +### 3.3 Template + +```xml + + + + + PayloadType Configuration + PayloadVersion 1 + PayloadIdentifier dev.officer.dav + PayloadUUID + PayloadDisplayName Officer Calendar & Contacts + PayloadDescription Adds your Officer calendars and address book. + PayloadOrganization Officer + PayloadScope User + PayloadRemovalDisallowed + PayloadContent + + + PayloadType com.apple.caldav.account + PayloadVersion 1 + PayloadIdentifier dev.officer.dav.caldav + PayloadUUID + PayloadDisplayNameOfficer Calendar + CalDAVAccountDescriptionOfficer + CalDAVHostName officer.example.com + CalDAVPort 443 + CalDAVUseSSL + CalDAVUsername owner@example.com + CalDAVPassword + CalDAVPrincipalURL/dav/1/ + + + PayloadType com.apple.carddav.account + PayloadVersion 1 + PayloadIdentifier dev.officer.dav.carddav + PayloadUUID + PayloadDisplayNameOfficer Contacts + CardDAVAccountDescriptionOfficer + CardDAVHostName officer.example.com + CardDAVPort 443 + CardDAVUseSSL + CardDAVUsername owner@example.com + CardDAVPassword + CardDAVPrincipalURL/dav/1/ + + + + +``` + +`PayloadUUID`s must be freshly generated per issued profile. `PayloadIdentifier` should stay **stable** +— reinstalling with the same identifier replaces the previous profile rather than stacking a duplicate +account, which is what you want when the user re-runs setup. + +### 3.4 Generate it server-side, not in the app + +The profile embeds a password that only the server can mint. Build it on the server. Suggested new +endpoint — **this does not exist yet; it is yours (or ours) to write**: + +``` +POST /api/dav/provision/ios body: { deviceLabel: string } + behind userMiddleware + 1. mints an app password labelled deviceLabel + 2. renders the plist + 3. signs it + 4. stores it against a single-use, short-lived token + → { url: "https:///dav/provision/.mobileconfig", expiresAt } + +GET /dav/provision/.mobileconfig + no session auth (Safari has none) — the token IS the auth + single use, ≤5 min TTL, deleted on first fetch + Content-Type: application/x-apple-aspen-config +``` + +The `Content-Type` is mandatory — iOS identifies a profile by MIME type, and serving it as +`application/octet-stream` or `text/xml` gets you a downloaded file the OS ignores. + +The single-use short-TTL token is not paranoia: **a profile URL that stays valid is a password that +stays valid**, sitting on a public HTTPS endpoint with no authentication. + +### 3.5 Signing + +Apple's requirement: "place the XML property list in a DER-encoded, CMS Signed Data structure." + +```bash +openssl smime -sign \ + -in profile.mobileconfig \ + -out profile-signed.mobileconfig \ + -signer /etc/letsencrypt/live//cert.pem \ + -inkey /etc/letsencrypt/live//privkey.pem \ + -certfile /etc/letsencrypt/live//chain.pem \ + -outform der -nodetach -md sha256 +``` + +`-outform der` and `-nodetach` are both load-bearing: the default output is S/MIME (base64 + MIME +headers), which iOS will not parse, and without `-nodetach` the file contains a signature with no +embedded profile. `-certfile` matters because iOS ships roots, not intermediates. + +**On using the Let's Encrypt TLS cert to sign:** iOS checks only that the signing certificate chains +to a trusted root and has not expired. It does **not** check EKU/key usage, and the certificate CN does +not have to match the host serving the profile. Multiple practitioner sources report LE-signed profiles +showing as verified; Apple publishes no requirements document for profile-signing certificates at all, +so this is well-corroborated practice rather than documented policy. Two real risks: + +1. **Use an RSA cert (`--key-type rsa`).** There is an unresolved report of ECDSA P-256-signed profiles + showing "Unverified" where a byte-identical RSA one shows verified. Unconfirmed, but the RSA path is + the one that is definitively known to work. +2. **90-day expiry.** There is no timestamping; iOS evaluates the signing cert at install time. You + must re-sign on every certificate renewal, or new installs show "Not Verified". Also: Apple rejects + a _replacement_ profile signed with a different identity, so plan the renewal, don't improvise it. + +Unsigned works too — the install flow is identical, the user just sees a red **Not Signed**. Ship +signed. + +### 3.6 The install flow — and its hard limits + +**Since iOS 12.2 a profile cannot self-install from a web page.** Apple: _"When you download a +configuration profile from a website or an email message in iOS 12.2 or later […] you need to go to +the Settings app to install it."_ There is no way around this without MDM enrollment, which is not +appropriate here. + +**Third-party browsers and in-app web views do not work.** `Linking.openURL()` → Safari. Do **not** use +`WKWebView` or an in-app browser; the profile will download and nothing will happen. + +Sequence to walk the user through, with screenshots in your UI: + +1. Your button → `Linking.openURL(profileUrl)` → Safari opens. +2. Safari alert asking permission to download a configuration profile → **Allow**. + _(Exact button labels are not in any Apple document — Safari's strings are closed. Screenshot from a + real device rather than quoting me.)_ +3. **"Profile Downloaded"** sheet → Close. +4. User opens **Settings**. At the top of the root list: **Profile Downloaded** → tap. +5. **Install** (top right) → device passcode → **Install** → **Install** → **Done**. + +Hard constraints, all Apple-documented: + +- **8-minute window.** "If a profile is not installed within 8 minutes of downloading it, it is + automatically deleted." Your instructions screen must not be a wall of text the user reads slowly. +- **One at a time.** Only one downloaded-but-uninstalled profile can exist; a second download replaces + the first. +- **Lockdown Mode blocks profile installation entirely.** The user must disable it, install, re-enable. +- **Stolen Device Protection (iOS 17.3+)** requires disabling — or being in a familiar location — + before installing a configuration profile. + +Detect success by probing whether the account exists (see §6) rather than by anything Safari returns — +you get no callback. + +Later profiles are viewable and removable at **Settings → General → VPN & Device Management**. + +--- + +## 4. Android — DAVx⁵ intent + +### 4.1 There is no `.mobileconfig` equivalent, and there structurally cannot be + +AOSP ships **no system DAV client at all** — only the Calendar and Contacts _providers_ plus the +sync-adapter framework. There is no built-in account type for a configuration payload to target. + +You also cannot create a DAVx⁵ account programmatically. `AccountManager.addAccountExplicitly` requires +a signature match with the authenticator that owns the account type, enforced server-side in +`AccountManagerService`. You can create _your own_ account type; you can never inject someone else's. + +`Settings.ACTION_ADD_ACCOUNT` only opens a filtered picker — no URL, no credentials. + +Android Enterprise managed configuration would work, but: DAVx⁵'s free/OSE build has **zero** +managed-config support (no `app_restrictions.xml`, no `RestrictionsManager` anywhere in the tree), the +managed build is a separate paid app (`com.davdroid.managed`, licence-gated, ~€2/user/month with a +5-user minimum), and Google's Android Management API explicitly prohibits _"solutions developed and +used exclusively for first party in-house applications"_ — which is precisely this. Not a path. + +**So: DAVx⁵'s documented intent API is the answer.** It is genuinely good. + +### 4.2 The intent + +Documented at https://manual.davx5.com/integration.html. Identifiers below were confirmed against +`bitfireAT/davx5-ose@main` source, not just the manual. + +- **applicationId:** `at.bitfire.davdroid` (same for F-Droid/OSE and Play builds). The Kotlin namespace + is `com.davx5.ose` — that is **not** a package name, don't use it. +- **Activity:** `at.bitfire.davdroid.ui.setup.LoginActivity`, `exported="true"`. +- **Extras** (all optional Strings): `"url"`, `"username"`, `"password"`. +- **URI schemes** on its VIEW/BROWSABLE filter: `caldav`, `caldavs`, `carddav`, `carddavs`, `davx5`. + `caldavs`/`carddavs`/`davx5` are rewritten to `https`; `caldav`/`carddav` to `http`. +- **Account types:** `bitfire.at.davdroid` and `at.bitfire.davdroid.address_book`. + +**Gotcha the official manual gets wrong.** `StandardLoginTypesProvider.intentToInitialLoginType()` +decides whether to skip the login-type chooser by inspecting **`intent.data` only** — it never reads the +`url` extra. An extras-only intent (exactly the manual's own snippet) logs _"Did not understand login +intent"_ and shows the type-picker first. **Set both `data` and the extras.** Extras win over any +userinfo in the URI. + +Also: the manual documents a `davPath` extra. **No such constant exists in current `main`.** Ignore it. + +```kotlin +Intent().apply { + setClassName("at.bitfire.davdroid", "at.bitfire.davdroid.ui.setup.LoginActivity") + data = Uri.parse("https://officer.example.com/") // drives skipLoginTypePage + putExtra("username", email) + putExtra("password", appPassword) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +} +``` + +The bare root URL is enough — our `.well-known` redirects handle discovery, and DAVx⁵'s own Radicale +documentation recommends `/` as the base URL for Radicale v3, which is what Officer runs. + +### 4.3 React Native specifics + +`Linking.sendIntent(action, extras)` **cannot set a component/class name**, and +`react-native-send-intent`'s `openApp()` uses `getLaunchIntentForPackage`, which lands on +`AccountsActivity`, not `LoginActivity`. So: + +- **Zero native code:** `Linking.openURL('davx5://user@officer.example.com/')` — matches the implicit + filter. Requires a `` entry for `canOpenURL` on Android 11+: + ```xml + + + + + + + ``` + **But the password ends up in a URI**, which is worse for logging and clipboard exposure. +- **Recommended: a ~20-line native module** that builds the explicit intent above, so the password + travels as an Intent extra. Do this one. + +### 4.4 What the user still does + +`LoginActivity` always renders its Compose UI. Best case the user taps through: login detail (pre-filled) +→ Next → resource detection → account name → **Create Account**. ~3 taps. + +There is **no `setResult`** — the manual says not to rely on the activity result, and that is accurate. +Detect completion with an `OnAccountsUpdateListener` on account type `bitfire.at.davdroid`, or by +polling `AccountManager.getAccountsByType()` on resume. + +### 4.5 DAVx⁵ not installed + +Check with `PackageManager` / `canOpenURL`. If absent, send the user to Play +(`market://details?id=at.bitfire.davdroid`) or F-Droid, and tell them which one they're getting — the +Play build is paid (~€6 one-off), the F-Droid build is free and identical. **Say this in the UI.** +Handing someone a paywall they didn't expect is a bad first experience of a self-hosted product. + +### 4.6 Alternative: build sync into the Officer app itself + +Viable, and worth costing before committing to the DAVx⁵ dependency. You would implement an +`AbstractAccountAuthenticator` + `AbstractThreadedSyncAdapter` under your own account type, writing +into `CalendarContract` / `ContactsContract` with `caller_is_syncadapter=true`. Libraries DAVx⁵ itself +uses: `com.github.bitfireAT:dav4jvm` (the standalone Kotlin WebDAV/CalDAV protocol layer — the piece +worth reusing), `org.mnode.ical4j:ical4j`, `com.googlecode.ez-vcard:ez-vcard`. Copy their dependency +pins: ical4j pulls Apache Commons versions that need a newer Java than a typical `minSdk`, so they +force `commons-codec` 1.17.1 and `commons-lang3` 3.15.0 as `strictly`. + +**None of this is expressible from JavaScript.** Manifest components and `res/xml` cannot be registered +at runtime, and `onPerformSync` runs with no JS bridge attached. It is an all-native subproject. +Permissions: `READ/WRITE_CALENDAR` and `READ/WRITE_CONTACTS` are dangerous/runtime with **no +sync-adapter exemption**; `READ/WRITE_SYNC_SETTINGS` are normal. `AUTHENTICATE_ACCOUNTS`, +`MANAGE_ACCOUNTS` and `USE_CREDENTIALS` are marked _removed_ in AOSP and are no-ops since API 23 — +several Android training docs still show them; those docs are stale. + +Recommendation: ship the DAVx⁵ intent first. It is days, not months. Revisit only if the dependency +becomes a real problem. + +--- + +## 5. Security requirements — read this one + +### 5.1 Non-negotiable + +1. **Mint a fresh app password per provisioning action.** Never reuse, never cache. The plaintext is + unrecoverable by design; there is nothing to reuse anyway. +2. **Label with the device name.** Revocation is only usable if the labels are distinguishable. +3. **HTTPS only, always.** `CalDAVUseSSL`/`CardDAVUseSSL` true, `https://` scheme in the Android URI. +4. **The iOS profile URL must be single-use and short-lived** (≤5 minutes, deleted on first fetch). It + is an unauthenticated public URL containing a live password. +5. **Do not persist the plaintext password in the app.** Hand it to the platform mechanism and drop it. + Do not put it in `AsyncStorage`, do not log it, do not include it in crash reports. +6. **Do not put it in a URI if you can avoid it** (the Android native-module argument in §4.3). +7. **Provide revocation in the app.** `GET /api/dav/passwords` to list, `POST /:id/revoke` to kill. + +### 5.2 A tradeoff to state out loud + +Apple documents `CalDAVPassword`/`CardDAVPassword` as _"only use this in encrypted profiles"_. We are +embedding a password in a **signed but unencrypted** profile served over HTTPS. That is a deliberate +deviation from the documented path, mitigated by TLS, the single-use token and the ≤5-minute TTL — +and by the fact that the credential is scoped to `/dav` and grants nothing but calendar and contacts. + +Profile encryption requires knowing the target device's certificate in advance, which we don't have +outside MDM. If this tradeoff is unacceptable to whoever reviews it, the fallback is to omit the +password key entirely — iOS then prompts for it at install time, and the user pastes it. That is one +extra step and still far better than typing the URL. + +--- + +## 6. Verifying it actually worked + +You get no success callback on either platform. Options, best first: + +1. **Server-side.** After provisioning, poll for `lastUsedAt` becoming non-null on the app-password + entry via `GET /api/dav/passwords`. Unambiguous: a device authenticated with it. This is the + cleanest signal and works identically on both platforms. Verified: `verifyDavAppPassword` writes + `lastUsedAt` on every successful auth, throttled to at most once a minute (a syncing phone hits it + constantly). So allow up to ~60s before treating a null as failure. +2. **Android:** `AccountManager.getAccountsByType("bitfire.at.davdroid")`. +3. **iOS:** query `EKEventStore` / `CNContactStore` for a source matching the account description. + Requires calendar/contacts permission, which you may not want to ask for. Weakest option. +4. **Manual, for support:** `pm2 logs officer | grep '\[dav\]'` on the server — the `ua` string names + the exact daemon that connected. + +--- + +## 7. Suggested order of work + +1. Server: `POST /api/dav/provision/ios` + the token-gated `.mobileconfig` endpoint (§3.4), signing + wired into the certbot renewal hook (§3.5). This is the only new backend work either platform needs. +2. Android: the native intent module + the setup screen. Smallest, ships first, and it validates the + whole shape of the feature. +3. iOS: the setup screen with real device screenshots for every step in §3.6. +4. Both: sync status + revoke. + +## 8. Open questions for whoever picks this up + +- Should the setup screen let the user choose _which_ collections sync, or sync everything? DAVx⁵ asks + anyway; iOS does not. Asking twice on Android is worse than not asking at all. +- What happens on re-provisioning? A stable iOS `PayloadIdentifier` replaces the profile cleanly, but + the old app password stays live unless you revoke it. Suggest: revoke the previous + same-labelled password when minting a new one, and say so in the UI. +- Multi-device: nothing prevents it, and the labels make it manageable. Worth a list view in the app + showing every device with a live DAV password. + +## 9. Sources + +Apple: [CalDAV payload](https://developer.apple.com/documentation/devicemanagement/caldav) · +[CardDAV payload](https://developer.apple.com/documentation/devicemanagement/carddav) · +[Install a configuration profile (HT209435 / 102400)](https://support.apple.com/en-us/102400) · +[Lockdown Mode](https://support.apple.com/en-us/105120) · +[Device management profiles](https://support.apple.com/guide/deployment/depc0aadd3fe/web) + +DAVx⁵: [integration manual](https://manual.davx5.com/integration.html) · +[source](https://github.com/bitfireAT/davx5-ose) + +Android: [`setApplicationRestrictions`](https://developer.android.com/reference/android/app/admin/DevicePolicyManager) · +[AMI permissible usage](https://developers.google.com/android/management/permissible-usage) + +Officer: `src/servers/api/dav/sync-router.ts` (the DAV door), `src/servers/api/dav/router.ts` +(app passwords), `src/servers/hono.ts` (CORS gating + well-known), `docs/nextcloud-replacement.md`. + +--- + +_Written 2026-08-04. §1 is verified against a running deployment and a real iPhone. §3 and §4 are +researched specifications, not shipped code — verify against a device before trusting any tap count._