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:
@@ -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:
|
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
||||||
// this file must never grow app logic.
|
// this file must never grow app logic.
|
||||||
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
|||||||
|
|
||||||
const proxy = createSidecarProxy({
|
const proxy = createSidecarProxy({
|
||||||
name: 'headscale',
|
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. */
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from 'officerdb/db';
|
||||||
import { headscaleServers } from './schema';
|
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 —
|
// 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.
|
// 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 { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
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
|
// 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
|
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||||
@@ -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';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
|
|
||||||
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
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.
|
// 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
|
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||||
// wire-level quirks are handled once:
|
// 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';
|
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
|
// 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 { OfficerContext } from './routes';
|
||||||
import type { OfficerUser } from './normalize';
|
import type { OfficerUser } from './normalize';
|
||||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
import { arrayField, toUser } from './normalize';
|
import { arrayField, toUser } from './normalize';
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { handleOfficerRoute } from './routes';
|
import { handleOfficerRoute } from './routes';
|
||||||
import { MIN_VERSION_LABEL } from './version';
|
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
|
// 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
|
// 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 { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
deleteHeadscaleServer,
|
deleteHeadscaleServer,
|
||||||
getHeadscaleCredentials,
|
getHeadscaleCredentials,
|
||||||
recordHeadscaleProbe,
|
recordHeadscaleProbe,
|
||||||
} from 'officerdb';
|
} from '../db/queries';
|
||||||
import { createClient, HeadscaleError } from './client';
|
import { createClient, HeadscaleError } from './client';
|
||||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
|||||||
import { Loader2, TerminalSquare } from 'lucide-react';
|
import { Loader2, TerminalSquare } from 'lucide-react';
|
||||||
import { headscaleSectionPath } from './shared';
|
import { headscaleSectionPath } from './shared';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||||
import { TerminalView } from '../Terminal/Terminal';
|
import { TerminalView } from 'officerdev';
|
||||||
import { Button } from './Cards';
|
import { Button } from './Cards';
|
||||||
|
|
||||||
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
|
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
|
|||||||
|
|
||||||
export const defaultLayout: LayoutNode = {
|
export const defaultLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'headscale-root',
|
id: 'offscale-root',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
||||||
import { HeadscaleNav } from './HeadscaleNav';
|
import { HeadscaleNav } from './HeadscaleNav';
|
||||||
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
||||||
+1
-1
@@ -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
|
// 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.
|
// 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;
|
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+15
-18
@@ -24,28 +24,26 @@ export function useHeadscaleNodes() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: NODES_KEY,
|
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.
|
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
||||||
refetchInterval: 20_000,
|
refetchInterval: 20_000,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rename = useMutation({
|
const rename = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
|
||||||
post(`/headscale/_officer/nodes/${id}/rename`, { name }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setTags = useMutation({
|
const setTags = useMutation({
|
||||||
mutationFn: ({ id, tags }: { id: string; tags: string[] }) =>
|
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
|
||||||
post(`/headscale/_officer/nodes/${id}/tags`, { tags }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
||||||
const moveToUser = useMutation({
|
const moveToUser = useMutation({
|
||||||
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
||||||
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
|
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,17 +51,17 @@ export function useHeadscaleNodes() {
|
|||||||
// because Headscale's approve_routes replaces the whole set.
|
// because Headscale's approve_routes replaces the whole set.
|
||||||
const toggleRoute = useMutation({
|
const toggleRoute = useMutation({
|
||||||
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
|
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,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expire = useMutation({
|
const expire = useMutation({
|
||||||
mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`),
|
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,24 +85,23 @@ export function useHeadscaleUsers() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: USERS_KEY,
|
queryKey: USERS_KEY,
|
||||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'),
|
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
|
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
|
||||||
post('/headscale/_officer/users', input),
|
post('/offscale/_officer/users', input),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rename = useMutation({
|
const rename = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
|
||||||
post(`/headscale/_officer/users/${id}/rename`, { name }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +130,7 @@ export function useHeadscaleKeys() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: KEYS_KEY,
|
queryKey: KEYS_KEY,
|
||||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'),
|
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
||||||
staleTime: 30_000,
|
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.
|
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (input: CreateKeyInput) =>
|
mutationFn: (input: CreateKeyInput) =>
|
||||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input),
|
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expire = useMutation({
|
const expire = useMutation({
|
||||||
mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`),
|
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
+1
-1
@@ -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
|
// 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.
|
// 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 INVITES_KEY = ['headscale', 'invites'] as const;
|
||||||
|
|
||||||
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
||||||
+1
-1
@@ -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".
|
// "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 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. */
|
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
||||||
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
||||||
+2
-2
@@ -12,7 +12,7 @@ import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './share
|
|||||||
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
||||||
const EMPTY: HeadscaleServer[] = [];
|
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
|
* 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() {
|
export function useHeadscaleSshTest() {
|
||||||
const { post } = useClient();
|
const { post } = useClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (host: string) => post<HeadscaleSshTest>('/headscale/_officer/ssh-test', { host }),
|
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +63,6 @@ export function App() {
|
|||||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/soulseek/:section" 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" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
<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
|
// 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'`,
|
// 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.
|
// 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
|
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||||
// things that disappears when uninstalled.
|
// things that disappears when uninstalled.
|
||||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export * from './Calendar';
|
|||||||
export * from './Contacts';
|
export * from './Contacts';
|
||||||
export * from './Music';
|
export * from './Music';
|
||||||
export * from './Soulseek';
|
export * from './Soulseek';
|
||||||
export * from './Headscale';
|
|
||||||
export * from './Photos';
|
export * from './Photos';
|
||||||
export * from './Jellyfin';
|
export * from './Jellyfin';
|
||||||
export * from './Transmission';
|
export * from './Transmission';
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
{ 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('/transmission'), title: 'Transmission' },
|
||||||
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
||||||
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
export {
|
export { getAllRoleGrants, getRoleGrants, setRoleGrant, revokeRoleGrant, replaceRoleGrants } from './queries';
|
||||||
getAllRoleGrants,
|
|
||||||
getRoleGrants,
|
|
||||||
setRoleGrant,
|
|
||||||
revokeRoleGrant,
|
|
||||||
replaceRoleGrants,
|
|
||||||
} from './queries';
|
|
||||||
|
|
||||||
export type { RoleGrant } from './queries';
|
export type { RoleGrant } from './queries';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1 @@
|
|||||||
export {
|
export { appendChatEvent, getChatEventsSince, getLastChatEventSeq, pruneChatEventsOlderThan } from './queries';
|
||||||
appendChatEvent,
|
|
||||||
getChatEventsSince,
|
|
||||||
getLastChatEventSeq,
|
|
||||||
pruneChatEventsOlderThan,
|
|
||||||
} from './queries';
|
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export async function waitForDatabase(timeoutMs = 60_000): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (Date.now() - started >= timeoutMs) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (!announced) {
|
if (!announced) {
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
export {
|
|
||||||
listHeadscaleServers,
|
|
||||||
getActiveHeadscaleCredentials,
|
|
||||||
getHeadscaleCredentials,
|
|
||||||
createHeadscaleServer,
|
|
||||||
updateHeadscaleServer,
|
|
||||||
setActiveHeadscaleServer,
|
|
||||||
deleteHeadscaleServer,
|
|
||||||
recordHeadscaleProbe,
|
|
||||||
} from './queries';
|
|
||||||
|
|
||||||
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries';
|
|
||||||
@@ -34,14 +34,12 @@ export * from './auth';
|
|||||||
export * from './capabilities';
|
export * from './capabilities';
|
||||||
export * from './chat-events';
|
export * from './chat-events';
|
||||||
export * from './dashboards';
|
export * from './dashboards';
|
||||||
export * from './headscale';
|
|
||||||
export * from './integrations';
|
export * from './integrations';
|
||||||
export * from './pipeline-jobs';
|
export * from './pipeline-jobs';
|
||||||
export * from './server';
|
export * from './server';
|
||||||
export * from './service-connections';
|
export * from './service-connections';
|
||||||
export * from './user-data';
|
export * from './user-data';
|
||||||
|
|
||||||
|
|
||||||
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
||||||
|
|
||||||
export * from './dav';
|
export * from './dav';
|
||||||
|
|||||||
@@ -60,7 +60,13 @@ export async function getActiveInvoiceshelfCredentials(userId: number): Promise<
|
|||||||
.from(invoiceshelfAccounts)
|
.from(invoiceshelfAccounts)
|
||||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
||||||
if (!row) return null;
|
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. */
|
/** 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)
|
.from(invoiceshelfAccounts)
|
||||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
|
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
|
||||||
if (!row) return null;
|
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 = {
|
type CreateInvoiceshelfAccountParams = {
|
||||||
|
|||||||
@@ -14,11 +14,4 @@ export {
|
|||||||
setPlaylistItems,
|
setPlaylistItems,
|
||||||
} from './queries';
|
} from './queries';
|
||||||
|
|
||||||
export type {
|
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput, PlaylistSummary, Playlist } from './queries';
|
||||||
FavoriteKind,
|
|
||||||
GroupedFavorites,
|
|
||||||
NowPlaying,
|
|
||||||
NowPlayingInput,
|
|
||||||
PlaylistSummary,
|
|
||||||
Playlist,
|
|
||||||
} from './queries';
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
export {
|
export { upsertPushDevice, getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from './queries';
|
||||||
upsertPushDevice,
|
|
||||||
getPushDevices,
|
|
||||||
deletePushDevice,
|
|
||||||
recordPushFailure,
|
|
||||||
markPushDeviceSeen,
|
|
||||||
} from './queries';
|
|
||||||
|
|
||||||
export type { PushDeviceSelect, PushDeviceInsert } from '../types';
|
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. */
|
/** A send worked: clear the failure count and mark the device alive. */
|
||||||
export async function markPushDeviceSeen(token: string): Promise<void> {
|
export async function markPushDeviceSeen(token: string): Promise<void> {
|
||||||
await db
|
await db.update(pushDevices).set({ failureCount: 0, lastSeenAt: new Date() }).where(eq(pushDevices.token, token));
|
||||||
.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))
|
.orderBy(desc(photosConfig.createdAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (next) {
|
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;
|
return true;
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export * from './chat-events/schema'; // chat_session_events
|
|||||||
export * from './agent-panels/schema'; // agent_panels
|
export * from './agent-panels/schema'; // agent_panels
|
||||||
|
|
||||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
// 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`
|
// 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.
|
// reads service_connections, so this is core however few plugins are installed.
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export type DashboardInsert = typeof Schema.dashboards.$inferInsert;
|
|||||||
export type ScreenSelect = typeof Schema.screens.$inferSelect;
|
export type ScreenSelect = typeof Schema.screens.$inferSelect;
|
||||||
export type ScreenInsert = typeof Schema.screens.$inferInsert;
|
export type ScreenInsert = typeof Schema.screens.$inferInsert;
|
||||||
|
|
||||||
|
|
||||||
// ── Email ──
|
// ── Email ──
|
||||||
|
|
||||||
export type EmailAccountSelect = typeof EmailSchema.emailAccounts.$inferSelect;
|
export type EmailAccountSelect = typeof EmailSchema.emailAccounts.$inferSelect;
|
||||||
|
|||||||
@@ -1,8 +1 @@
|
|||||||
export {
|
export { getUserSettings, setUserSettings, getUserState, patchUserState, getDockPaths, setDockPaths } from './queries';
|
||||||
getUserSettings,
|
|
||||||
setUserSettings,
|
|
||||||
getUserState,
|
|
||||||
patchUserState,
|
|
||||||
getDockPaths,
|
|
||||||
setDockPaths,
|
|
||||||
} from './queries';
|
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ async function listTaskFiles(): Promise<TaskFile[]> {
|
|||||||
const p = join(tasksDir, f);
|
const p = join(tasksDir, f);
|
||||||
const st = await stat(p).catch(() => null);
|
const st = await stat(p).catch(() => null);
|
||||||
if (!st) continue;
|
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);
|
if (!safe) return ctx.text('path is not under an allowed root', 403);
|
||||||
await mkdir(dirname(ANNOUNCED_PATH), { recursive: true });
|
await mkdir(dirname(ANNOUNCED_PATH), { recursive: true });
|
||||||
const list = await readAnnounced();
|
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));
|
await writeFile(ANNOUNCED_PATH, JSON.stringify(next));
|
||||||
return ctx.json({ ok: true, name: body.name, path: safe });
|
return ctx.json({ ok: true, name: body.name, path: safe });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
// 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 {
|
export function deriveRelayToken(userId: number, port: number, salt: string): string {
|
||||||
return createHmac('sha256', getKey('jwt'))
|
return createHmac('sha256', getKey('jwt')).update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`).digest('hex');
|
||||||
.update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`)
|
|
||||||
.digest('hex');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenToUser = new Map<string, number>();
|
const tokenToUser = new Map<string, number>();
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ function buildTransportUrl(config: SmtpConfig): string {
|
|||||||
if (config.provider === 'mailhog') {
|
if (config.provider === 'mailhog') {
|
||||||
return `smtp://${config.host ?? 'localhost'}:${config.port ?? 1025}`;
|
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';
|
const protocol = config.secure ? 'smtps' : 'smtp';
|
||||||
return `${protocol}://${auth}${config.host}:${config.port ?? 587}`;
|
return `${protocol}://${auth}${config.host}:${config.port ?? 587}`;
|
||||||
}
|
}
|
||||||
@@ -73,7 +75,7 @@ smtpRouter.post('/test-connection', async (ctx) => {
|
|||||||
|
|
||||||
if (result.type === 'resend') {
|
if (result.type === 'resend') {
|
||||||
const res = await fetch('https://api.resend.com/domains', {
|
const res = await fetch('https://api.resend.com/domains', {
|
||||||
headers: { 'Authorization': `Bearer ${result.apiKey}` },
|
headers: { Authorization: `Bearer ${result.apiKey}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
@@ -103,14 +105,15 @@ smtpRouter.post('/test', async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const from = `${body.fromName} <${body.fromEmail}>`;
|
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 {
|
try {
|
||||||
if (body.provider === 'resend') {
|
if (body.provider === 'resend') {
|
||||||
const res = await fetch('https://api.resend.com/emails', {
|
const res = await fetch('https://api.resend.com/emails', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${body.apiKey}`,
|
Authorization: `Bearer ${body.apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ from, to: body.to, subject: 'officer.dev Test Email', html: testHtml }),
|
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', [
|
const { stdout } = await exec('df', [
|
||||||
'-B1',
|
'-B1',
|
||||||
'--output=target,fstype,size,used,pcent',
|
'--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
|
return stdout
|
||||||
.trim()
|
.trim()
|
||||||
@@ -131,7 +140,9 @@ async function readTemps() {
|
|||||||
}
|
}
|
||||||
const CPU_DRIVERS = ['k10temp', 'zenpower', 'coretemp', 'k8temp', 'cpu_thermal'];
|
const CPU_DRIVERS = ['k10temp', 'zenpower', 'coretemp', 'k8temp', 'cpu_thermal'];
|
||||||
const cpu =
|
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())) ??
|
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase())) ??
|
||||||
null;
|
null;
|
||||||
return { cpuC: cpu?.celsius ?? null, cpuLabel: cpu ? `${cpu.name} · ${cpu.label}` : null, sensors };
|
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);
|
const busyRaw = await readFile(`${dev}/gpu_busy_percent`, 'utf8').catch(() => null);
|
||||||
if (busyRaw == null) continue;
|
if (busyRaw == null) continue;
|
||||||
const busyPct = Number.parseInt(busyRaw.trim(), 10);
|
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 vramUsed =
|
||||||
const vramTotal = Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0;
|
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 { busyPct: Number.isFinite(busyPct) ? busyPct : 0, vramUsedBytes: vramUsed, vramTotalBytes: vramTotal };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network throughput — bytes/sec computed from the delta since the previous /stats call (~2s window).
|
// 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() {
|
async function readNet() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let data: string;
|
let data: string;
|
||||||
@@ -185,11 +199,20 @@ async function readNet() {
|
|||||||
}
|
}
|
||||||
const prev = lastNet;
|
const prev = lastNet;
|
||||||
lastNet = { total: { rx: totRx, tx: totTx }, per, ts: now };
|
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 dt = (now - prev.ts) / 1000;
|
||||||
const rate = (cur: number, old: number) => Math.max(0, Math.round((cur - old) / dt));
|
const rate = (cur: number, old: number) => Math.max(0, Math.round((cur - old) / dt));
|
||||||
const interfaces = Object.entries(per)
|
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)
|
.filter((i) => i.rxBytesPerSec > 0 || i.txBytesPerSec > 0)
|
||||||
.sort((a, b) => b.rxBytesPerSec + b.txBytesPerSec - (a.rxBytesPerSec + a.txBytesPerSec));
|
.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 };
|
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')) {
|
for (const d of await readdir('/sys/class/hwmon')) {
|
||||||
const base = `/sys/class/hwmon/${d}`;
|
const base = `/sys/class/hwmon/${d}`;
|
||||||
if ((await readFile(`${base}/name`, 'utf8').catch(() => '')).trim() !== 'amdgpu') continue;
|
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) {
|
if (p != null) {
|
||||||
const uw = Number.parseInt(p.trim(), 10);
|
const uw = Number.parseInt(p.trim(), 10);
|
||||||
if (Number.isFinite(uw)) gpuWatts = Math.round((uw / 1e6) * 10) / 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));
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
const second = await readCpuSample().catch(() => null);
|
const second = await readCpuSample().catch(() => null);
|
||||||
|
|
||||||
const cpuUsage =
|
const cpuUsage = first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
|
||||||
first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
|
|
||||||
const perCore =
|
const perCore =
|
||||||
first && second
|
first && second
|
||||||
? second.perCore.map((c, i) => {
|
? second.perCore.map((c, i) => {
|
||||||
@@ -311,7 +335,9 @@ systemMonitorRouter.get('/pm2', async (ctx) => {
|
|||||||
// GET /docker — running docker containers (the "dockers" scope).
|
// GET /docker — running docker containers (the "dockers" scope).
|
||||||
systemMonitorRouter.get('/docker', async (ctx) => {
|
systemMonitorRouter.get('/docker', async (ctx) => {
|
||||||
try {
|
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({
|
return ctx.json({
|
||||||
containers: stdout
|
containers: stdout
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -43,11 +43,19 @@ export function descendantPids(root: number): number[] {
|
|||||||
export function killTree(root: number) {
|
export function killTree(root: number) {
|
||||||
const pids = [root, ...descendantPids(root)];
|
const pids = [root, ...descendantPids(root)];
|
||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
* 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.
|
* pasted, quoted in a shell and read aloud: no quotes, no backslash, no backtick.
|
||||||
*/
|
*/
|
||||||
const CLASSES = [
|
const CLASSES = ['abcdefghijkmnopqrstuvwxyz', 'ABCDEFGHJKLMNPQRSTUVWXYZ', '23456789', '!@#$%^&*()-_=+'] as const;
|
||||||
'abcdefghijkmnopqrstuvwxyz',
|
|
||||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
|
||||||
'23456789',
|
|
||||||
'!@#$%^&*()-_=+',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const PASSWORD_LENGTH = 20;
|
const PASSWORD_LENGTH = 20;
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ bash setup.sh
|
|||||||
|
|
||||||
Every script must:
|
Every script must:
|
||||||
|
|
||||||
| Rule | Why |
|
| 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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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_<KEY>=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. |
|
| **Print `OFFICER_RESULT_<KEY>=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
|
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.
|
row stays `failed` rather than pretending to be installed.
|
||||||
|
|||||||
@@ -187,7 +187,9 @@ describe('kinds', () => {
|
|||||||
|
|
||||||
test('admin capabilities are never grantable', () => {
|
test('admin capabilities are never grantable', () => {
|
||||||
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
|
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(CAPABILITY_BY_KEY.get(key)?.kind).toBe('admin');
|
||||||
expect(grantable.has(key)).toBe(false);
|
expect(grantable.has(key)).toBe(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// on this router and stays owner-only — see ownerGate in users-router.ts, which is the second lock.
|
||||||
selfService: ['PUT /'],
|
selfService: ['PUT /'],
|
||||||
},
|
},
|
||||||
{
|
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
|
||||||
key: 'headscale',
|
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
|
||||||
label: 'Headscale',
|
// platform no longer knows this capability exists, which is the entire point.
|
||||||
description: 'The tailnet: machines, routes and ACLs',
|
|
||||||
kind: 'admin',
|
|
||||||
api: ['/headscale'],
|
|
||||||
routes: ['/headscale'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'wallet',
|
key: 'wallet',
|
||||||
label: 'Wallet',
|
label: 'Wallet',
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { pluginsRouter } from './api/plugins/router';
|
|||||||
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
|
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
|
||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
// import { slskdRouter } from './api/slskd/router';
|
// import { slskdRouter } from './api/slskd/router';
|
||||||
import { headscaleRouter } from './api/headscale/router';
|
|
||||||
// import { transmissionRouter } from './api/transmission/router';
|
// import { transmissionRouter } from './api/transmission/router';
|
||||||
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||||
// import { jellyfinRouter } from './api/jellyfin/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
|
// ['/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
|
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
|
||||||
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
||||||
['/headscale', headscaleRouter],
|
|
||||||
// ['/transmission', transmissionRouter], // plugin — switched off 2026-08-13
|
// ['/transmission', transmissionRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
|
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
|
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
|
||||||
|
|||||||
@@ -432,9 +432,7 @@ export async function dropPostgresRole(osUser: string): Promise<DropRoleResult>
|
|||||||
if (!url) return { ok: false, error: 'POSTGRES_URL is not set' };
|
if (!url) return { ok: false, error: 'POSTGRES_URL is not set' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const present = await db.execute<{ rolname: string }>(
|
const present = await db.execute<{ rolname: string }>(sql`select rolname from pg_roles where rolname = ${osUser}`);
|
||||||
sql`select rolname from pg_roles where rolname = ${osUser}`,
|
|
||||||
);
|
|
||||||
// Already gone is success, so a retry after a partial teardown finishes rather than refuses.
|
// 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: [] };
|
if (present.length === 0) return { ok: true, removed: false, reassigned: [] };
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise<P
|
|||||||
|
|
||||||
const steps: string[] = [];
|
const steps: string[] = [];
|
||||||
try {
|
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)) {
|
if (hasSidecar(plugin)) {
|
||||||
addPluginToEcosystem(plugin);
|
addPluginToEcosystem(plugin);
|
||||||
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
|
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');
|
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 };
|
return { ok: true, appName, steps };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, appName, steps, error: err instanceof Error ? err.message : String(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');
|
await step(steps, onStep, enabled ? 'enabled' : 'disabled');
|
||||||
|
|
||||||
const plugin = await findPlugin(appName);
|
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)) {
|
if (plugin && hasSidecar(plugin)) {
|
||||||
const name = pluginProcessName(appName);
|
const name = pluginProcessName(appName);
|
||||||
const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR);
|
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}`);
|
await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mounted } = await refreshPluginMounts();
|
if (!enabled) {
|
||||||
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
|
const { mounted } = await refreshPluginMounts();
|
||||||
|
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
|
||||||
|
}
|
||||||
return { ok: true, appName, steps };
|
return { ok: true, appName, steps };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// machine-facing interface. Same split officer-email already uses.
|
// machine-facing interface. Same split officer-email already uses.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
function getFreePort(): number {
|
function getFreePort(): number {
|
||||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy'
|
|||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '../../officer-url.mjs';
|
||||||
|
|
||||||
|
|
||||||
// ── Startup ──
|
// ── Startup ──
|
||||||
|
|
||||||
if (!acquireLock()) {
|
if (!acquireLock()) {
|
||||||
|
|||||||
@@ -357,7 +357,12 @@ export function startAnthropicProxy() {
|
|||||||
headers.delete('x-api-key');
|
headers.delete('x-api-key');
|
||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
const existingBeta = headers.get('anthropic-beta');
|
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');
|
if (!betas.includes('oauth-2025-04-20')) betas.push('oauth-2025-04-20');
|
||||||
headers.set('anthropic-beta', betas.join(','));
|
headers.set('anthropic-beta', betas.join(','));
|
||||||
headers.delete('host');
|
headers.delete('host');
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ async function tick() {
|
|||||||
console.log(`[email-cron] ${account.email}: ${result.saved} new emails`);
|
console.log(`[email-cron] ${account.email}: ${result.saved} new emails`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(`[email-cron] Failed to resync ${account.email}:`, err instanceof Error ? err.message : err);
|
||||||
`[email-cron] Failed to resync ${account.email}:`,
|
|
||||||
err instanceof Error ? err.message : err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -115,7 +115,15 @@ async function connect(w: Watcher): Promise<void> {
|
|||||||
|
|
||||||
function startWatcher(account: Account): void {
|
function startWatcher(account: Account): void {
|
||||||
if (watchers.has(account.id)) return;
|
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);
|
watchers.set(account.id, w);
|
||||||
void connect(w);
|
void connect(w);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,9 +668,7 @@ const gmailSyncHandler = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`);
|
||||||
`[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_date', new Date().toISOString().split('T')[0]!);
|
||||||
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
|
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { startEmailServer } from './http';
|
|||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
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 —
|
// 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
|
// 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.
|
// (sync-runner.ts), so the shim is gone and nothing but a port crosses the socket at startup.
|
||||||
|
|||||||
@@ -364,7 +364,6 @@ emailRouter.delete('/messages/:id', async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
emailRouter.get('/sync-status', async (ctx) => {
|
emailRouter.get('/sync-status', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,13 @@ const firstAddress = (value: unknown): string => {
|
|||||||
* Groups by normalized subject + the counterpart address, so recurring 1:1 conversations collapse
|
* 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.
|
* 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 : '');
|
const norm = normalizeSubject(typeof row.subject === 'string' ? row.subject : '');
|
||||||
if (!norm) return row.id;
|
if (!norm) return row.id;
|
||||||
const me = typeof row.email_account === 'string' ? row.email_account.toLowerCase() : '';
|
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 {
|
function backfillThreadIds(db: Database): void {
|
||||||
const rows = db
|
const rows = db
|
||||||
.query('SELECT id, subject, from_address, to_address, email_account FROM emails WHERE thread_id IS NULL')
|
.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;
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
const update = db.prepare('UPDATE emails SET thread_id = ? WHERE id = ?');
|
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 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(ftsDeleteStmt, [id]);
|
||||||
db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]);
|
db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]);
|
||||||
}
|
}
|
||||||
@@ -319,7 +340,12 @@ function parseBranch(q: string): Branch {
|
|||||||
return { fts: fts.join(' '), where, params };
|
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).
|
// Split on top-level uppercase OR into branches (Gmail-style; lowercase "or" stays a search word).
|
||||||
const branches = q
|
const branches = q
|
||||||
.split(/\s+OR\s+/)
|
.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 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;
|
const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${whereSql}`).get(...params) as { c: number }).c;
|
||||||
return { rows, total };
|
return { rows, total };
|
||||||
}
|
}
|
||||||
@@ -372,7 +400,8 @@ const upsertEmailStmt = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const deleteAttachmentsStmt = 'DELETE FROM attachments WHERE email_id = ?';
|
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 {
|
export function upsertEmail(db: Database, email: ParsedEmail): void {
|
||||||
const domain = extractDomain(email.fromAddress);
|
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]);
|
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');
|
db.exec('COMMIT');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -439,7 +476,22 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label
|
|||||||
const threadId = computeThreadId(db, id, raw);
|
const threadId = computeThreadId(db, id, raw);
|
||||||
|
|
||||||
db.run(upsertEmailStmt, [
|
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) {
|
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 */
|
/** Convert a db row to an EmailSummary for the API */
|
||||||
export function rowToSummary(row: Record<string, unknown>): EmailSummary {
|
export function rowToSummary(row: Record<string, unknown>): EmailSummary {
|
||||||
const from = row.from_name
|
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
|
||||||
? `${row.from_name} <${row.from_address}>`
|
|
||||||
: (row.from_address as string);
|
|
||||||
|
|
||||||
const labels = labelsFromString(row.labels);
|
const labels = labelsFromString(row.labels);
|
||||||
|
|
||||||
@@ -604,7 +654,7 @@ function normalizeCharset(charset: string): string {
|
|||||||
'windows-1252': 'latin1',
|
'windows-1252': 'latin1',
|
||||||
'windows-1254': 'latin1',
|
'windows-1254': 'latin1',
|
||||||
'us-ascii': 'ascii',
|
'us-ascii': 'ascii',
|
||||||
'ascii': 'ascii',
|
ascii: 'ascii',
|
||||||
};
|
};
|
||||||
return map[charset] ?? charset;
|
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
|
// Walk backwards to find the start of this MIME part's headers
|
||||||
const partStart = raw.lastIndexOf('\n--', pos);
|
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)
|
// Skip inline parts without a filename (e.g. inline text/plain body parts)
|
||||||
const hasFilename = /filename/i.test(headerBlock);
|
const hasFilename = /filename/i.test(headerBlock);
|
||||||
@@ -731,12 +782,10 @@ function parseAttachments(raw: string): AttachmentMeta[] {
|
|||||||
content = bodyRaw.replace(/\s/g, '');
|
content = bodyRaw.replace(/\s/g, '');
|
||||||
} else {
|
} else {
|
||||||
// For quoted-printable or 7bit/8bit, re-encode to base64
|
// For quoted-printable or 7bit/8bit, re-encode to base64
|
||||||
const buf = encoding === 'quoted-printable'
|
const buf = encoding === 'quoted-printable' ? decodeQuotedPrintableBytes(bodyRaw) : Buffer.from(bodyRaw);
|
||||||
? decodeQuotedPrintableBytes(bodyRaw)
|
|
||||||
: Buffer.from(bodyRaw);
|
|
||||||
content = buf.toString('base64');
|
content = buf.toString('base64');
|
||||||
}
|
}
|
||||||
size = Math.floor(content.length * 3 / 4);
|
size = Math.floor((content.length * 3) / 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({ filename, size, contentType, content });
|
results.push({ filename, size, contentType, content });
|
||||||
|
|||||||
@@ -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.
|
// sidecar from being a general-purpose SSRF hop into whatever else is on that host.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
// Everything under /api/v1 the UI legitimately needs.
|
// 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
|
// `/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.
|
// update/*, installation/*, mail config, settings writes, ownership transfer — is deliberately unreachable.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
function getFreePort(): number {
|
function getFreePort(): number {
|
||||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// general proxy, and why HLS forces it to keep Jellyfin's own paths.
|
// 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. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
function getFreePort(): number {
|
function getFreePort(): number {
|
||||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// SSRF hop into whatever else is on that host.
|
// 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
|
// 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
|
// 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.
|
// token rather than borrowing the owner's Memos session.
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '../../data-path';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '../../officer-url.mjs';
|
||||||
|
|
||||||
|
|
||||||
// ── Per-user state validation ──
|
// ── Per-user state validation ──
|
||||||
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
// 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.
|
// 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.
|
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
// ── Audio-streaming HTTP server ──
|
// ── Audio-streaming HTTP server ──
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
||||||
if (prev.version !== next.version) {
|
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);
|
const { added, removed, changed } = diffManifest(prev, next);
|
||||||
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
|||||||
return;
|
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[]) => {
|
const sample = (label: string, rels: string[]) => {
|
||||||
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
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`);
|
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
|
|||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
const ms = msUntilNextHour(REINDEX_HOUR);
|
const ms = msUntilNextHour(REINDEX_HOUR);
|
||||||
const at = new Date(Date.now() + ms);
|
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 () => {
|
timer = setTimeout(async () => {
|
||||||
console.log('[music] nightly full reindex starting');
|
console.log('[music] nightly full reindex starting');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
|
|||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(
|
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' },
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
);
|
);
|
||||||
const out = (await new Response(proc.stdout).text()).trim();
|
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), {
|
return new Response(file.slice(start, end + 1), {
|
||||||
status: 206,
|
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),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// the visible string is composed here rather than sent by the producer.
|
// the visible string is composed here rather than sent by the producer.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
const VALID_TYPES: NotifyType[] = ['job', 'mail', 'agent', 'download', 'test'];
|
const VALID_TYPES: NotifyType[] = ['job', 'mail', 'agent', 'download', 'test'];
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
|
|||||||
@@ -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.
|
// 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. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
function getFreePort(): number {
|
function getFreePort(): number {
|
||||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
|||||||
@@ -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
|
// `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
|
// 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.
|
// 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') {
|
if (url.pathname === '/_officer/sessions' && req.method === 'GET') {
|
||||||
return json(res, 200, { sessions: store.list(scope) });
|
return json(res, 200, { sessions: store.list(scope) });
|
||||||
|
|||||||
@@ -237,7 +237,10 @@ async function runJob(job: Job) {
|
|||||||
fresh.completedAt = Date.now();
|
fresh.completedAt = Date.now();
|
||||||
fresh.meta = { ...fresh.meta, ...sharedMeta };
|
fresh.meta = { ...fresh.meta, ...sharedMeta };
|
||||||
await writeJob(fresh);
|
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);
|
await notifyFailure(fresh);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user