calendar and contacts, extracted from the officer platform

one sidecar, one permission, one install — and two screens, because Calendar and
Contacts are two places you go rather than two sections of one page. the first
plugin to declare `screens`, which the platform gained for this.

what moved: the radicale sidecar, the four routers, the panel apps, the
app-password table and the settings form. the form became a modal on the
calendar screen — a plugin ships panels and cannot contribute a settings
section, which is the same wall the email plugin hit and the same answer.

the routes are /calendar and /contacts, not /caldav/calendar. they were core
routes and keeping them is not cosmetic: a bookmark, a dock tile someone
arranged, and a persisted layout all address them. only a first-party plugin may
choose its own segment; a third-party one is confined to /p/<publisher>/.

four paths outside /api come with it, above the account gate, because phones are
not browsers: DAVx5, iOS and thunderbird send HTTP basic on every request and
have nowhere to put a JWT. that is not "open" — the sync door demands a scoped
app password and resolves it to one user before radicale sees anything.

two things deliberately unchanged. the table is still dav_app_passwords, not
caldav_app_passwords: installs hold real rows and a naming convention is not
worth a data migration on a live machine. and the panel names still resolve
through the platform's catalogs, so the six locales they had as core screens
survive rather than dropping to english to make the refactor tidier.

per-user was already true and is worth writing down because the config said
otherwise: the sync door resolves an app password to an account, tells radicale
that id is the principal, and owner_only does the rest. the comment claiming
"the owner is the only principal that exists" had been stale since multi-user
landed on 2026-08-07.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 04:53:45 +00:00
co-authored by Claude Opus 5
commit dcbc9b102c
30 changed files with 2362 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import { createRouter } from '@@/create-router';
import {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
} from '../db/queries';
import * as errors from '@@/custom-errors';
import { stashIosProfile } from './ios-profile';
// Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else
// under /api — this is the owner administering their devices from a logged-in browser, which is a
// completely different act from a phone syncing (that is /dav, see sync-router.ts).
//
// The plaintext credential exists for exactly one response and is never stored, so POST is the only
// place it appears. There is deliberately no "show me it again" endpoint: if it is lost, revoke the
// row and mint another. That is cheaper than any design where the secret can be read back.
export const appPasswordsRouter = createRouter();
appPasswordsRouter.get('/passwords', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
});
appPasswordsRouter.post('/passwords', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { label?: string } | undefined;
const label = body?.label?.trim();
if (!label) throw errors.BAD_REQUEST('label is required');
const { entry, password } = await createDavAppPassword(user.id, label);
// `password` is returned once, here. Nothing else in the system can produce it again.
return ctx.json({ entry, password, username: user.email });
});
// One-tap iOS setup: mint a credential, wrap it in a configuration profile, hand back a URL for the app
// to open in Safari. See docs/mobile-dav-provisioning.md §3, and ios-profile.ts for why the profile is
// held in memory behind a one-shot token rather than stored.
//
// This is the only endpoint that returns a route to a live password instead of the password itself. It
// 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 deviceLabel = body?.deviceLabel?.trim();
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
// The profile has to name a host, and PUBLIC_URL is the only place that knows the one a phone can
// actually reach — the request's own Host header is whatever the reverse proxy forwarded.
const publicUrl = process.env.OFFICERDEV_PUBLIC_URL ?? process.env.PUBLIC_URL;
if (!publicUrl) throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not set; cannot build a profile');
let url: URL;
try {
url = new URL(publicUrl);
} catch {
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not a valid URL');
}
if (url.protocol !== 'https:') {
// CalDAVUseSSL is hardcoded true in the payload, so an http PUBLIC_URL would produce a profile that
// cannot work. Better to say so than to ship a phone an account that silently never syncs.
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL must be https to provision a device');
}
const { entry, password } = await createDavAppPassword(user.id, deviceLabel);
const stored = await stashIosProfile(
{
host: url.hostname,
port: Number(url.port) || 443,
username: user.email,
password,
// 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',
},
url.origin,
);
console.log(`[dav] provisioned ios profile for "${deviceLabel}" (password #${entry.id}, signed=${stored.signed})`);
return ctx.json(stored);
});
appPasswordsRouter.post('/passwords/:id/revoke', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
if (!(await revokeDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
return ctx.json({ ok: true });
});
appPasswordsRouter.delete('/passwords/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
if (!(await deleteDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
return ctx.json({ ok: true });
});
+189
View File
@@ -0,0 +1,189 @@
import { randomUUID, randomBytes } from 'node:crypto';
// Generates the iOS configuration profile that sets up CalDAV + CardDAV in one install, and holds it
// against a single-use token until Safari fetches it.
//
// WHY A TOKEN AND NOT A SESSION. The profile is fetched by Safari, which has no platform JWT — the app
// hands the URL to the OS browser and loses all control of the request. So the token IS the credential.
// It is therefore minted long, used once, and dropped after five minutes, because the thing it protects
// is a live DAV password sitting in a file on a public endpoint. A profile URL that stays valid is a
// password that stays valid.
//
// WHY IN MEMORY. The profile contains the app password in PLAINTEXT — it has to, that is what makes the
// install one-tap. `createDavAppPassword` promises the plaintext "is not stored", and writing the
// profile to Postgres or to disk would quietly make that false. A process restart inside the five-minute
// window costs the owner one extra tap; persisting the secret costs the guarantee.
//
// See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from.
const TTL_MS = 5 * 60_000;
// A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint.
const MAX_PENDING = 32;
type PendingProfile = { body: Uint8Array; expiresAt: number };
const pending = new Map<string, PendingProfile>();
const sweep = () => {
const now = Date.now();
for (const [token, entry] of pending) if (entry.expiresAt <= now) pending.delete(token);
};
const escapeXml = (value: string) =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
type ProfileParams = {
host: string;
port: number;
username: string;
password: string;
/** The DAV principal, e.g. `/dav/1/`. Both payloads point at it and let the client discover the rest. */
principalUrl: string;
/** What the user sees as the account name in Settings. */
accountName: string;
};
// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
// CardDAV separately with the same pair gets folded into the existing CalDAV account and contacts
// 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;
// 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 e = escapeXml;
const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` <dict>
<key>PayloadType</key><string>com.apple.${kind.toLowerCase()}.account</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadIdentifier</key><string>${identifier}.${kind.toLowerCase()}</string>
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
<key>PayloadDisplayName</key><string>${e(label)}</string>
<key>${kind}AccountDescription</key><string>${e(accountName)}</string>
<key>${kind}HostName</key><string>${e(host)}</string>
<key>${kind}Port</key><integer>${port}</integer>
<key>${kind}UseSSL</key><true/>
<key>${kind}Username</key><string>${e(username)}</string>
<key>${kind}Password</key><string>${e(password)}</string>
<key>${kind}PrincipalURL</key><string>${e(principalUrl)}</string>
</dict>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadIdentifier</key><string>${identifier}</string>
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</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>
<key>PayloadRemovalDisallowed</key><false/>
<key>PayloadContent</key>
<array>
${account('CalDAV', 'Officer Calendar')}
${account('CardDAV', 'Officer Contacts')}
</array>
</dict>
</plist>
`;
}
// Signing is OPT-IN and off by default, because this machine has no TLS certificate on it — TLS
// terminates upstream. An unsigned profile installs exactly the same way; the only difference is that
// the install screen says "Not Signed" in red instead of naming the signer.
//
// Point these at a cert whose chain iOS already trusts (a public CA — the TLS cert of the reverse proxy
// works, iOS checks the trust chain and not the key usage) and profiles are signed from then on:
// DAV_PROFILE_SIGN_CERT leaf certificate, PEM
// DAV_PROFILE_SIGN_KEY its private key, PEM
// DAV_PROFILE_SIGN_CHAIN intermediates, PEM (iOS ships roots, not intermediates — without this the
// signature cannot be verified and shows as Not Verified)
//
// Signed at mint time, reading the cert from disk on every call, so a renewed certificate is picked up
// with no restart and no renewal hook — the failure mode the mobile team flagged cannot happen here.
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
// old one is removed. Nothing detects that for you.
const signingConfig = () => {
const cert = process.env.DAV_PROFILE_SIGN_CERT;
const key = process.env.DAV_PROFILE_SIGN_KEY;
if (!cert || !key) return null;
return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN };
};
let warnedUnsigned = false;
async function sign(plist: string): Promise<Uint8Array | null> {
const config = signingConfig();
if (!config) {
if (!warnedUnsigned) {
warnedUnsigned = true;
console.warn(
'[dav] serving UNSIGNED iOS profiles — iOS will show "Not Signed" in red. Set DAV_PROFILE_SIGN_CERT/KEY to sign.',
);
}
return null;
}
// Piped through stdin/stdout rather than temp files: the plist holds a live password in plaintext and
// there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
// load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
// the file is a signature with no profile embedded in it.
const args = ['smime', '-sign', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach'];
if (config.chain) args.push('-certfile', config.chain);
args.push('-md', 'sha256');
const proc = Bun.spawn(['openssl', ...args], {
stdin: new TextEncoder().encode(plist),
stdout: 'pipe',
stderr: 'pipe',
});
const [out, err, code] = await Promise.all([
new Response(proc.stdout).arrayBuffer(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (code !== 0 || out.byteLength === 0) {
// Deliberately not fatal. An unsigned profile still installs and still works; failing the whole
// provisioning request over a cosmetic signature would be the worse outcome.
console.error(`[dav] profile signing failed (exit ${code}), serving unsigned: ${err.trim().slice(0, 200)}`);
return null;
}
return new Uint8Array(out);
}
export type StoredProfile = { url: string; expiresAt: string; signed: boolean };
/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
sweep();
if (pending.size >= MAX_PENDING) throw new Error('too many pending profiles');
const plist = buildPlist(params);
const signed = await sign(plist);
const body = signed ?? new TextEncoder().encode(plist);
const token = randomBytes(32).toString('base64url');
const expiresAt = Date.now() + TTL_MS;
pending.set(token, { body, expiresAt });
return {
url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`,
expiresAt: new Date(expiresAt).toISOString(),
signed: signed !== null,
};
}
/** Take the profile for this token, if it is still valid. Consumes it — a second fetch gets nothing. */
export function claimIosProfile(token: string): Uint8Array | null {
sweep();
const entry = pending.get(token);
if (!entry) return null;
pending.delete(token);
return entry.body;
}
+82
View File
@@ -0,0 +1,82 @@
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();
// 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
* 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);
+17
View File
@@ -0,0 +1,17 @@
import { createSidecarProxy } from '@@/sidecar/create-proxy';
// The officer-caldav sidecar has TWO consumers in the platform and they need different things, so the
// port capture lives here and both import it.
//
// • `caldavRouter` — the ordinary JSON door for Officer's own UI. Behind userMiddleware, forwarded by
// the shared factory with `X-Officer-User`, exactly like every other sidecar.
// • `davSyncRouter` — the DAV door for phones. Cannot use the factory: it forwards only three headers
// (`create-proxy.ts:88`) and DAV dies without `Depth`, and it derives the user from a platform JWT
// that a DAV client has no way to hold.
//
// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app
// logic, so the DAV-specific half is a separate file rather than a branch inside it.
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav' });
export const caldavRouter = proxy.router;
export const getCaldavUrl = proxy.getHttpUrl;
+144
View File
@@ -0,0 +1,144 @@
import { createRouter } from '@@/create-router';
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;
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);
if (!userId) {
console.warn(`[dav] auth failed: bad app password 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 });
});