offscale is a plugin

headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
@@ -1,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;
@@ -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.
@@ -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
+46
View File
@@ -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'],
},
],
};
@@ -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
@@ -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.
//
@@ -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:
@@ -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
@@ -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';
@@ -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
@@ -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';
@@ -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';
@@ -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
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'headscale-root',
id: 'offscale-root',
direction: 'horizontal',
children: [
{
@@ -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';
@@ -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;
/**
@@ -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,
});
@@ -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: [] };
@@ -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 };
@@ -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<HeadscaleSshTest>('/headscale/_officer/ssh-test', { host }),
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
});
}
-2
View File
@@ -63,8 +63,6 @@ export function App() {
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
@@ -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<LayoutNode>('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 <Navigate to={headscaleSectionPath(DEFAULT_HEADSCALE_SECTION)} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{
allowed: ['headscale-servers', 'headscale-nav', 'headscale-view'],
fallback: 'headscale-view',
}}
/>
</div>
);
};
@@ -1 +0,0 @@
export * from './HeadscaleScreen';
@@ -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' },
@@ -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';
@@ -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' },
@@ -1,10 +1,4 @@
export {
getAllRoleGrants,
getRoleGrants,
setRoleGrant,
revokeRoleGrant,
replaceRoleGrants,
} from './queries';
export { getAllRoleGrants, getRoleGrants, setRoleGrant, revokeRoleGrant, replaceRoleGrants } from './queries';
export type { RoleGrant } from './queries';
@@ -1,6 +1 @@
export {
appendChatEvent,
getChatEventsSince,
getLastChatEventSeq,
pruneChatEventsOlderThan,
} from './queries';
export { appendChatEvent, getChatEventsSince, getLastChatEventSeq, pruneChatEventsOlderThan } from './queries';
+4 -1
View File
@@ -43,7 +43,10 @@ export async function waitForDatabase(timeoutMs = 60_000): Promise<boolean> {
return true;
} catch (err) {
if (Date.now() - started >= timeoutMs) {
console.error(`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`, err instanceof Error ? err.message : err);
console.error(
`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`,
err instanceof Error ? err.message : err,
);
return false;
}
if (!announced) {
@@ -1,12 +0,0 @@
export {
listHeadscaleServers,
getActiveHeadscaleCredentials,
getHeadscaleCredentials,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
recordHeadscaleProbe,
} from './queries';
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries';
-2
View File
@@ -34,14 +34,12 @@ export * from './auth';
export * from './capabilities';
export * from './chat-events';
export * from './dashboards';
export * from './headscale';
export * from './integrations';
export * from './pipeline-jobs';
export * from './server';
export * from './service-connections';
export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
@@ -60,7 +60,13 @@ export async function getActiveInvoiceshelfCredentials(userId: number): Promise<
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
return {
id: row.id,
label: row.label,
url: row.url,
token: decryptSecret('invoiceshelf', row.token),
companyId: row.companyId,
};
}
/** One account's credentials by id — for probing a specific account rather than the active one. */
@@ -70,7 +76,13 @@ export async function getInvoiceshelfCredentials(userId: number, id: number): Pr
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
return {
id: row.id,
label: row.label,
url: row.url,
token: decryptSecret('invoiceshelf', row.token),
companyId: row.companyId,
};
}
type CreateInvoiceshelfAccountParams = {
+1 -8
View File
@@ -14,11 +14,4 @@ export {
setPlaylistItems,
} from './queries';
export type {
FavoriteKind,
GroupedFavorites,
NowPlaying,
NowPlayingInput,
PlaylistSummary,
Playlist,
} from './queries';
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput, PlaylistSummary, Playlist } from './queries';
+1 -7
View File
@@ -1,9 +1,3 @@
export {
upsertPushDevice,
getPushDevices,
deletePushDevice,
recordPushFailure,
markPushDeviceSeen,
} from './queries';
export { upsertPushDevice, getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from './queries';
export type { PushDeviceSelect, PushDeviceInsert } from '../types';
@@ -69,8 +69,5 @@ export async function recordPushFailure(token: string): Promise<void> {
/** A send worked: clear the failure count and mark the device alive. */
export async function markPushDeviceSeen(token: string): Promise<void> {
await db
.update(pushDevices)
.set({ failureCount: 0, lastSeenAt: new Date() })
.where(eq(pushDevices.token, token));
await db.update(pushDevices).set({ failureCount: 0, lastSeenAt: new Date() }).where(eq(pushDevices.token, token));
}
@@ -158,7 +158,10 @@ export async function deletePhotosAccount(userId: number, id: number): Promise<b
.orderBy(desc(photosConfig.createdAt))
.limit(1);
if (next) {
await tx.update(photosConfig).set({ isActive: true, updatedAt: new Date() }).where(eq(photosConfig.id, next.id));
await tx
.update(photosConfig)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(photosConfig.id, next.id));
}
}
return true;
-2
View File
@@ -32,8 +32,6 @@ export * from './chat-events/schema'; // chat_session_events
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
// it as an optional extra. The secret store bootstraps a `headscale` key on this basis.
export * from './headscale/schema'; // headscale_servers
// The app store itself, and the credentials it stores for what it installs. `app-store/effects.ts`
// reads service_connections, so this is core however few plugins are installed.
-1
View File
@@ -61,7 +61,6 @@ export type DashboardInsert = typeof Schema.dashboards.$inferInsert;
export type ScreenSelect = typeof Schema.screens.$inferSelect;
export type ScreenInsert = typeof Schema.screens.$inferInsert;
// ── Email ──
export type EmailAccountSelect = typeof EmailSchema.emailAccounts.$inferSelect;
@@ -1,8 +1 @@
export {
getUserSettings,
setUserSettings,
getUserState,
patchUserState,
getDockPaths,
setDockPaths,
} from './queries';
export { getUserSettings, setUserSettings, getUserState, patchUserState, getDockPaths, setDockPaths } from './queries';
+11 -2
View File
@@ -55,7 +55,13 @@ async function listTaskFiles(): Promise<TaskFile[]> {
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 });
});
+1 -3
View File
@@ -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<string, number>();
+7 -4
View File
@@ -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 = '<h2>Officer Test Email</h2><p>If you received this, your email configuration is working correctly.</p>';
const testHtml =
'<h2>Officer Test Email</h2><p>If you received this, your email configuration is working correctly.</p>';
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 }),
@@ -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<string, { rx: number; tx: number }>; ts: number } | null = null;
let lastNet: { total: { rx: number; tx: number }; per: Record<string, { rx: number; tx: number }>; 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()
+10 -2
View File
@@ -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);
}
+1 -6
View File
@@ -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;
+1 -1
View File
@@ -27,7 +27,7 @@ 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. |
+3 -1
View File
@@ -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);
}
+3 -8
View File
@@ -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',
-2
View File
@@ -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<typeof createRouter>
// ['/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
+1 -3
View File
@@ -432,9 +432,7 @@ export async function dropPostgresRole(osUser: string): Promise<DropRoleResult>
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: [] };
+32 -12
View File
@@ -93,6 +93,27 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise<P
const steps: string[] = [];
try {
if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)');
await recordPluginInstall(appName, plugin.manifest.version);
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
// MOUNT BEFORE STARTING THE SIDECAR, and the order is not cosmetic.
//
// `createSidecarProxy` learns its sidecar's port from a one-shot event (`<name>: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<P
await step(steps, onStep, 'sidecar: started');
}
if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)');
await recordPluginInstall(appName, plugin.manifest.version);
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
const { mounted } = await refreshPluginMounts();
await step(
steps,
onStep,
mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)',
);
return { ok: true, appName, steps };
} catch (err) {
return { ok: false, appName, steps, error: err instanceof Error ? err.message : String(err) };
@@ -166,14 +175,25 @@ export async function setPluginRunning(
await step(steps, onStep, enabled ? 'enabled' : 'disabled');
const plugin = await findPlugin(appName);
// Enabling mounts BEFORE starting, for the same reason install does: the proxy has to be listening
// before the sidecar announces its port. Disabling is the mirror — stop answering, then stop the
// process — so neither direction leaves a mounted route in front of a sidecar that cannot be reached.
if (enabled) {
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
}
if (plugin && hasSidecar(plugin)) {
const name = pluginProcessName(appName);
const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR);
await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
}
if (!enabled) {
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
}
return { ok: true, appName, steps };
}
-1
View File
@@ -24,7 +24,6 @@ import { API_URL } from '../../officer-url.mjs';
// machine-facing interface. Same split officer-email already uses.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** 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('') });
-1
View File
@@ -4,7 +4,6 @@ import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy'
import { createSidecarConnector } from '../connect';
import { API_URL } from '../../officer-url.mjs';
// ── Startup ──
if (!acquireLock()) {
+6 -1
View File
@@ -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');
+1 -4
View File
@@ -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) {
+9 -1
View File
@@ -115,7 +115,15 @@ async function connect(w: Watcher): Promise<void> {
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);
}
+1 -3
View File
@@ -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());
-1
View File
@@ -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.
-1
View File
@@ -364,7 +364,6 @@ emailRouter.delete('/messages/:id', async (ctx) => {
}
});
emailRouter.get('/sync-status', async (ctx) => {
const user = ctx.get('user');
+67 -18
View File
@@ -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<string, unknown>[]; total: number } {
export function searchEmails(
db: Database,
q: string,
limit: number,
offset: number,
): { rows: Record<string, unknown>[]; 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<string, unknown>[];
const rows = db
.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`)
.all(...params, limit, offset) as Record<string, unknown>[];
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<string, unknown>): 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 });
-1
View File
@@ -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
@@ -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('') });
-1
View File
@@ -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('') });
-1
View File
@@ -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.
-2
View File
@@ -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. */
+6 -2
View File
@@ -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`);
+3 -1
View File
@@ -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 {
+15 -2
View File
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
if (cached !== undefined) return cached;
try {
const proc = Bun.spawn(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath],
[
'ffprobe',
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
absPath,
],
{ stdout: 'pipe', stderr: 'ignore' },
);
const out = (await new Response(proc.stdout).text()).trim();
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
}
return new Response(file.slice(start, end + 1), {
status: 206,
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) },
headers: {
...baseHeaders,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(end - start + 1),
},
});
}
}
-1
View File
@@ -27,7 +27,6 @@ import { API_URL } from '../../officer-url.mjs';
// the visible string is composed here rather than sent by the producer.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const VALID_TYPES: NotifyType[] = ['job', 'mail', 'agent', 'download', 'test'];
/** Grab an ephemeral free port by briefly binding one and releasing it. */
-1
View File
@@ -47,7 +47,6 @@ import { API_URL } from '../../officer-url.mjs';
// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** 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('') });
+1 -1
View File
@@ -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) });
+4 -1
View File
@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More