Files
caldav/api/sync-router.ts
pastilhasandClaude Opus 5 a161e2ac5f 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
2026-08-27 06:08:25 +00:00

215 lines
9.1 KiB
TypeScript

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';
// The DAV door: `/dav/*`, mounted TOP-LEVEL, outside protectedRouter.
//
// WHY IT IS NOT UNDER /api AND NOT BEHIND userMiddleware. DAVx5, iOS Calendar, macOS and Thunderbird
// speak CalDAV over HTTPS with HTTP Basic auth on every single request. They cannot log into Officer,
// cannot hold a 30-day JWT, cannot refresh one and cannot answer a passkey challenge. This is the same
// situation `/api/vault` is in — "the Bitwarden client carries its own bearer token, not a platform
// session JWT, so userMiddleware would 401 it" (hono.ts) — with a different credential type.
//
// The credential is a DAV app password (`dav_app_passwords`), scoped to this mount and nothing else.
// An app password is NOT a session: userMiddleware must never learn to accept one, or a device that
// syncs a calendar would also reach the wallet.
//
// Everything is forwarded verbatim to the caldav sidecar, which forwards it to Radicale. The platform
// authenticates and moves bytes; it does not parse a single line of iCalendar. That restraint is the
// same one create-proxy.ts documents, and for the same reason.
export const davSyncRouter = createRouter();
const unauthorized = () =>
new Response('Unauthorized', {
status: 401,
// Without this header a DAV client will not prompt for credentials at all — it just fails. The
// realm string is what the phone shows in its password dialog.
headers: { 'WWW-Authenticate': 'Basic realm="Officer DAV", charset="UTF-8"' },
});
type BasicCreds = { username: string; password: string };
function parseBasic(header: string | undefined): BasicCreds | null {
if (!header?.startsWith('Basic ')) return null;
let decoded: string;
try {
decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
} catch {
return null;
}
// Split on the FIRST colon only — a generated password never contains one, but an email local-part
// legally can, and splitting greedily would break those accounts in a way that looks like a wrong
// password.
const at = decoded.indexOf(':');
if (at < 0) return null;
return { username: decoded.slice(0, at), password: decoded.slice(at + 1) };
}
// Same list the sidecar forwards, and for the same reason: `Depth` decides whether PROPFIND returns a
// collection, its children or the whole tree, so dropping it makes every calendar look empty.
const REQUEST_HEADERS = [
'depth',
'content-type',
'content-length',
'if',
'if-match',
'if-none-match',
'destination',
'overwrite',
'lock-token',
'timeout',
'prefer',
'user-agent',
] as const;
const RESPONSE_HEADERS = [
'content-type',
'dav',
'allow',
'etag',
'last-modified',
'location',
'lock-token',
'preference-applied',
'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();
// A credential-less request is the normal opening move — the client is asking to be challenged — so it
// is not worth a line. A credential that is present and wrong is the interesting case, and it is
// indistinguishable from the normal one at the client end: both just say "password incorrect".
const user = await getUserByEmail(creds.username);
if (!user) {
console.warn(`[dav] auth failed: no such user ${creds.username}`);
return unauthorized();
}
const userId = (await verifyDavAppPassword(user.id, creds.password)) ?? (await agentKeyUser(ctx, user, creds.password));
if (!userId) {
console.warn(`[dav] auth failed: bad credential for ${creds.username}`);
return unauthorized();
}
const base = getCaldavUrl();
if (!base) return new Response('caldav sidecar not available', { status: 503 });
const url = new URL(ctx.req.url);
// The sidecar mounts DAV at /dav too, so the prefix is preserved rather than stripped — Radicale
// generates absolute hrefs in its multistatus responses, and if the path the client sees differs
// from the path the server thinks it is at, every href points somewhere that 404s.
const target = `${base}${url.pathname}${url.search}`;
const headers: Record<string, string> = {};
for (const name of REQUEST_HEADERS) {
const value = ctx.req.header(name);
if (value) headers[name] = value;
}
headers['X-Officer-User'] = String(userId);
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(target, {
method,
headers,
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
});
} catch (err) {
console.error('[dav] proxy fetch failed', { path: url.pathname, error: String(err) });
return new Response('caldav sidecar unreachable', { status: 502 });
}
const out = new Headers();
for (const name of RESPONSE_HEADERS) {
const value = upstream.headers.get(name);
if (value) out.set(name, value);
}
// One line per request, with the client's own name for itself. A DAV client tells you almost nothing
// when it fails — iOS in particular reports every setup failure as "Cannot connect using SSL" — so the
// only way to know whether a phone ever asked for an address book is to record that it did. Cheap:
// a sync is a handful of requests, and a body is never logged.
console.log(
`[dav] ${method} ${url.pathname} -> ${upstream.status} (user=${userId}, ua=${(ctx.req.header('user-agent') ?? '-').slice(0, 60)})`,
);
return new Response(upstream.body, { status: upstream.status, headers: out });
});