import { createRouter } from '../../create-router'; import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb'; 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 davRouter = createRouter(); davRouter.get('/passwords', async (ctx) => { const user = ctx.get('user'); return ctx.json({ passwords: await listDavAppPasswords(user.id) }); }); davRouter.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. davRouter.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.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); }); davRouter.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 }); }); davRouter.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 }); });