headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
4.3 KiB
TypeScript
105 lines
4.3 KiB
TypeScript
import type { OfficerContext } from './routes';
|
|
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
|
|
import { activeClient } from './active';
|
|
import { toUser, toNode, arrayField, type OfficerUser } from './normalize';
|
|
|
|
// User routes — /_officer/users/*. A Headscale "user" is a namespace that owns nodes and pre-auth keys.
|
|
//
|
|
// The list is enriched with a node count, which the admin API does not provide: deleting a user takes its
|
|
// nodes with it, so "3 nodes" next to the delete button is the difference between an informed action and a
|
|
// surprise. That is one extra upstream call for the whole list, not one per user.
|
|
|
|
export type UserWithCounts = OfficerUser & { nodeCount: number; onlineCount: number };
|
|
|
|
async function listUsers(ctx: OfficerContext): Promise<Response> {
|
|
const client = await activeClient(ctx.userId);
|
|
if (client instanceof Response) return client;
|
|
|
|
const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]);
|
|
|
|
const nodes = arrayField(nodeBody, 'nodes').map(toNode);
|
|
const counts = new Map<string, { total: number; online: number }>();
|
|
for (const node of nodes) {
|
|
const id = node.user?.id;
|
|
if (!id) continue;
|
|
const entry = counts.get(id) ?? { total: 0, online: 0 };
|
|
entry.total += 1;
|
|
if (node.online) entry.online += 1;
|
|
counts.set(id, entry);
|
|
}
|
|
|
|
const users: UserWithCounts[] = arrayField(userBody, 'users')
|
|
.map(toUser)
|
|
.filter((u): u is OfficerUser => !!u)
|
|
.map((u) => ({ ...u, nodeCount: counts.get(u.id)?.total ?? 0, onlineCount: counts.get(u.id)?.online ?? 0 }))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return Response.json({ users });
|
|
}
|
|
|
|
async function createUser(ctx: OfficerContext): Promise<Response> {
|
|
const client = await activeClient(ctx.userId);
|
|
if (client instanceof Response) return client;
|
|
|
|
const body = await readJson(ctx.req);
|
|
if (!body) return badRequest('expected a JSON body');
|
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
if (!name) return badRequest('name is required');
|
|
|
|
const created = await client.call<{ user?: Record<string, unknown> }>('/api/v1/user', {
|
|
method: 'POST',
|
|
body: {
|
|
name,
|
|
displayName: typeof body.displayName === 'string' ? body.displayName.trim() : undefined,
|
|
email: typeof body.email === 'string' ? body.email.trim() : undefined,
|
|
},
|
|
});
|
|
return Response.json({ user: toUser(created.user) }, { status: 201 });
|
|
}
|
|
|
|
type UserActionParams = { ctx: OfficerContext; id: string; action: string | undefined };
|
|
|
|
async function handleUserAction({ ctx, id, action }: UserActionParams): Promise<Response> {
|
|
const client = await activeClient(ctx.userId);
|
|
if (client instanceof Response) return client;
|
|
|
|
if (action === 'rename') {
|
|
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
|
const body = await readJson(ctx.req);
|
|
if (!body) return badRequest('expected a JSON body');
|
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
if (!name) return badRequest('name is required');
|
|
// Rename takes both the id and the new name in the path — encode or a '/' becomes a routing accident.
|
|
const renamed = await client.call<{ user?: Record<string, unknown> }>(
|
|
`/api/v1/user/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`,
|
|
{ method: 'POST' },
|
|
);
|
|
return Response.json({ user: toUser(renamed.user) });
|
|
}
|
|
|
|
if (action !== undefined) return notFound();
|
|
|
|
if (ctx.req.method === 'DELETE') {
|
|
// Headscale refuses to delete a user that still owns nodes, with a message the UI relays verbatim.
|
|
await client.call(`/api/v1/user/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
/** Dispatch `/_officer/users/...`. `rest` is the path after `users`. */
|
|
export async function handleUsersRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
|
if (rest.length === 0) {
|
|
if (ctx.req.method === 'GET') return listUsers(ctx);
|
|
if (ctx.req.method === 'POST') return createUser(ctx);
|
|
return methodNotAllowed();
|
|
}
|
|
if (rest.length > 2) return notFound();
|
|
|
|
const id = rest[0];
|
|
if (!id || !/^\d+$/.test(id)) return badRequest('user id must be numeric');
|
|
|
|
return handleUserAction({ ctx, id, action: rest[1] });
|
|
}
|