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>
This commit is contained in:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
@@ -1,10 +1,4 @@
export {
getAllRoleGrants,
getRoleGrants,
setRoleGrant,
revokeRoleGrant,
replaceRoleGrants,
} from './queries';
export { getAllRoleGrants, getRoleGrants, setRoleGrant, revokeRoleGrant, replaceRoleGrants } from './queries';
export type { RoleGrant } from './queries';
@@ -1,6 +1 @@
export {
appendChatEvent,
getChatEventsSince,
getLastChatEventSeq,
pruneChatEventsOlderThan,
} from './queries';
export { appendChatEvent, getChatEventsSince, getLastChatEventSeq, pruneChatEventsOlderThan } from './queries';
+4 -1
View File
@@ -43,7 +43,10 @@ export async function waitForDatabase(timeoutMs = 60_000): Promise<boolean> {
return true;
} catch (err) {
if (Date.now() - started >= timeoutMs) {
console.error(`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`, err instanceof Error ? err.message : err);
console.error(
`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`,
err instanceof Error ? err.message : err,
);
return false;
}
if (!announced) {
@@ -1,12 +0,0 @@
export {
listHeadscaleServers,
getActiveHeadscaleCredentials,
getHeadscaleCredentials,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
recordHeadscaleProbe,
} from './queries';
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries';
@@ -1,186 +0,0 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { headscaleServers } from './schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
// See ../crypto.ts and ../schema/headscale.ts.
//
// Two return types on purpose:
// HeadscaleServer — safe to serialize to the browser. Has NO api key field at all.
// HeadscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned
// by a route handler.
// The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column
// into every list response the moment someone forgot to strip it.
export type HeadscaleServer = {
id: number;
name: string;
url: string;
version: string | null;
sshHost: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
};
export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string };
const serverCols = {
id: headscaleServers.id,
name: headscaleServers.name,
url: headscaleServers.url,
version: headscaleServers.version,
sshHost: headscaleServers.sshHost,
isActive: headscaleServers.isActive,
lastSeenAt: headscaleServers.lastSeenAt,
createdAt: headscaleServers.createdAt,
};
/** Every server the owner has registered, active first then newest. Never includes the API key. */
export async function listHeadscaleServers(userId: number): Promise<HeadscaleServer[]> {
return db
.select(serverCols)
.from(headscaleServers)
.where(eq(headscaleServers.userId, userId))
.orderBy(desc(headscaleServers.isActive), desc(headscaleServers.createdAt));
}
/** The currently selected server with its key decrypted, or null when none is registered/active. */
export async function getActiveHeadscaleCredentials(userId: number): Promise<HeadscaleServerCredentials | null> {
const [row] = await db
.select()
.from(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
if (!row) return null;
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
}
/** One server's credentials by id — for probing a specific server rather than the active one. */
export async function getHeadscaleCredentials(userId: number, id: number): Promise<HeadscaleServerCredentials | null> {
const [row] = await db
.select()
.from(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
if (!row) return null;
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
}
type CreateHeadscaleServerParams = {
userId: number;
name: string;
url: string;
apiKey: string;
version: string | null;
/** Optional SSH target for the console. Null when the owner hasn't set one. */
sshHost: string | null;
/** Make it the active server. True for the first registration, so the UI is never left with none selected. */
activate: boolean;
};
/** Register a server. The key is encrypted before write; the returned row carries no key. */
export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise<HeadscaleServer> {
const { userId, name, url, apiKey, version, sshHost, activate } = params;
return db.transaction(async (tx) => {
if (activate) {
await tx
.update(headscaleServers)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
}
const [row] = await tx
.insert(headscaleServers)
.values({
userId,
name,
url,
apiKey: encryptSecret('headscale', apiKey),
version,
sshHost,
isActive: activate,
lastSeenAt: version ? new Date() : null,
})
.returning(serverCols);
return row!;
});
}
// `sshHost: null` clears the console target; omitting the field leaves it alone. The two must stay
// distinguishable, which is why this is `string | null` and not `string`.
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null };
/** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */
export async function updateHeadscaleServer(
userId: number,
id: number,
params: UpdateHeadscaleServerParams,
): Promise<HeadscaleServer | null> {
const set: Record<string, unknown> = { updatedAt: new Date() };
if (params.name !== undefined) set.name = params.name;
if (params.url !== undefined) set.url = params.url;
if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey);
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
const [row] = await db
.update(headscaleServers)
.set(set)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
.returning(serverCols);
return row ?? null;
}
/** Select a server. Clearing the others first keeps the one-active partial index satisfied. */
export async function setActiveHeadscaleServer(userId: number, id: number): Promise<HeadscaleServer | null> {
return db.transaction(async (tx) => {
await tx
.update(headscaleServers)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
const [row] = await tx
.update(headscaleServers)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
.returning(serverCols);
return row ?? null;
});
}
/**
* Drop a registration. If it was the active one, the newest survivor is promoted — otherwise deleting the
* active server would leave the UI with servers registered but none selected, which reads as "not
* configured" and is a confusing place to land.
*/
export async function deleteHeadscaleServer(userId: number, id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [deleted] = await tx
.delete(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
.returning({ id: headscaleServers.id, wasActive: headscaleServers.isActive });
if (!deleted) return false;
if (deleted.wasActive) {
const [next] = await tx
.select({ id: headscaleServers.id })
.from(headscaleServers)
.where(eq(headscaleServers.userId, userId))
.orderBy(desc(headscaleServers.createdAt))
.limit(1);
if (next) {
await tx
.update(headscaleServers)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(headscaleServers.id, next.id));
}
}
return true;
});
}
/** Record a successful reachability probe: the version observed and when we last reached the server. */
export async function recordHeadscaleProbe(userId: number, id: number, version: string | null): Promise<void> {
await db
.update(headscaleServers)
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
}
@@ -1,55 +0,0 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../auth/schema';
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
// toggles between them, so this is configuration the user creates at runtime rather than env vars.
//
// `api_key` is a Headscale *admin* credential — it can delete every node on a tailnet — so it is encrypted
// at rest via ../crypto.ts, exactly like the vault token set. Encryption/decryption is confined to
// queries/headscale.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
// email/integrations tables are debt to avoid copying, not a precedent to follow.
//
// Every table here is `headscale_`-prefixed and this file holds nothing else: when sidecars own their own
// schema it moves wholesale into src/servers/sidecar/headscale/ with no untangling. Only the
// officer-headscale sidecar reads or writes these tables.
export const headscaleServers = pgTable(
'headscale_servers',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
// Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator.
url: text('url').notNull(),
apiKey: text('api_key').notNull(), // encrypted
// Last version seen from the server's unauthenticated GET /version. Null until first probed; the
// literal 'dev' when the server was built without VCS info, which is unknown rather than too-old.
version: text('version'),
// Where to SSH for a shell on the box running this Headscale — the last-resort escape hatch for when the
// API cannot answer (headscale is down, the tailnet is down, the logs are the only evidence). Deliberately
// NOT derived from `url`: the whole point is to reach the machine when the control plane's own hostname
// stops resolving, so this is usually a raw IP on a different path. No port, user or key material — the
// connection uses whatever ~/.ssh already knows, so there is no credential here to protect.
sshHost: text('ssh_host'),
isActive: boolean('is_active').notNull().default(false),
// Last successful probe, so the UI can distinguish "never reached" from "was reachable, now isn't".
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// One registration per URL — re-registering the same server should be an edit, not a duplicate.
uniqueIndex('uq_headscale_servers_user_url').on(t.userId, t.url),
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction,
// but a bug there fails loudly here instead of silently leaving two servers active.
uniqueIndex('uq_headscale_servers_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);
-2
View File
@@ -34,14 +34,12 @@ export * from './auth';
export * from './capabilities';
export * from './chat-events';
export * from './dashboards';
export * from './headscale';
export * from './integrations';
export * from './pipeline-jobs';
export * from './server';
export * from './service-connections';
export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
@@ -60,7 +60,13 @@ export async function getActiveInvoiceshelfCredentials(userId: number): Promise<
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
return {
id: row.id,
label: row.label,
url: row.url,
token: decryptSecret('invoiceshelf', row.token),
companyId: row.companyId,
};
}
/** One account's credentials by id — for probing a specific account rather than the active one. */
@@ -70,7 +76,13 @@ export async function getInvoiceshelfCredentials(userId: number, id: number): Pr
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
return {
id: row.id,
label: row.label,
url: row.url,
token: decryptSecret('invoiceshelf', row.token),
companyId: row.companyId,
};
}
type CreateInvoiceshelfAccountParams = {
+1 -8
View File
@@ -14,11 +14,4 @@ export {
setPlaylistItems,
} from './queries';
export type {
FavoriteKind,
GroupedFavorites,
NowPlaying,
NowPlayingInput,
PlaylistSummary,
Playlist,
} from './queries';
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput, PlaylistSummary, Playlist } from './queries';
+1 -7
View File
@@ -1,9 +1,3 @@
export {
upsertPushDevice,
getPushDevices,
deletePushDevice,
recordPushFailure,
markPushDeviceSeen,
} from './queries';
export { upsertPushDevice, getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from './queries';
export type { PushDeviceSelect, PushDeviceInsert } from '../types';
@@ -69,8 +69,5 @@ export async function recordPushFailure(token: string): Promise<void> {
/** A send worked: clear the failure count and mark the device alive. */
export async function markPushDeviceSeen(token: string): Promise<void> {
await db
.update(pushDevices)
.set({ failureCount: 0, lastSeenAt: new Date() })
.where(eq(pushDevices.token, token));
await db.update(pushDevices).set({ failureCount: 0, lastSeenAt: new Date() }).where(eq(pushDevices.token, token));
}
@@ -158,7 +158,10 @@ export async function deletePhotosAccount(userId: number, id: number): Promise<b
.orderBy(desc(photosConfig.createdAt))
.limit(1);
if (next) {
await tx.update(photosConfig).set({ isActive: true, updatedAt: new Date() }).where(eq(photosConfig.id, next.id));
await tx
.update(photosConfig)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(photosConfig.id, next.id));
}
}
return true;
-2
View File
@@ -32,8 +32,6 @@ export * from './chat-events/schema'; // chat_session_events
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
// it as an optional extra. The secret store bootstraps a `headscale` key on this basis.
export * from './headscale/schema'; // headscale_servers
// The app store itself, and the credentials it stores for what it installs. `app-store/effects.ts`
// reads service_connections, so this is core however few plugins are installed.
-1
View File
@@ -61,7 +61,6 @@ export type DashboardInsert = typeof Schema.dashboards.$inferInsert;
export type ScreenSelect = typeof Schema.screens.$inferSelect;
export type ScreenInsert = typeof Schema.screens.$inferInsert;
// ── Email ──
export type EmailAccountSelect = typeof EmailSchema.emailAccounts.$inferSelect;
@@ -1,8 +1 @@
export {
getUserSettings,
setUserSettings,
getUserState,
patchUserState,
getDockPaths,
setDockPaths,
} from './queries';
export { getUserSettings, setUserSettings, getUserState, patchUserState, getDockPaths, setDockPaths } from './queries';