two todos from the caldav extraction

setting up a phone should be one link inside officer — open Calendar, the screen
already knows this phone is not set up, click, done. the endpoint for it exists
(POST /api/caldav/provision/ios) and nothing in the UI calls it, so the smooth
path is built and hidden while the path a new person walks has four fields.

it also does not currently work: the profile is refused by iOS 26.6 as "Invalid
Profile". the todo records what was RULED OUT with evidence rather than
reasoning, because that is the expensive part to redo — delivery works, the
phone really received it (the claim is logged now), the server side is correct,
and removing PayloadScope did not fix it. signing is untested and is the first
experiment.

second: a plugin with a sidecar proxy loses the request body on its own routes.
createSidecarProxy registers the whole mount prefix and bodyParser skips it,
which is right for forwarded traffic and load-bearing for uploads — but it
covers the plugin's own routes too. caldav works around it locally; the real fix
is the proxy claiming only the sub-path it serves, which is shared machinery and
not a thing to change with a phone waiting.
This commit is contained in:
2026-08-27 05:46:57 +00:00
parent fdf50ce2a4
commit 185aa3168e
2 changed files with 102 additions and 0 deletions
@@ -0,0 +1,64 @@
---
id: mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app
title: 'Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form'
theme: mobile-apps
status: open
created: 2026-08-27
date_source: stated
order: 5
---
**The owner's shape for it, stated 2026-08-27:** open Calendar or Contacts in Officer, and the screen
already knows this phone is not set up. One link. Clicking it generates the profile on the server, the
phone installs it, and everything works. No typing a server address, no username, no password.
The endpoint for that already exists — `POST /api/caldav/provision/ios` in the caldav plugin — and it is
**unreachable from the product**: nothing in the UI calls it. The "Sync to your phone" modal offers only
manual app passwords. So the smooth path is built and hidden, and the path a new person actually walks is
the one with four fields in it.
## What is known, from an evening of it on 2026-08-27
**The profile does not install on iOS 26.6.** Every attempt returned *Profile Error — Invalid Profile*.
What was ruled out, each with evidence rather than reasoning:
- **Delivery works.** Fetched over the public URL from another machine: `200`,
`application/x-apple-aspen-config`, 2,491 bytes, parses cleanly with `plistlib`.
- **The phone really received it.** `claimIosProfile` now logs, and it logged
`ios profile claimed — 0 still pending` on the attempt that failed. iOS rejected the CONTENTS.
- **The server side is correct.** The principal advertises `addressbook-home-set`, `/1/contacts/` is
typed as an addressbook, and the manually-added accounts sync fine against the same URLs.
- **`PayloadScope` was removed** (Apple documents it as macOS-only). It did not fix it. Worth stating
plainly because it was briefly believed to have — the calendar had started working at the same moment
for an unrelated reason: the owner had added the account by hand.
So the remaining suspect is the payload itself — some key iOS 26 refuses, or unsigned profiles being
rejected outright, which would make `DAV_PROFILE_SIGN_CERT`/`KEY` a precondition rather than a nicety.
**Nothing has been tested against a signed profile.** That is the first experiment.
## The second problem, and it does not go away with a working profile
**iOS folds CardDAV into an existing CalDAV account with the same server and username.** Adding the
calendar by hand produced one "Officer" account offering Calendars and Reminders, with no Contacts toggle
anywhere — and the server confirmed nothing had ever requested `/dav/1/contacts/`. The workaround that
worked was adding CardDAV as a separate account with a DIFFERENT description.
`ios-profile.ts` already carries a comment claiming a single two-payload profile sidesteps this. On
26.6 it does not, and the comment should be corrected when this is picked up rather than trusted.
A `kinds` option was added to `buildPlist` and `only: 'caldav' | 'carddav'` to the route so the two
accounts can be split across two profiles — written, never verified, because the profile itself never
installed.
## What "already knows this phone is not set up" needs
Nothing exists for it today. The server can see whether a device has ever authenticated — `lastUsedAt` on
`dav_app_passwords` — which is enough for "no phone has ever synced", but not for "THIS phone has not",
since a browser cannot identify the device it is being read on. Worth deciding whether the weaker signal
is enough before building the stronger one.
## Related
- `plugin-infrastructure/a-plugin-s-own-routes-lose-their-request-body` — found while debugging this.
- The manual path works and is what the owner is using: app password from the modal, then CalDAV and
CardDAV added by hand as two accounts with different descriptions.
@@ -0,0 +1,38 @@
---
id: plugin-infrastructure/a-plugin-s-own-routes-lose-their-request-body
title: "A plugin that has a sidecar proxy loses the request body on its OWN routes"
theme: plugin-infrastructure
status: open
created: 2026-08-27
date_source: stated
order: 9
---
**`createSidecarProxy` registers the plugin's whole mount prefix with `registerProxiedPrefix`, and
`bodyParser` then skips every request under it — including routes the plugin serves itself.** Those
routes get `ctx.get('body') === {}` and every field reads as missing.
Found on 2026-08-27 extracting caldav: `POST /api/caldav/provision/ios` answered
`{"error":"deviceLabel is required"}` for a request that carried one. It had never happened before
because app passwords lived at `/api/dav` — a second top-level prefix that was not proxied — and one
prefix per plugin is the rule that ended that arrangement.
**The skip itself is correct and must not be removed.** Parsing a body that is only passing through is
wasted for JSON and *corrupting* for `multipart/form-data`: Hono caches the parsed `FormData` and
re-serialises it with a NEW boundary while the proxy forwards the ORIGINAL content-type header naming
the old one, so every upload fails with "Multipart: Unexpected end of form". That is why the mechanism
exists.
**The narrow fix is that the proxy should register only the sub-path it actually serves.** Caldav's
sidecar answers `_officer/*` and nothing else, so `/api/caldav/_officer` is the honest claim; the rest of
the prefix is the plugin's own and should be parsed normally. That means `createSidecarProxy` taking the
proxied sub-path rather than assuming the whole mount.
Not done on the night it was found: it is shared machinery every sidecar depends on, and the change was
diagnosed while a phone was waiting. **The caldav plugin works around it locally** — `jsonBody()` in
`api/app-passwords.ts` reads `ctx.req.json()` directly, with the reasoning inline.
**Who else is affected:** every plugin that mixes its own routes with a sidecar proxy under one prefix,
which is most of them. They have not hit it because their own routes are GETs, or because their writes go
to the sidecar. A plugin adding its first non-proxied POST will hit it, and the symptom — a required
field reported missing on a request that plainly has it — points at the caller rather than at this.