From 25816084aa3e478f546633f1ed2394685c968505 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 19 Oct 2026 16:06:08 -0500 Subject: [PATCH] feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) --- CHANGELOG.md | 5 + packages/plugin-sdk/CHANGELOG.md | 8 + packages/plugin-sdk/PLUGIN-GUIDE.md | 12 +- packages/plugin-sdk/package.json | 2 +- .../plugin-sdk/schema/manifest.schema.json | 1 + packages/plugin-sdk/src/backend.ts | 423 ++++++++++++++ packages/plugin-sdk/src/capabilities.ts | 1 + packages/plugin-sdk/src/manifest.ts | 2 +- packages/plugin-sdk/src/testing.ts | 13 +- src/backend/database/database.ts | 6 +- .../database/routes/desktop-auto-session.ts | 12 +- src/backend/database/routes/users.ts | 10 +- src/backend/plugins/ctx.ts | 38 +- src/backend/starter.ts | 8 +- src/backend/sync/accounts.ts | 94 +++ src/backend/sync/client/device.ts | 5 + src/backend/sync/client/link-store.ts | 59 +- src/backend/sync/host.ts | 544 ++++++++++++++++++ src/backend/sync/provider.ts | 80 +++ src/backend/sync/server/change-watcher.ts | 10 +- src/backend/sync/server/write-listeners.ts | 11 + .../routes/desktop-auto-session.test.ts | 16 + src/backend/tests/sync/accounts.test.ts | 116 ++++ .../tests/sync/client/link-store.test.ts | 79 ++- src/backend/tests/sync/host.test.ts | 217 +++++++ src/backend/tests/sync/provider.test.ts | 66 +++ src/backend/upgrade/boot-migrations.ts | 4 +- src/ui/locales/en.json | 4 + 28 files changed, 1797 insertions(+), 49 deletions(-) create mode 100644 src/backend/sync/accounts.ts create mode 100644 src/backend/sync/client/device.ts create mode 100644 src/backend/sync/host.ts create mode 100644 src/backend/sync/provider.ts create mode 100644 src/backend/sync/server/write-listeners.ts create mode 100644 src/backend/tests/sync/accounts.test.ts create mode 100644 src/backend/tests/sync/host.test.ts create mode 100644 src/backend/tests/sync/provider.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 75655825d..85233ba73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 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 +- 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 +- 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 - 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 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 62118bbce..64f49bc36 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 Plugin API 1.7.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 857031258..e50cafebc 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # 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 `@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.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 | +| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops | | `users.getLocale` | `users:read` | A user's UI language | | `system` | `system:tls` | The server's HTTPS certificate | | `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 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 ```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) | | `auth:provide` | high | Add login methods, second factors, SSH auth types | | `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 | | `desktop:local` | high | Use the user's own computer, desktop app only | | `hosts:export` | high | Export hosts with their saved logins | diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 38d74c721..640bcaac7 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "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.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index ce5ab9458..3c7f343fe 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -152,6 +152,7 @@ "users:write", "auth:provide", "auth:policy", + "sync:provide", "system:tls", "device:serial", "hosts:write", diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index d44202f5e..5de206c14 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -236,6 +236,429 @@ export interface SyncEntityRegistration { */ export interface PluginSync { 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; +} + +/** 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; + +/** 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; version: () => string }; + + entities: { + list: () => SyncEntityInfo[]; + get: (type: string) => SyncEntityInfo | undefined; + /** Registers core's own entities once. */ + ensureCore: () => Promise; + 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; failed: Set }>; + loadRow: ( + type: string, + userId: string, + syncId: string, + resolvers: unknown, + ) => Promise; + writeRow: ( + type: string, + userId: string, + wire: SyncRow, + resolvers: unknown, + ) => Promise; + deleteRow: (type: string, userId: string, syncId: string) => Promise; + orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[]; + counts: (userId: string) => Promise>; + wipe: (userId: string) => Promise; + regenerateSyncIds: (userId: string) => Promise; + removeSharedCopies: (userId: string) => Promise; + records: { + list: (userId: string) => Promise>; + put: ( + userId: string, + type: string, + syncId: string, + values: { revision: number; hash: string | null; deleted: boolean }, + ) => Promise; + setError: ( + userId: string, + type: string, + syncId: string, + reason: string, + hash: string, + ) => Promise; + clearErrors: (userId: string) => Promise; + listErrors: (userId: string) => Promise; + deleteForUser: (userId: string) => Promise; + key: (type: string, syncId: string) => string; + }; + conflicts: { + save: ( + userId: string, + type: string, + syncId: string, + row: SyncRow, + revision: number, + ) => Promise; + list: (userId: string) => Promise; + get: (userId: string, id: number) => Promise; + delete: (userId: string, id: number) => Promise; + }; + }; + + /** The server side: what changed for an account and what a desktop pushed. */ + feed: { + withUserLock: (userId: string, run: () => Promise) => Promise; + 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; + applyPush: (userId: string, ops: SyncPushOp[]) => Promise; + /** 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; + get: (userId?: string) => Promise; + create: (link: SyncLinkCreate) => Promise; + update: ( + patch: SyncLinkPatch, + userId?: string, + ) => Promise; + remove: (userId?: string) => Promise; + }; + + /** Desktop accounts: the local user and the replica user of each server. */ + accounts: { + list: () => Promise; + /** A new empty account, ready to be linked to a server. */ + create: () => Promise; + /** Which account the app signs in as from now on. */ + setActive: (userId: string) => Promise; + activeId: () => Promise; + /** Deletes an account and all its data. The last one cannot go. */ + remove: (userId: string) => Promise; + }; + + http: { + json: ( + target: SyncRemoteTarget, + pathname: string, + request?: SyncRemoteRequest, + ) => Promise; + fetch: ( + target: SyncRemoteTarget, + pathname: string, + request?: SyncRemoteRequest, + ) => Promise<{ + ok: boolean; + status: number; + body: AsyncIterable | 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; + revoke: (sessionId: string) => Promise; + 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>; + }; + + 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; + mirror: (link: SyncLinkInfo) => Promise; + lastRemote: () => SyncRemotePlugin[]; + localNames: () => Map; + }; + + desktop: { + /** Tells the app's main process how to reach the linked origin. */ + applyProxy: (link: SyncLinkInfo | null) => Promise; + deviceName: () => string; + }; + + alerts: { + send: (alert: { + title: string; + body: string; + severity: "info" | "warning" | "error"; + category: string; + dedupeKey: string; + link?: { tab: string }; + }) => Promise; + }; } /** diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index eb3d39f0a..93b573cc3 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -48,6 +48,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("db:core-refs", "high"), entry("auth:provide", "high"), entry("auth:policy", "high"), + entry("sync:provide", "high"), entry("device:serial", "high"), entry("notify:hub", "high"), entry("ui:layout", "high"), diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 91a7cf562..54a72430a 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -37,7 +37,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * 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. */ export const SUPPORTED_PLUGIN_API_VERSION = String( diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 6c42ab7f3..8b69592a2 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -68,7 +68,7 @@ import type { PluginSshKeyCredential, PluginSshKeyCredentialInput, } from "./backend.js"; -import type { SyncEntityRegistration } from "./backend.js"; +import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js"; import type { PluginDatabase } from "./backend.js"; import type { PluginExternalClientRequest, @@ -104,6 +104,8 @@ export interface FakeScheduledJob { export interface FakeContextOptions { pluginId?: string; + /** What ctx.sync.host answers, for a sync provider's tests. */ + syncHost?: Partial; manifest?: Partial; /** Acting user returned by currentActor and used by asUser. */ actor?: string; @@ -756,6 +758,12 @@ export function createFakeContext( registerEntity: (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: { @@ -1582,6 +1590,8 @@ export function createFakeContext( export interface MockContextOptions { pluginId?: string; + /** What ctx.sync.host answers, for a sync provider's tests. */ + syncHost?: Partial; manifest?: Partial; /** Capabilities the plugin is granted. Anything else throws. */ capabilities?: string[]; @@ -1744,6 +1754,7 @@ export function createMockCtx( const base = createFakeContext({ pluginId: options.pluginId, + syncHost: options.syncHost, actor: options.actor, settings: options.settings, coreSettings: options.coreSettings, diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index bbb9fef6f..4ecc43468 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -27,8 +27,7 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js"; import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js"; import uiPreferencesRoutes from "./routes/ui-preferences.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; -import syncRoutes from "../sync/server/routes.js"; -import syncLinkRoutes from "../sync/client/routes.js"; +import { syncForwarder } from "../sync/provider.js"; import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import dashboardRoutes from "./routes/dashboard-routes.js"; import { @@ -1625,8 +1624,7 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes); app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes); app.use("/ui-preferences", uiPreferencesRoutes); registerAuditLogRoutes(app, authenticateJWT); -app.use("/sync", syncRoutes); -app.use("/sync", syncLinkRoutes); +app.use("/sync", syncForwarder); app.use("/dashboard", dashboardRoutes); app.use("/plugins", pluginRoutes); app.use( diff --git a/src/backend/database/routes/desktop-auto-session.ts b/src/backend/database/routes/desktop-auto-session.ts index 915e1bfd5..1c8dac924 100644 --- a/src/backend/database/routes/desktop-auto-session.ts +++ b/src/backend/database/routes/desktop-auto-session.ts @@ -61,15 +61,23 @@ export function isNativeTokenExportRequest(req: Request): boolean { * during testing, or a household sharing one install) -- a user in that * state deserves to get into the app they installed, not a confusing, * unexplained dead end. So this always returns a single, deterministic - * user: the admin account if one exists, else the earliest-registered - * account. It never returns null. + * user: the account the user switched to if it still exists, else the admin + * account if one exists, else the earliest-registered account. It never + * returns null when there are users. */ export function resolveDesktopAutoSessionUser( allUsers: UserRecord[], + preferredId?: string | null, ): UserRecord | null { if (allUsers.length === 0) return null; 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); if (admin) return admin; diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 5a572e741..7a9dd7ce3 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -576,8 +576,8 @@ async function describeDesktopLink(userId: string) { if (process.env.ELECTRON_EMBEDDED !== "true") return null; try { const { getLink } = await import("../../sync/client/link-store.js"); - const link = await getLink(); - if (!link || link.userId !== userId) return null; + const link = await getLink(userId); + if (!link) return null; return { serverUrl: link.serverUrl, serverName: link.serverName, @@ -743,7 +743,11 @@ router.post("/internal/auto-session", async (req, res) => { const userRepository = createCurrentUserRepository(); const allUsers = await userRepository.listAll(); - const userRecord = resolveDesktopAutoSessionUser(allUsers); + const { getSavedActiveUserId } = await import("../../sync/accounts.js"); + const userRecord = resolveDesktopAutoSessionUser( + allUsers, + await getSavedActiveUserId(), + ); if (!userRecord) { return res.status(403).json({ error: "No local users exist", diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 9696c2350..d4df218c2 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -54,6 +54,9 @@ import { } from "@termix-ssh/plugin-sdk/backend"; import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db"; 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 { needsExplicitPersist, resolveDatabaseDialect, @@ -189,6 +192,34 @@ export function createPluginContext( handle: PluginHandle, ): PluginContext { 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 = ( action: string, details: string, @@ -520,12 +551,7 @@ export function createPluginContext( }, }, - sync: { - registerEntity: (entity) => { - const dispose = syncRegistry.registerEntity(pluginId, entity); - handle.bag.add(dispose, `sync entity ${entity.type}`); - }, - }, + sync: syncApi, kv: { get: (key) => kvGet(key), diff --git a/src/backend/starter.ts b/src/backend/starter.ts index d827e3f43..10bd20a28 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -265,17 +265,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { }); } - // 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") { try { - const { startDesktopSync } = await import("./sync/client/engine.js"); - startDesktopSync(); const { startDesktopUpdateCheck } = await import("./updates/desktop-update-check.js"); startDesktopUpdateCheck(); } catch (error) { - systemLogger.warn("Desktop sync failed to start", { - operation: "sync_start", + systemLogger.warn("Desktop update check failed to start", { + operation: "update_check_start", error: getErrorMessage(error), }); } diff --git a/src/backend/sync/accounts.ts b/src/backend/sync/accounts.ts new file mode 100644 index 000000000..7a76b6463 --- /dev/null +++ b/src/backend/sync/accounts.ts @@ -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 { + 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 { + 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 { + return createCurrentSettingsRepository().get(ACTIVE_KEY); +} + +export interface DesktopAccount { + id: string; + username: string; + isAdmin: boolean; + active: boolean; +} + +export async function listAccounts(): Promise { + 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 { + 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 { + 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); + } +} diff --git a/src/backend/sync/client/device.ts b/src/backend/sync/client/device.ts new file mode 100644 index 000000000..b162a8c05 --- /dev/null +++ b/src/backend/sync/client/device.ts @@ -0,0 +1,5 @@ +import os from "os"; + +export function defaultDeviceName(): string { + return os.hostname() || "Termix Desktop"; +} diff --git a/src/backend/sync/client/link-store.ts b/src/backend/sync/client/link-store.ts index 036c13a0a..a333087b0 100644 --- a/src/backend/sync/client/link-store.ts +++ b/src/backend/sync/client/link-store.ts @@ -1,9 +1,12 @@ /** - * The desktop's link to a server, one row in sync_link. Secrets (the session - * token, proxy headers, basic auth) are stored encrypted with the system key. + * The desktop's links to servers, one row each in sync_link and at most one + * 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 { getActiveUserId } from "../accounts.js"; import { syncLink } from "../../database/db/schema.js"; import { createCurrentRepositoryContext } from "../../database/repositories/factory.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; @@ -48,6 +51,8 @@ export interface SyncLink { account: LinkAccount | null; /** Entity types switched off on this device. */ disabledTypes: string[]; + /** Extra connection options for this server, kept open for new ones. */ + options: Record; /** Entity types this device has fully pulled at least once. */ knownTypes: string[]; cursor: number; @@ -90,7 +95,10 @@ async function fromRow(row: Row): Promise { sessionToken = null; } } - const scope = parseJson<{ disabled?: string[] }>(row.scope, {}); + const scope = parseJson<{ + disabled?: string[]; + options?: Record; + }>(row.scope, {}); return { id: row.id, userId: row.userId, @@ -105,6 +113,8 @@ async function fromRow(row: Row): Promise { remoteUsername: row.remoteUsername, account: parseJson(row.account, null), disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [], + options: + scope.options && typeof scope.options === "object" ? scope.options : {}, knownTypes: parseJson(row.knownTypes, []), cursor: Number(row.cursor) || 0, status: (row.status as LinkStatus) || "idle", @@ -114,8 +124,20 @@ async function fromRow(row: Row): Promise { }; } -export async function getLink(): Promise { - const [row] = await db().select().from(syncLink).limit(1); +export async function listLinks(): Promise { + 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 { + 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; } @@ -133,7 +155,7 @@ export interface NewLink { } export async function createLink(link: NewLink): Promise { - await db().delete(syncLink); + await db().delete(syncLink).where(eq(syncLink.userId, link.userId)); await db() .insert(syncLink) .values({ @@ -157,7 +179,7 @@ export async function createLink(link: NewLink): Promise { linkedAt: new Date().toISOString(), }); await DatabaseSaveTrigger.forceSave("sync_link"); - return (await getLink())!; + return (await getLink(link.userId))!; } export interface LinkPatch { @@ -171,6 +193,7 @@ export interface LinkPatch { remoteUsername?: string | null; account?: LinkAccount | null; disabledTypes?: string[]; + options?: Record; knownTypes?: string[]; cursor?: number; status?: LinkStatus; @@ -178,8 +201,11 @@ export interface LinkPatch { lastSyncAt?: string | null; } -export async function updateLink(patch: LinkPatch): Promise { - const current = await getLink(); +export async function updateLink( + patch: LinkPatch, + userId?: string, +): Promise { + const current = await getLink(userId); if (!current) return null; const set: Partial = {}; if (patch.serverName !== undefined) set.serverName = patch.serverName; @@ -210,8 +236,11 @@ export async function updateLink(patch: LinkPatch): Promise { if (patch.account !== undefined) { set.account = patch.account ? JSON.stringify(patch.account) : null; } - if (patch.disabledTypes !== undefined) { - set.scope = JSON.stringify({ disabled: patch.disabledTypes }); + if (patch.disabledTypes !== undefined || patch.options !== undefined) { + set.scope = JSON.stringify({ + disabled: patch.disabledTypes ?? current.disabledTypes, + options: patch.options ?? current.options, + }); } if (patch.knownTypes !== undefined) { set.knownTypes = JSON.stringify(patch.knownTypes); @@ -224,10 +253,12 @@ export async function updateLink(patch: LinkPatch): Promise { await db().update(syncLink).set(set).where(eq(syncLink.id, current.id)); DatabaseSaveTrigger.triggerSave("sync_link"); } - return getLink(); + return getLink(current.userId); } -export async function deleteLink(): Promise { - await db().delete(syncLink); +export async function deleteLink(userId?: string): Promise { + const owner = userId ?? (await getActiveUserId()); + if (!owner) return; + await db().delete(syncLink).where(eq(syncLink.userId, owner)); await DatabaseSaveTrigger.forceSave("sync_link"); } diff --git a/src/backend/sync/host.ts b/src/backend/sync/host.ts new file mode 100644 index 000000000..cacbdef4c --- /dev/null +++ b/src/backend/sync/host.ts @@ -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 { + 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 { + 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 { + const { registerCoreSyncEntities } = await import("./entities.js"); + registerCoreSyncEntities(); +} + +async function counts(userId: string): Promise> { + await ensureCoreEntities(); + const result: Record = {}; + 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 { + 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 = {}; + 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(); + 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), + }, + }; +} diff --git a/src/backend/sync/provider.ts b/src/backend/sync/provider.ts new file mode 100644 index 000000000..ba24cb920 --- /dev/null +++ b/src/backend/sync/provider.ts @@ -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(); + +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" }); +} diff --git a/src/backend/sync/server/change-watcher.ts b/src/backend/sync/server/change-watcher.ts index c4481c86d..9a4c74e10 100644 --- a/src/backend/sync/server/change-watcher.ts +++ b/src/backend/sync/server/change-watcher.ts @@ -1,14 +1,8 @@ import type { NextFunction, Request, Response } from "express"; import { markChanged } from "./feed.js"; +import { notifyLocalWrite } from "./write-listeners.js"; 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 @@ -23,7 +17,7 @@ export function syncChangeWatcher( res.on("finish", () => { if (res.statusCode >= 400) return; markChanged(); - for (const listener of writeListeners) listener(); + notifyLocalWrite(); }); } next(); diff --git a/src/backend/sync/server/write-listeners.ts b/src/backend/sync/server/write-listeners.ts new file mode 100644 index 000000000..1465a4524 --- /dev/null +++ b/src/backend/sync/server/write-listeners.ts @@ -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(); +} diff --git a/src/backend/tests/database/routes/desktop-auto-session.test.ts b/src/backend/tests/database/routes/desktop-auto-session.test.ts index eeaccb1f9..879f791b0 100644 --- a/src/backend/tests/database/routes/desktop-auto-session.test.ts +++ b/src/backend/tests/database/routes/desktop-auto-session.test.ts @@ -136,6 +136,22 @@ describe("isNativeTokenExportRequest", () => { }); 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", () => { const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" }); expect(resolveDesktopAutoSessionUser([user])).toBe(user); diff --git a/src/backend/tests/sync/accounts.test.ts b/src/backend/tests/sync/accounts.test.ts new file mode 100644 index 000000000..e0676d2cd --- /dev/null +++ b/src/backend/tests/sync/accounts.test.ts @@ -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(); + }); +}); diff --git a/src/backend/tests/sync/client/link-store.test.ts b/src/backend/tests/sync/client/link-store.test.ts index b6acd908f..88bb0b1a1 100644 --- a/src/backend/tests/sync/client/link-store.test.ts +++ b/src/backend/tests/sync/client/link-store.test.ts @@ -21,8 +21,9 @@ vi.mock("../../../utils/system-crypto.js", () => ({ const { TestSqliteDatabase } = 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"); +const { setActiveUserId } = await import("../../../sync/accounts.js"); let db: InstanceType; @@ -96,3 +97,79 @@ describe("the desktop's link", () => { 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"]); + }); +}); diff --git a/src/backend/tests/sync/host.test.ts b/src/backend/tests/sync/host.test.ts new file mode 100644 index 000000000..e08fcb7bd --- /dev/null +++ b/src/backend/tests/sync/host.test.ts @@ -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", + }); + }); +}); diff --git a/src/backend/tests/sync/provider.test.ts b/src/backend/tests/sync/provider.test.ts new file mode 100644 index 000000000..c2c00a06e --- /dev/null +++ b/src/backend/tests/sync/provider.test.ts @@ -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(); + }); +}); diff --git a/src/backend/upgrade/boot-migrations.ts b/src/backend/upgrade/boot-migrations.ts index 6c56c0368..74bedc47e 100644 --- a/src/backend/upgrade/boot-migrations.ts +++ b/src/backend/upgrade/boot-migrations.ts @@ -48,8 +48,8 @@ export const runPluginDataMigrations = DatabaseSaveTrigger.batched( */ async function applyHostDefaults(): Promise { try { - const { getLink } = await import("../sync/client/link-store.js"); - if (await getLink().catch(() => null)) return; + const { listLinks } = await import("../sync/client/link-store.js"); + if ((await listLinks().catch(() => [])).length > 0) return; const { recomputeEverything } = await import("../hosts/defaults/recompute.js"); recomputeEverything("plugins started"); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ef9d973a2..108bbb334 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1151,6 +1151,10 @@ "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." }, + "sync:provide": { + "title": "Run sync", + "consequence": "It can answer all sync requests, so it sees the data that devices and servers sync." + }, "system:tls": { "title": "Manage certificates", "consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."