44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
611 lines
33 KiB
Markdown
611 lines
33 KiB
Markdown
# 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. `calendar` is now a grantable
|
|
permission, 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` permission, 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/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
|
|
<?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>
|
|
```
|
|
|
|
`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, 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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
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. 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:
|
|
|
|
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 `<queries>` entry for `canOpenURL` on Android 11+:
|
|
```xml
|
|
<queries>
|
|
<intent>
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<data android:scheme="davx5" />
|
|
</intent>
|
|
</queries>
|
|
```
|
|
**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).~~ **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 a `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.
|
|
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._
|