diff --git a/src/servers/api/headscale/router.ts b/plugins/offscale/api/router.ts similarity index 81% rename from src/servers/api/headscale/router.ts rename to plugins/offscale/api/router.ts index 174735a3..3d3a09a3 100644 --- a/src/servers/api/headscale/router.ts +++ b/plugins/offscale/api/router.ts @@ -1,4 +1,4 @@ -import { createSidecarProxy } from '../../sidecar/create-proxy'; +import { createSidecarProxy } from '@@/sidecar/create-proxy'; // /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge: // this file must never grow app logic. @@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy'; const proxy = createSidecarProxy({ name: 'headscale', - prefix: '/api/headscale', + prefix: '/api/offscale', }); -export const headscaleRouter = proxy.router; +export const router = proxy.router; /** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ export const getHeadscaleServerUrl = proxy.getHttpUrl; diff --git a/src/databases/officer_db/src/headscale/queries.ts b/plugins/offscale/db/queries.ts similarity index 98% rename from src/databases/officer_db/src/headscale/queries.ts rename to plugins/offscale/db/queries.ts index 70cab8e2..ba3b1558 100644 --- a/src/databases/officer_db/src/headscale/queries.ts +++ b/plugins/offscale/db/queries.ts @@ -1,7 +1,7 @@ import { eq, and, desc } from 'drizzle-orm'; -import { db } from '../db'; +import { db } from 'officerdb/db'; import { headscaleServers } from './schema'; -import { encryptSecret, decryptSecret } from '../crypto'; +import { encryptSecret, decryptSecret } from 'officerdb/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. diff --git a/src/databases/officer_db/src/headscale/schema.ts b/plugins/offscale/db/schema.ts similarity index 98% rename from src/databases/officer_db/src/headscale/schema.ts rename to plugins/offscale/db/schema.ts index b423f28d..08376bb7 100644 --- a/src/databases/officer_db/src/headscale/schema.ts +++ b/plugins/offscale/db/schema.ts @@ -1,6 +1,6 @@ import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; -import { users } from '../auth/schema'; +import { users } from 'officerdb/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 diff --git a/plugins/offscale/manifest.ts b/plugins/offscale/manifest.ts new file mode 100644 index 00000000..a08d3a87 --- /dev/null +++ b/plugins/offscale/manifest.ts @@ -0,0 +1,46 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +// Offscale — Headscale, plus the Companion that ships beside it. +// +// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what +// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a +// distinct product rather than a badge on someone else's. +// +// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here: +// +// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any +// sidecar/ the whole Headscale contract, holding the admin API keys +// db/ offscale_servers, and the only table this plugin owns +// web/ panels and a layout; the shell renders the Workspace +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + + label: 'Offscale', + summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites', + icon: 'Network', + color: '#818cf8', + + // One permission gating the whole surface. + // + // `ownerOnly` because the credential behind it is a Headscale ADMIN api key that can delete every node + // on a tailnet, and there is no read-only version of it. A read grant would still be reading through + // that key; the protection is that non-owners cannot reach the routes at all. + // + // Read/write for members is the model recorded in docs/offscale-plugin.md and deliberately not enabled + // here yet: it needs the queries to resolve to the OWNER's rows rather than the caller's, which is a + // change inside this plugin and not a flag. + permissions: [ + { + key: 'offscale', + label: 'Offscale', + description: 'The tailnet: machines, routes, keys and ACLs', + ownerOnly: true, + // Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves. + // Without declaring them a read-level account meets a broken feature where a withheld permission + // should be. Inert while ownerOnly, and correct the moment that changes. + readOnlyWrites: ['/ssh-test', '/policy/assist'], + }, + ], +}; diff --git a/src/servers/sidecar/headscale/active.ts b/plugins/offscale/sidecar/active.ts similarity index 93% rename from src/servers/sidecar/headscale/active.ts rename to plugins/offscale/sidecar/active.ts index 560d1003..d41e7816 100644 --- a/src/servers/sidecar/headscale/active.ts +++ b/plugins/offscale/sidecar/active.ts @@ -1,4 +1,4 @@ -import { getActiveHeadscaleCredentials } from 'officerdb'; +import { getActiveHeadscaleCredentials } from '../db/queries'; import { createClient, type HeadscaleClient } from './client'; // Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That diff --git a/src/servers/sidecar/headscale/assist.ts b/plugins/offscale/sidecar/assist.ts similarity index 100% rename from src/servers/sidecar/headscale/assist.ts rename to plugins/offscale/sidecar/assist.ts diff --git a/src/servers/sidecar/headscale/claude-proxy.ts b/plugins/offscale/sidecar/claude-proxy.ts similarity index 97% rename from src/servers/sidecar/headscale/claude-proxy.ts rename to plugins/offscale/sidecar/claude-proxy.ts index 3476f955..c0472954 100644 --- a/src/servers/sidecar/headscale/claude-proxy.ts +++ b/plugins/offscale/sidecar/claude-proxy.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { DATA_PATH } from '../../data-path'; -import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs'; +import { DATA_PATH } from '@@/data-path'; +import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs'; // One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent. // diff --git a/src/servers/sidecar/headscale/client.ts b/plugins/offscale/sidecar/client.ts similarity index 98% rename from src/servers/sidecar/headscale/client.ts rename to plugins/offscale/sidecar/client.ts index 8f357ea8..5e51fb35 100644 --- a/src/servers/sidecar/headscale/client.ts +++ b/plugins/offscale/sidecar/client.ts @@ -1,4 +1,4 @@ -import type { HeadscaleServerCredentials } from 'officerdb'; +import type { HeadscaleServerCredentials } from '../db/queries'; // The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the // wire-level quirks are handled once: diff --git a/src/servers/sidecar/headscale/companion.ts b/plugins/offscale/sidecar/companion.ts similarity index 99% rename from src/servers/sidecar/headscale/companion.ts rename to plugins/offscale/sidecar/companion.ts index 852d914e..c44a21c7 100644 --- a/src/servers/sidecar/headscale/companion.ts +++ b/plugins/offscale/sidecar/companion.ts @@ -1,4 +1,4 @@ -import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb'; +import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries'; import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes'; // The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the diff --git a/src/servers/sidecar/headscale/enroll.ts b/plugins/offscale/sidecar/enroll.ts similarity index 98% rename from src/servers/sidecar/headscale/enroll.ts rename to plugins/offscale/sidecar/enroll.ts index 945fc540..eed8195d 100644 --- a/src/servers/sidecar/headscale/enroll.ts +++ b/plugins/offscale/sidecar/enroll.ts @@ -1,6 +1,6 @@ import type { OfficerContext } from './routes'; import type { OfficerUser } from './normalize'; -import { getActiveHeadscaleCredentials } from 'officerdb'; +import { getActiveHeadscaleCredentials } from '../db/queries'; import { badRequest, methodNotAllowed, readJson } from './routes'; import { createClient, type HeadscaleClient } from './client'; import { arrayField, toUser } from './normalize'; diff --git a/src/servers/sidecar/headscale/index.ts b/plugins/offscale/sidecar/index.ts similarity index 97% rename from src/servers/sidecar/headscale/index.ts rename to plugins/offscale/sidecar/index.ts index 4ad2e28b..1581ebd9 100644 --- a/src/servers/sidecar/headscale/index.ts +++ b/plugins/offscale/sidecar/index.ts @@ -1,8 +1,8 @@ -import type { SidecarCommand, SidecarEvent } from '../protocol'; -import { createSidecarConnector } from '../connect'; +import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; +import { createSidecarConnector } from '@@/sidecar/connect'; import { handleOfficerRoute } from './routes'; import { MIN_VERSION_LABEL } from './version'; -import { API_URL } from '../../officer-url.mjs'; +import { API_URL } from '@@/officer-url.mjs'; // The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and // their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform diff --git a/src/servers/sidecar/headscale/invites.ts b/plugins/offscale/sidecar/invites.ts similarity index 99% rename from src/servers/sidecar/headscale/invites.ts rename to plugins/offscale/sidecar/invites.ts index 07f83876..37fed1db 100644 --- a/src/servers/sidecar/headscale/invites.ts +++ b/plugins/offscale/sidecar/invites.ts @@ -1,4 +1,4 @@ -import type { HeadscaleServerCredentials } from 'officerdb'; +import type { HeadscaleServerCredentials } from '../db/queries'; import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes'; import { activeCreds, callCompanion, readBody, unavailable } from './companion'; diff --git a/src/servers/sidecar/headscale/keys.ts b/plugins/offscale/sidecar/keys.ts similarity index 100% rename from src/servers/sidecar/headscale/keys.ts rename to plugins/offscale/sidecar/keys.ts diff --git a/src/servers/sidecar/headscale/nodes.ts b/plugins/offscale/sidecar/nodes.ts similarity index 100% rename from src/servers/sidecar/headscale/nodes.ts rename to plugins/offscale/sidecar/nodes.ts diff --git a/src/servers/sidecar/headscale/normalize.ts b/plugins/offscale/sidecar/normalize.ts similarity index 100% rename from src/servers/sidecar/headscale/normalize.ts rename to plugins/offscale/sidecar/normalize.ts diff --git a/src/servers/sidecar/headscale/policy.ts b/plugins/offscale/sidecar/policy.ts similarity index 100% rename from src/servers/sidecar/headscale/policy.ts rename to plugins/offscale/sidecar/policy.ts diff --git a/src/servers/sidecar/headscale/routes.ts b/plugins/offscale/sidecar/routes.ts similarity index 100% rename from src/servers/sidecar/headscale/routes.ts rename to plugins/offscale/sidecar/routes.ts diff --git a/src/servers/sidecar/headscale/servers.ts b/plugins/offscale/sidecar/servers.ts similarity index 99% rename from src/servers/sidecar/headscale/servers.ts rename to plugins/offscale/sidecar/servers.ts index 10dd8d80..6d9c780c 100644 --- a/src/servers/sidecar/headscale/servers.ts +++ b/plugins/offscale/sidecar/servers.ts @@ -7,7 +7,7 @@ import { deleteHeadscaleServer, getHeadscaleCredentials, recordHeadscaleProbe, -} from 'officerdb'; +} from '../db/queries'; import { createClient, HeadscaleError } from './client'; import { probeVersion, MIN_VERSION_LABEL } from './version'; import { badRequest, notFound, methodNotAllowed } from './routes'; diff --git a/src/servers/sidecar/headscale/ssh.ts b/plugins/offscale/sidecar/ssh.ts similarity index 100% rename from src/servers/sidecar/headscale/ssh.ts rename to plugins/offscale/sidecar/ssh.ts diff --git a/src/servers/sidecar/headscale/users.ts b/plugins/offscale/sidecar/users.ts similarity index 100% rename from src/servers/sidecar/headscale/users.ts rename to plugins/offscale/sidecar/users.ts diff --git a/src/servers/sidecar/headscale/version.ts b/plugins/offscale/sidecar/version.ts similarity index 100% rename from src/servers/sidecar/headscale/version.ts rename to plugins/offscale/sidecar/version.ts diff --git a/src/workspaces/officerdev/src/apps/Headscale/Cards.tsx b/plugins/offscale/web/Cards.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/Cards.tsx rename to plugins/offscale/web/Cards.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/ConsoleView.tsx b/plugins/offscale/web/ConsoleView.tsx similarity index 98% rename from src/workspaces/officerdev/src/apps/Headscale/ConsoleView.tsx rename to plugins/offscale/web/ConsoleView.tsx index 0515762f..fe932b9d 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/ConsoleView.tsx +++ b/plugins/offscale/web/ConsoleView.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router'; import { Loader2, TerminalSquare } from 'lucide-react'; import { headscaleSectionPath } from './shared'; import { useHeadscaleServers } from './useHeadscaleServers'; -import { TerminalView } from '../Terminal/Terminal'; +import { TerminalView } from 'officerdev'; import { Button } from './Cards'; // A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot diff --git a/src/workspaces/officerdev/src/apps/Headscale/DiagnosticsView.tsx b/plugins/offscale/web/DiagnosticsView.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/DiagnosticsView.tsx rename to plugins/offscale/web/DiagnosticsView.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx b/plugins/offscale/web/HeadscaleNav.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx rename to plugins/offscale/web/HeadscaleNav.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleServerPicker.tsx b/plugins/offscale/web/HeadscaleServerPicker.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/HeadscaleServerPicker.tsx rename to plugins/offscale/web/HeadscaleServerPicker.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx b/plugins/offscale/web/HeadscaleView.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx rename to plugins/offscale/web/HeadscaleView.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx b/plugins/offscale/web/HeadscaleViewHeader.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/HeadscaleViewHeader.tsx rename to plugins/offscale/web/HeadscaleViewHeader.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx b/plugins/offscale/web/InvitesView.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx rename to plugins/offscale/web/InvitesView.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/KeysView.tsx b/plugins/offscale/web/KeysView.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/KeysView.tsx rename to plugins/offscale/web/KeysView.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx b/plugins/offscale/web/NodesView.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/NodesView.tsx rename to plugins/offscale/web/NodesView.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx b/plugins/offscale/web/PolicyAssistant.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx rename to plugins/offscale/web/PolicyAssistant.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/PolicyView.tsx b/plugins/offscale/web/PolicyView.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/PolicyView.tsx rename to plugins/offscale/web/PolicyView.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx b/plugins/offscale/web/ServerForm.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/ServerForm.tsx rename to plugins/offscale/web/ServerForm.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx b/plugins/offscale/web/ServersView.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/ServersView.tsx rename to plugins/offscale/web/ServersView.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/UsersView.tsx b/plugins/offscale/web/UsersView.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/UsersView.tsx rename to plugins/offscale/web/UsersView.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/ViewShell.tsx b/plugins/offscale/web/ViewShell.tsx similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/ViewShell.tsx rename to plugins/offscale/web/ViewShell.tsx diff --git a/src/workspaces/officerdev/src/apps/Headscale/diff.ts b/plugins/offscale/web/diff.ts similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/diff.ts rename to plugins/offscale/web/diff.ts diff --git a/src/workspaces/officerdev/src/apps/Headscale/format.ts b/plugins/offscale/web/format.ts similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/format.ts rename to plugins/offscale/web/format.ts diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts b/plugins/offscale/web/layout.ts similarity index 96% rename from src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts rename to plugins/offscale/web/layout.ts index 9d5c4236..101c9ed1 100644 --- a/src/apps/officer-web/Screens/Dashboard/Headscale/defaultLayout.ts +++ b/plugins/offscale/web/layout.ts @@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev'; export const defaultLayout: LayoutNode = { type: 'group', - id: 'headscale-root', + id: 'offscale-root', direction: 'horizontal', children: [ { diff --git a/src/workspaces/officerdev/src/apps/Headscale/index.ts b/plugins/offscale/web/panels.ts similarity index 93% rename from src/workspaces/officerdev/src/apps/Headscale/index.ts rename to plugins/offscale/web/panels.ts index d6182f52..8c4cb612 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/index.ts +++ b/plugins/offscale/web/panels.ts @@ -1,4 +1,4 @@ -import type { AppRegistryMeta } from '../../AppRegistry'; +import type { AppRegistryMeta } from 'officerdev'; import { PanelLeft, LayoutGrid, Network } from 'lucide-react'; import { HeadscaleNav } from './HeadscaleNav'; import { HeadscaleServerPicker } from './HeadscaleServerPicker'; diff --git a/src/workspaces/officerdev/src/apps/Headscale/shared.ts b/plugins/offscale/web/shared.ts similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/shared.ts rename to plugins/offscale/web/shared.ts diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleCompanion.ts b/plugins/offscale/web/useHeadscaleCompanion.ts similarity index 99% rename from src/workspaces/officerdev/src/apps/Headscale/useHeadscaleCompanion.ts rename to plugins/offscale/web/useHeadscaleCompanion.ts index ed5f16ee..833c890b 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleCompanion.ts +++ b/plugins/offscale/web/useHeadscaleCompanion.ts @@ -7,7 +7,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com // because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and // decryptable only there. The browser never sees it and never talks to the companion directly. -const BASE = '/headscale/_officer/companion'; +const BASE = '/offscale/_officer/companion'; const HEALTH_KEY = ['headscale', 'companion', 'health'] as const; /** diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleData.ts b/plugins/offscale/web/useHeadscaleData.ts similarity index 78% rename from src/workspaces/officerdev/src/apps/Headscale/useHeadscaleData.ts rename to plugins/offscale/web/useHeadscaleData.ts index af87bfa4..b129fe96 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleData.ts +++ b/plugins/offscale/web/useHeadscaleData.ts @@ -24,28 +24,26 @@ export function useHeadscaleNodes() { const query = useQuery({ queryKey: NODES_KEY, - queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'), + queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'), // Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join. refetchInterval: 20_000, staleTime: 10_000, }); const rename = useMutation({ - mutationFn: ({ id, name }: { id: string; name: string }) => - post(`/headscale/_officer/nodes/${id}/rename`, { name }), + mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }), onSuccess: invalidate, }); const setTags = useMutation({ - mutationFn: ({ id, tags }: { id: string; tags: string[] }) => - post(`/headscale/_officer/nodes/${id}/tags`, { tags }), + mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }), onSuccess: invalidate, }); // Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string. const moveToUser = useMutation({ mutationFn: ({ id, userId }: { id: string; userId: string }) => - post(`/headscale/_officer/nodes/${id}/user`, { userId }), + post(`/offscale/_officer/nodes/${id}/user`, { userId }), onSuccess: invalidate, }); @@ -53,17 +51,17 @@ export function useHeadscaleNodes() { // because Headscale's approve_routes replaces the whole set. const toggleRoute = useMutation({ mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) => - post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }), + post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }), onSuccess: invalidate, }); const expire = useMutation({ - mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`), + mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`), onSuccess: invalidate, }); const remove = useMutation({ - mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`), + mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`), onSuccess: invalidate, }); @@ -87,24 +85,23 @@ export function useHeadscaleUsers() { const query = useQuery({ queryKey: USERS_KEY, - queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'), + queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'), staleTime: 30_000, }); const create = useMutation({ mutationFn: (input: { name: string; displayName?: string; email?: string }) => - post('/headscale/_officer/users', input), + post('/offscale/_officer/users', input), onSuccess: invalidate, }); const rename = useMutation({ - mutationFn: ({ id, name }: { id: string; name: string }) => - post(`/headscale/_officer/users/${id}/rename`, { name }), + mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }), onSuccess: invalidate, }); const remove = useMutation({ - mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`), + mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`), onSuccess: invalidate, }); @@ -133,7 +130,7 @@ export function useHeadscaleKeys() { const query = useQuery({ queryKey: KEYS_KEY, - queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'), + queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'), staleTime: 30_000, }); @@ -141,17 +138,17 @@ export function useHeadscaleKeys() { // (not merged into the list cache) so the view can show it once and deliberately drop it. const create = useMutation({ mutationFn: (input: CreateKeyInput) => - post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input), + post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input), onSuccess: invalidate, }); const expire = useMutation({ - mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`), + mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`), onSuccess: invalidate, }); const remove = useMutation({ - mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`), + mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`), onSuccess: invalidate, }); diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleInvites.ts b/plugins/offscale/web/useHeadscaleInvites.ts similarity index 97% rename from src/workspaces/officerdev/src/apps/Headscale/useHeadscaleInvites.ts rename to plugins/offscale/web/useHeadscaleInvites.ts index 2d4e147b..0593d7eb 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleInvites.ts +++ b/plugins/offscale/web/useHeadscaleInvites.ts @@ -10,7 +10,7 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv // claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and // drops it. The list is refetched instead, which returns the same invite without its token. -const BASE = '/headscale/_officer/enroll/invites'; +const BASE = '/offscale/_officer/enroll/invites'; const INVITES_KEY = ['headscale', 'invites'] as const; const EMPTY: InvitesListResult = { available: true, invites: [] }; diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscalePolicy.ts b/plugins/offscale/web/useHeadscalePolicy.ts similarity index 98% rename from src/workspaces/officerdev/src/apps/Headscale/useHeadscalePolicy.ts rename to plugins/offscale/web/useHeadscalePolicy.ts index b1f0d6e8..535b6625 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/useHeadscalePolicy.ts +++ b/plugins/offscale/web/useHeadscalePolicy.ts @@ -8,7 +8,7 @@ import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared'; // "your document is wrong, here is where" versus "this server does not accept written policies at all". const POLICY_KEY = ['headscale', 'policy'] as const; -const PATH = '/headscale/_officer/policy'; +const PATH = '/offscale/_officer/policy'; /** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */ export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string }; diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleSection.ts b/plugins/offscale/web/useHeadscaleSection.ts similarity index 100% rename from src/workspaces/officerdev/src/apps/Headscale/useHeadscaleSection.ts rename to plugins/offscale/web/useHeadscaleSection.ts diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts b/plugins/offscale/web/useHeadscaleServers.ts similarity index 96% rename from src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts rename to plugins/offscale/web/useHeadscaleServers.ts index 613419dc..9914c33c 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleServers.ts +++ b/plugins/offscale/web/useHeadscaleServers.ts @@ -12,7 +12,7 @@ import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './share const SERVERS_KEY = ['headscale', 'servers'] as const; const EMPTY: HeadscaleServer[] = []; -const BASE = '/headscale/_officer/servers'; +const BASE = '/offscale/_officer/servers'; /** * Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw @@ -89,7 +89,7 @@ export function useHeadscaleServers() { export function useHeadscaleSshTest() { const { post } = useClient(); return useMutation({ - mutationFn: (host: string) => post('/headscale/_officer/ssh-test', { host }), + mutationFn: (host: string) => post('/offscale/_officer/ssh-test', { host }), }); } diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 438b0d86..d870fefe 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -63,8 +63,6 @@ export function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx deleted file mode 100644 index 9ac2fd16..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { Navigate, useParams } from 'react-router'; -import type { LayoutNode } from 'officerdev'; -import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev'; -import { useDashboardState } from 'state/useDashboardState'; -import { defaultLayout } from './defaultLayout'; - -// /headscale uses the Workspace/Panel system (like /soulseek and /music): the server picker -// (headscale-servers) above the section nav (headscale-nav) on the left, and the section view -// (headscale-view) on the right. All three talk to the officer-headscale sidecar through the /api/headscale -// auth proxy, which holds no Headscale credentials of its own — the registered servers and their keys live -// in the sidecar. -// -// The open section is :section in the URL, so every panel reads it with useParams instead of passing it -// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the -// single place that decides what an absent or bogus section means. - -function hasAppType(node: LayoutNode, appType: string): boolean { - if (node.type === 'panel') return node.appType === appType; - return node.children.some((c) => hasAppType(c.node, appType)); -} - -export const HeadscaleScreen = () => { - const { section } = useParams(); - const rawWorkspace = useDashboardState('screens/headscale', defaultLayout); - - // A layout saved before the server picker existed has no panel for it, and nothing else would ever add - // one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is - // cheaper than a screen permanently missing a panel. Pinning the app types is `appTypes` below; this is - // the part the framework can't do, because it is about a panel that is *missing* rather than wrong. - const workspace = useMemo(() => { - if (hasAppType(rawWorkspace.value, 'headscale-servers')) return rawWorkspace; - return { ...rawWorkspace, value: defaultLayout }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); - - // Bare /headscale, or a section that doesn't exist, resolves to a canonical URL rather than rendering a - // default while the address bar says something else — the nav highlight is derived from the URL, so a URL - // that names nothing would leave nothing highlighted. - if (!isHeadscaleSection(section)) { - return ; - } - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/index.tsx b/src/apps/officer-web/Screens/Dashboard/Headscale/index.tsx deleted file mode 100644 index a73ee431..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Headscale/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './HeadscaleScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 1c5659f6..71a7d899 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -178,7 +178,6 @@ export const CORE_DOCK_ITEMS: DockItem[] = [ // Core because the tailnet is the perimeter — origin checking was removed on the grounds that the // tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`, // and DashboardLayout filters every tile through canVisit(), so a member never sees this one. - { label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' }, // Core by necessity: the store is how every other feature arrives, so it can never be one of the // things that disappears when uninstalled. { label: 'App store', to: '/app-store', icon: Store, color: '#64748b' }, diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 4226c1fc..a407b63a 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -15,7 +15,6 @@ export * from './Calendar'; export * from './Contacts'; export * from './Music'; export * from './Soulseek'; -export * from './Headscale'; export * from './Photos'; export * from './Jellyfin'; export * from './Transmission'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 9219b8a1..37f43a0e 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -27,7 +27,6 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/photos'), title: 'Photos' }, { match: (p) => p.startsWith('/jellyfin'), title: 'Video' }, { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, - { match: (p) => p.startsWith('/headscale'), title: 'Headscale' }, { match: (p) => p.startsWith('/transmission'), title: 'Transmission' }, { match: (p) => p.startsWith('/gitea'), title: 'Gitea' }, { match: (p) => p.startsWith('/invoices'), title: 'Invoices' }, diff --git a/src/databases/officer_db/src/capabilities/index.ts b/src/databases/officer_db/src/capabilities/index.ts index f7d4bb92..637d723f 100644 --- a/src/databases/officer_db/src/capabilities/index.ts +++ b/src/databases/officer_db/src/capabilities/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/chat-events/index.ts b/src/databases/officer_db/src/chat-events/index.ts index 49a897d3..316cec5d 100644 --- a/src/databases/officer_db/src/chat-events/index.ts +++ b/src/databases/officer_db/src/chat-events/index.ts @@ -1,6 +1 @@ -export { - appendChatEvent, - getChatEventsSince, - getLastChatEventSeq, - pruneChatEventsOlderThan, -} from './queries'; +export { appendChatEvent, getChatEventsSince, getLastChatEventSeq, pruneChatEventsOlderThan } from './queries'; diff --git a/src/databases/officer_db/src/db.ts b/src/databases/officer_db/src/db.ts index a39fd41f..1bbc2485 100644 --- a/src/databases/officer_db/src/db.ts +++ b/src/databases/officer_db/src/db.ts @@ -43,7 +43,10 @@ export async function waitForDatabase(timeoutMs = 60_000): Promise { 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) { diff --git a/src/databases/officer_db/src/headscale/index.ts b/src/databases/officer_db/src/headscale/index.ts deleted file mode 100644 index 9fde0782..00000000 --- a/src/databases/officer_db/src/headscale/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - listHeadscaleServers, - getActiveHeadscaleCredentials, - getHeadscaleCredentials, - createHeadscaleServer, - updateHeadscaleServer, - setActiveHeadscaleServer, - deleteHeadscaleServer, - recordHeadscaleProbe, -} from './queries'; - -export type { HeadscaleServer, HeadscaleServerCredentials } from './queries'; diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 4253c236..e74a78d6 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/invoiceshelf/queries.ts b/src/databases/officer_db/src/invoiceshelf/queries.ts index ab6f2563..be2122fd 100644 --- a/src/databases/officer_db/src/invoiceshelf/queries.ts +++ b/src/databases/officer_db/src/invoiceshelf/queries.ts @@ -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 = { diff --git a/src/databases/officer_db/src/music/index.ts b/src/databases/officer_db/src/music/index.ts index df79717e..0892d074 100644 --- a/src/databases/officer_db/src/music/index.ts +++ b/src/databases/officer_db/src/music/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/notify/index.ts b/src/databases/officer_db/src/notify/index.ts index 21f53db0..8addf1f9 100644 --- a/src/databases/officer_db/src/notify/index.ts +++ b/src/databases/officer_db/src/notify/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/notify/queries.ts b/src/databases/officer_db/src/notify/queries.ts index 6ac46d65..f7ed83ac 100644 --- a/src/databases/officer_db/src/notify/queries.ts +++ b/src/databases/officer_db/src/notify/queries.ts @@ -69,8 +69,5 @@ export async function recordPushFailure(token: string): Promise { /** A send worked: clear the failure count and mark the device alive. */ export async function markPushDeviceSeen(token: string): Promise { - 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)); } diff --git a/src/databases/officer_db/src/photos/queries.ts b/src/databases/officer_db/src/photos/queries.ts index b07791ea..43592328 100644 --- a/src/databases/officer_db/src/photos/queries.ts +++ b/src/databases/officer_db/src/photos/queries.ts @@ -158,7 +158,10 @@ export async function deletePhotosAccount(userId: number, id: number): Promise { const p = join(tasksDir, f); const st = await stat(p).catch(() => null); if (!st) continue; - out.push({ taskId: f.slice(0, -'.output'.length), path: p, cwdLabel: cwd.name, sizeBytes: st.size, mtimeMs: st.mtimeMs }); + out.push({ + taskId: f.slice(0, -'.output'.length), + path: p, + cwdLabel: cwd.name, + sizeBytes: st.size, + mtimeMs: st.mtimeMs, + }); } } } @@ -109,7 +115,10 @@ activityRouter.post('/announce', async (ctx) => { if (!safe) return ctx.text('path is not under an allowed root', 403); await mkdir(dirname(ANNOUNCED_PATH), { recursive: true }); const list = await readAnnounced(); - const next = [{ name: body.name, path: safe, ts: Date.now() }, ...list.filter((a) => a.name !== body.name)].slice(0, 100); + const next = [{ name: body.name, path: safe, ts: Date.now() }, ...list.filter((a) => a.name !== body.name)].slice( + 0, + 100, + ); await writeFile(ANNOUNCED_PATH, JSON.stringify(next)); return ctx.json({ ok: true, name: body.name, path: safe }); }); diff --git a/src/servers/api/browser/relay-auth.ts b/src/servers/api/browser/relay-auth.ts index e6d59445..729dff98 100644 --- a/src/servers/api/browser/relay-auth.ts +++ b/src/servers/api/browser/relay-auth.ts @@ -11,9 +11,7 @@ const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1'; // tokens from the platform's signing key is the coupling the per-purpose split exists to remove. export function deriveRelayToken(userId: number, port: number, salt: string): string { - return createHmac('sha256', getKey('jwt')) - .update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`) - .digest('hex'); + return createHmac('sha256', getKey('jwt')).update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`).digest('hex'); } const tokenToUser = new Map(); diff --git a/src/servers/api/chat-types.ts b/src/servers/api/chat-types.ts index c18a489e..6fbdf8f8 100644 --- a/src/servers/api/chat-types.ts +++ b/src/servers/api/chat-types.ts @@ -1,6 +1,6 @@ /** * @deprecated Legacy chat types - Use types from ./chat/types.ts instead - * + * * This file is kept for backward compatibility with existing code. * New code should import from ./chat/types.ts */ diff --git a/src/servers/api/chat/logger.ts b/src/servers/api/chat/logger.ts index bf75e12f..eeb42454 100644 --- a/src/servers/api/chat/logger.ts +++ b/src/servers/api/chat/logger.ts @@ -26,11 +26,11 @@ function formatTimestamp(): string { function formatContext(context?: LogContext): string { if (!context || Object.keys(context).length === 0) return ''; - + const lines = Object.entries(context) .map(([key, value]) => ` ${key}=${value}`) .join('\n'); - + return '\n' + lines; } diff --git a/src/servers/api/server-settings/smtp.ts b/src/servers/api/server-settings/smtp.ts index f4ed0237..814ed4c0 100644 --- a/src/servers/api/server-settings/smtp.ts +++ b/src/servers/api/server-settings/smtp.ts @@ -38,7 +38,9 @@ function buildTransportUrl(config: SmtpConfig): string { if (config.provider === 'mailhog') { return `smtp://${config.host ?? 'localhost'}:${config.port ?? 1025}`; } - const auth = config.username ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@` : ''; + const auth = config.username + ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@` + : ''; const protocol = config.secure ? 'smtps' : 'smtp'; return `${protocol}://${auth}${config.host}:${config.port ?? 587}`; } @@ -73,7 +75,7 @@ smtpRouter.post('/test-connection', async (ctx) => { if (result.type === 'resend') { const res = await fetch('https://api.resend.com/domains', { - headers: { 'Authorization': `Bearer ${result.apiKey}` }, + headers: { Authorization: `Bearer ${result.apiKey}` }, }); if (!res.ok) { const err = await res.json(); @@ -103,14 +105,15 @@ smtpRouter.post('/test', async (ctx) => { } const from = `${body.fromName} <${body.fromEmail}>`; - const testHtml = '

Officer Test Email

If you received this, your email configuration is working correctly.

'; + const testHtml = + '

Officer Test Email

If you received this, your email configuration is working correctly.

'; try { if (body.provider === 'resend') { const res = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { - 'Authorization': `Bearer ${body.apiKey}`, + Authorization: `Bearer ${body.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from, to: body.to, subject: 'officer.dev Test Email', html: testHtml }), diff --git a/src/servers/api/system-monitor/system-monitor.ts b/src/servers/api/system-monitor/system-monitor.ts index 05db5155..884d1b9c 100644 --- a/src/servers/api/system-monitor/system-monitor.ts +++ b/src/servers/api/system-monitor/system-monitor.ts @@ -57,7 +57,16 @@ async function readDisks() { const { stdout } = await exec('df', [ '-B1', '--output=target,fstype,size,used,pcent', - '-x', 'tmpfs', '-x', 'devtmpfs', '-x', 'squashfs', '-x', 'overlay', '-x', 'efivarfs', + '-x', + 'tmpfs', + '-x', + 'devtmpfs', + '-x', + 'squashfs', + '-x', + 'overlay', + '-x', + 'efivarfs', ]); return stdout .trim() @@ -131,7 +140,9 @@ async function readTemps() { } const CPU_DRIVERS = ['k10temp', 'zenpower', 'coretemp', 'k8temp', 'cpu_thermal']; const cpu = - sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label)) ?? + sensors.find( + (s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label), + ) ?? sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase())) ?? null; return { cpuC: cpu?.celsius ?? null, cpuLabel: cpu ? `${cpu.name} · ${cpu.label}` : null, sensors }; @@ -151,15 +162,18 @@ async function readGpu() { const busyRaw = await readFile(`${dev}/gpu_busy_percent`, 'utf8').catch(() => null); if (busyRaw == null) continue; const busyPct = Number.parseInt(busyRaw.trim(), 10); - const vramUsed = Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0; - const vramTotal = Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0; + const vramUsed = + Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0; + const vramTotal = + Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0; return { busyPct: Number.isFinite(busyPct) ? busyPct : 0, vramUsedBytes: vramUsed, vramTotalBytes: vramTotal }; } return null; } // Network throughput — bytes/sec computed from the delta since the previous /stats call (~2s window). -let lastNet: { total: { rx: number; tx: number }; per: Record; ts: number } | null = null; +let lastNet: { total: { rx: number; tx: number }; per: Record; ts: number } | null = + null; async function readNet() { const now = Date.now(); let data: string; @@ -185,11 +199,20 @@ async function readNet() { } const prev = lastNet; lastNet = { total: { rx: totRx, tx: totTx }, per, ts: now }; - if (!prev || now <= prev.ts) return { rxBytesPerSec: 0, txBytesPerSec: 0, interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[] }; + if (!prev || now <= prev.ts) + return { + rxBytesPerSec: 0, + txBytesPerSec: 0, + interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[], + }; const dt = (now - prev.ts) / 1000; const rate = (cur: number, old: number) => Math.max(0, Math.round((cur - old) / dt)); const interfaces = Object.entries(per) - .map(([name, v]) => ({ name, rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx), txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx) })) + .map(([name, v]) => ({ + name, + rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx), + txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx), + })) .filter((i) => i.rxBytesPerSec > 0 || i.txBytesPerSec > 0) .sort((a, b) => b.rxBytesPerSec + b.txBytesPerSec - (a.rxBytesPerSec + a.txBytesPerSec)); return { rxBytesPerSec: rate(totRx, prev.total.rx), txBytesPerSec: rate(totTx, prev.total.tx), interfaces }; @@ -215,7 +238,9 @@ async function readPower() { for (const d of await readdir('/sys/class/hwmon')) { const base = `/sys/class/hwmon/${d}`; if ((await readFile(`${base}/name`, 'utf8').catch(() => '')).trim() !== 'amdgpu') continue; - const p = (await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ?? (await readFile(`${base}/power1_input`, 'utf8').catch(() => null)); + const p = + (await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ?? + (await readFile(`${base}/power1_input`, 'utf8').catch(() => null)); if (p != null) { const uw = Number.parseInt(p.trim(), 10); if (Number.isFinite(uw)) gpuWatts = Math.round((uw / 1e6) * 10) / 10; @@ -233,8 +258,7 @@ systemMonitorRouter.get('/stats', async (ctx) => { await new Promise((r) => setTimeout(r, 120)); const second = await readCpuSample().catch(() => null); - const cpuUsage = - first && second ? pct(second.busy - first.busy, second.total - first.total) : 0; + const cpuUsage = first && second ? pct(second.busy - first.busy, second.total - first.total) : 0; const perCore = first && second ? second.perCore.map((c, i) => { @@ -311,7 +335,9 @@ systemMonitorRouter.get('/pm2', async (ctx) => { // GET /docker — running docker containers (the "dockers" scope). systemMonitorRouter.get('/docker', async (ctx) => { try { - const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], { maxBuffer: 8 * 1024 * 1024 }); + const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], { + maxBuffer: 8 * 1024 * 1024, + }); return ctx.json({ containers: stdout .trim() diff --git a/src/servers/api/tasks/process-tree.ts b/src/servers/api/tasks/process-tree.ts index 0e386dce..fb85f9e8 100644 --- a/src/servers/api/tasks/process-tree.ts +++ b/src/servers/api/tasks/process-tree.ts @@ -43,11 +43,19 @@ export function descendantPids(root: number): number[] { export function killTree(root: number) { const pids = [root, ...descendantPids(root)]; for (const pid of pids) { - try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ } + try { + process.kill(pid, 'SIGTERM'); + } catch { + /* already gone */ + } } setTimeout(() => { for (const pid of pids) { - try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } + try { + process.kill(pid, 'SIGKILL'); + } catch { + /* gone */ + } } }, 2000); } diff --git a/src/servers/api/users/reset-user-password.ts b/src/servers/api/users/reset-user-password.ts index 9e7f020b..3a45b7c0 100644 --- a/src/servers/api/users/reset-user-password.ts +++ b/src/servers/api/users/reset-user-password.ts @@ -36,12 +36,7 @@ import { validatePassword } from '../auth/validate-password'; * The specials are a subset of the class `validatePassword` accepts, chosen to survive being copied, * pasted, quoted in a shell and read aloud: no quotes, no backslash, no backtick. */ -const CLASSES = [ - 'abcdefghijkmnopqrstuvwxyz', - 'ABCDEFGHJKLMNPQRSTUVWXYZ', - '23456789', - '!@#$%^&*()-_=+', -] as const; +const CLASSES = ['abcdefghijkmnopqrstuvwxyz', 'ABCDEFGHJKLMNPQRSTUVWXYZ', '23456789', '!@#$%^&*()-_=+'] as const; const PASSWORD_LENGTH = 20; diff --git a/src/servers/app-store/templates/README.md b/src/servers/app-store/templates/README.md index 5fd5320e..844b73e7 100644 --- a/src/servers/app-store/templates/README.md +++ b/src/servers/app-store/templates/README.md @@ -26,13 +26,13 @@ bash setup.sh Every script must: -| Rule | Why | -|---|---| -| **Be idempotent.** Running twice must be safe and must not create a second anything. | Install is resumable; a retry after a half-failure re-runs steps that already succeeded. | -| **Never prompt when `OFFICER_NONINTERACTIVE=1`.** Fail with a clear message instead. | A prompt behind a web form is a hang with no output, which is the worst failure to diagnose. | -| **Write only inside `OFFICER_SERVICE_DIR`.** | The app store owns that directory and nothing else. The user's own estate is never touched. | -| **Emit progress on stdout.** | The installer streams it to a terminal panel in the UI, so the user watches it happen rather than staring at a spinner. | -| **Print `OFFICER_RESULT_=value` for anything the platform must store.** | How a generated secret or a resolved port gets back to `service_connections` without the installer parsing free text. | +| Rule | Why | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| **Be idempotent.** Running twice must be safe and must not create a second anything. | Install is resumable; a retry after a half-failure re-runs steps that already succeeded. | +| **Never prompt when `OFFICER_NONINTERACTIVE=1`.** Fail with a clear message instead. | A prompt behind a web form is a hang with no output, which is the worst failure to diagnose. | +| **Write only inside `OFFICER_SERVICE_DIR`.** | The app store owns that directory and nothing else. The user's own estate is never touched. | +| **Emit progress on stdout.** | The installer streams it to a terminal panel in the UI, so the user watches it happen rather than staring at a spinner. | +| **Print `OFFICER_RESULT_=value` for anything the platform must store.** | How a generated secret or a resolved port gets back to `service_connections` without the installer parsing free text. | Exit non-zero on failure, with the reason on stderr. The installer records it in `last_error` and the row stays `failed` rather than pretending to be installed. diff --git a/src/servers/capabilities/registry.test.ts b/src/servers/capabilities/registry.test.ts index b1f4f1fb..3c036478 100644 --- a/src/servers/capabilities/registry.test.ts +++ b/src/servers/capabilities/registry.test.ts @@ -187,7 +187,9 @@ describe('kinds', () => { test('admin capabilities are never grantable', () => { const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); - for (const key of ['user-admin', 'server-admin', 'wallet', 'headscale']) { + // `headscale` was here until 2026-08-15, when it left with offscale. A plugin's permissions are + // registered at install from its manifest, so they are not in this compile-time list by design. + for (const key of ['user-admin', 'server-admin', 'wallet']) { expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('admin'); expect(grantable.has(key)).toBe(false); } diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index 24ac0269..1c06088c 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -408,14 +408,9 @@ const CORE_REGISTRY: Capability[] = [ // on this router and stays owner-only — see ownerGate in users-router.ts, which is the second lock. selfService: ['PUT /'], }, - { - key: 'headscale', - label: 'Headscale', - description: 'The tailnet: machines, routes and ACLs', - kind: 'admin', - api: ['/headscale'], - routes: ['/headscale'], - }, + // `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares + // its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The + // platform no longer knows this capability exists, which is the entire point. { key: 'wallet', label: 'Wallet', diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 1c95eb2c..7d0faa65 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -25,7 +25,6 @@ import { pluginsRouter } from './api/plugins/router'; // import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router'; import { agentHandoffRouter } from './api/agent-handoff/router'; // import { slskdRouter } from './api/slskd/router'; -import { headscaleRouter } from './api/headscale/router'; // import { transmissionRouter } from './api/transmission/router'; // import { invoiceshelfRouter } from './api/invoiceshelf/router'; // import { jellyfinRouter } from './api/jellyfin/router'; @@ -144,7 +143,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13 // ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 - ['/headscale', headscaleRouter], // ['/transmission', transmissionRouter], // plugin — switched off 2026-08-13 // ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13 // ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13 diff --git a/src/servers/os-user-postgres.ts b/src/servers/os-user-postgres.ts index e037a498..32dacc1d 100644 --- a/src/servers/os-user-postgres.ts +++ b/src/servers/os-user-postgres.ts @@ -432,9 +432,7 @@ export async function dropPostgresRole(osUser: string): Promise if (!url) return { ok: false, error: 'POSTGRES_URL is not set' }; try { - const present = await db.execute<{ rolname: string }>( - sql`select rolname from pg_roles where rolname = ${osUser}`, - ); + const present = await db.execute<{ rolname: string }>(sql`select rolname from pg_roles where rolname = ${osUser}`); // Already gone is success, so a retry after a partial teardown finishes rather than refuses. if (present.length === 0) return { ok: true, removed: false, reassigned: [] }; diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 75492198..671d6541 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -93,6 +93,27 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise

:server`), and it + // subscribes when the plugin's router module is first imported — which happens here, at mount. Start + // the process first and it announces its port to nobody: the sidecar is online, the routes are + // mounted, and every request answers `503 sidecar not available` until something makes it reconnect. + // + // Found installing offscale, whose sidecar binds its own HTTP server. `example` never caught it + // because it has no listener to announce. + const { mounted } = await refreshPluginMounts(); + await step( + steps, + onStep, + mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)', + ); + if (hasSidecar(plugin)) { addPluginToEcosystem(plugin); await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`); @@ -106,18 +127,6 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise

new Response('') }); diff --git a/src/servers/sidecar/claude/index.ts b/src/servers/sidecar/claude/index.ts index 3aedb303..1c3fc684 100644 --- a/src/servers/sidecar/claude/index.ts +++ b/src/servers/sidecar/claude/index.ts @@ -4,7 +4,6 @@ import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy' import { createSidecarConnector } from '../connect'; import { API_URL } from '../../officer-url.mjs'; - // ── Startup ── if (!acquireLock()) { diff --git a/src/servers/sidecar/claude/proxy.ts b/src/servers/sidecar/claude/proxy.ts index c013d0c7..c1107473 100644 --- a/src/servers/sidecar/claude/proxy.ts +++ b/src/servers/sidecar/claude/proxy.ts @@ -357,7 +357,12 @@ export function startAnthropicProxy() { headers.delete('x-api-key'); headers.set('Authorization', `Bearer ${token}`); const existingBeta = headers.get('anthropic-beta'); - const betas = existingBeta ? existingBeta.split(',').map((s) => s.trim()).filter(Boolean) : []; + const betas = existingBeta + ? existingBeta + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; if (!betas.includes('oauth-2025-04-20')) betas.push('oauth-2025-04-20'); headers.set('anthropic-beta', betas.join(',')); headers.delete('host'); diff --git a/src/servers/sidecar/email/email-cron.ts b/src/servers/sidecar/email/email-cron.ts index 4b9a0ceb..ee329ccc 100644 --- a/src/servers/sidecar/email/email-cron.ts +++ b/src/servers/sidecar/email/email-cron.ts @@ -21,10 +21,7 @@ async function tick() { console.log(`[email-cron] ${account.email}: ${result.saved} new emails`); } } catch (err) { - console.error( - `[email-cron] Failed to resync ${account.email}:`, - err instanceof Error ? err.message : err, - ); + console.error(`[email-cron] Failed to resync ${account.email}:`, err instanceof Error ? err.message : err); } } } catch (err) { diff --git a/src/servers/sidecar/email/email-idle.ts b/src/servers/sidecar/email/email-idle.ts index 48ac6b74..646e83c0 100644 --- a/src/servers/sidecar/email/email-idle.ts +++ b/src/servers/sidecar/email/email-idle.ts @@ -115,7 +115,15 @@ async function connect(w: Watcher): Promise { function startWatcher(account: Account): void { if (watchers.has(account.id)) return; - const w: Watcher = { account, client: null, closing: false, syncing: false, pending: false, backoff: RECONNECT_BASE, reconnectTimer: null }; + const w: Watcher = { + account, + client: null, + closing: false, + syncing: false, + pending: false, + backoff: RECONNECT_BASE, + reconnectTimer: null, + }; watchers.set(account.id, w); void connect(w); } diff --git a/src/servers/sidecar/email/gmail-api.ts b/src/servers/sidecar/email/gmail-api.ts index b8f6d55f..f6ec5636 100644 --- a/src/servers/sidecar/email/gmail-api.ts +++ b/src/servers/sidecar/email/gmail-api.ts @@ -668,9 +668,7 @@ const gmailSyncHandler = { }); } - console.log( - `[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`, - ); + console.log(`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`); setSyncMeta(db, 'last_sync_date', new Date().toISOString().split('T')[0]!); setSyncMeta(db, 'last_sync_at', new Date().toISOString()); diff --git a/src/servers/sidecar/email/index.ts b/src/servers/sidecar/email/index.ts index 783df087..501438d0 100644 --- a/src/servers/sidecar/email/index.ts +++ b/src/servers/sidecar/email/index.ts @@ -6,7 +6,6 @@ import { startEmailServer } from './http'; import { createSidecarConnector } from '../connect'; import { API_URL } from '../../officer-url.mjs'; - // The sidecar used to reach BACK into the platform's queue over this socket to get a sync run — // enqueueViaWs / listJobsViaWs and a pending-response map. Syncs run in this process now // (sync-runner.ts), so the shim is gone and nothing but a port crosses the socket at startup. diff --git a/src/servers/sidecar/email/routes.ts b/src/servers/sidecar/email/routes.ts index 1336fb4b..667fb077 100644 --- a/src/servers/sidecar/email/routes.ts +++ b/src/servers/sidecar/email/routes.ts @@ -364,7 +364,6 @@ emailRouter.delete('/messages/:id', async (ctx) => { } }); - emailRouter.get('/sync-status', async (ctx) => { const user = ctx.get('user'); diff --git a/src/servers/sidecar/email/store.ts b/src/servers/sidecar/email/store.ts index 241e1923..6701c815 100644 --- a/src/servers/sidecar/email/store.ts +++ b/src/servers/sidecar/email/store.ts @@ -122,7 +122,13 @@ const firstAddress = (value: unknown): string => { * Groups by normalized subject + the counterpart address, so recurring 1:1 conversations collapse * while unrelated same-subject mail from different people stays apart. Trivial subjects stay ungrouped. */ -function fallbackThreadId(row: { id: string; subject: unknown; from_address: unknown; to_address: unknown; email_account: unknown }): string { +function fallbackThreadId(row: { + id: string; + subject: unknown; + from_address: unknown; + to_address: unknown; + email_account: unknown; +}): string { const norm = normalizeSubject(typeof row.subject === 'string' ? row.subject : ''); if (!norm) return row.id; const me = typeof row.email_account === 'string' ? row.email_account.toLowerCase() : ''; @@ -202,7 +208,13 @@ function migrate(db: Database): void { function backfillThreadIds(db: Database): void { const rows = db .query('SELECT id, subject, from_address, to_address, email_account FROM emails WHERE thread_id IS NULL') - .all() as Array<{ id: string; subject: unknown; from_address: unknown; to_address: unknown; email_account: unknown }>; + .all() as Array<{ + id: string; + subject: unknown; + from_address: unknown; + to_address: unknown; + email_account: unknown; + }>; if (rows.length === 0) return; const update = db.prepare('UPDATE emails SET thread_id = ? WHERE id = ?'); @@ -243,9 +255,18 @@ function ensureFts(db: Database): void { } const ftsDeleteStmt = 'DELETE FROM emails_fts WHERE id = ?'; -const ftsInsertStmt = 'INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body) VALUES (?, ?, ?, ?, ?, ?)'; +const ftsInsertStmt = + 'INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body) VALUES (?, ?, ?, ?, ?, ?)'; -function syncFtsRow(db: Database, id: string, subject: string, sender: string, recipients: string, snippet: string, body: string): void { +function syncFtsRow( + db: Database, + id: string, + subject: string, + sender: string, + recipients: string, + snippet: string, + body: string, +): void { db.run(ftsDeleteStmt, [id]); db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]); } @@ -319,7 +340,12 @@ function parseBranch(q: string): Branch { return { fts: fts.join(' '), where, params }; } -export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record[]; total: number } { +export function searchEmails( + db: Database, + q: string, + limit: number, + offset: number, +): { rows: Record[]; total: number } { // Split on top-level uppercase OR into branches (Gmail-style; lowercase "or" stays a search word). const branches = q .split(/\s+OR\s+/) @@ -343,7 +369,9 @@ export function searchEmails(db: Database, q: string, limit: number, offset: num } const whereSql = `e.deleted = 0 AND (${conds.join(' OR ')})`; - const rows = db.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`).all(...params, limit, offset) as Record[]; + const rows = db + .query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`) + .all(...params, limit, offset) as Record[]; const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${whereSql}`).get(...params) as { c: number }).c; return { rows, total }; } @@ -372,7 +400,8 @@ const upsertEmailStmt = ` `; const deleteAttachmentsStmt = 'DELETE FROM attachments WHERE email_id = ?'; -const insertAttachmentStmt = 'INSERT INTO attachments (email_id, idx, filename, size, content_type, content) VALUES (?, ?, ?, ?, ?, ?)'; +const insertAttachmentStmt = + 'INSERT INTO attachments (email_id, idx, filename, size, content_type, content) VALUES (?, ?, ?, ?, ?, ?)'; export function upsertEmail(db: Database, email: ParsedEmail): void { const domain = extractDomain(email.fromAddress); @@ -404,7 +433,15 @@ export function upsertEmail(db: Database, email: ParsedEmail): void { db.run(insertAttachmentStmt, [email.id, i, att.filename, att.size, att.contentType, att.content]); } - syncFtsRow(db, email.id, email.subject ?? '', `${email.fromName ?? ''} ${email.fromAddress ?? ''}`.trim(), `${email.to ?? ''} ${email.cc ?? ''}`.trim(), email.snippet ?? '', email.text ?? ''); + syncFtsRow( + db, + email.id, + email.subject ?? '', + `${email.fromName ?? ''} ${email.fromAddress ?? ''}`.trim(), + `${email.to ?? ''} ${email.cc ?? ''}`.trim(), + email.snippet ?? '', + email.text ?? '', + ); db.exec('COMMIT'); } catch (err) { @@ -439,7 +476,22 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label const threadId = computeThreadId(db, id, raw); db.run(upsertEmailStmt, [ - id, integration, emailAccount, name, address, domain, to, cc, subject, date, snippet, html, text, attachments.length, labelsToString(labels), threadId, + id, + integration, + emailAccount, + name, + address, + domain, + to, + cc, + subject, + date, + snippet, + html, + text, + attachments.length, + labelsToString(labels), + threadId, ]); if (attachments.length > 0) { @@ -455,9 +507,7 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label /** Convert a db row to an EmailSummary for the API */ export function rowToSummary(row: Record): EmailSummary { - const from = row.from_name - ? `${row.from_name} <${row.from_address}>` - : (row.from_address as string); + const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string); const labels = labelsFromString(row.labels); @@ -604,7 +654,7 @@ function normalizeCharset(charset: string): string { 'windows-1252': 'latin1', 'windows-1254': 'latin1', 'us-ascii': 'ascii', - 'ascii': 'ascii', + ascii: 'ascii', }; return map[charset] ?? charset; } @@ -691,7 +741,8 @@ function parseAttachments(raw: string): AttachmentMeta[] { // Walk backwards to find the start of this MIME part's headers const partStart = raw.lastIndexOf('\n--', pos); - const headerBlock = partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500); + const headerBlock = + partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500); // Skip inline parts without a filename (e.g. inline text/plain body parts) const hasFilename = /filename/i.test(headerBlock); @@ -731,12 +782,10 @@ function parseAttachments(raw: string): AttachmentMeta[] { content = bodyRaw.replace(/\s/g, ''); } else { // For quoted-printable or 7bit/8bit, re-encode to base64 - const buf = encoding === 'quoted-printable' - ? decodeQuotedPrintableBytes(bodyRaw) - : Buffer.from(bodyRaw); + const buf = encoding === 'quoted-printable' ? decodeQuotedPrintableBytes(bodyRaw) : Buffer.from(bodyRaw); content = buf.toString('base64'); } - size = Math.floor(content.length * 3 / 4); + size = Math.floor((content.length * 3) / 4); } results.push({ filename, size, contentType, content }); diff --git a/src/servers/sidecar/gitea/index.ts b/src/servers/sidecar/gitea/index.ts index 536c5339..09c45c27 100644 --- a/src/servers/sidecar/gitea/index.ts +++ b/src/servers/sidecar/gitea/index.ts @@ -43,7 +43,6 @@ import { API_URL } from '../../officer-url.mjs'; // sidecar from being a general-purpose SSRF hop into whatever else is on that host. // ───────────────────────────────────────────────────────────────────────────────────────────────── - // Everything under /api/v1 the UI legitimately needs. // // `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site diff --git a/src/servers/sidecar/invoiceshelf/index.ts b/src/servers/sidecar/invoiceshelf/index.ts index 08ec70bd..2a760828 100644 --- a/src/servers/sidecar/invoiceshelf/index.ts +++ b/src/servers/sidecar/invoiceshelf/index.ts @@ -52,7 +52,6 @@ import { API_URL } from '../../officer-url.mjs'; // update/*, installation/*, mail config, settings writes, ownership transfer — is deliberately unreachable. // ───────────────────────────────────────────────────────────────────────────────────────────────── - /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); diff --git a/src/servers/sidecar/jellyfin/index.ts b/src/servers/sidecar/jellyfin/index.ts index 27176a93..8bf42026 100644 --- a/src/servers/sidecar/jellyfin/index.ts +++ b/src/servers/sidecar/jellyfin/index.ts @@ -44,7 +44,6 @@ import { API_URL } from '../../officer-url.mjs'; // general proxy, and why HLS forces it to keep Jellyfin's own paths. // ───────────────────────────────────────────────────────────────────────────────────────────────── - /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); diff --git a/src/servers/sidecar/memos/index.ts b/src/servers/sidecar/memos/index.ts index d1582496..650fc81f 100644 --- a/src/servers/sidecar/memos/index.ts +++ b/src/servers/sidecar/memos/index.ts @@ -23,7 +23,6 @@ import { API_URL } from '../../officer-url.mjs'; // SSRF hop into whatever else is on that host. // ───────────────────────────────────────────────────────────────────────────────────────────────── - // Everything under /api/v1 the UI legitimately needs. Auth routes are excluded on purpose: signin and // signout would mint or destroy sessions on the instance, and this sidecar authenticates with a stored // token rather than borrowing the owner's Memos session. diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts index 9c04fdb2..8939eb9f 100644 --- a/src/servers/sidecar/music/index.ts +++ b/src/servers/sidecar/music/index.ts @@ -41,7 +41,6 @@ import { import { DATA_PATH } from '../../data-path'; import { API_URL } from '../../officer-url.mjs'; - // ── Per-user state validation ── // The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're // loopback-only so we trust it). Favorite/playlist `key`s are opaque paths we never interpret. @@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null => // `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading. // ───────────────────────────────────────────────────────────────────────────────────────────────── - // ── Audio-streaming HTTP server ── /** Grab an ephemeral free port by briefly binding one and releasing it. */ diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index 3f5431c6..49990c85 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void { // A cache-format upgrade rebuilds every album by definition, so the delta is expected and says // nothing about drift. Label it rather than let it read as 6k albums of rot. if (prev.version !== next.version) { - console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`); + console.log( + `[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`, + ); } const { added, removed, changed } = diffManifest(prev, next); @@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void { return; } - console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`); + console.log( + `[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`, + ); const sample = (label: string, rels: string[]) => { for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`); if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`); diff --git a/src/servers/sidecar/music/nightly-reindex.ts b/src/servers/sidecar/music/nightly-reindex.ts index 178416e4..b1532454 100644 --- a/src/servers/sidecar/music/nightly-reindex.ts +++ b/src/servers/sidecar/music/nightly-reindex.ts @@ -21,7 +21,9 @@ export function startNightlyReindex(): void { const schedule = () => { const ms = msUntilNextHour(REINDEX_HOUR); const at = new Date(Date.now() + ms); - console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`); + console.log( + `[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`, + ); timer = setTimeout(async () => { console.log('[music] nightly full reindex starting'); try { diff --git a/src/servers/sidecar/music/stream-audio.ts b/src/servers/sidecar/music/stream-audio.ts index 52ebc734..938d4f2c 100644 --- a/src/servers/sidecar/music/stream-audio.ts +++ b/src/servers/sidecar/music/stream-audio.ts @@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise new Response('') }); diff --git a/src/servers/sidecar/pty/server.mjs b/src/servers/sidecar/pty/server.mjs index 2b039ba3..00e714eb 100644 --- a/src/servers/sidecar/pty/server.mjs +++ b/src/servers/sidecar/pty/server.mjs @@ -31,7 +31,7 @@ export function startServer() { // `osUser` scopes both to one account's sessions. The platform sends it for a member and omits it for the // owner; absent means unscoped. Until this existed these two listed and killed EVERY shell on the box for // anyone who could reach them, which was safe only because the terminal was owner-only. - const scope = url.searchParams.has('osUser') ? (url.searchParams.get('osUser') || null) : undefined; + const scope = url.searchParams.has('osUser') ? url.searchParams.get('osUser') || null : undefined; if (url.pathname === '/_officer/sessions' && req.method === 'GET') { return json(res, 200, { sessions: store.list(scope) }); diff --git a/src/servers/sidecar/queue-runner.ts b/src/servers/sidecar/queue-runner.ts index d2811387..a5252abb 100644 --- a/src/servers/sidecar/queue-runner.ts +++ b/src/servers/sidecar/queue-runner.ts @@ -237,7 +237,10 @@ async function runJob(job: Job) { fresh.completedAt = Date.now(); fresh.meta = { ...fresh.meta, ...sharedMeta }; await writeJob(fresh); - console.error(`[sidecar:queue] ✗ job ${fresh.id} failed at step "${step.name}" in ${formatDuration(Date.now() - startTime)}:`, errorMessage); + console.error( + `[sidecar:queue] ✗ job ${fresh.id} failed at step "${step.name}" in ${formatDuration(Date.now() - startTime)}:`, + errorMessage, + ); await notifyFailure(fresh); return; } diff --git a/src/servers/sidecar/slskd/index.ts b/src/servers/sidecar/slskd/index.ts index 9378d21b..0fa76bb1 100644 --- a/src/servers/sidecar/slskd/index.ts +++ b/src/servers/sidecar/slskd/index.ts @@ -38,7 +38,6 @@ import { API_URL } from '../../officer-url.mjs'; // (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its // credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP. - /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); diff --git a/src/servers/sidecar/transmission/index.ts b/src/servers/sidecar/transmission/index.ts index d6f04f56..54a4a443 100644 --- a/src/servers/sidecar/transmission/index.ts +++ b/src/servers/sidecar/transmission/index.ts @@ -46,7 +46,6 @@ import { API_URL } from '../../officer-url.mjs'; // platform, and which daemon to talk to is per-owner data. // ───────────────────────────────────────────────────────────────────────────────────────────────── - /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); diff --git a/src/servers/sidecar/vault/index.ts b/src/servers/sidecar/vault/index.ts index 30d95351..17f4f183 100644 --- a/src/servers/sidecar/vault/index.ts +++ b/src/servers/sidecar/vault/index.ts @@ -22,7 +22,6 @@ import { API_URL } from '../../officer-url.mjs'; // The server listens on a random loopback port, reported to the API on connect so it can route here. // ───────────────────────────────────────────────────────────────────────────────────────────────── - /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); diff --git a/src/servers/sidecar/vnc/index.ts b/src/servers/sidecar/vnc/index.ts index e0746db2..c2c03469 100644 --- a/src/servers/sidecar/vnc/index.ts +++ b/src/servers/sidecar/vnc/index.ts @@ -4,7 +4,6 @@ import { createSidecarConnector } from '../connect'; import { getOwnerHomeDir } from '@@/data-path'; import { API_URL } from '../../officer-url.mjs'; - // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void; diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts index c23af54b..d4f8b458 100644 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -148,9 +148,7 @@ function readDisplayGeometry(xauthority: string, display: string): DisplayGeomet // "HDMI-A-0 connected primary 3840x2160+0+0 (normal left ..." — the geometry only appears on an // output that is actually enabled, so a connected-but-off output correctly yields no match. const p = out.match(/^\S+ connected primary (\d+)x(\d+)\+(\d+)\+(\d+)/m); - const primary = p - ? { w: Number(p[1]), h: Number(p[2]), x: Number(p[3]), y: Number(p[4]) } - : null; + const primary = p ? { w: Number(p[1]), h: Number(p[2]), x: Number(p[3]), y: Number(p[4]) } : null; return { framebufferWidth: fb ? Number(fb[1]) : null, primary, connected }; } diff --git a/src/servers/sidecar/wallet/index.ts b/src/servers/sidecar/wallet/index.ts index f0ebf90b..c9f3a7e8 100644 --- a/src/servers/sidecar/wallet/index.ts +++ b/src/servers/sidecar/wallet/index.ts @@ -82,7 +82,6 @@ import { API_URL } from '../../officer-url.mjs'; // WALLET_LOCKED from signing paths only; every read above keeps working. // ───────────────────────────────────────────────────────────────────────────────────────────────── - /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); diff --git a/src/servers/tool-loader-source.ts b/src/servers/tool-loader-source.ts index 1d8f1f02..ebec63dd 100644 --- a/src/servers/tool-loader-source.ts +++ b/src/servers/tool-loader-source.ts @@ -116,11 +116,7 @@ function buildSchema(inputs: Record): TSchema { switch (param.type) { case 'enum': { const raw = param.values; - const values = Array.isArray(raw) - ? raw - : typeof raw === 'string' - ? raw.split(',').map((v) => v.trim()) - : []; + const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',').map((v) => v.trim()) : []; schema = Type.Union( values.map((v) => Type.Literal(v)), { description: param.description }, @@ -175,7 +171,7 @@ function discoverTools(dir: string): Array<{ toolDir: string; entryFile: string; continue; } - if ((meta.targets as string ?? 'all') === 'claude') continue; + if (((meta.targets as string) ?? 'all') === 'claude') continue; discovered.push({ toolDir, entryFile, meta: meta as ToolMeta }); } @@ -225,7 +221,9 @@ export default function (pi: ExtensionAPI) { if (typeof executeFn !== 'function') { return { - content: [{ type: 'text', text: `[tool-loader] ${meta.name}/index.ts must export an "execute" function` }], + content: [ + { type: 'text', text: `[tool-loader] ${meta.name}/index.ts must export an "execute" function` }, + ], details: {}, isError: true, }; diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 045d4e0f..1ebc4e56 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -11,7 +11,6 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; import { appRegistryMetas as musicMetas } from '../apps/Music'; import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek'; -import { appRegistryMetas as headscaleMetas } from '../apps/Headscale'; import { appRegistryMetas as photosMetas } from '../apps/Photos'; import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin'; import { appRegistryMetas as transmissionMetas } from '../apps/Transmission'; @@ -36,7 +35,6 @@ export const apps = [ ...desktopMetas, ...musicMetas, ...soulseekMetas, - ...headscaleMetas, ...photosMetas, ...jellyfinMetas, ...transmissionMetas, diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 870f02c5..96a89363 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -35,8 +35,6 @@ export type { SelectedSession } from './apps/ChatHistory'; export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory'; export { CodeEditorView } from './apps/CodeEditor'; // The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL. -export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared'; -export type { HeadscaleSectionId } from './apps/Headscale/shared'; // Same for /photos. export { DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from './apps/Photos/shared';