Files
platform/plugins/offscale/sidecar/users.ts
T
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
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>
2026-08-15 00:15:38 +00:00

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] });
}