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
+18
View File
@@ -245,6 +245,24 @@ invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to O
- **phone → Companion** — untouched by anything here
- **web admin → Officer → sidecar** — ours to rename freely
### There are THREE components, not two
Easy to miss, and worth stating because two of them contain the word "enroll":
| Component | Repo | Enrolment surface |
| ---------------- | ---------------------------- | ------------------------------------------------ |
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
in the invite (`https://<domain>/officer-api`).
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
`POST /_officer/enroll` handler below it is dead.
-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) });
});
+11 -10
View File
@@ -236,16 +236,17 @@ export const CAPABILITIES: Capability[] = [
api: [],
routes: ['/invoices'],
},
{
key: 'vpn',
label: 'VPN',
description: 'Enrol your own devices on the tailnet',
kind: 'app',
api: ['/vpn'],
// Minting a pre-auth key for your own device is the entire point of the capability, and the key is
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
personal: ['/'],
},
// `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale,
// minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone
// OffScale app gates it on `embedded`, which it never sets, and it never will — the app is permanently
// independent of the platform, since the thing that gets you to the platform cannot itself need it.
//
// Device enrolment did not go away, it moved out. A phone claims an invite from the Companion at
// `${invite.base}/api/v1/enroll/claim`, which is a different component entirely and does not involve
// Officer. Confirmed against the mobile monorepo and the companion repo before removal.
//
// What this does cost: `headscale` is `admin`, so with `vpn` gone no member-grantable headscale surface
// remains. Reintroduce one here if members ever need to enrol their own devices through Officer.
// Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is
// the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`,
// `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it
-2
View File
@@ -30,7 +30,6 @@ import { headscaleRouter } from './api/headscale/router';
// import { jellyfinRouter } from './api/jellyfin/router';
// import { photosRouter } from './api/photos/router';
// import { walletRouter } from './api/wallet/router';
import { vpnRouter } from './api/vpn/router';
import { terminalRouter } from './api/terminal/sidecar-server';
// import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
@@ -214,7 +213,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
['/vpn', vpnRouter],
['/system-monitor', systemMonitorRouter],
['/activity', activityRouter],
['/dock', dockRouter],
+2 -1
View File
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
//
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
// HEADSCALE_URL, HEADSCALE_API_KEY
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
+5 -2
View File
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
// DELETE /_officer/keys/:id delete outright
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
// for a joining device. userId is only required when the server
// has more than one user; reached via /api/vpn/enroll.
// has more than one user.
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
// which is deleted. Kept because it is the handler a route under
// /api/offscale would reuse, and because `/enroll/invites` — which
// IS live — dispatches through the same function.
// anything else 404
//
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });