import type { HeadscaleServerCredentials } from 'officerdb'; import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes'; import { activeCreds, callCompanion, readBody, unavailable } from './companion'; // Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use // invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth // key it never had to be told. // // WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining // phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an // ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when // the platform is down, because the tailnet is often how you reach the platform. So the invite records, the // token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what // the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke. // // Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond // the moment it is handed to the admin." The create response passes through this process once, in memory, // on its way to the browser — that is the whole of its life here. // // A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other // companion route: most registered servers have no companion at all, and that is a state to render rather // than a request that failed. /** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */ const DEFAULT_TTL_SECONDS = 900; const MIN_TTL_SECONDS = 60; const MAX_TTL_SECONDS = 86_400; type CreateInput = { user: string; ttlSeconds: number; ephemeral: boolean; tags: string[]; note?: string; }; /** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */ function parseCreate(body: Record | null): CreateInput | Response { const user = typeof body?.user === 'string' ? body.user.trim() : ''; if (!user) return badRequest('user is required — an invite files the joining device under one Headscale user'); const raw = body?.ttlSeconds; const ttlSeconds = raw === undefined || raw === null ? DEFAULT_TTL_SECONDS : Number(raw); if (!Number.isInteger(ttlSeconds) || ttlSeconds < MIN_TTL_SECONDS || ttlSeconds > MAX_TTL_SECONDS) { return badRequest(`ttlSeconds must be an integer between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS}`); } // Tags are admin-set and passed through opaquely (spec §9.2). The `tag:` prefix is Headscale's, and // adding it here means the admin can type either form without minting a key that silently has no tag. const tags = Array.isArray(body?.tags) ? [ ...new Set( body.tags .filter((t): t is string => typeof t === 'string') .map((t) => t.trim()) .filter(Boolean) .map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)), ), ] : []; const note = typeof body?.note === 'string' ? body.note.trim().slice(0, 200) : ''; return { user, ttlSeconds, ephemeral: body?.ephemeral === true, tags, ...(note ? { note } : {}) }; } /** * Turn a companion answer into ours. * * The three cases are distinct and the UI needs them to stay that way: unreachable is `available: false` * (render an explanation), a companion refusal keeps its own status and message (the admin typed something * the server rejected), and success is the body with `available: true` on it. */ async function relay(res: Response | string, wrap: (body: Record) => unknown): Promise { if (typeof res === 'string') return Response.json(unavailable(res)); const body = await readBody(res); if (typeof body === 'string') return Response.json(unavailable(body)); if (!res.ok) { const error = typeof body.error === 'string' ? body.error : `the companion returned ${res.status}`; return Response.json( { error, code: typeof body.code === 'string' ? body.code : undefined }, { status: res.status }, ); } return Response.json(wrap(body)); } /** `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once. */ async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise { const input = parseCreate(await readJson(ctx.req)); if (input instanceof Response) return input; const res = await callCompanion(creds, { path: '/enroll/invites', method: 'POST', body: input }); return relay(res, (body) => ({ available: true, invite: body.invite ?? body })); } /** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */ async function list(creds: HeadscaleServerCredentials): Promise { const res = await callCompanion(creds, { path: '/enroll/invites' }); return relay(res, (body) => ({ available: true, invites: Array.isArray(body.invites) ? body.invites : [] })); } /** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */ async function revoke(creds: HeadscaleServerCredentials, id: string): Promise { const res = await callCompanion(creds, { path: `/enroll/invites/${encodeURIComponent(id)}`, method: 'DELETE' }); return relay(res, (body) => ({ available: true, ...body })); } /** Dispatch `/_officer/enroll/invites...`. Acts on the ACTIVE server, like every other domain route. */ export async function handleInvitesRoute(ctx: OfficerContext, rest: string[]): Promise { const creds = await activeCreds(ctx.userId); if (creds instanceof Response) return creds; const [id, extra] = rest; if (extra) return notFound(); if (!id) { if (ctx.req.method === 'POST') return create(creds, ctx); if (ctx.req.method === 'GET') return list(creds); return methodNotAllowed(); } if (ctx.req.method !== 'DELETE') return methodNotAllowed(); return revoke(creds, id); }