CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
33 KiB
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 is built, deployed and verified — §1 against a real iPhone, and §3.4's provisioning endpoint end to end on 2026-08-04. The client work in §3 (iOS) and §4 (Android) is still a specification: 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 — the signed-in account's own, not a constant. (This said
"Officer is single-user; there is exactly one" until 2026-08-07.
calendaris now a grantable capability, so a member can hold their own app passwords and their own collections.) - 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 <jwt> 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: "<plaintext, once>", username: "<owner email>" }
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://<host>/dav/
Principal https://<host>/dav/<userId>/
A collection https://<host>/dav/<userId>/<collection>/
<userId> is the platform user id, by construction — the same integer /auth/me returns. They
cannot diverge: sync-router.ts sets X-Officer-User: String(userId) straight from the app-password
row, the sidecar forwards it to Radicale as X-Remote-User, and Radicale's storage tree is literally
/<that value>/. There is no mapping table to get out of step.
Do not hardcode 1. This passage used to say that on a single-user instance — "which every Officer
instance is" — the value is always 1. That stopped being true on 2026-08-07: members can hold the
calendar capability, and a member's id is not 1. Derive it from /auth/me or from the collection
paths; both work, and both stay correct when the caller is not the owner.
A collection cannot live outside /dav/<userId>/. Two independent guards: the sidecar rejects any
collection outside that prefix, and Radicale runs rights type = owner_only.
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/collectionsreturns[{ 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:
- Explains in one sentence what will happen.
- Has one primary button.
- 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.
- 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/caldavcom.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 version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key> <string>Configuration</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadIdentifier</key> <string>dev.officer.dav</string>
<key>PayloadUUID</key> <string><!-- fresh UUID per issued profile --></string>
<key>PayloadDisplayName</key> <string>Officer Calendar & Contacts</string>
<key>PayloadDescription</key> <string>Adds your Officer calendars and address book.</string>
<key>PayloadOrganization</key> <string>Officer</string>
<key>PayloadScope</key> <string>User</string>
<key>PayloadRemovalDisallowed</key> <false/>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key> <string>com.apple.caldav.account</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadIdentifier</key> <string>dev.officer.dav.caldav</string>
<key>PayloadUUID</key> <string><!-- fresh UUID --></string>
<key>PayloadDisplayName</key><string>Officer Calendar</string>
<key>CalDAVAccountDescription</key><string>Officer</string>
<key>CalDAVHostName</key> <string>officer.example.com</string>
<key>CalDAVPort</key> <integer>443</integer>
<key>CalDAVUseSSL</key> <true/>
<key>CalDAVUsername</key> <string>owner@example.com</string>
<key>CalDAVPassword</key> <string><!-- minted app password --></string>
<key>CalDAVPrincipalURL</key><string>/dav/1/</string>
</dict>
<dict>
<key>PayloadType</key> <string>com.apple.carddav.account</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadIdentifier</key> <string>dev.officer.dav.carddav</string>
<key>PayloadUUID</key> <string><!-- fresh UUID --></string>
<key>PayloadDisplayName</key><string>Officer Contacts</string>
<key>CardDAVAccountDescription</key><string>Officer</string>
<key>CardDAVHostName</key> <string>officer.example.com</string>
<key>CardDAVPort</key> <integer>443</integer>
<key>CardDAVUseSSL</key> <true/>
<key>CardDAVUsername</key> <string>owner@example.com</string>
<key>CardDAVPassword</key> <string><!-- minted app password --></string>
<key>CardDAVPrincipalURL</key><string>/dav/1/</string>
</dict>
</array>
</dict>
</plist>
PayloadUUIDs 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, so it is built on the server.
Implemented and verified live on 2026-08-04 (src/servers/api/dav/ios-profile.ts):
POST /api/dav/provision/ios body: { deviceLabel: string }
behind userMiddleware
1. mints an app password labelled deviceLabel
2. renders one plist carrying BOTH payloads, PayloadIdentifier stable per host
3. signs it, if signing is configured (§3.5) — unsigned otherwise, never a hard failure
4. holds it in memory against a single-use token
→ 200 { url: "https://<host>/dav/provision/<token>.mobileconfig", expiresAt, signed }
GET /dav/provision/<token>.mobileconfig
no session auth (Safari has none) — the token IS the auth
single use, 5 min TTL, deleted on first fetch; expired/used/never-existed all answer 404
Content-Type: application/x-apple-aspen-config
signed is a boolean the app can surface — it tells you in advance whether the user is about to see a
red Not Signed on the install screen.
Errors are all 500 with a message, and all are misconfiguration rather than anything the app did:
PUBLIC_URL unset, unparseable, or not https. A missing deviceLabel is 400.
The profile is never persisted — not to Postgres, not to disk. It holds the app password in
plaintext, and createDavAppPassword promises that plaintext is not stored. A server restart inside
the five-minute window invalidates a pending URL; the app should treat a 404 as "mint another", which
costs one extra tap.
Registration order matters if you ever touch hono.ts: this GET must be registered before
honoServer.route('/dav', davSyncRouter), or the sync door's /* catch-all answers it with an HTTP
Basic challenge that Safari has no credential for.
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."
Signing is opt-in and currently OFF on the reference deployment, because there is no TLS certificate on this box — TLS terminates on an upstream VPS that proxies in. Profiles are served unsigned; they install identically, the user just sees a red Not Signed. Three env vars turn it on, no code change:
DAV_PROFILE_SIGN_CERT leaf certificate, PEM
DAV_PROFILE_SIGN_KEY its private key, PEM
DAV_PROFILE_SIGN_CHAIN intermediates, PEM (optional but effectively required — see below)
Point them at whatever cert the box ends up holding once TLS moves local. The equivalent by hand is:
openssl smime -sign \
-in profile.mobileconfig \
-out profile-signed.mobileconfig \
-signer $DAV_PROFILE_SIGN_CERT \
-inkey $DAV_PROFILE_SIGN_KEY \
-certfile $DAV_PROFILE_SIGN_CHAIN \
-outform der -nodetach -md sha256
There is no renewal hook and none is needed. Signing happens at mint time and reads the certificate
off disk on every call, so a renewed cert is picked up on the next provision with no restart and nothing
to remember. If signing fails for any reason it is logged ([dav] profile signing failed …) and the
profile is served unsigned rather than failing the request — the response's signed: false is how the
app finds out. The one caveat below that this does not solve is Apple's own: a replacement profile
must be signed by the same identity as the one it replaces.
-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:
- 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. - 90-day expiry. There is no timestamping; iOS evaluates the signing cert at install time. Every profile being signed fresh at mint time handles the renewal itself. What it does not handle: Apple rejects a replacement profile signed with a different identity, so after a rotation a device may refuse to replace an older profile until the old one is removed. Nothing detects that for you.
Unsigned works too — the install flow is identical, the user just sees a red Not Signed. Ship signed once there is a certificate on the box to sign with.
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:
- Your button →
Linking.openURL(profileUrl)→ Safari opens. - 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.)
- "Profile Downloaded" sheet → Close.
- User opens Settings. At the top of the root list: Profile Downloaded → tap.
- 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 iscom.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/davx5are rewritten tohttps;caldav/carddavtohttp. - Account types:
bitfire.at.davdroidandat.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.
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<queries>entry forcanOpenURLon Android 11+:But the password ends up in a URI, which is worse for logging and clipboard exposure.<queries> <intent> <action android:name="android.intent.action.VIEW" /> <data android:scheme="davx5" /> </intent> </queries> - 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
- Mint a fresh app password per provisioning action. Never reuse, never cache. The plaintext is unrecoverable by design; there is nothing to reuse anyway.
- Label with the device name. Revocation is only usable if the labels are distinguishable.
- HTTPS only, always.
CalDAVUseSSL/CardDAVUseSSLtrue,https://scheme in the Android URI. - 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.
- 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. - Do not put it in a URI if you can avoid it (the Android native-module argument in §4.3).
- Provide revocation in the app.
GET /api/dav/passwordsto list,POST /:id/revoketo 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:
- Server-side. After provisioning, poll for
lastUsedAtbecoming non-null on the app-password entry viaGET /api/dav/passwords. Unambiguous: a device authenticated with it. This is the cleanest signal and works identically on both platforms. Verified:verifyDavAppPasswordwriteslastUsedAton 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. - Android:
AccountManager.getAccountsByType("bitfire.at.davdroid"). - iOS: query
EKEventStore/CNContactStorefor a source matching the account description. Requires calendar/contacts permission, which you may not want to ask for. Weakest option. - Manual, for support:
pm2 logs officer | grep '\[dav\]'on the server — theuastring names the exact daemon that connected.
7. Suggested order of work
Server:Done 2026-08-04, verified end to end: 200 with a URL, correct MIME type, both payloads present, second fetch 404, and the embedded password authenticates aPOST /api/dav/provision/ios+ the token-gated.mobileconfigendpoint (§3.4).PROPFIND /dav/1/(207) until it is revoked. Signing is opt-in and off (§3.5) — no renewal hook exists or is needed. This was the only new backend work either platform needed.- Android: the native intent module + the setup screen. Smallest, ships first, and it validates the whole shape of the feature.
- iOS: the setup screen with real device screenshots for every step in §3.6.
- 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
PayloadIdentifierreplaces 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 · CardDAV payload · Install a configuration profile (HT209435 / 102400) · Lockdown Mode · Device management profiles
DAVx⁵: integration manual · source
Android: setApplicationRestrictions ·
AMI 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.