caldav/carddav: officer-caldav sidecar and the /dav door
first two steps of docs/nextcloud-replacement.md — the half that has to work on
a phone, because that is the half that cannot be faked.
radicale is supervised by the sidecar rather than reimplemented. nextcloud does
not implement caldav either; it vendors sabre/dav. icalendar and vcard are a
weekend, but sync-collection, rrule expansion, vtimezone and ctag/etag are not,
and when they are subtly wrong a phone does not error — it silently stops
syncing, or silently duplicates every event.
two doors, because a browser should not speak dav:
/dav/* top-level, http basic against a scoped app password, every
verb and every dav header forwarded verbatim. this is what
davx5 and ios talk to. same reasoning as /api/vault being
mounted outside protectedRouter.
/api/caldav/* the ordinary sidecar proxy, for officer's own ui. json.
the shared proxy factory could not carry the dav door: it forwards three
headers and dav dies without Depth, and it derives the user from a jwt a phone
cannot hold. so it is a separate file, per that factory's own instruction never
to grow per-app logic.
new `dav_app_passwords` — a phone cannot do jwt, and the alternative is the
account password living in a phone's account manager. argon2, shown once,
revocable per device, and accepted ONLY by /dav.
.well-known/caldav and carddav redirect to the dav root. they are most of what
makes adding an account feel transparent, and they need naming explicitly in
server.tsx or the SPA `/*` fallback answers the phone with html.
verified end to end against the running stack: 401 + WWW-Authenticate
unauthenticated; 207 with calendar-access and addressbook advertised; MKCALENDAR,
PUT and GET of a real VEVENT; calendar-query and sync-collection REPORTs; MKCOL,
PUT and GET of a real vCard. X-Script-Name is set because radicale otherwise
generates hrefs at / and the client follows them into the SPA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb';
|
||||
import * as errors from '../../custom-errors';
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
Reference in New Issue
Block a user