agents can manage the calendar and the address book

the owner's scenario: any chat, "I'm having dinner Saturday with my wife, add
that", "cancel it", "add Peter Griffin, this number" — and it happens.

reading already worked through the JSON api. writing is the DAV door, and the
credential question is what took the thinking: every option meant a NEW secret
and then somewhere to keep it — a file, the secret store, one per commander —
each 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. so the DAV door accepts it as the
basic-auth password, and no new secret exists anywhere.

it cannot reach another account, and that is structural rather than a check:
resolveApiKey says whose the key is and that id becomes X-Remote-User. the URL
is never consulted, radicale refuses any other principal, and the username in
the header must AGREE with the key rather than select the account. verified —
own principal 207/201, another account 403, spoofed username 401.

the permission check is isApiRequestAllowed against this plugin's own prefix, so
the answer is exactly what the gate would give the same credential at
/api/caldav. a second copy of that rule is the copy that stops being updated.
DAV methods are mapped to read/write with PROPFIND and REPORT as reads —
unmapped they counted as writes and a member with read could not list their own
calendar. anything unrecognised defaults to write, which is the safe direction.

the skill is written from recipes that were RUN, not recalled: create 201, move
204 (same UID, same href, SEQUENCE bumped), delete 200, contact create and
delete, and the json read for lookups.

the trap it exists to prevent, found while testing rather than by reading:
`date -u -d 'saturday 20:00'` does not convert. -u parses AND prints as UTC, so
8pm becomes 20:00Z, which is 21:00 in lisbon. every event an hour out, nothing
errors, and it is invisible until someone is stood up. the two-step through
epoch is the only form that works and the skill leads with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 06:08:25 +00:00
co-authored by Claude Opus 5
parent 622dfa3602
commit a161e2ac5f
5 changed files with 322 additions and 13 deletions
+36 -3
View File
@@ -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<T>(ctx: Context): Promise<T> {
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,
);
+46 -7
View File
@@ -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) => ` <dict>
@@ -80,12 +108,12 @@ function buildPlist(params: ProfileParams): 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>
<!-- PayloadScope is deliberately absent: Apple documents it for macOS only, and iOS rejected this
profile outright with "Invalid Profile" while it was present. Removed 2026-08-27. -->
<key>PayloadRemovalDisallowed</key><false/>
<key>PayloadContent</key>
<array>
${account('CalDAV', 'Officer Calendar')}
${account('CardDAV', 'Officer Contacts')}
${kinds.map((k) => account(k, k === 'CalDAV' ? 'Officer Calendar' : 'Officer Contacts')).join('\n')}
</array>
</dict>
</plist>
@@ -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;
}
+13 -1
View File
@@ -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
+72 -2
View File
@@ -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<number | null> {
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();
}