feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

This commit is contained in:
LukeGus committed 2026-09-26 00:41:25 -05:00
1 parent 7c02daccf4
commit 6cd9a2846d
242 files changed
+39127 -8558

No files matched your search

@@ -0,0 +1,59 @@
CREATE TABLE `sync_conflicts` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` varchar(255) NOT NULL,
`entity_type` text NOT NULL,
`sync_id` text NOT NULL,
`local_row` text NOT NULL,
`server_revision` int NOT NULL,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `sync_conflicts_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `sync_link` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` varchar(255) NOT NULL,
`server_url` text NOT NULL,
`server_name` text,
`server_version` text,
`session_token` text,
`custom_headers` text,
`basic_auth` text,
`allow_invalid_certificate` boolean NOT NULL DEFAULT false,
`remote_user_id` text,
`remote_username` text,
`account` text,
`scope` text,
`known_types` text,
`cursor` int NOT NULL DEFAULT 0,
`status` text NOT NULL DEFAULT ('idle'),
`last_error` text,
`linked_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`last_sync_at` text,
CONSTRAINT `sync_link_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `sync_records` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` varchar(255) NOT NULL,
`entity_type` varchar(255) NOT NULL,
`sync_id` varchar(255) NOT NULL,
`revision` int NOT NULL DEFAULT 0,
`seq` int NOT NULL DEFAULT 0,
`hash` text,
`deleted` boolean NOT NULL DEFAULT false,
`error` text,
`error_hash` text,
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `sync_records_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_sync_records_user_entity_sync` UNIQUE(`user_id`,`entity_type`,`sync_id`)
);
--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `local_only` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `shared_source` text;--> statement-breakpoint
ALTER TABLE `ssh_credentials` ADD `shared_source` text;--> statement-breakpoint
ALTER TABLE `ssh_folders` ADD `local_only` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `sync_conflicts` ADD CONSTRAINT `sync_conflicts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `sync_link` ADD CONSTRAINT `sync_link_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `sync_records` ADD CONSTRAINT `sync_records_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_sync_conflicts_user` ON `sync_conflicts` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_sync_records_user_seq` ON `sync_records` (`user_id`,`seq`);
+1
View File
@@ -0,0 +1 @@
DROP TABLE `sync_tombstones`;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -414,6 +414,20 @@
"when": 1790370152568,
"tag": "0058_boring_avengers",
"breakpoints": true
},
{
"idx": 59,
"version": "5",
"when": 1790393785782,
"tag": "0059_military_purple_man",
"breakpoints": true
},
{
"idx": 60,
"version": "5",
"when": 1790393789429,
"tag": "0060_mean_gorgon",
"breakpoints": true
}
]
}
@@ -0,0 +1,56 @@
CREATE TABLE "sync_conflicts" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"entity_type" text NOT NULL,
"sync_id" text NOT NULL,
"local_row" text NOT NULL,
"server_revision" integer NOT NULL,
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sync_link" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"server_url" text NOT NULL,
"server_name" text,
"server_version" text,
"session_token" text,
"custom_headers" text,
"basic_auth" text,
"allow_invalid_certificate" boolean DEFAULT false NOT NULL,
"remote_user_id" text,
"remote_username" text,
"account" text,
"scope" text,
"known_types" text,
"cursor" integer DEFAULT 0 NOT NULL,
"status" text DEFAULT 'idle' NOT NULL,
"last_error" text,
"linked_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
"last_sync_at" text
);
--> statement-breakpoint
CREATE TABLE "sync_records" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"entity_type" varchar(255) NOT NULL,
"sync_id" varchar(255) NOT NULL,
"revision" integer DEFAULT 0 NOT NULL,
"seq" integer DEFAULT 0 NOT NULL,
"hash" text,
"deleted" boolean DEFAULT false NOT NULL,
"error" text,
"error_hash" text,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "local_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "shared_source" text;--> statement-breakpoint
ALTER TABLE "ssh_credentials" ADD COLUMN "shared_source" text;--> statement-breakpoint
ALTER TABLE "ssh_folders" ADD COLUMN "local_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "sync_conflicts" ADD CONSTRAINT "sync_conflicts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sync_link" ADD CONSTRAINT "sync_link_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sync_records" ADD CONSTRAINT "sync_records_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_sync_conflicts_user" ON "sync_conflicts" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_sync_records_user_entity_sync" ON "sync_records" USING btree ("user_id","entity_type","sync_id");--> statement-breakpoint
CREATE INDEX "idx_sync_records_user_seq" ON "sync_records" USING btree ("user_id","seq");
@@ -0,0 +1 @@
DROP TABLE "sync_tombstones" CASCADE;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -421,6 +421,20 @@
"when": 1790370151997,
"tag": "0059_square_korg",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1790393784687,
"tag": "0060_conscious_juggernaut",
"breakpoints": true
},
{
"idx": 61,
"version": "7",
"when": 1790393788357,
"tag": "0061_red_lila_cheney",
"breakpoints": true
}
]
}
@@ -0,0 +1,56 @@
CREATE TABLE `sync_conflicts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`entity_type` text NOT NULL,
`sync_id` text NOT NULL,
`local_row` text NOT NULL,
`server_revision` integer NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_sync_conflicts_user` ON `sync_conflicts` (`user_id`);--> statement-breakpoint
CREATE TABLE `sync_link` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`server_url` text NOT NULL,
`server_name` text,
`server_version` text,
`session_token` text,
`custom_headers` text,
`basic_auth` text,
`allow_invalid_certificate` integer DEFAULT false NOT NULL,
`remote_user_id` text,
`remote_username` text,
`account` text,
`scope` text,
`known_types` text,
`cursor` integer DEFAULT 0 NOT NULL,
`status` text DEFAULT 'idle' NOT NULL,
`last_error` text,
`linked_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`last_sync_at` text,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sync_records` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`entity_type` text NOT NULL,
`sync_id` text NOT NULL,
`revision` integer DEFAULT 0 NOT NULL,
`seq` integer DEFAULT 0 NOT NULL,
`hash` text,
`deleted` integer DEFAULT false NOT NULL,
`error` text,
`error_hash` text,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_sync_records_user_entity_sync` ON `sync_records` (`user_id`,`entity_type`,`sync_id`);--> statement-breakpoint
CREATE INDEX `idx_sync_records_user_seq` ON `sync_records` (`user_id`,`seq`);--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `local_only` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `shared_source` text;--> statement-breakpoint
ALTER TABLE `ssh_credentials` ADD `shared_source` text;--> statement-breakpoint
ALTER TABLE `ssh_folders` ADD `local_only` integer DEFAULT false NOT NULL;
+1
View File
@@ -0,0 +1 @@
DROP TABLE `sync_tombstones`;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -386,6 +386,20 @@
"when": 1790370151385,
"tag": "0054_superb_guardian",
"breakpoints": true
},
{
"idx": 55,
"version": "6",
"when": 1790393783561,
"tag": "0055_shiny_squadron_sinister",
"breakpoints": true
},
{
"idx": 56,
"version": "6",
"when": 1790393787230,
"tag": "0056_rare_luckman",
"breakpoints": true
}
]
}
+80
View File
@@ -0,0 +1,80 @@
// Desktop-only settings kept next to the app's data, and the server address
// older versions saved, so linking can offer it again.
const { app } = require("electron");
const fs = require("fs");
const path = require("path");
function dataPath(filename) {
return path.join(app.getPath("userData"), filename);
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
mode: 0o600,
});
fs.renameSync(temporaryPath, filePath);
} catch (error) {
fs.rmSync(temporaryPath, { force: true });
throw error;
}
}
const DEFAULTS = { defaultConnectionOrigin: "local" };
function getDesktopSettings() {
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
}
function saveDesktopSettings(settings) {
writeJson(dataPath("desktop-settings.json"), {
...getDesktopSettings(),
...settings,
});
return { success: true };
}
// The server a 2.8 or older desktop was pointed at, if any.
function getPreviousServerUrl() {
for (const file of ["remote-sync-config.json", "server-config.json"]) {
const config = readJson(dataPath(file), null);
if (config && typeof config.serverUrl === "string" && config.serverUrl) {
return config.serverUrl;
}
}
return null;
}
// The old sync engine's session and cursors are useless to the new one,
// and the session is a credential, so they go.
function removeOldSyncFiles() {
for (const file of [
"remote-sync-credential.json",
"remote-sync-state.json",
]) {
try {
fs.rmSync(dataPath(file), { force: true });
} catch {
// Best effort.
}
}
}
module.exports = {
getDesktopSettings,
saveDesktopSettings,
getPreviousServerUrl,
removeOldSyncFiles,
};
+98
View File
@@ -0,0 +1,98 @@
// The server this desktop is linked to, as the embedded backend reports it.
// The backend owns the link (and its secrets); main only keeps what it needs
// to reach that one origin: the proxy headers and basic auth a reverse proxy
// in front of it wants, whether its certificate may be self-signed, and the
// session for requests main makes itself (C2S tunnels).
let linked = null;
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function setLinkedServer(config) {
if (!config || !config.origin) {
linked = null;
return;
}
linked = {
origin: config.origin,
serverUrl: config.serverUrl || config.origin,
token: config.token || null,
headers: Array.isArray(config.headers) ? config.headers : [],
basicAuth:
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
allowInvalidCertificate: !!config.allowInvalidCertificate,
};
}
function getLinkedServer() {
return linked;
}
function isLinkedOrigin(url) {
return !!linked && getOrigin(url) === linked.origin;
}
function basicAuthValue(basicAuth) {
return `Basic ${Buffer.from(
`${basicAuth.username}:${basicAuth.password || ""}`,
).toString("base64")}`;
}
// Adds the link's proxy headers to a request bound for the linked origin.
// With basic auth in front, Authorization belongs to the proxy, so a Termix
// bearer token moves into the jwt cookie, which Termix reads first.
function applyLinkHeaders(url, headers) {
if (!isLinkedOrigin(url)) return headers;
const out = { ...headers };
for (const header of linked.headers) {
if (header && header.name && header.value) out[header.name] = header.value;
}
if (linked.basicAuth) {
const authKey = Object.keys(out).find(
(key) => key.toLowerCase() === "authorization",
);
const current = authKey ? String(out[authKey]) : "";
if (authKey) delete out[authKey];
const bearer = current.match(/^Bearer\s+(.+)$/i);
if (bearer) {
const cookieKey =
Object.keys(out).find((key) => key.toLowerCase() === "cookie") ||
"Cookie";
const existing = out[cookieKey] ? `${out[cookieKey]}; ` : "";
out[cookieKey] = `${existing}jwt=${bearer[1]}`;
}
out.Authorization = basicAuthValue(linked.basicAuth);
}
return out;
}
// Headers for a request main itself makes to the linked server.
function linkedRequestHeaders(extra = {}) {
if (!linked) return { ...extra };
const base = { "X-Electron-App": "true", ...extra };
if (linked.token) base.Authorization = `Bearer ${linked.token}`;
return applyLinkHeaders(linked.origin, base);
}
// Answers a basic auth challenge from the linked origin's proxy.
function answerLogin(authInfo, url) {
if (!linked || !linked.basicAuth) return null;
if (authInfo && authInfo.isProxy) return null;
if (!isLinkedOrigin(url)) return null;
return linked.basicAuth;
}
module.exports = {
setLinkedServer,
getLinkedServer,
isLinkedOrigin,
applyLinkHeaders,
linkedRequestHeaders,
answerLogin,
};
+15 -26
View File
@@ -10,8 +10,8 @@
// Trust boundary: the renderer never supplies a URL or headers. It names an
// origin ("local" | "remote") and a route from a fixed allowlist; the main
// process resolves the actual Termix backend URL and attaches credentials
// itself (session cookies for the embedded backend, the stored Remote Sync JWT
// for the remote server). Nothing here can be pointed at another host.
// itself (session cookies for the embedded backend, the linked server's
// session). Nothing here can be pointed at another host.
const fs = require("fs");
const fsp = require("fs/promises");
@@ -344,12 +344,11 @@ function normalizeHttpBase(candidate, what) {
}
// Resolves where a transfer may go. Only the two file-manager streaming
// routes are reachable, and only on the embedded backend or the configured
// Remote Sync server; credentials come from the main process, not the caller.
// routes are reachable, and only on the embedded backend or the linked
// server; credentials come from the main process, not the caller.
function createTargetResolver({
localBaseUrl = DEFAULT_LOCAL_FILE_MANAGER_BASE,
getRemoteSyncConfig,
getRemoteSyncJwt,
getLinkedServer,
}) {
return function resolveTransferTarget({
origin,
@@ -396,20 +395,13 @@ function createTargetResolver({
}
if (origin === "remote") {
const config =
typeof getRemoteSyncConfig === "function"
? getRemoteSyncConfig()
: null;
if (!config || !config.serverUrl) {
throw new Error("Remote sync server is not configured");
const linked =
typeof getLinkedServer === "function" ? getLinkedServer() : null;
if (!linked || !linked.serverUrl) {
throw new Error("This device is not linked to a server");
}
const base = normalizeHttpBase(
config.serverUrl,
"Remote sync server URL",
);
const jwt =
typeof getRemoteSyncJwt === "function" ? getRemoteSyncJwt() : null;
if (jwt) headers.Authorization = `Bearer ${jwt}`;
const base = normalizeHttpBase(linked.serverUrl, "Linked server URL");
if (linked.token) headers.Authorization = `Bearer ${linked.token}`;
return { url: `${base}${FILE_MANAGER_API}${routePath}`, headers };
}
@@ -880,8 +872,7 @@ function wrap(handler) {
function createLocalFileHandlers({
net,
shell,
getRemoteSyncConfig,
getRemoteSyncJwt,
getLinkedServer,
localBaseUrl,
publishFs,
}) {
@@ -890,8 +881,7 @@ function createLocalFileHandlers({
}
const resolveTransferTarget = createTargetResolver({
localBaseUrl,
getRemoteSyncConfig,
getRemoteSyncJwt,
getLinkedServer,
});
const deps = {
net,
@@ -1020,12 +1010,11 @@ function createLocalFileHandlers({
function registerLocalFileHandlers({ ipcMain, shell }) {
// Real Electron wiring; tests build the handlers directly instead.
const { net } = require("electron");
const remoteSync = require("./remote-sync.cjs");
const { getLinkedServer } = require("./linked-server.cjs");
const handlers = createLocalFileHandlers({
net,
shell,
getRemoteSyncConfig: remoteSync.getRemoteSyncConfig,
getRemoteSyncJwt: remoteSync.getRemoteSyncJwt,
getLinkedServer,
});
for (const [channel, handler] of Object.entries(handlers)) {
ipcMain.handle(channel, handler);
+62 -356
View File
@@ -27,7 +27,8 @@ const { URL, pathToFileURL } = require("url");
const { fork, spawn } = require("child_process");
const pty = require("node-pty");
const WebSocket = require("ws");
const remoteSync = require("./remote-sync.cjs");
const linkedServer = require("./linked-server.cjs");
const desktopSettings = require("./desktop-settings.cjs");
const { launchNativeRdp } = require("./native-rdp.cjs");
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
@@ -54,7 +55,7 @@ function closeLocalTerminalsFor(ownerId) {
}
// The main process's Node.js networking (the `https`/`http` modules used by
// httpFetch below, and the global `fetch` used by remote-sync.cjs) only
// httpFetch below and the global `fetch`) only
// trusts Node's bundled Mozilla CA list by default, not the OS/system trust
// store. Chromium (the renderer, i.e. the web app and the login iframe) uses
// the OS trust store instead, so a certificate that's valid in-browser --
@@ -432,20 +433,6 @@ function isInsecureModeEnabled() {
);
}
function getServerConfigPath() {
return path.join(app.getPath("userData"), "server-config.json");
}
function getServerConfigSync() {
try {
const configPath = getServerConfigPath();
if (!fs.existsSync(configPath)) return null;
return JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
return null;
}
}
function getOrigin(url) {
try {
return new URL(url).origin;
@@ -493,13 +480,11 @@ function isInvalidCertificateAllowedForUrl(url) {
// fall through
}
// The only remaining "connected remote server" a self-signed/invalid
// certificate could legitimately apply to is the Remote Sync server
// (also used for C2S tunnel relaying, see getC2SRelayUrl).
const config = remoteSync.getRemoteSyncConfig();
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
return getOrigin(url) === getOrigin(config.serverUrl);
// Otherwise only the server this desktop is linked to, when its link
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return getOrigin(url) === linked.origin;
}
function getTlsVerificationOptions(url) {
@@ -646,6 +631,10 @@ const isolatedWindows = createIsolatedWindows({
const BACKEND_REQUEST_HANDLERS = {
"open-isolated-window": (payload) => isolatedWindows.open(payload),
"launch-native-rdp": (payload) => launchNativeRdp(payload),
"sync-proxy-config": (payload) => {
linkedServer.setLinkedServer(payload);
return { success: true };
},
};
async function handleBackendRequest(msg) {
@@ -888,6 +877,16 @@ app.on(
},
);
// A reverse proxy in front of the linked server may ask for basic auth; the
// link's saved username and password answer it. Anything else gets the
// default behaviour.
app.on("login", (event, _webContents, details, authInfo, callback) => {
const credentials = linkedServer.answerLogin(authInfo, details?.url);
if (!credentials) return;
event.preventDefault();
callback(credentials.username, credentials.password || "");
});
function getElectronBuildTimestamp() {
try {
const buildInfo = require("./build-info.cjs");
@@ -1420,7 +1419,12 @@ function createWindow() {
);
}
callback({ requestHeaders: details.requestHeaders });
callback({
requestHeaders: linkedServer.applyLinkHeaders(
details.url,
details.requestHeaders,
),
});
},
);
@@ -1564,111 +1568,6 @@ ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix";
const githubCache = new Map();
const CACHE_DURATION = 30 * 60 * 1000;
async function fetchGitHubAPI(endpoint, cacheKey) {
const cached = githubCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return {
data: cached.data,
cached: true,
cache_age: Date.now() - cached.timestamp,
};
}
try {
const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixElectronUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout: 10000,
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
githubCache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return {
data: data,
cached: false,
};
} catch (error) {
console.error("Failed to fetch from GitHub API:", error);
throw error;
}
}
ipcMain.handle("check-electron-update", async () => {
try {
const localVersion = app.getVersion();
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release_electron",
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
return {
success: false,
error: "Remote version not found",
localVersion,
};
}
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const result = {
success: true,
status,
localVersion: localVersion,
remoteVersion: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
body: releaseData.data.body,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
return result;
} catch (error) {
return {
success: false,
error: error.message,
localVersion: app.getVersion(),
};
}
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
@@ -1769,109 +1668,20 @@ ipcMain.handle(
},
);
ipcMain.handle("get-server-config", () => {
try {
return getServerConfigSync();
} catch (error) {
console.error("Error reading server config:", error);
return null;
}
});
ipcMain.handle("save-server-config", (event, config) => {
try {
const userDataPath = app.getPath("userData");
const configPath = getServerConfigPath();
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { success: true };
} catch (error) {
console.error("Error saving server config:", error);
return { success: false, error: error.message };
}
});
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was
// ever set in it) so the renderer can prompt upgraded installs to set up
// Remote Sync -- their hosts live on that old server and won't appear
// locally until sync is enabled. A fresh install never had this file, so
// this is naturally false for anyone who never used the old architecture.
ipcMain.handle("get-legacy-server-config", () => {
const config = getServerConfigSync();
return { serverUrl: config?.serverUrl || null };
});
// Desktop-only settings, such as whether hosts connect from this device or
// through the linked server by default.
ipcMain.handle("get-desktop-settings", () => {
return remoteSync.getDesktopSettings();
return desktopSettings.getDesktopSettings();
});
ipcMain.handle("save-desktop-settings", (_event, settings) => {
return remoteSync.saveDesktopSettings(settings);
return desktopSettings.saveDesktopSettings(settings);
});
ipcMain.handle("get-remote-sync-config", () => {
return remoteSync.getRemoteSyncConfig();
});
ipcMain.handle("save-remote-sync-config", (_event, config) => {
return remoteSync.saveRemoteSyncConfig(config);
});
ipcMain.handle("clear-remote-sync-config", async () => {
const result = remoteSync.clearRemoteSyncConfig();
remoteSync.clearRemoteSyncJwt();
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: false,
syncing: false,
needsReauth: false,
lastError: null,
});
return result;
});
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: true,
needsReauth: false,
lastError: null,
});
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
return { ...result, status: status || null };
}
return result;
});
ipcMain.handle("get-remote-sync-jwt", () => {
return remoteSync.getRemoteSyncJwt();
});
ipcMain.handle("clear-remote-sync-jwt", () => {
return remoteSync.clearRemoteSyncJwt();
});
ipcMain.handle("get-remote-sync-status", () => {
return remoteSync.getRemoteSyncEngine()?.status || null;
});
ipcMain.handle("get-remote-sync-user-info", () => {
return remoteSync.getRemoteSyncUserInfo();
});
ipcMain.handle("remote-sync-now", async () => {
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
});
ipcMain.handle("notify-local-login", (_event, token) => {
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
return { success: true };
// The server an older version of the app was pointed at, offered again when
// linking so upgraded installs do not have to look it up.
ipcMain.handle("get-previous-server-url", () => {
return { serverUrl: desktopSettings.getPreviousServerUrl() };
});
function getC2STunnelConfigPath() {
@@ -2040,40 +1850,25 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
// C2S (client-to-server) tunnels relay through a connected, self-hosted
// Termix server -- the same "remote server" concept Remote Sync connects
// to, not the always-local embedded backend. There's no separate C2S
// server-URL setting in the UI; it has always shared whatever remote
// server the rest of the app was pointed at. Before the standalone-first
// rework that was server-config.json; now it's remote-sync-config.json,
// since that's the only remaining notion of "a connected remote server."
function getC2SRelayUrl() {
const config = remoteSync.getRemoteSyncConfig();
const serverUrl = config?.serverUrl;
if (!serverUrl) {
throw new Error(
"No remote Termix server connected -- enable Remote Sync first",
);
}
const base = serverUrl.replace(/\/$/, "");
const relayHttpUrl = `${base}/plugin-ws/tunnels/c2s/stream`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
// C2S (client-to-server) tunnels relay through the server this desktop is
// linked to, not the embedded backend.
function getC2SRemoteBaseUrl() {
const config = remoteSync.getRemoteSyncConfig();
const serverUrl = config?.serverUrl;
const serverUrl = linkedServer.getLinkedServer()?.serverUrl;
if (!serverUrl) {
throw new Error(
"No remote Termix server connected -- enable Remote Sync first",
"This device is not linked to a Termix server. Link it in Sync first.",
);
}
return serverUrl.replace(/\/$/, "");
}
function getC2SRelayUrl() {
const relayHttpUrl = `${getC2SRemoteBaseUrl()}/plugin-ws/tunnels/c2s/stream`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
const C2S_REMOTE_SESSION_EXPIRED_ERROR =
"Remote Termix session expired. Reconnect Remote Sync and try again.";
"This device was signed out of its server. Sign in again from Sync and try again.";
function normalizeC2SAuthToken(authToken) {
return typeof authToken === "string" ? authToken.trim() : "";
@@ -2111,17 +1906,13 @@ function createC2SFailure(message, fallback) {
}
async function fetchC2SRemoteJson(pathname) {
const remoteSyncJwt = remoteSync.getRemoteSyncJwt();
if (!remoteSyncJwt || remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) {
if (!linkedServer.getLinkedServer()?.token) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
const url = `${getC2SRemoteBaseUrl()}${pathname}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${remoteSyncJwt}`,
"X-Electron-App": "true",
},
const response = await httpFetch(url, {
headers: linkedServer.linkedRequestHeaders(),
});
if (response.status === 401 || response.status === 403) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
@@ -2140,14 +1931,18 @@ async function resolveC2SRemoteSourceHost(tunnel) {
: "";
if (!sourceHostSyncId) return normalized;
const data = await fetchC2SRemoteJson("/sync/hosts");
const remoteHost = (Array.isArray(data?.rows) ? data.rows : []).find(
(row) => row?.syncId === sourceHostSyncId,
);
let remoteHost = null;
try {
remoteHost = await fetchC2SRemoteJson(
`/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`,
);
} catch (error) {
if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error;
}
const remoteHostId = Number(remoteHost?.id);
if (!Number.isInteger(remoteHostId) || remoteHostId < 1) {
throw new Error(
"Intermediate Host is not available on the remote Termix server yet. Run Remote Sync for hosts, then try again.",
"The intermediate host is not on the server yet. Sync, then try again.",
);
}
@@ -2161,18 +1956,11 @@ async function resolveC2SRemoteSourceHost(tunnel) {
}
async function getC2SRelayHeaders(relayUrl) {
if (linkedServer.getLinkedServer()?.token) {
return linkedServer.linkedRequestHeaders();
}
const headers = { "X-Electron-App": "true" };
const remoteSyncJwt = remoteSync.getRemoteSyncJwt();
if (remoteSyncJwt && !remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) {
headers.Authorization = `Bearer ${remoteSyncJwt}`;
return headers;
}
if (remoteSyncJwt) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
if (!mainWindow?.webContents?.session) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
@@ -3779,88 +3567,6 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
}
});
async function testServerConnection(
_event,
serverUrl,
allowInvalidCertificate = false,
) {
try {
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`;
// This is a best-effort reachability probe, not a hard gate: a reverse
// proxy doing SSO in front of the real server (Pangolin, Authelia,
// Cloudflare Access, etc.) intercepts this unauthenticated request
// before it ever reaches Termix's own /health route, and returns its
// own login page (HTML, or a redirect) instead of {"status":"ok"}.
// That's a legitimate, working setup -- the login iframe shown right
// after this check is what actually proves the server is real, by
// completing an authenticated round-trip. So any response at all here
// (any status code, any body) means "something is there, let the user
// proceed"; only a network-level failure (nothing answered at all)
// blocks continuing.
try {
const response = await httpFetch(healthUrl, {
method: "GET",
timeout: 10000,
allowInvalidCertificate,
});
const data = await response.text();
const looksLikeHtml =
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>");
if (response.ok && !looksLikeHtml) {
try {
const healthData = JSON.parse(data);
if (
healthData &&
(healthData.status === "ok" ||
healthData.status === "healthy" ||
healthData.healthy === true ||
healthData.database === "connected")
) {
return {
success: true,
status: response.status,
testedUrl: healthUrl,
};
}
} catch (parseError) {
console.log("Health endpoint did not return valid JSON");
}
}
// Reachable, but not a recognized Termix health response -- likely a
// proxy/SSO login page in front of the real server. Let the user
// proceed; the login step next will fail clearly if this really
// isn't a Termix server.
return {
success: true,
status: response.status,
testedUrl: healthUrl,
warning: looksLikeHtml
? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server."
: "Server responded, but not with the expected health check format. Continuing anyway.",
};
} catch (urlError) {
console.error("Health check failed:", urlError);
return {
success: false,
error:
"Server is not responding. Please ensure the server is running and accessible.",
};
}
} catch (error) {
return { success: false, error: error.message };
}
}
ipcMain.handle("test-server-connection", testServerConnection);
// Local disk browsing + streamed local<->remote transfers for the file
// manager's dual-pane mode (see electron/local-files.cjs).
registerLocalFileHandlers({ ipcMain, shell });
@@ -3953,7 +3659,7 @@ app.whenReady().then(async () => {
createTray();
createWindow();
remoteSync.initRemoteSync(() => mainWindow);
desktopSettings.removeOldSyncFiles();
logToFile("=== Startup complete ===");
});
+8 -19
View File
@@ -1,20 +1,9 @@
const { contextBridge, ipcRenderer } = require("electron");
const { contextBridge, ipcRenderer, webFrame } = require("electron");
const ALLOWED_INVOKE_CHANNELS = new Set([
"check-electron-update",
"clear-remote-sync-config",
"get-desktop-settings",
"get-legacy-server-config",
"get-remote-sync-config",
"get-remote-sync-jwt",
"get-remote-sync-status",
"get-remote-sync-user-info",
"notify-local-login",
"remote-sync-now",
"get-previous-server-url",
"save-desktop-settings",
"save-remote-sync-config",
"save-remote-sync-jwt",
"test-server-connection",
"allow-invalid-certificate-for-origin",
]);
@@ -27,6 +16,12 @@ function invokeAllowed(channel, ...args) {
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
const value = Number(factor);
if (!Number.isFinite(value)) return;
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
},
getPlatform: () => ipcRenderer.invoke("get-platform"),
getEmbeddedServerStatus: () =>
ipcRenderer.invoke("get-embedded-server-status"),
@@ -59,12 +54,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
onRemoteSyncStatusChanged: (callback) => {
const listener = (_event, status) => callback(status);
ipcRenderer.on("remote-sync-status-changed", listener);
return () =>
ipcRenderer.removeListener("remote-sync-status-changed", listener);
},
onCloseActiveTab: (callback) => {
const listener = () => callback();
ipcRenderer.on("close-active-tab", listener);
-23
View File
@@ -1,23 +0,0 @@
// The fallback list, for a server older than GET /sync/entity-types.
//
// Current servers return the entity types and their order, which is the only
// way this process learns about one a plugin added. This array is what those
// servers would have synced anyway, so an old pairing keeps working.
const SYNCED_ENTITY_TYPES = Object.freeze([
// Ordered by reference dependency: hosts and snippets resolve credential,
// vault and folder syncIds, so those have to exist on the other side first.
"sshCredentials",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
// networkTopology stores host references by their local numeric id inside
// the topology JSON, so it must sync after hosts have been reconciled.
"networkTopology",
]);
module.exports = { SYNCED_ENTITY_TYPES };
-27
View File
@@ -1,27 +0,0 @@
const { Agent, fetch: undiciFetch } = require("undici");
function createRemoteSyncFetch(getConfig) {
let invalidCertificateAgent;
return function fetchRemoteSync(url, options = {}) {
const config = getConfig();
const target = new URL(url);
const allowInvalidCertificate =
config?.allowInvalidCertificate === true &&
config.serverUrl &&
target.protocol === "https:" &&
target.origin === new URL(config.serverUrl).origin;
if (!allowInvalidCertificate) return fetch(url, options);
invalidCertificateAgent ??= new Agent({
connect: { rejectUnauthorized: false },
});
return undiciFetch(url, {
...options,
dispatcher: invalidCertificateAgent,
// A redirected request must not inherit the configured origin's TLS exception.
redirect: "error",
});
};
}
module.exports = { createRemoteSyncFetch };
-644
View File
@@ -1,644 +0,0 @@
// Remote sync engine for the desktop app's optional connection to a
// self-hosted Termix server. Runs entirely in the Electron main process:
// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to
// the renderer's localStorage) and the local embedded backend's JWT
// (cached in memory only, handed over by the renderer at local-login
// time via notify-local-login).
// - On a timer, pulls + pushes each synced entity type between the
// embedded backend (always localhost:30001) and the configured remote
// server, reconciling by syncId with last-write-wins on updatedAt, and
// propagating tombstones (deletions) in both directions.
// - Pushes connection/sync status to the renderer via IPC so the Settings
// UI and a global banner can reflect it without polling.
const { app, safeStorage } = require("electron");
const fs = require("fs");
const path = require("path");
const { orderSyncWrites } = require("./sync-write-order.cjs");
const { SYNCED_ENTITY_TYPES } = require("./remote-sync-entities.cjs");
const { createRemoteSyncFetch } = require("./remote-sync-fetch.cjs");
const fetchRemoteSync = createRemoteSyncFetch(getRemoteSyncConfig);
const SYNC_INTERVAL_MS = 90 * 1000;
const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
function dataPath(filename) {
return path.join(app.getPath("userData"), filename);
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
const userDataPath = app.getPath("userData");
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
mode: 0o600,
});
fs.renameSync(temporaryPath, filePath);
} catch (error) {
try {
fs.unlinkSync(temporaryPath);
} catch {
// already absent
}
throw error;
}
}
function getDesktopSettingsPath() {
return dataPath("desktop-settings.json");
}
function getRemoteSyncConfigPath() {
return dataPath("remote-sync-config.json");
}
function getRemoteSyncCredentialPath() {
return dataPath("remote-sync-credential.json");
}
function getRemoteSyncStatePath() {
return dataPath("remote-sync-state.json");
}
function getDesktopSettings() {
return readJson(getDesktopSettingsPath(), {
defaultConnectionOrigin: "local",
migrationNoticeAcknowledged: false,
});
}
function saveDesktopSettings(settings) {
writeJson(getDesktopSettingsPath(), settings);
return { success: true };
}
function getRemoteSyncConfig() {
return readJson(getRemoteSyncConfigPath(), null);
}
function saveRemoteSyncConfig(config) {
writeJson(getRemoteSyncConfigPath(), config);
return { success: true };
}
function clearRemoteSyncConfig() {
try {
fs.unlinkSync(getRemoteSyncConfigPath());
} catch {
// already absent
}
return { success: true };
}
function getSafeStorageAvailable() {
try {
return safeStorage.isEncryptionAvailable();
} catch {
return false;
}
}
function saveRemoteSyncJwt(token) {
if (!getSafeStorageAvailable()) {
// Carries a stable reason alongside the message: the renderer has a
// translated explanation for this one, because "no OS keyring" is a
// machine-level problem the user has to go and fix, not something signing
// in again can resolve.
return {
success: false,
reason: "encryption_unavailable",
error: "Encryption unavailable on this system",
};
}
writeJson(getRemoteSyncCredentialPath(), {
encrypted: true,
value: safeStorage.encryptString(token).toString("base64"),
obtainedAt: new Date().toISOString(),
});
return { success: true };
}
function getRemoteSyncJwt() {
const record = readJson(getRemoteSyncCredentialPath(), null);
if (!record?.encrypted || !getSafeStorageAvailable()) return null;
try {
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
} catch {
return null;
}
}
function clearRemoteSyncJwt() {
try {
fs.unlinkSync(getRemoteSyncCredentialPath());
} catch {
// already absent
}
return { success: true };
}
async function getRemoteSyncUserInfo() {
const config = getRemoteSyncConfig();
const token = getRemoteSyncJwt();
if (!config?.serverUrl || !token || isJwtExpiredOrExpiringSoon(token)) {
return null;
}
const baseUrl = config.serverUrl.replace(/\/$/, "");
const userResponse = await fetchRemoteSync(`${baseUrl}/users/me`, {
headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" },
});
if (!userResponse.ok) return null;
const user = await userResponse.json();
const rolesResponse = await fetchRemoteSync(
`${baseUrl}/rbac/users/${encodeURIComponent(user.userId)}/roles`,
{
headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" },
},
);
const roles = rolesResponse.ok
? (await rolesResponse.json()).roles || []
: [];
return {
userId: user.userId,
username: user.username,
is_admin: !!user.is_admin,
is_oidc: !!user.is_oidc,
is_dual_auth: !!user.is_dual_auth,
totp_enabled: !!user.totp_enabled,
roles,
};
}
function decodeJwtExpiry(token) {
try {
const payloadB64 = token.split(".")[1];
const payload = JSON.parse(
Buffer.from(payloadB64, "base64").toString("utf8"),
);
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
} catch {
return null;
}
}
function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) {
const expiresAt = decodeJwtExpiry(token);
if (expiresAt === null) return false;
return Date.now() + marginMs >= expiresAt;
}
class RemoteSyncEngine {
constructor(getMainWindow) {
this.getMainWindow = getMainWindow;
this.localJwt = null;
this.timer = null;
this.startupTimer = null;
this.syncing = false;
this.status = {
connected: false,
syncing: false,
lastSyncedAt: null,
lastError: null,
needsReauth: false,
};
}
setLocalJwt(token) {
this.localJwt = token || null;
}
emitStatus() {
const win = this.getMainWindow?.();
if (!win || win.isDestroyed()) return;
win.webContents.send("remote-sync-status-changed", this.status);
}
updateStatus(patch) {
this.status = { ...this.status, ...patch };
this.emitStatus();
}
start() {
const config = getRemoteSyncConfig();
this.status.connected = !!config?.serverUrl;
if (this.timer) clearInterval(this.timer);
if (this.startupTimer) clearTimeout(this.startupTimer);
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
if (config?.serverUrl) {
// Fire an initial sync shortly after startup rather than waiting a
// full interval, but don't block app boot on it.
this.startupTimer = setTimeout(() => {
this.startupTimer = null;
this.syncNow();
}, 5000);
}
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.startupTimer) {
clearTimeout(this.startupTimer);
this.startupTimer = null;
}
}
async syncNow() {
if (this.syncing) return this.status;
const config = getRemoteSyncConfig();
if (!config?.serverUrl) {
this.updateStatus({ connected: false, syncing: false });
return this.status;
}
const remoteJwt = getRemoteSyncJwt();
if (!remoteJwt) {
this.updateStatus({
connected: true,
syncing: false,
needsReauth: true,
lastError: "Not signed in to remote server",
});
return this.status;
}
if (isJwtExpiredOrExpiringSoon(remoteJwt)) {
this.updateStatus({
connected: true,
syncing: false,
needsReauth: true,
lastError: "Remote session expired",
});
return this.status;
}
if (!this.localJwt) {
// Local login hasn't handed us a token yet -- this is expected for the
// first tick or two right after a cold boot (renderer hasn't finished
// its own session check yet), but if it never arrives (e.g. a gap in
// whichever code path establishes the local session), sync would
// otherwise silently no-op forever with no visible error. Surface it
// as a normal, non-alarming "not synced yet" status rather than
// leaving lastSyncedAt/lastError untouched.
this.updateStatus({
connected: true,
syncing: false,
lastError: "Waiting for local session",
});
return this.status;
}
this.syncing = true;
this.updateStatus({ connected: true, syncing: true, lastError: null });
try {
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
let sawAuthFailure = false;
const remoteBase = config.serverUrl.replace(/\/$/, "");
const entityTypes = await this.resolveEntityTypes(remoteBase, remoteJwt);
for (const entityType of entityTypes) {
const entityState = state.entities[entityType] || {
lastPulledAt: null,
lastPushedAt: null,
};
const result = await this.syncEntity({
entityType,
remoteBaseUrl: remoteBase,
remoteJwt,
since: entityState.lastPulledAt,
});
if (result.authFailure) {
sawAuthFailure = true;
break;
}
state.entities[entityType] = {
lastPulledAt: result.syncedAt,
lastPushedAt: result.syncedAt,
};
}
if (sawAuthFailure) {
this.updateStatus({
syncing: false,
needsReauth: true,
lastError: "Remote server rejected the session",
});
return this.status;
}
writeJson(getRemoteSyncStatePath(), state);
writeJson(getRemoteSyncConfigPath(), {
...config,
lastSyncedAt: new Date().toISOString(),
lastSyncStatus: "ok",
lastSyncError: null,
});
this.updateStatus({
connected: true,
syncing: false,
needsReauth: false,
lastSyncedAt: new Date().toISOString(),
lastError: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeJson(getRemoteSyncConfigPath(), {
...config,
lastSyncStatus: "error",
lastSyncError: message,
});
this.updateStatus({ syncing: false, lastError: message });
} finally {
this.syncing = false;
}
return this.status;
}
/**
* Which entities to sync, and in what order.
*
* Asked of the remote server, because a plugin installed there can add one
* and this process has no way to know. Intersected with what the embedded
* backend supports: the two sides may have different plugins installed, and
* pushing an entity the local backend has never heard of just 400s.
*
* A server older than this endpoint 404s, and the frozen list is what it
* would have synced anyway.
*/
async resolveEntityTypes(remoteBaseUrl, remoteJwt) {
const fetchTypes = async (base, token) => {
try {
const body = await this.fetchJson(`${base}/sync/entity-types`, token);
const types = (body?.entityTypes || [])
.slice()
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((entity) => entity.type)
.filter((type) => typeof type === "string" && type);
return types.length > 0 ? types : null;
} catch {
return null;
}
};
const remote = await fetchTypes(remoteBaseUrl, remoteJwt);
if (!remote) return SYNCED_ENTITY_TYPES;
const local = await fetchTypes(EMBEDDED_BASE_URL, this.localJwt);
if (!local) return remote;
const supported = new Set(local);
const shared = remote.filter((type) => supported.has(type));
return shared.length > 0 ? shared : SYNCED_ENTITY_TYPES;
}
async fetchJson(url, token, options = {}) {
const res = await fetchRemoteSync(url, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(options.headers || {}),
},
});
if (res.status === 401 || res.status === 403) {
const err = new Error(`Auth failed (${res.status})`);
err.authFailure = true;
throw err;
}
if (!res.ok) {
throw new Error(`Request failed (${res.status}): ${url}`);
}
const text = await res.text();
// A reverse-proxy SSO in front of the remote server (Pangolin, Authelia,
// etc.) can intercept even an authenticated, Bearer-token'd request and
// serve its own login page instead of forwarding to Termix -- that comes
// back as a normal 200 OK, so the status checks above don't catch it.
// This is NOT the same as needsReauth/a bad Termix JWT: sync runs as a
// plain server-to-server fetch() in this main process, with no browser
// cookie jar at all, so re-authenticating through the login iframe (which
// only affects the renderer's browser session) can never fix this --
// reconnecting would tell the user to do something that doesn't help.
// The proxy has to allow this traffic through some other way (an API
// bypass rule, a separate hostname/port that isn't proxy-gated, etc.),
// so this gets its own distinct, honest error rather than piggybacking
// on needsReauth or a raw JSON.parse crash.
const looksLikeHtml =
text.includes("<html") ||
text.includes("<!DOCTYPE") ||
text.includes("<head>") ||
text.includes("<body>");
if (looksLikeHtml) {
const err = new Error(
"The reverse proxy in front of this server is blocking sync traffic with its own login page. Reconnecting won't fix this. The proxy needs to let Termix's API requests through (e.g. an SSO bypass rule for the sync API, or a non-proxied hostname/port for it).",
);
err.proxyBlocked = true;
throw err;
}
try {
return JSON.parse(text);
} catch {
throw new Error(`Server returned invalid JSON: ${url}`);
}
}
async pullSide(baseUrl, token, entityType, since) {
const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`;
const data = await this.fetchJson(url, token);
return data.rows || [];
}
/**
* Every syncId a side currently holds, ignoring the incremental window.
* Used only to decide whether a deletion still has something to delete.
*/
async pullSyncIds(baseUrl, token, entityType) {
const rows = await this.pullSide(baseUrl, token, entityType, null);
return new Set(rows.filter((row) => row.syncId).map((row) => row.syncId));
}
async pullTombstones(baseUrl, token, entityType, since) {
const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`;
const data = await this.fetchJson(url, token);
return data.tombstones || [];
}
async pushRow(baseUrl, token, entityType, row) {
await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, {
method: "POST",
body: JSON.stringify({ row }),
});
}
async pushTombstone(baseUrl, token, entityType, syncId) {
await this.fetchJson(`${baseUrl}/sync/tombstones`, token, {
method: "POST",
body: JSON.stringify({ entityType, syncId }),
});
}
async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) {
const syncedAt = new Date().toISOString();
try {
const [localRows, remoteRows, localTombstones, remoteTombstones] =
await Promise.all([
this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since),
this.pullSide(remoteBaseUrl, remoteJwt, entityType, since),
this.pullTombstones(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
since,
),
this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since),
]);
const tombstonedSyncIds = new Set([
...localTombstones.map((t) => t.syncId),
...remoteTombstones.map((t) => t.syncId),
]);
const localBySyncId = new Map(
localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
);
const remoteBySyncId = new Map(
remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
);
const allSyncIds = new Set([
...localBySyncId.keys(),
...remoteBySyncId.keys(),
]);
const writes = [];
for (const syncId of allSyncIds) {
if (tombstonedSyncIds.has(syncId)) continue;
const localRow = localBySyncId.get(syncId);
const remoteRow = remoteBySyncId.get(syncId);
const localTime = new Date(localRow?.updatedAt || 0).getTime();
const remoteTime = new Date(remoteRow?.updatedAt || 0).getTime();
if (localRow && (!remoteRow || localTime > remoteTime)) {
writes.push({
baseUrl: remoteBaseUrl,
token: remoteJwt,
row: localRow,
});
} else if (remoteRow && (!localRow || remoteTime > localTime)) {
writes.push({
baseUrl: EMBEDDED_BASE_URL,
token: this.localJwt,
row: remoteRow,
});
}
}
for (const { baseUrl, token, row } of orderSyncWrites(
entityType,
writes,
)) {
await this.pushRow(baseUrl, token, entityType, row);
}
// Apply tombstones to whichever side hasn't already deleted the row.
//
// The presence check cannot use localRows/remoteRows: those are the
// incremental window, and a row deleted on one side while untouched on
// the other is by definition outside it, so every deletion was dropped.
// It also cannot be skipped -- pushing unconditionally makes the
// receiving side record a fresh tombstone, which the next pass would push
// back, forever. So ask the receiving side what it actually still holds,
// and only when there is a deletion to apply.
if (localTombstones.length) {
const remoteSyncIds = await this.pullSyncIds(
remoteBaseUrl,
remoteJwt,
entityType,
);
for (const tombstone of localTombstones) {
if (remoteSyncIds.has(tombstone.syncId)) {
await this.pushTombstone(
remoteBaseUrl,
remoteJwt,
entityType,
tombstone.syncId,
);
}
}
}
if (remoteTombstones.length) {
const localSyncIds = await this.pullSyncIds(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
);
for (const tombstone of remoteTombstones) {
if (localSyncIds.has(tombstone.syncId)) {
await this.pushTombstone(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
tombstone.syncId,
);
}
}
}
return { syncedAt };
} catch (error) {
if (error?.authFailure) {
return { syncedAt, authFailure: true };
}
throw error;
}
}
}
let engine = null;
function initRemoteSync(getMainWindow) {
engine = new RemoteSyncEngine(getMainWindow);
engine.start();
return engine;
}
function getRemoteSyncEngine() {
return engine;
}
module.exports = {
initRemoteSync,
getRemoteSyncEngine,
getDesktopSettings,
saveDesktopSettings,
getRemoteSyncConfig,
saveRemoteSyncConfig,
clearRemoteSyncConfig,
saveRemoteSyncJwt,
getRemoteSyncJwt,
clearRemoteSyncJwt,
getRemoteSyncUserInfo,
isJwtExpiredOrExpiringSoon,
decodeJwtExpiry,
};
-49
View File
@@ -1,49 +0,0 @@
// Sort writes per destination: response order is lost when both sides are merged.
//
// Keyed on the self-reference field the rows actually carry rather than on the
// entity name, so an entity a plugin adds gets the same treatment without this
// process needing the server's entity registry.
const SELF_REFERENCE_FIELDS = { hosts: "parentHostSyncId" };
function selfReferenceField(entityType, writes) {
const known = SELF_REFERENCE_FIELDS[entityType];
if (known) return known;
// A plugin entity declares its own; detect it from the payload.
const sample = writes.find((write) => write && write.row);
if (!sample) return null;
const candidate = Object.keys(sample.row).find(
(key) => key === `parent${entityType}SyncId`,
);
return candidate || null;
}
function orderSyncWrites(entityType, writes) {
const field = selfReferenceField(entityType, writes);
if (!field) return writes;
const destinations = new Map();
for (const write of writes) {
if (!destinations.has(write.baseUrl)) {
destinations.set(write.baseUrl, new Map());
}
destinations.get(write.baseUrl).set(write.row.syncId, write);
}
const ordered = [];
const visited = new Set();
const visiting = new Set();
const visit = (write) => {
if (visited.has(write)) return;
if (visiting.has(write))
throw new Error("Cyclic parent host sync dependency");
visiting.add(write);
const parent = destinations.get(write.baseUrl).get(write.row[field]);
if (parent) visit(parent);
visiting.delete(write);
visited.add(write);
ordered.push(write);
};
writes.forEach(visit);
return ordered;
}
module.exports = { orderSyncWrites };
+11
View File
@@ -129,6 +129,7 @@
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"lucide-react": "^1.33.0",
"postcss-pxtorem": "^6.1.0",
"prettier": "3.9.8",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
@@ -15420,6 +15421,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-pxtorem": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/postcss-pxtorem/-/postcss-pxtorem-6.1.0.tgz",
"integrity": "sha512-ROODSNci9ADal3zUcPHOF/K83TiCgNSPXQFSbwyPHNV8ioHIE4SaC+FPOufd8jsr5jV2uIz29v1Uqy1c4ov42g==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"postcss": "^8.0.0"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+1
View File
@@ -171,6 +171,7 @@
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"lucide-react": "^1.33.0",
"postcss-pxtorem": "^6.1.0",
"prettier": "3.9.8",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
+77 -29
View File
@@ -195,7 +195,7 @@ regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations.
**What stays in core.** After 2.9.0, `schema.ts`, the SQLite DDL in
`db/index.ts`, `performance-indexes.ts`, the repository factory and the
drizzle migrations hold only these 36 tables. Anything else is a plugin's,
drizzle migrations hold only these 38 tables. Anything else is a plugin's,
under `p_<id>_`.
| Area | Tables |
@@ -206,7 +206,7 @@ under `p_<id>_`.
| Credentials | `ssh_credentials`, `ssh_credential_usage` |
| Sharing and RBAC | `roles`, `user_roles`, `host_access`, `folder_access`, `credential_access`, `shared_host_secrets`, `shared_host_auth_overrides`, `shared_credential_secrets`, `rbac_known_permissions`, `rbac_applied_defaults` |
| Audit | `audit_logs` |
| Sync | `sync_tombstones` |
| Sync | `sync_records`, `sync_conflicts`, `sync_link` |
| Plugin runtime | `plugins`, `plugin_permission_grants`, `plugin_registries`, `plugin_install_counts`, `plugin_storage`, `plugin_settings`, `plugin_migrations` |
A few core tables still carry columns a plugin reads through its own host or
@@ -222,24 +222,72 @@ they are sealed with this server's keys. An import reads each table by its
`p_` name, or by the legacy name it adopted for a file written before 2.9.
**Sync entities are registered, not hardcoded.** `ctx.sync.registerEntity`
adds an entity to remote sync; core registers its own the same way in
`database/routes/sync-entities.ts`. The wire names are unchanged, because
existing tombstones and rows on the far side match on those strings. The
Electron client asks the server for the ordered list and falls back to its
frozen array for a server that predates the endpoint.
adds an entity to sync between a linked desktop and its server; core
registers its own the same way in `src/backend/sync/entities.ts`. The wire
names are part of the protocol: records on both sides match on them, so never
rename one.
`shouldSync(row)` leaves rows out in both directions: they are not pulled, and
a push, update or tombstone aimed at one is refused with 400. It exists for
state that belongs to one install, such as the workspaces plugin's "Last
Session" row, which would otherwise leave a user with one from each side.
**How sync works.** A desktop links to one server account (Sync in the rail)
and keeps an offline copy of that account's data. Nothing records a change as
it happens. On both sides a reconciler hashes every registered row the user
owns and compares it with `sync_records`:
**Deletes need a tombstone.** Registering an entity does not make a delete
propagate on its own: a pull reads `sync_tombstones` for the entity's wire
name, so a plugin route that deletes a synced row calls
`ctx.sync.recordTombstone(userId, entityType, syncId)` right after, the way
core's own delete routes call `SyncTombstoneRepository.record`. **B2** added
this method to the SDK; the snippets plugin's folder and snippet deletes are
its first callers.
- On a server, a new, changed or missing row bumps the record's `revision`
and gives it the next `seq`. `GET /sync/v2/changes?cursor=` pages through
records after a cursor, so a desktop never compares timestamps from two
clocks.
- On a desktop, a row whose hash differs from the one recorded at the last
sync is a local edit to push. `POST /sync/v2/push` carries the revision the
desktop last saw; if the server moved on, the op is a conflict, the
server's version wins and the local one is kept in `sync_conflicts` for the
user to restore.
- Every op gets its own result, so one refused row never holds up the rest.
A refused row is not sent again until it changes.
- `GET /sync/v2/events` is a server-sent event stream that tells a linked
desktop to pull now.
Because deletes are found by reconciliation, a plugin needs nothing beyond
the registration: a registered row that is gone is a delete. There is no
tombstone table any more.
**What a registration says.**
- `references` translate local ids to syncIds on the way out and back. A
`field` is a column (`credentialId` with `syncField: "credentialSyncId"`)
or a path into a JSON column (`jumpHosts[].hostId`,
`layout.panes[].hostId`); `idType: "string"` keeps an id stored as a
string. A syncId with no row on the receiving side keeps the value the
column already has, and a row written before its target is written again
once the target exists.
- `encryptedFields` are decrypted with the owner's data key for the wire and
encrypted again on arrival. A field that cannot be decrypted keeps the row
out of the feed; it is never sent empty.
- `readOnlyFields` stay on their device: never sent, never overwritten, not
part of the hash.
- `shouldSync(row)` leaves rows out in both directions, for state that
belongs to one install, such as the workspaces plugin's "Last Session" row.
- `permissions` (`create`, `update`, `delete`) are checked on the server
before a pushed change is written.
- `serialize` and `deserialize` run after `references`, for anything they
cannot express. `afterWrite` runs after a synced row is written and
`remove` replaces the plain delete, for rows with side effects of their
own.
- `answersTo` names a plugin entity by a name core uses without knowing the
plugin: core's host quick actions point at `commandSnippet`, which the
snippets plugin's `snippets` entity answers to.
The manifest lists the wire names under `contributes.syncEntities`, so a
server can name them while the plugin is off and a desktop can list them in
its sync settings.
**Plugins on a linked desktop.** The manifest's `desktop` field says how a
linked desktop treats a plugin. `mirror` (the default) follows the server: a
plugin installed there is downloaded from `/sync/v2/plugins/<id>/package`,
verified like any `.tmxplug`, installed, and switched on or off with the
server, with the capabilities an admin granted it there. `local` is decided
on each desktop (serial ports), and `server` never runs on a linked desktop
(ACME, sign-in methods, automations). While linked, the admin toggle for a
mirrored plugin answers 409.
### 6. HTTP and WebSockets
@@ -445,7 +493,7 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
secret field.
- Host fields take `enableDefault` (on the host section), `defaultFrom`,
`shareRead` and `ownerOnly`; see the manifest reference. A host settings
save also bumps the host's `updated_at`, so remote sync carries it.
save also changes the host's sync hash, so sync carries it.
### 8. UI
@@ -573,7 +621,7 @@ boundary and Suspense.
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) |
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which server it is linked to (**C6**) |
| `registerComponent` | A component other plugins and core render by id with `PluginComponent` |
| `onSettingsChanged` | Called when one of the plugin's settings changes |
@@ -2166,7 +2214,7 @@ authority for signatures.
| `kv.get/set/delete/list` | `kv:own` | Small key/value state |
| `files.dataDir()` | `files:own` | A per-plugin folder, `<DATA_DIR>/plugin-data/<id>` |
| `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk |
| `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it |
| `sync.registerEntity` | none | Adds a table to sync between a linked desktop and its server |
| `registry.provide/consume/revoke` | none | The low-level named registry services sit on |
| `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call |
| `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values |
@@ -3058,7 +3106,7 @@ upgrade on all three engines, and the checks in step 15 are green.
(`TOPICS.userDeleted` in `src/backend/plugins/events.ts`), emitted from
`deleteUserAndRelatedData` right where the old direct repository call
used to sit. `host.deleted` already existed (B7).
- `sync-entities.ts`, if core synced it (step 9).
- `src/backend/sync/entities.ts`, if core synced it (step 9).
- Anything else `grep -rn "<table_name>\|<tableConst>" src` finds, including
core tests and `src/types`.
4. `npm run schema:generate` (the pg and mysql schema variants), then
@@ -3131,13 +3179,13 @@ is not ready to drop.
### 9. Sync entities
If core synced the data, or it is personal configuration that should follow a
user between the desktop app and a server, register it in `activate`:
`ctx.sync.registerEntity({ type, table, order, ... })`. Keep an existing wire
name exactly and remove core's registration from `sync-entities.ts`. Leave
`electron/remote-sync-entities.cjs` alone: it is the fallback for servers
older than the entity-types endpoint, which still sync the core entity. Give
an entity that references others a higher `order` than they have. Use `shouldSync` for rows that belong to one
install (workspaces leaves out its `last_session` row).
user between a linked desktop and its server, register it in `activate`:
`ctx.sync.registerEntity({ type, table, order, ... })` and list the wire name
under `contributes.syncEntities`. Keep an existing wire name exactly and
remove core's registration from `src/backend/sync/entities.ts`. Give an
entity that references others a higher `order` than they have. Use
`shouldSync` for rows that belong to one install (workspaces leaves out its
`last_session` row). Deletes need no extra call.
### 10. Cross-plugin and core callers
+46 -1
View File
@@ -17,6 +17,37 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.js",
];
/**
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
* other host-provided package) cannot reach it: the package is external, and
* esbuild's ESM output has no require, so the plugin throws "Dynamic require
* is not supported" as soon as it loads. Each such require is pointed at a
* small ES module that imports the external and re-exports it, which esbuild
* can hand to CommonJS code.
*/
export function externalRequireInterop(externals) {
const names = new Set(externals.filter((name) => !name.includes("*")));
return {
name: "termix-external-require",
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
if (args.kind !== "require-call" || !names.has(args.path)) return;
return { path: args.path, namespace: "termix-external-require" };
});
build.onLoad(
{ filter: /.*/, namespace: "termix-external-require" },
(args) => {
const id = JSON.stringify(args.path);
return {
contents: `import * as mod from ${id};\nexport * from ${id};\nexport default mod.default ?? mod;\n`,
loader: "js",
};
},
);
},
};
}
export async function build({ cwd }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
@@ -74,7 +105,21 @@ export async function build({ cwd }) {
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
plugins: [staticUrlImports({ outDir })],
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
}
@@ -279,6 +279,16 @@
"guest": {
"type": "boolean"
},
"syncEntities": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"description": "Wire names of the sync entities this plugin registers.",
"items": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
}
},
"guestViews": {
"type": "array",
"minItems": 1,
@@ -955,6 +965,11 @@
"enum": ["linux", "win32", "darwin"]
}
},
"desktop": {
"type": "string",
"enum": ["mirror", "local", "server"],
"description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror."
},
"nativeDependencies": {
"type": "array",
"minItems": 1,
+60 -20
View File
@@ -132,64 +132,104 @@ export interface PluginDatabase {
export type SyncRow = Record<string, unknown>;
export interface SyncEntityReference {
/** Stored column holding a local numeric id, e.g. "credentialId". */
/**
* Where a local numeric id lives. A plain column ("credentialId"), or a
* path into a JSON column: "jumpHosts[].hostId" walks every item of the
* jumpHosts array, "layout.panes[].hostId" walks nested objects. A JSON
* column may be stored as a string or as parsed JSON.
*/
field: string;
/** Wire field holding the portable id, e.g. "credentialSyncId". */
syncField: string;
/**
* Wire field holding the portable id, e.g. "credentialSyncId". Only for a
* plain column; a JSON path is rewritten in place.
*/
syncField?: string;
/** The entity the id points at. */
entityType: string;
/** How a JSON path stores the local id. Defaults to "number". */
idType?: "number" | "string";
}
export interface SyncEntityPermissions {
/** Checked before a pushed row that does not exist yet is written. */
create?: string;
/** Checked before a pushed change to an existing row is written. */
update?: string;
/** Checked before a pushed delete runs. */
delete?: string;
}
export interface SyncWriteEvent {
/** The local id of the row that was written. */
id: number | null;
userId: string;
/** The row as it arrived, before references were resolved. */
wire: SyncRow;
created: boolean;
}
export interface SyncEntityRegistration {
/** Stable wire name. Never change it: tombstones and remote rows match on it. */
/** Stable wire name. Never change it: records on both sides match on it. */
type: string;
/** The table, as returned by ctx.db.define. */
table: unknown;
/** Column holding the owning user id. Defaults to "userId". */
userColumn?: string;
/** Fields DataCrypto translates between plaintext wire and encrypted row. */
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
encryptedFields?: readonly string[];
/** Local-id to sync-id translations applied on the way out and back. */
references?: readonly SyncEntityReference[];
/** Lower sorts first. Reference targets must sort before their referrers. */
order?: number;
/** Fields that must never be overwritten by an inbound payload. */
/**
* Fields that stay on this side: never sent, never overwritten by an
* inbound row, and ignored when deciding whether a row changed.
*/
readOnlyFields?: readonly string[];
/** One row per user rather than many, keyed on the owner. */
singleton?: boolean;
/**
* Names core uses when its own rows point at this entity without knowing
* which plugin provides it, e.g. "commandSnippet" for a host quick action.
*/
answersTo?: readonly string[];
/**
* Rows this returns false for are left out of sync in both directions, for
* state that belongs to one install (a per-device "last session").
*/
shouldSync?: (row: SyncRow) => boolean;
/** RBAC permissions a pushed change needs on the server. */
permissions?: SyncEntityPermissions;
/**
* Escape hatch for a row whose references are not plain columns, such as ids
* embedded in a JSON blob. Runs instead of `references`, not alongside it.
* Runs after `references` on the way out, for anything they cannot
* express. Gets the row with references already translated.
*/
serialize?: (
row: SyncRow,
resolveSyncId: (entityType: string, id: number) => Promise<string | null>,
) => Promise<SyncRow>;
/** Runs after `references` on the way in. */
deserialize?: (
row: SyncRow,
resolveId: (entityType: string, syncId: string) => Promise<number | null>,
) => Promise<SyncRow>;
/** Runs after a synced row was written on this side. */
afterWrite?: (event: SyncWriteEvent) => Promise<void>;
/**
* Replaces the plain delete of a synced row, for rows with cleanup of their
* own. Gets the stored row.
*/
remove?: (row: SyncRow, userId: string) => Promise<void>;
}
/** Adds an entity to remote sync between a desktop backend and a server. */
/**
* Adds an entity to sync between a linked desktop and its server.
*
* Deletes need nothing extra: core notices a registered row is gone and
* sends the delete itself.
*/
export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void;
/**
* Records that a row was deleted, so a pull on the other side of remote
* sync removes it too. Call it with the row's syncId right after deleting
* it, for a registered entity whose rows can be deleted (a no-op silently
* loses deletes across devices otherwise). A falsy syncId is ignored.
*/
recordTombstone: (
userId: string,
entityType: string,
syncId: string | null | undefined,
) => Promise<void>;
}
export interface PluginRegistry {
+51
View File
@@ -337,8 +337,27 @@ export interface PluginContributions {
"simple" | "balanced" | "advanced",
Record<string, unknown>
>;
/**
* Wire names of the sync entities this plugin registers with
* ctx.sync.registerEntity, so a server can name them while the plugin is
* off and a desktop can list them in its sync settings.
*/
syncEntities?: string[];
}
/**
* How a desktop linked to a server treats this plugin.
* - "mirror": follows the server, installed and switched on or off with it.
* - "local": each desktop decides for itself (a serial port is local).
* - "server": only makes sense on a server, never runs on a linked desktop.
*/
export type PluginDesktopMode = "mirror" | "local" | "server";
export const PLUGIN_DESKTOP_MODES: readonly PluginDesktopMode[] = [
"mirror",
"local",
"server",
];
export interface PluginHttpContribution {
/**
* Old URLs something outside Termix still calls (a webhook a third party
@@ -405,6 +424,8 @@ export interface PluginManifest {
* dependencies".
*/
nativeDependencies?: string[];
/** How a linked desktop treats this plugin. Defaults to "mirror". */
desktop?: PluginDesktopMode;
}
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
@@ -439,6 +460,7 @@ const ALLOWED_TOP_LEVEL = new Set([
"locales",
"platforms",
"nativeDependencies",
"desktop",
]);
const ALLOWED_CONTRIBUTES = new Set([
@@ -455,6 +477,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"http",
"uiPresets",
"auth",
"syncEntities",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -587,6 +610,14 @@ export function validateManifest(manifest: unknown): string[] {
validateCapabilities(m.capabilities, errors);
validatePlatforms(m.platforms, errors);
validateNativeDependencies(m.nativeDependencies, errors);
if (
m.desktop !== undefined &&
!PLUGIN_DESKTOP_MODES.includes(m.desktop as PluginDesktopMode)
) {
errors.push(
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
);
}
validateDependencyMap(m.dependencies, "dependencies", errors);
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
validateProvides(m.provides, errors);
@@ -761,6 +792,23 @@ function validateProvides(provides: unknown, errors: string[]): void {
const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
function validateSyncEntities(value: unknown, errors: string[]) {
const where = "contributes.syncEntities";
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
return;
}
const seen = new Set<string>();
for (const name of value) {
if (typeof name !== "string" || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
errors.push(`${where} entries must be letters and digits`);
continue;
}
if (seen.has(name)) errors.push(`${where} duplicates "${name}"`);
seen.add(name);
}
}
function validateNameList(value: unknown, where: string, errors: string[]) {
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
@@ -888,6 +936,9 @@ function validateContributes(
errors.push('Field "contributes.guestViews" needs "contributes.guest"');
}
}
if (contributes.syncEntities !== undefined) {
validateSyncEntities(contributes.syncEntities, errors);
}
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
-12
View File
@@ -260,8 +260,6 @@ export interface FakePluginContext {
tables: PluginTableDefinition[];
/** Sync entities registered through ctx.sync.registerEntity, in order. */
syncEntities: SyncEntityRegistration[];
/** Tombstones recorded through ctx.sync.recordTombstone, in order. */
tombstones: Array<{ userId: string; entityType: string; syncId: string }>;
/**
* WebSocket routes registered through ctx.ws, in order, with the handler
* and options, so a test can hand a route a fake socket.
@@ -412,11 +410,6 @@ export function createFakeContext(
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
const tombstones: Array<{
userId: string;
entityType: string;
syncId: string;
}> = [];
const wsRoutes: FakeWsRoute[] = [];
const httpRouters: Array<PluginRouterOptions | undefined> = [];
const listeners = new Map<string, Set<(payload: unknown) => void>>();
@@ -626,10 +619,6 @@ export function createFakeContext(
registerEntity: (entity) => {
syncEntities.push(entity);
},
recordTombstone: async (userId, entityType, syncId) => {
if (!syncId) return;
tombstones.push({ userId, entityType, syncId });
},
},
registry: {
@@ -1241,7 +1230,6 @@ export function createFakeContext(
kv,
tables,
syncEntities,
tombstones,
wsRoutes,
httpRouters,
settings,
+2 -1
View File
@@ -117,5 +117,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+2 -1
View File
@@ -125,5 +125,6 @@
"platforms": ["linux", "win32", "darwin"],
"dependencies": {
"snippets": "^1.0.0"
}
},
"desktop": "server"
}
@@ -44,7 +44,7 @@ type ConnectErrorResponse = {
};
/**
* The interactive-auth branches /ssh/connect can take before a session exists.
* The interactive-auth branches /connect can take before a session exists.
* Callers switch on these, so they cannot stay behind Record<string, unknown>.
*/
export interface SSHConnectResult {
@@ -106,7 +106,7 @@ export async function connectSSH(
): Promise<SSHConnectResult> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect",
"/connect",
{ sessionId, ...config },
{ timeout: 120000 },
);
@@ -156,7 +156,7 @@ export async function disconnectSSH(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/disconnect",
"/disconnect",
{ sessionId },
);
return response.data;
@@ -173,7 +173,7 @@ export async function verifySSHTOTP(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect-totp",
"/connect-totp",
{ sessionId, totpCode },
);
return response.data;
@@ -187,7 +187,7 @@ export async function verifySSHBrowserSignIn(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect-browser-sign-in",
"/connect-browser-sign-in",
{ sessionId },
);
return response.data;
@@ -201,7 +201,7 @@ export async function getSSHStatus(
): Promise<{ connected: boolean }> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/status",
"/status",
{ params: { sessionId } },
);
return response.data;
@@ -215,7 +215,7 @@ export async function keepSSHAlive(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/keepalive",
"/keepalive",
{ sessionId },
);
return response.data;
@@ -235,7 +235,7 @@ export async function listSSHFiles(
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/listFiles",
"/listFiles",
{ params: { sessionId, path } },
);
return response.data || { files: [], path };
@@ -253,7 +253,7 @@ export async function identifySSHSymlink(
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/identifySymlink",
"/identifySymlink",
{ params: { sessionId, path } },
);
return response.data;
@@ -268,7 +268,7 @@ export async function resolveSSHPath(
): Promise<string> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/resolvePath",
"/resolvePath",
{ params: { sessionId, path } },
);
return response.data?.resolvedPath || path;
@@ -288,7 +288,7 @@ export async function readSSHFile(
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/readFile",
"/readFile",
{ params: { sessionId, path } },
);
return response.data;
@@ -326,7 +326,7 @@ export async function writeSSHFile(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/writeFile",
"/writeFile",
{ sessionId, path, content, hostId, userId },
);
@@ -385,7 +385,7 @@ export async function uploadSSHFile(
const chunkBlob = file.slice(start, end);
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/uploadFileChunk",
"/uploadFileChunk",
chunkBlob,
{
params: {
@@ -429,7 +429,7 @@ export async function uploadSSHFile(
form.append("file", file, fileName);
const response = await getFileManagerApiForSession(sessionId).postForm(
"/ssh/uploadFileStream",
"/uploadFileStream",
form,
{
timeout: 0,
@@ -470,7 +470,7 @@ export async function downloadSSHFile(
): Promise<DownloadedSSHFile> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFile",
"/downloadFile",
{
sessionId,
path: filePath,
@@ -496,7 +496,7 @@ export async function downloadSSHFileStream(
onProgress?: (event: DownloadProgressEvent) => void,
): Promise<void> {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFileStream",
"/downloadFileStream",
{ sessionId, path: filePath },
{
responseType: "blob",
@@ -521,7 +521,7 @@ export async function createSSHFile(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/createFile",
"/createFile",
{ sessionId, path, fileName, content, hostId, userId },
);
return response.data;
@@ -539,7 +539,7 @@ export async function createSSHFolder(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/createFolder",
"/createFolder",
{ sessionId, path, folderName, hostId, userId },
);
return response.data;
@@ -558,7 +558,7 @@ export async function deleteSSHItem(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).delete(
"/ssh/deleteItem",
"/deleteItem",
{
data: {
sessionId,
@@ -599,7 +599,7 @@ export async function getSSHTrash(sessionId: string): Promise<{
}> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/ssh/trash",
"/trash",
{ params: { sessionId } },
);
return response.data;
@@ -611,7 +611,7 @@ export async function getSSHTrash(sessionId: string): Promise<{
export async function restoreSSHTrashItem(sessionId: string, id: string) {
try {
const response = await getFileManagerApiForSession(sessionId).post(
`/ssh/trash/${encodeURIComponent(id)}/restore`,
`/trash/${encodeURIComponent(id)}/restore`,
{ sessionId },
);
return response.data;
@@ -626,7 +626,7 @@ export async function permanentlyDeleteSSHTrashItem(
) {
try {
await getFileManagerApiForSession(sessionId).delete(
`/ssh/trash/${encodeURIComponent(id)}`,
`/trash/${encodeURIComponent(id)}`,
{ data: { sessionId } },
);
} catch (error) {
@@ -636,7 +636,7 @@ export async function permanentlyDeleteSSHTrashItem(
export async function emptySSHTrash(sessionId: string) {
try {
await getFileManagerApiForSession(sessionId).delete("/ssh/trash", {
await getFileManagerApiForSession(sessionId).delete("/trash", {
data: { sessionId },
});
} catch (error) {
@@ -649,7 +649,7 @@ export async function updateSSHTrashRetention(
retentionDays: number,
) {
try {
await getFileManagerApiForSession(sessionId).put("/ssh/trash-retention", {
await getFileManagerApiForSession(sessionId).put("/trash-retention", {
retentionDays,
});
} catch (error) {
@@ -687,7 +687,7 @@ export async function copySSHItem(
): Promise<CopySSHItemResult> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/copyItem",
"/copyItem",
{
sessionId,
sourcePath,
@@ -715,7 +715,7 @@ export async function renameSSHItem(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).put(
"/ssh/renameItem",
"/renameItem",
{ sessionId, oldPath, newName, hostId, userId },
);
invalidateCachedFileContent(sessionId, oldPath);
@@ -738,7 +738,7 @@ export async function moveSSHItem(
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).put(
"/ssh/moveItem",
"/moveItem",
{
sessionId,
oldPath,
@@ -777,7 +777,7 @@ export async function changeSSHPermissions(
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/changePermissions",
"/changePermissions",
{ sessionId, path, permissions, hostId, userId },
);
@@ -819,7 +819,7 @@ export async function extractSSHArchive(
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/extractArchive",
"/extractArchive",
{ sessionId, archivePath, extractPath, hostId, userId },
);
@@ -863,7 +863,7 @@ export async function compressSSHFiles(
});
const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/compressFiles",
"/compressFiles",
{
sessionId,
paths,
@@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { pluginDir } from "./helpers";
function read(relative: string): string {
return fs.readFileSync(path.join(pluginDir, relative), "utf8");
}
/** Every "/path" the frontend's SSH client sends, without its parameters. */
function frontendPaths(): string[] {
const source = read("src/frontend/api/ssh-file-operations-api.ts");
const paths = new Set<string>();
for (const match of source.matchAll(/["`](\/[A-Za-z][\w-]*(?:\/[\w-]+)*)/g)) {
paths.add(match[1]);
}
return [...paths];
}
/** Every route the backend's express app registers. */
function backendPaths(): Set<string> {
const paths = new Set<string>();
const dir = path.join(pluginDir, "src/backend");
for (const file of fs
.readdirSync(dir)
.filter((name) => name.endsWith(".ts"))
.map((name) => `src/backend/${name}`)) {
for (const match of read(file).matchAll(
/app\.(?:get|post|put|delete|patch)\(\s*"(\/[^"]*)"/g,
)) {
paths.add(match[1].replace(/\/:[\w]+$/, ""));
}
}
return paths;
}
describe("file manager routes", () => {
it("serves every path the frontend's SSH client calls", () => {
const served = backendPaths();
const missing = frontendPaths().filter((p) => !served.has(p));
expect(missing).toEqual([]);
});
});
@@ -50,7 +50,7 @@ describe("chunked SSH file uploads", () => {
expect(fileManagerApiMock.post).toHaveBeenCalledTimes(193);
expect(fileManagerApiMock.post).toHaveBeenNthCalledWith(
1,
"/ssh/uploadFileChunk",
"/uploadFileChunk",
expect.any(Blob),
{
params: {
@@ -65,7 +65,7 @@ describe("chunked SSH file uploads", () => {
},
);
expect(fileManagerApiMock.post).toHaveBeenLastCalledWith(
"/ssh/uploadFileChunk",
"/uploadFileChunk",
expect.any(Blob),
expect.objectContaining({
params: expect.objectContaining({ offset: 1.5 * 1024 * 1024 * 1024 }),
+2 -1
View File
@@ -47,6 +47,7 @@
],
"actionSlots": [
{ "id": "dashboard.secondaryView", "accepts": ["component"] }
]
],
"syncEntities": ["dashboardServiceLinks", "homepageItems"]
}
}
+13 -1
View File
@@ -26,7 +26,19 @@ export async function activate(ctx: PluginContext) {
registerHomepageRoutes(router, repo, ctx);
registerOutboundRoutes(router, ctx);
ctx.sync.registerEntity({ type: "homepageItems", table: items, order: 80 });
ctx.sync.registerEntity({
type: "homepageItems",
table: items,
order: 80,
// A tile inside a folder points at the folder's row.
references: [
{
field: "folderId",
syncField: "folderSyncId",
entityType: "homepageItems",
},
],
});
ctx.sync.registerEntity({
type: "dashboardServiceLinks",
table: serviceLinks,
-6
View File
@@ -219,7 +219,6 @@ export function registerHomepageRoutes(
try {
const deleted = await repo.deleteItem(userId, id);
if (!deleted) return res.status(404).json({ error: "Not found" });
await ctx.sync.recordTombstone(userId, "homepageItems", deleted.syncId);
res.json({ message: "Homepage item deleted" });
} catch (err) {
logError(
@@ -506,11 +505,6 @@ export function registerHomepageRoutes(
try {
const deleted = await repo.deleteServiceLink(userId, id);
if (!deleted) return res.status(404).json({ error: "Not found" });
await ctx.sync.recordTombstone(
userId,
"dashboardServiceLinks",
deleted.syncId,
);
res.json({ message: "Service link deleted" });
} catch (err) {
logError(
@@ -161,7 +161,9 @@ export function ProcessInspectorCard({ hostId }: { hostId: number | null }) {
<span
className="truncate"
title={`${p.user} · ${p.args}`}
style={depth ? { paddingLeft: depth * 12 } : undefined}
style={
depth ? { paddingLeft: `${(depth * 12) / 14}rem` } : undefined
}
>
{depth > 0 && (
<span className="text-muted-foreground/40">└ </span>
+2 -1
View File
@@ -45,5 +45,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+2 -1
View File
@@ -45,7 +45,8 @@
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
],
"syncEntities": ["networkTopology"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
@@ -34,6 +34,7 @@ import {
Input,
PasswordInput,
isElectron,
linkedServerUrl,
resolveConnectionOrigin,
useConnectionLog,
type ConnectionOrigin,
@@ -309,10 +310,7 @@ const GuacamoleAppInner = React.forwardRef<
{ defaultRemote: true },
);
if (resolvedOrigin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (!remoteConfig?.serverUrl) {
if (!(await linkedServerUrl())) {
throw new Error(t("errors.remoteServerRequired"));
}
}
+2 -1
View File
@@ -34,5 +34,6 @@
"icon": "Usb"
}
]
}
},
"desktop": "local"
}
+2 -1
View File
@@ -81,6 +81,7 @@
"default": true
}
]
}
},
"syncEntities": ["snippetFolders", "snippets"]
}
}
+2 -3
View File
@@ -74,6 +74,8 @@ export async function activate(ctx: PluginContext) {
type: "snippets",
table: snippetsTable,
order: 60,
// Host quick actions and keybindings in core point at snippets by this.
answersTo: ["commandSnippet"],
});
// The user row survives a password-reset data wipe, so it never triggers
@@ -152,9 +154,6 @@ export async function activate(ctx: PluginContext) {
if (!userId) throw new Error("snippets.access.remove needs an actor");
await requirePermission("delete");
const existing = await repo.deleteSnippet(userId, id);
if (existing?.syncId) {
await ctx.sync.recordTombstone(userId, "snippets", existing.syncId);
}
return !!existing;
},
};
+1 -7
View File
@@ -319,12 +319,7 @@ export function registerSnippetRoutes(
try {
const folderName = decodeURIComponent(name);
const deleted = await repo.deleteFolder(userId, folderName);
await ctx.sync.recordTombstone(
userId,
"snippetFolders",
deleted?.syncId,
);
await repo.deleteFolder(userId, folderName);
res.json({ success: true });
} catch (err) {
logError(
@@ -921,7 +916,6 @@ export function registerSnippetRoutes(
const existing = await repo.deleteSnippet(userId, parseInt(id, 10));
if (!existing)
return res.status(404).json({ error: "Snippet not found" });
await ctx.sync.recordTombstone(userId, "snippets", existing.syncId);
res.json({ success: true });
} catch (err) {
logError(ctx, "Failed to delete snippet", err, "snippet_delete_failed");
+2 -1
View File
@@ -64,5 +64,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
@@ -12,6 +12,12 @@ import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const linked = vi.hoisted(() => ({ serverUrl: null as string | null }));
vi.mock("@/lib/linked-server", () => ({
getLinkedSession: async () =>
linked.serverUrl ? { serverUrl: linked.serverUrl, token: "t" } : null,
}));
let rendered: RenderedPluginApp | null = null;
type ElectronWindow = { electronAPI?: unknown };
@@ -95,10 +101,10 @@ describe(`${manifest.id} activate`, () => {
});
it("hides the rail item in a standalone desktop app", async () => {
linked.serverUrl = null;
(window as unknown as ElectronWindow).electronAPI = {
isElectron: true,
invoke: vi.fn(async () => null),
onRemoteSyncStatusChanged: () => () => {},
};
rendered = await renderWithApp(plugin, { manifest, locales });
await waitFor(() =>
@@ -108,24 +114,19 @@ describe(`${manifest.id} activate`, () => {
);
});
it("shows the rail item once the desktop app has a remote server", async () => {
let notify = () => {};
let serverUrl: string | null = null;
it("shows the rail item once the desktop app is linked to a server", async () => {
linked.serverUrl = null;
(window as unknown as ElectronWindow).electronAPI = {
isElectron: true,
invoke: vi.fn(async () => (serverUrl ? { serverUrl } : null)),
onRemoteSyncStatusChanged: (callback: () => void) => {
notify = callback;
return () => {};
},
invoke: vi.fn(async () => null),
};
rendered = await renderWithApp(plugin, { manifest, locales });
await waitFor(() =>
expect(rendered!.registered.railItems()[0]?.hidden).toBe(true),
);
serverUrl = "https://termix.example";
notify();
linked.serverUrl = "https://termix.example";
window.dispatchEvent(new CustomEvent("termix:sync-changed"));
await waitFor(() =>
expect(rendered!.registered.railItems()).toEqual([
expect.objectContaining({ id: "termix-id", hidden: false }),
+2 -1
View File
@@ -27,5 +27,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+2 -1
View File
@@ -74,7 +74,8 @@
"to": "/oidc/callback"
}
]
}
},
"syncEntities": ["vaultProfiles"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
@@ -134,7 +134,6 @@ export function createProfileStore(
await db.delete(tokensTable).where(eq(tokensTable.profileId, row.id));
await db.delete(table).where(eq(table.id, row.id));
await ctx.db.persist();
await ctx.sync.recordTombstone(row.userId, SYNC_ENTITY, row.syncId);
},
};
}
+1 -9
View File
@@ -124,7 +124,7 @@ describe("profiles", () => {
expect((await server.request("GET", "/profiles")).body).toEqual([]);
});
it("records a tombstone and drops cached certificates on delete", async () => {
it("drops cached certificates on delete", async () => {
server = await startServer();
const { id } = await profileOnHost();
server.db.sqlite
@@ -132,17 +132,9 @@ describe("profiles", () => {
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", id, "c", "k", "2999-01-01T00:00:00.000Z");
const syncId = (
server.db.sqlite
.prepare("SELECT sync_id FROM p_vault_profiles WHERE id = ?")
.get(id) as { sync_id: string }
).sync_id;
await server.request("DELETE", `/profiles/${id}`);
expect(server.mock.tombstones).toEqual([
{ userId: "user-1", entityType: "vaultProfiles", syncId },
]);
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
+2 -1
View File
@@ -27,5 +27,6 @@
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
"platforms": ["linux", "win32", "darwin"],
"desktop": "server"
}
+2 -1
View File
@@ -39,6 +39,7 @@
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
],
"syncEntities": ["workspaces"]
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The interface size setting scales the root font size, so every length has
* to be in rem to follow it. Tailwind classes like text-[10px] and w-[240px]
* are written in px, so they are converted here against the 14px Normal root.
*
* Left alone: 1px lines (hairline borders and dividers stay crisp), media
* queries (breakpoints follow the window, not the text size), the root size
* rules themselves, and third-party CSS such as xterm's, which measures in px.
*/
module.exports = {
plugins: {
"postcss-pxtorem": {
rootValue: 14,
unitPrecision: 5,
propList: ["*"],
selectorBlackList: [/^html(\.|$)/, /^:root/],
replace: true,
mediaQuery: false,
minPixelValue: 2,
exclude: /node_modules/i,
},
},
};
+17 -3
View File
@@ -19,8 +19,22 @@ describe("Electron security boundary", () => {
);
});
it("allows the renderer to hand the local session to Remote Sync", () => {
expect(main).toContain('ipcMain.handle("notify-local-login"');
expect(preload).toContain('"notify-local-login"');
it("never hands the linked server's session to the renderer over IPC", () => {
// The embedded backend owns the link; the renderer asks it, with its
// own local session, rather than main.
for (const channel of [
"get-remote-sync-jwt",
"save-remote-sync-jwt",
"notify-local-login",
]) {
expect(main).not.toContain(`"${channel}"`);
expect(preload).not.toContain(`"${channel}"`);
}
});
it("scopes proxy headers and basic auth to the linked origin", () => {
expect(main).toContain('"sync-proxy-config"');
expect(main).toContain("linkedServer.applyLinkHeaders(");
expect(main).toContain("linkedServer.answerLogin(");
});
});
+44
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { build } from "../packages/plugin-sdk/cli/commands/build.mjs";
const cleanups: Array<() => void> = [];
@@ -39,4 +40,47 @@ describe("termix-plugin build", () => {
expect(pkg).toEqual({ type: "module" });
expect(fs.existsSync(path.join(dir, "dist", "backend.js"))).toBe(true);
});
it("loads a bundled CommonJS package that requires a host-provided one, with Vite's build constants", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
const dep = path.join(dir, "node_modules", "cjs-dep");
fs.mkdirSync(dep, { recursive: true });
fs.writeFileSync(
path.join(dep, "package.json"),
JSON.stringify({ name: "cjs-dep", main: "index.js" }),
);
// What react-xtermjs does: a CommonJS require of React's JSX runtime.
fs.writeFileSync(
path.join(dep, "index.js"),
'const runtime = require("react/jsx-runtime");\nexports.render = () => runtime.jsx("div", {});\n',
);
fs.mkdirSync(path.join(dir, "src", "frontend"), { recursive: true });
fs.writeFileSync(
path.join(dir, "src", "frontend", "index.ts"),
'import { render } from "cjs-dep";\nexport const rendered = render();\nexport const dev = import.meta.env.DEV;\nexport const mode = process.env.NODE_ENV;\n',
);
await build({ cwd: dir });
// Stand in for the import map: point the external at a stub module.
const stub = path.join(dir, "jsx-runtime.mjs");
fs.writeFileSync(
stub,
"export const jsx = (type) => ({ type });\nexport const jsxs = jsx;\n",
);
const bundle = fs
.readFileSync(path.join(dir, "dist", "frontend.js"), "utf8")
.replaceAll(
'"react/jsx-runtime"',
JSON.stringify(pathToFileURL(stub).href),
);
const runnable = path.join(dir, "dist", "runnable.mjs");
fs.writeFileSync(runnable, bundle);
const mod = await import(pathToFileURL(runnable).href);
expect(mod.rendered).toEqual({ type: "div" });
expect(mod.dev).toBe(false);
expect(mod.mode).toBe("production");
});
});
+43
View File
@@ -0,0 +1,43 @@
import { createRequire } from "node:module";
import postcss from "postcss";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const config = require("../postcss.config.cjs") as {
plugins: Record<string, unknown>;
};
const pxtorem = require("postcss-pxtorem");
async function convert(css: string, from = "src/ui/index.css") {
const result = await postcss([
pxtorem(config.plugins["postcss-pxtorem"]),
]).process(css, { from });
return result.css;
}
describe("interface size conversion", () => {
it("turns px into rem against the 14px Normal root", async () => {
expect(await convert(".a{font-size:10px;width:280px}")).toBe(
".a{font-size:0.71429rem;width:20rem}",
);
});
it("keeps hairlines, breakpoints and the root size rules in px", async () => {
expect(await convert(".b{border-width:1px}")).toBe(".b{border-width:1px}");
expect(await convert("@media (min-width:768px){.c{gap:7px}}")).toBe(
"@media (min-width:768px){.c{gap:0.5rem}}",
);
expect(await convert("html.fs-lg{font-size:17px}")).toBe(
"html.fs-lg{font-size:17px}",
);
});
it("leaves third-party CSS alone", async () => {
expect(
await convert(
".xterm{padding:4px}",
"node_modules/@xterm/xterm/css/xterm.css",
),
).toBe(".xterm{padding:4px}");
});
});
+14 -163
View File
@@ -16,7 +16,9 @@ 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 "./routes/sync.js";
import syncRoutes from "../sync/server/routes.js";
import syncLinkRoutes from "../sync/client/routes.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import {
mountPluginApi,
@@ -33,6 +35,13 @@ import path from "path";
import os from "os";
import "dotenv/config";
import { databaseLogger, apiLogger } from "../utils/logger.js";
import { getLocalVersion } from "../utils/app-version.js";
import {
compareSemver,
fetchGitHubAPI,
REPO_NAME,
REPO_OWNER,
} from "../utils/latest-release.js";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
@@ -55,13 +64,7 @@ import {
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { getProxyAgent } from "../utils/proxy-agent.js";
import type {
CacheEntry,
GitHubRelease,
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import type { GitHubRelease, AuthenticatedRequest } from "../../types/index.js";
import { DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
@@ -147,116 +150,6 @@ const upload = multer({
},
});
class GitHubCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000;
set<T>(key: string, data: T): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
}
const githubCache = new GitHubCache();
function parseSemver(
version: string | undefined,
): [number, number, number] | null {
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
}
function compareSemver(
a: string | undefined,
b: string | undefined,
): number | null {
const parsedA = parseSemver(a);
const parsedB = parseSemver(b);
if (!parsedA || !parsedB) return null;
for (let i = 0; i < 3; i += 1) {
if (parsedA[i] > parsedB[i]) return 1;
if (parsedA[i] < parsedB[i]) return -1;
}
return 0;
}
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix";
async function fetchGitHubAPI<T>(
endpoint: string,
cacheKey: string,
): Promise<GitHubAPIResponse<T>> {
const cachedEntry = githubCache.get<CacheEntry<T>>(cacheKey);
if (cachedEntry) {
return {
data: cachedEntry.data,
cached: true,
cache_age: Date.now() - cachedEntry.timestamp,
};
}
try {
const url = `${GITHUB_API_BASE}${endpoint}`;
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as T;
const cacheData: CacheEntry<T> = {
data,
timestamp: Date.now(),
expiresAt: Date.now() + 30 * 60 * 1000,
};
githubCache.set(cacheKey, cacheData);
return {
data: data,
cached: false,
};
} catch (error) {
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
operation: "github_api",
endpoint,
});
throw error;
}
}
// Skipped for /plugin-api: a plugin router brings its own parsers with its own
// limit (see plugins/http.ts), and parsing here first would consume the body
// and silently cap every plugin at this limit instead.
@@ -279,6 +172,7 @@ app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use(syncChangeWatcher);
/**
* @openapi
@@ -343,51 +237,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* description: Fetch error.
*/
app.get("/version", authenticateJWT, async (req, res) => {
let localVersion = process.env.VERSION;
if (!localVersion) {
const versionSources = [
() => {
try {
const packagePath = path.resolve(process.cwd(), "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packagePath = path.resolve("/app", "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packagePath = path.resolve(__dirname, "../../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
];
for (const getVersion of versionSources) {
try {
const foundVersion = getVersion();
if (foundVersion && foundVersion !== "unknown") {
localVersion = foundVersion;
break;
}
} catch {
continue;
}
}
}
const localVersion = getLocalVersion();
if (!localVersion) {
databaseLogger.error("No version information available", undefined, {
@@ -1567,6 +1417,7 @@ app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncRoutes);
app.use("/sync", syncLinkRoutes);
app.use("/dashboard", dashboardRoutes);
app.use("/plugins", pluginRoutes);
app.use(
+72 -20
View File
@@ -1598,28 +1598,80 @@ const migrateSchema = () => {
}
}
addColumnIfNotExists(
"ssh_data",
"local_only",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "shared_source", "TEXT");
addColumnIfNotExists("ssh_credentials", "shared_source", "TEXT");
addColumnIfNotExists(
"ssh_folders",
"local_only",
"INTEGER NOT NULL DEFAULT 0",
);
try {
sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS sync_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
sqlite.exec(
"CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
sqlite.exec(`
DROP TABLE IF EXISTS sync_tombstones;
CREATE TABLE IF NOT EXISTS sync_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL DEFAULT 0,
hash TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
error TEXT,
error_hash TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
} catch (createError) {
databaseLogger.warn("Failed to create sync_tombstones table", {
operation: "schema_migration",
error: createError,
});
}
CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_records_user_entity_sync
ON sync_records(user_id, entity_type, sync_id);
CREATE INDEX IF NOT EXISTS idx_sync_records_user_seq
ON sync_records(user_id, seq);
CREATE TABLE IF NOT EXISTS sync_conflicts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
local_row TEXT NOT NULL,
server_revision INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user
ON sync_conflicts(user_id);
CREATE TABLE IF NOT EXISTS sync_link (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_url TEXT NOT NULL,
server_name TEXT,
server_version TEXT,
session_token TEXT,
custom_headers TEXT,
basic_auth TEXT,
allow_invalid_certificate INTEGER NOT NULL DEFAULT 0,
remote_user_id TEXT,
remote_username TEXT,
account TEXT,
scope TEXT,
known_types TEXT,
cursor INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'idle',
last_error TEXT,
linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_sync_at TEXT
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create sync tables", {
operation: "schema_migration",
error: createError,
});
}
// --- sync end ---
+95 -7
View File
@@ -251,6 +251,11 @@ export const hosts = mysqlTable(
// databases (the embedded backend and a connected remote server) during
// sync -- local autoincrement ids collide across instances.
syncId: varchar("sync_id", { length: 255 }).unique(),
// Desktop only: a host kept on this device that never goes to the server.
localOnly: boolean("local_only").notNull().default(false),
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
@@ -303,6 +308,7 @@ export const sshCredentials = mysqlTable(
usageCount: int("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -353,6 +359,7 @@ export const sshFolders = mysqlTable(
// to name sort, same convention as hosts.sortOrder.
sortOrder: int("sort_order"),
syncId: varchar("sync_id", { length: 255 }).unique(),
localOnly: boolean("local_only").notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -707,19 +714,100 @@ export const uiPreferences = mysqlTable("ui_preferences", {
});
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
// the deletion instead of re-creating the row on its next pull.
export const syncTombstones = mysqlTable("sync_tombstones", {
/**
* One row per synced record a user owns, on both ends of a sync link.
*
* On a server, revision counts the record's changes and seq is the position
* in the user's change feed, which is what a desktop's cursor points at. On a
* linked desktop, revision is the last server revision this device saw and
* hash is what the record looked like then, so a different hash now means a
* local edit waiting to be pushed.
*/
export const syncRecords = mysqlTable(
"sync_records",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: varchar("entity_type", { length: 255 }).notNull(),
syncId: varchar("sync_id", { length: 255 }).notNull(),
revision: int("revision").notNull().default(0),
seq: int("seq").notNull().default(0),
hash: text("hash"),
deleted: boolean("deleted").notNull().default(false),
// Desktop only: why the server refused the last push of this record, and
// the hash that was refused, so the same content is not re-sent forever.
error: text("error"),
errorHash: text("error_hash"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_sync_records_user_entity_sync").on(
table.userId,
table.entityType,
table.syncId,
),
index("idx_sync_records_user_seq").on(table.userId, table.seq),
],
);
/**
* Desktop only: a local edit that lost to a newer server edit. The server
* version was applied; this keeps the local one so the user can pick it.
*/
export const syncConflicts = mysqlTable(
"sync_conflicts",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
localRow: text("local_row").notNull(),
serverRevision: int("server_revision").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
);
/**
* Desktop only: the server this install is linked to. One row at most.
* Secrets (session token, proxy headers, basic auth) are encrypted with the
* system key.
*/
export const syncLink = mysqlTable("sync_link", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
serverUrl: text("server_url").notNull(),
serverName: text("server_name"),
serverVersion: text("server_version"),
sessionToken: text("session_token"),
customHeaders: text("custom_headers"),
basicAuth: text("basic_auth"),
allowInvalidCertificate: boolean("allow_invalid_certificate")
.notNull()
.default(false),
remoteUserId: text("remote_user_id"),
remoteUsername: text("remote_username"),
// The linked account as the server describes it: roles, admin, permissions.
account: text("account"),
scope: text("scope"),
knownTypes: text("known_types"),
cursor: int("cursor").notNull().default(0),
status: text("status").notNull().default("idle"),
lastError: text("last_error"),
linkedAt: text("linked_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
lastSyncAt: text("last_sync_at"),
});
// --- sync end ---
+95 -7
View File
@@ -252,6 +252,11 @@ export const hosts = pgTable(
// databases (the embedded backend and a connected remote server) during
// sync -- local autoincrement ids collide across instances.
syncId: varchar("sync_id", { length: 255 }).unique(),
// Desktop only: a host kept on this device that never goes to the server.
localOnly: boolean("local_only").notNull().default(false),
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
@@ -304,6 +309,7 @@ export const sshCredentials = pgTable(
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -354,6 +360,7 @@ export const sshFolders = pgTable(
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
syncId: varchar("sync_id", { length: 255 }).unique(),
localOnly: boolean("local_only").notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -708,19 +715,100 @@ export const uiPreferences = pgTable("ui_preferences", {
});
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
// the deletion instead of re-creating the row on its next pull.
export const syncTombstones = pgTable("sync_tombstones", {
/**
* One row per synced record a user owns, on both ends of a sync link.
*
* On a server, revision counts the record's changes and seq is the position
* in the user's change feed, which is what a desktop's cursor points at. On a
* linked desktop, revision is the last server revision this device saw and
* hash is what the record looked like then, so a different hash now means a
* local edit waiting to be pushed.
*/
export const syncRecords = pgTable(
"sync_records",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: varchar("entity_type", { length: 255 }).notNull(),
syncId: varchar("sync_id", { length: 255 }).notNull(),
revision: integer("revision").notNull().default(0),
seq: integer("seq").notNull().default(0),
hash: text("hash"),
deleted: boolean("deleted").notNull().default(false),
// Desktop only: why the server refused the last push of this record, and
// the hash that was refused, so the same content is not re-sent forever.
error: text("error"),
errorHash: text("error_hash"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_sync_records_user_entity_sync").on(
table.userId,
table.entityType,
table.syncId,
),
index("idx_sync_records_user_seq").on(table.userId, table.seq),
],
);
/**
* Desktop only: a local edit that lost to a newer server edit. The server
* version was applied; this keeps the local one so the user can pick it.
*/
export const syncConflicts = pgTable(
"sync_conflicts",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
localRow: text("local_row").notNull(),
serverRevision: integer("server_revision").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
);
/**
* Desktop only: the server this install is linked to. One row at most.
* Secrets (session token, proxy headers, basic auth) are encrypted with the
* system key.
*/
export const syncLink = pgTable("sync_link", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
serverUrl: text("server_url").notNull(),
serverName: text("server_name"),
serverVersion: text("server_version"),
sessionToken: text("session_token"),
customHeaders: text("custom_headers"),
basicAuth: text("basic_auth"),
allowInvalidCertificate: boolean("allow_invalid_certificate")
.notNull()
.default(false),
remoteUserId: text("remote_user_id"),
remoteUsername: text("remote_username"),
// The linked account as the server describes it: roles, admin, permissions.
account: text("account"),
scope: text("scope"),
knownTypes: text("known_types"),
cursor: integer("cursor").notNull().default(0),
status: text("status").notNull().default("idle"),
lastError: text("last_error"),
linkedAt: text("linked_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
lastSyncAt: text("last_sync_at"),
});
// --- sync end ---
+97 -7
View File
@@ -244,6 +244,11 @@ export const hosts = sqliteTable(
// databases (the embedded backend and a connected remote server) during
// sync -- local autoincrement ids collide across instances.
syncId: text("sync_id").unique(),
// Desktop only: a host kept on this device that never goes to the server.
localOnly: integer("local_only", { mode: "boolean" }).notNull().default(false),
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
@@ -296,6 +301,7 @@ export const sshCredentials = sqliteTable(
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: text("sync_id").unique(),
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -346,6 +352,7 @@ export const sshFolders = sqliteTable(
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
syncId: text("sync_id").unique(),
localOnly: integer("local_only", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -704,19 +711,102 @@ export const uiPreferences = sqliteTable("ui_preferences", {
});
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
// the deletion instead of re-creating the row on its next pull.
export const syncTombstones = sqliteTable("sync_tombstones", {
/**
* One row per synced record a user owns, on both ends of a sync link.
*
* On a server, revision counts the record's changes and seq is the position
* in the user's change feed, which is what a desktop's cursor points at. On a
* linked desktop, revision is the last server revision this device saw and
* hash is what the record looked like then, so a different hash now means a
* local edit waiting to be pushed.
*/
export const syncRecords = sqliteTable(
"sync_records",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
revision: integer("revision").notNull().default(0),
seq: integer("seq").notNull().default(0),
hash: text("hash"),
deleted: integer("deleted", { mode: "boolean" }).notNull().default(false),
// Desktop only: why the server refused the last push of this record, and
// the hash that was refused, so the same content is not re-sent forever.
error: text("error"),
errorHash: text("error_hash"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_sync_records_user_entity_sync").on(
table.userId,
table.entityType,
table.syncId,
),
index("idx_sync_records_user_seq").on(table.userId, table.seq),
],
);
/**
* Desktop only: a local edit that lost to a newer server edit. The server
* version was applied; this keeps the local one so the user can pick it.
*/
export const syncConflicts = sqliteTable(
"sync_conflicts",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
localRow: text("local_row").notNull(),
serverRevision: integer("server_revision").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
);
/**
* Desktop only: the server this install is linked to. One row at most.
* Secrets (session token, proxy headers, basic auth) are encrypted with the
* system key.
*/
export const syncLink = sqliteTable("sync_link", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
serverUrl: text("server_url").notNull(),
serverName: text("server_name"),
serverVersion: text("server_version"),
sessionToken: text("session_token"),
customHeaders: text("custom_headers"),
basicAuth: text("basic_auth"),
allowInvalidCertificate: integer("allow_invalid_certificate", {
mode: "boolean",
})
.notNull()
.default(false),
remoteUserId: text("remote_user_id"),
remoteUsername: text("remote_username"),
// The linked account as the server describes it: roles, admin, permissions.
account: text("account"),
scope: text("scope"),
knownTypes: text("known_types"),
cursor: integer("cursor").notNull().default(0),
status: text("status").notNull().default("idle"),
lastError: text("last_error"),
linkedAt: text("linked_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
lastSyncAt: text("last_sync_at"),
});
// --- sync end ---
@@ -31,7 +31,6 @@ import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
@@ -138,13 +137,6 @@ export function createCurrentCredentialRepository(): CredentialRepository {
);
}
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
return new SyncTombstoneRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
);
}
export function createCurrentHostFolderRepository(): HostFolderRepository {
return new HostFolderRepository(
createCurrentRepositoryContext(),
@@ -85,6 +85,28 @@ export class HostFolderRepository {
.where(eq(sshFolders.userId, userId));
}
/**
* Keeps a folder, its subfolders and every host in them on this device
* only, or lets them sync again.
*/
async setLocalOnly(
userId: string,
folderName: string,
localOnly: boolean,
): Promise<void> {
const inFolder = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, `${folderName} / %`));
await this.context.drizzle
.update(sshFolders)
.set({ localOnly })
.where(and(eq(sshFolders.userId, userId), inFolder(sshFolders.name)));
await this.context.drizzle
.update(hosts)
.set({ localOnly, updatedAt: new Date().toISOString() })
.where(and(eq(hosts.userId, userId), inFolder(hosts.folder)));
await this.afterWrite();
}
async upsertMetadata(
userId: string,
name: string,
@@ -1,73 +0,0 @@
import { and, eq } from "drizzle-orm";
import { syncTombstones } from "../db/schema.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";
import type { DatabaseContext } from "./database-context.js";
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
/**
* The wire name of a synced entity.
*
* A string rather than a union: the set is whatever core and the installed
* plugins have registered, so it cannot be known at compile time. The column
* was already free-form text with no enum constraint, so nothing in the
* database changes. sync.ts validates a name through the registry before it
* reaches a query.
*/
export type SyncEntityType = string;
export class SyncTombstoneRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async record(
userId: string,
entityType: SyncEntityType,
syncId: string,
): Promise<void> {
if (!syncId) return;
await this.context.drizzle.insert(syncTombstones).values({
userId,
entityType,
syncId,
});
await this.afterWrite();
}
async recordMany(
userId: string,
entityType: SyncEntityType,
syncIds: string[],
): Promise<void> {
const rows = syncIds.filter(Boolean);
if (rows.length === 0) return;
await this.context.drizzle
.insert(syncTombstones)
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
await this.afterWrite();
}
async listSince(
userId: string,
entityType: SyncEntityType,
since: string | null,
): Promise<SyncTombstoneRecord[]> {
const conditions = [
eq(syncTombstones.userId, userId),
eq(syncTombstones.entityType, entityType),
];
if (since)
conditions.push(timestampAtOrAfter(syncTombstones.deletedAt, since));
return this.context.drizzle
.select()
.from(syncTombstones)
.where(and(...conditions));
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
+20 -52
View File
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
import { deleteOwnedCredential } from "../../hosts/delete-credential.js";
import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
@@ -22,8 +23,9 @@ import {
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { parseSharedSource } from "./host-normalizers.js";
/** Built-in password and key, plus any type a plugin offers for credentials. */
function getCredentialTypes(): string[] {
@@ -32,6 +34,7 @@ function getCredentialTypes(): string[] {
}
const router = express.Router();
router.use(rejectSharedCopyWrites("credential", /^\/(\d+)$/));
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
@@ -816,59 +819,11 @@ router.delete(
try {
const credentialId = parseInt(id);
const credentialToDelete =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (!credentialToDelete) {
const deleted = await deleteOwnedCredential(userId, credentialId);
if (!deleted) {
return res.status(404).json({ error: "Credential not found" });
}
const hostsUsingCredential =
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
userId,
credentialId,
);
if (hostsUsingCredential.length > 0) {
await createCurrentHostRepository().updateManyForUser(
userId,
hostsUsingCredential.map((host) => host.id),
{
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: "password",
},
);
}
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
await sharedSecretsManager.deleteForCredential(credentialId);
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
if (credentialToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"sshCredentials",
credentialToDelete.syncId,
);
}
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
for (const host of hostsUsingCredential) {
await sharedSecretsManager.resyncHost(host.id);
}
authLogger.success("SSH credential deleted", {
operation: "credential_delete_success",
userId,
@@ -882,7 +837,7 @@ router.delete(
action: "delete_credential",
resourceType: "credential",
resourceId: id,
resourceName: String(credentialToDelete.name ?? id),
resourceName: String(deleted.name ?? id),
ipAddress: cdIp,
userAgent: cdUa,
success: true,
@@ -1138,6 +1093,19 @@ function formatCredentialOutput(
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
...sharedCopyFields(credential.sharedSource),
};
}
/** A read-only copy of a credential shared with a linked desktop's account. */
function sharedCopyFields(value: unknown): Record<string, unknown> {
const shared = parseSharedSource(value);
if (!shared) return {};
return {
isShared: true,
sharedCopy: true,
ownerUsername: shared.owner || null,
permissionLevel: shared.permissionLevel === "manage" ? "manage" : "use",
};
}
@@ -21,6 +21,17 @@ export function isLoopbackRequest(req: Request): boolean {
);
}
/**
* Whether this backend may hand out the desktop auto-session at all: the
* embedded desktop backend, or a development backend the desktop app points
* at. A production server never does, whoever asks.
*/
export function allowsDesktopAutoSession(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env.ELECTRON_EMBEDDED === "true" || env.NODE_ENV !== "production";
}
export function extractBearerOrCookieToken(req: Request): string | undefined {
const cookieToken = (req as Request & { cookies?: Record<string, string> })
.cookies?.jwt;
@@ -9,7 +9,6 @@ import {
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -178,6 +177,9 @@ export function registerHostFolderRoutes(
* credentialId:
* type: integer
* nullable: true
* localOnly:
* type: boolean
* description: Desktop only. Keeps the folder and its hosts on this device instead of syncing them.
* responses:
* 200:
* description: Folder metadata updated successfully.
@@ -193,7 +195,7 @@ export function registerHostFolderRoutes(
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon, credentialId } = req.body;
const { name, color, icon, credentialId, localOnly } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
@@ -234,6 +236,13 @@ export function registerHostFolderRoutes(
icon,
normalizedCredentialId,
);
if (typeof localOnly === "boolean") {
await createCurrentHostFolderRepository().setLocalOnly(
userId,
name,
localOnly,
);
}
if (!created) {
databaseLogger.info("Updating SSH folder", {
@@ -411,17 +420,9 @@ export function registerHostFolderRoutes(
);
}
const { hostSyncIds, folderSyncIds } =
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
folderName,
);
const tombstoneRepository = createCurrentSyncTombstoneRepository();
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
await tombstoneRepository.recordMany(
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
"sshFolders",
folderSyncIds,
folderName,
);
try {
@@ -383,11 +383,35 @@ export function sanitizeHostForRecipient(
return reduced;
}
/** Who shared a host that a linked desktop holds a read-only copy of. */
export function parseSharedSource(
value: unknown,
): { owner?: string; permissionLevel?: string } | null {
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
export function transformHostResponse(
host: Record<string, unknown>,
): Record<string, unknown> {
const shared = parseSharedSource(host.sharedSource);
return {
...host,
sharedSource: undefined,
localOnly: !!host.localOnly,
sharedCopy: !!shared,
...(shared
? {
isShared: true,
ownerUsername: shared.owner || undefined,
permissionLevel: shared.permissionLevel || "connect",
}
: {}),
tags:
typeof host.tags === "string"
? host.tags
+12 -4
View File
@@ -67,8 +67,10 @@ import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
} from "../../utils/shared-host-auth-resolver.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
const router = express.Router();
router.use(rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/));
const upload = multer({ storage: multer.memoryStorage() });
@@ -199,6 +201,7 @@ router.post(
socks5Password,
socks5ProxyChain,
connectionOrigin,
localOnly,
portKnockSequence,
overrideCredentialUsername,
enableSsh,
@@ -311,6 +314,7 @@ router.post(
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
...(typeof localOnly === "boolean" ? { localOnly } : {}),
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -795,6 +799,7 @@ router.put(
socks5Password,
socks5ProxyChain,
connectionOrigin,
localOnly,
portKnockSequence,
overrideCredentialUsername,
enableSsh,
@@ -908,6 +913,7 @@ router.put(
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
...(typeof localOnly === "boolean" ? { localOnly } : {}),
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -1454,14 +1460,16 @@ router.get(
const result = await Promise.all(
data.map(async (row: Record<string, unknown>) => {
const transformed = transformHostResponse(row);
const baseHost = {
...transformHostResponse(row),
isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined,
...transformed,
isShared: !!row.isShared || !!transformed.sharedCopy,
permissionLevel:
row.permissionLevel || transformed.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined,
ownerUsername: row.isShared
? ownerUsernames.get(row.userId as string) || undefined
: undefined,
: transformed.ownerUsername,
};
const resolved =
+19
View File
@@ -306,6 +306,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
}
});
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
const { getLink } = await import("../../sync/client/link-store.js");
if (!(await getLink())) return false;
const { isServerManaged } = await import("../../sync/client/plugins.js");
return isServerManaged(pluginId);
}
/**
* @openapi
* /plugins/{id}/state:
@@ -338,6 +346,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
* 409:
* description: This desktop is linked to a server, which decides whether the plugin runs.
*/
router.patch(
"/:id/state",
@@ -360,6 +370,15 @@ router.patch(
return;
}
// A linked desktop runs what its server runs.
if (await isManagedByLinkedServer(pluginId)) {
res.status(409).json({
error: "Managed by the linked server",
code: "MANAGED_BY_SERVER",
});
return;
}
// Persist first: if the start or stop below throws, the recorded
// intent still matches what the user asked for, and the next boot
// acts on it rather than silently reverting.
@@ -1,112 +0,0 @@
/**
* Core's own sync entities, registered the same way a plugin registers one.
*
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
* the tombstone repository, a REFERENCES map in sync-references.ts and a
* frozen array in electron/. Four lists that had to agree and had no test
* saying they did. Now there is one registration per entity and the rest is
* derived.
*
* The order values reproduce the dependency order the Electron array encoded:
* anything referenced by another entity syncs first.
*/
import {
hosts,
sshCredentials,
sshFolders,
userPreferences,
} from "../db/schema.js";
import {
CORE_OWNER,
registerEntity,
listEntities,
} from "../../plugins/sync-registry.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
} as const;
const HOST_REFERENCES = [
CREDENTIAL_REFERENCE,
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
entityType: "hosts",
},
] as const;
let registered = false;
/**
* Registers core's entities. Idempotent, because the sync routes and the
* Electron entity endpoint both need the registry primed and neither can
* assume it ran first.
*/
export function registerCoreSyncEntities(): void {
if (registered && listEntities().length > 0) return;
registered = true;
// 10, 20, 30... so a plugin can slot an entity between two core ones
// without every order value having to be renumbered.
registerEntity(CORE_OWNER, {
type: "sshCredentials",
table: sshCredentials,
order: 10,
});
// vaultProfiles (order 20) moved to the vault plugin. Hosts no longer
// reference it: a host's profile is the plugin's profileId host setting.
registerEntity(CORE_OWNER, {
type: "sshFolders",
table: sshFolders,
order: 30,
references: [CREDENTIAL_REFERENCE],
});
registerEntity(CORE_OWNER, {
type: "hosts",
table: hosts,
order: 50,
// connectionOrigin says how this device reached the host, which is never
// true of the other side.
readOnlyFields: ["connectionOrigin"],
references: HOST_REFERENCES,
encryptedFields: ["ssh_data"],
});
// dashboardServiceLinks (order 70) and homepageItems (order 80) moved to
// the homepage plugin, which registers them itself at activation.
registerEntity(CORE_OWNER, {
type: "userPreferences",
table: userPreferences,
order: 90,
singleton: true,
readOnlyFields: ["storageMode"],
});
}
/** The tables a reference may point at, derived from the registrations. */
export const ENCRYPTED_ENTITY_TABLES: Readonly<Record<string, string>> = {
hosts: "ssh_data",
sshCredentials: "ssh_credentials",
};
@@ -1,123 +0,0 @@
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import { getEntity } from "../../plugins/sync-registry.js";
/**
* An entity another entity's rows may point at.
*
* A plain string rather than a union: the set is whatever is registered, and a
* plugin can both register an entity and reference one. sync.ts resolves the
* name through the registry, so an unknown one is an error there rather than a
* silent fallthrough.
*/
export type SyncReferenceEntity = string;
interface SyncReference {
field: string;
syncField: string;
entityType: SyncReferenceEntity;
}
export function orderSyncRows(
entityType: SyncEntityType,
rows: Record<string, unknown>[],
): Record<string, unknown>[] {
// Only an entity that references itself needs its rows ordered among
// themselves. Today that is hosts, through parentHostId.
const selfReference = referencesFor(entityType).find(
(reference) => reference.entityType === entityType,
);
if (!selfReference) return rows;
const bySyncId = new Map(
rows
.filter((row) => typeof row.syncId === "string")
.map((row) => [row.syncId as string, row]),
);
const ordered: Record<string, unknown>[] = [];
const visited = new Set<Record<string, unknown>>();
const visiting = new Set<Record<string, unknown>>();
const visit = (row: Record<string, unknown>) => {
if (visited.has(row)) return;
if (visiting.has(row)) return;
visiting.add(row);
const parentSyncId = row[selfReference.syncField];
if (typeof parentSyncId === "string") {
const parent = bySyncId.get(parentSyncId);
if (parent) visit(parent);
}
visiting.delete(row);
visited.add(row);
ordered.push(row);
};
rows.forEach(visit);
return ordered;
}
function referencesFor(entityType: SyncEntityType): readonly SyncReference[] {
return (getEntity(entityType)?.references ?? []) as readonly SyncReference[];
}
export async function serializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveSyncId: (
entityType: SyncReferenceEntity,
id: number,
) => Promise<string | null>,
): Promise<Record<string, unknown>> {
const serialized = { ...row };
const custom = getEntity(entityType)?.serialize;
if (custom) return custom(serialized, resolveSyncId);
for (const reference of referencesFor(entityType)) {
const id = serialized[reference.field];
serialized[reference.syncField] =
typeof id === "number"
? await resolveSyncId(reference.entityType, id)
: null;
delete serialized[reference.field];
}
return serialized;
}
export async function deserializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveId: (
entityType: SyncReferenceEntity,
syncId: string,
) => Promise<number | null>,
): Promise<Record<string, unknown>> {
const deserialized = { ...row };
const custom = getEntity(entityType)?.deserialize;
if (custom) return custom(deserialized, resolveId);
for (const reference of referencesFor(entityType)) {
const syncId = deserialized[reference.syncField];
delete deserialized[reference.syncField];
delete deserialized[reference.field];
if (syncId == null) {
deserialized[reference.field] = null;
continue;
}
if (typeof syncId !== "string") {
throw new Error(`Invalid ${reference.syncField}`);
}
const id = await resolveId(reference.entityType, syncId);
if (id === null) {
throw new Error(
`Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`,
);
}
deserialized[reference.field] = id;
}
return deserialized;
}
-588
View File
@@ -1,588 +0,0 @@
import express, { type Request, type Response } from "express";
import { and, eq, type SQL } from "drizzle-orm";
import { hosts } from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentRepositoryContext,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import {
getEntity,
hasEntity,
listEntities,
} from "../../plugins/sync-registry.js";
import {
ENCRYPTED_ENTITY_TABLES,
registerCoreSyncEntities,
} from "./sync-entities.js";
import {
deserializeSyncReferences,
orderSyncRows,
serializeSyncReferences,
type SyncReferenceEntity,
} from "./sync-references.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";
import {
insertReturningWhere,
updateReturning,
} from "../repositories/returning.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
exportHostPluginSettings,
importHostPluginSettings,
} from "./sync-host-plugin-settings.js";
// Primed at import so every consumer of this module - the routes below, the
// reference resolvers, the Electron entity-types endpoint - sees the same
// registry regardless of which one runs first.
registerCoreSyncEntities();
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* What a sync entity looks like once it is registered.
*
* The table was a hand-maintained union of ten concrete drizzle types, which a
* plugin-provided table could never join. It is the same deliberate
* approximation repositories/database-context.ts documents: the query-builder
* surface used here is identical across the engines and across tables, and
* every use already casts to reach .syncId and .updatedAt.
*/
type SyncTable = typeof hosts;
function entityConfig(entityType: SyncEntityType) {
const entity = getEntity(entityType);
if (!entity) {
throw new Error(`Unknown sync entity "${entityType}"`);
}
return {
table: entity.table as SyncTable,
readOnlyFields: entity.readOnlyFields,
singleton: entity.singleton === true,
shouldSync: (row: Record<string, unknown>) => isSyncedRow(entityType, row),
};
}
/**
* Whether a row takes part in sync. An entity can leave some of its rows out
* (a per-install "last session"): they are not pulled, and a push, update or
* delete aimed at one is refused.
*/
export function isSyncedRow(
entityType: string,
row: Record<string, unknown>,
): boolean {
const entity = getEntity(entityType);
if (!entity?.shouldSync) return true;
return entity.shouldSync(row) !== false;
}
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
export function isValidEntityType(value: unknown): value is SyncEntityType {
if (typeof value !== "string") return false;
registerCoreSyncEntities();
return hasEntity(value);
}
/**
* Locates the stored row a sync payload corresponds to.
*
* Read and write have to agree on this. A singleton entity is keyed on its
* owner rather than a sync id, and `user_preferences` — the only singleton —
* has no `id` column at all, so an update cannot fall back to one: `table.id`
* is undefined there and drizzle emits `WHERE = ?`.
*/
export function locateSyncRow(
entityType: SyncEntityType,
userId: string,
syncId: string,
): SQL {
const { table, singleton } = entityConfig(entityType);
if (singleton) {
return eq(table.userId, userId);
}
return and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
)!;
}
/**
* The syncId of a referenced row.
*
* Generic over the registry rather than an if/else over three known entities.
* The old chain ended in an unguarded else that returned vaultProfiles, so a
* fourth reference target would have silently resolved against the wrong
* table; an unregistered entity is an error here instead.
*/
async function findReferenceSyncId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
id: number,
userId: string,
): Promise<string | null> {
const { table } = entityConfig(entityType as SyncEntityType);
const [row] = await context.drizzle
.select({ syncId: table.syncId })
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
async function findReferenceId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
syncId: string,
userId: string,
): Promise<number | null> {
const { table } = entityConfig(entityType as SyncEntityType);
const [row] = await context.drizzle
.select({ id: table.id })
.from(table)
.where(and(eq(table.syncId, syncId), eq(table.userId, userId)))
.limit(1);
return row?.id ?? null;
}
function requireUserDataKey(userId: string): Buffer {
return DataCrypto.validateUserAccess(userId);
}
function decryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return row;
return DataCrypto.decryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
function encryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = requireUserDataKey(userId);
return DataCrypto.encryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
export function stripWritePayload(
entityType: SyncEntityType,
payload: Record<string, unknown>,
): Record<string, unknown> {
const { readOnlyFields } = entityConfig(entityType);
const clean = { ...payload };
delete clean.id;
delete clean.userId;
delete clean.syncId;
for (const field of readOnlyFields) delete clean[field];
return clean;
}
/**
* @openapi
* /sync/entity-types:
* get:
* summary: List the entity types this server syncs, in dependency order
* description: The desktop app syncs the entities in the order returned here, because an entity referenced by another has to exist on the far side first. A server that predates this endpoint returns 404 and the client falls back to its built-in list.
* tags:
* - Sync
* responses:
* 200:
* description: The ordered entity types.
* 500:
* description: Failed to list entity types.
*/
router.get(
"/entity-types",
authenticateJWT,
async (_req: Request, res: Response) => {
try {
registerCoreSyncEntities();
return res.json({
entityTypes: listEntities().map((entity) => ({
type: entity.type,
order: entity.order,
singleton: entity.singleton === true,
})),
});
} catch (error) {
databaseLogger.error("Failed to list sync entity types", error, {
operation: "sync_entity_types",
});
return res.status(500).json({ error: "Failed to list entity types" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* get:
* summary: Pull synced rows for an entity type
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Rows updated since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch rows.
*/
router.get(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const { table, singleton, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
conditions.push(
timestampAtOrAfter((table as typeof hosts).updatedAt, since),
);
}
const rows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(and(...conditions));
const synced = (rows as Record<string, unknown>[]).filter(shouldSync);
const decrypted = await Promise.all(
synced.map(async (row) => {
const result = await serializeSyncReferences(
entityType,
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
);
if (entityType === "hosts") {
result.pluginSettings = await exportHostPluginSettings(
row.id as number,
);
}
return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
}),
);
res.json({ rows: orderSyncRows(entityType, decrypted) });
} catch (err) {
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
operation: "sync_pull",
entityType,
userId,
});
res.status(500).json({ error: "Failed to fetch rows" });
}
},
);
/**
* @openapi
* /sync/tombstones:
* post:
* summary: Report a deletion from the other side of a sync pair
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
* tags:
* - Sync
* responses:
* 200:
* description: Deletion applied (or row already absent).
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to apply deletion.
*/
router.post(
"/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.body?.entityType;
const syncId = req.body?.syncId;
if (
!isValidEntityType(entityType) ||
typeof syncId !== "string" ||
!syncId
) {
return res.status(400).json({ error: "Missing entityType or syncId" });
}
try {
const { table, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);
const [target] = (await context.drizzle
.select()
.from(table as typeof hosts)
.where(locateRow)
.limit(1)) as Record<string, unknown>[];
if (target && !shouldSync(target)) {
return res.status(400).json({ error: "This row is not synced" });
}
await context.drizzle.delete(table as typeof hosts).where(locateRow);
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to apply sync tombstone", err, {
operation: "sync_tombstone_apply",
entityType,
userId,
});
res.status(500).json({ error: "Failed to apply deletion" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* post:
* summary: Upsert a synced row by syncId
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Row upserted.
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to upsert row.
*/
router.post(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const payload = req.body?.row;
const syncId = payload?.syncId;
if (!payload || typeof syncId !== "string" || !syncId) {
return res.status(400).json({ error: "Missing row.syncId" });
}
try {
// singleton is still needed below: those tables have no sync_id column
// for the insert to populate.
const { table, singleton, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);
if (!shouldSync(payload)) {
return res.status(400).json({ error: "This row is not synced" });
}
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(locateRow)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
if (existing && !shouldSync(existing)) {
return res.status(400).json({ error: "This row is not synced" });
}
const resolvedPayload = await deserializeSyncReferences(
entityType,
payload,
(referenceType, referenceSyncId) =>
findReferenceId(context, referenceType, referenceSyncId, userId),
);
if (
entityType === "hosts" &&
typeof resolvedPayload.parentHostId === "number"
) {
const parentError = await validateParentHostId(
userId,
typeof existing?.id === "number" ? existing.id : null,
resolvedPayload.parentHostId,
);
if (parentError) return res.status(400).json({ error: parentError });
}
const carriedPluginSettings = resolvedPayload.pluginSettings;
delete resolvedPayload.pluginSettings;
const writePayload = stripWritePayload(entityType, resolvedPayload);
const encryptedPayload = encryptIfNeeded(
entityType,
writePayload,
userId,
);
// Through the returning helpers, because MySQL has no RETURNING and a
// bare .returning() throws there.
let resultRow: Record<string, unknown>;
if (existing) {
const updatedRows = await updateReturning(
context,
table as typeof hosts,
encryptedPayload,
locateRow,
);
resultRow = updatedRows[0] as Record<string, unknown>;
} else {
const insertedRows = await insertReturningWhere(
context,
table as typeof hosts,
(singleton
? { ...encryptedPayload, userId }
: {
...encryptedPayload,
userId,
syncId,
}) as typeof hosts.$inferInsert,
locateRow,
);
resultRow = insertedRows[0] as Record<string, unknown>;
}
if (entityType === "hosts" && typeof resultRow?.id === "number") {
await importHostPluginSettings(resultRow.id, carriedPluginSettings);
}
await DatabaseSaveTrigger.forceSave("sync_upsert");
res.json({
row: decryptIfNeeded(entityType, resultRow, userId),
created: !existing,
});
} catch (err) {
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
operation: "sync_upsert",
entityType,
userId,
});
res.status(500).json({ error: "Failed to upsert row" });
}
},
);
/**
* @openapi
* /sync/{entityType}/tombstones:
* get:
* summary: Pull deletion tombstones for an entity type
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Tombstones recorded since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch tombstones.
*/
router.get(
"/:entityType/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
userId,
entityType,
since,
);
res.json({ tombstones });
} catch (err) {
databaseLogger.error(
`Failed to fetch sync tombstones for ${entityType}`,
err,
{ operation: "sync_tombstones_pull", entityType, userId },
);
res.status(500).json({ error: "Failed to fetch tombstones" });
}
},
);
export default router;
+27 -1
View File
@@ -8,6 +8,7 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import {
allowsDesktopAutoSession,
isLoopbackRequest,
extractBearerOrCookieToken,
isNativeTokenExportRequest,
@@ -545,6 +546,30 @@ router.post("/logout", authenticateJWT, async (req, res) => {
}
});
/**
* On a desktop linked to a server, the account it is signed in to there.
* That account is who the user is; the local profile only mirrors it.
*/
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;
return {
serverUrl: link.serverUrl,
serverName: link.serverName,
username: link.account?.username ?? link.remoteUsername,
isAdmin: !!link.account?.isAdmin,
roles: link.account?.roles ?? [],
permissions: link.account?.permissions ?? [],
status: link.status,
};
} catch {
return null;
}
}
/**
* @openapi
* /users/me:
@@ -595,6 +620,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
user.id,
),
show_donation_modal: showDonationModal,
linked: await describeDesktopLink(user.id),
});
} catch (err) {
authLogger.error("Failed to get username", err);
@@ -728,7 +754,7 @@ router.get("/setup-required", async (req, res) => {
*/
router.post("/internal/auto-session", async (req, res) => {
try {
if (!isLoopbackRequest(req)) {
if (!allowsDesktopAutoSession() || !isLoopbackRequest(req)) {
authLogger.warn(
"Rejected non-loopback attempt to access auto-session endpoint",
{ source: req.ip },
-35
View File
@@ -1,35 +0,0 @@
import { sql, type SQLWrapper } from "drizzle-orm";
/**
* Sync cursors and stored timestamps do not share a layout.
*
* `updated_at` and `deleted_at` are TEXT columns written both by
* `default(sql`CURRENT_TIMESTAMP`)` ("2026-07-29 10:11:21") and by
* `new Date().toISOString()` ("2026-07-29T10:11:21.123Z"), while the desktop
* sync engine always sends the ISO form as `since`. Comparing those as text is
* decided at position 10, where ' ' (0x20) sorts below 'T' (0x54), so the
* answer depends on which writer produced the row rather than on when it was
* written -- and `column > :isoCursor` is false for every row stored in the
* CURRENT_TIMESTAMP form, however new it is.
*
* Both layouts share a prefix once the separator is levelled, so comparing
* "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace` and
* `substr` are used rather than `datetime()` to keep the expression portable
* across engines.
*/
const CANONICAL_TIMESTAMP_LENGTH = 19;
export function normalizeSyncTimestamp(value: string): string {
return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH);
}
/**
* `>=` rather than `>`: normalising truncates sub-second precision, so a strict
* comparison would permanently skip rows written in the same second as the
* cursor. Re-sending that boundary second costs nothing -- the sync engine only
* pushes a row when one side is strictly newer, so rows equal on both sides are
* a no-op.
*/
export function timestampAtOrAfter(column: SQLWrapper, since: string) {
return sql`substr(replace(${column}, 'T', ' '), 1, ${CANONICAL_TIMESTAMP_LENGTH}) >= ${normalizeSyncTimestamp(since)}`;
}
+56
View File
@@ -0,0 +1,56 @@
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
} from "../database/repositories/factory.js";
/**
* Deletes a credential the user owns: hosts using it fall back to password
* auth with no secret, and shares are re-snapshotted. Shared by the delete
* route and sync. Null when it does not exist or is not the user's.
*/
export async function deleteOwnedCredential(
userId: string,
credentialId: number,
): Promise<{ name: string | null } | null> {
const credential =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (!credential) return null;
const hostsUsingCredential =
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
userId,
credentialId,
);
if (hostsUsingCredential.length > 0) {
await createCurrentHostRepository().updateManyForUser(
userId,
hostsUsingCredential.map((host) => host.id),
{
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: "password",
},
);
}
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
await sharedSecretsManager.deleteForCredential(credentialId);
await createCurrentCredentialRepository().deleteForUser(userId, credentialId);
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
for (const host of hostsUsingCredential) {
await sharedSecretsManager.resyncHost(host.id);
}
return { name: (credential.name as string | null) ?? null };
}
-9
View File
@@ -4,7 +4,6 @@ import {
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSyncTombstoneRepository,
} from "../database/repositories/factory.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { sshLogger } from "../utils/logger.js";
@@ -38,14 +37,6 @@ export async function deleteOwnedHost(
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
await createCurrentHostRepository().deleteForUser(userId, hostId);
if (host.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"hosts",
host.syncId,
);
}
const name = host.name ?? host.ip;
emitInternalEvent("host_deleted", userId, hostId, { name });
+33
View File
@@ -0,0 +1,33 @@
/**
* Alerts core itself sends (an update is out, a desktop lost its link),
* through whichever plugin serves as the alert hub. With no hub running they
* reach nobody, same as a plugin's.
*/
import type { PluginNotification } from "@termix/plugin-sdk/backend";
import { activeNotifyHub } from "../plugins/notify-hub.js";
import { resolveAudience } from "../plugins/ctx-notify.js";
import { systemLogger } from "../utils/logger.js";
/** Shown as the sender of core's alerts. */
export const CORE_ALERT_SOURCE = "termix";
export async function sendCoreAlert(
notification: PluginNotification & {
audience: NonNullable<PluginNotification["audience"]>;
},
): Promise<void> {
try {
const hub = await activeNotifyHub();
if (!hub) return;
const recipients = await resolveAudience(notification.audience);
if (recipients.length === 0) return;
await hub.deliver({ source: CORE_ALERT_SOURCE, recipients, notification });
} catch (error) {
systemLogger.warn("Could not send an alert", {
operation: "core_alert",
category: notification.category,
error: error instanceof Error ? error.message : String(error),
});
}
}
-10
View File
@@ -488,16 +488,6 @@ export function createPluginContext(
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
recordTombstone: async (userId, entityType, syncId) => {
if (!syncId) return;
const { createCurrentSyncTombstoneRepository } =
await import("../database/repositories/factory.js");
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
},
},
kv: {
+44
View File
@@ -478,6 +478,50 @@ export async function deactivatePlugin(pluginId: string): Promise<void> {
}
}
/** Switches a plugin on or off and records it, the same as the admin route. */
export async function setPluginEnabled(
pluginId: string,
enabled: boolean,
): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginRepository().update(pluginId, {
state: enabled ? "enabled" : "disabled",
lastError: null,
});
if (enabled) await activatePlugin(pluginId);
else await deactivatePlugin(pluginId);
}
/**
* Loads a user plugin from a .tmxplug in the plugins directory and gives it
* a row. It starts disabled, like any plugin that arrives on disk. A copy
* already loaded under the same id is stopped and replaced.
*/
export async function installPluginArtifact(
file: string,
): Promise<LoadedPlugin> {
const { loader: pluginLoader } = getPluginRuntime();
const bundledIds = new Set(
pluginLoader
.list()
.filter((plugin) => plugin.source === "bundled")
.map((plugin) => plugin.id),
);
const plugin = await pluginLoader.loadArtifact(file, bundledIds);
await seedPlugins([plugin]);
await syncCapabilityGrants([plugin]);
return plugin;
}
/** Stops a plugin and forgets it, before a new version is installed. */
export async function unloadPlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
if (!pluginLoader.get(pluginId)) return;
await deactivatePlugin(pluginId);
pluginLoader.forget(pluginId);
}
export async function shutdownPlugins(): Promise<void> {
if (!loader) return;
for (const plugin of loader.list()) unregisterPluginHttp(plugin.id);
+9
View File
@@ -91,6 +91,15 @@ export class PluginLoader {
return this.plugins.get(pluginId);
}
/** Drops a stopped plugin from the list, so a new copy can load in its place. */
forget(pluginId: string): void {
const plugin = this.plugins.get(pluginId);
if (plugin?.state === "active" || plugin?.state === "activating") {
throw new Error(`Plugin ${pluginId} is still running`);
}
this.plugins.delete(pluginId);
}
/** Reads and validates one plugin directory. Starts nothing. */
async load(dir: string, source: PluginSource): Promise<LoadedPlugin> {
const manifestPath = getPluginManifestPath(dir);
+84 -17
View File
@@ -1,20 +1,55 @@
/**
* Which entities remote sync carries.
* Which entities sync between a linked desktop and its server.
*
* Core registers its ten at boot and a plugin registers its own through
* ctx.sync, so the list is built rather than hardcoded in four places. The
* wire names are unchanged: existing tombstones and rows on the other side of
* a pair match on those strings, so renaming one would orphan data.
* Core registers its own at boot and a plugin registers its own through
* ctx.sync, so the list is built rather than hardcoded. The wire names are
* part of the protocol: records on both sides match on them, so renaming one
* orphans data.
*
* Order is dependency order, not declaration order. A row referencing another
* entity is resolved by that entity's syncId, so the target has to exist on
* the far side first. That is why every registration carries an explicit
* order rather than relying on registration sequence.
* entity is resolved by that entity's syncId, so the target has to be written
* first. That is why every registration carries an explicit order.
*/
import type { SyncEntityRegistration } from "@termix/plugin-sdk/backend";
import type {
SyncEntityRegistration,
SyncRow,
} from "@termix/plugin-sdk/backend";
export interface RegisteredSyncEntity extends SyncEntityRegistration {
/** Core-only extras a plugin registration cannot set. */
export interface CoreSyncEntityOptions {
/** Server-to-desktop only. A desktop never pushes it. */
readOnly?: boolean;
/**
* Builds the user's rows instead of reading `table`, for data that is not
* a table row the user owns (hosts shared with them, plugin settings).
* Rows come back already on the wire: plaintext, with a syncId.
*/
load?: (userId: string) => Promise<SyncRow[]>;
/** Writes an inbound row, for an entity with `load`. */
write?: (userId: string, wire: SyncRow) => Promise<void>;
/** Deletes a row by syncId, for an entity with `load`. */
erase?: (userId: string, syncId: string) => Promise<void>;
/**
* Whether `load` would have returned this syncId if it existed. A row
* `load` cannot see right now (its plugin is off) is not a delete.
*/
covers?: (syncId: string) => boolean;
/** Keys kept off the content hash and out of writes. */
hashIgnore?: readonly string[];
/**
* Widens reference lookups past rows the user owns, e.g. a credential
* shared with them is a valid target for their host's credentialId.
*/
canReference?: (
userId: string,
ownerId: string,
id: number,
) => Promise<boolean>;
}
export interface RegisteredSyncEntity
extends SyncEntityRegistration, CoreSyncEntityOptions {
/** Which plugin registered it, or "core". */
owner: string;
order: number;
@@ -24,6 +59,7 @@ export interface RegisteredSyncEntity extends SyncEntityRegistration {
}
const entities = new Map<string, RegisteredSyncEntity>();
const listeners = new Set<() => void>();
/** Core's own entities register under this owner. */
export const CORE_OWNER = "core";
@@ -31,6 +67,7 @@ export const CORE_OWNER = "core";
export function registerEntity(
owner: string,
entity: SyncEntityRegistration,
core: CoreSyncEntityOptions = {},
): () => void {
if (!entity.type) {
throw new Error("A sync entity needs a type");
@@ -43,25 +80,35 @@ export function registerEntity(
);
}
entities.set(entity.type, {
const registered: RegisteredSyncEntity = {
...entity,
...(owner === CORE_OWNER ? core : {}),
owner,
order: entity.order ?? 100,
userColumn: entity.userColumn ?? "userId",
readOnlyFields: entity.readOnlyFields ?? [],
encryptedFields: entity.encryptedFields ?? [],
});
};
entities.set(entity.type, registered);
notify();
return () => {
const current = entities.get(entity.type);
// Identity-checked: a plugin that restarted must not revoke the
// registration its own restart installed.
if (current && current.owner === owner) entities.delete(entity.type);
if (entities.get(entity.type) === registered) {
entities.delete(entity.type);
notify();
}
};
}
export function getEntity(type: string): RegisteredSyncEntity | undefined {
return entities.get(type);
const direct = entities.get(type);
if (direct) return direct;
for (const entity of entities.values()) {
if (entity.answersTo?.includes(type)) return entity;
}
return undefined;
}
export function hasEntity(type: string): boolean {
@@ -76,14 +123,34 @@ export function listEntities(): RegisteredSyncEntity[] {
});
}
/** The ordered wire names, which is what the Electron client syncs by. */
export function listEntityTypes(): string[] {
return listEntities().map((entity) => entity.type);
}
export function unregisterByOwner(owner: string): void {
let changed = false;
for (const [type, entity] of [...entities]) {
if (entity.owner === owner) entities.delete(type);
if (entity.owner === owner) {
entities.delete(type);
changed = true;
}
}
if (changed) notify();
}
/** Called whenever the set of entities changes (a plugin came or went). */
export function onEntitiesChanged(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notify(): void {
for (const listener of listeners) {
try {
listener();
} catch {
// A listener's failure is its own problem.
}
}
}
+16
View File
@@ -277,6 +277,22 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
startAnalyticsHeartbeat();
// After plugins, so their sync entities are registered before a pass.
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",
error: getErrorMessage(error),
});
}
}
systemLogger.success("Termix backend started successfully", {
operation: "backend_init_complete",
port: process.env.PORT || 4090,
+36
View File
@@ -0,0 +1,36 @@
import {
isElectronIpcAvailable,
requestFromElectronMain,
} from "../../utils/electron-ipc-bridge.js";
import type { SyncLink } from "./link-store.js";
/**
* Chromium's own requests to the linked server (the sign-in page, remote
* terminals, file transfers) cannot set headers themselves, so Electron's
* main process adds the proxy headers and answers basic auth for that one
* origin. Sent again whenever the link changes; null clears it.
*/
export async function applyElectronProxyConfig(
link:
| (Pick<
SyncLink,
"serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate"
> & { sessionToken?: string | null; status?: string })
| null,
): Promise<void> {
if (!isElectronIpcAvailable()) return;
await requestFromElectronMain(
"sync-proxy-config",
link
? {
origin: new URL(link.serverUrl).origin,
serverUrl: link.serverUrl,
token:
link.status === "signed_out" ? null : (link.sessionToken ?? null),
headers: link.customHeaders ?? [],
basicAuth: link.basicAuth ?? null,
allowInvalidCertificate: !!link.allowInvalidCertificate,
}
: null,
);
}
+758
View File
@@ -0,0 +1,758 @@
/**
* The linked desktop's side of sync.
*
* A pass pushes local edits, then pulls what changed on the server after the
* saved cursor, then backfills any entity type this device has never pulled
* (first link, a feature switched on, a category turned back on). Local
* edits are found the same way the server finds changes: by comparing each
* row's hash with the one recorded at the last sync.
*/
import os from "os";
import type { SyncRow } from "@termix/plugin-sdk/backend";
import {
getEntity,
listEntities,
onEntitiesChanged,
type RegisteredSyncEntity,
} from "../../plugins/sync-registry.js";
import { syncLogger } from "../../utils/logger.js";
import { sendCoreAlert } from "../../notify/core-notify.js";
import { registerCoreSyncEntities } from "../entities.js";
import {
createResolvers,
deleteStoredRow,
loadWireRow,
loadWireRows,
writeWireRow,
} from "../store.js";
import {
listRecords,
putRecord,
recordKey,
saveConflict,
setRecordError,
type SyncRecord,
} from "../records.js";
import { orderSelfReferences } from "../wire.js";
import { onLocalWrite } from "../server/change-watcher.js";
import { getLink, updateLink, type SyncLink } from "./link-store.js";
import { RemoteError, remoteFetch, remoteJson } from "./http.js";
import { mirrorPlugins } from "./plugins.js";
import { applyElectronProxyConfig } from "./electron-proxy.js";
export interface RemoteChange {
seq: number;
entityType: string;
syncId: string;
revision: number;
deleted: boolean;
row: SyncRow | null;
}
interface ChangesPage {
changes: RemoteChange[];
nextCursor: number;
hasMore: boolean;
entityTypes: Array<{
type: string;
owner: string;
order: number;
readOnly: boolean;
}>;
}
interface PushOp {
entityType: string;
syncId: string;
baseRevision: number;
op: "upsert" | "delete";
row?: SyncRow;
}
type PushResult =
| { 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 };
const MAX_BATCH_OPS = 200;
const MAX_BATCH_BYTES = 1_000_000;
const MAX_PAGES = 500;
const SAFETY_INTERVAL_MS = 5 * 60_000;
const REFRESH_CHECK_MS = 24 * 60 * 60_000;
const DELETED_HASH = "deleted";
export interface EngineState {
running: boolean;
/** Local changes found in the last pass that the server has not taken. */
pending: number;
/** Entity types the server runs, from the last pull. */
serverTypes: Array<{ type: string; owner: string; readOnly: boolean }>;
lastPassAt: string | null;
}
/** An entity this device could sync: registered here, and on the server. */
function syncable(
entity: RegisteredSyncEntity,
link: SyncLink,
serverTypes: Set<string> | null,
): boolean {
if (link.disabledTypes.includes(entity.type)) return false;
if (serverTypes && !serverTypes.has(entity.type)) return false;
return true;
}
function sortChanges(changes: RemoteChange[]): RemoteChange[] {
const byType = new Map<string, RemoteChange[]>();
for (const change of changes) {
const list = byType.get(change.entityType) ?? [];
list.push(change);
byType.set(change.entityType, list);
}
const types = [...byType.keys()].sort(
(a, b) => (getEntity(a)?.order ?? 1e6) - (getEntity(b)?.order ?? 1e6),
);
const out: RemoteChange[] = [];
for (const type of types) {
const list = byType.get(type)!;
const entity = getEntity(type);
// Only the latest record of each row matters.
const latest = new Map<string, RemoteChange>();
for (const change of list) latest.set(change.syncId, change);
const live = [...latest.values()].filter((change) => !change.deleted);
const gone = [...latest.values()].filter((change) => change.deleted);
if (entity) {
const bySyncId = new Map(live.map((change) => [change.syncId, change]));
const ordered = orderSelfReferences(
entity,
live.map((change) => ({
...(change.row ?? {}),
syncId: change.syncId,
})),
);
out.push(...ordered.map((row) => bySyncId.get(row.syncId as string)!));
} else {
out.push(...live);
}
out.push(...gone);
}
return out;
}
export class SyncEngine {
private running = false;
private again = false;
private timer: NodeJS.Timeout | null = null;
private safety: NodeJS.Timeout | null = null;
private backoffMs = 0;
private events: AbortController | null = null;
private eventsFor: string | null = null;
private lastRefreshCheck = 0;
private serverTypes: Set<string> | null = null;
private stopFns: Array<() => void> = [];
private alerted = false;
readonly state: EngineState = {
running: false,
pending: 0,
serverTypes: [],
lastPassAt: null,
};
start(): void {
if (this.safety) return;
registerCoreSyncEntities();
this.safety = setInterval(() => this.request(0), SAFETY_INTERVAL_MS);
this.safety.unref?.();
this.stopFns.push(onLocalWrite(() => this.request(2000)));
// A plugin that came up may bring entity types to backfill.
this.stopFns.push(onEntitiesChanged(() => this.request(3000)));
this.request(3000);
}
stop(): void {
if (this.safety) clearInterval(this.safety);
if (this.timer) clearTimeout(this.timer);
this.safety = this.timer = null;
this.closeEvents();
for (const stop of this.stopFns.splice(0)) stop();
}
/** Runs a pass after `delayMs`, folding requests that arrive meanwhile. */
request(delayMs = 0): void {
if (this.timer) {
if (delayMs > 0) return;
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.timer = null;
void this.run();
}, delayMs);
this.timer.unref?.();
}
/** Runs a pass now and waits for it. */
async syncNow(): Promise<void> {
if (this.running) {
this.again = true;
while (this.running) await new Promise((r) => setTimeout(r, 100));
return;
}
await this.run();
}
/** Forgets what the server runs, after a relink or unlink. */
reset(): void {
this.serverTypes = null;
this.state.serverTypes = [];
this.state.pending = 0;
this.alerted = false;
this.backoffMs = 0;
this.closeEvents();
}
private async run(): Promise<void> {
if (this.running) {
this.again = true;
return;
}
this.running = this.state.running = true;
try {
await this.pass();
} finally {
this.running = this.state.running = false;
this.state.lastPassAt = new Date().toISOString();
if (this.again) {
this.again = false;
this.request(0);
}
}
}
private async pass(): Promise<void> {
const link = await getLink();
if (!link?.sessionToken) {
this.closeEvents();
return;
}
await updateLink({ status: "syncing" });
try {
await this.refreshSession(link);
const fresh = (await getLink())!;
await mirrorPlugins(fresh);
// A first pass pulls before it pushes, so rows both sides already
// have are matched up instead of sent back as edits.
const firstPass = fresh.knownTypes.length === 0;
if (!firstPass) {
await this.push(fresh);
await this.pull(fresh);
}
await this.backfill((await getLink())!);
if (firstPass) await this.push((await getLink())!);
this.backoffMs = 0;
this.alerted = false;
await updateLink({
status: "idle",
lastError: null,
lastSyncAt: new Date().toISOString(),
});
const done = (await getLink())!;
await applyElectronProxyConfig(done).catch(() => {});
this.openEvents(done);
} catch (error) {
await this.fail(error);
}
}
private async fail(error: unknown): Promise<void> {
const remote = error instanceof RemoteError ? error : null;
const message = error instanceof Error ? error.message : String(error);
syncLogger.warn("Sync pass failed", {
operation: "sync_pass",
kind: remote?.kind ?? "internal",
error: message,
});
if (remote?.kind === "signed_out") {
const link = await updateLink({
status: "signed_out",
lastError: message,
});
await applyElectronProxyConfig(link).catch(() => {});
this.closeEvents();
if (!this.alerted) {
this.alerted = true;
await sendCoreAlert({
title: "Sync is paused",
body: "This device was signed out of the server it syncs with. Sign in again from Sync to keep going.",
severity: "warning",
category: "termix.sync.signed_out",
dedupeKey: "sync:signed_out",
audience: "admins",
link: { tab: "sync" },
});
}
return;
}
const offline = remote?.kind === "offline";
await updateLink({
status: offline ? "offline" : "error",
lastError: message,
});
this.backoffMs = Math.min(
this.backoffMs ? this.backoffMs * 2 : 15_000,
10 * 60_000,
);
this.request(this.backoffMs);
}
private async refreshSession(link: SyncLink): Promise<void> {
if (Date.now() - this.lastRefreshCheck < REFRESH_CHECK_MS) return;
const result = await remoteJson<{ token: string | null }>(
link,
"/sync/v2/session/refresh",
{ method: "POST", body: {} },
);
this.lastRefreshCheck = Date.now();
if (result.token) await updateLink({ sessionToken: result.token });
}
/** Local rows the server does not have in this form yet. */
async outbox(link: SyncLink): Promise<{
ops: PushOp[];
hashes: Map<string, string>;
}> {
const records = await listRecords(link.userId);
const resolvers = createResolvers(link.userId);
const ops: PushOp[] = [];
const hashes = new Map<string, string>();
for (const entity of listEntities()) {
if (entity.readOnly) continue;
if (!link.knownTypes.includes(entity.type)) continue;
if (!syncable(entity, link, this.serverTypes)) continue;
const { rows, failed } = await loadWireRows(
entity,
link.userId,
resolvers,
);
for (const row of rows.values()) {
const key = recordKey(entity.type, row.syncId);
const record = records.get(key);
if (record && !record.deleted && record.hash === row.hash) continue;
if (record?.errorHash === row.hash) continue;
ops.push({
entityType: entity.type,
syncId: row.syncId,
baseRevision: record?.revision ?? 0,
op: "upsert",
row: row.wire,
});
hashes.set(key, row.hash);
}
for (const record of records.values()) {
if (record.entityType !== entity.type || record.deleted) continue;
if (record.revision === 0) continue;
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
if (entity.covers && !entity.covers(record.syncId)) continue;
if (record.errorHash === DELETED_HASH) continue;
ops.push({
entityType: entity.type,
syncId: record.syncId,
baseRevision: record.revision,
op: "delete",
});
}
}
return { ops, hashes };
}
private async push(link: SyncLink): Promise<void> {
const { ops, hashes } = await this.outbox(link);
this.state.pending = ops.length;
if (ops.length === 0) return;
for (const batch of batches(ops)) {
const { results } = await remoteJson<{ results: PushResult[] }>(
link,
"/sync/v2/push",
{ method: "POST", body: { ops: batch }, timeoutMs: 120_000 },
);
const byKey = new Map(
batch.map((op) => [recordKey(op.entityType, op.syncId), op]),
);
for (const result of results) {
const key = recordKey(result.entityType, result.syncId);
const op = byKey.get(key);
if (!op) continue;
await this.settle(link, op, result, hashes.get(key) ?? null);
}
this.state.pending = Math.max(0, this.state.pending - batch.length);
}
}
private async settle(
link: SyncLink,
op: PushOp,
result: PushResult,
hash: string | null,
): Promise<void> {
const userId = link.userId;
const entity = getEntity(op.entityType);
if (!entity) return;
if (result.status === "ok") {
await putRecord(userId, op.entityType, op.syncId, {
revision: result.revision,
hash: op.op === "delete" ? null : hash,
deleted: op.op === "delete",
});
return;
}
if (result.status === "rejected") {
await setRecordError(
userId,
op.entityType,
op.syncId,
result.reason,
hash ?? DELETED_HASH,
);
return;
}
// Conflict: the server moved on since this device last saw the row. Its
// version wins here, and a local edit it replaced is kept for the user.
if (op.op === "upsert" && op.row) {
await saveConflict(
userId,
op.entityType,
op.syncId,
op.row,
result.revision,
);
}
const resolvers = createResolvers(userId);
if (result.deleted || !result.row) {
await deleteStoredRow(entity, userId, op.syncId);
await putRecord(userId, op.entityType, op.syncId, {
revision: result.revision,
hash: null,
deleted: true,
});
return;
}
await writeWireRow(
entity,
userId,
{ ...result.row, syncId: op.syncId },
resolvers,
);
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
await putRecord(userId, op.entityType, op.syncId, {
revision: result.revision,
hash: written?.hash ?? null,
deleted: false,
});
}
private async fetchChanges(
link: SyncLink,
cursor: number,
types?: string[],
): Promise<{ changes: RemoteChange[]; cursor: number }> {
const all: RemoteChange[] = [];
let next = cursor;
for (let page = 0; page < MAX_PAGES; page++) {
const query = new URLSearchParams({
cursor: String(next),
limit: "1000",
});
if (types?.length) query.set("types", types.join(","));
const result = await remoteJson<ChangesPage>(
link,
`/sync/v2/changes?${query.toString()}`,
{ timeoutMs: 120_000 },
);
all.push(...result.changes);
next = result.nextCursor;
this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type));
this.state.serverTypes = result.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
if (!result.hasMore) break;
}
return { changes: all, cursor: next };
}
private async pull(link: SyncLink): Promise<void> {
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
const known = new Set(link.knownTypes);
await this.apply(
link,
changes.filter(
(change) =>
known.has(change.entityType) &&
!link.disabledTypes.includes(change.entityType),
),
false,
);
await updateLink({ cursor });
}
/** Pulls every row of types this device has not pulled before. */
private async backfill(link: SyncLink): Promise<void> {
if (!this.serverTypes) {
await this.fetchChanges(link, Number.MAX_SAFE_INTEGER);
}
const fresh = listEntities()
.map((entity) => entity.type)
.filter(
(type) =>
!link.knownTypes.includes(type) &&
!link.disabledTypes.includes(type) &&
this.serverTypes?.has(type),
);
if (fresh.length === 0) return;
const { changes, cursor } = await this.fetchChanges(link, 0, fresh);
await this.apply(link, changes, true);
const current = (await getLink())!;
await updateLink({
knownTypes: [...new Set([...current.knownTypes, ...fresh])],
// On a first pass the backfill is the whole pull, so it sets the cursor.
...(link.knownTypes.length === 0 ? { cursor } : {}),
});
}
/**
* Writes pulled changes. `adopt` is for a first pull of a type: a local row
* with no record yet takes the server's version without a conflict.
*/
private async apply(
link: SyncLink,
changes: RemoteChange[],
adopt: boolean,
): Promise<void> {
if (changes.length === 0) return;
const userId = link.userId;
const records = await listRecords(userId);
let missed = false;
const resolvers = createResolvers(userId, () => {
missed = true;
});
const retry: RemoteChange[] = [];
for (const change of sortChanges(changes)) {
const entity = getEntity(change.entityType);
if (!entity) continue;
const key = recordKey(change.entityType, change.syncId);
const record = records.get(key);
if (record && record.revision >= change.revision) continue;
try {
if (!entity.readOnly) {
await this.keepLocalEdit(
entity,
userId,
change,
record,
adopt,
resolvers,
);
}
if (change.deleted) {
await deleteStoredRow(entity, userId, change.syncId);
await putRecord(userId, change.entityType, change.syncId, {
revision: change.revision,
hash: null,
deleted: true,
});
continue;
}
missed = false;
await writeWireRow(
entity,
userId,
{ ...(change.row ?? {}), syncId: change.syncId },
resolvers,
);
if (missed) retry.push(change);
await this.recordApplied(entity, userId, change, resolvers);
} catch (error) {
syncLogger.warn("Could not apply a synced change", {
operation: "sync_apply",
entityType: change.entityType,
syncId: change.syncId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Rows that pointed at something written later in the same pull.
for (const change of retry) {
const entity = getEntity(change.entityType)!;
try {
await writeWireRow(
entity,
userId,
{ ...(change.row ?? {}), syncId: change.syncId },
resolvers,
);
await this.recordApplied(entity, userId, change, resolvers);
} catch {
// The first write stands.
}
}
}
private async recordApplied(
entity: RegisteredSyncEntity,
userId: string,
change: RemoteChange,
resolvers: ReturnType<typeof createResolvers>,
): Promise<void> {
const written = entity.readOnly
? null
: await loadWireRow(entity, userId, change.syncId, resolvers);
await putRecord(userId, change.entityType, change.syncId, {
revision: change.revision,
hash: written?.hash ?? null,
deleted: false,
});
}
/** Saves a local edit the incoming change is about to replace. */
private async keepLocalEdit(
entity: RegisteredSyncEntity,
userId: string,
change: RemoteChange,
record: SyncRecord | undefined,
adopt: boolean,
resolvers: ReturnType<typeof createResolvers>,
): Promise<void> {
if (!record && adopt) return;
const local = await loadWireRow(
entity,
userId,
change.syncId,
resolvers,
).catch(() => null);
if (!local) return;
if (record && !record.deleted && record.hash === local.hash) return;
await saveConflict(
userId,
change.entityType,
change.syncId,
local.wire,
change.revision,
);
}
private openEvents(link: SyncLink): void {
const key = `${link.serverUrl}|${link.sessionToken}`;
if (this.events && this.eventsFor === key) return;
this.closeEvents();
const controller = new AbortController();
this.events = controller;
this.eventsFor = key;
void this.listen(link, controller);
}
private closeEvents(): void {
this.events?.abort();
this.events = null;
this.eventsFor = null;
}
private async listen(
link: SyncLink,
controller: AbortController,
): Promise<void> {
try {
const response = await remoteFetch(link, "/sync/v2/events", {
signal: controller.signal,
});
if (!response.ok || !response.body)
throw new Error(`HTTP ${response.status}`);
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of response.body) {
buffer += decoder.decode(chunk as Uint8Array, { stream: true });
let index;
while ((index = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
if (/^event: change$/m.test(block)) this.request(500);
}
}
} catch {
// Dropped or refused: the safety interval still syncs, and the next
// successful pass reopens the stream.
}
if (this.events === controller) {
this.events = null;
this.eventsFor = null;
if (!controller.signal.aborted) {
setTimeout(() => this.request(0), 30_000).unref?.();
}
}
}
/** Where the link's proxy settings have to reach outside the backend. */
async applyProxyConfig(): Promise<void> {
await applyElectronProxyConfig(await getLink());
}
}
function* batches(ops: PushOp[]): Generator<PushOp[]> {
let batch: PushOp[] = [];
let bytes = 0;
for (const op of ops) {
const size = JSON.stringify(op).length;
if (
batch.length > 0 &&
(batch.length >= MAX_BATCH_OPS || bytes + size > MAX_BATCH_BYTES)
) {
yield batch;
batch = [];
bytes = 0;
}
batch.push(op);
bytes += size;
}
if (batch.length > 0) yield batch;
}
let engine: SyncEngine | null = null;
export function getSyncEngine(): SyncEngine {
if (!engine) engine = new SyncEngine();
return engine;
}
/** Starts syncing on a desktop. A server install never links. */
export function startDesktopSync(): void {
if (process.env.ELECTRON_EMBEDDED !== "true") return;
const instance = getSyncEngine();
instance.start();
void instance.applyProxyConfig().catch(() => {});
}
export function defaultDeviceName(): string {
return os.hostname() || "Termix Desktop";
}
+260
View File
@@ -0,0 +1,260 @@
/**
* Requests from a desktop to the server it is linked to.
*
* The Termix session travels as the jwt cookie rather than an Authorization
* header, so a proxy in front that uses basic auth can have that header to
* itself. Proxy headers (a Cloudflare Access service token, a gateway key)
* are added to every request.
*/
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
import type { Dispatcher } from "undici";
import type { BasicAuth, ProxyHeader } from "./link-store.js";
export interface RemoteTarget {
serverUrl: string;
sessionToken?: string | null;
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
}
export type RemoteErrorKind =
/** Could not reach the server at all. */
| "offline"
/** TLS failed and the certificate is not allowed. */
| "tls"
/** A proxy answered instead of Termix (its login page, a 404 page). */
| "proxy"
/** The proxy wants basic auth. */
| "basic_auth"
/** Termix refused the session: revoked, expired or signed out. */
| "signed_out"
/** Termix answered with an error. */
| "server";
export class RemoteError extends Error {
constructor(
readonly kind: RemoteErrorKind,
message: string,
readonly status?: number,
) {
super(message);
}
}
const TLS_CODES = new Set([
"DEPTH_ZERO_SELF_SIGNED_CERT",
"SELF_SIGNED_CERT_IN_CHAIN",
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
"CERT_HAS_EXPIRED",
"ERR_TLS_CERT_ALTNAME_INVALID",
"CERT_UNTRUSTED",
]);
function envProxy(url: string): string | null {
const proxy =
process.env.https_proxy ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.HTTP_PROXY;
if (!proxy) return null;
const hostname = new URL(url).hostname.toLowerCase();
const noProxy = (process.env.no_proxy || process.env.NO_PROXY || "")
.split(",")
.map((entry) =>
entry
.trim()
.toLowerCase()
.replace(/^\*?\./, ""),
)
.filter(Boolean);
if (
noProxy.some(
(entry) => hostname === entry || hostname.endsWith(`.${entry}`),
)
)
return null;
return proxy;
}
const dispatchers = new Map<string, Dispatcher>();
function dispatcherFor(target: RemoteTarget): Dispatcher {
const insecure = !!target.allowInvalidCertificate;
const proxy = envProxy(target.serverUrl);
const key = `${insecure}|${proxy ?? ""}`;
let dispatcher = dispatchers.get(key);
if (!dispatcher) {
const tls = insecure ? { rejectUnauthorized: false } : {};
dispatcher = proxy
? new ProxyAgent({ uri: proxy, requestTls: tls })
: new Agent({ connect: tls });
dispatchers.set(key, dispatcher);
}
return dispatcher;
}
/** "https://x.example/termix/" + "/sync/v2/info" keeps the base path. */
export function remoteUrl(serverUrl: string, pathname: string): string {
return `${serverUrl.replace(/\/+$/, "")}${pathname}`;
}
export function normalizeServerUrl(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (
/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) &&
!/^https?:\/\//i.test(trimmed)
) {
return null;
}
const withScheme = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
const url = new URL(withScheme);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
url.hash = "";
url.search = "";
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
export function requestHeaders(target: RemoteTarget): Record<string, string> {
const headers: Record<string, string> = {
"X-Electron-App": "true",
"User-Agent": "Termix-Desktop-Sync",
};
for (const header of target.customHeaders ?? []) {
if (header.name && header.value) headers[header.name] = header.value;
}
if (target.basicAuth?.username) {
headers.Authorization = `Basic ${Buffer.from(
`${target.basicAuth.username}:${target.basicAuth.password ?? ""}`,
).toString("base64")}`;
}
if (target.sessionToken) headers.Cookie = `jwt=${target.sessionToken}`;
return headers;
}
function describeNetworkError(error: unknown): RemoteError {
const cause = (error as { cause?: { code?: string; message?: string } })
?.cause;
const code = cause?.code ?? (error as { code?: string })?.code ?? "";
if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) {
return new RemoteError("tls", cause?.message || code);
}
if ((error as Error)?.name === "AbortError") {
return new RemoteError("offline", "The server did not answer in time");
}
return new RemoteError(
"offline",
cause?.message || (error as Error)?.message || "Could not reach the server",
);
}
export interface RemoteRequest {
method?: string;
body?: unknown;
timeoutMs?: number;
signal?: AbortSignal;
}
/** Sends a request and returns the raw response, classifying failures. */
export async function remoteFetch(
target: RemoteTarget,
pathname: string,
request: RemoteRequest = {},
) {
const url = remoteUrl(target.serverUrl, pathname);
const headers = requestHeaders(target);
if (request.body !== undefined) headers["Content-Type"] = "application/json";
const controller = new AbortController();
const timeout = request.timeoutMs
? setTimeout(() => controller.abort(), request.timeoutMs)
: null;
request.signal?.addEventListener("abort", () => controller.abort(), {
once: true,
});
let response;
try {
response = await undiciFetch(url, {
method: request.method ?? "GET",
headers,
body:
request.body !== undefined ? JSON.stringify(request.body) : undefined,
dispatcher: dispatcherFor(target),
signal: controller.signal,
});
} catch (error) {
throw describeNetworkError(error);
} finally {
if (timeout) clearTimeout(timeout);
}
return response;
}
/** Sends a request and parses a JSON answer from Termix. */
export async function remoteJson<T>(
target: RemoteTarget,
pathname: string,
request: RemoteRequest = {},
): Promise<T> {
const response = await remoteFetch(target, pathname, {
timeoutMs: 60_000,
...request,
});
const type = response.headers.get("content-type") ?? "";
const text = await response.text();
if (!type.includes("application/json")) {
if (
response.status === 401 &&
/basic/i.test(response.headers.get("www-authenticate") ?? "")
) {
throw new RemoteError(
"basic_auth",
"The server asks for a username and password before Termix",
401,
);
}
throw new RemoteError(
"proxy",
`Something other than Termix answered (${response.status})`,
response.status,
);
}
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
throw new RemoteError(
"proxy",
"The server sent invalid JSON",
response.status,
);
}
if (response.status === 401) {
throw new RemoteError(
"signed_out",
(data as { error?: string })?.error || "Signed out",
401,
);
}
if (!response.ok) {
throw new RemoteError(
"server",
(data as { error?: string })?.error || `Server error ${response.status}`,
response.status,
);
}
return data as T;
}
+233
View File
@@ -0,0 +1,233 @@
/**
* 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.
*/
import { eq } from "drizzle-orm";
import { syncLink } from "../../database/db/schema.js";
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import {
decryptSystemSecret,
encryptSystemSecret,
} from "../../utils/system-secret-crypto.js";
export type LinkStatus =
"idle" | "syncing" | "offline" | "error" | "signed_out";
export interface LinkAccount {
remoteUserId?: string;
username?: string;
isAdmin?: boolean;
roles?: string[];
permissions?: string[];
}
export interface ProxyHeader {
name: string;
value: string;
}
export interface BasicAuth {
username: string;
password: string;
}
export interface SyncLink {
id: number;
userId: string;
serverUrl: string;
serverName: string | null;
serverVersion: string | null;
sessionToken: string | null;
customHeaders: ProxyHeader[];
basicAuth: BasicAuth | null;
allowInvalidCertificate: boolean;
remoteUserId: string | null;
remoteUsername: string | null;
account: LinkAccount | null;
/** Entity types switched off on this device. */
disabledTypes: string[];
/** Entity types this device has fully pulled at least once. */
knownTypes: string[];
cursor: number;
status: LinkStatus;
lastError: string | null;
linkedAt: string;
lastSyncAt: string | null;
}
type Row = typeof syncLink.$inferSelect;
function db() {
return createCurrentRepositoryContext().drizzle;
}
function parseJson<T>(value: string | null, fallback: T): T {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
async function decryptJson<T>(value: string | null, fallback: T): Promise<T> {
if (!value) return fallback;
try {
return JSON.parse(await decryptSystemSecret(value)) as T;
} catch {
return fallback;
}
}
async function fromRow(row: Row): Promise<SyncLink> {
let sessionToken: string | null = null;
if (row.sessionToken) {
try {
sessionToken = await decryptSystemSecret(row.sessionToken);
} catch {
sessionToken = null;
}
}
const scope = parseJson<{ disabled?: string[] }>(row.scope, {});
return {
id: row.id,
userId: row.userId,
serverUrl: row.serverUrl,
serverName: row.serverName,
serverVersion: row.serverVersion,
sessionToken,
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
allowInvalidCertificate: !!row.allowInvalidCertificate,
remoteUserId: row.remoteUserId,
remoteUsername: row.remoteUsername,
account: parseJson<LinkAccount | null>(row.account, null),
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
knownTypes: parseJson<string[]>(row.knownTypes, []),
cursor: Number(row.cursor) || 0,
status: (row.status as LinkStatus) || "idle",
lastError: row.lastError,
linkedAt: row.linkedAt,
lastSyncAt: row.lastSyncAt,
};
}
export async function getLink(): Promise<SyncLink | null> {
const [row] = await db().select().from(syncLink).limit(1);
return row ? fromRow(row) : null;
}
export interface NewLink {
userId: string;
serverUrl: string;
serverName?: string | null;
serverVersion?: string | null;
sessionToken: string;
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
remoteUserId?: string | null;
remoteUsername?: string | null;
}
export async function createLink(link: NewLink): Promise<SyncLink> {
await db().delete(syncLink);
await db()
.insert(syncLink)
.values({
userId: link.userId,
serverUrl: link.serverUrl,
serverName: link.serverName ?? null,
serverVersion: link.serverVersion ?? null,
sessionToken: await encryptSystemSecret(link.sessionToken),
customHeaders: link.customHeaders?.length
? await encryptSystemSecret(JSON.stringify(link.customHeaders))
: null,
basicAuth: link.basicAuth
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
: null,
allowInvalidCertificate: !!link.allowInvalidCertificate,
remoteUserId: link.remoteUserId ?? null,
remoteUsername: link.remoteUsername ?? null,
cursor: 0,
status: "idle",
knownTypes: "[]",
linkedAt: new Date().toISOString(),
});
await DatabaseSaveTrigger.forceSave("sync_link");
return (await getLink())!;
}
export interface LinkPatch {
serverName?: string | null;
serverVersion?: string | null;
sessionToken?: string | null;
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
remoteUserId?: string | null;
remoteUsername?: string | null;
account?: LinkAccount | null;
disabledTypes?: string[];
knownTypes?: string[];
cursor?: number;
status?: LinkStatus;
lastError?: string | null;
lastSyncAt?: string | null;
}
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
const current = await getLink();
if (!current) return null;
const set: Partial<Row> = {};
if (patch.serverName !== undefined) set.serverName = patch.serverName;
if (patch.serverVersion !== undefined)
set.serverVersion = patch.serverVersion;
if (patch.sessionToken !== undefined) {
set.sessionToken = patch.sessionToken
? await encryptSystemSecret(patch.sessionToken)
: null;
}
if (patch.customHeaders !== undefined) {
set.customHeaders = patch.customHeaders.length
? await encryptSystemSecret(JSON.stringify(patch.customHeaders))
: null;
}
if (patch.basicAuth !== undefined) {
set.basicAuth = patch.basicAuth
? await encryptSystemSecret(JSON.stringify(patch.basicAuth))
: null;
}
if (patch.allowInvalidCertificate !== undefined) {
set.allowInvalidCertificate = patch.allowInvalidCertificate;
}
if (patch.remoteUserId !== undefined) set.remoteUserId = patch.remoteUserId;
if (patch.remoteUsername !== undefined) {
set.remoteUsername = patch.remoteUsername;
}
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.knownTypes !== undefined) {
set.knownTypes = JSON.stringify(patch.knownTypes);
}
if (patch.cursor !== undefined) set.cursor = patch.cursor;
if (patch.status !== undefined) set.status = patch.status;
if (patch.lastError !== undefined) set.lastError = patch.lastError;
if (patch.lastSyncAt !== undefined) set.lastSyncAt = patch.lastSyncAt;
if (Object.keys(set).length > 0) {
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
DatabaseSaveTrigger.triggerSave("sync_link");
}
return getLink();
}
export async function deleteLink(): Promise<void> {
await db().delete(syncLink);
await DatabaseSaveTrigger.forceSave("sync_link");
}
+197
View File
@@ -0,0 +1,197 @@
/**
* A linked desktop runs the features its server runs.
*
* For every plugin in "mirror" mode, the server decides: a plugin installed
* there is installed here, on or off to match, with the capabilities an
* admin granted it there. Plugins marked "local" are left to the desktop and
* "server" ones never run on a linked desktop.
*/
import fs from "fs";
import path from "path";
import semver from "semver";
import type { PluginDesktopMode } from "@termix/plugin-sdk/manifest";
import { syncLogger } from "../../utils/logger.js";
import { sendCoreAlert } from "../../notify/core-notify.js";
import {
getPluginRuntime,
installPluginArtifact,
setPluginEnabled,
unloadPlugin,
} from "../../plugins/index.js";
import { getPluginsDir } from "../../plugins/paths.js";
import {
createCurrentPluginPermissionGrantRepository,
createCurrentPluginRepository,
} from "../../database/repositories/factory.js";
import { remoteFetch, remoteJson } from "./http.js";
import type { SyncLink } from "./link-store.js";
export interface RemotePlugin {
id: string;
name: string;
version: string;
source: "bundled" | "user";
enabled: boolean;
active: boolean;
desktop: PluginDesktopMode;
syncEntities: string[];
granted: string[];
}
let lastRemote: RemotePlugin[] = [];
const alertedVersions = new Set<string>();
/** What the server ran at the last sync, for the sync panel. */
export function lastRemotePlugins(): RemotePlugin[] {
return lastRemote;
}
async function isEnabledLocally(pluginId: string): Promise<boolean> {
const record = await createCurrentPluginRepository().findById(pluginId);
return record?.state === "enabled";
}
async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
const response = await remoteFetch(
link,
`/sync/v2/plugins/${encodeURIComponent(plugin.id)}/package`,
{ timeoutMs: 120_000 },
);
if (!response.ok) {
throw new Error(`Could not download ${plugin.id} (${response.status})`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const dir = getPluginsDir();
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `${plugin.id}.tmxplug`);
await fs.promises.writeFile(file, buffer);
const signature = response.headers.get("x-termix-signature");
const sigFile = `${file}.sig`;
if (signature) {
await fs.promises.writeFile(
sigFile,
Buffer.from(signature, "base64").toString("utf8"),
);
} else {
await fs.promises.rm(sigFile, { force: true });
}
return file;
}
async function matchGrants(plugin: RemotePlugin): Promise<void> {
const repository = createCurrentPluginPermissionGrantRepository();
const local = getPluginRuntime().loader.get(plugin.id);
if (!local || local.source !== "user") return;
const declared = new Set(local.manifest.capabilities);
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
const existing = await repository.listByPlugin(plugin.id);
for (const grant of existing) {
if (!wanted.has(grant.capability)) {
await repository.revoke(plugin.id, grant.capability);
}
}
const have = new Set(existing.map((grant) => grant.capability));
for (const capability of wanted) {
if (have.has(capability)) continue;
await repository.grant({
pluginId: plugin.id,
capability,
grantedBy: null,
source: "admin",
});
}
}
async function alertOlderDesktop(plugin: RemotePlugin): Promise<void> {
const key = `${plugin.id}@${plugin.version}`;
if (alertedVersions.has(key)) return;
alertedVersions.add(key);
await sendCoreAlert({
title: "Update the desktop app",
body: `The server runs a newer ${plugin.name} (${plugin.version}) than this app has. Update the app to get it.`,
severity: "info",
category: "termix.sync.desktop_outdated",
dedupeKey: `sync:outdated:${key}`,
audience: "admins",
});
}
async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
const { loader } = getPluginRuntime();
let local = loader.get(plugin.id);
const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror";
if (mode === "server") {
if (local && (await isEnabledLocally(plugin.id))) {
await setPluginEnabled(plugin.id, false);
}
return;
}
if (mode !== "mirror") return;
if (plugin.source === "bundled") {
if (!local || semver.gt(plugin.version, local.manifest.version)) {
await alertOlderDesktop(plugin);
}
if (!local) return;
} else if (plugin.enabled || local) {
const outdated =
!local ||
local.source !== "user" ||
local.manifest.version !== plugin.version;
if (outdated && (!local || local.source === "user")) {
const wasEnabled = local ? await isEnabledLocally(plugin.id) : false;
if (local) await unloadPlugin(plugin.id);
const file = await download(link, plugin);
local = await installPluginArtifact(file);
if (wasEnabled && !plugin.enabled) {
await setPluginEnabled(plugin.id, false);
}
}
await matchGrants(plugin);
}
if (!local) return;
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
await setPluginEnabled(plugin.id, plugin.enabled);
} else if (plugin.enabled && local.state !== "active") {
await setPluginEnabled(plugin.id, true);
}
}
export async function mirrorPlugins(link: SyncLink): Promise<void> {
const { plugins } = await remoteJson<{ plugins: RemotePlugin[] }>(
link,
"/sync/v2/plugins",
);
lastRemote = plugins;
const remoteIds = new Set(plugins.map((plugin) => plugin.id));
for (const plugin of plugins) {
try {
await mirrorOne(link, plugin);
} catch (error) {
syncLogger.warn("Could not match a feature to the server", {
operation: "sync_plugin_mirror",
pluginId: plugin.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
// A plugin the server no longer has is switched off here. Its data stays.
for (const local of getPluginRuntime().loader.list()) {
if (remoteIds.has(local.id) || local.source !== "user") continue;
if ((local.manifest.desktop ?? "mirror") !== "mirror") continue;
if (await isEnabledLocally(local.id)) {
await setPluginEnabled(local.id, false).catch(() => {});
}
}
}
/** Whether a plugin's on/off state belongs to the linked server. */
export function isServerManaged(pluginId: string): boolean {
const local = getPluginRuntime().loader.get(pluginId);
return (local?.manifest.desktop ?? "mirror") !== "local";
}
+761
View File
@@ -0,0 +1,761 @@
/**
* The desktop's own sync routes, under /sync/link. They only exist in the
* embedded desktop backend; on a server they answer 404.
*/
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import { and, eq, isNotNull } from "drizzle-orm";
import type { SyncRow } from "@termix/plugin-sdk/backend";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { syncLogger } from "../../utils/logger.js";
import { hosts, sshCredentials } from "../../database/db/schema.js";
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
import { registerCoreSyncEntities } from "../entities.js";
import {
createResolvers,
deleteStoredRow,
listStoredRows,
regenerateSyncIds,
writeWireRow,
} from "../store.js";
import {
clearRecordErrors,
deleteConflict,
deleteRecordsForUser,
getConflict,
listConflicts,
listRecordErrors,
} from "../records.js";
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
import {
createLink,
deleteLink,
getLink,
updateLink,
type BasicAuth,
type ProxyHeader,
type SyncLink,
} from "./link-store.js";
import {
normalizeServerUrl,
RemoteError,
remoteJson,
type RemoteTarget,
} from "./http.js";
import { defaultDeviceName, getSyncEngine } from "./engine.js";
import { applyElectronProxyConfig } from "./electron-proxy.js";
import { lastRemotePlugins } from "./plugins.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
function desktopOnly(_req: Request, res: Response, next: NextFunction) {
if (process.env.ELECTRON_EMBEDDED !== "true") {
res.status(404).json({ error: "Not found" });
return;
}
next();
}
const guard = [desktopOnly, authenticateJWT];
function userOf(req: Request): string {
return (req as AuthenticatedRequest).userId;
}
function readHeaders(value: unknown): ProxyHeader[] {
if (!Array.isArray(value)) return [];
return value
.filter(
(header): header is ProxyHeader =>
!!header &&
typeof header.name === "string" &&
typeof header.value === "string" &&
/^[A-Za-z0-9-]{1,100}$/.test(header.name.trim()),
)
.map((header) => ({ name: header.name.trim(), value: header.value }))
.slice(0, 20);
}
function readBasicAuth(value: unknown): BasicAuth | null {
if (!value || typeof value !== "object") return null;
const { username, password } = value as Record<string, unknown>;
if (typeof username !== "string" || !username) return null;
return {
username,
password: typeof password === "string" ? password : "",
};
}
function readTarget(body: Record<string, unknown>): RemoteTarget | null {
const serverUrl =
typeof body.serverUrl === "string"
? normalizeServerUrl(body.serverUrl)
: null;
if (!serverUrl) return null;
return {
serverUrl,
customHeaders: readHeaders(body.customHeaders),
basicAuth: readBasicAuth(body.basicAuth),
allowInvalidCertificate: body.allowInvalidCertificate === true,
};
}
/** What the sync panel can show about each entity type. */
function describeEntities(link: SyncLink | null) {
registerCoreSyncEntities();
const names = new Map<string, string>();
for (const plugin of getPluginRuntime().loader.list()) {
names.set(plugin.id, plugin.manifest.name);
}
for (const plugin of lastRemotePlugins()) {
if (!names.has(plugin.id)) names.set(plugin.id, plugin.name);
}
const serverTypes = getSyncEngine().state.serverTypes;
const seen = new Set<string>();
const entries = [
...listEntities().map((entity) => ({
type: entity.type,
owner: entity.owner,
readOnly: !!entity.readOnly,
})),
...serverTypes,
];
return entries
.filter((entry) => {
if (seen.has(entry.type) || entry.type === "accountProfile") return false;
seen.add(entry.type);
return true;
})
.map((entry) => ({
type: entry.type,
owner: entry.owner,
ownerName:
entry.owner === "core" ? null : (names.get(entry.owner) ?? entry.owner),
readOnly: entry.readOnly,
onServer: serverTypes.some((server) => server.type === entry.type),
onDevice: !!getEntity(entry.type),
enabled: !link?.disabledTypes.includes(entry.type),
}));
}
async function statusBody() {
const link = await getLink();
const engine = getSyncEngine();
if (!link) {
return { linked: false, entities: describeEntities(null) };
}
return {
linked: true,
serverUrl: link.serverUrl,
serverName: link.serverName,
serverVersion: link.serverVersion,
account: link.account ?? {
username: link.remoteUsername ?? undefined,
},
status: engine.state.running ? "syncing" : link.status,
lastError: link.lastError,
linkedAt: link.linkedAt,
lastSyncAt: link.lastSyncAt,
pending: engine.state.pending,
conflicts: (await listConflicts(link.userId)).length,
errors: (await listRecordErrors(link.userId)).length,
firstSyncDone: link.knownTypes.length > 0,
allowInvalidCertificate: link.allowInvalidCertificate,
customHeaders: link.customHeaders.map((header) => ({
name: header.name,
value: "",
set: true,
})),
basicAuth: link.basicAuth
? { username: link.basicAuth.username, password: "", set: true }
: null,
entities: describeEntities(link),
};
}
/** 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);
}
}
/** 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),
});
}
}
}
function problemFor(error: unknown) {
if (!(error instanceof RemoteError)) {
return { problem: "unreachable", message: String(error) };
}
switch (error.kind) {
case "tls":
return { problem: "tls_untrusted", message: error.message };
case "basic_auth":
return { problem: "basic_auth_required", message: error.message };
case "proxy":
return error.status === 404
? { problem: "not_termix", message: error.message }
: { problem: "proxy_login", message: error.message };
case "signed_out":
case "server":
// A Termix older than 2.9 has no /sync/v2 and answers with an error.
return { problem: "version_mismatch", message: error.message };
default:
return { problem: "unreachable", message: error.message };
}
}
/**
* @openapi
* /sync/link/status:
* get:
* summary: This desktop's link to a server
* description: Desktop only. Whether the app is linked, to which server and account, how syncing is going, and which kinds of data sync.
* tags:
* - Sync
* responses:
* 200:
* description: The link status.
*/
router.get("/link/status", ...guard, async (_req: Request, res: Response) => {
try {
res.json(await statusBody());
} catch (error) {
syncLogger.error("Failed to read sync status", error, {
operation: "sync_status",
});
res.status(500).json({ error: "Failed to read sync status" });
}
});
/**
* @openapi
* /sync/link/probe:
* post:
* summary: Check a server address before linking
* description: Desktop only. Tries the server with the given proxy settings and says what is in the way, if anything. On success the proxy settings are also applied to the desktop's browser session so the sign-in page loads.
* tags:
* - Sync
* responses:
* 200:
* description: The server's name and version, or the problem found.
*/
router.post("/link/probe", ...guard, async (req: Request, res: Response) => {
const target = readTarget(req.body ?? {});
if (!target) return res.json({ ok: false, problem: "invalid_url" });
try {
const info = await remoteJson<{
termix?: boolean;
name?: string;
version?: string;
protocol?: number;
}>(target, "/sync/v2/info", { timeoutMs: 15_000 });
if (!info?.termix) return res.json({ ok: false, problem: "not_termix" });
if (info.protocol !== SYNC_PROTOCOL_VERSION) {
return res.json({
ok: false,
problem: "version_mismatch",
version: info.version,
});
}
await applyElectronProxyConfig({
serverUrl: target.serverUrl,
customHeaders: target.customHeaders ?? [],
basicAuth: target.basicAuth ?? null,
allowInvalidCertificate: !!target.allowInvalidCertificate,
}).catch(() => {});
res.json({
ok: true,
serverUrl: target.serverUrl,
name: info.name ?? "Termix",
version: info.version ?? null,
});
} catch (error) {
res.json({ ok: false, ...problemFor(error) });
}
});
/**
* @openapi
* /sync/link/preview:
* get:
* summary: How much this desktop has that would sync
* description: Desktop only. Row counts per kind of data, shown before choosing whether to merge this desktop's data into the account or replace it.
* tags:
* - Sync
* responses:
* 200:
* description: Counts per entity type.
*/
router.get("/link/preview", ...guard, async (req: Request, res: Response) => {
const userId = userOf(req);
registerCoreSyncEntities();
const counts: Record<string, number> = {};
for (const entity of listEntities()) {
if (entity.readOnly) continue;
try {
if (entity.load) {
counts[entity.type] = (await entity.load(userId)).length;
continue;
}
if (!entity.table) continue;
counts[entity.type] = (await listStoredRows(entity, userId)).filter(
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
).length;
} catch {
counts[entity.type] = 0;
}
}
res.json({ counts });
});
/**
* @openapi
* /sync/link/complete:
* post:
* summary: Link this desktop to a server account
* description: Desktop only. Takes the session from signing in to the server, trades it for a long-lived desktop session, and starts syncing. "merge" uploads this desktop's data into the account; "replace" deletes it here first and takes the account's.
* tags:
* - Sync
* responses:
* 200:
* description: Linked; the new status.
* 400:
* description: Missing server or session.
* 502:
* description: The server refused the session.
*/
router.post("/link/complete", ...guard, async (req: Request, res: Response) => {
const userId = userOf(req);
const body = req.body ?? {};
const target = readTarget(body);
const token = typeof body.token === "string" ? body.token : "";
const mode = body.mode === "replace" ? "replace" : "merge";
if (!target || !token) {
return res.status(400).json({ error: "Missing server or session" });
}
try {
const deviceName =
typeof body.deviceName === "string" && body.deviceName.trim()
? body.deviceName.trim()
: defaultDeviceName();
const linked = await remoteJson<{
token: string;
user: { id: string; username: string };
server?: { version?: string | null };
}>({ ...target, sessionToken: token }, "/sync/v2/link", {
method: "POST",
body: { deviceName },
});
const previous = await getLink();
if (previous) {
await removeSharedCopies(previous.userId);
await deleteRecordsForUser(previous.userId);
}
if (mode === "replace") {
await wipeSyncedData(userId);
}
await deleteRecordsForUser(userId);
const link = await createLink({
userId,
serverUrl: target.serverUrl,
serverName: typeof body.serverName === "string" ? body.serverName : null,
serverVersion: linked.server?.version ?? null,
sessionToken: linked.token,
customHeaders: target.customHeaders,
basicAuth: target.basicAuth,
allowInvalidCertificate: target.allowInvalidCertificate,
remoteUserId: linked.user.id,
remoteUsername: linked.user.username,
});
const engine = getSyncEngine();
engine.reset();
await applyElectronProxyConfig(link).catch(() => {});
engine.request(0);
res.json(await statusBody());
} catch (error) {
syncLogger.warn("Linking to a server failed", {
operation: "sync_link_complete",
error: error instanceof Error ? error.message : String(error),
});
res.status(502).json({ ...problemFor(error) });
}
});
/**
* @openapi
* /sync/link/relogin:
* post:
* summary: Sign this desktop back in after its session ended
* description: Desktop only. Takes a new sign-in session for the same account and resumes syncing where it stopped.
* tags:
* - Sync
* responses:
* 200:
* description: Signed in again; the new status.
* 409:
* description: The sign-in was for a different account.
*/
router.post("/link/relogin", ...guard, async (req: Request, res: Response) => {
const token = typeof req.body?.token === "string" ? req.body.token : "";
const link = await getLink();
if (!link || !token) return res.status(400).json({ error: "Not linked" });
try {
const linked = await remoteJson<{
token: string;
user: { id: string; username: string };
}>({ ...link, sessionToken: token }, "/sync/v2/link", {
method: "POST",
body: { deviceName: defaultDeviceName() },
});
if (link.remoteUserId && linked.user.id !== link.remoteUserId) {
return res.status(409).json({ error: "different_account" });
}
await updateLink({
sessionToken: linked.token,
status: "idle",
lastError: null,
});
getSyncEngine().request(0);
res.json(await statusBody());
} catch (error) {
res.status(502).json({ ...problemFor(error) });
}
});
/**
* @openapi
* /sync/link/unlink:
* post:
* summary: Unlink this desktop from its server
* description: Desktop only. Ends the desktop's session on the server when it can be reached. With keepData the synced data stays on this device as its own; otherwise it is deleted. Copies of what others shared with the account are always removed.
* tags:
* - Sync
* responses:
* 200:
* description: Unlinked.
*/
router.post("/link/unlink", ...guard, async (req: Request, res: Response) => {
const keepData = req.body?.keepData !== false;
const link = await getLink();
if (!link) return res.json(await statusBody());
const userId = link.userId;
try {
await remoteJson(link, "/sync/v2/unlink", {
method: "POST",
body: {},
timeoutMs: 10_000,
}).catch(() => {});
const engine = getSyncEngine();
engine.reset();
await removeSharedCopies(userId);
if (keepData) {
// New syncIds so this data reads as new if it is ever linked again.
for (const entity of listEntities()) {
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
}
} else {
await wipeSyncedData(userId);
}
await deleteRecordsForUser(userId);
await deleteLink();
await applyElectronProxyConfig(null).catch(() => {});
res.json(await statusBody());
} catch (error) {
syncLogger.error("Unlinking failed", error, { operation: "sync_unlink" });
res.status(500).json({ error: "Failed to unlink" });
}
});
/**
* @openapi
* /sync/link/sync:
* post:
* summary: Sync now
* description: Desktop only. Runs a sync pass and waits for it.
* tags:
* - Sync
* responses:
* 200:
* description: The status after the pass.
*/
router.post("/link/sync", ...guard, async (_req: Request, res: Response) => {
await getSyncEngine().syncNow();
res.json(await statusBody());
});
/**
* @openapi
* /sync/link/settings:
* put:
* summary: Change what syncs and how the server is reached
* description: Desktop only. disabledTypes lists the kinds of data that stay on this device. Proxy headers and basic auth left blank keep their saved values.
* tags:
* - Sync
* responses:
* 200:
* description: The new status.
* 400:
* description: Not linked.
*/
router.put("/link/settings", ...guard, async (req: Request, res: Response) => {
const link = await getLink();
if (!link) return res.status(400).json({ error: "Not linked" });
const body = req.body ?? {};
const patch: Parameters<typeof updateLink>[0] = {};
if (Array.isArray(body.disabledTypes)) {
const disabled = body.disabledTypes.filter(
(type: unknown): type is string =>
typeof type === "string" && type !== "accountProfile",
);
const changed = new Set([
...disabled.filter((type: string) => !link.disabledTypes.includes(type)),
...link.disabledTypes.filter((type) => !disabled.includes(type)),
]);
patch.disabledTypes = disabled;
// A type switched back on is pulled again in full.
patch.knownTypes = link.knownTypes.filter((type) => !changed.has(type));
}
if (Array.isArray(body.customHeaders)) {
const saved = new Map(link.customHeaders.map((h) => [h.name, h.value]));
patch.customHeaders = (body.customHeaders as unknown[])
.filter(
(header): header is { name: string; value?: string } =>
!!header && typeof (header as { name?: unknown }).name === "string",
)
.map((header) => ({
name: header.name,
value: header.value || saved.get(header.name) || "",
}))
.filter((header) => header.value);
patch.customHeaders = readHeaders(patch.customHeaders);
}
if (body.basicAuth === null) {
patch.basicAuth = null;
} else if (body.basicAuth && typeof body.basicAuth === "object") {
const next = readBasicAuth(body.basicAuth);
if (next && !next.password && link.basicAuth) {
next.password = link.basicAuth.password;
}
patch.basicAuth = next;
}
if (typeof body.allowInvalidCertificate === "boolean") {
patch.allowInvalidCertificate = body.allowInvalidCertificate;
}
const updated = await updateLink(patch);
await applyElectronProxyConfig(updated).catch(() => {});
getSyncEngine().request(0);
res.json(await statusBody());
});
/**
* @openapi
* /sync/link/conflicts:
* get:
* summary: Local edits that lost to a newer edit on the server
* description: Desktop only. The server's version was kept; each entry holds this device's version so it can be restored.
* tags:
* - Sync
* responses:
* 200:
* description: The conflicts, oldest first.
*/
router.get("/link/conflicts", ...guard, async (req: Request, res: Response) => {
const conflicts = await listConflicts(userOf(req));
res.json({
conflicts: conflicts.map((conflict) => {
let row: SyncRow = {};
try {
row = JSON.parse(conflict.localRow);
} catch {
row = {};
}
return {
id: conflict.id,
entityType: conflict.entityType,
syncId: conflict.syncId,
name: row.name ?? row.title ?? row.label ?? row.ip ?? conflict.syncId,
createdAt: conflict.createdAt,
};
}),
});
});
/**
* @openapi
* /sync/link/conflicts/{id}:
* post:
* summary: Settle a conflict
* description: Desktop only. keep "mine" puts this device's version back, and the next sync sends it to the server. keep "server" drops it.
* tags:
* - Sync
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Settled.
* 404:
* description: No such conflict.
*/
router.post(
"/link/conflicts/:id",
...guard,
async (req: Request, res: Response) => {
const userId = userOf(req);
const conflict = await getConflict(userId, Number(req.params.id));
if (!conflict) return res.status(404).json({ error: "Not found" });
try {
if (req.body?.keep === "mine") {
const entity = getEntity(conflict.entityType);
if (entity && !entity.readOnly) {
const row = JSON.parse(conflict.localRow) as SyncRow;
await writeWireRow(
entity,
userId,
{ ...row, syncId: conflict.syncId },
createResolvers(userId),
);
}
}
await deleteConflict(userId, conflict.id);
getSyncEngine().request(0);
res.json({ success: true });
} catch (error) {
syncLogger.warn("Could not settle a sync conflict", {
operation: "sync_conflict",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to settle conflict" });
}
},
);
/**
* @openapi
* /sync/link/errors:
* get:
* summary: Changes the server refused
* description: Desktop only. Each entry names the record and why the server refused it. A refused change is not sent again until it changes or is retried.
* tags:
* - Sync
* responses:
* 200:
* description: The refused changes.
*/
router.get("/link/errors", ...guard, async (req: Request, res: Response) => {
const errors = await listRecordErrors(userOf(req));
res.json({
errors: errors.map((record) => ({
entityType: record.entityType,
syncId: record.syncId,
reason: record.error,
})),
});
});
/**
* @openapi
* /sync/link/errors/retry:
* post:
* summary: Send refused changes again
* description: Desktop only. Clears every refusal and syncs.
* tags:
* - Sync
* responses:
* 200:
* description: The status after the pass.
*/
router.post(
"/link/errors/retry",
...guard,
async (req: Request, res: Response) => {
await clearRecordErrors(userOf(req));
await getSyncEngine().syncNow();
res.json(await statusBody());
},
);
/**
* @openapi
* /sync/link/session:
* get:
* summary: The linked server and session, for connecting to it directly
* description: Desktop only. Used when a host is set to connect through the server rather than from this device.
* tags:
* - Sync
* responses:
* 200:
* description: The server address and session token, or nulls when not linked.
*/
router.get("/link/session", ...guard, async (_req: Request, res: Response) => {
const link = await getLink();
if (!link?.sessionToken || link.status === "signed_out") {
return res.json({ serverUrl: null, token: null });
}
res.json({ serverUrl: link.serverUrl, token: link.sessionToken });
});
export default router;
+581
View File
@@ -0,0 +1,581 @@
/**
* Core's own sync entities, registered the same way a plugin registers one.
*
* Order values leave room between them (10, 30, 50...) so a plugin can slot
* an entity in without renumbering; anything referenced syncs first.
*/
import { and, eq } from "drizzle-orm";
import type { SyncRow } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
hosts,
sshCredentials,
sshFolders,
userPreferences,
} from "../database/db/schema.js";
import {
CORE_OWNER,
listEntities,
registerEntity,
} from "../plugins/sync-registry.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import { syncLogger } from "../utils/logger.js";
import { getPluginRuntime } from "../plugins/index.js";
import { singletonSyncId } from "./wire.js";
import {
exportHostPluginSettings,
importHostPluginSettings,
} from "./host-plugin-settings.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
} as const;
const HOST_REFERENCES = [
CREDENTIAL_REFERENCE,
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
entityType: "hosts",
},
{ field: "jumpHosts[].hostId", entityType: "hosts" },
{ field: "quickActions[].snippetId", entityType: "commandSnippet" },
] as const;
/** Columns that only mean something on the device that holds them. */
const HOST_LOCAL_FIELDS = [
"connectionOrigin",
"localOnly",
"sharedSource",
"hostKeyLastVerified",
];
let registered = false;
/** Registers core's entities. Idempotent. */
export function registerCoreSyncEntities(): void {
if (
registered &&
listEntities().some((entity) => entity.owner === CORE_OWNER)
)
return;
registered = true;
registerEntity(
CORE_OWNER,
{
type: "sshCredentials",
table: sshCredentials,
order: 10,
encryptedFields: FieldCrypto.fieldsFor("ssh_credentials"),
readOnlyFields: ["usageCount", "lastUsed", "sharedSource"],
shouldSync: (row) => !row.sharedSource,
permissions: {
create: "credentials.create",
update: "credentials.edit",
delete: "credentials.delete",
},
afterWrite: async ({ id, userId, created }) => {
if (id === null || created) return;
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
id,
userId,
);
const { SharedCredentialSecretsManager } =
await import("../utils/shared-credential-secrets-manager.js");
await SharedCredentialSecretsManager.getInstance().resyncCredential(
id,
userId,
);
},
remove: async (row, userId) => {
const { deleteOwnedCredential } =
await import("../hosts/delete-credential.js");
await deleteOwnedCredential(userId, row.id as number);
},
},
{
// A credential shared with the user can back their own host.
canReference: async (userId, _ownerId, id) => {
const { findUsableCredential } =
await import("../hosts/usable-credential.js");
return (await findUsableCredential(id, userId)) !== null;
},
},
);
// vaultProfiles (order 20) belongs to the vault plugin.
registerEntity(CORE_OWNER, {
type: "sshFolders",
table: sshFolders,
order: 30,
references: [CREDENTIAL_REFERENCE],
readOnlyFields: ["localOnly"],
shouldSync: (row) => !row.localOnly,
permissions: {
create: "hosts.create",
update: "hosts.edit",
delete: "hosts.delete",
},
});
registerEntity(
CORE_OWNER,
{
type: "hosts",
table: hosts,
order: 50,
encryptedFields: FieldCrypto.fieldsFor("ssh_data"),
readOnlyFields: HOST_LOCAL_FIELDS,
references: HOST_REFERENCES,
shouldSync: (row) => !row.localOnly && !row.sharedSource,
permissions: {
create: "hosts.create",
update: "hosts.edit",
delete: "hosts.delete",
},
serialize: async (row) => ({
...row,
pluginSettings:
typeof row.id === "number"
? await exportHostPluginSettings(row.id)
: {},
}),
afterWrite: async ({ id, userId, wire }) => {
if (id === null) return;
await importHostPluginSettings(id, wire.pluginSettings);
await dropInvalidParent(userId, id);
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHost(id);
},
remove: async (row, userId) => {
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
await deleteOwnedHost(userId, row.id as number);
},
},
{
canReference: async (userId, _ownerId, id) => {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
id,
"view",
);
return access.hasAccess;
},
},
);
// dashboardServiceLinks (70) and homepageItems (80) belong to homepage.
registerEntity(CORE_OWNER, {
type: "userPreferences",
table: userPreferences,
order: 90,
singleton: true,
readOnlyFields: ["storageMode"],
references: [
{
field: "customKeybindings[].action.snippetId",
entityType: "commandSnippet",
idType: "string",
},
],
});
registerEntity(
CORE_OWNER,
{ type: "pluginUserSettings", table: null, order: 95 },
{
load: loadPluginUserSettings,
write: writePluginUserSetting,
erase: erasePluginUserSetting,
covers: (syncId) => !!activeManifest(syncId.split(":")[0]),
},
);
registerEntity(
CORE_OWNER,
{ type: "accountProfile", table: null, order: 5, singleton: true },
{
readOnly: true,
load: loadAccountProfile,
write: writeAccountProfile,
erase: async () => {},
},
);
registerEntity(
CORE_OWNER,
{ type: "sharedCredentials", table: null, order: 15 },
{
readOnly: true,
load: loadSharedCredentials,
write: (userId, wire) => writeSharedCopy("sshCredentials", userId, wire),
erase: (userId, syncId) =>
eraseSharedCopy("sshCredentials", userId, syncId),
},
);
registerEntity(
CORE_OWNER,
{ type: "sharedHosts", table: null, order: 55 },
{
readOnly: true,
load: loadSharedHosts,
write: (userId, wire) => writeSharedCopy("hosts", userId, wire),
erase: (userId, syncId) => eraseSharedCopy("hosts", userId, syncId),
},
);
}
/** A synced parent link that would make a cycle here is dropped. */
async function dropInvalidParent(userId: string, hostId: number) {
const { createCurrentRepositoryContext } =
await import("../database/repositories/factory.js");
const db = createCurrentRepositoryContext().drizzle;
const [row] = await db
.select({ parentHostId: hosts.parentHostId })
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.limit(1);
if (typeof row?.parentHostId !== "number") return;
const { validateParentHostId } =
await import("../database/routes/host-parent-validation.js");
if (await validateParentHostId(userId, hostId, row.parentHostId)) {
await db
.update(hosts)
.set({ parentHostId: null })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
}
}
function activeManifests(): PluginManifest[] {
try {
return getPluginRuntime()
.loader.list()
.filter((plugin) => plugin.state === "active" && plugin.manifest)
.map((plugin) => plugin.manifest as PluginManifest);
} catch {
return [];
}
}
function activeManifest(pluginId: string): PluginManifest | undefined {
return activeManifests().find((manifest) => manifest.id === pluginId);
}
async function loadPluginUserSettings(userId: string): Promise<SyncRow[]> {
const { declaredFields, getSetting } = await import("../plugins/settings.js");
const { createCurrentPluginSettingsRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginSettingsRepository();
const rows: SyncRow[] = [];
for (const manifest of activeManifests()) {
const fields = declaredFields(manifest, "user");
if (fields.length === 0) continue;
const stored = new Set(
(await repository.getAll(manifest.id, "user", userId))
.filter((row) => row.value !== null)
.map((row) => row.key),
);
for (const field of fields) {
if (!stored.has(field.key)) continue;
rows.push({
syncId: `${manifest.id}:${field.key}`,
pluginId: manifest.id,
key: field.key,
value: await getSetting(manifest, "user", userId, field.key),
});
}
}
return rows;
}
async function writePluginUserSetting(userId: string, wire: SyncRow) {
const manifest = activeManifest(String(wire.pluginId));
if (!manifest) return;
const { setSetting } = await import("../plugins/settings.js");
const error = await setSetting(
manifest,
"user",
userId,
String(wire.key),
wire.value,
);
if (error) throw new Error(error);
}
async function erasePluginUserSetting(userId: string, syncId: string) {
const [pluginId, ...rest] = syncId.split(":");
const { createCurrentPluginSettingsRepository } =
await import("../database/repositories/factory.js");
await createCurrentPluginSettingsRepository().delete(
pluginId,
"user",
userId,
rest.join(":"),
);
}
async function loadAccountProfile(userId: string): Promise<SyncRow[]> {
const { createCurrentUserRepository, createCurrentRoleRepository } =
await import("../database/repositories/factory.js");
const user = await createCurrentUserRepository().findById(userId);
if (!user) return [];
const { PermissionManager } = await import("../utils/permission-manager.js");
const roles = await createCurrentRoleRepository().listUserRoles(userId);
return [
{
syncId: singletonSyncId("accountProfile"),
remoteUserId: userId,
username: user.username,
isAdmin: !!user.isAdmin,
roles: roles.map((role) => role.roleName).sort(),
permissions: (
await PermissionManager.getInstance().getUserPermissions(userId)
).sort(),
},
];
}
async function writeAccountProfile(userId: string, wire: SyncRow) {
const { updateLink } = await import("./client/link-store.js");
await updateLink({
remoteUserId: (wire.remoteUserId as string) ?? null,
remoteUsername: (wire.username as string) ?? null,
account: {
remoteUserId: wire.remoteUserId as string,
username: wire.username as string,
isAdmin: !!wire.isAdmin,
roles: (wire.roles as string[]) ?? [],
permissions: (wire.permissions as string[]) ?? [],
},
});
if (typeof wire.username === "string" && wire.username) {
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const users = createCurrentUserRepository();
const current = await users.findById(userId);
if (current && current.username !== wire.username) {
await users.update(userId, { username: wire.username });
}
}
}
/** Host columns a shared copy never carries. */
const SHARED_HOST_DROP = [
"id",
"userId",
"credentialId",
"rdpCredentialId",
"vncCredentialId",
"telnetCredentialId",
"parentHostId",
"pluginSettings",
"quickActions",
"createdAt",
"updatedAt",
"isShared",
"permissionLevel",
...HOST_LOCAL_FIELDS,
];
async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
const {
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentUserRepository,
} = await import("../database/repositories/factory.js");
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const entries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
const hostRepository = createCurrentHostResolutionRepository();
const owned = await hostRepository.listOwnedHostIds(userId);
const levels = new Map<number, string>();
for (const entry of entries) {
if (owned.has(entry.hostId)) continue;
if (!levels.has(entry.hostId)) {
levels.set(entry.hostId, entry.permissionLevel);
}
}
if (levels.size === 0) return [];
const { resolveHostById } = await import("../hosts/host-resolver.js");
const users = createCurrentUserRepository();
const ownerNames = new Map<string, string>();
const rows: SyncRow[] = [];
for (const [hostId, permissionLevel] of levels) {
try {
const host = (await resolveHostById(
hostId,
userId,
)) as unknown as SyncRow | null;
if (!host || typeof host.syncId !== "string") continue;
const ownerId = String(host.userId);
if (!ownerNames.has(ownerId)) {
ownerNames.set(
ownerId,
(await users.findById(ownerId))?.username ?? "",
);
}
const wire: SyncRow = {};
for (const [field, value] of Object.entries(host)) {
if (SHARED_HOST_DROP.includes(field)) continue;
wire[field] =
value !== null && typeof value === "object"
? JSON.stringify(value)
: value;
}
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
wire.syncId = host.syncId;
wire.shared = {
hostId,
owner: ownerNames.get(ownerId) ?? "",
permissionLevel,
};
rows.push(wire);
} catch (error) {
syncLogger.warn("Could not include a shared host in sync", {
operation: "sync_shared_host",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return rows;
}
async function jumpHostsToSyncIds(value: unknown): Promise<string | null> {
const list = Array.isArray(value) ? value : [];
if (list.length === 0) return null;
const { createCurrentHostRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentHostRepository();
const mapped = [];
for (const item of list) {
const hostId = (item as { hostId?: unknown })?.hostId;
if (typeof hostId !== "number") continue;
const target = await repository.findById(hostId);
if (target?.syncId) mapped.push({ ...item, hostId: target.syncId });
}
return JSON.stringify(mapped);
}
async function loadSharedCredentials(userId: string): Promise<SyncRow[]> {
const {
createCurrentCredentialAccessRepository,
createCurrentCredentialRepository,
createCurrentRoleRepository,
createCurrentUserRepository,
} = await import("../database/repositories/factory.js");
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const grants =
await createCurrentCredentialAccessRepository().listSharedWithUser(
userId,
roleIds,
);
if (grants.length === 0) return [];
const { findUsableCredential } =
await import("../hosts/usable-credential.js");
const credentials = createCurrentCredentialRepository();
const users = createCurrentUserRepository();
const rows: SyncRow[] = [];
for (const grant of grants) {
try {
const row = (await credentials.findById(
grant.credentialId,
)) as SyncRow | null;
const usable = (await findUsableCredential(
grant.credentialId,
userId,
)) as unknown as SyncRow | null;
if (!row || !usable || typeof row.syncId !== "string") continue;
rows.push({
syncId: row.syncId,
name: row.name,
description: row.description ?? null,
tags: row.tags ?? null,
authType: usable.authType,
username: usable.username ?? null,
password: usable.password ?? null,
privateKey: usable.privateKey ?? null,
keyPassword: usable.keyPassword ?? null,
keyType: usable.keyType ?? null,
publicKey: usable.publicKey ?? null,
certPublicKey: usable.certPublicKey ?? null,
shared: {
credentialId: grant.credentialId,
owner: (await users.findById(grant.ownerId))?.username ?? "",
permissionLevel: grant.permissionLevel,
},
});
} catch (error) {
syncLogger.warn("Could not include a shared credential in sync", {
operation: "sync_shared_credential",
credentialId: grant.credentialId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return rows;
}
/** Stores a read-only copy of something shared with the linked account. */
async function writeSharedCopy(
entityType: "hosts" | "sshCredentials",
userId: string,
wire: SyncRow,
): Promise<void> {
const { getEntity } = await import("../plugins/sync-registry.js");
const { writeWireRow, createResolvers } = await import("./store.js");
const entity = getEntity(entityType);
if (!entity) return;
const { shared, ...rest } = wire;
await writeWireRow(entity, userId, rest, createResolvers(userId), {
sharedSource: JSON.stringify(shared ?? {}),
});
}
async function eraseSharedCopy(
entityType: "hosts" | "sshCredentials",
userId: string,
syncId: string,
): Promise<void> {
const { getEntity } = await import("../plugins/sync-registry.js");
const { deleteStoredRow, findStoredRow } = await import("./store.js");
const entity = getEntity(entityType);
if (!entity) return;
const row = await findStoredRow(entity, userId, syncId);
if (!row?.sharedSource) return;
await deleteStoredRow(entity, userId, syncId);
}
@@ -1,21 +1,21 @@
/**
* Host plugin settings over remote sync. A host row travels with a
* Host plugin settings over sync. A host row travels with a
* pluginSettings map of its non-secret values; a plugin whose value refers to
* one of its own rows by local id translates it with a registered
* ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue }).
*/
import { consume } from "../../plugins/registry.js";
import { consume } from "../plugins/registry.js";
import {
declaredFields,
getAllSettings,
setSetting,
} from "../../plugins/settings.js";
import { databaseLogger } from "../../utils/logger.js";
} from "../plugins/settings.js";
import { databaseLogger } from "../utils/logger.js";
import {
hostSettingsPlugins,
type HostPluginSettings,
} from "./host-plugin-settings.js";
} from "../database/routes/host-plugin-settings.js";
export interface PluginHostSettingsSync {
exportValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
+2
View File
@@ -0,0 +1,2 @@
/** Bumped when a desktop and server can no longer understand each other. */
export const SYNC_PROTOCOL_VERSION = 1;
+248
View File
@@ -0,0 +1,248 @@
/**
* The per-record sync bookkeeping in sync_records. See the table's comment in
* schema.ts for what the columns mean on a server and on a desktop.
*/
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm";
import { syncConflicts, syncRecords } from "../database/db/schema.js";
import { createCurrentRepositoryContext } from "../database/repositories/factory.js";
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
export type SyncRecord = typeof syncRecords.$inferSelect;
export type SyncConflict = typeof syncConflicts.$inferSelect;
export function recordKey(entityType: string, syncId: string): string {
return `${entityType}\u0000${syncId}`;
}
function db() {
return createCurrentRepositoryContext().drizzle;
}
export async function listRecords(
userId: string,
entityType?: string,
): Promise<Map<string, SyncRecord>> {
const rows = await db()
.select()
.from(syncRecords)
.where(
entityType
? and(
eq(syncRecords.userId, userId),
eq(syncRecords.entityType, entityType),
)
: eq(syncRecords.userId, userId),
);
return new Map(
rows.map((row) => [recordKey(row.entityType, row.syncId), row]),
);
}
export async function getRecord(
userId: string,
entityType: string,
syncId: string,
): Promise<SyncRecord | null> {
const [row] = await db()
.select()
.from(syncRecords)
.where(
and(
eq(syncRecords.userId, userId),
eq(syncRecords.entityType, entityType),
eq(syncRecords.syncId, syncId),
),
)
.limit(1);
return row ?? null;
}
export interface RecordValues {
revision: number;
seq?: number;
hash: string | null;
deleted: boolean;
error?: string | null;
errorHash?: string | null;
}
export async function putRecord(
userId: string,
entityType: string,
syncId: string,
values: RecordValues,
): Promise<void> {
const set = {
revision: values.revision,
hash: values.hash,
deleted: values.deleted,
error: values.error ?? null,
errorHash: values.errorHash ?? null,
updatedAt: new Date().toISOString(),
...(values.seq !== undefined ? { seq: values.seq } : {}),
};
const existing = await getRecord(userId, entityType, syncId);
if (existing) {
await db()
.update(syncRecords)
.set(set)
.where(eq(syncRecords.id, existing.id));
} else {
await db()
.insert(syncRecords)
.values({ userId, entityType, syncId, seq: 0, ...set });
}
DatabaseSaveTrigger.triggerSave("sync_record");
}
export async function setRecordError(
userId: string,
entityType: string,
syncId: string,
error: string,
errorHash: string,
): Promise<void> {
const existing = await getRecord(userId, entityType, syncId);
if (existing) {
await db()
.update(syncRecords)
.set({ error, errorHash })
.where(eq(syncRecords.id, existing.id));
} else {
await db().insert(syncRecords).values({
userId,
entityType,
syncId,
revision: 0,
seq: 0,
hash: null,
deleted: false,
error,
errorHash,
});
}
DatabaseSaveTrigger.triggerSave("sync_record");
}
export async function clearRecordErrors(userId: string): Promise<void> {
await db()
.update(syncRecords)
.set({ error: null, errorHash: null })
.where(eq(syncRecords.userId, userId));
}
export async function listRecordErrors(userId: string): Promise<SyncRecord[]> {
const rows = await db()
.select()
.from(syncRecords)
.where(eq(syncRecords.userId, userId));
return rows.filter((row) => !!row.error);
}
export async function listChanges(
userId: string,
cursor: number,
limit: number,
types?: string[],
): Promise<SyncRecord[]> {
const conditions = [
eq(syncRecords.userId, userId),
gt(syncRecords.seq, cursor),
];
if (types && types.length > 0) {
conditions.push(inArray(syncRecords.entityType, types));
}
return db()
.select()
.from(syncRecords)
.where(and(...conditions))
.orderBy(asc(syncRecords.seq))
.limit(limit);
}
export async function deleteRecordsForUser(userId: string): Promise<void> {
await db().delete(syncRecords).where(eq(syncRecords.userId, userId));
await db().delete(syncConflicts).where(eq(syncConflicts.userId, userId));
DatabaseSaveTrigger.triggerSave("sync_record");
}
let nextSeq: number | null = null;
/**
* The next position in the change feed. One counter for every user, so a
* seq is never reused; a single process owns it, seeded from the table.
*/
export async function allocateSeq(): Promise<number> {
if (nextSeq === null) {
const [row] = await db()
.select({ max: sql<number>`max(${syncRecords.seq})` })
.from(syncRecords);
const max = Number(row?.max ?? 0);
nextSeq = (Number.isFinite(max) ? max : 0) + 1;
}
return nextSeq++;
}
/** Test seam. */
export function resetSeqAllocator(): void {
nextSeq = null;
}
export async function saveConflict(
userId: string,
entityType: string,
syncId: string,
localRow: Record<string, unknown>,
serverRevision: number,
): Promise<void> {
await db()
.delete(syncConflicts)
.where(
and(
eq(syncConflicts.userId, userId),
eq(syncConflicts.entityType, entityType),
eq(syncConflicts.syncId, syncId),
),
);
await db()
.insert(syncConflicts)
.values({
userId,
entityType,
syncId,
localRow: JSON.stringify(localRow),
serverRevision,
});
DatabaseSaveTrigger.triggerSave("sync_conflict");
}
export async function listConflicts(userId: string): Promise<SyncConflict[]> {
return db()
.select()
.from(syncConflicts)
.where(eq(syncConflicts.userId, userId))
.orderBy(asc(syncConflicts.createdAt));
}
export async function getConflict(
userId: string,
id: number,
): Promise<SyncConflict | null> {
const [row] = await db()
.select()
.from(syncConflicts)
.where(and(eq(syncConflicts.userId, userId), eq(syncConflicts.id, id)))
.limit(1);
return row ?? null;
}
export async function deleteConflict(
userId: string,
id: number,
): Promise<void> {
await db()
.delete(syncConflicts)
.where(and(eq(syncConflicts.userId, userId), eq(syncConflicts.id, id)));
DatabaseSaveTrigger.triggerSave("sync_conflict");
}
+30
View File
@@ -0,0 +1,30 @@
import type { NextFunction, Request, Response } from "express";
import { markChanged } from "./feed.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
* nudges the change feed. Sync's own pushes reconcile themselves.
*/
export function syncChangeWatcher(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!READS.has(req.method) && !req.path.startsWith("/sync/")) {
res.on("finish", () => {
if (res.statusCode >= 400) return;
markChanged();
for (const listener of writeListeners) listener();
});
}
next();
}
+220
View File
@@ -0,0 +1,220 @@
/**
* The server's change feed.
*
* Nothing records a change as it happens. Instead the reconciler compares
* every synced row a user owns with sync_records and bumps the record of
* anything new, changed or gone. That catches every write path, including
* plugin tables, bulk routes and background jobs, without each of them having
* to remember to report it.
*/
import type { Response } from "express";
import type { SyncRow } from "@termix/plugin-sdk/backend";
import { listEntities } from "../../plugins/sync-registry.js";
import { syncLogger } from "../../utils/logger.js";
import { registerCoreSyncEntities } from "../entities.js";
import { createResolvers, loadWireRows } from "../store.js";
import { allocateSeq, listRecords, putRecord, recordKey } from "../records.js";
const locks = new Map<string, Promise<unknown>>();
/** Runs one user's sync work at a time: reconcile and push must not interleave. */
export async function withUserLock<T>(
userId: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = locks.get(userId) ?? Promise.resolve();
const run = previous.catch(() => {}).then(fn);
locks.set(userId, run);
try {
return await run;
} finally {
if (locks.get(userId) === run) locks.delete(userId);
}
}
interface Snapshot {
at: number;
generation: number;
wire: Map<string, SyncRow>;
}
const snapshots = new Map<string, Snapshot>();
const SNAPSHOT_TTL_MS = 60_000;
let generation = 0;
export interface ReconcileResult {
changed: boolean;
/** Every live row on the wire, by recordKey. */
wire: Map<string, SyncRow>;
}
/**
* Brings the user's records up to date. Call inside withUserLock. Skipped
* when nothing was written since the last run and it is recent, unless
* forced.
*/
export async function reconcileUser(
userId: string,
options: { force?: boolean } = {},
): Promise<ReconcileResult> {
const snapshot = snapshots.get(userId);
if (
!options.force &&
snapshot &&
snapshot.generation === generation &&
Date.now() - snapshot.at < SNAPSHOT_TTL_MS
) {
return { changed: false, wire: snapshot.wire };
}
registerCoreSyncEntities();
const startedAt = generation;
const records = await listRecords(userId);
const resolvers = createResolvers(userId);
const wire = new Map<string, SyncRow>();
let changed = false;
for (const entity of listEntities()) {
let loaded;
try {
loaded = await loadWireRows(entity, userId, resolvers);
} catch (error) {
// An entity that cannot load right now is left alone rather than read
// as every row deleted.
syncLogger.warn("Could not read a sync entity", {
operation: "sync_reconcile",
entityType: entity.type,
userId,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
for (const row of loaded.rows.values()) {
const key = recordKey(entity.type, row.syncId);
wire.set(key, row.wire);
const record = records.get(key);
if (record && !record.deleted && record.hash === row.hash) continue;
await putRecord(userId, entity.type, row.syncId, {
revision: (record?.revision ?? 0) + 1,
seq: await allocateSeq(),
hash: row.hash,
deleted: false,
});
changed = true;
}
for (const record of records.values()) {
if (record.entityType !== entity.type || record.deleted) continue;
if (loaded.rows.has(record.syncId) || loaded.failed.has(record.syncId)) {
continue;
}
if (entity.covers && !entity.covers(record.syncId)) continue;
await putRecord(userId, entity.type, record.syncId, {
revision: record.revision + 1,
seq: await allocateSeq(),
hash: null,
deleted: true,
});
changed = true;
}
}
snapshots.set(userId, { at: Date.now(), generation: startedAt, wire });
if (changed) notifyListeners(userId);
return { changed, wire };
}
/** Forgets a user's snapshot so the next reconcile reads everything. */
export function invalidateSnapshot(userId: string): void {
snapshots.delete(userId);
}
const listeners = new Map<string, Set<Response>>();
let debounce: NodeJS.Timeout | null = null;
let heartbeat: NodeJS.Timeout | null = null;
let sweep: NodeJS.Timeout | null = null;
/**
* Something was written. Desktops listening for changes get their
* account reconciled shortly after, since a write by one user (an owner
* editing a shared host) can change what another one sees.
*/
export function markChanged(): void {
generation++;
if (listeners.size === 0) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
debounce = null;
void reconcileListeners();
}, 1500);
debounce.unref?.();
}
async function reconcileListeners(): Promise<void> {
for (const userId of [...listeners.keys()]) {
try {
await withUserLock(userId, () => reconcileUser(userId));
} catch (error) {
syncLogger.warn("Background sync reconcile failed", {
operation: "sync_reconcile_background",
userId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
function notifyListeners(userId: string): void {
const set = listeners.get(userId);
if (!set) return;
for (const res of set) {
res.write(`event: change\ndata: {}\n\n`);
}
}
export function addListener(userId: string, res: Response): () => void {
let set = listeners.get(userId);
if (!set) {
set = new Set();
listeners.set(userId, set);
}
set.add(res);
ensureTimers();
return () => {
set!.delete(res);
if (set!.size === 0) listeners.delete(userId);
};
}
function ensureTimers(): void {
if (!heartbeat) {
heartbeat = setInterval(() => {
for (const set of listeners.values()) {
for (const res of set) res.write(`: ping\n\n`);
}
}, 25_000);
heartbeat.unref?.();
}
if (!sweep) {
// Background jobs write without a request; this catches them.
sweep = setInterval(() => {
generation++;
void reconcileListeners();
}, 60_000);
sweep.unref?.();
}
}
/** Test seam. */
export function resetFeed(): void {
snapshots.clear();
listeners.clear();
locks.clear();
generation = 0;
for (const timer of [debounce, heartbeat, sweep]) {
if (timer) clearTimeout(timer);
}
debounce = heartbeat = sweep = null;
}
+250
View File
@@ -0,0 +1,250 @@
/**
* Applying a desktop's pushed changes on the server.
*
* Each op names the revision its sender last saw. If the server has moved on
* since, the op is a conflict and the server's version is sent back instead
* of being overwritten. Every op gets its own result, so one bad row never
* holds up the rest of the batch.
*/
import type { SyncRow } from "@termix/plugin-sdk/backend";
import { getEntity } from "../../plugins/sync-registry.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { syncLogger } from "../../utils/logger.js";
import {
createResolvers,
deleteStoredRow,
loadWireRow,
writeWireRow,
} from "../store.js";
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
import { orderSelfReferences } from "../wire.js";
import { reconcileUser } from "./feed.js";
export interface PushOp {
entityType: string;
syncId: string;
baseRevision: number;
op: "upsert" | "delete";
row?: SyncRow;
}
export type PushResult =
| { 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 const MAX_PUSH_OPS = 500;
function isPushOp(value: unknown): value is PushOp {
if (!value || typeof value !== "object") return false;
const op = value as Record<string, unknown>;
return (
typeof op.entityType === "string" &&
typeof op.syncId === "string" &&
op.syncId.length > 0 &&
op.syncId.length <= 191 &&
typeof op.baseRevision === "number" &&
(op.op === "upsert" || op.op === "delete") &&
(op.op === "delete" || (!!op.row && typeof op.row === "object"))
);
}
export function parsePushOps(body: unknown): PushOp[] | null {
const ops = (body as { ops?: unknown })?.ops;
if (!Array.isArray(ops) || ops.length > MAX_PUSH_OPS) return null;
return ops.every(isPushOp) ? ops : null;
}
/** Upserts in dependency order, then deletes in reverse, children first. */
export function orderOps(ops: PushOp[]): PushOp[] {
const order = (op: PushOp) => getEntity(op.entityType)?.order ?? 1_000_000;
const upserts = ops
.filter((op) => op.op === "upsert")
.sort((a, b) => order(a) - order(b));
const deletes = ops
.filter((op) => op.op === "delete")
.sort((a, b) => order(b) - order(a));
const orderedUpserts: PushOp[] = [];
const byType = new Map<string, PushOp[]>();
for (const op of upserts) {
const list = byType.get(op.entityType) ?? [];
list.push(op);
byType.set(op.entityType, list);
}
for (const [type, list] of byType) {
const entity = getEntity(type);
if (!entity) {
orderedUpserts.push(...list);
continue;
}
const bySyncId = new Map(list.map((op) => [op.syncId, op]));
const rows = orderSelfReferences(
entity,
list.map((op) => ({ ...(op.row as SyncRow), syncId: op.syncId })),
);
for (const row of rows) {
orderedUpserts.push(bySyncId.get(row.syncId as string)!);
}
}
return [...orderedUpserts, ...deletes];
}
/** Call inside withUserLock. */
export async function applyPush(
userId: string,
ops: PushOp[],
): Promise<PushResult[]> {
const { wire } = await reconcileUser(userId, { force: true });
const permissions = PermissionManager.getInstance();
let missed = false;
const resolvers = createResolvers(userId, () => {
missed = true;
});
const results = new Map<string, PushResult>();
// Rows written before something they point at, for a second pass.
const retry: PushOp[] = [];
for (const op of orderOps(ops)) {
const key = recordKey(op.entityType, op.syncId);
const base = { syncId: op.syncId, entityType: op.entityType };
const entity = getEntity(op.entityType);
if (!entity) {
results.set(key, { ...base, status: "rejected", reason: "unknown_type" });
continue;
}
if (entity.readOnly) {
results.set(key, { ...base, status: "rejected", reason: "read_only" });
continue;
}
const record = await getRecord(userId, op.entityType, op.syncId);
const currentRevision = record?.revision ?? 0;
if (op.baseRevision !== currentRevision) {
results.set(key, {
...base,
status: "conflict",
revision: currentRevision,
deleted: !!record?.deleted,
row: record?.deleted ? null : (wire.get(key) ?? null),
});
continue;
}
const exists = !!record && !record.deleted;
const permission =
op.op === "delete"
? entity.permissions?.delete
: exists
? entity.permissions?.update
: entity.permissions?.create;
if (permission && !(await permissions.hasPermission(userId, permission))) {
results.set(key, { ...base, status: "rejected", reason: "permission" });
continue;
}
try {
if (op.op === "delete") {
await deleteStoredRow(entity, userId, op.syncId);
await putRecord(userId, op.entityType, op.syncId, {
revision: currentRevision + 1,
seq: await allocateSeq(),
hash: null,
deleted: true,
});
wire.delete(key);
results.set(key, {
...base,
status: "ok",
revision: currentRevision + 1,
});
continue;
}
const row = { ...(op.row as SyncRow), syncId: op.syncId };
if (entity.shouldSync && entity.shouldSync(row) === false) {
results.set(key, { ...base, status: "rejected", reason: "not_synced" });
continue;
}
missed = false;
await writeWireRow(entity, userId, row, resolvers);
if (missed) retry.push(op);
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
await putRecord(userId, op.entityType, op.syncId, {
revision: currentRevision + 1,
seq: await allocateSeq(),
hash: written?.hash ?? null,
deleted: false,
});
if (written) wire.set(key, written.wire);
results.set(key, {
...base,
status: "ok",
revision: currentRevision + 1,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
syncLogger.warn("Rejected a pushed sync change", {
operation: "sync_push",
userId,
entityType: op.entityType,
syncId: op.syncId,
error: reason,
});
results.set(key, {
...base,
status: "rejected",
reason: `invalid: ${reason}`.slice(0, 500),
});
}
}
for (const op of retry) {
const entity = getEntity(op.entityType)!;
try {
await writeWireRow(
entity,
userId,
{ ...(op.row as SyncRow), syncId: op.syncId },
resolvers,
);
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
const record = await getRecord(userId, op.entityType, op.syncId);
if (record && written) {
await putRecord(userId, op.entityType, op.syncId, {
revision: record.revision,
seq: record.seq,
hash: written.hash,
deleted: false,
});
}
} catch {
// The first write stands.
}
}
// Refresh the snapshot so the next pull serves what was just written.
await reconcileUser(userId, { force: true });
return ops.map(
(op) =>
results.get(recordKey(op.entityType, op.syncId)) ?? {
syncId: op.syncId,
entityType: op.entityType,
status: "rejected",
reason: "duplicate",
},
);
}
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more