mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
+5
-5
@@ -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>;
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Bumped when a desktop and server can no longer understand each other. */
|
||||
export const SYNC_PROTOCOL_VERSION = 1;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import * as tar from "tar";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { getLocalVersion } from "../../utils/app-version.js";
|
||||
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import {
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { resolveBrandingSettings } from "../../database/routes/branding-settings.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import { listChanges, recordKey } from "../records.js";
|
||||
import { addListener, reconcileUser, withUserLock } from "./feed.js";
|
||||
import { applyPush, parsePushOps } from "./push.js";
|
||||
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
|
||||
|
||||
const SESSION_DAYS = 365;
|
||||
const REFRESH_WITHIN_MS = 60 * 24 * 60 * 60 * 1000;
|
||||
const MAX_PAGE = 1000;
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
registerCoreSyncEntities();
|
||||
|
||||
function userOf(req: Request): string {
|
||||
return (req as AuthenticatedRequest).userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/info:
|
||||
* get:
|
||||
* summary: Describe this server to a desktop that wants to link to it
|
||||
* description: Public. Lets the desktop app confirm it reached Termix and not a proxy's login page, and that the sync protocol matches.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server name, version and sync protocol version.
|
||||
*/
|
||||
router.get("/v2/info", async (_req: Request, res: Response) => {
|
||||
let name = "Termix";
|
||||
try {
|
||||
name = (await resolveBrandingSettings(createCurrentSettingsRepository()))
|
||||
.appName;
|
||||
} catch {
|
||||
// The default name is fine.
|
||||
}
|
||||
res.setHeader("X-Termix-Sync", String(SYNC_PROTOCOL_VERSION));
|
||||
res.json({
|
||||
termix: true,
|
||||
name,
|
||||
version: getLocalVersion(),
|
||||
protocol: SYNC_PROTOCOL_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/link:
|
||||
* post:
|
||||
* summary: Link a desktop to this account
|
||||
* description: Trades the short login session the desktop just signed in with for a long-lived desktop session, which shows in the account's sessions list and is revoked like any other. The login session is ended.
|
||||
* tags:
|
||||
* - Sync
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* deviceName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The desktop session token and the account it belongs to.
|
||||
* 401:
|
||||
* description: Not signed in.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/link",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) return res.status(401).json({ error: "User not found" });
|
||||
const rawName =
|
||||
typeof req.body?.deviceName === "string" ? req.body.deviceName : "";
|
||||
const deviceName = rawName.trim().slice(0, 100) || "Termix Desktop";
|
||||
const token = await authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${SESSION_DAYS}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: `${deviceName} (sync)`,
|
||||
});
|
||||
const loginSession = (req as AuthenticatedRequest).sessionId;
|
||||
if (loginSession) await authManager.revokeSession(loginSession);
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, username: user.username },
|
||||
server: { version: getLocalVersion() },
|
||||
});
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to link a desktop", error, {
|
||||
operation: "sync_link",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to link" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/session/refresh:
|
||||
* post:
|
||||
* summary: Keep a linked desktop's session from expiring
|
||||
* description: Returns a fresh desktop session when the current one ends within 60 days, and ends the old one. Otherwise returns null and the desktop keeps its token.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new token, or null.
|
||||
* 401:
|
||||
* description: Session revoked or expired.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/session/refresh",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
try {
|
||||
if (!sessionId) return res.json({ token: null });
|
||||
const session =
|
||||
await createCurrentSessionRepository().findById(sessionId);
|
||||
if (!session) return res.status(401).json({ error: "Session not found" });
|
||||
const remaining = new Date(session.expiresAt).getTime() - Date.now();
|
||||
if (remaining > REFRESH_WITHIN_MS) return res.json({ token: null });
|
||||
const token = await authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${SESSION_DAYS}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: session.deviceInfo,
|
||||
});
|
||||
await authManager.revokeSession(sessionId);
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to refresh a desktop session", error, {
|
||||
operation: "sync_session_refresh",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to refresh session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/unlink:
|
||||
* post:
|
||||
* summary: End a linked desktop's session
|
||||
* description: Called by a desktop when it unlinks, so its session stops working here right away.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The session was ended.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/unlink",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
try {
|
||||
if (sessionId) await authManager.revokeSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to end a desktop session", error, {
|
||||
operation: "sync_unlink",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unlink" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/changes:
|
||||
* get:
|
||||
* summary: Pull what changed for this account after a cursor
|
||||
* description: Returns records in change order. Each has the entity type, syncId, revision, whether it is deleted, and the row when it is not. Records of entity types this server does not currently run are skipped but still move the cursor.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: cursor
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: types
|
||||
* description: Comma-separated entity types, to backfill only those.
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A page of changes, the next cursor and whether more remain.
|
||||
* 500:
|
||||
* description: Failed to read changes.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/changes",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const cursor = Math.max(0, Number(req.query.cursor) || 0);
|
||||
const limit = Math.min(
|
||||
MAX_PAGE,
|
||||
Math.max(1, Number(req.query.limit) || MAX_PAGE),
|
||||
);
|
||||
const types =
|
||||
typeof req.query.types === "string" && req.query.types
|
||||
? req.query.types.split(",").filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const page = await withUserLock(userId, async () => {
|
||||
const { wire } = await reconcileUser(userId);
|
||||
const records = await listChanges(userId, cursor, limit + 1, types);
|
||||
return { wire, records };
|
||||
});
|
||||
const hasMore = page.records.length > limit;
|
||||
const records = page.records.slice(0, limit);
|
||||
const changes = [];
|
||||
for (const record of records) {
|
||||
if (!getEntity(record.entityType)) continue;
|
||||
const row = record.deleted
|
||||
? null
|
||||
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
|
||||
null);
|
||||
// A live record with no row failed to serialize; it comes again once it
|
||||
// changes, rather than arriving as a delete.
|
||||
if (!record.deleted && !row) continue;
|
||||
changes.push({
|
||||
seq: record.seq,
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
revision: record.revision,
|
||||
deleted: !!record.deleted,
|
||||
row,
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
changes,
|
||||
nextCursor: records.length ? records[records.length - 1].seq : cursor,
|
||||
hasMore,
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read sync changes", error, {
|
||||
operation: "sync_changes",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read changes" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/push:
|
||||
* post:
|
||||
* summary: Push a desktop's changes
|
||||
* description: Each op carries the revision the desktop last saw. Ops are applied in dependency order and each gets its own result, ok with the new revision, conflict with the server's version, or rejected with a reason.
|
||||
* tags:
|
||||
* - Sync
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ops:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: One result per op, in the order sent.
|
||||
* 400:
|
||||
* description: Malformed ops, or more than 500.
|
||||
* 500:
|
||||
* description: Failed to apply changes.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/push",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const ops = parsePushOps(req.body);
|
||||
if (!ops) return res.status(400).json({ error: "Invalid ops" });
|
||||
try {
|
||||
const results = await withUserLock(userId, () => applyPush(userId, ops));
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to apply pushed changes", error, {
|
||||
operation: "sync_push",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply changes" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/events:
|
||||
* get:
|
||||
* summary: Stream a notice whenever this account's data changes
|
||||
* description: Server-sent events. A "change" event means there is something new to pull. Comments are sent every 25 seconds to keep proxies from closing the stream.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: An event stream.
|
||||
*/
|
||||
router.get("/v2/events", authenticateJWT, (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
res.write(`event: ready\ndata: {}\n\n`);
|
||||
const remove = addListener(userId, res);
|
||||
req.on("close", remove);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/hosts/{syncId}:
|
||||
* get:
|
||||
* summary: This server's id for a host, by its sync id
|
||||
* description: For a linked desktop that asks the server to act on a host (a tunnel through it), which needs the server's own id. Only for hosts the caller can see.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: syncId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The host's id and name.
|
||||
* 404:
|
||||
* description: No such host the caller can see.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/hosts/:syncId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
try {
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncId(
|
||||
String(req.params.syncId),
|
||||
);
|
||||
if (hostId === null) return res.status(404).json({ error: "Not found" });
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"view",
|
||||
);
|
||||
if (!access.hasAccess)
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const row = await createCurrentHostRepository().findById(hostId);
|
||||
res.json({ id: hostId, name: row?.name ?? row?.ip ?? null });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to look up a host by sync id", error, {
|
||||
operation: "sync_host_lookup",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to look up host" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/host-status:
|
||||
* get:
|
||||
* summary: Status of the hosts this account can see, keyed by sync id
|
||||
* description: For a linked desktop whose hosts connect through this server, so it can show their status against its own copies.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Status entries by host sync id.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/host-status",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
try {
|
||||
const { hostStatusService } =
|
||||
await import("../../hosts/status/host-status-service.js");
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const statuses = await hostStatusService.statusesFor(userId, null);
|
||||
const entries = [...statuses.entries()];
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
entries.map(([id]) => id),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [id, entry] of entries) {
|
||||
if (!allowed.has(id)) continue;
|
||||
const row = await hostRepository.findById(id);
|
||||
if (row?.syncId) result[row.syncId] = entry;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read host status for sync", error, {
|
||||
operation: "sync_host_status",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read host status" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function listPluginsForDesktop() {
|
||||
return getPluginRuntime().loader.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/plugins:
|
||||
* get:
|
||||
* summary: The features this server runs, for a linked desktop to mirror
|
||||
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin list.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/plugins",
|
||||
authenticateJWT,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const grants = createCurrentPluginPermissionGrantRepository();
|
||||
const plugins = [];
|
||||
for (const plugin of await listPluginsForDesktop()) {
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
enabled: records.get(plugin.id)?.state === "enabled",
|
||||
active: plugin.state === "active",
|
||||
desktop: plugin.manifest.desktop ?? "mirror",
|
||||
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
|
||||
granted: (await grants.listByPlugin(plugin.id)).map(
|
||||
(grant) => grant.capability,
|
||||
),
|
||||
});
|
||||
}
|
||||
res.json({ plugins });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to list plugins for sync", error, {
|
||||
operation: "sync_plugins",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list plugins" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/plugins/{id}/package:
|
||||
* get:
|
||||
* summary: Download an installed plugin so a linked desktop can install it
|
||||
* description: Only for plugins installed on this server, not bundled ones. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin archive.
|
||||
* 404:
|
||||
* description: No installed plugin with that id.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/plugins/:id/package",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
try {
|
||||
const plugin = (await listPluginsForDesktop()).find(
|
||||
(candidate) => candidate.id === pluginId,
|
||||
);
|
||||
if (!plugin || plugin.source !== "user") {
|
||||
return res.status(404).json({ error: "Plugin not found" });
|
||||
}
|
||||
const { getPluginsDir } = await import("../../plugins/paths.js");
|
||||
const artifact = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
if (fs.existsSync(artifact)) {
|
||||
const sig = `${artifact}.sig`;
|
||||
if (fs.existsSync(sig)) {
|
||||
res.setHeader(
|
||||
"X-Termix-Signature",
|
||||
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString(
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
}
|
||||
return fs.createReadStream(artifact).pipe(res);
|
||||
}
|
||||
const temp = path.join(
|
||||
os.tmpdir(),
|
||||
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
|
||||
);
|
||||
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
|
||||
const stream = fs.createReadStream(temp);
|
||||
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to package a plugin for sync", error, {
|
||||
operation: "sync_plugin_package",
|
||||
pluginId,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Failed to package plugin" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { createCurrentRepositoryContext } from "../database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* On a linked desktop, hosts and credentials someone shared with the account
|
||||
* are read-only copies kept in step by sync. Editing or deleting one here
|
||||
* would be undone by the next sync, so it is refused with a clear reason.
|
||||
*/
|
||||
export function rejectSharedCopyWrites(
|
||||
kind: "host" | "credential",
|
||||
pattern: RegExp,
|
||||
) {
|
||||
const table = kind === "host" ? hosts : sshCredentials;
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return next();
|
||||
if (req.method !== "PUT" && req.method !== "DELETE") return next();
|
||||
const match = req.path.match(pattern);
|
||||
if (!match) return next();
|
||||
try {
|
||||
const [row] = await createCurrentRepositoryContext()
|
||||
.drizzle.select({ sharedSource: table.sharedSource })
|
||||
.from(table)
|
||||
.where(eq(table.id, Number(match[1])))
|
||||
.limit(1);
|
||||
if (row?.sharedSource) {
|
||||
res.status(403).json({
|
||||
error: "This was shared with you and is managed on the server",
|
||||
code: "SHARED_COPY",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Let the route answer for itself.
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Reading and writing a registered entity's rows, whatever table they are in.
|
||||
*
|
||||
* Every registration names a drizzle table with an id, a syncId (unless it is
|
||||
* a singleton) and an owner column, and that is all this relies on. Writes
|
||||
* read the row back by its key rather than using RETURNING, which MySQL lacks.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import { and, eq, getTableColumns, type SQL } from "drizzle-orm";
|
||||
import type { SyncRow } from "@termix/plugin-sdk/backend";
|
||||
import { createCurrentRepositoryContext } from "../database/repositories/factory.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||
import {
|
||||
getEntity,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../plugins/sync-registry.js";
|
||||
import {
|
||||
decryptFields,
|
||||
deserializeReferences,
|
||||
encryptFields,
|
||||
hashWire,
|
||||
serializeReferences,
|
||||
singletonSyncId,
|
||||
toWire,
|
||||
toWritable,
|
||||
type ResolveId,
|
||||
type ResolveSyncId,
|
||||
} from "./wire.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type AnyTable = any;
|
||||
|
||||
function tableOf(entity: RegisteredSyncEntity): AnyTable {
|
||||
if (!entity.table) {
|
||||
throw new Error(`Sync entity "${entity.type}" has no table`);
|
||||
}
|
||||
return entity.table as AnyTable;
|
||||
}
|
||||
|
||||
function db() {
|
||||
return createCurrentRepositoryContext().drizzle as any;
|
||||
}
|
||||
|
||||
function ownerColumn(entity: RegisteredSyncEntity) {
|
||||
const column = tableOf(entity)[entity.userColumn];
|
||||
if (!column) {
|
||||
throw new Error(
|
||||
`Sync entity "${entity.type}" has no ${entity.userColumn} column`,
|
||||
);
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
export function locate(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
): SQL {
|
||||
const table = tableOf(entity);
|
||||
if (entity.singleton) return eq(ownerColumn(entity), userId);
|
||||
return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!;
|
||||
}
|
||||
|
||||
export function rowSyncId(entity: RegisteredSyncEntity, row: SyncRow): string {
|
||||
return entity.singleton
|
||||
? singletonSyncId(entity.type)
|
||||
: (row.syncId as string);
|
||||
}
|
||||
|
||||
let hashKey: Buffer | null = null;
|
||||
|
||||
export async function getHashKey(): Promise<Buffer> {
|
||||
if (!hashKey) {
|
||||
const secret = await SystemCrypto.getInstance().getEncryptionKey();
|
||||
hashKey = Buffer.from(
|
||||
crypto.hkdfSync(
|
||||
"sha256",
|
||||
secret,
|
||||
Buffer.alloc(0),
|
||||
"termix-sync-hash",
|
||||
32,
|
||||
),
|
||||
);
|
||||
}
|
||||
return hashKey;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetHashKey(): void {
|
||||
hashKey = null;
|
||||
}
|
||||
|
||||
export async function listStoredRows(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
): Promise<SyncRow[]> {
|
||||
const rows = (await db()
|
||||
.select()
|
||||
.from(tableOf(entity))
|
||||
.where(eq(ownerColumn(entity), userId))) as SyncRow[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function findStoredRow(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
): Promise<SyncRow | null> {
|
||||
const [row] = (await db()
|
||||
.select()
|
||||
.from(tableOf(entity))
|
||||
.where(locate(entity, userId, syncId))
|
||||
.limit(1)) as SyncRow[];
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Gives an old row with no syncId one, so it can take part. */
|
||||
async function ensureSyncId(
|
||||
entity: RegisteredSyncEntity,
|
||||
row: SyncRow,
|
||||
): Promise<string> {
|
||||
if (entity.singleton) return singletonSyncId(entity.type);
|
||||
if (typeof row.syncId === "string" && row.syncId) return row.syncId;
|
||||
const syncId = crypto.randomUUID();
|
||||
const table = tableOf(entity);
|
||||
await db().update(table).set({ syncId }).where(eq(table.id, row.id));
|
||||
row.syncId = syncId;
|
||||
return syncId;
|
||||
}
|
||||
|
||||
export interface Resolvers {
|
||||
resolveSyncId: ResolveSyncId;
|
||||
resolveId: ResolveId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Id and syncId lookups for references, scoped to rows the user owns. A core
|
||||
* entity can widen that (a credential shared with the user is a valid
|
||||
* reference target on the server).
|
||||
*/
|
||||
export function createResolvers(
|
||||
userId: string,
|
||||
onMissing?: (entityType: string, syncId: string) => void,
|
||||
): Resolvers {
|
||||
const allowed = async (
|
||||
entityType: string,
|
||||
ownerId: unknown,
|
||||
id: number,
|
||||
): Promise<boolean> => {
|
||||
if (ownerId === userId) return true;
|
||||
const canReference = getEntity(entityType)?.canReference;
|
||||
return canReference ? canReference(userId, String(ownerId), id) : false;
|
||||
};
|
||||
|
||||
return {
|
||||
resolveSyncId: async (entityType, id) => {
|
||||
const target = getEntity(entityType);
|
||||
if (!target?.table || target.singleton) return null;
|
||||
const table = tableOf(target);
|
||||
const [row] = await db()
|
||||
.select({ syncId: table.syncId, owner: ownerColumn(target) })
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.limit(1);
|
||||
if (!row || !(await allowed(entityType, row.owner, id))) return null;
|
||||
return row.syncId ?? null;
|
||||
},
|
||||
resolveId: async (entityType, syncId) => {
|
||||
const target = getEntity(entityType);
|
||||
if (!target?.table || target.singleton) {
|
||||
onMissing?.(entityType, syncId);
|
||||
return null;
|
||||
}
|
||||
const table = tableOf(target);
|
||||
const rows = await db()
|
||||
.select({ id: table.id, owner: ownerColumn(target) })
|
||||
.from(table)
|
||||
.where(eq(table.syncId, syncId));
|
||||
const own = rows.find((row: any) => row.owner === userId);
|
||||
if (own) return own.id;
|
||||
for (const row of rows) {
|
||||
if (await allowed(entityType, row.owner, row.id)) return row.id;
|
||||
}
|
||||
onMissing?.(entityType, syncId);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface WireRow {
|
||||
syncId: string;
|
||||
wire: SyncRow;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface LoadResult {
|
||||
rows: Map<string, WireRow>;
|
||||
/** Rows that exist but could not be serialized, so must not read as deleted. */
|
||||
failed: Set<string>;
|
||||
}
|
||||
|
||||
/** The user's rows of one entity, on the wire and hashed. */
|
||||
export async function loadWireRows(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
resolvers: Resolvers,
|
||||
options: { filter?: (row: SyncRow) => boolean } = {},
|
||||
): Promise<LoadResult> {
|
||||
const key = await getHashKey();
|
||||
const rows = new Map<string, WireRow>();
|
||||
const failed = new Set<string>();
|
||||
|
||||
if (entity.load) {
|
||||
for (const wire of await entity.load(userId)) {
|
||||
const syncId = String(wire.syncId);
|
||||
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
||||
}
|
||||
return { rows, failed };
|
||||
}
|
||||
|
||||
const dataKey = DataCrypto.getUserDataKey(userId);
|
||||
for (const stored of await listStoredRows(entity, userId)) {
|
||||
if (entity.shouldSync && entity.shouldSync(stored) === false) continue;
|
||||
if (options.filter && !options.filter(stored)) continue;
|
||||
const syncId = await ensureSyncId(entity, stored);
|
||||
try {
|
||||
const plain = decryptFields(entity, stored, dataKey);
|
||||
const serialized = await serializeReferences(
|
||||
entity,
|
||||
plain,
|
||||
resolvers.resolveSyncId,
|
||||
);
|
||||
const wire = toWire(entity, serialized, syncId);
|
||||
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
||||
} catch {
|
||||
failed.add(syncId);
|
||||
}
|
||||
}
|
||||
return { rows, failed };
|
||||
}
|
||||
|
||||
/** One row on the wire, or null when it is gone or excluded. */
|
||||
export async function loadWireRow(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
resolvers: Resolvers,
|
||||
): Promise<WireRow | null> {
|
||||
if (entity.load) {
|
||||
const all = await loadWireRows(entity, userId, resolvers);
|
||||
return all.rows.get(syncId) ?? null;
|
||||
}
|
||||
const stored = await findStoredRow(entity, userId, syncId);
|
||||
if (!stored) return null;
|
||||
if (entity.shouldSync && entity.shouldSync(stored) === false) return null;
|
||||
const plain = decryptFields(
|
||||
entity,
|
||||
stored,
|
||||
DataCrypto.getUserDataKey(userId),
|
||||
);
|
||||
const serialized = await serializeReferences(
|
||||
entity,
|
||||
plain,
|
||||
resolvers.resolveSyncId,
|
||||
);
|
||||
const wire = toWire(entity, serialized, syncId);
|
||||
return { syncId, wire, hash: hashWire(entity, wire, await getHashKey()) };
|
||||
}
|
||||
|
||||
function columnsOf(entity: RegisteredSyncEntity): Set<string> {
|
||||
return new Set(Object.keys(getTableColumns(tableOf(entity))));
|
||||
}
|
||||
|
||||
export interface WriteResult {
|
||||
id: number | null;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an inbound wire row for the user. `extra` is set on top of the
|
||||
* inbound fields, for columns this side owns (a shared copy's source).
|
||||
*/
|
||||
export async function writeWireRow(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
wire: SyncRow,
|
||||
resolvers: Resolvers,
|
||||
extra: SyncRow = {},
|
||||
): Promise<WriteResult> {
|
||||
const syncId = String(wire.syncId);
|
||||
if (entity.write) {
|
||||
await entity.write(userId, wire);
|
||||
DatabaseSaveTrigger.triggerSave("sync_write");
|
||||
return { id: null, created: false };
|
||||
}
|
||||
const table = tableOf(entity);
|
||||
const current = await findStoredRow(entity, userId, syncId);
|
||||
|
||||
const resolved = await deserializeReferences(
|
||||
entity,
|
||||
wire,
|
||||
resolvers.resolveId,
|
||||
current,
|
||||
);
|
||||
const columns = columnsOf(entity);
|
||||
const writable = Object.fromEntries(
|
||||
Object.entries({ ...toWritable(entity, resolved), ...extra }).filter(
|
||||
([field]) => columns.has(field),
|
||||
),
|
||||
);
|
||||
const encrypted = encryptFields(
|
||||
entity,
|
||||
writable,
|
||||
DataCrypto.getUserDataKey(userId),
|
||||
current ? String(current.id) : `sync-${syncId}`,
|
||||
);
|
||||
if (columns.has("updatedAt")) encrypted.updatedAt = new Date().toISOString();
|
||||
|
||||
const where = locate(entity, userId, syncId);
|
||||
if (current) {
|
||||
await db().update(table).set(encrypted).where(where);
|
||||
} else {
|
||||
const insert: SyncRow = { ...encrypted, [entity.userColumn]: userId };
|
||||
if (!entity.singleton) insert.syncId = syncId;
|
||||
await db().insert(table).values(insert);
|
||||
}
|
||||
|
||||
const written = await findStoredRow(entity, userId, syncId);
|
||||
const id = typeof written?.id === "number" ? written.id : null;
|
||||
if (entity.afterWrite) {
|
||||
await entity.afterWrite({ id, userId, wire, created: !current });
|
||||
}
|
||||
DatabaseSaveTrigger.triggerSave("sync_write");
|
||||
return { id, created: !current };
|
||||
}
|
||||
|
||||
/** Deletes the user's row, through the entity's own remove when it has one. */
|
||||
export async function deleteStoredRow(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
): Promise<boolean> {
|
||||
if (entity.erase) {
|
||||
await entity.erase(userId, syncId);
|
||||
DatabaseSaveTrigger.triggerSave("sync_delete");
|
||||
return true;
|
||||
}
|
||||
const current = await findStoredRow(entity, userId, syncId);
|
||||
if (!current) return false;
|
||||
if (entity.remove) {
|
||||
await entity.remove(current, userId);
|
||||
} else {
|
||||
await db()
|
||||
.delete(tableOf(entity))
|
||||
.where(locate(entity, userId, syncId));
|
||||
}
|
||||
DatabaseSaveTrigger.triggerSave("sync_delete");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sets fresh syncIds on every row the user owns, so they read as new. */
|
||||
export async function regenerateSyncIds(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
if (entity.singleton || entity.load || !entity.table) return;
|
||||
const table = tableOf(entity);
|
||||
for (const row of await listStoredRows(entity, userId)) {
|
||||
await db()
|
||||
.update(table)
|
||||
.set({ syncId: crypto.randomUUID() })
|
||||
.where(eq(table.id, row.id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Turning a stored row into its wire form and back.
|
||||
*
|
||||
* On the wire a row has plaintext secrets, a syncId, and syncIds in place of
|
||||
* every local numeric id it references. Local ids mean nothing on the other
|
||||
* side of a link, and secrets are encrypted with a key only this side has.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { SyncEntityReference, SyncRow } from "@termix/plugin-sdk/backend";
|
||||
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import type { RegisteredSyncEntity } from "../plugins/sync-registry.js";
|
||||
|
||||
export type ResolveSyncId = (
|
||||
entityType: string,
|
||||
id: number,
|
||||
) => Promise<string | null>;
|
||||
export type ResolveId = (
|
||||
entityType: string,
|
||||
syncId: string,
|
||||
) => Promise<number | null>;
|
||||
|
||||
/** Keys that describe where a row lives rather than what it says. */
|
||||
const LOCATION_KEYS = ["id", "syncId", "createdAt", "updatedAt"];
|
||||
|
||||
export function singletonSyncId(entityType: string): string {
|
||||
return `${entityType}:singleton`;
|
||||
}
|
||||
|
||||
interface JsonPath {
|
||||
column: string;
|
||||
steps: Array<{ key: string; each: boolean }>;
|
||||
}
|
||||
|
||||
/** "jumpHosts[].hostId" -> column jumpHosts, then each item, then hostId. */
|
||||
export function parseReferencePath(field: string): JsonPath | null {
|
||||
if (!field.includes(".") && !field.includes("[]")) return null;
|
||||
const parts = field.split(".");
|
||||
const head = parts.shift()!;
|
||||
const headEach = head.endsWith("[]");
|
||||
const column = headEach ? head.slice(0, -2) : head;
|
||||
const steps: JsonPath["steps"] = [];
|
||||
if (headEach) steps.push({ key: "", each: true });
|
||||
for (const part of parts) {
|
||||
const each = part.endsWith("[]");
|
||||
steps.push({ key: each ? part.slice(0, -2) : part, each });
|
||||
}
|
||||
return { column, steps };
|
||||
}
|
||||
|
||||
async function rewritePath(
|
||||
value: unknown,
|
||||
steps: JsonPath["steps"],
|
||||
map: (leaf: unknown) => Promise<unknown>,
|
||||
): Promise<unknown> {
|
||||
if (steps.length === 0) return map(value);
|
||||
const [step, ...rest] = steps;
|
||||
|
||||
if (step.key === "" && step.each) {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return Promise.all(value.map((item) => rewritePath(item, rest, map)));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const object = value as Record<string, unknown>;
|
||||
if (!(step.key in object)) return value;
|
||||
|
||||
const child = object[step.key];
|
||||
if (step.each) {
|
||||
if (!Array.isArray(child)) return value;
|
||||
return {
|
||||
...object,
|
||||
[step.key]: await Promise.all(
|
||||
child.map((item) => rewritePath(item, rest, map)),
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ...object, [step.key]: await rewritePath(child, rest, map) };
|
||||
}
|
||||
|
||||
/** Rewrites every leaf a JSON path points at, keeping the column's storage form. */
|
||||
async function rewriteJsonColumn(
|
||||
row: SyncRow,
|
||||
path: JsonPath,
|
||||
map: (leaf: unknown) => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const stored = row[path.column];
|
||||
if (stored == null || stored === "") return;
|
||||
|
||||
let parsed: unknown = stored;
|
||||
const asString = typeof stored === "string";
|
||||
if (asString) {
|
||||
try {
|
||||
parsed = JSON.parse(stored as string);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rewritten = await rewritePath(parsed, path.steps, map);
|
||||
row[path.column] = asString ? JSON.stringify(rewritten) : rewritten;
|
||||
}
|
||||
|
||||
function wireField(reference: SyncEntityReference): string {
|
||||
return reference.syncField ?? `${reference.field}SyncId`;
|
||||
}
|
||||
|
||||
/** Local ids to syncIds. */
|
||||
export async function serializeReferences(
|
||||
entity: RegisteredSyncEntity,
|
||||
row: SyncRow,
|
||||
resolveSyncId: ResolveSyncId,
|
||||
): Promise<SyncRow> {
|
||||
const out = { ...row };
|
||||
for (const reference of entity.references ?? []) {
|
||||
const path = parseReferencePath(reference.field);
|
||||
if (path) {
|
||||
await rewriteJsonColumn(out, path, async (leaf) => {
|
||||
const id = typeof leaf === "string" ? Number(leaf) : leaf;
|
||||
if (typeof id !== "number" || !Number.isInteger(id)) return leaf;
|
||||
return (await resolveSyncId(reference.entityType, id)) ?? null;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const id = out[reference.field];
|
||||
out[wireField(reference)] =
|
||||
typeof id === "number"
|
||||
? await resolveSyncId(reference.entityType, id)
|
||||
: null;
|
||||
delete out[reference.field];
|
||||
}
|
||||
return entity.serialize ? entity.serialize(out, resolveSyncId) : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* SyncIds back to local ids.
|
||||
*
|
||||
* A syncId with no local row keeps whatever the column already holds here
|
||||
* (`current`), rather than failing the whole row. That happens when the
|
||||
* target's category is not synced on this device, or it has not arrived yet.
|
||||
*/
|
||||
export async function deserializeReferences(
|
||||
entity: RegisteredSyncEntity,
|
||||
row: SyncRow,
|
||||
resolveId: ResolveId,
|
||||
current: SyncRow | null,
|
||||
): Promise<SyncRow> {
|
||||
const out = { ...row };
|
||||
for (const reference of entity.references ?? []) {
|
||||
const path = parseReferencePath(reference.field);
|
||||
if (path) {
|
||||
await rewriteJsonColumn(out, path, async (leaf) => {
|
||||
if (typeof leaf !== "string" || !leaf) return leaf;
|
||||
const id = await resolveId(reference.entityType, leaf);
|
||||
if (id === null) return null;
|
||||
return reference.idType === "string" ? String(id) : id;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const field = wireField(reference);
|
||||
const syncId = out[field];
|
||||
delete out[field];
|
||||
delete out[reference.field];
|
||||
if (syncId == null || syncId === "") {
|
||||
out[reference.field] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof syncId !== "string") continue;
|
||||
const id = await resolveId(reference.entityType, syncId);
|
||||
if (id !== null) {
|
||||
out[reference.field] = id;
|
||||
} else if (current && reference.field in current) {
|
||||
out[reference.field] = current[reference.field];
|
||||
} else {
|
||||
out[reference.field] = null;
|
||||
}
|
||||
}
|
||||
return entity.deserialize ? entity.deserialize(out, resolveId) : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the entity's secret fields. Throws when one cannot be decrypted:
|
||||
* sending an empty value instead would wipe the secret on the other side.
|
||||
*/
|
||||
export function decryptFields(
|
||||
entity: RegisteredSyncEntity,
|
||||
row: SyncRow,
|
||||
key: Buffer | null,
|
||||
): SyncRow {
|
||||
if (entity.encryptedFields.length === 0) return row;
|
||||
const out = { ...row };
|
||||
for (const field of entity.encryptedFields) {
|
||||
const value = out[field];
|
||||
if (typeof value !== "string" || !FieldCrypto.isEncrypted(value)) continue;
|
||||
if (!key) throw new Error("No data key to decrypt synced fields");
|
||||
out[field] = FieldCrypto.decryptField(value, key, String(row.id), field);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encryptFields(
|
||||
entity: RegisteredSyncEntity,
|
||||
row: SyncRow,
|
||||
key: Buffer | null,
|
||||
recordId: string,
|
||||
): SyncRow {
|
||||
if (entity.encryptedFields.length === 0) return row;
|
||||
const out = { ...row };
|
||||
for (const field of entity.encryptedFields) {
|
||||
const value = out[field];
|
||||
if (typeof value !== "string" || !value) continue;
|
||||
if (FieldCrypto.isEncrypted(value)) continue;
|
||||
if (!key) throw new Error("No data key to encrypt synced fields");
|
||||
out[field] = FieldCrypto.encryptField(value, key, recordId, field);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What goes on the wire: no local ids, no owner, no fields that stay here. */
|
||||
export function toWire(
|
||||
entity: RegisteredSyncEntity,
|
||||
row: SyncRow,
|
||||
syncId: string,
|
||||
): SyncRow {
|
||||
const out = { ...row };
|
||||
delete out.id;
|
||||
delete out[entity.userColumn];
|
||||
if (entity.userColumn !== "userId") delete out.userId;
|
||||
for (const field of entity.readOnlyFields) delete out[field];
|
||||
out.syncId = syncId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What may be written from an inbound row. */
|
||||
export function toWritable(
|
||||
entity: RegisteredSyncEntity,
|
||||
wire: SyncRow,
|
||||
): SyncRow {
|
||||
const out = { ...wire };
|
||||
delete out.id;
|
||||
delete out.syncId;
|
||||
delete out.userId;
|
||||
delete out[entity.userColumn];
|
||||
delete out.createdAt;
|
||||
delete out.updatedAt;
|
||||
for (const field of entity.readOnlyFields) delete out[field];
|
||||
for (const field of entity.hashIgnore ?? []) delete out[field];
|
||||
return out;
|
||||
}
|
||||
|
||||
function stable(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (value && typeof value === "object") {
|
||||
const object = value as Record<string, unknown>;
|
||||
return Object.keys(object)
|
||||
.sort()
|
||||
.reduce<Record<string, unknown>>((acc, key) => {
|
||||
if (object[key] !== undefined) acc[key] = stable(object[key]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booleans are stored as 0/1 on SQLite and true/false elsewhere, and JSON
|
||||
* columns come back as text or parsed depending on the driver. Leveled here
|
||||
* so the same content always hashes the same.
|
||||
*/
|
||||
function normalize(value: unknown): unknown {
|
||||
if (value === true) return 1;
|
||||
if (value === false) return 0;
|
||||
if (value === undefined) return null;
|
||||
if (typeof value === "string" && /^[[{]/.test(value)) {
|
||||
try {
|
||||
return stable(JSON.parse(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return stable(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content hash of a wire row, keyed so a stored hash cannot be used to guess
|
||||
* a weak secret it covers.
|
||||
*/
|
||||
export function hashWire(
|
||||
entity: RegisteredSyncEntity,
|
||||
wire: SyncRow,
|
||||
key: Buffer,
|
||||
): string {
|
||||
const skip = new Set([
|
||||
...LOCATION_KEYS,
|
||||
...entity.readOnlyFields,
|
||||
...(entity.hashIgnore ?? []),
|
||||
]);
|
||||
const content: Record<string, unknown> = {};
|
||||
for (const [field, value] of Object.entries(wire)) {
|
||||
if (skip.has(field)) continue;
|
||||
const normalized = normalize(value);
|
||||
if (normalized === null || normalized === "") continue;
|
||||
content[field] = normalized;
|
||||
}
|
||||
return crypto
|
||||
.createHmac("sha256", key)
|
||||
.update(JSON.stringify(stable(content)))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** Parents before children when an entity references itself. */
|
||||
export function orderSelfReferences(
|
||||
entity: RegisteredSyncEntity,
|
||||
rows: SyncRow[],
|
||||
): SyncRow[] {
|
||||
const self = (entity.references ?? []).find(
|
||||
(reference) =>
|
||||
reference.entityType === entity.type &&
|
||||
!parseReferencePath(reference.field),
|
||||
);
|
||||
if (!self) return rows;
|
||||
const field = wireField(self);
|
||||
const bySyncId = new Map(
|
||||
rows
|
||||
.filter((row) => typeof row.syncId === "string")
|
||||
.map((row) => [row.syncId as string, row]),
|
||||
);
|
||||
const ordered: SyncRow[] = [];
|
||||
const done = new Set<SyncRow>();
|
||||
const visiting = new Set<SyncRow>();
|
||||
const visit = (row: SyncRow) => {
|
||||
if (done.has(row) || visiting.has(row)) return;
|
||||
visiting.add(row);
|
||||
const parent = row[field];
|
||||
if (typeof parent === "string") {
|
||||
const parentRow = bySyncId.get(parent);
|
||||
if (parentRow) visit(parentRow);
|
||||
}
|
||||
visiting.delete(row);
|
||||
done.add(row);
|
||||
ordered.push(row);
|
||||
};
|
||||
rows.forEach(visit);
|
||||
return ordered;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
|
||||
|
||||
describe("SyncTombstoneRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SyncTombstoneRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new SyncTombstoneRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("records a tombstone and lists it back for the owning user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.record("user-1", "hosts", "sync-abc");
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
entityType: "hosts",
|
||||
syncId: "sync-abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record a tombstone for an empty syncId", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.record("user-1", "hosts", "");
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("recordMany writes multiple tombstones and filters out falsy ids", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]);
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("recordMany is a no-op when given no syncIds", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.recordMany("user-1", "hosts", []);
|
||||
expect(writeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("scopes listSince by userId and entityType", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.record("user-1", "hosts", "sync-1");
|
||||
await repo.record("user-1", "snippets", "sync-2");
|
||||
await repo.record("user-2", "hosts", "sync-3");
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].syncId).toBe("sync-1");
|
||||
});
|
||||
|
||||
it("filters listSince by the since timestamp", async () => {
|
||||
const adapterLocal = new TestSqliteDatabase();
|
||||
adapter = adapterLocal;
|
||||
const context = await adapterLocal.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash');
|
||||
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at)
|
||||
VALUES
|
||||
('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'),
|
||||
('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z');
|
||||
`);
|
||||
const repo = new SyncTombstoneRepository(context);
|
||||
|
||||
const rows = await repo.listSince(
|
||||
"user-1",
|
||||
"hosts",
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].syncId).toBe("new");
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
|
||||
import {
|
||||
normalizeSyncTimestamp,
|
||||
timestampAtOrAfter,
|
||||
} from "../../../database/sync-timestamp.js";
|
||||
import { sshCredentials } from "../../../database/db/schema.js";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
// The desktop sync engine always sends its cursor as new Date().toISOString().
|
||||
const ISO_CURSOR = "2026-07-29T09:00:00.000Z";
|
||||
|
||||
describe("sync cursors across timestamp layouts", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The harness migrates the schema itself, so the seeds below are INSERTs into
|
||||
* the real tables. Both `sync_tombstones.user_id` and `ssh_credentials.user_id`
|
||||
* are foreign keys into `users`, which the harness enforces, so the owning row
|
||||
* has to exist before either seed runs.
|
||||
*/
|
||||
async function connect(): Promise<{
|
||||
db: TestSqliteDatabase;
|
||||
context: DatabaseContext;
|
||||
}> {
|
||||
const db = new TestSqliteDatabase();
|
||||
adapter = db;
|
||||
const context = await db.connect();
|
||||
await db.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user', 'hash');
|
||||
`);
|
||||
return { db, context };
|
||||
}
|
||||
|
||||
it("normalizes both layouts to one comparable form", () => {
|
||||
expect(normalizeSyncTimestamp("2026-07-29T10:11:21.123Z")).toBe(
|
||||
"2026-07-29 10:11:21",
|
||||
);
|
||||
expect(normalizeSyncTimestamp("2026-07-29 10:11:21")).toBe(
|
||||
"2026-07-29 10:11:21",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns tombstones recorded after an ISO cursor, whatever layout they were stored in", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) VALUES
|
||||
('user-1', 'sshCredentials', 'sqlite-layout', '2026-07-29 10:07:32'),
|
||||
('user-1', 'sshCredentials', 'iso-layout', '2026-07-29T10:07:32.500Z'),
|
||||
('user-1', 'sshCredentials', 'too-old', '2026-07-29 08:00:00');
|
||||
`);
|
||||
|
||||
const repo = new SyncTombstoneRepository(context);
|
||||
const rows = await repo.listSince("user-1", "sshCredentials", ISO_CURSOR);
|
||||
|
||||
expect(rows.map((row) => row.syncId).sort()).toEqual([
|
||||
"iso-layout",
|
||||
"sqlite-layout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns rows written by CURRENT_TIMESTAMP against an ISO cursor", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at) VALUES
|
||||
('user-1', 'newer-sqlite-layout', 'password', '2026-07-29 10:11:21'),
|
||||
('user-1', 'newer-iso-layout', 'password', '2026-07-29T10:11:21.123Z'),
|
||||
('user-1', 'older', 'password', '2026-07-29 08:59:59');
|
||||
`);
|
||||
|
||||
const rows = await context.drizzle
|
||||
.select({ name: sshCredentials.name })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, "user-1"),
|
||||
timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR),
|
||||
),
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.name).sort()).toEqual([
|
||||
"newer-iso-layout",
|
||||
"newer-sqlite-layout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps rows written in the same second as the cursor", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at)
|
||||
VALUES ('user-1', 'same-second', 'password', '2026-07-29 09:00:00');
|
||||
`);
|
||||
|
||||
const rows = await context.drizzle
|
||||
.select({ name: sshCredentials.name })
|
||||
.from(sshCredentials)
|
||||
.where(timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR));
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual(["same-second"]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { Request } from "express";
|
||||
import type { UserRecord } from "../../../database/repositories/user-repository.js";
|
||||
import {
|
||||
allowsDesktopAutoSession,
|
||||
isLoopbackRequest,
|
||||
extractBearerOrCookieToken,
|
||||
isNativeTokenExportRequest,
|
||||
@@ -184,3 +185,20 @@ describe("resolveDesktopAutoSessionUser", () => {
|
||||
expect(result).toBe(earliest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowsDesktopAutoSession", () => {
|
||||
it("allows the embedded desktop backend and a development backend", () => {
|
||||
expect(
|
||||
allowsDesktopAutoSession({
|
||||
ELECTRON_EMBEDDED: "true",
|
||||
NODE_ENV: "production",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(allowsDesktopAutoSession({ NODE_ENV: "development" })).toBe(true);
|
||||
expect(allowsDesktopAutoSession({})).toBe(true);
|
||||
});
|
||||
|
||||
it("never allows it on a production server", () => {
|
||||
expect(allowsDesktopAutoSession({ NODE_ENV: "production" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { SQLiteSyncDialect } from "drizzle-orm/sqlite-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { locateSyncRow } from "../../../database/routes/sync.js";
|
||||
import { hosts, userPreferences } from "../../../database/db/schema.js";
|
||||
|
||||
/**
|
||||
* A sync push locates the stored row twice: once to see whether it exists,
|
||||
* once to write it. Those lookups used to be spelled out separately, and only
|
||||
* the read knew about singleton entities — the write always keyed on
|
||||
* `table.id`.
|
||||
*
|
||||
* `user_preferences` is the only singleton, and the one synced table whose
|
||||
* primary key is `user_id` with no `id` column at all. `table.id` was
|
||||
* therefore `undefined` and drizzle emitted a comparison with nothing on its
|
||||
* left: `( = ? and "user_preferences"."user_id" = ?)`. The insert branch was
|
||||
* fine, so the first push of preferences succeeded and every push after it —
|
||||
* the steady state — failed with `SqliteError: near "=": syntax error`.
|
||||
*/
|
||||
describe("locateSyncRow", () => {
|
||||
const dialect = new SQLiteSyncDialect();
|
||||
|
||||
const toSql = (condition: Parameters<typeof dialect.sqlToQuery>[0]): string =>
|
||||
dialect.sqlToQuery(condition).sql;
|
||||
|
||||
/** `= ?` with no operand to its left — what used to reach SQLite. */
|
||||
const EMPTY_LEFT_OPERAND = /(^|\(|\band\b|\bor\b)\s*=\s*\?/;
|
||||
|
||||
it("keys a singleton entity on its owner", () => {
|
||||
const sql = toSql(locateSyncRow("userPreferences", "user-1", "ignored"));
|
||||
|
||||
expect(sql).toContain('"user_id"');
|
||||
expect(sql).not.toContain('"sync_id"');
|
||||
expect(sql).not.toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("keys a regular entity on its sync id and owner", () => {
|
||||
const sql = toSql(locateSyncRow("hosts", "user-1", "sync-abc"));
|
||||
|
||||
expect(sql).toContain('"sync_id"');
|
||||
expect(sql).toContain('"user_id"');
|
||||
expect(sql).not.toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("is the shape the id-keyed lookup could not produce", () => {
|
||||
// Guards the assertions above: the pattern really does catch the old SQL.
|
||||
const previous = and(
|
||||
eq((userPreferences as unknown as typeof hosts).id, 1),
|
||||
eq(userPreferences.userId, "user-1"),
|
||||
)!;
|
||||
|
||||
expect(toSql(previous)).toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("updates a preferences row that already exists", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
theme TEXT
|
||||
);
|
||||
`);
|
||||
const db = drizzle(sqlite, { schema: { userPreferences } });
|
||||
|
||||
// The steady state: a row exists, so the push takes the update path.
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences (user_id, theme) VALUES (?, ?)")
|
||||
.run("user-1", "dark");
|
||||
|
||||
const updated = db
|
||||
.update(userPreferences)
|
||||
.set({ theme: "light" })
|
||||
.where(locateSyncRow("userPreferences", "user-1", "ignored"))
|
||||
.returning({ theme: userPreferences.theme })
|
||||
.all();
|
||||
|
||||
expect(updated).toEqual([{ theme: "light" }]);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import syncRouter, {
|
||||
isSyncedRow,
|
||||
isValidEntityType,
|
||||
stripWritePayload,
|
||||
} from "../../../database/routes/sync.js";
|
||||
import { registerEntity } from "../../../plugins/sync-registry.js";
|
||||
|
||||
describe("sync route order", () => {
|
||||
it("registers POST /tombstones before the POST /:entityType wildcard", () => {
|
||||
const postPaths = (
|
||||
syncRouter as unknown as {
|
||||
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
||||
}
|
||||
).stack
|
||||
.filter((layer) => layer.route?.methods?.post)
|
||||
.map((layer) => layer.route!.path);
|
||||
|
||||
// "/tombstones" is a valid value for :entityType as far as Express is
|
||||
// concerned, so registering the wildcard first makes the tombstone
|
||||
// endpoint unreachable -- every deletion push answers 400 "Unknown entity
|
||||
// type" instead of applying the deletion.
|
||||
expect(postPaths).toContain("/tombstones");
|
||||
expect(postPaths.indexOf("/tombstones")).toBeLessThan(
|
||||
postPaths.indexOf("/:entityType"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidEntityType", () => {
|
||||
it("accepts every whitelisted sync entity type", () => {
|
||||
for (const type of [
|
||||
"hosts",
|
||||
"sshCredentials",
|
||||
"sshFolders",
|
||||
"userPreferences",
|
||||
]) {
|
||||
expect(isValidEntityType(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown or non-string entity types", () => {
|
||||
expect(isValidEntityType("hostAccess")).toBe(false);
|
||||
expect(isValidEntityType("")).toBe(false);
|
||||
expect(isValidEntityType(undefined)).toBe(false);
|
||||
expect(isValidEntityType(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripWritePayload", () => {
|
||||
it("strips id, userId, and syncId from every entity type", () => {
|
||||
const payload = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "prod-db",
|
||||
};
|
||||
expect(stripWritePayload("sshFolders", payload)).toEqual({
|
||||
name: "prod-db",
|
||||
});
|
||||
});
|
||||
|
||||
it("also strips desktop-only fields flagged read-only for hosts", () => {
|
||||
const payload = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "web",
|
||||
connectionOrigin: "remote",
|
||||
};
|
||||
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
|
||||
});
|
||||
|
||||
it("keeps preference storage mode local to each device", () => {
|
||||
expect(
|
||||
stripWritePayload("userPreferences", {
|
||||
syncId: "userPreferences:singleton",
|
||||
theme: "dark",
|
||||
storageMode: "cloud",
|
||||
}),
|
||||
).toEqual({ theme: "dark" });
|
||||
});
|
||||
|
||||
it("does not mutate the original payload object", () => {
|
||||
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
|
||||
stripWritePayload("sshFolders", payload);
|
||||
expect(payload).toEqual({
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "x",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyncedRow", () => {
|
||||
let dispose: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
dispose?.();
|
||||
dispose = null;
|
||||
});
|
||||
|
||||
it("syncs every row of an entity that does not filter", () => {
|
||||
expect(isSyncedRow("hosts", { id: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves out the rows an entity's shouldSync refuses", () => {
|
||||
dispose = registerEntity("demo", {
|
||||
type: "demoLayouts",
|
||||
table: {},
|
||||
shouldSync: (row) => row.kind !== "local",
|
||||
});
|
||||
expect(isSyncedRow("demoLayouts", { kind: "shared" })).toBe(true);
|
||||
expect(isSyncedRow("demoLayouts", { kind: "local" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deserializeSyncReferences,
|
||||
orderSyncRows,
|
||||
serializeSyncReferences,
|
||||
} from "../../database/routes/sync-references.js";
|
||||
import { registerCoreSyncEntities } from "../../database/routes/sync-entities.js";
|
||||
|
||||
describe("sync references", () => {
|
||||
// References come from the registry now, not a hardcoded map, so the core
|
||||
// entities have to be registered before any of this resolves. sync.ts does
|
||||
// this at import in production.
|
||||
beforeAll(() => {
|
||||
registerCoreSyncEntities();
|
||||
});
|
||||
|
||||
it("serializes database-local host IDs as stable sync IDs", async () => {
|
||||
const row = await serializeSyncReferences(
|
||||
"hosts",
|
||||
{
|
||||
id: 7,
|
||||
credentialId: 12,
|
||||
rdpCredentialId: 13,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
parentHostId: 3,
|
||||
},
|
||||
async (entityType, id) => `${entityType}-${id}`,
|
||||
);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
credentialSyncId: "sshCredentials-12",
|
||||
rdpCredentialSyncId: "sshCredentials-13",
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
parentHostSyncId: "hosts-3",
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialId");
|
||||
});
|
||||
|
||||
it("resolves stable sync IDs to IDs from the receiving database", async () => {
|
||||
const ids = new Map([
|
||||
["sshCredentials:credential-sync", 91],
|
||||
["hosts:parent-sync", 52],
|
||||
]);
|
||||
const row = await deserializeSyncReferences(
|
||||
"hosts",
|
||||
{
|
||||
credentialId: 12,
|
||||
credentialSyncId: "credential-sync",
|
||||
rdpCredentialSyncId: null,
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
parentHostSyncId: "parent-sync",
|
||||
},
|
||||
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
|
||||
);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
credentialId: 91,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
parentHostId: 52,
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialSyncId");
|
||||
});
|
||||
|
||||
it("orders parent hosts before children", () => {
|
||||
const parent = { syncId: "parent", parentHostSyncId: null };
|
||||
const child = { syncId: "child", parentHostSyncId: "parent" };
|
||||
const grandchild = { syncId: "grandchild", parentHostSyncId: "child" };
|
||||
|
||||
expect(orderSyncRows("hosts", [grandchild, child, parent])).toEqual([
|
||||
parent,
|
||||
child,
|
||||
grandchild,
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves other entity types in their existing order", () => {
|
||||
const rows = [{ syncId: "second" }, { syncId: "first" }];
|
||||
expect(orderSyncRows("sshFolders", rows)).toBe(rows);
|
||||
});
|
||||
|
||||
it("rejects a row whose referenced dependency has not synced", async () => {
|
||||
await expect(
|
||||
deserializeSyncReferences(
|
||||
"sshFolders",
|
||||
{ credentialSyncId: "missing" },
|
||||
async () => null,
|
||||
),
|
||||
).rejects.toThrow("Missing sshCredentials dependency");
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
process: { env: { VERSION: "2.7.1" } },
|
||||
getLocalVersion: () => "2.7.1",
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn() },
|
||||
fetchGitHubAPI,
|
||||
REPO_OWNER: "Termix-SSH",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const linked = require("../../../../electron/linked-server.cjs") as {
|
||||
setLinkedServer: (config: unknown) => void;
|
||||
getLinkedServer: () => Record<string, unknown> | null;
|
||||
isLinkedOrigin: (url: string) => boolean;
|
||||
applyLinkHeaders: (
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
) => Record<string, string>;
|
||||
linkedRequestHeaders: (
|
||||
extra?: Record<string, string>,
|
||||
) => Record<string, string>;
|
||||
answerLogin: (
|
||||
authInfo: { isProxy?: boolean } | null,
|
||||
url: string,
|
||||
) => { username: string; password: string } | null;
|
||||
};
|
||||
|
||||
const ORIGIN = "https://termix.example";
|
||||
|
||||
beforeEach(() => {
|
||||
linked.setLinkedServer(null);
|
||||
});
|
||||
|
||||
describe("linked server in Electron main", () => {
|
||||
it("adds proxy headers only for the linked origin", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
|
||||
});
|
||||
expect(
|
||||
linked.applyLinkHeaders(`${ORIGIN}/sync/v2/info`, { Accept: "x" }),
|
||||
).toEqual({ Accept: "x", "CF-Access-Client-Id": "id" });
|
||||
expect(
|
||||
linked.applyLinkHeaders("https://elsewhere.example/", { Accept: "x" }),
|
||||
).toEqual({ Accept: "x" });
|
||||
});
|
||||
|
||||
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
const headers = linked.applyLinkHeaders(`${ORIGIN}/host/status`, {
|
||||
authorization: "Bearer tok",
|
||||
Cookie: "a=b",
|
||||
});
|
||||
expect(headers.Authorization).toBe(
|
||||
`Basic ${Buffer.from("gate:pw").toString("base64")}`,
|
||||
);
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
expect(headers.Cookie).toBe("a=b; jwt=tok");
|
||||
});
|
||||
|
||||
it("builds main's own request headers with the session", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN, token: "tok" });
|
||||
expect(linked.linkedRequestHeaders()).toEqual({
|
||||
"X-Electron-App": "true",
|
||||
Authorization: "Bearer tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers basic auth challenges from the linked origin only", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
expect(linked.answerLogin({}, `${ORIGIN}/`)).toEqual({
|
||||
username: "gate",
|
||||
password: "pw",
|
||||
});
|
||||
expect(linked.answerLogin({ isProxy: true }, `${ORIGIN}/`)).toBeNull();
|
||||
expect(linked.answerLogin({}, "https://other.example/")).toBeNull();
|
||||
});
|
||||
|
||||
it("forgets everything on unlink", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN, token: "tok" });
|
||||
linked.setLinkedServer(null);
|
||||
expect(linked.getLinkedServer()).toBeNull();
|
||||
expect(linked.isLinkedOrigin(`${ORIGIN}/`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,8 +44,10 @@ const localFiles = require("../../../../electron/local-files.cjs") as {
|
||||
TRANSFER_ROUTES: Record<string, string>;
|
||||
createTargetResolver: (deps: {
|
||||
localBaseUrl?: string;
|
||||
getRemoteSyncConfig?: () => { serverUrl?: string } | null;
|
||||
getRemoteSyncJwt?: () => string | null;
|
||||
getLinkedServer?: () => {
|
||||
serverUrl?: string;
|
||||
token?: string | null;
|
||||
} | null;
|
||||
}) => (req: { origin?: unknown; route?: unknown; deviceId?: unknown }) => {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -53,8 +55,10 @@ const localFiles = require("../../../../electron/local-files.cjs") as {
|
||||
createLocalFileHandlers: (deps: {
|
||||
net: unknown;
|
||||
shell: unknown;
|
||||
getRemoteSyncConfig?: () => { serverUrl?: string } | null;
|
||||
getRemoteSyncJwt?: () => string | null;
|
||||
getLinkedServer?: () => {
|
||||
serverUrl?: string;
|
||||
token?: string | null;
|
||||
} | null;
|
||||
localBaseUrl?: string;
|
||||
publishFs?: PublishFs;
|
||||
}) => Record<
|
||||
@@ -161,8 +165,10 @@ function startBackend(
|
||||
describe("local-files transfer target resolution", () => {
|
||||
const resolve = localFiles.createTargetResolver({
|
||||
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
|
||||
getRemoteSyncConfig: () => ({ serverUrl: "https://termix.example.com/" }),
|
||||
getRemoteSyncJwt: () => "remote-jwt",
|
||||
getLinkedServer: () => ({
|
||||
serverUrl: "https://termix.example.com/",
|
||||
token: "remote-jwt",
|
||||
}),
|
||||
});
|
||||
|
||||
it("only reaches the two file-manager streaming routes on the local backend", () => {
|
||||
@@ -175,7 +181,7 @@ describe("local-files transfer target resolution", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the remote target from the configured sync server and attaches its JWT itself", () => {
|
||||
it("derives the remote target from the linked server and attaches its session itself", () => {
|
||||
const target = resolve({ origin: "remote", route: "downloadFileStream" });
|
||||
expect(target.url).toBe(
|
||||
"https://termix.example.com/plugin-api/file-manager/downloadFileStream",
|
||||
@@ -196,15 +202,15 @@ describe("local-files transfer target resolution", () => {
|
||||
expect(() => resolve({})).toThrow(/Unknown transfer route/);
|
||||
});
|
||||
|
||||
it("refuses a remote origin when no sync server is configured or it is not http(s)", () => {
|
||||
it("refuses a remote origin when the device is not linked or the server is not http(s)", () => {
|
||||
const unconfigured = localFiles.createTargetResolver({
|
||||
getRemoteSyncConfig: () => null,
|
||||
getLinkedServer: () => null,
|
||||
});
|
||||
expect(() =>
|
||||
unconfigured({ origin: "remote", route: "uploadFileStream" }),
|
||||
).toThrow(/not configured/);
|
||||
).toThrow(/not linked/);
|
||||
const bogus = localFiles.createTargetResolver({
|
||||
getRemoteSyncConfig: () => ({ serverUrl: "file:///etc/passwd" }),
|
||||
getLinkedServer: () => ({ serverUrl: "file:///etc/passwd" }),
|
||||
});
|
||||
expect(() =>
|
||||
bogus({ origin: "remote", route: "uploadFileStream" }),
|
||||
@@ -290,8 +296,7 @@ describe("local-files download boundary", () => {
|
||||
net: fakeNet,
|
||||
shell: {},
|
||||
localBaseUrl: backend.url,
|
||||
getRemoteSyncConfig: () => null,
|
||||
getRemoteSyncJwt: () => null,
|
||||
getLinkedServer: () => null,
|
||||
});
|
||||
});
|
||||
afterAll(() => backend.close());
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import https from "node:https";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
createRemoteSyncFetch,
|
||||
} = require("../../../../electron/remote-sync-fetch.cjs");
|
||||
let server: https.Server;
|
||||
let origin: string;
|
||||
let directory: string;
|
||||
let redirectedRequests = 0;
|
||||
const config = { serverUrl: "", allowInvalidCertificate: false };
|
||||
const request = createRemoteSyncFetch(() => config);
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = mkdtempSync(join(tmpdir(), "termix-sync-tls-"));
|
||||
const key = join(directory, "key.pem");
|
||||
const cert = join(directory, "cert.pem");
|
||||
execFileSync(
|
||||
"openssl",
|
||||
[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
key,
|
||||
"-out",
|
||||
cert,
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
server = https.createServer(
|
||||
{ key: readFileSync(key), cert: readFileSync(cert) },
|
||||
(req, res) => {
|
||||
if (req.url === "/redirect") {
|
||||
res.writeHead(302, { Location: `${origin}/redirect-target` });
|
||||
} else {
|
||||
if (req.url === "/redirect-target") redirectedRequests++;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.write(
|
||||
JSON.stringify({
|
||||
authorization: req.headers.authorization,
|
||||
method: req.method,
|
||||
}),
|
||||
);
|
||||
}
|
||||
res.end();
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
origin = `https://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
config.serverUrl = `${origin}/termix`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server?.closeAllConnections();
|
||||
if (server)
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
if (directory) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects self-signed certificates until enabled, then preserves authenticated requests", async () => {
|
||||
await expect(request(`${origin}/users/me`)).rejects.toThrow();
|
||||
config.allowInvalidCertificate = true;
|
||||
const response = await request(`${origin}/users/me`, {
|
||||
headers: { Authorization: "Bearer dummy-token" },
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
expect(await response.json()).toEqual({
|
||||
authorization: "Bearer dummy-token",
|
||||
method: "POST",
|
||||
});
|
||||
config.allowInvalidCertificate = false;
|
||||
await expect(request(`${origin}/users/me`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not apply an exception to another configured origin", async () => {
|
||||
config.allowInvalidCertificate = true;
|
||||
config.serverUrl = "https://another.example";
|
||||
await expect(request(`${origin}/sync/hosts`)).rejects.toThrow();
|
||||
config.serverUrl = origin;
|
||||
});
|
||||
|
||||
it("refuses redirects rather than carrying the TLS exception to another request", async () => {
|
||||
config.allowInvalidCertificate = true;
|
||||
await expect(request(`${origin}/redirect`)).rejects.toThrow();
|
||||
expect(redirectedRequests).toBe(0);
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deserializeSyncReferences } from "../../database/routes/sync-references.js";
|
||||
|
||||
const filename = resolve("electron/remote-sync.cjs");
|
||||
const localRequire = createRequire(filename);
|
||||
const { orderSyncWrites } = localRequire("./sync-write-order.cjs");
|
||||
const moduleStub = { exports: {} };
|
||||
const Engine = runInNewContext(
|
||||
readFileSync(filename, "utf8") + "\nRemoteSyncEngine;",
|
||||
{
|
||||
module: moduleStub,
|
||||
require: (name: string) => (name === "electron" ? {} : localRequire(name)),
|
||||
},
|
||||
);
|
||||
|
||||
type Row = {
|
||||
syncId: string;
|
||||
updatedAt: string;
|
||||
parentHostSyncId: string | null;
|
||||
};
|
||||
const local = "http://127.0.0.1:30001";
|
||||
const remote = "https://remote.example";
|
||||
|
||||
describe("nested host sync writes", () => {
|
||||
it.each([local, remote])(
|
||||
"copies parents before reparented children into %s",
|
||||
async (destination) => {
|
||||
const oldChild: Row = {
|
||||
syncId: "child",
|
||||
updatedAt: "2026-01-01",
|
||||
parentHostSyncId: null,
|
||||
};
|
||||
const parent: Row = {
|
||||
syncId: "parent",
|
||||
updatedAt: "2026-01-02",
|
||||
parentHostSyncId: null,
|
||||
};
|
||||
const newChild: Row = {
|
||||
...oldChild,
|
||||
updatedAt: "2026-01-02",
|
||||
parentHostSyncId: "parent",
|
||||
};
|
||||
const source = destination === local ? remote : local;
|
||||
const stores = new Map<string, Map<string, Row>>([
|
||||
[destination, new Map([["child", oldChild]])],
|
||||
[
|
||||
source,
|
||||
new Map([
|
||||
["parent", parent],
|
||||
["child", newChild],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const engine = new Engine(() => null);
|
||||
engine.localJwt = "local-token";
|
||||
engine.pullSide = async (url: string) => [...stores.get(url)!.values()];
|
||||
engine.pullTombstones = async () => [];
|
||||
const written: string[] = [];
|
||||
engine.pushRow = async (
|
||||
url: string,
|
||||
_token: string,
|
||||
entity: "hosts",
|
||||
row: Row,
|
||||
) => {
|
||||
await deserializeSyncReferences(entity, row, async (_type, id) =>
|
||||
stores.get(url)!.has(id) ? 1 : null,
|
||||
);
|
||||
stores.get(url)!.set(row.syncId, row);
|
||||
written.push(row.syncId);
|
||||
};
|
||||
await engine.syncEntity({
|
||||
entityType: "hosts",
|
||||
remoteBaseUrl: remote,
|
||||
remoteJwt: "remote-token",
|
||||
});
|
||||
expect(written).toEqual(["parent", "child"]);
|
||||
expect(stores.get(destination)!.get("child")!.parentHostSyncId).toBe(
|
||||
"parent",
|
||||
);
|
||||
written.length = 0;
|
||||
await engine.syncEntity({
|
||||
entityType: "hosts",
|
||||
remoteBaseUrl: remote,
|
||||
remoteJwt: "remote-token",
|
||||
});
|
||||
expect(written).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects cyclic write dependencies before any writes are sent", () => {
|
||||
const writes = [
|
||||
{ baseUrl: local, row: { syncId: "a", parentHostSyncId: "b" } },
|
||||
{ baseUrl: local, row: { syncId: "b", parentHostSyncId: "a" } },
|
||||
];
|
||||
expect(() => orderSyncWrites("hosts", writes)).toThrow(
|
||||
"Cyclic parent host",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not confuse writes to different destinations", () => {
|
||||
const writes = [
|
||||
{ baseUrl: local, row: { syncId: "a", parentHostSyncId: "b" } },
|
||||
{ baseUrl: remote, row: { syncId: "b", parentHostSyncId: "a" } },
|
||||
];
|
||||
expect(orderSyncWrites("hosts", writes)).toEqual(writes);
|
||||
expect(orderSyncWrites("snippets", writes)).toBe(writes);
|
||||
});
|
||||
});
|
||||
@@ -345,6 +345,70 @@ SELECT 1;
|
||||
-- 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0059_military_purple_man ====
|
||||
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`);
|
||||
|
||||
-- ==== core 0060_mean_gorgon ====
|
||||
DROP TABLE `sync_tombstones`;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -371,6 +371,67 @@ SELECT 1;
|
||||
-- 3.0.0.
|
||||
SELECT 1;
|
||||
|
||||
-- ==== core 0060_conscious_juggernaut ====
|
||||
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");
|
||||
|
||||
-- ==== core 0061_red_lila_cheney ====
|
||||
DROP TABLE "sync_tombstones" CASCADE;
|
||||
|
||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hub = vi.hoisted(() => ({
|
||||
value: null as null | { deliver: ReturnType<typeof vi.fn> },
|
||||
}));
|
||||
const resolveAudience = vi.hoisted(() => vi.fn(async () => ["u1", "u2"]));
|
||||
|
||||
vi.mock("../../plugins/notify-hub.js", () => ({
|
||||
activeNotifyHub: async () => hub.value,
|
||||
}));
|
||||
vi.mock("../../plugins/ctx-notify.js", () => ({ resolveAudience }));
|
||||
|
||||
const { sendCoreAlert, CORE_ALERT_SOURCE } =
|
||||
await import("../../notify/core-notify.js");
|
||||
|
||||
beforeEach(() => {
|
||||
hub.value = { deliver: vi.fn(async () => ({ recipients: 2 })) };
|
||||
resolveAudience.mockClear();
|
||||
});
|
||||
|
||||
describe("core alerts", () => {
|
||||
it("hands the alert to the hub as coming from Termix", async () => {
|
||||
await sendCoreAlert({ title: "t", audience: "admins" });
|
||||
expect(hub.value!.deliver).toHaveBeenCalledWith({
|
||||
source: CORE_ALERT_SOURCE,
|
||||
recipients: ["u1", "u2"],
|
||||
notification: { title: "t", audience: "admins" },
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op with no hub, and never throws", async () => {
|
||||
hub.value = null;
|
||||
await expect(
|
||||
sendCoreAlert({ title: "t", audience: "admins" }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
hub.value = { deliver: vi.fn(async () => Promise.reject(new Error("x"))) };
|
||||
await expect(
|
||||
sendCoreAlert({ title: "t", audience: "admins" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,6 @@ const UNGATED: Record<string, string> = {
|
||||
"events.emit": "namespace check: plugin.<id>.* only without events:core",
|
||||
"events.on": "namespace check: plugin.* only without events:core",
|
||||
"sync.registerEntity": "adds the plugin's own table to remote sync",
|
||||
"sync.recordTombstone": "records a delete of the plugin's own row",
|
||||
"registry.provide": "the plugin's own registry key",
|
||||
"registry.consume": "reads what another plugin chose to publish",
|
||||
"registry.revoke": "removes the plugin's own registry key",
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
resetSyncRegistry,
|
||||
unregisterByOwner,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { registerCoreSyncEntities } from "../../database/routes/sync-entities.js";
|
||||
import { SYNCED_ENTITY_TYPES } from "../../../../electron/remote-sync-entities.cjs";
|
||||
import { registerCoreSyncEntities } from "../../sync/entities.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
@@ -91,35 +90,47 @@ describe("sync registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity aliases", () => {
|
||||
it("finds an entity by a name core uses for it", () => {
|
||||
registerEntity("demo", {
|
||||
type: "snippets",
|
||||
table: {},
|
||||
answersTo: ["commandSnippet"],
|
||||
});
|
||||
|
||||
expect(getEntity("commandSnippet")?.type).toBe("snippets");
|
||||
expect(getEntity("snippets")?.type).toBe("snippets");
|
||||
expect(getEntity("nothing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps core-only options away from plugins", () => {
|
||||
registerEntity(
|
||||
"demo",
|
||||
{ type: "thing", table: {} },
|
||||
{ readOnly: true, load: async () => [] },
|
||||
);
|
||||
|
||||
expect(getEntity("thing")?.readOnly).toBeUndefined();
|
||||
expect(getEntity("thing")?.load).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("core sync entities", () => {
|
||||
beforeEach(() => {
|
||||
registerCoreSyncEntities();
|
||||
});
|
||||
|
||||
// The frozen array also lists networkTopology, snippets/snippetFolders,
|
||||
// dashboardServiceLinks/homepageItems and vaultProfiles, now registered by
|
||||
// the network-topology, snippets, homepage and vault plugins rather than core.
|
||||
const PLUGIN_OWNED_TYPES = new Set([
|
||||
"networkTopology",
|
||||
"snippets",
|
||||
"snippetFolders",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
"vaultProfiles",
|
||||
]);
|
||||
|
||||
it("registers every core-owned entity the Electron client knows about", () => {
|
||||
const coreTypes = new Set(listEntityTypes());
|
||||
for (const type of SYNCED_ENTITY_TYPES) {
|
||||
if (PLUGIN_OWNED_TYPES.has(type)) continue;
|
||||
expect(coreTypes.has(type), type).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the dependency order the frozen Electron array encodes", () => {
|
||||
expect(listEntityTypes()).toEqual(
|
||||
[...SYNCED_ENTITY_TYPES].filter((type) => !PLUGIN_OWNED_TYPES.has(type)),
|
||||
);
|
||||
it("registers what core syncs, under the wire names 2.8 used", () => {
|
||||
expect(listEntityTypes()).toEqual([
|
||||
"accountProfile",
|
||||
"sshCredentials",
|
||||
"sharedCredentials",
|
||||
"sshFolders",
|
||||
"hosts",
|
||||
"sharedHosts",
|
||||
"userPreferences",
|
||||
"pluginUserSettings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("owns all of them as core", () => {
|
||||
@@ -128,15 +139,18 @@ describe("core sync entities", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sorts every reference target before the entity that points at it", () => {
|
||||
it("sorts every column reference target before the entity that points at it", () => {
|
||||
const order = new Map(
|
||||
listEntities().map((entity) => [entity.type, entity.order]),
|
||||
);
|
||||
|
||||
for (const entity of listEntities()) {
|
||||
for (const reference of entity.references ?? []) {
|
||||
// A self-reference is ordered within the entity by orderSyncRows.
|
||||
// Rows pointing at themselves are ordered within the entity, and a
|
||||
// JSON reference that arrives first is written again once the
|
||||
// target exists.
|
||||
if (reference.entityType === entity.type) continue;
|
||||
if (reference.field.includes(".")) continue;
|
||||
expect(
|
||||
order.get(reference.entityType),
|
||||
`${entity.type} -> ${reference.entityType}`,
|
||||
@@ -145,21 +159,51 @@ describe("core sync entities", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("marks the one singleton and nothing else", () => {
|
||||
it("marks the singletons and nothing else", () => {
|
||||
const singletons = listEntities()
|
||||
.filter((entity) => entity.singleton)
|
||||
.map((entity) => entity.type);
|
||||
|
||||
expect(singletons.sort()).toEqual(["userPreferences"]);
|
||||
expect(singletons.sort()).toEqual(["accountProfile", "userPreferences"]);
|
||||
});
|
||||
|
||||
it("keeps the read-only fields that must survive a sync payload", () => {
|
||||
expect(getEntity("hosts")?.readOnlyFields).toEqual(["connectionOrigin"]);
|
||||
it("only sends shared copies and the account one way", () => {
|
||||
const readOnly = listEntities()
|
||||
.filter((entity) => entity.readOnly)
|
||||
.map((entity) => entity.type);
|
||||
|
||||
expect(readOnly.sort()).toEqual([
|
||||
"accountProfile",
|
||||
"sharedCredentials",
|
||||
"sharedHosts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps device-only host fields off the wire", () => {
|
||||
expect(getEntity("hosts")?.readOnlyFields).toEqual(
|
||||
expect.arrayContaining(["connectionOrigin", "localOnly", "sharedSource"]),
|
||||
);
|
||||
expect(getEntity("userPreferences")?.readOnlyFields).toEqual([
|
||||
"storageMode",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves local-only and shared rows out of what a device sends", () => {
|
||||
const hosts = getEntity("hosts")!;
|
||||
expect(hosts.shouldSync?.({ localOnly: true })).toBe(false);
|
||||
expect(hosts.shouldSync?.({ sharedSource: "{}" })).toBe(false);
|
||||
expect(hosts.shouldSync?.({ localOnly: false, sharedSource: null })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("encrypts host and credential secrets", () => {
|
||||
expect(getEntity("hosts")?.encryptedFields).toContain("password");
|
||||
expect(getEntity("sshCredentials")?.encryptedFields).toContain(
|
||||
"privateKey",
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent, because several modules prime it", () => {
|
||||
const before = listEntityTypes();
|
||||
registerCoreSyncEntities();
|
||||
|
||||
@@ -592,8 +592,7 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
|
||||
it("syncs every entity type 2.8 synced, under the same name", async () => {
|
||||
const { listEntityTypes } = await import("../../plugins/sync-registry.js");
|
||||
const { registerCoreSyncEntities } =
|
||||
await import("../../database/routes/sync-entities.js");
|
||||
const { registerCoreSyncEntities } = await import("../../sync/entities.js");
|
||||
registerCoreSyncEntities();
|
||||
const known = new Set(listEntityTypes());
|
||||
for (const type of SYNCED_ENTITY_TYPES_28) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import http from "http";
|
||||
import type { AddressInfo } from "net";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
RemoteError,
|
||||
remoteJson,
|
||||
remoteUrl,
|
||||
requestHeaders,
|
||||
} from "../../../sync/client/http.js";
|
||||
|
||||
describe("server addresses", () => {
|
||||
it("adds https, drops a trailing slash, query and hash, keeps a base path", () => {
|
||||
expect(normalizeServerUrl("termix.example.com/")).toBe(
|
||||
"https://termix.example.com",
|
||||
);
|
||||
expect(normalizeServerUrl(" http://10.0.0.5:8080/termix/?x=1#y ")).toBe(
|
||||
"http://10.0.0.5:8080/termix",
|
||||
);
|
||||
expect(normalizeServerUrl("")).toBeNull();
|
||||
expect(normalizeServerUrl("ftp://nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("joins paths onto a base path", () => {
|
||||
expect(remoteUrl("https://x.example/termix/", "/sync/v2/info")).toBe(
|
||||
"https://x.example/termix/sync/v2/info",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("request headers", () => {
|
||||
it("sends the session as a cookie so basic auth can have Authorization", () => {
|
||||
const headers = requestHeaders({
|
||||
serverUrl: "https://x",
|
||||
sessionToken: "tok",
|
||||
basicAuth: { username: "u", password: "p" },
|
||||
customHeaders: [
|
||||
{ name: "CF-Access-Client-Id", value: "id" },
|
||||
{ name: "", value: "ignored" },
|
||||
],
|
||||
});
|
||||
expect(headers.Cookie).toBe("jwt=tok");
|
||||
expect(headers.Authorization).toBe(
|
||||
`Basic ${Buffer.from("u:p").toString("base64")}`,
|
||||
);
|
||||
expect(headers["CF-Access-Client-Id"]).toBe("id");
|
||||
expect(Object.keys(headers)).not.toContain("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifying answers", () => {
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
switch (req.url) {
|
||||
case "/ok":
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ fine: true, cookie: req.headers.cookie }));
|
||||
return;
|
||||
case "/login-page":
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end("<html>sign in</html>");
|
||||
return;
|
||||
case "/basic":
|
||||
res.statusCode = 401;
|
||||
res.setHeader("WWW-Authenticate", 'Basic realm="proxy"');
|
||||
res.end("no");
|
||||
return;
|
||||
case "/signed-out":
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Session not found" }));
|
||||
return;
|
||||
default:
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "boom" }));
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
const kindOf = async (path: string) => {
|
||||
try {
|
||||
await remoteJson({ serverUrl: base, sessionToken: "t" }, path);
|
||||
return "none";
|
||||
} catch (error) {
|
||||
return (error as RemoteError).kind;
|
||||
}
|
||||
};
|
||||
|
||||
it("returns JSON from Termix with the session attached", async () => {
|
||||
const body = await remoteJson<{ fine: boolean; cookie: string }>(
|
||||
{ serverUrl: base, sessionToken: "t" },
|
||||
"/ok",
|
||||
);
|
||||
expect(body).toEqual({ fine: true, cookie: "jwt=t" });
|
||||
});
|
||||
|
||||
it("tells a proxy page, basic auth, sign out and server errors apart", async () => {
|
||||
expect(await kindOf("/login-page")).toBe("proxy");
|
||||
expect(await kindOf("/basic")).toBe("basic_auth");
|
||||
expect(await kindOf("/signed-out")).toBe("signed_out");
|
||||
expect(await kindOf("/boom")).toBe("server");
|
||||
});
|
||||
|
||||
it("calls an unreachable server offline", async () => {
|
||||
const error = await remoteJson({ serverUrl: "http://127.0.0.1:1" }, "/x", {
|
||||
timeoutMs: 5000,
|
||||
}).catch((e) => e);
|
||||
expect((error as RemoteError).kind).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ drizzle: null as unknown }));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => state.drizzle,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({ getEncryptionKey: async () => Buffer.alloc(32, 5) }),
|
||||
},
|
||||
}));
|
||||
|
||||
const { TestSqliteDatabase } =
|
||||
await import("../../database/repositories/test-support.js");
|
||||
const { createLink, deleteLink, getLink, updateLink } =
|
||||
await import("../../../sync/client/link-store.js");
|
||||
|
||||
let db: InstanceType<typeof TestSqliteDatabase>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new TestSqliteDatabase("sqlite");
|
||||
state.drizzle = (await db.connect()).drizzle;
|
||||
await db.run(
|
||||
sql`INSERT INTO users (id, username, password_hash) VALUES ('local', 'local', '')`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
describe("the desktop's link", () => {
|
||||
it("stores secrets encrypted and reads them back", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://termix.example",
|
||||
sessionToken: "session-token",
|
||||
customHeaders: [{ name: "CF-Access-Client-Secret", value: "shh" }],
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
|
||||
const [raw] = await db.query<Record<string, string>>(
|
||||
sql`SELECT session_token, custom_headers, basic_auth FROM sync_link`,
|
||||
);
|
||||
for (const value of Object.values(raw)) {
|
||||
expect(value).toMatch(/^sysenc:v1:/);
|
||||
expect(value).not.toContain("shh");
|
||||
}
|
||||
|
||||
const link = await getLink();
|
||||
expect(link).toMatchObject({
|
||||
sessionToken: "session-token",
|
||||
customHeaders: [{ name: "CF-Access-Client-Secret", value: "shh" }],
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
cursor: 0,
|
||||
knownTypes: [],
|
||||
disabledTypes: [],
|
||||
status: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps one link at a time and updates it in place", async () => {
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://a",
|
||||
sessionToken: "a",
|
||||
});
|
||||
await createLink({
|
||||
userId: "local",
|
||||
serverUrl: "https://b",
|
||||
sessionToken: "b",
|
||||
});
|
||||
await updateLink({
|
||||
cursor: 42,
|
||||
disabledTypes: ["hosts"],
|
||||
status: "offline",
|
||||
});
|
||||
|
||||
const link = await getLink();
|
||||
expect(link?.serverUrl).toBe("https://b");
|
||||
expect(link?.cursor).toBe(42);
|
||||
expect(link?.disabledTypes).toEqual(["hosts"]);
|
||||
expect(link?.status).toBe("offline");
|
||||
|
||||
await deleteLink();
|
||||
expect(await getLink()).toBeNull();
|
||||
expect(await updateLink({ cursor: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -13,16 +13,16 @@ const h = vi.hoisted(() => ({
|
||||
manifests: [] as PluginManifest[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => h.manifests,
|
||||
}));
|
||||
vi.mock("../../../plugins/registry.js", () => ({
|
||||
vi.mock("../../plugins/registry.js", () => ({
|
||||
consume: (key: string) => h.hooks.get(key),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../plugins/settings.js", () => ({
|
||||
vi.mock("../../plugins/settings.js", () => ({
|
||||
declaredFields: (manifest: PluginManifest) =>
|
||||
manifest.contributes!.settings!.host!.fields,
|
||||
getAllSettings: async (
|
||||
@@ -49,7 +49,7 @@ vi.mock("../../../plugins/settings.js", () => ({
|
||||
}));
|
||||
|
||||
const { exportHostPluginSettings, importHostPluginSettings } =
|
||||
await import("../../../database/routes/sync-host-plugin-settings.js");
|
||||
await import("../../sync/host-plugin-settings.js");
|
||||
|
||||
const VAULT = {
|
||||
id: "vault",
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
registerEntity,
|
||||
resetSyncRegistry,
|
||||
} from "../../../plugins/sync-registry.js";
|
||||
import {
|
||||
MAX_PUSH_OPS,
|
||||
orderOps,
|
||||
parsePushOps,
|
||||
type PushOp,
|
||||
} from "../../../sync/server/push.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
registerEntity("core", { type: "creds", table: {}, order: 10 });
|
||||
registerEntity("core", {
|
||||
type: "hosts",
|
||||
table: {},
|
||||
order: 50,
|
||||
references: [
|
||||
{ field: "parentId", syncField: "parentSyncId", entityType: "hosts" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const op = (
|
||||
entityType: string,
|
||||
syncId: string,
|
||||
kind: "upsert" | "delete" = "upsert",
|
||||
row: Record<string, unknown> = {},
|
||||
): PushOp => ({
|
||||
entityType,
|
||||
syncId,
|
||||
baseRevision: 0,
|
||||
op: kind,
|
||||
...(kind === "upsert" ? { row } : {}),
|
||||
});
|
||||
|
||||
describe("parsing pushed ops", () => {
|
||||
it("accepts well-formed ops", () => {
|
||||
const ops = [op("hosts", "a"), op("hosts", "b", "delete")];
|
||||
expect(parsePushOps({ ops })).toEqual(ops);
|
||||
});
|
||||
|
||||
it("refuses malformed or oversized batches", () => {
|
||||
expect(parsePushOps({})).toBeNull();
|
||||
expect(parsePushOps({ ops: [{ entityType: "hosts" }] })).toBeNull();
|
||||
expect(
|
||||
parsePushOps({ ops: [{ ...op("hosts", "a"), row: undefined }] }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parsePushOps({
|
||||
ops: Array.from({ length: MAX_PUSH_OPS + 1 }, (_, i) =>
|
||||
op("hosts", String(i)),
|
||||
),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ordering pushed ops", () => {
|
||||
it("writes targets first and parents before children, and deletes last in reverse", () => {
|
||||
const ordered = orderOps([
|
||||
op("hosts", "old", "delete"),
|
||||
op("hosts", "child", "upsert", { parentSyncId: "parent" }),
|
||||
op("creds", "gone", "delete"),
|
||||
op("hosts", "parent"),
|
||||
op("creds", "key"),
|
||||
]);
|
||||
expect(ordered.map((o) => `${o.op}:${o.syncId}`)).toEqual([
|
||||
"upsert:key",
|
||||
"upsert:parent",
|
||||
"upsert:child",
|
||||
"delete:old",
|
||||
"delete:gone",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* A server and linked desktops, each with its own database, syncing through
|
||||
* the real engine and the real /sync/v2 routes. Only the network, auth and
|
||||
* the crypto keys are faked.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DatabaseContext } from "../../database/repositories/database-context.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
current: null as null | { drizzle: unknown },
|
||||
server: null as null | { drizzle: unknown },
|
||||
allow: true,
|
||||
}));
|
||||
|
||||
const SERVER_USER = "srv-user";
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => state.current!.drizzle,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({
|
||||
getEncryptionKey: async () => Buffer.alloc(32, 7),
|
||||
getJWTSecret: async () => "secret",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) },
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async () => state.allow,
|
||||
canAccessHost: async () => ({ hasAccess: false }),
|
||||
getUserPermissions: async () => [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
resyncHost: async () => {},
|
||||
resyncHostsForCredential: async () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ resyncCredential: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/delete-host.js", async () => {
|
||||
const { hosts } = await import("../../database/db/schema.js");
|
||||
const { eq: equals } = await import("drizzle-orm");
|
||||
return {
|
||||
deleteOwnedHost: async (_userId: string, id: number) => {
|
||||
await (state.current!.drizzle as DatabaseContext["drizzle"])
|
||||
.delete(hosts)
|
||||
.where(equals(hosts.id, id));
|
||||
return { id, name: "" };
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../hosts/delete-credential.js", async () => {
|
||||
const { sshCredentials } = await import("../../database/db/schema.js");
|
||||
const { eq: equals } = await import("drizzle-orm");
|
||||
return {
|
||||
deleteOwnedCredential: async (_userId: string, id: number) => {
|
||||
await (state.current!.drizzle as DatabaseContext["drizzle"])
|
||||
.delete(sshCredentials)
|
||||
.where(equals(sshCredentials.id, id));
|
||||
return { name: null };
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
|
||||
req.userId = SERVER_USER;
|
||||
next();
|
||||
},
|
||||
generateJWTToken: async () => "desktop-session",
|
||||
revokeSession: async () => true,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
|
||||
vi.mock("../../sync/client/plugins.js", () => ({
|
||||
mirrorPlugins: async () => {},
|
||||
lastRemotePlugins: () => [],
|
||||
isServerManaged: () => false,
|
||||
}));
|
||||
vi.mock("../../sync/client/http.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../sync/client/http.js")>();
|
||||
return {
|
||||
...actual,
|
||||
// The event stream is not under test.
|
||||
remoteFetch: () => new Promise(() => {}),
|
||||
remoteJson: async (
|
||||
_target: unknown,
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown } = {},
|
||||
) => {
|
||||
const previous = state.current;
|
||||
state.current = state.server;
|
||||
try {
|
||||
const agent = request(app);
|
||||
const method = (init.method ?? "GET").toLowerCase() as "get" | "post";
|
||||
const response = await agent[method](path).send(
|
||||
init.body as object | undefined,
|
||||
);
|
||||
if (response.status >= 400) {
|
||||
throw new actual.RemoteError(
|
||||
"server",
|
||||
response.body?.error ?? "error",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return response.body;
|
||||
} finally {
|
||||
state.current = previous;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { TestSqliteDatabase } =
|
||||
await import("../database/repositories/test-support.js");
|
||||
const schema = await import("../../database/db/schema.js");
|
||||
const { default: syncRouter } = await import("../../sync/server/routes.js");
|
||||
const { SyncEngine } = await import("../../sync/client/engine.js");
|
||||
const { createLink, getLink, updateLink } =
|
||||
await import("../../sync/client/link-store.js");
|
||||
const { markChanged, resetFeed } = await import("../../sync/server/feed.js");
|
||||
const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } =
|
||||
await import("../../sync/records.js");
|
||||
const { createResolvers, writeWireRow } = await import("../../sync/store.js");
|
||||
const { getEntity } = await import("../../plugins/sync-registry.js");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
app.use("/sync", syncRouter);
|
||||
|
||||
type Db = InstanceType<typeof TestSqliteDatabase>;
|
||||
interface Side {
|
||||
db: Db;
|
||||
ctx: DatabaseContext;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
let server: Side;
|
||||
let desktops: Side[] = [];
|
||||
|
||||
async function side(userId: string): Promise<Side> {
|
||||
const db = new TestSqliteDatabase("sqlite");
|
||||
const ctx = await db.connect();
|
||||
await db.run(
|
||||
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${userId}, ${userId}, '', 1)`,
|
||||
);
|
||||
return { db, ctx, userId };
|
||||
}
|
||||
|
||||
async function on<T>(where: Side, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = state.current;
|
||||
state.current = where.ctx;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
state.current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function db(where: Side) {
|
||||
return where.ctx.drizzle;
|
||||
}
|
||||
|
||||
async function addHost(
|
||||
where: Side,
|
||||
values: Partial<typeof schema.hosts.$inferInsert> & { name: string },
|
||||
) {
|
||||
await db(where)
|
||||
.insert(schema.hosts)
|
||||
.values({
|
||||
userId: where.userId,
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
syncId: `host-${values.name}`,
|
||||
...values,
|
||||
});
|
||||
// Stands in for the change watcher a write request would trip.
|
||||
if (where === server) markChanged();
|
||||
}
|
||||
|
||||
async function hostsOf(where: Side) {
|
||||
return db(where)
|
||||
.select()
|
||||
.from(schema.hosts)
|
||||
.where(eq(schema.hosts.userId, where.userId));
|
||||
}
|
||||
|
||||
async function linkDesktop(
|
||||
desktop: Side,
|
||||
): Promise<InstanceType<typeof SyncEngine>> {
|
||||
await on(desktop, () =>
|
||||
createLink({
|
||||
userId: desktop.userId,
|
||||
serverUrl: "https://termix.example",
|
||||
sessionToken: "desktop-session",
|
||||
}),
|
||||
);
|
||||
return new SyncEngine();
|
||||
}
|
||||
|
||||
async function pass(desktop: Side, engine: InstanceType<typeof SyncEngine>) {
|
||||
await on(desktop, () => engine.syncNow());
|
||||
const link = await on(desktop, () => getLink());
|
||||
expect(link?.lastError ?? null).toBeNull();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetFeed();
|
||||
resetSeqAllocator();
|
||||
state.allow = true;
|
||||
server = await side(SERVER_USER);
|
||||
state.server = server.ctx;
|
||||
desktops = [await side("desk-a"), await side("desk-b")];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of [server, ...desktops]) await s.db.close();
|
||||
});
|
||||
|
||||
describe("linking a desktop", () => {
|
||||
it("merges both sides, translating references and keeping secrets", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(desk, async () => {
|
||||
await db(desk).insert(schema.sshCredentials).values({
|
||||
userId: desk.userId,
|
||||
name: "deploy key",
|
||||
authType: "password",
|
||||
username: "deploy",
|
||||
syncId: "cred-1",
|
||||
});
|
||||
const [cred] = await db(desk).select().from(schema.sshCredentials);
|
||||
await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
{
|
||||
syncId: "host-desk",
|
||||
name: "desk",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "credential",
|
||||
password: "s3cret",
|
||||
credentialSyncId: cred.syncId,
|
||||
},
|
||||
createResolvers(desk.userId),
|
||||
);
|
||||
});
|
||||
await on(server, () => addHost(server, { name: "srv" }));
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const serverHosts = await on(server, () => hostsOf(server));
|
||||
expect(serverHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]);
|
||||
const pushed = serverHosts.find((h) => h.name === "desk")!;
|
||||
const [serverCred] = await db(server).select().from(schema.sshCredentials);
|
||||
expect(serverCred.syncId).toBe("cred-1");
|
||||
expect(pushed.credentialId).toBe(serverCred.id);
|
||||
// Stored encrypted, never as plaintext.
|
||||
expect(pushed.password).not.toBe("s3cret");
|
||||
expect(pushed.password).toContain('"iv"');
|
||||
|
||||
const deskHosts = await on(desk, () => hostsOf(desk));
|
||||
expect(deskHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]);
|
||||
});
|
||||
|
||||
it("adopts the server's version of rows both sides already have", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(desk, () => addHost(desk, { name: "same", ip: "old" }));
|
||||
await on(server, () => addHost(server, { name: "same", ip: "new" }));
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const [host] = await on(desk, () => hostsOf(desk));
|
||||
expect(host.ip).toBe("new");
|
||||
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("after linking", () => {
|
||||
it("sends edits and deletes both ways", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "a" }));
|
||||
await on(server, () => addHost(server, { name: "b" }));
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, async () => {
|
||||
await db(desk)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "10.9.9.9" })
|
||||
.where(eq(schema.hosts.syncId, "host-a"));
|
||||
});
|
||||
await on(server, async () => {
|
||||
await db(server)
|
||||
.delete(schema.hosts)
|
||||
.where(eq(schema.hosts.syncId, "host-b"));
|
||||
markChanged();
|
||||
});
|
||||
await pass(desk, engine);
|
||||
|
||||
const serverHosts = await on(server, () => hostsOf(server));
|
||||
expect(serverHosts.map((h) => [h.name, h.ip])).toEqual([["a", "10.9.9.9"]]);
|
||||
const deskHosts = await on(desk, () => hostsOf(desk));
|
||||
expect(deskHosts.map((h) => h.name)).toEqual(["a"]);
|
||||
|
||||
await on(desk, async () => {
|
||||
await db(desk)
|
||||
.delete(schema.hosts)
|
||||
.where(eq(schema.hosts.syncId, "host-a"));
|
||||
});
|
||||
await pass(desk, engine);
|
||||
expect(await on(server, () => hostsOf(server))).toEqual([]);
|
||||
});
|
||||
|
||||
it("never sends a host kept on this device only", async () => {
|
||||
const [desk] = desktops;
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
await on(desk, () => addHost(desk, { name: "mine", localOnly: true }));
|
||||
await pass(desk, engine);
|
||||
|
||||
expect(await on(server, () => hostsOf(server))).toEqual([]);
|
||||
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
|
||||
"mine",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a local edit that lost to a newer server edit, and can restore it", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "a" }));
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, () =>
|
||||
db(desk)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "desk-edit" })
|
||||
.where(eq(schema.hosts.syncId, "host-a")),
|
||||
);
|
||||
await on(server, async () => {
|
||||
await db(server)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "server-edit" })
|
||||
.where(eq(schema.hosts.syncId, "host-a"));
|
||||
markChanged();
|
||||
});
|
||||
await pass(desk, engine);
|
||||
|
||||
const [local] = await on(desk, () => hostsOf(desk));
|
||||
expect(local.ip).toBe("server-edit");
|
||||
const conflicts = await on(desk, () => listConflicts(desk.userId));
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(JSON.parse(conflicts[0].localRow).ip).toBe("desk-edit");
|
||||
|
||||
// Keep mine: put it back, and the next pass sends it.
|
||||
await on(desk, async () => {
|
||||
const conflict = (await getConflict(desk.userId, conflicts[0].id))!;
|
||||
await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
{ ...JSON.parse(conflict.localRow), syncId: conflict.syncId },
|
||||
createResolvers(desk.userId),
|
||||
);
|
||||
await deleteConflict(desk.userId, conflict.id);
|
||||
});
|
||||
await pass(desk, engine);
|
||||
const [serverHost] = await on(server, () => hostsOf(server));
|
||||
expect(serverHost.ip).toBe("desk-edit");
|
||||
});
|
||||
|
||||
it("does not let one refused change hold up the rest", async () => {
|
||||
const [desk] = desktops;
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, async () => {
|
||||
await addHost(desk, { name: "one" });
|
||||
await db(desk).insert(schema.sshCredentials).values({
|
||||
userId: desk.userId,
|
||||
name: "cred",
|
||||
authType: "password",
|
||||
syncId: "cred-x",
|
||||
});
|
||||
});
|
||||
// The server refuses host changes only.
|
||||
const hostPermissions = getEntity("hosts")!.permissions;
|
||||
const credentialPermissions = getEntity("sshCredentials")!.permissions;
|
||||
getEntity("sshCredentials")!.permissions = undefined;
|
||||
state.allow = false;
|
||||
try {
|
||||
await pass(desk, engine);
|
||||
} finally {
|
||||
getEntity("hosts")!.permissions = hostPermissions;
|
||||
getEntity("sshCredentials")!.permissions = credentialPermissions;
|
||||
}
|
||||
|
||||
expect(await on(server, () => hostsOf(server))).toEqual([]);
|
||||
const creds = await db(server).select().from(schema.sshCredentials);
|
||||
expect(creds.map((c) => c.name)).toEqual(["cred"]);
|
||||
const [record] = await on(desk, () =>
|
||||
db(desk)
|
||||
.select()
|
||||
.from(schema.syncRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.syncRecords.entityType, "hosts"),
|
||||
eq(schema.syncRecords.syncId, "host-one"),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(record.error).toBe("permission");
|
||||
});
|
||||
|
||||
it("leaves a switched-off kind of data alone, and catches up when it is back on", async () => {
|
||||
const [desk] = desktops;
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const link = (await on(desk, () => getLink()))!;
|
||||
await on(desk, () =>
|
||||
updateLink({
|
||||
disabledTypes: ["hosts"],
|
||||
knownTypes: link.knownTypes.filter((type) => type !== "hosts"),
|
||||
}),
|
||||
);
|
||||
await on(server, () => addHost(server, { name: "later" }));
|
||||
await on(desk, () => addHost(desk, { name: "private" }));
|
||||
await pass(desk, engine);
|
||||
|
||||
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
|
||||
"private",
|
||||
]);
|
||||
expect(
|
||||
(await on(server, () => hostsOf(server))).map((h) => h.name),
|
||||
).toEqual(["later"]);
|
||||
|
||||
await on(desk, () => updateLink({ disabledTypes: [] }));
|
||||
await pass(desk, engine);
|
||||
await pass(desk, engine);
|
||||
const names = (await on(desk, () => hostsOf(desk)))
|
||||
.map((h) => h.name)
|
||||
.sort();
|
||||
expect(names).toEqual(["later", "private"]);
|
||||
expect(
|
||||
(await on(server, () => hostsOf(server))).map((h) => h.name).sort(),
|
||||
).toEqual(["later", "private"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("two desktops on one account", () => {
|
||||
it("converge on edits, creates and deletes made offline on each", async () => {
|
||||
const [a, b] = desktops;
|
||||
await on(server, () => addHost(server, { name: "shared-one" }));
|
||||
await on(server, () => addHost(server, { name: "shared-two" }));
|
||||
const engineA = await linkDesktop(a);
|
||||
const engineB = await linkDesktop(b);
|
||||
await pass(a, engineA);
|
||||
await pass(b, engineB);
|
||||
|
||||
// Offline on both.
|
||||
await on(a, () => addHost(a, { name: "from-a" }));
|
||||
await on(a, () =>
|
||||
db(a)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "10.1.1.1" })
|
||||
.where(eq(schema.hosts.syncId, "host-shared-one")),
|
||||
);
|
||||
await on(b, () =>
|
||||
db(b)
|
||||
.delete(schema.hosts)
|
||||
.where(eq(schema.hosts.syncId, "host-shared-two")),
|
||||
);
|
||||
await on(b, () => addHost(b, { name: "from-b" }));
|
||||
|
||||
await pass(a, engineA);
|
||||
await pass(b, engineB);
|
||||
await pass(a, engineA);
|
||||
|
||||
const view = async (where: Side) =>
|
||||
(await on(where, () => hostsOf(where)))
|
||||
.map((h) => `${h.name}:${h.ip}`)
|
||||
.sort();
|
||||
const expected = [
|
||||
"from-a:10.0.0.1",
|
||||
"from-b:10.0.0.1",
|
||||
"shared-one:10.1.1.1",
|
||||
];
|
||||
expect(await view(server)).toEqual(expected);
|
||||
expect(await view(a)).toEqual(expected);
|
||||
expect(await view(b)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
registerEntity,
|
||||
resetSyncRegistry,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { getEntity } from "../../plugins/sync-registry.js";
|
||||
import {
|
||||
decryptFields,
|
||||
deserializeReferences,
|
||||
encryptFields,
|
||||
hashWire,
|
||||
orderSelfReferences,
|
||||
parseReferencePath,
|
||||
serializeReferences,
|
||||
toWire,
|
||||
toWritable,
|
||||
} from "../../sync/wire.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
const KEY = Buffer.alloc(32, 9);
|
||||
const OTHER_KEY = Buffer.alloc(32, 4);
|
||||
|
||||
function entity(type: string): RegisteredSyncEntity {
|
||||
return getEntity(type)!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
registerEntity("core", {
|
||||
type: "things",
|
||||
table: {},
|
||||
encryptedFields: ["secret"],
|
||||
readOnlyFields: ["deviceOnly"],
|
||||
references: [
|
||||
{ field: "parentId", syncField: "parentSyncId", entityType: "things" },
|
||||
{ field: "hops[].thingId", entityType: "things" },
|
||||
{
|
||||
field: "bindings[].action.snippetId",
|
||||
entityType: "things",
|
||||
idType: "string",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const toSync = async (_type: string, id: number) =>
|
||||
id === 404 ? null : `sync-${id}`;
|
||||
const toId = async (_type: string, syncId: string) =>
|
||||
syncId === "sync-missing" ? null : Number(syncId.replace("sync-", ""));
|
||||
|
||||
describe("reference paths", () => {
|
||||
it("parses columns, arrays and nested objects", () => {
|
||||
expect(parseReferencePath("credentialId")).toBeNull();
|
||||
expect(parseReferencePath("jumpHosts[].hostId")).toEqual({
|
||||
column: "jumpHosts",
|
||||
steps: [
|
||||
{ key: "", each: true },
|
||||
{ key: "hostId", each: false },
|
||||
],
|
||||
});
|
||||
expect(parseReferencePath("layout.panes[].hostId")).toEqual({
|
||||
column: "layout",
|
||||
steps: [
|
||||
{ key: "panes", each: true },
|
||||
{ key: "hostId", each: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializing references", () => {
|
||||
it("turns column and JSON ids into sync ids, keeping the storage form", async () => {
|
||||
const wire = await serializeReferences(
|
||||
entity("things"),
|
||||
{
|
||||
id: 1,
|
||||
parentId: 7,
|
||||
hops: JSON.stringify([{ thingId: 3 }, { thingId: 404 }]),
|
||||
bindings: [{ action: { type: "run", snippetId: "12" } }],
|
||||
},
|
||||
toSync,
|
||||
);
|
||||
|
||||
expect(wire.parentId).toBeUndefined();
|
||||
expect(wire.parentSyncId).toBe("sync-7");
|
||||
expect(JSON.parse(wire.hops as string)).toEqual([
|
||||
{ thingId: "sync-3" },
|
||||
{ thingId: null },
|
||||
]);
|
||||
expect(wire.bindings).toEqual([
|
||||
{ action: { type: "run", snippetId: "sync-12" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips back to local ids, including string ids", async () => {
|
||||
const back = await deserializeReferences(
|
||||
entity("things"),
|
||||
{
|
||||
parentSyncId: "sync-7",
|
||||
hops: JSON.stringify([{ thingId: "sync-3" }]),
|
||||
bindings: [{ action: { snippetId: "sync-12" } }],
|
||||
},
|
||||
toId,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(back.parentId).toBe(7);
|
||||
expect(back.parentSyncId).toBeUndefined();
|
||||
expect(JSON.parse(back.hops as string)).toEqual([{ thingId: 3 }]);
|
||||
expect(back.bindings).toEqual([{ action: { snippetId: "12" } }]);
|
||||
});
|
||||
|
||||
it("keeps the current value when a referenced row is not here", async () => {
|
||||
const back = await deserializeReferences(
|
||||
entity("things"),
|
||||
{ parentSyncId: "sync-missing" },
|
||||
toId,
|
||||
{ parentId: 5 },
|
||||
);
|
||||
expect(back.parentId).toBe(5);
|
||||
|
||||
const fresh = await deserializeReferences(
|
||||
entity("things"),
|
||||
{ parentSyncId: "sync-missing" },
|
||||
toId,
|
||||
null,
|
||||
);
|
||||
expect(fresh.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it("runs a registration's own serialize after the references", async () => {
|
||||
resetSyncRegistry();
|
||||
registerEntity("demo", {
|
||||
type: "custom",
|
||||
table: {},
|
||||
references: [
|
||||
{ field: "otherId", syncField: "otherSyncId", entityType: "custom" },
|
||||
],
|
||||
serialize: async (row) => ({ ...row, extra: row.otherSyncId }),
|
||||
});
|
||||
const wire = await serializeReferences(
|
||||
entity("custom"),
|
||||
{ otherId: 2 },
|
||||
toSync,
|
||||
);
|
||||
expect(wire.extra).toBe("sync-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret fields", () => {
|
||||
it("decrypts to plaintext and encrypts again", () => {
|
||||
const stored = encryptFields(
|
||||
entity("things"),
|
||||
{ id: 1, secret: "hunter2" },
|
||||
KEY,
|
||||
"1",
|
||||
);
|
||||
expect(FieldCrypto.isEncrypted(stored.secret as string)).toBe(true);
|
||||
|
||||
const plain = decryptFields(entity("things"), stored, KEY);
|
||||
expect(plain.secret).toBe("hunter2");
|
||||
});
|
||||
|
||||
it("refuses to decrypt with the wrong key instead of sending an empty secret", () => {
|
||||
const stored = encryptFields(
|
||||
entity("things"),
|
||||
{ id: 1, secret: "hunter2" },
|
||||
KEY,
|
||||
"1",
|
||||
);
|
||||
expect(() => decryptFields(entity("things"), stored, OTHER_KEY)).toThrow();
|
||||
});
|
||||
|
||||
it("leaves plaintext and already encrypted values alone when encrypting", () => {
|
||||
const once = encryptFields(entity("things"), { secret: "a" }, KEY, "1");
|
||||
const twice = encryptFields(entity("things"), once, KEY, "1");
|
||||
expect(twice.secret).toBe(once.secret);
|
||||
expect(
|
||||
decryptFields(entity("things"), { id: 1, secret: "plain" }, KEY).secret,
|
||||
).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wire shape", () => {
|
||||
it("drops local ids, the owner and device-only fields", () => {
|
||||
const wire = toWire(
|
||||
entity("things"),
|
||||
{ id: 3, userId: "u", name: "a", deviceOnly: true },
|
||||
"s-1",
|
||||
);
|
||||
expect(wire).toEqual({ name: "a", syncId: "s-1" });
|
||||
});
|
||||
|
||||
it("never lets an inbound row set ids, the owner or device-only fields", () => {
|
||||
const writable = toWritable(entity("things"), {
|
||||
id: 99,
|
||||
syncId: "s",
|
||||
userId: "attacker",
|
||||
deviceOnly: true,
|
||||
createdAt: "x",
|
||||
updatedAt: "y",
|
||||
name: "a",
|
||||
});
|
||||
expect(writable).toEqual({ name: "a" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("content hash", () => {
|
||||
const hash = (row: Record<string, unknown>) =>
|
||||
hashWire(entity("things"), row, KEY);
|
||||
|
||||
it("ignores where a row lives and device-only fields", () => {
|
||||
expect(hash({ syncId: "a", name: "x", updatedAt: "1" })).toBe(
|
||||
hash({ syncId: "b", name: "x", updatedAt: "2", deviceOnly: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats the same content the same whatever the driver returned", () => {
|
||||
expect(hash({ on: true, empty: null, json: '{"b":1,"a":2}' })).toBe(
|
||||
hash({ on: 1, empty: "", json: { a: 2, b: 1 } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when a secret changes and depends on the key", () => {
|
||||
expect(hash({ secret: "a" })).not.toBe(hash({ secret: "b" }));
|
||||
expect(hashWire(entity("things"), { secret: "a" }, OTHER_KEY)).not.toBe(
|
||||
hash({ secret: "a" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("self references", () => {
|
||||
it("puts parents before their children", () => {
|
||||
const rows = orderSelfReferences(entity("things"), [
|
||||
{ syncId: "child", parentSyncId: "parent" },
|
||||
{ syncId: "grandchild", parentSyncId: "child" },
|
||||
{ syncId: "parent", parentSyncId: null },
|
||||
]);
|
||||
expect(rows.map((row) => row.syncId)).toEqual([
|
||||
"parent",
|
||||
"child",
|
||||
"grandchild",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not loop on a cycle", () => {
|
||||
const rows = orderSelfReferences(entity("things"), [
|
||||
{ syncId: "a", parentSyncId: "b" },
|
||||
{ syncId: "b", parentSyncId: "a" },
|
||||
]);
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendCoreAlert = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const latest = vi.hoisted(() => ({
|
||||
value: null as null | { version: string; url: string },
|
||||
}));
|
||||
const version = vi.hoisted(() => ({ value: "2.9.0" as string | null }));
|
||||
const disabled = vi.hoisted(() => ({ value: false }));
|
||||
|
||||
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert }));
|
||||
vi.mock("../../utils/app-version.js", () => ({
|
||||
getLocalVersion: () => version.value,
|
||||
}));
|
||||
vi.mock("../../utils/latest-release.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../utils/latest-release.js")>()),
|
||||
fetchLatestRelease: async () => latest.value,
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => [{ id: "local" }],
|
||||
}),
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async () => ({ disableUpdateCheck: disabled.value }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { checkForDesktopUpdate } =
|
||||
await import("../../updates/desktop-update-check.js");
|
||||
|
||||
beforeEach(() => {
|
||||
sendCoreAlert.mockClear();
|
||||
version.value = "2.9.0";
|
||||
disabled.value = false;
|
||||
latest.value = {
|
||||
version: "2.9.1",
|
||||
url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1",
|
||||
};
|
||||
});
|
||||
|
||||
describe("desktop update check", () => {
|
||||
it("sends one alert per newer release, linking to it", async () => {
|
||||
await checkForDesktopUpdate();
|
||||
expect(sendCoreAlert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
category: "termix.update",
|
||||
dedupeKey: "update:2.9.1",
|
||||
link: { url: latest.value!.url },
|
||||
audience: "admins",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays quiet when up to date, ahead, or turned off", async () => {
|
||||
version.value = "2.9.1";
|
||||
await checkForDesktopUpdate();
|
||||
version.value = "3.0.0";
|
||||
await checkForDesktopUpdate();
|
||||
version.value = "2.9.0";
|
||||
disabled.value = true;
|
||||
await checkForDesktopUpdate();
|
||||
expect(sendCoreAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Tells desktop users a newer release is out, through the alerts inbox
|
||||
* rather than a dialog that blocks the app from opening.
|
||||
*/
|
||||
|
||||
import { sendCoreAlert } from "../notify/core-notify.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import { compareSemver, fetchLatestRelease } from "../utils/latest-release.js";
|
||||
import { systemLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createCurrentUserPreferenceRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
const CHECK_EVERY_MS = 12 * 60 * 60 * 1000;
|
||||
const FIRST_CHECK_MS = 60 * 1000;
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Whether every local user turned the update check off. */
|
||||
async function checkDisabled(): Promise<boolean> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length === 0) return false;
|
||||
const preferences = createCurrentUserPreferenceRepository();
|
||||
for (const user of users) {
|
||||
const row = await preferences.findByUserId(user.id);
|
||||
if (!row?.disableUpdateCheck) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function checkForDesktopUpdate(): Promise<void> {
|
||||
const current = getLocalVersion();
|
||||
if (!current || (await checkDisabled())) return;
|
||||
|
||||
const latest = await fetchLatestRelease();
|
||||
if (!latest) return;
|
||||
const comparison = compareSemver(current, latest.version);
|
||||
if (comparison === null || comparison >= 0) return;
|
||||
|
||||
await sendCoreAlert({
|
||||
title: `Termix ${latest.version} is available`,
|
||||
body: `You are on ${current}. Download the new version to update.`,
|
||||
severity: "info",
|
||||
category: "termix.update",
|
||||
dedupeKey: `update:${latest.version}`,
|
||||
audience: "admins",
|
||||
link: { url: latest.url },
|
||||
context: { value: latest.version },
|
||||
});
|
||||
}
|
||||
|
||||
export function startDesktopUpdateCheck(): void {
|
||||
if (timer) return;
|
||||
const run = () =>
|
||||
void checkForDesktopUpdate().catch((error) => {
|
||||
systemLogger.warn("Update check failed", {
|
||||
operation: "desktop_update_check",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
setTimeout(run, FIRST_CHECK_MS).unref?.();
|
||||
timer = setInterval(run, CHECK_EVERY_MS);
|
||||
timer.unref?.();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let cached: string | null | undefined;
|
||||
|
||||
function readVersion(file: string): string | null {
|
||||
try {
|
||||
const version = JSON.parse(fs.readFileSync(file, "utf8")).version;
|
||||
return typeof version === "string" && version !== "unknown"
|
||||
? version
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** This install's version: VERSION, else the nearest package.json. */
|
||||
export function getLocalVersion(): string | null {
|
||||
if (cached !== undefined) return cached;
|
||||
cached =
|
||||
process.env.VERSION ||
|
||||
readVersion(path.resolve(process.cwd(), "package.json")) ||
|
||||
readVersion(path.resolve("/app", "package.json")) ||
|
||||
readVersion(path.resolve(here, "../../../package.json")) ||
|
||||
readVersion(path.resolve(here, "../../../../package.json")) ||
|
||||
null;
|
||||
return cached;
|
||||
}
|
||||
@@ -130,6 +130,13 @@ class FieldCrypto {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/** The encrypted fields of a table, as property names. */
|
||||
static fieldsFor(tableName: string): string[] {
|
||||
const fields =
|
||||
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
return fields ? [...fields] : [];
|
||||
}
|
||||
|
||||
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||
const fields =
|
||||
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { getProxyAgent } from "./proxy-agent.js";
|
||||
import type {
|
||||
CacheEntry,
|
||||
GitHubAPIResponse,
|
||||
GitHubRelease,
|
||||
} from "../../types/index.js";
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
export 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";
|
||||
export const REPO_OWNER = "Termix-SSH";
|
||||
export const REPO_NAME = "Termix";
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LatestRelease {
|
||||
version: string;
|
||||
tagName: string;
|
||||
name: string;
|
||||
url: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
/** The newest Termix release on GitHub, cached for 30 minutes. */
|
||||
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
|
||||
const release = await fetchGitHubAPI<GitHubRelease>(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
"latest_release",
|
||||
);
|
||||
const rawTag = release.data.tag_name || release.data.name || "";
|
||||
const version = rawTag.match(/(\d+\.\d+(\.\d+)?)/)?.[1];
|
||||
if (!version) return null;
|
||||
return {
|
||||
version,
|
||||
tagName: release.data.tag_name,
|
||||
name: release.data.name,
|
||||
url: release.data.html_url,
|
||||
publishedAt: release.data.published_at,
|
||||
};
|
||||
}
|
||||
@@ -306,5 +306,6 @@ export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||
export const pluginLogger = new Logger("PLUGIN", "🧩", "#84cc16");
|
||||
export const syncLogger = new Logger("SYNC", "🔄", "#06b6d4");
|
||||
|
||||
export const logger = systemLogger;
|
||||
Reference in new issue
Block a user