Files
offscale/db/schema.ts
T
pastilhasandClaude Opus 5 373d67e0a0 finish the rebrand: the parts a user reads, and the manifest that was wrong
The extraction landed months ago and the platform side is clean. What was left was
inside the plugin, and it was the half that shows.

── Names a user actually reads ──

The three registered panels were still called "Headscale servers", "Headscale" and
"Headscale" in the panel picker, and the server picker rendered "Headscale" as its
own heading. Those name OUR panels, not the upstream server, so they were simply
missed.

Deliberately NOT a find-and-replace. Most of the ~175 "headscale" strings in here
are correct — "no active Headscale server", "headscale timed out", "is this a
Headscale server?" all refer to the thing this plugin drives, and renaming them
would make the errors lie. What was fixed is the plugin's own identity, plus nine
stale `/headscale` route paths that are wrong rather than merely old (the route is
`/offscale`) and one reference to `queries/headscale.ts`, a file the extraction
deleted.

The console's pty session key moves from `headscale-console-<id>` to
`offscale-console-<id>`. That one has a real cost: a console open at the moment of
the change keeps running in the sidecar under the old key with nothing pointing at
it, until that sidecar restarts. Paid once, deliberately, rather than carrying the
pre-extraction name in persisted state forever.

── The manifest was missing a dependency it has always had ──

`sidecar/ssh.ts` spawns `ssh`, and so does the reachability probe. The manifest
said "Offscale needed nothing", so install checked for nothing and a host without
an ssh client failed at RUNTIME with a spawn error — precisely the failure
`osDependencies` exists to turn into a refusal at install time. PLUGIN.md listed
ssh as a host dependency all along; the manifest was the file that was wrong.

Missed because ssh is on effectively every Linux box, which is also why declaring
it costs nothing there and is the whole difference where it is absent.

── Documentation that said the opposite of the code ──

PLUGIN.md instructed the reader to carry `assist.ts` in "but do not wire it up",
as a marker. It has been wired for some time: `policy.ts` routes
`POST /_officer/policy/assist`, `PolicyView.tsx` renders `<PolicyAssistant />`, and
the manifest declares the route in `readOnlyWrites`. Corrected rather than deleted,
because that instruction is exactly the kind someone would follow — a person
tidying "unused" code would have found it wired and assumed the document knew
something they did not.

Present-tense identifiers corrected to the shipped names; the "before" columns of
the migration tables still say headscale, because that is what they record. A note
at the top says which is which.

tsgo clean, 21 plugin tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:23:28 +00:00

56 lines
3.4 KiB
TypeScript

import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from 'officerdb/auth/schema';
// The Headscale servers the owner manages, for the officer-offscale 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
// db/queries.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 `offscale_`-prefixed and this file holds nothing else: when sidecars own their own
// schema it moves wholesale into ../sidecar/ with no untangling. Only the
// officer-offscale sidecar reads or writes these tables.
export const offscaleServers = pgTable(
'offscale_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_offscale_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. setActiveOffscaleServer still clears the others in a transaction,
// but a bug there fails loudly here instead of silently leaving two servers active.
uniqueIndex('uq_offscale_servers_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);