diff --git a/api/app-passwords.ts b/api/app-passwords.ts index 5242a18..0e7d82b 100644 --- a/api/app-passwords.ts +++ b/api/app-passwords.ts @@ -1,3 +1,4 @@ +import type { Context } from 'hono'; import { createRouter } from '@@/create-router'; import { listDavAppPasswords, @@ -18,6 +19,35 @@ import { stashIosProfile } from './ios-profile'; export const appPasswordsRouter = createRouter(); +/** + * Read this request's JSON body. + * + * `ctx.get('body')` is the platform's parsed body, and it is EMPTY here — deliberately, and for a reason + * that only became true when caldav became a plugin. + * + * `createSidecarProxy` registers `/api/caldav` with `registerProxiedPrefix`, which tells `bodyParser` to + * leave those request streams untouched. That is right for forwarded traffic and load-bearing for + * multipart uploads: a body parsed here is re-serialised with a new boundary while the old content-type + * header is still forwarded, and every upload fails. But the prefix covers this plugin's OWN routes too, + * so they get `{}` and every field reads as missing. + * + * Before the extraction these routes lived under `/api/dav`, a second top-level prefix that was not + * proxied, so their bodies parsed. One prefix per plugin is the rule that ended that arrangement. + * + * Read here rather than fixed in the platform because the platform's rule is correct — the narrower fix + * is for the proxy to register only the sub-path it actually serves (`_officer/*`), which is a change to + * shared machinery every sidecar depends on and not one to make while a phone is waiting. + */ +async function jsonBody(ctx: Context): Promise { + try { + return (await ctx.req.json()) as T; + } catch { + // No body, or not JSON. Every caller below treats a missing field as a bad request anyway. + return {} as T; + } +} + + appPasswordsRouter.get('/passwords', async (ctx) => { const user = ctx.get('user'); return ctx.json({ passwords: await listDavAppPasswords(user.id) }); @@ -25,7 +55,7 @@ appPasswordsRouter.get('/passwords', async (ctx) => { appPasswordsRouter.post('/passwords', async (ctx) => { const user = ctx.get('user'); - const body = ctx.get('body') as { label?: string } | undefined; + const body = await jsonBody<{ label?: string }>(ctx); const label = body?.label?.trim(); if (!label) throw errors.BAD_REQUEST('label is required'); @@ -42,7 +72,7 @@ appPasswordsRouter.post('/passwords', async (ctx) => { // exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice. appPasswordsRouter.post('/provision/ios', async (ctx) => { const user = ctx.get('user'); - const body = ctx.get('body') as { deviceLabel?: string } | undefined; + const body = await jsonBody<{ deviceLabel?: string; only?: 'caldav' | 'carddav' }>(ctx); const deviceLabel = body?.deviceLabel?.trim(); if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required'); @@ -74,7 +104,10 @@ appPasswordsRouter.post('/provision/ios', async (ctx) => { // 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', + accountName: body.only === 'carddav' ? 'Officer Contacts' : 'Officer', + // `only` splits the two accounts across two profiles — see ProfileParams.kinds for the iOS 26 + // behaviour that made it necessary. + ...(body.only ? { kinds: [body.only === 'carddav' ? ('CardDAV' as const) : ('CalDAV' as const)] } : {}), }, url.origin, ); diff --git a/api/ios-profile.ts b/api/ios-profile.ts index cdaae05..8ded80f 100644 --- a/api/ios-profile.ts +++ b/api/ios-profile.ts @@ -16,7 +16,19 @@ import { randomUUID, randomBytes } from 'node:crypto'; // // See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from. -const TTL_MS = 5 * 60_000; +/* + How long a provisioning link lives. + + Five minutes until 2026-08-27, which assumed the person minting the link is the person clicking it — + true for a button in the UI, false for every other way this gets used. Handing a URL to someone who then + has to pick up a phone, open Safari and type it took longer than that every single time, and the failure + is indistinguishable from a broken profile: the link 404s and iOS says "Invalid Profile", which sends you + looking at the plist rather than at the clock. That cost most of an evening. + + Thirty minutes, still single-use. The link embeds a live password, so this is a real trade — but the + binding constraint is that it is claimed ONCE and then gone, not that the window is narrow. +*/ +const TTL_MS = 30 * 60_000; // A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint. const MAX_PENDING = 32; @@ -41,6 +53,21 @@ type ProfileParams = { principalUrl: string; /** What the user sees as the account name in Settings. */ accountName: string; + /** + * Which accounts this profile creates. Both, unless something is wrong. + * + * ── Why this had to exist ── + * + * The comment below says one profile carrying both payloads sidesteps iOS folding CardDAV into an + * existing CalDAV account with the same server and username. On iOS 26.6 it does not: installing the + * two-payload profile produced ONE account offering Calendars and Reminders, with no Contacts toggle + * anywhere and 460 cards that the phone never once requested. Verified from the server side — the + * principal advertises `addressbook-home-set`, `/1/contacts/` is typed as an addressbook, and nothing + * had ever asked for it. + * + * So CardDAV gets its own profile, with its own PayloadIdentifier, which iOS has nothing to merge into. + */ + kinds?: ('CalDAV' | 'CardDAV')[]; }; // One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding @@ -48,10 +75,11 @@ type ProfileParams = { // 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; + const { host, port, username, password, principalUrl, accountName, kinds = ['CalDAV', 'CardDAV'] } = 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 suffix = kinds.length === 1 ? `.${kinds[0]!.toLowerCase()}` : ''; + const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}${suffix}`; const e = escapeXml; const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` @@ -80,12 +108,12 @@ function buildPlist(params: ProfileParams): string { PayloadDisplayName${e(accountName)} — Calendar & Contacts PayloadDescriptionAdds your Officer calendars and address book to this device. PayloadOrganizationOfficer - PayloadScopeUser + PayloadRemovalDisallowed PayloadContent -${account('CalDAV', 'Officer Calendar')} -${account('CardDAV', 'Officer Contacts')} +${kinds.map((k) => account(k, k === 'CalDAV' ? 'Officer Calendar' : 'Officer Contacts')).join('\n')} @@ -183,7 +211,18 @@ export async function stashIosProfile(params: ProfileParams, publicUrl: string): export function claimIosProfile(token: string): Uint8Array | null { sweep(); const entry = pending.get(token); - if (!entry) return null; + if (!entry) { + // SAID OUT LOUD, because the alternative is silence at the only moment it matters. + // + // A miss means expired, already used, or never existed, and the caller cannot be told which — telling + // them would confirm a token once existed. But WE need to know: the response is a 404, iOS renders + // that as "Invalid Profile", and the person reading it goes and inspects the plist. Nothing in the log + // said a phone had even asked. One line here is the difference between "the link expired" and an + // evening spent validating XML that was correct the whole time. + console.warn(`[dav] ios profile claim MISSED (expired, already used, or never minted) — ${pending.size} pending`); + return null; + } pending.delete(token); + console.log(`[dav] ios profile claimed — ${pending.size} still pending`); return entry.body; } diff --git a/api/router.ts b/api/router.ts index 9d9cd2d..3a46a97 100644 --- a/api/router.ts +++ b/api/router.ts @@ -27,10 +27,22 @@ import { claimIosProfile } from './ios-profile'; export const router = createRouter(); +/* + ORDER IS LOAD-BEARING, and getting it wrong is silent. + + `caldavRouter` is a sidecar proxy, and `createSidecarProxy` mounts `router.all('/*')` — a catch-all. Hono + matches in registration order, so mounting it first means it swallows EVERY path under `/api/caldav`, + forwards it to the sidecar, and the sidecar answers 404 for anything that is not its own `_officer/*` + namespace. `POST /api/caldav/provision/ios` returned "not found" for exactly that reason, and it reads + like a missing route rather than a shadowed one. + + So: the plugin's own routes first, the catch-all proxy last. +*/ +router.route('/', appPasswordsRouter); + // The JSON door for this plugin's own screens. `_officer/*` is the sidecar's namespace; everything under // it is proxied to the Radicale-holding sidecar, which is the only thing that ever sees a DAV credential. router.route('/', caldavRouter); -router.route('/', appPasswordsRouter); /** * Mounted at EVERY path the manifest claims — `/dav`, `/.well-known/caldav`, `/.well-known/carddav` — so diff --git a/api/sync-router.ts b/api/sync-router.ts index 0c0d753..66c99d9 100644 --- a/api/sync-router.ts +++ b/api/sync-router.ts @@ -1,4 +1,9 @@ +import type { Context } from 'hono'; import { createRouter } from '@@/create-router'; +import { looksLikeApiKey, resolveApiKey } from '@@/auth-token'; +import { isApiRequestAllowed } from '@@/permissions/authorize'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; import { getUserByEmail } from 'officerdb'; import { verifyDavAppPassword } from '../db/queries'; import { getCaldavUrl } from './sidecar-server'; @@ -76,6 +81,71 @@ const RESPONSE_HEADERS = [ 'vary', ] as const; + +/* + DAV methods, sorted into read and write. + + `decideApiRequest` reasons in HTTP terms — a safe method needs a read grant, anything else needs write — + and DAV's vocabulary is not in that list at all. Left unmapped, PROPFIND and REPORT would count as + writes, and a member granted READ on the calendar could not so much as list it. + + Anything not named here is treated as a WRITE. That is the safe direction for a default: a DAV extension + nobody here has heard of asking for read access is a small annoyance, and one silently granted write + access is not. +*/ +const DAV_READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PROPFIND', 'REPORT']); + +/** + * The other way to authenticate here: an agent's own session credential. + * + * ── Why this exists ── + * + * The owner asked for any agent they are chatting with to be able to add an event or a contact. Doing + * that through this door needs a credential, and every alternative meant creating a NEW secret and then + * solving where it lives — a file on disk, the secret store, one per commander. Each of those is a + * password to distribute, rotate and eventually leak. + * + * `$OFFICER_AGENT_KEY` is already in every agent's environment, already bound to one user, already + * revoked when the session ends. Accepting it here adds no secret to the world. + * + * ── Why it cannot reach another account ── + * + * The key IS the identity — `resolveApiKey` returns whose it is, and that id becomes `X-Remote-User`. + * There is no field an agent could change to address someone else: the URL is not consulted, and Radicale + * refuses a principal that is not the one in the header. The username in the Basic header must also match + * the key's owner, so a request cannot even LOOK like it is acting for somebody else. + * + * ── Why the permission check is the platform's own ── + * + * `isApiRequestAllowed` against this plugin's API prefix, so the answer is exactly what the permission + * gate would give for the same credential on the same plugin: role grants, the credential's own allow + * list, and its excluded kinds. Reimplementing that here would be a second copy of the rule, and the + * second copy is the one that stops being updated. A key narrowed away from `calendar` is refused here + * for the same reason it would be refused at `/api/caldav`. + */ +async function agentKeyUser(ctx: Context, user: { id: number }, secret: string): Promise { + if (!looksLikeApiKey(secret)) return null; + + const identity = await resolveApiKey(secret); + if (!identity) return null; + + // The credential decides who this is; the username has to agree with it rather than select it. + if (identity.id !== user.id) { + console.warn(`[dav] auth failed: api key belongs to user ${identity.id}, not ${user.id}`); + return null; + } + + const method = DAV_READ_METHODS.has(ctx.req.method.toUpperCase()) ? 'GET' : 'PUT'; + const verdict = await isApiRequestAllowed(identity.id, method, `/api${mountPrefix({ appName: 'caldav', manifest })}/`, identity.scope); + if (!verdict.allowed) { + console.warn(`[dav] api key refused for user ${identity.id}: ${verdict.reason ?? 'not permitted'}`); + return null; + } + + console.log(`[dav] authenticated user ${identity.id} by session credential (${ctx.req.method})`); + return identity.id; +} + davSyncRouter.all('/*', async (ctx) => { const creds = parseBasic(ctx.req.header('authorization')); if (!creds) return unauthorized(); @@ -89,9 +159,9 @@ davSyncRouter.all('/*', async (ctx) => { return unauthorized(); } - const userId = await verifyDavAppPassword(user.id, creds.password); + const userId = (await verifyDavAppPassword(user.id, creds.password)) ?? (await agentKeyUser(ctx, user, creds.password)); if (!userId) { - console.warn(`[dav] auth failed: bad app password for ${creds.username}`); + console.warn(`[dav] auth failed: bad credential for ${creds.username}`); return unauthorized(); } diff --git a/skills/calendar-and-contacts/SKILL.md b/skills/calendar-and-contacts/SKILL.md new file mode 100644 index 0000000..55eead3 --- /dev/null +++ b/skills/calendar-and-contacts/SKILL.md @@ -0,0 +1,155 @@ +--- +name: Calendar and contacts +description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "cancel that", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the working recipes, the two credentials, and the timezone trap that puts every event an hour out. +version: 1 +--- + +You can read and write the owner's calendars and address book directly. **Reading goes through Officer's +API. Writing goes through the DAV door.** Both use credentials you already have; there is nothing to ask +for and no password to be given. + +## The two doors + +**Read — Officer's own API, with `$OFFICER_AGENT_KEY`.** Parsed JSON, no XML. + +```bash +curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ + "$OFFICER_API_URL/api/caldav/_officer/collections" +``` + +**Write — the DAV door, HTTP Basic, the SAME key as the password.** + +```bash +ME=$(curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ + "$OFFICER_API_URL/api/auth/me" | python3 -c 'import json,sys; print(json.load(sys.stdin)["email"])') +AUTH="$ME:$OFFICER_AGENT_KEY" +``` + +Your key is bound to ONE account — the owner's. You cannot reach anyone else's calendar with it and you +do not need to guard against doing so: another principal answers 403, and naming a different user in the +Basic header answers 401. Write the owner's own paths and stop thinking about it. + +## Find the collections first — never guess a path + +```bash +curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ + "$OFFICER_API_URL/api/caldav/_officer/collections" +# {"collections":[{"path":"/dav/1/personal/","displayName":"Personal","kind":"calendar", ...}, +# {"path":"/dav/1/contacts/","displayName":"Contacts","kind":"addressbook"}]} +``` + +`kind` is `calendar` or `addressbook`. There are usually several calendars — **"Personal" is the default +for anything the owner does not place**, and Tasks holds VTODOs that show up in Reminders on a phone. + +## THE TIMEZONE TRAP — read this before writing any time + +The owner says "Saturday at 8". Calendars store UTC. **`date -u -d` does not convert** — `-u` makes GNU +date parse the input as UTC *and* print UTC, so "8pm" becomes 20:00Z, which is 21:00 in Lisbon. Every +event lands an hour out and nothing errors. + +```bash +# WRONG — prints 20260829T200000Z +date -u -d 'saturday 20:00' +%Y%m%dT%H%M%SZ +# RIGHT — prints 20260829T190000Z, via epoch so the local zone is applied +date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ +``` + +The machine's clock is the owner's timezone. Parse what they said with plain `date -d`, convert through +`+%s`, and **say the LOCAL time back to them** when you confirm — never the UTC you stored. + +## Create an event + +The filename must be `.ics`, and iCalendar lines are **CRLF-terminated**. `DTSTAMP` is required. + +```bash +UID="officer-$(date +%s)-$RANDOM" +S=$(date -u -d "@$(date -d 'saturday 20:00' +%s)" +%Y%m%dT%H%M%SZ) +E=$(date -u -d "@$(date -d 'saturday 22:00' +%s)" +%Y%m%dT%H%M%SZ) +printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Officer//agent//EN\r\nBEGIN:VEVENT\r\nUID:$UID\r\nDTSTAMP:$(date -u +%Y%m%dT%H%M%SZ)\r\nDTSTART:$S\r\nDTEND:$E\r\nSUMMARY:Dinner with my wife\r\nLOCATION:Cervejaria Ramiro\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" \ + | curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \ + -H 'Content-Type: text/calendar; charset=utf-8' --data-binary @- \ + "$OFFICER_API_URL/dav/1/personal/$UID.ics" +# 201 created +``` + +An **all-day** event uses dates, not times, and DTEND is the day AFTER: +`DTSTART;VALUE=DATE:20260829` with `DTEND;VALUE=DATE:20260830`. + +## Look something up + +"When am I having dinner with the CEO?" — read, filter, answer in local time. + +```bash +curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ + "$OFFICER_API_URL/api/caldav/_officer/events?collection=%2Fdav%2F1%2Fpersonal%2F" +# {"events":[{"uid":"…","summary":"Dinner with my wife","start":"2026-08-29T19:00:00Z","end":"…", +# "location":"Cervejaria Ramiro","allDay":false}]} +``` + +`start` and `end` are UTC. **Convert before speaking**: `date -d '2026-08-29T19:00:00Z' '+%A %-d %B, %H:%M'`. + +Search every calendar, not just Personal — the answer is often in another one. `location` is where +"where did I set it up" is answered from; if it is empty, say so rather than inventing somewhere. + +`rrule` present means the event REPEATS and the listed `start` is the series start, not the next +occurrence. Do not compute occurrences yourself — say it repeats and give the rule in words. + +## Move an event + +**Same UID, same filename, new times.** A new UID creates a second event and leaves the first in place, +which is the most common way this goes wrong. + +```bash +# fetch the current copy, so nothing else in it is lost +curl -s -u "$AUTH" "$OFFICER_API_URL/dav/1/personal/$UID.ics" > /tmp/ev.ics +# then PUT a version with new DTSTART/DTEND and SEQUENCE bumped by one +``` + +Bump `SEQUENCE` (add `SEQUENCE:1`, or increment what is there). Phones use it to tell an update from a +duplicate. A successful update answers **204**, not 201 — 201 means you created something new, so check +your filename. + +## Delete an event, or a contact + +```bash +curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X DELETE "$OFFICER_API_URL/dav/1/personal/$UID.ics" +# 200 +``` + +**Find the UID by reading first — never guess.** The file is normally `.ics`, and if that 404s, list +the collection and match on href: + +```bash +curl -s -u "$AUTH" -X PROPFIND -H 'Depth: 1' "$OFFICER_API_URL/dav/1/personal/" | grep -o '[^<]*' +``` + +**Confirm before deleting when the match is not exact.** "Cancel dinner on Saturday" with one dinner on +Saturday is unambiguous — do it. Two dinners, or a recurring one, is a question: deleting the wrong event +cannot be undone from here, and the owner will discover it when someone is stood up. + +Deleting one occurrence of a REPEATING event is not a DELETE — it needs an EXDATE added to the series. +If that is what they meant, say so rather than removing every occurrence. + +## Add or remove a contact + +Same shape, `.vcf` in the addressbook collection. + +```bash +UID="officer-$(date +%s)-$RANDOM" +printf "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:$UID\r\nFN:Peter Griffin\r\nN:Griffin;Peter;;;\r\nTEL;TYPE=CELL:+351912345678\r\nEMAIL;TYPE=INTERNET:peter@quahog.example\r\nEND:VCARD\r\n" \ + | curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X PUT \ + -H 'Content-Type: text/vcard; charset=utf-8' --data-binary @- \ + "$OFFICER_API_URL/dav/1/contacts/$UID.vcf" +``` + +`FN` is what shows on a phone and is REQUIRED. `N` is `Family;Given;Middle;Prefix;Suffix` — fill what you +know and leave the rest empty rather than omitting the line. Read contacts back with +`_officer/contacts?collection=…`, which returns `fullName`, `emails` and `phones` already parsed. + +## Confirm in one line, in their words + +"Added — Dinner with my wife, Saturday 29 August, 20:00, Cervejaria Ramiro." Local time, the name they +used, and where. Not the UID, not the UTC, not the HTTP status. + +If a write fails, say what failed and stop. **Never retry a PUT with a new UID** — the usual cause of a +failure is that the first one partly worked, and a retry leaves two.