delete /api/vpn

it had no caller. verified three ways before removing: nothing in the mobile
monorepo reaches it (enrollVpn's only call site is behind `if (embedded)`, and
the one app rendering VpnScreen never passes embedded), nothing in the officer
web app references it, and the live database holds no vpn grants. the companion
was checked separately by its own author — zero references there either.

and it will not come back. offscale is permanently standalone: the thing that
gets you to the platform cannot itself need the platform, or a broken tailnet
locks you out of both.

gone: api/vpn/router.ts, its mount, and the `vpn` capability. the registry keeps
a comment where the capability was, because its removal has a cost worth
recording — headscale is admin-only, so no member-grantable headscale surface
remains, and reintroducing one is a deliberate act rather than an oversight.

kept: the sidecar's enroll.ts. its bare POST /_officer/enroll handler is now
unreachable, but the file is also the dispatcher for /enroll/invites, which is
live and fundamental. the header comment now says so, so nobody deletes it
looking for dead code.

also records the third component in the doc. two of the three have an "enroll"
surface and only one is ours: /api/v1/enroll/* belongs to the companion, is
where the phone actually goes, and must not be collapsed into /api/offscale/*.

capabilities tests: 17 pass / 8 fail both before and after, stash-verified — the
8 are pre-existing, in totality and path-to-capability, which is precisely the
machinery dynamic mounting will rework.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 17:40:44 +00:00
co-authored by Claude Opus 5
parent bbc60b34ac
commit 88a44ec4a7
6 changed files with 36 additions and 63 deletions
-48
View File
@@ -1,48 +0,0 @@
import { createRouter } from '../../create-router';
import { getHeadscaleServerUrl } from '../headscale/router';
// Enrollment for OffTail, the in-app Tailscale. Authenticate the owner, forward to officer-headscale, and
// hold no Headscale knowledge whatsoever — no URL, no admin key, no user name.
//
// This file used to mint the pre-auth key itself, from HEADSCALE_URL / HEADSCALE_API_KEY / HEADSCALE_USER
// read out of the host env. Three globals describe exactly one server; Officer keeps a registry of many in
// `headscale_servers`, one active at a time, so the env could contradict the server the owner had selected.
// The two credential vars were later removed and the failure was silent — `if (!base || !apiKey)` returned
// 503 before the rest of the route ever ran, so enrollment had simply stopped working and said nothing.
// The logic now lives in the sidecar that owns the registry (src/servers/sidecar/headscale/enroll.ts).
//
// It stays mounted at /api/vpn rather than moving under /api/headscale because the path is a contract:
// enrollVpn() in the mobile core POSTs exactly /api/vpn/enroll. createSidecarProxy strips its own prefix
// and cannot express that rewrite, so this one forward is spelled out by hand.
export const vpnRouter = createRouter();
// POST /api/vpn/enroll → { controlUrl, authKey }
//
// The response shape is the other half of the contract: enrollVpn() in @officer/core destructures exactly
// those two fields, so changing them means changing the mobile app too.
vpnRouter.post('/enroll', async (ctx) => {
const baseUrl = getHeadscaleServerUrl();
if (!baseUrl) return ctx.json({ error: 'headscale sidecar not available' }, 503);
// Forwarded verbatim: an optional {userId} picks the owning Headscale user when the server has several.
const body = await ctx.req.arrayBuffer();
let upstream: Response;
try {
upstream = await fetch(`${baseUrl}/_officer/enroll`, {
method: 'POST',
headers: {
'content-type': ctx.req.header('content-type') ?? 'application/json',
// The authenticated owner. The sidecar binds loopback only, so its presence is the trust signal.
'X-Officer-User': String(ctx.get('user').id),
},
body: body.byteLength ? body : undefined,
});
} catch (err) {
console.error('[vpn] headscale sidecar unreachable:', err);
return ctx.json({ error: 'headscale sidecar unreachable' }, 502);
}
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
});