import type { Context } from 'hono'; 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(); /** * 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) }); }); appPasswordsRouter.post('/passwords', async (ctx) => { const user = ctx.get('user'); const body = await jsonBody<{ label?: string }>(ctx); 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 = await jsonBody<{ deviceLabel?: string; only?: 'caldav' | 'carddav' }>(ctx); 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: 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, ); 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 }); });