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.
103 lines
4.6 KiB
TypeScript
103 lines
4.6 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { arrayField, isoOrNull, toNode, toUser } from './normalize';
|
|
|
|
// Headscale's REST layer is a gRPC gateway marshalling protobuf, and it leaks in three specific ways.
|
|
// These transforms are where that leak is contained, so they are the file most likely to be quietly wrong
|
|
// after an upstream release — and they had no tests at all.
|
|
|
|
describe('isoOrNull', () => {
|
|
test("the protobuf zero timestamp means 'never', not the year 1", () => {
|
|
// The leak that matters most: unset timestamps arrive as this literal rather than being omitted.
|
|
// Rendered naively a node's expiry reads as year 1, which looks like an expired node rather than one
|
|
// that never expires.
|
|
expect(isoOrNull('0001-01-01T00:00:00Z')).toBeNull();
|
|
});
|
|
|
|
test('pre-1971 is treated as the sentinel too, for builds that emit a different zero', () => {
|
|
expect(isoOrNull('1970-01-01T00:00:00Z')).toBeNull();
|
|
expect(isoOrNull('1960-06-01T00:00:00Z')).toBeNull();
|
|
});
|
|
|
|
test('a real timestamp survives, normalised to ISO', () => {
|
|
expect(isoOrNull('2026-08-15T10:30:00Z')).toBe('2026-08-15T10:30:00.000Z');
|
|
});
|
|
|
|
test('anything that is not a usable string is null rather than a crash', () => {
|
|
// EmitUnpopulated means absent messages arrive as null, so these are normal input, not corruption.
|
|
for (const bad of [null, undefined, '', 'not a date', 42, {}, []]) {
|
|
expect(isoOrNull(bad)).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('toNode', () => {
|
|
test('ids stay STRINGS — never numbers', () => {
|
|
// Headscale ids are uint64 serialized as JSON strings. Number() breaks silently above 2^53, and the
|
|
// ids are database-assigned rather than small by contract, so this is a real ceiling and not theory.
|
|
const node = toNode({ id: '9007199254740993', name: 'a' });
|
|
expect(node.id).toBe('9007199254740993');
|
|
expect(typeof node.id).toBe('string');
|
|
});
|
|
|
|
test('givenName wins over name, falling back when it is unset', () => {
|
|
expect(toNode({ id: '1', givenName: 'laptop', name: 'laptop.tail1234.ts.net' }).name).toBe('laptop');
|
|
expect(toNode({ id: '1', name: 'laptop.tail1234.ts.net' }).name).toBe('laptop.tail1234.ts.net');
|
|
});
|
|
|
|
test('an exit node is recognised from either default route', () => {
|
|
expect(toNode({ id: '1', availableRoutes: ['0.0.0.0/0'] }).isExitNode).toBe(true);
|
|
expect(toNode({ id: '1', availableRoutes: ['::/0'] }).isExitNode).toBe(true);
|
|
expect(toNode({ id: '1', availableRoutes: ['10.0.0.0/24'] }).isExitNode).toBe(false);
|
|
expect(toNode({ id: '1' }).isExitNode).toBe(false);
|
|
});
|
|
|
|
test('an unknown register method degrades rather than leaking the enum', () => {
|
|
expect(toNode({ id: '1', registerMethod: 'REGISTER_METHOD_AUTH_KEY' }).registerMethod).toBe('authkey');
|
|
// A method added in a future release must not put REGISTER_METHOD_SOMETHING_NEW in the UI.
|
|
expect(toNode({ id: '1', registerMethod: 'REGISTER_METHOD_FUTURE' }).registerMethod).toBe('unknown');
|
|
expect(toNode({ id: '1' }).registerMethod).toBe('unknown');
|
|
});
|
|
|
|
test('online is strictly true, so a missing field is offline rather than truthy', () => {
|
|
expect(toNode({ id: '1', online: true }).online).toBe(true);
|
|
expect(toNode({ id: '1', online: 'true' }).online).toBe(false);
|
|
expect(toNode({ id: '1' }).online).toBe(false);
|
|
});
|
|
|
|
test('a node with nothing but an id normalises instead of throwing', () => {
|
|
// EmitUnpopulated guarantees absent repeated fields arrive as [] and absent messages as null, and
|
|
// there is no way to tell "unset" from "empty" — so every accessor has to tolerate both.
|
|
const node = toNode({ id: '7' });
|
|
expect(node.ipAddresses).toEqual([]);
|
|
expect(node.tags).toEqual([]);
|
|
expect(node.user).toBeNull();
|
|
expect(node.lastSeen).toBeNull();
|
|
});
|
|
|
|
test('non-string entries are dropped from string arrays rather than rendered', () => {
|
|
expect(toNode({ id: '1', ipAddresses: ['100.64.0.1', null, 42, '::1'] }).ipAddresses).toEqual([
|
|
'100.64.0.1',
|
|
'::1',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('toUser', () => {
|
|
test('null and undefined pass through as null', () => {
|
|
expect(toUser(null)).toBeNull();
|
|
expect(toUser(undefined)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('arrayField', () => {
|
|
test('pulls the named array, keeping only objects', () => {
|
|
expect(arrayField({ nodes: [{ id: '1' }, null, 'x', { id: '2' }] }, 'nodes')).toEqual([{ id: '1' }, { id: '2' }]);
|
|
});
|
|
|
|
test('a missing field or a non-array body is an empty list, not a throw', () => {
|
|
expect(arrayField({}, 'nodes')).toEqual([]);
|
|
expect(arrayField(null, 'nodes')).toEqual([]);
|
|
expect(arrayField({ nodes: 'not-an-array' }, 'nodes')).toEqual([]);
|
|
});
|
|
});
|