Files
caldav/api/router.ts
T
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

95 lines
4.6 KiB
TypeScript

import { createRouter } from '@@/create-router';
import { caldavRouter } from './sidecar-server';
import { appPasswordsRouter } from './app-passwords';
import { davSyncRouter } from './sync-router';
import { claimIosProfile } from './ios-profile';
/**
* The plugin's two doors, and why it needs two.
*
* ── `router` — the ordinary one ──
*
* Mounted at `/api/caldav`, behind the account gate and the permission gate like every other plugin. It
* carries the JSON door the Calendar and Contacts screens read (`/_officer/*`, proxied to the sidecar)
* and the app-password management the modal uses.
*
* `/passwords` used to be `/api/dav/passwords`, a second top-level API prefix the platform granted this
* one feature. A plugin gets one prefix, which is the rule that stops a plugin spreading across the API
* surface — so it moved under this one. The UI moved with it.
*
* ── `publicRouter` — the one that exists because phones are not browsers ──
*
* Mounted at the top-level paths claimed in the manifest, ABOVE the account gate. That is not a weakening:
* DAVx5, iOS and Thunderbird send HTTP Basic on every request and have nowhere to put a platform JWT, so
* `userMiddleware` would 401 every real client. `sync-router` demands its own credential — a scoped DAV
* app password, resolved to a user, and Radicale is then told which principal that is.
*/
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);
/**
* Mounted at EVERY path the manifest claims — `/dav`, `/.well-known/caldav`, `/.well-known/carddav` — so
* it has to work out which one it is answering rather than assume.
*
* The platform mounts one router per claim and hono strips the prefix, so a request to
* `/.well-known/caldav` arrives here as `/`, indistinguishable from `/dav/` unless the original path is
* consulted. `ctx.req.path` is the whole path, before stripping.
*/
export const publicRouter = createRouter();
/*
Autodiscovery. Most of what makes adding an account on a phone feel transparent instead of fiddly: the
client is given a bare domain and probes these UNAUTHENTICATED, before it has any credential.
`.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually
wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real
client while looking perfectly healthy in a browser.
*/
publicRouter.all('*', async (ctx, next) => {
if (!ctx.req.path.startsWith('/.well-known/')) return next();
return ctx.redirect('/dav/', 301);
});
/*
The iOS profile download, BEFORE the sync 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/caldav/provision/ios`.
*/
publicRouter.get('/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',
},
});
});
publicRouter.route('/', davSyncRouter);