one-tap ios dav provisioning

mints a dav app password, renders a configuration profile carrying both the
caldav and carddav payloads, and parks it behind a single-use five-minute token
that safari can fetch without a session.

one profile with both payloads is not a convenience: ios keys accounts by
server+username, so adding carddav separately gets folded into the existing
caldav account and contacts silently never appear.

the profile holds the password in plaintext, so it is held in memory only —
persisting it would falsify createDavAppPassword's "not stored" guarantee.

signing is opt-in via DAV_PROFILE_SIGN_CERT/_KEY/_CHAIN and off by default;
this box has no tls certificate, tls terminates upstream. signed at mint time
reading the cert from disk, so a renewal needs no restart and no hook.

the download route is registered before the /dav mount because hono matches in
registration order and the sync door's /* would otherwise demand http basic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:20:52 +00:00
co-authored by Claude Opus 5
parent 7b16e88b2f
commit 3f57cec551
4 changed files with 331 additions and 21 deletions
+69 -21
View File
@@ -4,10 +4,11 @@
**Goal:** the user opens the Officer app, taps one button, and their phone's native Calendar and **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. 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 **Status of this document:** the server side is built, deployed and verified — §1 against a real iPhone,
real iPhone. Everything in §3 (iOS) and §4 (Android) is a specification for work that has **not** been and §3.4's provisioning endpoint end to end on 2026-08-04. The **client** work in §3 (iOS) and §4
written yet — the payload keys, intent names and install flows are researched and cited, not (Android) is still a specification: the payload keys, intent names and install flows are researched and
implemented. Where something is unverified I say so explicitly rather than rounding it up to fact. cited, not implemented. Where something is unverified I say so explicitly rather than rounding it up to
fact.
--- ---
@@ -96,7 +97,16 @@ Principal https://<host>/dav/<userId>/
A collection https://<host>/dav/<userId>/<collection>/ A collection https://<host>/dav/<userId>/<collection>/
``` ```
`<userId>` is the numeric account id — `1` on a single-user instance, which every Officer instance is. `<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. On a single-user instance — which every
Officer instance is — that is `1`. Deriving it from `/auth/me` is safe; so is deriving it from the
collection paths, which is why both work today.
**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 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: book), but **these are user-created and you must not hardcode them.** Discover them:
@@ -261,24 +271,39 @@ account, which is what you want when the user re-runs setup.
### 3.4 Generate it server-side, not in the app ### 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 The profile embeds a password that only the server can mint, so it is built on the server.
endpoint — **this does not exist yet; it is yours (or ours) to write**: **Implemented and verified live on 2026-08-04** (`src/servers/api/dav/ios-profile.ts`):
``` ```
POST /api/dav/provision/ios body: { deviceLabel: string } POST /api/dav/provision/ios body: { deviceLabel: string }
behind userMiddleware behind userMiddleware
1. mints an app password labelled deviceLabel 1. mints an app password labelled deviceLabel
2. renders the plist 2. renders one plist carrying BOTH payloads, PayloadIdentifier stable per host
3. signs it 3. signs it, if signing is configured (§3.5) — unsigned otherwise, never a hard failure
4. stores it against a single-use, short-lived token 4. holds it in memory against a single-use token
→ { url: "https://<host>/dav/provision/<opaque-token>.mobileconfig", expiresAt } 200 { url: "https://<host>/dav/provision/<token>.mobileconfig", expiresAt, signed }
GET /dav/provision/<token>.mobileconfig GET /dav/provision/<token>.mobileconfig
no session auth (Safari has none) — the token IS the auth no session auth (Safari has none) — the token IS the auth
single use, 5 min TTL, deleted on first fetch single use, 5 min TTL, deleted on first fetch; expired/used/never-existed all answer 404
Content-Type: application/x-apple-aspen-config 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 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. `application/octet-stream` or `text/xml` gets you a downloaded file the OS ignores.
@@ -289,16 +314,35 @@ stays valid**, sitting on a public HTTPS endpoint with no authentication.
Apple's requirement: "place the XML property list in a DER-encoded, CMS Signed Data structure." 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 ```bash
openssl smime -sign \ openssl smime -sign \
-in profile.mobileconfig \ -in profile.mobileconfig \
-out profile-signed.mobileconfig \ -out profile-signed.mobileconfig \
-signer /etc/letsencrypt/live/<domain>/cert.pem \ -signer $DAV_PROFILE_SIGN_CERT \
-inkey /etc/letsencrypt/live/<domain>/privkey.pem \ -inkey $DAV_PROFILE_SIGN_KEY \
-certfile /etc/letsencrypt/live/<domain>/chain.pem \ -certfile $DAV_PROFILE_SIGN_CHAIN \
-outform der -nodetach -md sha256 -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 `-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 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. embedded profile. `-certfile` matters because iOS ships roots, not intermediates.
@@ -312,12 +356,13 @@ so this is well-corroborated practice rather than documented policy. Two real ri
1. **Use an RSA cert (`--key-type rsa`).** There is an unresolved report of ECDSA P-256-signed profiles 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 showing "Unverified" where a byte-identical RSA one shows verified. Unconfirmed, but the RSA path is
the one that is definitively known to work. 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 2. **90-day expiry.** There is no timestamping; iOS evaluates the signing cert at install time. Every
must re-sign on every certificate renewal, or new installs show "Not Verified". Also: Apple rejects profile being signed fresh at mint time handles the renewal itself. What it does not handle: Apple
a _replacement_ profile signed with a different identity, so plan the renewal, don't improvise it. 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 Unsigned works too — the install flow is identical, the user just sees a red **Not Signed**. Ship
signed. signed once there is a certificate on the box to sign with.
### 3.6 The install flow — and its hard limits ### 3.6 The install flow — and its hard limits
@@ -517,8 +562,11 @@ You get no success callback on either platform. Options, best first:
## 7. Suggested order of work ## 7. Suggested order of work
1. Server: `POST /api/dav/provision/ios` + the token-gated `.mobileconfig` endpoint (§3.4), signing 1. ~~Server: `POST /api/dav/provision/ios` + the token-gated `.mobileconfig` endpoint (§3.4).~~ **Done
wired into the certbot renewal hook (§3.5). This is the only new backend work either platform needs. 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 2. Android: the native intent module + the setup screen. Smallest, ships first, and it validates the
whole shape of the feature. whole shape of the feature.
3. iOS: the setup screen with real device screenshots for every step in §3.6. 3. iOS: the setup screen with real device screenshots for every step in §3.6.
+189
View File
@@ -0,0 +1,189 @@
import { randomUUID, randomBytes } from 'node:crypto';
// Generates the iOS configuration profile that sets up CalDAV + CardDAV in one install, and holds it
// against a single-use token until Safari fetches it.
//
// WHY A TOKEN AND NOT A SESSION. The profile is fetched by Safari, which has no platform JWT — the app
// hands the URL to the OS browser and loses all control of the request. So the token IS the credential.
// It is therefore minted long, used once, and dropped after five minutes, because the thing it protects
// is a live DAV password sitting in a file on a public endpoint. A profile URL that stays valid is a
// password that stays valid.
//
// WHY IN MEMORY. The profile contains the app password in PLAINTEXT — it has to, that is what makes the
// install one-tap. `createDavAppPassword` promises the plaintext "is not stored", and writing the
// profile to Postgres or to disk would quietly make that false. A process restart inside the five-minute
// window costs the owner one extra tap; persisting the secret costs the guarantee.
//
// See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from.
const TTL_MS = 5 * 60_000;
// A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint.
const MAX_PENDING = 32;
type PendingProfile = { body: Uint8Array; expiresAt: number };
const pending = new Map<string, PendingProfile>();
const sweep = () => {
const now = Date.now();
for (const [token, entry] of pending) if (entry.expiresAt <= now) pending.delete(token);
};
const escapeXml = (value: string) =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
type ProfileParams = {
host: string;
port: number;
username: string;
password: string;
/** The DAV principal, e.g. `/dav/1/`. Both payloads point at it and let the client discover the rest. */
principalUrl: string;
/** What the user sees as the account name in Settings. */
accountName: string;
};
// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
// CardDAV separately with the same pair gets folded into the existing CalDAV account and contacts
// silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in
// docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn.
function buildPlist(params: ProfileParams): string {
const { host, port, username, password, principalUrl, accountName } = params;
// Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and
// two Officer instances on one phone do not overwrite each other.
const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}`;
const e = escapeXml;
const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` <dict>
<key>PayloadType</key><string>com.apple.${kind.toLowerCase()}.account</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadIdentifier</key><string>${identifier}.${kind.toLowerCase()}</string>
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
<key>PayloadDisplayName</key><string>${e(label)}</string>
<key>${kind}AccountDescription</key><string>${e(accountName)}</string>
<key>${kind}HostName</key><string>${e(host)}</string>
<key>${kind}Port</key><integer>${port}</integer>
<key>${kind}UseSSL</key><true/>
<key>${kind}Username</key><string>${e(username)}</string>
<key>${kind}Password</key><string>${e(password)}</string>
<key>${kind}PrincipalURL</key><string>${e(principalUrl)}</string>
</dict>`;
return `<?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>${identifier}</string>
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
<key>PayloadDisplayName</key><string>${e(accountName)} — Calendar &amp; Contacts</string>
<key>PayloadDescription</key><string>Adds your Officer calendars and address book to this device.</string>
<key>PayloadOrganization</key><string>Officer</string>
<key>PayloadScope</key><string>User</string>
<key>PayloadRemovalDisallowed</key><false/>
<key>PayloadContent</key>
<array>
${account('CalDAV', 'Officer Calendar')}
${account('CardDAV', 'Officer Contacts')}
</array>
</dict>
</plist>
`;
}
// Signing is OPT-IN and off by default, because this machine has no TLS certificate on it — TLS
// terminates upstream. An unsigned profile installs exactly the same way; the only difference is that
// the install screen says "Not Signed" in red instead of naming the signer.
//
// Point these at a cert whose chain iOS already trusts (a public CA — the TLS cert of the reverse proxy
// works, iOS checks the trust chain and not the key usage) and profiles are signed from then on:
// DAV_PROFILE_SIGN_CERT leaf certificate, PEM
// DAV_PROFILE_SIGN_KEY its private key, PEM
// DAV_PROFILE_SIGN_CHAIN intermediates, PEM (iOS ships roots, not intermediates — without this the
// signature cannot be verified and shows as Not Verified)
//
// Signed at mint time, reading the cert from disk on every call, so a renewed certificate is picked up
// with no restart and no renewal hook — the failure mode the mobile team flagged cannot happen here.
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
// old one is removed. Nothing detects that for you.
const signingConfig = () => {
const cert = process.env.DAV_PROFILE_SIGN_CERT;
const key = process.env.DAV_PROFILE_SIGN_KEY;
if (!cert || !key) return null;
return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN };
};
let warnedUnsigned = false;
async function sign(plist: string): Promise<Uint8Array | null> {
const config = signingConfig();
if (!config) {
if (!warnedUnsigned) {
warnedUnsigned = true;
console.warn(
'[dav] serving UNSIGNED iOS profiles — iOS will show "Not Signed" in red. Set DAV_PROFILE_SIGN_CERT/KEY to sign.',
);
}
return null;
}
// Piped through stdin/stdout rather than temp files: the plist holds a live password in plaintext and
// there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
// load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
// the file is a signature with no profile embedded in it.
const args = ['smime', '-sign', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach'];
if (config.chain) args.push('-certfile', config.chain);
args.push('-md', 'sha256');
const proc = Bun.spawn(['openssl', ...args], {
stdin: new TextEncoder().encode(plist),
stdout: 'pipe',
stderr: 'pipe',
});
const [out, err, code] = await Promise.all([
new Response(proc.stdout).arrayBuffer(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (code !== 0 || out.byteLength === 0) {
// Deliberately not fatal. An unsigned profile still installs and still works; failing the whole
// provisioning request over a cosmetic signature would be the worse outcome.
console.error(`[dav] profile signing failed (exit ${code}), serving unsigned: ${err.trim().slice(0, 200)}`);
return null;
}
return new Uint8Array(out);
}
export type StoredProfile = { url: string; expiresAt: string; signed: boolean };
/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
sweep();
if (pending.size >= MAX_PENDING) throw new Error('too many pending profiles');
const plist = buildPlist(params);
const signed = await sign(plist);
const body = signed ?? new TextEncoder().encode(plist);
const token = randomBytes(32).toString('base64url');
const expiresAt = Date.now() + TTL_MS;
pending.set(token, { body, expiresAt });
return {
url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`,
expiresAt: new Date(expiresAt).toISOString(),
signed: signed !== null,
};
}
/** Take the profile for this token, if it is still valid. Consumes it — a second fetch gets nothing. */
export function claimIosProfile(token: string): Uint8Array | null {
sweep();
const entry = pending.get(token);
if (!entry) return null;
pending.delete(token);
return entry.body;
}
+50
View File
@@ -1,6 +1,7 @@
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb'; import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb';
import * as errors from '../../custom-errors'; import * as errors from '../../custom-errors';
import { stashIosProfile } from './ios-profile';
// Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else // Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else
// under /api — this is the owner administering their devices from a logged-in browser, which is a // under /api — this is the owner administering their devices from a logged-in browser, which is a
@@ -28,6 +29,55 @@ davRouter.post('/passwords', async (ctx) => {
return ctx.json({ entry, password, username: user.email }); return ctx.json({ entry, password, username: user.email });
}); });
// One-tap iOS setup: mint a credential, wrap it in a configuration profile, hand back a URL for the app
// to open in Safari. See docs/mobile-dav-provisioning.md §3, and ios-profile.ts for why the profile is
// held in memory behind a one-shot token rather than stored.
//
// This is the only endpoint that returns a route to a live password instead of the password itself. It
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
davRouter.post('/provision/ios', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { deviceLabel?: string } | undefined;
const deviceLabel = body?.deviceLabel?.trim();
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
// The profile has to name a host, and PUBLIC_URL is the only place that knows the one a phone can
// actually reach — the request's own Host header is whatever the reverse proxy forwarded.
const publicUrl = process.env.PUBLIC_URL;
if (!publicUrl) throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not set; cannot build a profile');
let url: URL;
try {
url = new URL(publicUrl);
} catch {
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not a valid URL');
}
if (url.protocol !== 'https:') {
// CalDAVUseSSL is hardcoded true in the payload, so an http PUBLIC_URL would produce a profile that
// cannot work. Better to say so than to ship a phone an account that silently never syncs.
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL must be https to provision a device');
}
const { entry, password } = await createDavAppPassword(user.id, deviceLabel);
const stored = await stashIosProfile(
{
host: url.hostname,
port: Number(url.port) || 443,
username: user.email,
password,
// The principal, not a collection: iOS discovers every calendar and address book under it, so a
// collection the owner adds later appears without re-provisioning.
principalUrl: `/dav/${user.id}/`,
accountName: 'Officer',
},
url.origin,
);
console.log(`[dav] provisioned ios profile for "${deviceLabel}" (password #${entry.id}, signed=${stored.signed})`);
return ctx.json(stored);
});
davRouter.post('/passwords/:id/revoke', async (ctx) => { davRouter.post('/passwords/:id/revoke', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
const id = Number(ctx.req.param('id')); const id = Number(ctx.req.param('id'));
+23
View File
@@ -34,6 +34,7 @@ import { caldavRouter } from './api/dav/sidecar-server';
import { memosRouter } from './api/memos/router'; import { memosRouter } from './api/memos/router';
import { davSyncRouter } from './api/dav/sync-router'; import { davSyncRouter } from './api/dav/sync-router';
import { davRouter } from './api/dav/router'; import { davRouter } from './api/dav/router';
import { claimIosProfile } from './api/dav/ios-profile';
import { notifyRouter } from './api/notify/router'; import { notifyRouter } from './api/notify/router';
import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { systemMonitorRouter } from './api/system-monitor/system-monitor';
import { activityRouter } from './api/activity/router'; import { activityRouter } from './api/activity/router';
@@ -104,6 +105,28 @@ honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a // DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a
// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see // platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see
// api/dav/sync-router.ts. // api/dav/sync-router.ts.
// The iOS profile download, registered BEFORE the /dav mount below because hono matches in registration
// order and davSyncRouter's `/*` would otherwise demand HTTP Basic for it. Safari has no credential to
// offer — it was handed a URL by the app and nothing else — so the one-shot token in the path IS the
// authentication. Minted by POST /api/dav/provision/ios; see api/dav/ios-profile.ts.
honoServer.get('/dav/provision/:file', (ctx) => {
const file = ctx.req.param('file');
const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null;
const body = token ? claimIosProfile(token) : null;
// Expired, already used, or never existed — all the same 404. There is nothing useful to tell a
// caller who has the wrong token, and distinguishing the cases would confirm that a token once existed.
if (!body) return ctx.text('not found', 404);
return new Response(body as unknown as BodyInit, {
headers: {
// Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or
// text/xml the file downloads and the OS does nothing with it.
'Content-Type': 'application/x-apple-aspen-config',
'Cache-Control': 'no-store',
},
});
});
honoServer.route('/dav', davSyncRouter); honoServer.route('/dav', davSyncRouter);
// Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of // Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of