feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

This commit is contained in:
LukeGus committed 2026-10-19 16:06:08 -05:00
1 parent 87cc80b291
commit 25816084aa
28 files changed
+1797 -49

No files matched your search

+5
View File
@@ -19,9 +19,14 @@
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor - Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
- A user who must have a second factor but has none can sign in only to set one up - A user who must have a second factor but has none can sign in only to set one up
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working - Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app
- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch
- The linked server list holds one link per account, with room for more connection options per server
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
### Changed ### Changed
- Server and desktop sync moved out of the core app into a plugin. Existing links and offline copies keep working
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release - The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only - Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own - The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 1.0.16
Plugin API 1.8.0.
### Added
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
## 1.0.15 ## 1.0.15
Plugin API 1.7.0. Plugin API 1.7.0.
+11 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.15 | Plugin API 1.7.0 SDK 1.0.16 | Plugin API 1.8.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -227,6 +227,7 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types | | `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user | | `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors | | `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
| `users.getLocale` | `users:read` | A user's UI language | | `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate | | `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed | | `plugins.list()` | `plugins:read` | Which plugins are installed |
@@ -707,6 +708,14 @@ lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`,
refuses the login. A user who must have a second factor but has none gets a session that can only set one refuses the login. A user who must have a second factor but has none gets a session that can only set one
up, and `/users/me` reports `restriction: "second-factor-setup"`. up, and `/users/me` reports `restriction: "second-factor-setup"`.
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
## Testing ## Testing
```ts ```ts
@@ -904,6 +913,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) | | `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types | | `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA | | `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
| `sync:provide` | high | Run sync: answer every /sync request |
| `device:serial` | high | Open serial and USB ports on the server | | `device:serial` | high | Open serial and USB ports on the server |
| `desktop:local` | high | Use the user's own computer, desktop app only | | `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins | | `hosts:export` | high | Export hosts with their saved logins |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.15", "version": "1.0.16",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -152,6 +152,7 @@
"users:write", "users:write",
"auth:provide", "auth:provide",
"auth:policy", "auth:policy",
"sync:provide",
"system:tls", "system:tls",
"device:serial", "device:serial",
"hosts:write", "hosts:write",
+423
View File
@@ -236,6 +236,429 @@ export interface SyncEntityRegistration {
*/ */
export interface PluginSync { export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void; registerEntity: (entity: SyncEntityRegistration) => void;
/** Needs sync:provide. Takes over /sync/v2 and /sync/link. Removed on deactivate. */
provide: (provider: PluginSyncProvider) => () => void;
/** Needs sync:provide. The replica store, change feed and link registry. */
readonly host: PluginSyncHost;
}
/**
* The plugin that owns server and desktop sync. Core forwards every request
* under /sync to `handle` (an Express style handler) and answers 404 when no
* plugin provides it. Call `next()` to let core answer instead.
*/
export interface PluginSyncProvider {
handle: (
req: unknown,
res: unknown,
next: (error?: unknown) => void,
) => void | Promise<void>;
}
/** A kind of data that syncs, as the sync provider sees it. */
export interface SyncEntityInfo {
type: string;
owner: string;
order: number;
readOnly: boolean;
/** Whether the row would show in a load even if it exists. */
covers?: (syncId: string) => boolean;
}
/** A row ready for the wire, with its content hash. */
export interface SyncWireRow {
syncId: string;
wire: SyncRow;
hash: string;
}
export interface SyncRecordInfo {
entityType: string;
syncId: string;
revision: number;
hash: string | null;
deleted: boolean;
error: string | null;
errorHash: string | null;
seq: number;
}
export interface SyncConflictInfo {
id: number;
entityType: string;
syncId: string;
localRow: string;
createdAt: string;
}
export interface SyncAccountInfo {
id: string;
username: string;
isAdmin: boolean;
active: boolean;
}
export interface SyncProxyHeader {
name: string;
value: string;
}
export interface SyncBasicAuth {
username: string;
password: string;
}
/** Extra per server connection options. Kept open so new ones need no migration. */
export type SyncLinkOptions = Record<string, unknown>;
/** One server a desktop is linked to, with a replica of its data. */
export interface SyncLinkInfo {
id: number;
/** The local user the replica belongs to. */
userId: string;
serverUrl: string;
serverName: string | null;
serverVersion: string | null;
sessionToken: string | null;
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
allowInvalidCertificate: boolean;
remoteUserId: string | null;
remoteUsername: string | null;
account: {
remoteUserId?: string;
username?: string;
isAdmin?: boolean;
roles?: string[];
permissions?: string[];
} | null;
disabledTypes: string[];
/** Extra connection options for this server, kept open for new ones. */
options: SyncLinkOptions;
knownTypes: string[];
cursor: number;
status: "idle" | "syncing" | "offline" | "error" | "signed_out";
lastError: string | null;
linkedAt: string;
lastSyncAt: string | null;
}
export interface SyncLinkCreate {
userId: string;
serverUrl: string;
serverName?: string | null;
serverVersion?: string | null;
sessionToken: string;
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
remoteUserId?: string | null;
remoteUsername?: string | null;
}
export type SyncLinkPatch = Partial<
Pick<
SyncLinkInfo,
| "serverName"
| "serverVersion"
| "sessionToken"
| "customHeaders"
| "basicAuth"
| "allowInvalidCertificate"
| "remoteUserId"
| "remoteUsername"
| "account"
| "disabledTypes"
| "options"
| "knownTypes"
| "cursor"
| "status"
| "lastError"
| "lastSyncAt"
>
>;
export type SyncRemoteErrorKind =
"offline" | "tls" | "proxy" | "basic_auth" | "signed_out" | "server";
export interface SyncRemoteError extends Error {
readonly kind: SyncRemoteErrorKind;
readonly status?: number;
}
export interface SyncRemoteTarget {
serverUrl: string;
sessionToken?: string | null;
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
}
export interface SyncRemoteRequest {
method?: string;
body?: unknown;
timeoutMs?: number;
signal?: AbortSignal;
}
export interface SyncPushOp {
entityType: string;
syncId: string;
baseRevision: number;
op: "upsert" | "delete";
row?: SyncRow;
}
export type SyncPushResult =
| { syncId: string; entityType: string; status: "ok"; revision: number }
| {
syncId: string;
entityType: string;
status: "conflict";
revision: number;
deleted: boolean;
row: SyncRow | null;
}
| { syncId: string; entityType: string; status: "rejected"; reason: string };
export interface SyncRemotePlugin {
id: string;
name: string;
version: string;
source: string;
artifact: boolean;
enabled: boolean;
active: boolean;
desktop: string;
syncEntities: string[];
granted: string[];
}
/**
* What the sync provider gets from core: the replica store, the change feed,
* the linked server registry, sessions and a few helpers. Core keeps all of
* it neutral; the provider decides when and how to use it. Needs sync:provide.
*/
export interface PluginSyncHost {
/** True on the desktop app's own backend. */
readonly embedded: boolean;
info: { appName: () => Promise<string>; version: () => string };
entities: {
list: () => SyncEntityInfo[];
get: (type: string) => SyncEntityInfo | undefined;
/** Registers core's own entities once. */
ensureCore: () => Promise<void>;
onChanged: (listener: () => void) => () => void;
onLocalWrite: (listener: () => void) => () => void;
};
/** Rows and bookkeeping of one local user's replica. */
replica: {
resolvers: (userId: string, onMissing?: () => void) => unknown;
loadRows: (
type: string,
userId: string,
resolvers: unknown,
) => Promise<{ rows: Map<string, SyncWireRow>; failed: Set<string> }>;
loadRow: (
type: string,
userId: string,
syncId: string,
resolvers: unknown,
) => Promise<SyncWireRow | null>;
writeRow: (
type: string,
userId: string,
wire: SyncRow,
resolvers: unknown,
) => Promise<void>;
deleteRow: (type: string, userId: string, syncId: string) => Promise<void>;
orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[];
counts: (userId: string) => Promise<Record<string, number>>;
wipe: (userId: string) => Promise<void>;
regenerateSyncIds: (userId: string) => Promise<void>;
removeSharedCopies: (userId: string) => Promise<void>;
records: {
list: (userId: string) => Promise<Map<string, SyncRecordInfo>>;
put: (
userId: string,
type: string,
syncId: string,
values: { revision: number; hash: string | null; deleted: boolean },
) => Promise<void>;
setError: (
userId: string,
type: string,
syncId: string,
reason: string,
hash: string,
) => Promise<void>;
clearErrors: (userId: string) => Promise<void>;
listErrors: (userId: string) => Promise<SyncRecordInfo[]>;
deleteForUser: (userId: string) => Promise<void>;
key: (type: string, syncId: string) => string;
};
conflicts: {
save: (
userId: string,
type: string,
syncId: string,
row: SyncRow,
revision: number,
) => Promise<void>;
list: (userId: string) => Promise<SyncConflictInfo[]>;
get: (userId: string, id: number) => Promise<SyncConflictInfo | null>;
delete: (userId: string, id: number) => Promise<void>;
};
};
/** The server side: what changed for an account and what a desktop pushed. */
feed: {
withUserLock: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
changes: (
userId: string,
cursor: number,
limit: number,
types?: string[],
) => Promise<{
records: Array<{
seq: number;
entityType: string;
syncId: string;
revision: number;
deleted: boolean;
row: SyncRow | null;
}>;
hasMore: boolean;
nextCursor: number;
entityTypes: Array<{
type: string;
owner: string;
order: number;
readOnly: boolean;
}>;
}>;
parsePushOps: (body: unknown) => Promise<SyncPushOp[] | null>;
applyPush: (userId: string, ops: SyncPushOp[]) => Promise<SyncPushResult[]>;
/** Streams a change notice to the account's open event streams. */
listen: (userId: string, res: unknown) => Promise<() => void>;
};
/**
* The linked server registry, kept in core so it is there before plugins.
* One link per local user. Without a user, the active account is meant.
*/
links: {
list: () => Promise<SyncLinkInfo[]>;
get: (userId?: string) => Promise<SyncLinkInfo | null>;
create: (link: SyncLinkCreate) => Promise<SyncLinkInfo>;
update: (
patch: SyncLinkPatch,
userId?: string,
) => Promise<SyncLinkInfo | null>;
remove: (userId?: string) => Promise<void>;
};
/** Desktop accounts: the local user and the replica user of each server. */
accounts: {
list: () => Promise<SyncAccountInfo[]>;
/** A new empty account, ready to be linked to a server. */
create: () => Promise<string>;
/** Which account the app signs in as from now on. */
setActive: (userId: string) => Promise<void>;
activeId: () => Promise<string | null>;
/** Deletes an account and all its data. The last one cannot go. */
remove: (userId: string) => Promise<void>;
};
http: {
json: <T>(
target: SyncRemoteTarget,
pathname: string,
request?: SyncRemoteRequest,
) => Promise<T>;
fetch: (
target: SyncRemoteTarget,
pathname: string,
request?: SyncRemoteRequest,
) => Promise<{
ok: boolean;
status: number;
body: AsyncIterable<Uint8Array> | null;
}>;
normalizeServerUrl: (input: string) => string | null;
isRemoteError: (error: unknown) => error is SyncRemoteError;
};
sessions: {
/** A long lived session for a linked desktop. Shows in the account's sessions. */
issueDesktop: (
userId: string,
options: { days: number; deviceInfo: string },
) => Promise<string>;
revoke: (sessionId: string) => Promise<void>;
find: (
sessionId: string,
) => Promise<{ expiresAt: string; deviceInfo: string | null } | null>;
findUser: (
userId: string,
) => Promise<{ id: string; username: string } | null>;
/** The user and session behind a token, or null when it is not valid. */
verify: (
token: string,
) => Promise<{ userId: string; sessionId?: string } | null>;
/** Sets the sign in cookie on a response, like a login does. A name sets another cookie. */
setCookie: (
req: unknown,
res: unknown,
value: string,
options?: { name?: string; days?: number },
) => void;
/** Removes the sign in cookie, without ending the session behind it. */
clearCookie: (req: unknown, res: unknown, name?: string) => void;
/** Express middleware that signs the request in and sets userId and sessionId. */
middleware: () => unknown;
/** Express middleware that lets a request through only on a desktop. */
desktopOnly: () => unknown;
};
hosts: {
findForSync: (
userId: string,
syncId: string,
) => Promise<{ id: number; name: string | null } | null>;
statusBySyncId: (userId: string) => Promise<Record<string, unknown>>;
};
plugins: {
listForDesktop: () => Promise<{
plugins: SyncRemotePlugin[];
registryKeys: unknown;
}>;
/** Sends a plugin archive; false when there is no such plugin to share. */
sendPackage: (pluginId: string, res: unknown) => Promise<boolean>;
mirror: (link: SyncLinkInfo) => Promise<void>;
lastRemote: () => SyncRemotePlugin[];
localNames: () => Map<string, string>;
};
desktop: {
/** Tells the app's main process how to reach the linked origin. */
applyProxy: (link: SyncLinkInfo | null) => Promise<void>;
deviceName: () => string;
};
alerts: {
send: (alert: {
title: string;
body: string;
severity: "info" | "warning" | "error";
category: string;
dedupeKey: string;
link?: { tab: string };
}) => Promise<void>;
};
} }
/** /**
+1
View File
@@ -48,6 +48,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("db:core-refs", "high"), entry("db:core-refs", "high"),
entry("auth:provide", "high"), entry("auth:provide", "high"),
entry("auth:policy", "high"), entry("auth:policy", "high"),
entry("sync:provide", "high"),
entry("device:serial", "high"), entry("device:serial", "high"),
entry("notify:hub", "high"), entry("notify:hub", "high"),
entry("ui:layout", "high"), entry("ui:layout", "high"),
+1 -1
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * version by the server.
*/ */
export const PLUGIN_API_VERSION = "1.7.0"; export const PLUGIN_API_VERSION = "1.8.0";
/** The API major, the default engine.api for a new plugin. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
+12 -1
View File
@@ -68,7 +68,7 @@ import type {
PluginSshKeyCredential, PluginSshKeyCredential,
PluginSshKeyCredentialInput, PluginSshKeyCredentialInput,
} from "./backend.js"; } from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js"; import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js"; import type { PluginDatabase } from "./backend.js";
import type { import type {
PluginExternalClientRequest, PluginExternalClientRequest,
@@ -104,6 +104,8 @@ export interface FakeScheduledJob {
export interface FakeContextOptions { export interface FakeContextOptions {
pluginId?: string; pluginId?: string;
/** What ctx.sync.host answers, for a sync provider's tests. */
syncHost?: Partial<PluginSyncHost>;
manifest?: Partial<PluginManifest>; manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */ /** Acting user returned by currentActor and used by asUser. */
actor?: string; actor?: string;
@@ -756,6 +758,12 @@ export function createFakeContext(
registerEntity: (entity) => { registerEntity: (entity) => {
syncEntities.push(entity); syncEntities.push(entity);
}, },
provide: () => () => {},
get host() {
if (!options.syncHost)
throw new Error("Pass syncHost to use ctx.sync.host");
return options.syncHost as PluginSyncHost;
},
}, },
registry: { registry: {
@@ -1582,6 +1590,8 @@ export function createFakeContext(
export interface MockContextOptions { export interface MockContextOptions {
pluginId?: string; pluginId?: string;
/** What ctx.sync.host answers, for a sync provider's tests. */
syncHost?: Partial<PluginSyncHost>;
manifest?: Partial<PluginManifest>; manifest?: Partial<PluginManifest>;
/** Capabilities the plugin is granted. Anything else throws. */ /** Capabilities the plugin is granted. Anything else throws. */
capabilities?: string[]; capabilities?: string[];
@@ -1744,6 +1754,7 @@ export function createMockCtx(
const base = createFakeContext({ const base = createFakeContext({
pluginId: options.pluginId, pluginId: options.pluginId,
syncHost: options.syncHost,
actor: options.actor, actor: options.actor,
settings: options.settings, settings: options.settings,
coreSettings: options.coreSettings, coreSettings: options.coreSettings,
+2 -4
View File
@@ -27,8 +27,7 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js"; import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js"; import uiPreferencesRoutes from "./routes/ui-preferences.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import syncRoutes from "../sync/server/routes.js"; import { syncForwarder } from "../sync/provider.js";
import syncLinkRoutes from "../sync/client/routes.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js"; import dashboardRoutes from "./routes/dashboard-routes.js";
import { import {
@@ -1625,8 +1624,7 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes); app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes); app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT); registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncRoutes); app.use("/sync", syncForwarder);
app.use("/sync", syncLinkRoutes);
app.use("/dashboard", dashboardRoutes); app.use("/dashboard", dashboardRoutes);
app.use("/plugins", pluginRoutes); app.use("/plugins", pluginRoutes);
app.use( app.use(
@@ -61,15 +61,23 @@ export function isNativeTokenExportRequest(req: Request): boolean {
* during testing, or a household sharing one install) -- a user in that * during testing, or a household sharing one install) -- a user in that
* state deserves to get into the app they installed, not a confusing, * state deserves to get into the app they installed, not a confusing,
* unexplained dead end. So this always returns a single, deterministic * unexplained dead end. So this always returns a single, deterministic
* user: the admin account if one exists, else the earliest-registered * user: the account the user switched to if it still exists, else the admin
* account. It never returns null. * account if one exists, else the earliest-registered account. It never
* returns null when there are users.
*/ */
export function resolveDesktopAutoSessionUser( export function resolveDesktopAutoSessionUser(
allUsers: UserRecord[], allUsers: UserRecord[],
preferredId?: string | null,
): UserRecord | null { ): UserRecord | null {
if (allUsers.length === 0) return null; if (allUsers.length === 0) return null;
if (allUsers.length === 1) return allUsers[0]; if (allUsers.length === 1) return allUsers[0];
// An account the user switched to on purpose, while it still exists.
const preferred = preferredId
? allUsers.find((user) => user.id === preferredId)
: undefined;
if (preferred) return preferred;
const admin = allUsers.find((user) => user.isAdmin); const admin = allUsers.find((user) => user.isAdmin);
if (admin) return admin; if (admin) return admin;
+7 -3
View File
@@ -576,8 +576,8 @@ async function describeDesktopLink(userId: string) {
if (process.env.ELECTRON_EMBEDDED !== "true") return null; if (process.env.ELECTRON_EMBEDDED !== "true") return null;
try { try {
const { getLink } = await import("../../sync/client/link-store.js"); const { getLink } = await import("../../sync/client/link-store.js");
const link = await getLink(); const link = await getLink(userId);
if (!link || link.userId !== userId) return null; if (!link) return null;
return { return {
serverUrl: link.serverUrl, serverUrl: link.serverUrl,
serverName: link.serverName, serverName: link.serverName,
@@ -743,7 +743,11 @@ router.post("/internal/auto-session", async (req, res) => {
const userRepository = createCurrentUserRepository(); const userRepository = createCurrentUserRepository();
const allUsers = await userRepository.listAll(); const allUsers = await userRepository.listAll();
const userRecord = resolveDesktopAutoSessionUser(allUsers); const { getSavedActiveUserId } = await import("../../sync/accounts.js");
const userRecord = resolveDesktopAutoSessionUser(
allUsers,
await getSavedActiveUserId(),
);
if (!userRecord) { if (!userRecord) {
return res.status(403).json({ return res.status(403).json({
error: "No local users exist", error: "No local users exist",
+32 -6
View File
@@ -54,6 +54,9 @@ import {
} from "@termix-ssh/plugin-sdk/backend"; } from "@termix-ssh/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db"; import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js"; import * as syncRegistry from "./sync-registry.js";
import { provideSync } from "../sync/provider.js";
import { createSyncHost } from "../sync/host.js";
import type { PluginSyncHost } from "@termix-ssh/plugin-sdk/backend";
import { import {
needsExplicitPersist, needsExplicitPersist,
resolveDatabaseDialect, resolveDatabaseDialect,
@@ -189,6 +192,34 @@ export function createPluginContext(
handle: PluginHandle, handle: PluginHandle,
): PluginContext { ): PluginContext {
const pluginId = manifest.id; const pluginId = manifest.id;
let syncHost: PluginSyncHost | undefined;
const syncApi: PluginContext["sync"] = {
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
provide: (provider) => {
if (!manifest.capabilities.includes("sync:provide")) {
throw capabilityRefused(pluginId, "sync:provide");
}
const remove = provideSync(pluginId, provider);
const drop = handle.bag.add(remove, "sync provider");
return () => {
drop();
remove();
};
},
} as PluginContext["sync"];
// Not enumerable, so tools that walk the context do not trip the check.
Object.defineProperty(syncApi, "host", {
enumerable: false,
get() {
if (!manifest.capabilities.includes("sync:provide")) {
throw capabilityRefused(pluginId, "sync:provide");
}
return (syncHost ??= createSyncHost());
},
});
const auditCall = ( const auditCall = (
action: string, action: string,
details: string, details: string,
@@ -520,12 +551,7 @@ export function createPluginContext(
}, },
}, },
sync: { sync: syncApi,
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
},
kv: { kv: {
get: (key) => kvGet(key), get: (key) => kvGet(key),
+3 -5
View File
@@ -265,17 +265,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
}); });
} }
// After plugins, so their sync entities are registered before a pass. // Sync itself starts with the plugin that provides it.
if (process.env.ELECTRON_EMBEDDED === "true") { if (process.env.ELECTRON_EMBEDDED === "true") {
try { try {
const { startDesktopSync } = await import("./sync/client/engine.js");
startDesktopSync();
const { startDesktopUpdateCheck } = const { startDesktopUpdateCheck } =
await import("./updates/desktop-update-check.js"); await import("./updates/desktop-update-check.js");
startDesktopUpdateCheck(); startDesktopUpdateCheck();
} catch (error) { } catch (error) {
systemLogger.warn("Desktop sync failed to start", { systemLogger.warn("Desktop update check failed to start", {
operation: "sync_start", operation: "update_check_start",
error: getErrorMessage(error), error: getErrorMessage(error),
}); });
} }
+94
View File
@@ -0,0 +1,94 @@
/**
* Accounts on a desktop. Each local user is one account: the plain local one,
* or the replica of a linked server. Exactly one is active, and the app signs
* in as it. Switching only changes which user the desktop auto session picks,
* so no data is shared between accounts.
*/
import crypto from "crypto";
import {
createCurrentRoleRepository,
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { resolveDesktopAutoSessionUser } from "../database/routes/desktop-auto-session.js";
import { AuthManager } from "../utils/auth-manager.js";
const ACTIVE_KEY = "desktop_active_user";
/** The user the desktop signs in as. Falls back to the usual single-user pick. */
export async function getActiveUserId(): Promise<string | null> {
const users = await createCurrentUserRepository().listAll();
if (users.length === 0) return null;
const saved = await createCurrentSettingsRepository().get(ACTIVE_KEY);
return resolveDesktopAutoSessionUser(users, saved)?.id ?? null;
}
export async function setActiveUserId(userId: string): Promise<void> {
const user = await createCurrentUserRepository().findById(userId);
if (!user) throw new Error("No such account");
await createCurrentSettingsRepository().set(ACTIVE_KEY, userId);
}
/** Saved choice only, for the auto session route. */
export async function getSavedActiveUserId(): Promise<string | null> {
return createCurrentSettingsRepository().get(ACTIVE_KEY);
}
export interface DesktopAccount {
id: string;
username: string;
isAdmin: boolean;
active: boolean;
}
export async function listAccounts(): Promise<DesktopAccount[]> {
const users = await createCurrentUserRepository().listAll();
const active = await getActiveUserId();
return users.map((user) => ({
id: user.id,
username: user.username,
isAdmin: !!user.isAdmin,
active: user.id === active,
}));
}
/** A new empty account for another server to be linked to. */
export async function createAccount(): Promise<string> {
const users = createCurrentUserRepository();
const id = crypto.randomUUID();
await users.create({
id,
username: `server-${id.slice(0, 8)}`,
passwordHash: "",
isAdmin: true,
});
try {
await createCurrentRoleRepository().assignRoleNameToUser({
userId: id,
roleName: "admin",
grantedBy: id,
});
} catch {
// The account still works; admin is also set on the user row.
}
await AuthManager.getInstance().registerUser(
id,
crypto.randomBytes(32).toString("hex"),
);
return id;
}
/** Deletes an account and everything it owns. The last account stays. */
export async function removeAccount(userId: string): Promise<void> {
const users = await createCurrentUserRepository().listAll();
if (users.length <= 1) throw new Error("The last account cannot be removed");
const active = await getActiveUserId();
const { deleteUserAndRelatedData } =
await import("../database/routes/delete-user-data.js");
await deleteUserAndRelatedData(userId);
if (active === userId) {
const next = users.find((user) => user.id !== userId);
if (next) await setActiveUserId(next.id);
}
}
+5
View File
@@ -0,0 +1,5 @@
import os from "os";
export function defaultDeviceName(): string {
return os.hostname() || "Termix Desktop";
}
+45 -14
View File
@@ -1,9 +1,12 @@
/** /**
* The desktop's link to a server, one row in sync_link. Secrets (the session * The desktop's links to servers, one row each in sync_link and at most one
* token, proxy headers, basic auth) are stored encrypted with the system key. * per local user, since each link has its own replica user. Secrets (the
* session token, proxy headers, basic auth) are stored encrypted with the
* system key. Without a user, a call means the active account's link.
*/ */
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getActiveUserId } from "../accounts.js";
import { syncLink } from "../../database/db/schema.js"; import { syncLink } from "../../database/db/schema.js";
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js"; import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
@@ -48,6 +51,8 @@ export interface SyncLink {
account: LinkAccount | null; account: LinkAccount | null;
/** Entity types switched off on this device. */ /** Entity types switched off on this device. */
disabledTypes: string[]; disabledTypes: string[];
/** Extra connection options for this server, kept open for new ones. */
options: Record<string, unknown>;
/** Entity types this device has fully pulled at least once. */ /** Entity types this device has fully pulled at least once. */
knownTypes: string[]; knownTypes: string[];
cursor: number; cursor: number;
@@ -90,7 +95,10 @@ async function fromRow(row: Row): Promise<SyncLink> {
sessionToken = null; sessionToken = null;
} }
} }
const scope = parseJson<{ disabled?: string[] }>(row.scope, {}); const scope = parseJson<{
disabled?: string[];
options?: Record<string, unknown>;
}>(row.scope, {});
return { return {
id: row.id, id: row.id,
userId: row.userId, userId: row.userId,
@@ -105,6 +113,8 @@ async function fromRow(row: Row): Promise<SyncLink> {
remoteUsername: row.remoteUsername, remoteUsername: row.remoteUsername,
account: parseJson<LinkAccount | null>(row.account, null), account: parseJson<LinkAccount | null>(row.account, null),
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [], disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
options:
scope.options && typeof scope.options === "object" ? scope.options : {},
knownTypes: parseJson<string[]>(row.knownTypes, []), knownTypes: parseJson<string[]>(row.knownTypes, []),
cursor: Number(row.cursor) || 0, cursor: Number(row.cursor) || 0,
status: (row.status as LinkStatus) || "idle", status: (row.status as LinkStatus) || "idle",
@@ -114,8 +124,20 @@ async function fromRow(row: Row): Promise<SyncLink> {
}; };
} }
export async function getLink(): Promise<SyncLink | null> { export async function listLinks(): Promise<SyncLink[]> {
const [row] = await db().select().from(syncLink).limit(1); const rows = await db().select().from(syncLink);
return Promise.all(rows.map(fromRow));
}
/** The link of one local user, or of the active account when none is given. */
export async function getLink(userId?: string): Promise<SyncLink | null> {
const owner = userId ?? (await getActiveUserId());
if (!owner) return null;
const [row] = await db()
.select()
.from(syncLink)
.where(eq(syncLink.userId, owner))
.limit(1);
return row ? fromRow(row) : null; return row ? fromRow(row) : null;
} }
@@ -133,7 +155,7 @@ export interface NewLink {
} }
export async function createLink(link: NewLink): Promise<SyncLink> { export async function createLink(link: NewLink): Promise<SyncLink> {
await db().delete(syncLink); await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
await db() await db()
.insert(syncLink) .insert(syncLink)
.values({ .values({
@@ -157,7 +179,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
linkedAt: new Date().toISOString(), linkedAt: new Date().toISOString(),
}); });
await DatabaseSaveTrigger.forceSave("sync_link"); await DatabaseSaveTrigger.forceSave("sync_link");
return (await getLink())!; return (await getLink(link.userId))!;
} }
export interface LinkPatch { export interface LinkPatch {
@@ -171,6 +193,7 @@ export interface LinkPatch {
remoteUsername?: string | null; remoteUsername?: string | null;
account?: LinkAccount | null; account?: LinkAccount | null;
disabledTypes?: string[]; disabledTypes?: string[];
options?: Record<string, unknown>;
knownTypes?: string[]; knownTypes?: string[];
cursor?: number; cursor?: number;
status?: LinkStatus; status?: LinkStatus;
@@ -178,8 +201,11 @@ export interface LinkPatch {
lastSyncAt?: string | null; lastSyncAt?: string | null;
} }
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> { export async function updateLink(
const current = await getLink(); patch: LinkPatch,
userId?: string,
): Promise<SyncLink | null> {
const current = await getLink(userId);
if (!current) return null; if (!current) return null;
const set: Partial<Row> = {}; const set: Partial<Row> = {};
if (patch.serverName !== undefined) set.serverName = patch.serverName; if (patch.serverName !== undefined) set.serverName = patch.serverName;
@@ -210,8 +236,11 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
if (patch.account !== undefined) { if (patch.account !== undefined) {
set.account = patch.account ? JSON.stringify(patch.account) : null; set.account = patch.account ? JSON.stringify(patch.account) : null;
} }
if (patch.disabledTypes !== undefined) { if (patch.disabledTypes !== undefined || patch.options !== undefined) {
set.scope = JSON.stringify({ disabled: patch.disabledTypes }); set.scope = JSON.stringify({
disabled: patch.disabledTypes ?? current.disabledTypes,
options: patch.options ?? current.options,
});
} }
if (patch.knownTypes !== undefined) { if (patch.knownTypes !== undefined) {
set.knownTypes = JSON.stringify(patch.knownTypes); set.knownTypes = JSON.stringify(patch.knownTypes);
@@ -224,10 +253,12 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id)); await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
DatabaseSaveTrigger.triggerSave("sync_link"); DatabaseSaveTrigger.triggerSave("sync_link");
} }
return getLink(); return getLink(current.userId);
} }
export async function deleteLink(): Promise<void> { export async function deleteLink(userId?: string): Promise<void> {
await db().delete(syncLink); const owner = userId ?? (await getActiveUserId());
if (!owner) return;
await db().delete(syncLink).where(eq(syncLink.userId, owner));
await DatabaseSaveTrigger.forceSave("sync_link"); await DatabaseSaveTrigger.forceSave("sync_link");
} }
+544
View File
@@ -0,0 +1,544 @@
/**
* What the sync provider plugin gets from core through ctx.sync.host: the
* replica store, the change feed, the linked server registry, sessions and
* a few helpers. Core stays neutral; the plugin decides what to do with it.
*/
import fs from "fs";
import os from "os";
import path from "path";
import * as tar from "tar";
import { and, eq, isNotNull } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express";
import type { PluginSyncHost, SyncRow } from "@termix-ssh/plugin-sdk/backend";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { AuthManager } from "../utils/auth-manager.js";
import { getLocalVersion } from "../utils/app-version.js";
import { syncLogger } from "../utils/logger.js";
import { sendCoreAlert } from "../notify/core-notify.js";
import { getPluginRuntime } from "../plugins/index.js";
import {
getEntity,
listEntities,
onEntitiesChanged,
type RegisteredSyncEntity,
} from "../plugins/sync-registry.js";
import {
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentPluginPermissionGrantRepository,
createCurrentPluginRepository,
createCurrentRepositoryContext,
createCurrentSessionRepository,
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { resolveBrandingSettings } from "../database/routes/branding-settings.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import {
createResolvers,
deleteStoredRow,
listStoredRows,
loadWireRow,
loadWireRows,
regenerateSyncIds,
writeWireRow,
type Resolvers,
} from "./store.js";
import {
clearRecordErrors,
deleteConflict,
deleteRecordsForUser,
getConflict,
listChanges,
listConflicts,
listRecordErrors,
listRecords,
putRecord,
recordKey,
saveConflict,
setRecordError,
} from "./records.js";
import { orderSelfReferences } from "./wire.js";
import { onLocalWrite } from "./server/write-listeners.js";
import {
createLink,
deleteLink,
getLink,
listLinks,
updateLink,
} from "./client/link-store.js";
import {
createAccount,
getActiveUserId,
listAccounts,
removeAccount,
setActiveUserId,
} from "./accounts.js";
import {
normalizeServerUrl,
RemoteError,
remoteFetch,
remoteJson,
} from "./client/http.js";
import { applyElectronProxyConfig } from "./client/electron-proxy.js";
import { lastRemotePlugins, mirrorPlugins } from "./client/plugins.js";
import { defaultDeviceName } from "./client/device.js";
function entityOf(type: string): RegisteredSyncEntity {
const entity = getEntity(type);
if (!entity) throw new Error(`Unknown sync entity "${type}"`);
return entity;
}
function info(entity: RegisteredSyncEntity) {
return {
type: entity.type,
owner: entity.owner,
order: entity.order,
readOnly: !!entity.readOnly,
covers: entity.covers,
};
}
/** Deletes every row that syncs, keeping local-only ones. */
async function wipeSyncedData(userId: string): Promise<void> {
const entities = [...listEntities()].reverse();
for (const entity of entities) {
if (entity.readOnly) continue;
try {
if (entity.load) {
for (const row of await entity.load(userId)) {
await entity.erase?.(userId, String(row.syncId));
}
continue;
}
if (!entity.table || entity.singleton) continue;
for (const row of await listStoredRows(entity, userId)) {
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
if (typeof row.syncId !== "string") continue;
await deleteStoredRow(entity, userId, row.syncId);
}
} catch (error) {
syncLogger.warn("Could not clear synced data", {
operation: "sync_wipe",
entityType: entity.type,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/** Removes read-only copies of what other users shared with the account. */
async function removeSharedCopies(userId: string): Promise<void> {
const db = createCurrentRepositoryContext().drizzle;
const sharedHosts = await db
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
const sharedCredentials = await db
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
isNotNull(sshCredentials.sharedSource),
),
);
const { deleteOwnedCredential } =
await import("../hosts/delete-credential.js");
for (const credential of sharedCredentials) {
await deleteOwnedCredential(userId, credential.id);
}
}
async function ensureCoreEntities(): Promise<void> {
const { registerCoreSyncEntities } = await import("./entities.js");
registerCoreSyncEntities();
}
async function counts(userId: string): Promise<Record<string, number>> {
await ensureCoreEntities();
const result: Record<string, number> = {};
for (const entity of listEntities()) {
if (entity.readOnly) continue;
try {
if (entity.load) {
result[entity.type] = (await entity.load(userId)).length;
continue;
}
if (!entity.table) continue;
result[entity.type] = (await listStoredRows(entity, userId)).filter(
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
).length;
} catch {
result[entity.type] = 0;
}
}
return result;
}
async function listPluginsForDesktop() {
const records = new Map(
(await createCurrentPluginRepository().listAll()).map((record) => [
record.id,
record,
]),
);
const grants = createCurrentPluginPermissionGrantRepository();
const plugins = [];
for (const plugin of getPluginRuntime().loader.list()) {
plugins.push({
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
// A bundled plugin updated from a signed .tmxplug can be mirrored.
artifact: !!plugin.artifact,
enabled: records.get(plugin.id)?.state === "enabled",
active: plugin.state === "active",
desktop: resolvePluginDesktopMode(plugin.manifest),
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
granted: (await grants.listByPlugin(plugin.id)).map(
(grant) => grant.capability,
),
});
}
const { customRegistryKeys } = await import("../plugins/registries.js");
return { plugins, registryKeys: await customRegistryKeys() };
}
async function sendPackage(
pluginId: string,
target: unknown,
): Promise<boolean> {
const res = target as Response;
const plugin = getPluginRuntime()
.loader.list()
.find((candidate) => candidate.id === pluginId);
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) return false;
const artifact = plugin.artifact ?? "";
res.setHeader("Content-Type", "application/gzip");
if (fs.existsSync(artifact)) {
const sig = `${artifact}.sig`;
if (fs.existsSync(sig)) {
res.setHeader(
"X-Termix-Signature",
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString("base64"),
);
}
fs.createReadStream(artifact).pipe(res);
return true;
}
const temp = path.join(
os.tmpdir(),
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
);
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
const stream = fs.createReadStream(temp);
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
stream.pipe(res);
return true;
}
export function createSyncHost(): PluginSyncHost {
const authManager = AuthManager.getInstance();
return {
embedded: process.env.ELECTRON_EMBEDDED === "true",
info: {
appName: async () => {
try {
return (
await resolveBrandingSettings(createCurrentSettingsRepository())
).appName;
} catch {
return "Termix";
}
},
version: () => getLocalVersion(),
},
entities: {
list: () => listEntities().map(info),
get: (type) => {
const entity = getEntity(type);
return entity ? info(entity) : undefined;
},
ensureCore: () => ensureCoreEntities(),
onChanged: (listener) => onEntitiesChanged(listener),
onLocalWrite: (listener) => onLocalWrite(listener),
},
replica: {
resolvers: (userId, onMissing) => createResolvers(userId, onMissing),
loadRows: async (type, userId, resolvers) => {
const result = await loadWireRows(
entityOf(type),
userId,
resolvers as Resolvers,
);
return { rows: result.rows, failed: result.failed };
},
loadRow: (type, userId, syncId, resolvers) =>
loadWireRow(entityOf(type), userId, syncId, resolvers as Resolvers),
writeRow: async (type, userId, wire, resolvers) => {
await writeWireRow(
entityOf(type),
userId,
wire,
resolvers as Resolvers,
);
},
deleteRow: async (type, userId, syncId) => {
await deleteStoredRow(entityOf(type), userId, syncId);
},
orderSelfReferences: (type, rows) =>
orderSelfReferences(entityOf(type), rows as SyncRow[]),
counts: (userId) => counts(userId),
wipe: (userId) => wipeSyncedData(userId),
regenerateSyncIds: async (userId) => {
for (const entity of listEntities()) {
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
}
},
removeSharedCopies: (userId) => removeSharedCopies(userId),
records: {
list: (userId) => listRecords(userId) as never,
put: (userId, type, syncId, values) =>
putRecord(userId, type, syncId, values),
setError: (userId, type, syncId, reason, hash) =>
setRecordError(userId, type, syncId, reason, hash),
clearErrors: (userId) => clearRecordErrors(userId),
listErrors: (userId) => listRecordErrors(userId) as never,
deleteForUser: (userId) => deleteRecordsForUser(userId),
key: (type, syncId) => recordKey(type, syncId),
},
conflicts: {
save: (userId, type, syncId, row, revision) =>
saveConflict(userId, type, syncId, row, revision),
list: (userId) => listConflicts(userId) as never,
get: (userId, id) => getConflict(userId, id) as never,
delete: (userId, id) => deleteConflict(userId, id),
},
},
feed: {
withUserLock: async (userId, run) => {
const { withUserLock } = await import("./server/feed.js");
return withUserLock(userId, run);
},
changes: async (userId, cursor, limit, types) => {
const { withUserLock, reconcileUser } =
await import("./server/feed.js");
const page = await withUserLock(userId, async () => {
const { wire } = await reconcileUser(userId);
const records = await listChanges(userId, cursor, limit + 1, types);
return { wire, records };
});
const hasMore = page.records.length > limit;
const records = [];
for (const record of page.records.slice(0, limit)) {
if (!getEntity(record.entityType)) continue;
const row = record.deleted
? null
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
null);
// A live record with no row failed to serialize; it comes again once
// it changes, rather than arriving as a delete.
if (!record.deleted && !row) continue;
records.push({
seq: record.seq,
entityType: record.entityType,
syncId: record.syncId,
revision: record.revision,
deleted: !!record.deleted,
row,
});
}
const slice = page.records.slice(0, limit);
return {
records,
hasMore,
nextCursor: slice.length ? slice[slice.length - 1].seq : cursor,
entityTypes: listEntities().map((entity) => ({
type: entity.type,
owner: entity.owner,
order: entity.order,
readOnly: !!entity.readOnly,
})),
} as never;
},
parsePushOps: async (body) => {
const { parsePushOps } = await import("./server/push.js");
return parsePushOps(body) as never;
},
applyPush: async (userId, ops) => {
const { withUserLock } = await import("./server/feed.js");
const { applyPush } = await import("./server/push.js");
return withUserLock(userId, () =>
applyPush(userId, ops as never),
) as never;
},
listen: async (userId, res) => {
const { addListener } = await import("./server/feed.js");
return addListener(userId, res as Response);
},
},
links: {
list: () => listLinks() as never,
get: (userId) => getLink(userId) as never,
create: (link) => createLink(link) as never,
update: (patch, userId) => updateLink(patch, userId) as never,
remove: (userId) => deleteLink(userId),
},
accounts: {
list: () => listAccounts(),
create: () => createAccount(),
setActive: (userId) => setActiveUserId(userId),
activeId: () => getActiveUserId(),
remove: (userId) => removeAccount(userId),
},
http: {
json: (target, pathname, request) =>
remoteJson(target, pathname, request),
fetch: ((target, pathname, request) =>
remoteFetch(target, pathname, request)) as never,
normalizeServerUrl,
isRemoteError: ((error: unknown) =>
error instanceof RemoteError) as never,
},
sessions: {
issueDesktop: (userId, options) =>
authManager.generateJWTToken(userId, {
expiresIn: `${options.days}d`,
deviceType: "desktop",
deviceInfo: options.deviceInfo,
}),
revoke: async (sessionId) => {
await authManager.revokeSession(sessionId);
},
find: async (sessionId) => {
const session =
await createCurrentSessionRepository().findById(sessionId);
return session
? { expiresAt: session.expiresAt, deviceInfo: session.deviceInfo }
: null;
},
findUser: async (userId) => {
const user = await createCurrentUserRepository().findById(userId);
return user ? { id: user.id, username: user.username } : null;
},
verify: async (token) => {
const payload = await authManager.verifyJWTToken(token);
return payload
? { userId: payload.userId, sessionId: payload.sessionId }
: null;
},
setCookie: (req, res, value, options) => {
(res as Response).cookie(
options?.name ?? "jwt",
value,
authManager.getSecureCookieOptions(
req as Request,
(options?.days ?? 30) * 24 * 60 * 60 * 1000,
),
);
},
clearCookie: (req, res, name) => {
(res as Response).clearCookie(
name ?? "jwt",
authManager.getClearCookieOptions(req as Request),
);
},
middleware: () => authManager.createAuthMiddleware(),
desktopOnly: () => (_req: Request, res: Response, next: NextFunction) => {
if (process.env.ELECTRON_EMBEDDED !== "true") {
res.status(404).json({ error: "Not found" });
return;
}
next();
},
},
hosts: {
findForSync: async (userId, syncId) => {
const hostId =
await createCurrentHostResolutionRepository().findHostIdBySyncId(
syncId,
);
if (hostId === null) return null;
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess) return null;
const row = await createCurrentHostRepository().findById(hostId);
return { id: hostId, name: row?.name ?? row?.ip ?? null };
},
statusBySyncId: async (userId) => {
const { hostStatus } =
await import("../plugins/host-status-provider.js");
const { PermissionManager } =
await import("../utils/permission-manager.js");
const statuses = await hostStatus.statusesFor(userId, null);
const entries = [...statuses.entries()];
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
userId,
entries.map(([id]) => id),
);
const hostRepository = createCurrentHostRepository();
const result: Record<string, unknown> = {};
for (const [id, entry] of entries) {
if (!allowed.has(id)) continue;
const row = await hostRepository.findById(id);
if (row?.syncId) result[row.syncId] = entry;
}
return result;
},
},
plugins: {
listForDesktop: listPluginsForDesktop as never,
sendPackage,
mirror: (link) => mirrorPlugins(link as never),
lastRemote: () => lastRemotePlugins() as never,
localNames: () => {
const names = new Map<string, string>();
for (const plugin of getPluginRuntime().loader.list()) {
names.set(plugin.id, plugin.manifest.name);
}
return names;
},
},
desktop: {
applyProxy: (link) => applyElectronProxyConfig(link as never),
deviceName: defaultDeviceName,
},
alerts: {
send: (alert) =>
sendCoreAlert({
title: alert.title,
body: alert.body,
severity: alert.severity,
category: alert.category,
dedupeKey: alert.dedupeKey,
audience: "admins",
link: alert.link,
} as never),
},
};
}
+80
View File
@@ -0,0 +1,80 @@
/**
* The sync provider: a plugin that answers /sync/v2 and /sync/link through
* ctx.sync.provide. Core only forwards. With no provider the paths answer 404,
* so a server without it looks like one that never had sync.
*/
import type { NextFunction, Request, RequestHandler, Response } from "express";
import type { PluginSyncProvider } from "@termix-ssh/plugin-sdk/backend";
import { syncLogger } from "../utils/logger.js";
interface Entry {
pluginId: string;
provider: PluginSyncProvider;
}
const providers = new Map<symbol, Entry>();
export function provideSync(
pluginId: string,
provider: PluginSyncProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearSyncProviders(): void {
providers.clear();
}
export function hasSyncProvider(): boolean {
return providers.size > 0;
}
/** Sends the request to the first provider that does not pass. */
export const syncForwarder: RequestHandler = (
req: Request,
res: Response,
next: NextFunction,
) => {
const entries = [...providers.values()];
const run = (index: number): void => {
const entry = entries[index];
if (!entry) {
next();
return;
}
const pass = (error?: unknown): void => {
if (error) next(error);
else run(index + 1);
};
try {
const result = entry.provider.handle(req, res, pass);
if (result && typeof result.catch === "function") {
result.catch((error: unknown) => fail(entry, error, res, next));
}
} catch (error) {
fail(entry, error, res, next);
}
};
run(0);
};
function fail(
entry: Entry,
error: unknown,
res: Response,
next: NextFunction,
): void {
syncLogger.warn("Sync provider failed", {
operation: "sync_provider",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
if (res.headersSent) next(error);
else res.status(500).json({ error: "Sync is not available right now" });
}
+2 -8
View File
@@ -1,14 +1,8 @@
import type { NextFunction, Request, Response } from "express"; import type { NextFunction, Request, Response } from "express";
import { markChanged } from "./feed.js"; import { markChanged } from "./feed.js";
import { notifyLocalWrite } from "./write-listeners.js";
const READS = new Set(["GET", "HEAD", "OPTIONS"]); const READS = new Set(["GET", "HEAD", "OPTIONS"]);
const writeListeners = new Set<() => void>();
/** Called after every successful write request, for a linked desktop's engine. */
export function onLocalWrite(listener: () => void): () => void {
writeListeners.add(listener);
return () => writeListeners.delete(listener);
}
/** /**
* Any successful write may change what a linked desktop should have, so it * Any successful write may change what a linked desktop should have, so it
@@ -23,7 +17,7 @@ export function syncChangeWatcher(
res.on("finish", () => { res.on("finish", () => {
if (res.statusCode >= 400) return; if (res.statusCode >= 400) return;
markChanged(); markChanged();
for (const listener of writeListeners) listener(); notifyLocalWrite();
}); });
} }
next(); next();
@@ -0,0 +1,11 @@
const writeListeners = new Set<() => void>();
/** Called after every successful write request, for a linked desktop's engine. */
export function onLocalWrite(listener: () => void): () => void {
writeListeners.add(listener);
return () => writeListeners.delete(listener);
}
export function notifyLocalWrite(): void {
for (const listener of writeListeners) listener();
}
@@ -136,6 +136,22 @@ describe("isNativeTokenExportRequest", () => {
}); });
describe("resolveDesktopAutoSessionUser", () => { describe("resolveDesktopAutoSessionUser", () => {
it("returns the account the user switched to, while it exists", () => {
const admin = makeUser({ id: "admin", isAdmin: true });
const chosen = makeUser({ id: "chosen" });
expect(resolveDesktopAutoSessionUser([admin, chosen], "chosen")).toBe(
chosen,
);
});
it("falls back to the usual pick when the chosen account is gone", () => {
const admin = makeUser({ id: "admin", isAdmin: true });
const other = makeUser({ id: "other" });
expect(resolveDesktopAutoSessionUser([admin, other], "deleted")).toBe(
admin,
);
});
it("returns the sole local user regardless of having a real password", () => { it("returns the sole local user regardless of having a real password", () => {
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" }); const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
expect(resolveDesktopAutoSessionUser([user])).toBe(user); expect(resolveDesktopAutoSessionUser([user])).toBe(user);
+116
View File
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
users: [] as Array<{
id: string;
username: string;
isAdmin: boolean;
registeredAt: string;
}>,
saved: null as string | null,
create: vi.fn(),
assignRole: vi.fn(),
registerUser: vi.fn(),
deleteUserAndRelatedData: vi.fn(),
set: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
listAll: async () => mocks.users,
findById: async (id: string) =>
mocks.users.find((u) => u.id === id) ?? null,
create: mocks.create,
}),
createCurrentRoleRepository: () => ({
assignRoleNameToUser: mocks.assignRole,
}),
createCurrentSettingsRepository: () => ({
get: async () => mocks.saved,
set: async (key: string, value: string) => {
mocks.set(key, value);
mocks.saved = value;
},
}),
}));
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ registerUser: mocks.registerUser }) },
}));
vi.mock("../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: mocks.deleteUserAndRelatedData,
}));
const {
createAccount,
getActiveUserId,
listAccounts,
removeAccount,
setActiveUserId,
} = await import("../../sync/accounts.js");
function user(
id: string,
isAdmin = false,
registeredAt = "2026-01-01T00:00:00Z",
) {
return { id, username: id, isAdmin, registeredAt };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.saved = null;
mocks.users = [
user("local", true),
user("work", false, "2026-02-01T00:00:00Z"),
];
});
describe("desktop accounts", () => {
it("signs in as the usual user until one is chosen", async () => {
expect(await getActiveUserId()).toBe("local");
await setActiveUserId("work");
expect(await getActiveUserId()).toBe("work");
});
it("will not switch to an account that does not exist", async () => {
await expect(setActiveUserId("nope")).rejects.toThrow("No such account");
expect(mocks.set).not.toHaveBeenCalled();
});
it("lists the accounts with the active one marked", async () => {
await setActiveUserId("work");
expect(await listAccounts()).toEqual([
expect.objectContaining({ id: "local", active: false }),
expect.objectContaining({ id: "work", active: true }),
]);
});
it("makes an empty admin account with its own data key", async () => {
const id = await createAccount();
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({ id, isAdmin: true, passwordHash: "" }),
);
expect(mocks.assignRole).toHaveBeenCalledWith(
expect.objectContaining({ userId: id, roleName: "admin" }),
);
expect(mocks.registerUser).toHaveBeenCalledWith(id, expect.any(String));
});
it("removes an account and its data", async () => {
await removeAccount("work");
expect(mocks.deleteUserAndRelatedData).toHaveBeenCalledWith("work");
});
it("moves to another account when the active one is removed", async () => {
await setActiveUserId("work");
mocks.set.mockClear();
await removeAccount("work");
expect(mocks.set).toHaveBeenCalledWith("desktop_active_user", "local");
});
it("keeps the last account", async () => {
mocks.users = [user("local", true)];
await expect(removeAccount("local")).rejects.toThrow("last account");
expect(mocks.deleteUserAndRelatedData).not.toHaveBeenCalled();
});
});
@@ -21,8 +21,9 @@ vi.mock("../../../utils/system-crypto.js", () => ({
const { TestSqliteDatabase } = const { TestSqliteDatabase } =
await import("../../database/repositories/test-support.js"); await import("../../database/repositories/test-support.js");
const { createLink, deleteLink, getLink, updateLink } = const { createLink, deleteLink, getLink, listLinks, updateLink } =
await import("../../../sync/client/link-store.js"); await import("../../../sync/client/link-store.js");
const { setActiveUserId } = await import("../../../sync/accounts.js");
let db: InstanceType<typeof TestSqliteDatabase>; let db: InstanceType<typeof TestSqliteDatabase>;
@@ -96,3 +97,79 @@ describe("the desktop's link", () => {
expect(await updateLink({ cursor: 1 })).toBeNull(); expect(await updateLink({ cursor: 1 })).toBeNull();
}); });
}); });
describe("several links", () => {
beforeEach(async () => {
await db.run(sql`UPDATE users SET is_admin = 1 WHERE id = 'local'`);
await db.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('second', 'second', '')`,
);
});
it("keeps one link per local user", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await createLink({
userId: "second",
serverUrl: "https://b",
sessionToken: "b",
});
expect((await listLinks()).map((link) => link.serverUrl).sort()).toEqual([
"https://a",
"https://b",
]);
expect((await getLink("second"))?.serverUrl).toBe("https://b");
expect((await getLink("local"))?.serverUrl).toBe("https://a");
});
it("answers for the active account when no user is given", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await createLink({
userId: "second",
serverUrl: "https://b",
sessionToken: "b",
});
expect((await getLink())?.serverUrl).toBe("https://a");
await setActiveUserId("second");
expect((await getLink())?.serverUrl).toBe("https://b");
});
it("changes and removes only the link it is asked about", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await createLink({
userId: "second",
serverUrl: "https://b",
sessionToken: "b",
});
await updateLink({ cursor: 9, status: "offline" }, "second");
expect((await getLink("local"))?.cursor).toBe(0);
expect((await getLink("second"))?.cursor).toBe(9);
await deleteLink("second");
expect(await getLink("second")).toBeNull();
expect((await getLink("local"))?.serverUrl).toBe("https://a");
});
it("stores connection options without touching the type switches", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await updateLink({ disabledTypes: ["hosts"] }, "local");
await updateLink({ options: { clientCertificate: "work" } }, "local");
const link = await getLink("local");
expect(link?.options).toEqual({ clientCertificate: "work" });
expect(link?.disabledTypes).toEqual(["hosts"]);
});
});
+217
View File
@@ -0,0 +1,217 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listChanges: vi.fn(),
findHostIdBySyncId: vi.fn(),
canAccessHost: vi.fn(),
findById: vi.fn(),
getLink: vi.fn(),
generateJWTToken: vi.fn(),
revokeSession: vi.fn(),
}));
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware: () => vi.fn(),
generateJWTToken: mocks.generateJWTToken,
revokeSession: mocks.revokeSession,
}),
},
}));
vi.mock("../../utils/logger.js", () => ({
syncLogger: { error: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3",
}));
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../plugins/sync-registry.js", () => ({
getEntity: vi.fn(),
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
onEntitiesChanged: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostRepository: () => ({ findById: mocks.findById }),
createCurrentHostResolutionRepository: () => ({
findHostIdBySyncId: mocks.findHostIdBySyncId,
}),
createCurrentPluginPermissionGrantRepository: vi.fn(),
createCurrentPluginRepository: vi.fn(),
createCurrentRepositoryContext: vi.fn(),
createCurrentSessionRepository: vi.fn(),
createCurrentSettingsRepository: vi.fn(),
createCurrentUserRepository: vi.fn(),
}));
vi.mock("../../database/routes/branding-settings.js", () => ({
resolveBrandingSettings: async () => ({ appName: "Termix" }),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ canAccessHost: mocks.canAccessHost }),
},
}));
vi.mock("../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(),
}));
vi.mock("../../sync/store.js", () => ({}));
vi.mock("../../sync/wire.js", () => ({}));
vi.mock("../../sync/records.js", () => ({
listChanges: mocks.listChanges,
recordKey: (type: string, id: string) => `${type}\u0000${id}`,
}));
vi.mock("../../sync/server/write-listeners.js", () => ({
onLocalWrite: vi.fn(),
}));
vi.mock("../../sync/accounts.js", () => ({
createAccount: vi.fn(),
getActiveUserId: vi.fn(),
listAccounts: vi.fn(),
removeAccount: vi.fn(),
setActiveUserId: vi.fn(),
}));
vi.mock("../../sync/server/feed.js", () => ({
withUserLock: (_userId: string, fn: () => unknown) => fn(),
reconcileUser: async () => ({ wire: new Map() }),
addListener: vi.fn(),
}));
vi.mock("../../sync/server/push.js", () => ({}));
vi.mock("../../sync/client/link-store.js", () => ({
getLink: mocks.getLink,
listLinks: vi.fn(),
createLink: vi.fn(),
updateLink: vi.fn(),
deleteLink: vi.fn(),
}));
vi.mock("../../sync/client/http.js", () => ({
normalizeServerUrl: vi.fn(),
RemoteError: class RemoteError extends Error {},
remoteFetch: vi.fn(),
remoteJson: vi.fn(),
}));
vi.mock("../../sync/client/electron-proxy.js", () => ({
applyElectronProxyConfig: vi.fn(),
}));
vi.mock("../../sync/client/plugins.js", () => ({
lastRemotePlugins: () => [],
mirrorPlugins: vi.fn(),
}));
const { createSyncHost } = await import("../../sync/host.js");
beforeEach(() => {
vi.clearAllMocks();
mocks.listChanges.mockResolvedValue([]);
});
describe("host.feed.changes", () => {
it("answers an empty page with the entity types past the newest change", async () => {
const cursor = Number.MAX_SAFE_INTEGER;
const page = await createSyncHost().feed.changes("user-1", cursor, 1000);
expect(page).toEqual({
records: [],
hasMore: false,
nextCursor: cursor,
entityTypes: [
{ type: "hosts", owner: "core", order: 10, readOnly: false },
],
});
});
it("asks for one more row than the limit to know if more remain", async () => {
await createSyncHost().feed.changes("user-1", 0, 1000);
expect(mocks.listChanges).toHaveBeenCalledWith(
"user-1",
0,
1001,
undefined,
);
});
it("leaves out records of types this server does not run", async () => {
mocks.listChanges.mockResolvedValue([
{ seq: 5, entityType: "gone", syncId: "a", revision: 1, deleted: false },
]);
const page = await createSyncHost().feed.changes("user-1", 0, 10);
expect(page.records).toEqual([]);
expect(page.nextCursor).toBe(5);
});
});
describe("host.hosts.findForSync", () => {
beforeEach(() => {
mocks.findHostIdBySyncId.mockResolvedValue(41);
mocks.canAccessHost.mockImplementation(async (_user, _host, action) => ({
hasAccess: action === "connect",
}));
mocks.findById.mockResolvedValue({ name: "Shared host", ip: "10.0.0.1" });
});
it("resolves the server ID for a connect only recipient", async () => {
const found = await createSyncHost().hosts.findForSync(
"recipient",
"host-sync-id",
);
expect(mocks.canAccessHost).toHaveBeenCalledWith(
"recipient",
41,
"connect",
);
expect(found).toEqual({ id: 41, name: "Shared host" });
});
it("does not disclose a host the caller cannot connect to", async () => {
mocks.canAccessHost.mockResolvedValue({ hasAccess: false });
expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull();
});
it("returns null for a missing sync ID", async () => {
mocks.findHostIdBySyncId.mockResolvedValue(null);
expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull();
expect(mocks.canAccessHost).not.toHaveBeenCalled();
});
});
describe("host.sessions", () => {
it("issues a long desktop session that shows in the sessions list", async () => {
mocks.generateJWTToken.mockResolvedValue("token");
const token = await createSyncHost().sessions.issueDesktop("u1", {
days: 365,
deviceInfo: "Laptop (sync)",
});
expect(token).toBe("token");
expect(mocks.generateJWTToken).toHaveBeenCalledWith("u1", {
expiresIn: "365d",
deviceType: "desktop",
deviceInfo: "Laptop (sync)",
});
});
it("answers 404 outside the desktop app", () => {
const previous = process.env.ELECTRON_EMBEDDED;
delete process.env.ELECTRON_EMBEDDED;
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
const next = vi.fn();
(
createSyncHost().sessions.desktopOnly() as (
req: unknown,
res: unknown,
next: () => void,
) => void
)({}, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
if (previous !== undefined) process.env.ELECTRON_EMBEDDED = previous;
});
});
describe("host.links", () => {
it("reads the linked server registry", async () => {
mocks.getLink.mockResolvedValue({ id: 1, serverUrl: "https://a.example" });
expect(await createSyncHost().links.get()).toEqual({
id: 1,
serverUrl: "https://a.example",
});
});
});
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
syncLogger: { warn: vi.fn() },
}));
const { clearSyncProviders, hasSyncProvider, provideSync, syncForwarder } =
await import("../../sync/provider.js");
function response() {
const res = {
headersSent: false,
status: vi.fn(() => res),
json: vi.fn(() => res),
};
return res;
}
beforeEach(() => clearSyncProviders());
describe("sync forwarder", () => {
it("passes through when nothing provides sync", () => {
const next = vi.fn();
syncForwarder({} as never, response() as never, next);
expect(hasSyncProvider()).toBe(false);
expect(next).toHaveBeenCalledWith();
});
it("hands the request to the provider", () => {
const handle = vi.fn();
provideSync("a", { handle });
const next = vi.fn();
syncForwarder({} as never, response() as never, next);
expect(handle).toHaveBeenCalledOnce();
expect(next).not.toHaveBeenCalled();
});
it("moves on when the provider calls next", () => {
provideSync("a", { handle: (_req, _res, pass) => pass() });
const second = vi.fn();
provideSync("b", { handle: second });
syncForwarder({} as never, response() as never, vi.fn());
expect(second).toHaveBeenCalledOnce();
});
it("answers 500 when the provider throws", async () => {
provideSync("a", {
handle: async () => {
throw new Error("boom");
},
});
const res = response();
syncForwarder({} as never, res as never, vi.fn());
await Promise.resolve();
await Promise.resolve();
expect(res.status).toHaveBeenCalledWith(500);
});
it("stops forwarding after the provider is removed", () => {
const remove = provideSync("a", { handle: vi.fn() });
remove();
const next = vi.fn();
syncForwarder({} as never, response() as never, next);
expect(next).toHaveBeenCalledWith();
});
});
+2 -2
View File
@@ -48,8 +48,8 @@ export const runPluginDataMigrations = DatabaseSaveTrigger.batched(
*/ */
async function applyHostDefaults(): Promise<void> { async function applyHostDefaults(): Promise<void> {
try { try {
const { getLink } = await import("../sync/client/link-store.js"); const { listLinks } = await import("../sync/client/link-store.js");
if (await getLink().catch(() => null)) return; if ((await listLinks().catch(() => [])).length > 0) return;
const { recomputeEverything } = const { recomputeEverything } =
await import("../hosts/defaults/recompute.js"); await import("../hosts/defaults/recompute.js");
recomputeEverything("plugins started"); recomputeEverything("plugins started");
+4
View File
@@ -1151,6 +1151,10 @@
"title": "Set sign in rules", "title": "Set sign in rules",
"consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor." "consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor."
}, },
"sync:provide": {
"title": "Run sync",
"consequence": "It can answer all sync requests, so it sees the data that devices and servers sync."
},
"system:tls": { "system:tls": {
"title": "Manage certificates", "title": "Manage certificates",
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities." "consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."