rebrand to OffScale, and fix what the first extraction missed
Offscale was the first plugin extracted and it was done before we knew what "extracted" meant. Music, done last, is the standard. This brings offscale to it. ── The rebrand ── The plugin was `offscale` to the platform and `headscale` to itself: sidecar name and handles, the port announcement, the API proxy name, the React components, every hook, the react-query keys, the panel ids and appTypes, and the Postgres table. Now all of those say offscale. The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and Headscale is the server it manages. So every IDENTIFIER is offscale, while a message like `headscale unreachable`, the `headscale apikeys create` hint and the ACL assistant's prompt still say Headscale — because they are talking about the remote server, and renaming them would make the code lie about what it reached. 495 occurrences became 180, and the 180 are all of that second kind. ── The live bug this uncovered ── `headscaleSectionPath` built links to `/headscale/<section>`. The shell has no such route — plugin routes come from `plugin.route`, which is `/offscale` — and it redirects unknown paths to the home page. So every section link in the nav, the console and the server picker silently went home. The extraction moved the route and left the link builder behind. Also live: ServersView told the user to run `pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not existed since the sidecar was renamed. ── The correctness fix music already had ── api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips `prefix.length` characters, so a literal is correct only for a first-party publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale` and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does. ── The rest ── - assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops being a glyph badge. - First tests: 21 of them, over the version floor and the protobuf normalisers. Those are the two places a Headscale release actually breaks this, and they had no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor first would refuse 1.0 as older than 0.29. - OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts, which is not linkable and not published. Now a document, as MUSIC_API.md is. - web/panels.ts re-exported three components. A plugin cannot export components; that was residue of the platform importing them before extraction. - Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/, neither of which has existed since the extraction. The crypto purpose moved headscale → offscale too, and the secret-store row was renamed rather than left to create a fresh key — the material is preserved, so this is reversible. Free to do only because offscale_servers had 0 rows; with one stored API key it would have been a migration.
This commit is contained in:
+58
-58
@@ -1,20 +1,20 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from 'officerdb/db';
|
||||
import { headscaleServers } from './schema';
|
||||
import { offscaleServers } from './schema';
|
||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||
|
||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||
// Headscale server registry access for the officer-offscale 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.
|
||||
// See ./schema.ts — the encryption lives in the platform, via officerdb.
|
||||
//
|
||||
// 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
|
||||
// OffscaleServer — safe to serialize to the browser. Has NO api key field at all.
|
||||
// OffscaleServerCredentials — 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 = {
|
||||
export type OffscaleServer = {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -25,49 +25,49 @@ export type HeadscaleServer = {
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string };
|
||||
export type OffscaleServerCredentials = { 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,
|
||||
id: offscaleServers.id,
|
||||
name: offscaleServers.name,
|
||||
url: offscaleServers.url,
|
||||
version: offscaleServers.version,
|
||||
sshHost: offscaleServers.sshHost,
|
||||
isActive: offscaleServers.isActive,
|
||||
lastSeenAt: offscaleServers.lastSeenAt,
|
||||
createdAt: offscaleServers.createdAt,
|
||||
};
|
||||
|
||||
/** Every server the owner has registered, active first then newest. Never includes the API key. */
|
||||
export async function listHeadscaleServers(userId: number): Promise<HeadscaleServer[]> {
|
||||
export async function listOffscaleServers(userId: number): Promise<OffscaleServer[]> {
|
||||
return db
|
||||
.select(serverCols)
|
||||
.from(headscaleServers)
|
||||
.where(eq(headscaleServers.userId, userId))
|
||||
.orderBy(desc(headscaleServers.isActive), desc(headscaleServers.createdAt));
|
||||
.from(offscaleServers)
|
||||
.where(eq(offscaleServers.userId, userId))
|
||||
.orderBy(desc(offscaleServers.isActive), desc(offscaleServers.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> {
|
||||
export async function getActiveOffscaleCredentials(userId: number): Promise<OffscaleServerCredentials | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||
.from(offscaleServers)
|
||||
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('offscale', 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> {
|
||||
export async function getOffscaleCredentials(userId: number, id: number): Promise<OffscaleServerCredentials | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
||||
.from(offscaleServers)
|
||||
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('offscale', row.apiKey) };
|
||||
}
|
||||
|
||||
type CreateHeadscaleServerParams = {
|
||||
type CreateOffscaleServerParams = {
|
||||
userId: number;
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -80,22 +80,22 @@ type CreateHeadscaleServerParams = {
|
||||
};
|
||||
|
||||
/** Register a server. The key is encrypted before write; the returned row carries no key. */
|
||||
export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise<HeadscaleServer> {
|
||||
export async function createOffscaleServer(params: CreateOffscaleServerParams): Promise<OffscaleServer> {
|
||||
const { userId, name, url, apiKey, version, sshHost, activate } = params;
|
||||
return db.transaction(async (tx) => {
|
||||
if (activate) {
|
||||
await tx
|
||||
.update(headscaleServers)
|
||||
.update(offscaleServers)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true)));
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(headscaleServers)
|
||||
.insert(offscaleServers)
|
||||
.values({
|
||||
userId,
|
||||
name,
|
||||
url,
|
||||
apiKey: encryptSecret('headscale', apiKey),
|
||||
apiKey: encryptSecret('offscale', apiKey),
|
||||
version,
|
||||
sshHost,
|
||||
isActive: activate,
|
||||
@@ -108,39 +108,39 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
|
||||
|
||||
// `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 };
|
||||
type UpdateOffscaleServerParams = { 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(
|
||||
export async function updateOffscaleServer(
|
||||
userId: number,
|
||||
id: number,
|
||||
params: UpdateHeadscaleServerParams,
|
||||
): Promise<HeadscaleServer | null> {
|
||||
params: UpdateOffscaleServerParams,
|
||||
): Promise<OffscaleServer | 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.apiKey !== undefined) set.apiKey = encryptSecret('offscale', params.apiKey);
|
||||
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
|
||||
|
||||
const [row] = await db
|
||||
.update(headscaleServers)
|
||||
.update(offscaleServers)
|
||||
.set(set)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
|
||||
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.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> {
|
||||
export async function setActiveOffscaleServer(userId: number, id: number): Promise<OffscaleServer | null> {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(headscaleServers)
|
||||
.update(offscaleServers)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true)));
|
||||
const [row] = await tx
|
||||
.update(headscaleServers)
|
||||
.update(offscaleServers)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)))
|
||||
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)))
|
||||
.returning(serverCols);
|
||||
return row ?? null;
|
||||
});
|
||||
@@ -151,26 +151,26 @@ export async function setActiveHeadscaleServer(userId: number, id: number): Prom
|
||||
* 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> {
|
||||
export async function deleteOffscaleServer(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 });
|
||||
.delete(offscaleServers)
|
||||
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)))
|
||||
.returning({ id: offscaleServers.id, wasActive: offscaleServers.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))
|
||||
.select({ id: offscaleServers.id })
|
||||
.from(offscaleServers)
|
||||
.where(eq(offscaleServers.userId, userId))
|
||||
.orderBy(desc(offscaleServers.createdAt))
|
||||
.limit(1);
|
||||
if (next) {
|
||||
await tx
|
||||
.update(headscaleServers)
|
||||
.update(offscaleServers)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(eq(headscaleServers.id, next.id));
|
||||
.where(eq(offscaleServers.id, next.id));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -178,9 +178,9 @@ export async function deleteHeadscaleServer(userId: number, id: number): Promise
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
export async function recordOffscaleProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||
await db
|
||||
.update(headscaleServers)
|
||||
.update(offscaleServers)
|
||||
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
||||
.where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id)));
|
||||
}
|
||||
|
||||
+9
-9
@@ -2,7 +2,7 @@ import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||
// 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.
|
||||
//
|
||||
@@ -12,12 +12,12 @@ import { users } from 'officerdb/auth/schema';
|
||||
// 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.
|
||||
// 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 headscaleServers = pgTable(
|
||||
'headscale_servers',
|
||||
export const offscaleServers = pgTable(
|
||||
'offscale_servers',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
@@ -44,11 +44,11 @@ export const headscaleServers = pgTable(
|
||||
},
|
||||
(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),
|
||||
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. setActiveHeadscaleServer still clears the others in a transaction,
|
||||
// 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_headscale_servers_one_active')
|
||||
uniqueIndex('uq_offscale_servers_one_active')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isActive}`),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user