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

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

No files matched your search

+14 -163
View File
@@ -16,7 +16,9 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import syncRoutes from "./routes/sync.js";
import syncRoutes from "../sync/server/routes.js";
import syncLinkRoutes from "../sync/client/routes.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import {
mountPluginApi,
@@ -33,6 +35,13 @@ import path from "path";
import os from "os";
import "dotenv/config";
import { databaseLogger, apiLogger } from "../utils/logger.js";
import { getLocalVersion } from "../utils/app-version.js";
import {
compareSemver,
fetchGitHubAPI,
REPO_NAME,
REPO_OWNER,
} from "../utils/latest-release.js";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
@@ -55,13 +64,7 @@ import {
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { getProxyAgent } from "../utils/proxy-agent.js";
import type {
CacheEntry,
GitHubRelease,
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import type { GitHubRelease, AuthenticatedRequest } from "../../types/index.js";
import { DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
@@ -147,116 +150,6 @@ const upload = multer({
},
});
class GitHubCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000;
set<T>(key: string, data: T): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
}
const githubCache = new GitHubCache();
function parseSemver(
version: string | undefined,
): [number, number, number] | null {
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
}
function compareSemver(
a: string | undefined,
b: string | undefined,
): number | null {
const parsedA = parseSemver(a);
const parsedB = parseSemver(b);
if (!parsedA || !parsedB) return null;
for (let i = 0; i < 3; i += 1) {
if (parsedA[i] > parsedB[i]) return 1;
if (parsedA[i] < parsedB[i]) return -1;
}
return 0;
}
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix";
async function fetchGitHubAPI<T>(
endpoint: string,
cacheKey: string,
): Promise<GitHubAPIResponse<T>> {
const cachedEntry = githubCache.get<CacheEntry<T>>(cacheKey);
if (cachedEntry) {
return {
data: cachedEntry.data,
cached: true,
cache_age: Date.now() - cachedEntry.timestamp,
};
}
try {
const url = `${GITHUB_API_BASE}${endpoint}`;
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as T;
const cacheData: CacheEntry<T> = {
data,
timestamp: Date.now(),
expiresAt: Date.now() + 30 * 60 * 1000,
};
githubCache.set(cacheKey, cacheData);
return {
data: data,
cached: false,
};
} catch (error) {
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
operation: "github_api",
endpoint,
});
throw error;
}
}
// Skipped for /plugin-api: a plugin router brings its own parsers with its own
// limit (see plugins/http.ts), and parsing here first would consume the body
// and silently cap every plugin at this limit instead.
@@ -279,6 +172,7 @@ app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use(syncChangeWatcher);
/**
* @openapi
@@ -343,51 +237,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* description: Fetch error.
*/
app.get("/version", authenticateJWT, async (req, res) => {
let localVersion = process.env.VERSION;
if (!localVersion) {
const versionSources = [
() => {
try {
const packagePath = path.resolve(process.cwd(), "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packagePath = path.resolve("/app", "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packagePath = path.resolve(__dirname, "../../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
];
for (const getVersion of versionSources) {
try {
const foundVersion = getVersion();
if (foundVersion && foundVersion !== "unknown") {
localVersion = foundVersion;
break;
}
} catch {
continue;
}
}
}
const localVersion = getLocalVersion();
if (!localVersion) {
databaseLogger.error("No version information available", undefined, {
@@ -1567,6 +1417,7 @@ app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncRoutes);
app.use("/sync", syncLinkRoutes);
app.use("/dashboard", dashboardRoutes);
app.use("/plugins", pluginRoutes);
app.use(
+72 -20
View File
@@ -1598,28 +1598,80 @@ const migrateSchema = () => {
}
}
addColumnIfNotExists(
"ssh_data",
"local_only",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "shared_source", "TEXT");
addColumnIfNotExists("ssh_credentials", "shared_source", "TEXT");
addColumnIfNotExists(
"ssh_folders",
"local_only",
"INTEGER NOT NULL DEFAULT 0",
);
try {
sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS sync_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
sqlite.exec(
"CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
sqlite.exec(`
DROP TABLE IF EXISTS sync_tombstones;
CREATE TABLE IF NOT EXISTS sync_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL DEFAULT 0,
hash TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
error TEXT,
error_hash TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
} catch (createError) {
databaseLogger.warn("Failed to create sync_tombstones table", {
operation: "schema_migration",
error: createError,
});
}
CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_records_user_entity_sync
ON sync_records(user_id, entity_type, sync_id);
CREATE INDEX IF NOT EXISTS idx_sync_records_user_seq
ON sync_records(user_id, seq);
CREATE TABLE IF NOT EXISTS sync_conflicts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
local_row TEXT NOT NULL,
server_revision INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user
ON sync_conflicts(user_id);
CREATE TABLE IF NOT EXISTS sync_link (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_url TEXT NOT NULL,
server_name TEXT,
server_version TEXT,
session_token TEXT,
custom_headers TEXT,
basic_auth TEXT,
allow_invalid_certificate INTEGER NOT NULL DEFAULT 0,
remote_user_id TEXT,
remote_username TEXT,
account TEXT,
scope TEXT,
known_types TEXT,
cursor INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'idle',
last_error TEXT,
linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_sync_at TEXT
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create sync tables", {
operation: "schema_migration",
error: createError,
});
}
// --- sync end ---
+95 -7
View File
@@ -251,6 +251,11 @@ export const hosts = mysqlTable(
// databases (the embedded backend and a connected remote server) during
// sync -- local autoincrement ids collide across instances.
syncId: varchar("sync_id", { length: 255 }).unique(),
// Desktop only: a host kept on this device that never goes to the server.
localOnly: boolean("local_only").notNull().default(false),
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
@@ -303,6 +308,7 @@ export const sshCredentials = mysqlTable(
usageCount: int("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -353,6 +359,7 @@ export const sshFolders = mysqlTable(
// to name sort, same convention as hosts.sortOrder.
sortOrder: int("sort_order"),
syncId: varchar("sync_id", { length: 255 }).unique(),
localOnly: boolean("local_only").notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -707,19 +714,100 @@ export const uiPreferences = mysqlTable("ui_preferences", {
});
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
// the deletion instead of re-creating the row on its next pull.
export const syncTombstones = mysqlTable("sync_tombstones", {
/**
* One row per synced record a user owns, on both ends of a sync link.
*
* On a server, revision counts the record's changes and seq is the position
* in the user's change feed, which is what a desktop's cursor points at. On a
* linked desktop, revision is the last server revision this device saw and
* hash is what the record looked like then, so a different hash now means a
* local edit waiting to be pushed.
*/
export const syncRecords = mysqlTable(
"sync_records",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: varchar("entity_type", { length: 255 }).notNull(),
syncId: varchar("sync_id", { length: 255 }).notNull(),
revision: int("revision").notNull().default(0),
seq: int("seq").notNull().default(0),
hash: text("hash"),
deleted: boolean("deleted").notNull().default(false),
// Desktop only: why the server refused the last push of this record, and
// the hash that was refused, so the same content is not re-sent forever.
error: text("error"),
errorHash: text("error_hash"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_sync_records_user_entity_sync").on(
table.userId,
table.entityType,
table.syncId,
),
index("idx_sync_records_user_seq").on(table.userId, table.seq),
],
);
/**
* Desktop only: a local edit that lost to a newer server edit. The server
* version was applied; this keeps the local one so the user can pick it.
*/
export const syncConflicts = mysqlTable(
"sync_conflicts",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
localRow: text("local_row").notNull(),
serverRevision: int("server_revision").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
);
/**
* Desktop only: the server this install is linked to. One row at most.
* Secrets (session token, proxy headers, basic auth) are encrypted with the
* system key.
*/
export const syncLink = mysqlTable("sync_link", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
serverUrl: text("server_url").notNull(),
serverName: text("server_name"),
serverVersion: text("server_version"),
sessionToken: text("session_token"),
customHeaders: text("custom_headers"),
basicAuth: text("basic_auth"),
allowInvalidCertificate: boolean("allow_invalid_certificate")
.notNull()
.default(false),
remoteUserId: text("remote_user_id"),
remoteUsername: text("remote_username"),
// The linked account as the server describes it: roles, admin, permissions.
account: text("account"),
scope: text("scope"),
knownTypes: text("known_types"),
cursor: int("cursor").notNull().default(0),
status: text("status").notNull().default("idle"),
lastError: text("last_error"),
linkedAt: text("linked_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
lastSyncAt: text("last_sync_at"),
});
// --- sync end ---
+95 -7
View File
@@ -252,6 +252,11 @@ export const hosts = pgTable(
// databases (the embedded backend and a connected remote server) during
// sync -- local autoincrement ids collide across instances.
syncId: varchar("sync_id", { length: 255 }).unique(),
// Desktop only: a host kept on this device that never goes to the server.
localOnly: boolean("local_only").notNull().default(false),
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
@@ -304,6 +309,7 @@ export const sshCredentials = pgTable(
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -354,6 +360,7 @@ export const sshFolders = pgTable(
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
syncId: varchar("sync_id", { length: 255 }).unique(),
localOnly: boolean("local_only").notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -708,19 +715,100 @@ export const uiPreferences = pgTable("ui_preferences", {
});
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
// the deletion instead of re-creating the row on its next pull.
export const syncTombstones = pgTable("sync_tombstones", {
/**
* One row per synced record a user owns, on both ends of a sync link.
*
* On a server, revision counts the record's changes and seq is the position
* in the user's change feed, which is what a desktop's cursor points at. On a
* linked desktop, revision is the last server revision this device saw and
* hash is what the record looked like then, so a different hash now means a
* local edit waiting to be pushed.
*/
export const syncRecords = pgTable(
"sync_records",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: varchar("entity_type", { length: 255 }).notNull(),
syncId: varchar("sync_id", { length: 255 }).notNull(),
revision: integer("revision").notNull().default(0),
seq: integer("seq").notNull().default(0),
hash: text("hash"),
deleted: boolean("deleted").notNull().default(false),
// Desktop only: why the server refused the last push of this record, and
// the hash that was refused, so the same content is not re-sent forever.
error: text("error"),
errorHash: text("error_hash"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_sync_records_user_entity_sync").on(
table.userId,
table.entityType,
table.syncId,
),
index("idx_sync_records_user_seq").on(table.userId, table.seq),
],
);
/**
* Desktop only: a local edit that lost to a newer server edit. The server
* version was applied; this keeps the local one so the user can pick it.
*/
export const syncConflicts = pgTable(
"sync_conflicts",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
localRow: text("local_row").notNull(),
serverRevision: integer("server_revision").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
);
/**
* Desktop only: the server this install is linked to. One row at most.
* Secrets (session token, proxy headers, basic auth) are encrypted with the
* system key.
*/
export const syncLink = pgTable("sync_link", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
serverUrl: text("server_url").notNull(),
serverName: text("server_name"),
serverVersion: text("server_version"),
sessionToken: text("session_token"),
customHeaders: text("custom_headers"),
basicAuth: text("basic_auth"),
allowInvalidCertificate: boolean("allow_invalid_certificate")
.notNull()
.default(false),
remoteUserId: text("remote_user_id"),
remoteUsername: text("remote_username"),
// The linked account as the server describes it: roles, admin, permissions.
account: text("account"),
scope: text("scope"),
knownTypes: text("known_types"),
cursor: integer("cursor").notNull().default(0),
status: text("status").notNull().default("idle"),
lastError: text("last_error"),
linkedAt: text("linked_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
lastSyncAt: text("last_sync_at"),
});
// --- sync end ---
+97 -7
View File
@@ -244,6 +244,11 @@ export const hosts = sqliteTable(
// databases (the embedded backend and a connected remote server) during
// sync -- local autoincrement ids collide across instances.
syncId: text("sync_id").unique(),
// Desktop only: a host kept on this device that never goes to the server.
localOnly: integer("local_only", { mode: "boolean" }).notNull().default(false),
// Desktop only: set on the read-only copy of a host someone shared with
// the linked account. JSON with the share's owner and permission level.
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
@@ -296,6 +301,7 @@ export const sshCredentials = sqliteTable(
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: text("sync_id").unique(),
sharedSource: text("shared_source"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -346,6 +352,7 @@ export const sshFolders = sqliteTable(
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
syncId: text("sync_id").unique(),
localOnly: integer("local_only", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -704,19 +711,102 @@ export const uiPreferences = sqliteTable("ui_preferences", {
});
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
// the deletion instead of re-creating the row on its next pull.
export const syncTombstones = sqliteTable("sync_tombstones", {
/**
* One row per synced record a user owns, on both ends of a sync link.
*
* On a server, revision counts the record's changes and seq is the position
* in the user's change feed, which is what a desktop's cursor points at. On a
* linked desktop, revision is the last server revision this device saw and
* hash is what the record looked like then, so a different hash now means a
* local edit waiting to be pushed.
*/
export const syncRecords = sqliteTable(
"sync_records",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
revision: integer("revision").notNull().default(0),
seq: integer("seq").notNull().default(0),
hash: text("hash"),
deleted: integer("deleted", { mode: "boolean" }).notNull().default(false),
// Desktop only: why the server refused the last push of this record, and
// the hash that was refused, so the same content is not re-sent forever.
error: text("error"),
errorHash: text("error_hash"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_sync_records_user_entity_sync").on(
table.userId,
table.entityType,
table.syncId,
),
index("idx_sync_records_user_seq").on(table.userId, table.seq),
],
);
/**
* Desktop only: a local edit that lost to a newer server edit. The server
* version was applied; this keeps the local one so the user can pick it.
*/
export const syncConflicts = sqliteTable(
"sync_conflicts",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
localRow: text("local_row").notNull(),
serverRevision: integer("server_revision").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
);
/**
* Desktop only: the server this install is linked to. One row at most.
* Secrets (session token, proxy headers, basic auth) are encrypted with the
* system key.
*/
export const syncLink = sqliteTable("sync_link", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
serverUrl: text("server_url").notNull(),
serverName: text("server_name"),
serverVersion: text("server_version"),
sessionToken: text("session_token"),
customHeaders: text("custom_headers"),
basicAuth: text("basic_auth"),
allowInvalidCertificate: integer("allow_invalid_certificate", {
mode: "boolean",
})
.notNull()
.default(false),
remoteUserId: text("remote_user_id"),
remoteUsername: text("remote_username"),
// The linked account as the server describes it: roles, admin, permissions.
account: text("account"),
scope: text("scope"),
knownTypes: text("known_types"),
cursor: integer("cursor").notNull().default(0),
status: text("status").notNull().default("idle"),
lastError: text("last_error"),
linkedAt: text("linked_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
lastSyncAt: text("last_sync_at"),
});
// --- sync end ---
@@ -31,7 +31,6 @@ import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
@@ -138,13 +137,6 @@ export function createCurrentCredentialRepository(): CredentialRepository {
);
}
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
return new SyncTombstoneRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
);
}
export function createCurrentHostFolderRepository(): HostFolderRepository {
return new HostFolderRepository(
createCurrentRepositoryContext(),
@@ -85,6 +85,28 @@ export class HostFolderRepository {
.where(eq(sshFolders.userId, userId));
}
/**
* Keeps a folder, its subfolders and every host in them on this device
* only, or lets them sync again.
*/
async setLocalOnly(
userId: string,
folderName: string,
localOnly: boolean,
): Promise<void> {
const inFolder = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, `${folderName} / %`));
await this.context.drizzle
.update(sshFolders)
.set({ localOnly })
.where(and(eq(sshFolders.userId, userId), inFolder(sshFolders.name)));
await this.context.drizzle
.update(hosts)
.set({ localOnly, updatedAt: new Date().toISOString() })
.where(and(eq(hosts.userId, userId), inFolder(hosts.folder)));
await this.afterWrite();
}
async upsertMetadata(
userId: string,
name: string,
@@ -1,73 +0,0 @@
import { and, eq } from "drizzle-orm";
import { syncTombstones } from "../db/schema.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";
import type { DatabaseContext } from "./database-context.js";
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
/**
* The wire name of a synced entity.
*
* A string rather than a union: the set is whatever core and the installed
* plugins have registered, so it cannot be known at compile time. The column
* was already free-form text with no enum constraint, so nothing in the
* database changes. sync.ts validates a name through the registry before it
* reaches a query.
*/
export type SyncEntityType = string;
export class SyncTombstoneRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async record(
userId: string,
entityType: SyncEntityType,
syncId: string,
): Promise<void> {
if (!syncId) return;
await this.context.drizzle.insert(syncTombstones).values({
userId,
entityType,
syncId,
});
await this.afterWrite();
}
async recordMany(
userId: string,
entityType: SyncEntityType,
syncIds: string[],
): Promise<void> {
const rows = syncIds.filter(Boolean);
if (rows.length === 0) return;
await this.context.drizzle
.insert(syncTombstones)
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
await this.afterWrite();
}
async listSince(
userId: string,
entityType: SyncEntityType,
since: string | null,
): Promise<SyncTombstoneRecord[]> {
const conditions = [
eq(syncTombstones.userId, userId),
eq(syncTombstones.entityType, entityType),
];
if (since)
conditions.push(timestampAtOrAfter(syncTombstones.deletedAt, since));
return this.context.drizzle
.select()
.from(syncTombstones)
.where(and(...conditions));
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
+20 -52
View File
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
import { deleteOwnedCredential } from "../../hosts/delete-credential.js";
import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
@@ -22,8 +23,9 @@ import {
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { parseSharedSource } from "./host-normalizers.js";
/** Built-in password and key, plus any type a plugin offers for credentials. */
function getCredentialTypes(): string[] {
@@ -32,6 +34,7 @@ function getCredentialTypes(): string[] {
}
const router = express.Router();
router.use(rejectSharedCopyWrites("credential", /^\/(\d+)$/));
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
@@ -816,59 +819,11 @@ router.delete(
try {
const credentialId = parseInt(id);
const credentialToDelete =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (!credentialToDelete) {
const deleted = await deleteOwnedCredential(userId, credentialId);
if (!deleted) {
return res.status(404).json({ error: "Credential not found" });
}
const hostsUsingCredential =
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
userId,
credentialId,
);
if (hostsUsingCredential.length > 0) {
await createCurrentHostRepository().updateManyForUser(
userId,
hostsUsingCredential.map((host) => host.id),
{
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: "password",
},
);
}
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
await sharedSecretsManager.deleteForCredential(credentialId);
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
if (credentialToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"sshCredentials",
credentialToDelete.syncId,
);
}
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
for (const host of hostsUsingCredential) {
await sharedSecretsManager.resyncHost(host.id);
}
authLogger.success("SSH credential deleted", {
operation: "credential_delete_success",
userId,
@@ -882,7 +837,7 @@ router.delete(
action: "delete_credential",
resourceType: "credential",
resourceId: id,
resourceName: String(credentialToDelete.name ?? id),
resourceName: String(deleted.name ?? id),
ipAddress: cdIp,
userAgent: cdUa,
success: true,
@@ -1138,6 +1093,19 @@ function formatCredentialOutput(
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
...sharedCopyFields(credential.sharedSource),
};
}
/** A read-only copy of a credential shared with a linked desktop's account. */
function sharedCopyFields(value: unknown): Record<string, unknown> {
const shared = parseSharedSource(value);
if (!shared) return {};
return {
isShared: true,
sharedCopy: true,
ownerUsername: shared.owner || null,
permissionLevel: shared.permissionLevel === "manage" ? "manage" : "use",
};
}
@@ -21,6 +21,17 @@ export function isLoopbackRequest(req: Request): boolean {
);
}
/**
* Whether this backend may hand out the desktop auto-session at all: the
* embedded desktop backend, or a development backend the desktop app points
* at. A production server never does, whoever asks.
*/
export function allowsDesktopAutoSession(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env.ELECTRON_EMBEDDED === "true" || env.NODE_ENV !== "production";
}
export function extractBearerOrCookieToken(req: Request): string | undefined {
const cookieToken = (req as Request & { cookies?: Record<string, string> })
.cookies?.jwt;
@@ -9,7 +9,6 @@ import {
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -178,6 +177,9 @@ export function registerHostFolderRoutes(
* credentialId:
* type: integer
* nullable: true
* localOnly:
* type: boolean
* description: Desktop only. Keeps the folder and its hosts on this device instead of syncing them.
* responses:
* 200:
* description: Folder metadata updated successfully.
@@ -193,7 +195,7 @@ export function registerHostFolderRoutes(
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon, credentialId } = req.body;
const { name, color, icon, credentialId, localOnly } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
@@ -234,6 +236,13 @@ export function registerHostFolderRoutes(
icon,
normalizedCredentialId,
);
if (typeof localOnly === "boolean") {
await createCurrentHostFolderRepository().setLocalOnly(
userId,
name,
localOnly,
);
}
if (!created) {
databaseLogger.info("Updating SSH folder", {
@@ -411,17 +420,9 @@ export function registerHostFolderRoutes(
);
}
const { hostSyncIds, folderSyncIds } =
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
folderName,
);
const tombstoneRepository = createCurrentSyncTombstoneRepository();
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
await tombstoneRepository.recordMany(
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
"sshFolders",
folderSyncIds,
folderName,
);
try {
@@ -383,11 +383,35 @@ export function sanitizeHostForRecipient(
return reduced;
}
/** Who shared a host that a linked desktop holds a read-only copy of. */
export function parseSharedSource(
value: unknown,
): { owner?: string; permissionLevel?: string } | null {
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
export function transformHostResponse(
host: Record<string, unknown>,
): Record<string, unknown> {
const shared = parseSharedSource(host.sharedSource);
return {
...host,
sharedSource: undefined,
localOnly: !!host.localOnly,
sharedCopy: !!shared,
...(shared
? {
isShared: true,
ownerUsername: shared.owner || undefined,
permissionLevel: shared.permissionLevel || "connect",
}
: {}),
tags:
typeof host.tags === "string"
? host.tags
+12 -4
View File
@@ -67,8 +67,10 @@ import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
} from "../../utils/shared-host-auth-resolver.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
const router = express.Router();
router.use(rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/));
const upload = multer({ storage: multer.memoryStorage() });
@@ -199,6 +201,7 @@ router.post(
socks5Password,
socks5ProxyChain,
connectionOrigin,
localOnly,
portKnockSequence,
overrideCredentialUsername,
enableSsh,
@@ -311,6 +314,7 @@ router.post(
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
...(typeof localOnly === "boolean" ? { localOnly } : {}),
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -795,6 +799,7 @@ router.put(
socks5Password,
socks5ProxyChain,
connectionOrigin,
localOnly,
portKnockSequence,
overrideCredentialUsername,
enableSsh,
@@ -908,6 +913,7 @@ router.put(
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
...(typeof localOnly === "boolean" ? { localOnly } : {}),
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -1454,14 +1460,16 @@ router.get(
const result = await Promise.all(
data.map(async (row: Record<string, unknown>) => {
const transformed = transformHostResponse(row);
const baseHost = {
...transformHostResponse(row),
isShared: !!row.isShared,
permissionLevel: row.permissionLevel || undefined,
...transformed,
isShared: !!row.isShared || !!transformed.sharedCopy,
permissionLevel:
row.permissionLevel || transformed.permissionLevel || undefined,
sharedExpiresAt: row.expiresAt || undefined,
ownerUsername: row.isShared
? ownerUsernames.get(row.userId as string) || undefined
: undefined,
: transformed.ownerUsername,
};
const resolved =
+19
View File
@@ -306,6 +306,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
}
});
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
const { getLink } = await import("../../sync/client/link-store.js");
if (!(await getLink())) return false;
const { isServerManaged } = await import("../../sync/client/plugins.js");
return isServerManaged(pluginId);
}
/**
* @openapi
* /plugins/{id}/state:
@@ -338,6 +346,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin.
* 409:
* description: This desktop is linked to a server, which decides whether the plugin runs.
*/
router.patch(
"/:id/state",
@@ -360,6 +370,15 @@ router.patch(
return;
}
// A linked desktop runs what its server runs.
if (await isManagedByLinkedServer(pluginId)) {
res.status(409).json({
error: "Managed by the linked server",
code: "MANAGED_BY_SERVER",
});
return;
}
// Persist first: if the start or stop below throws, the recorded
// intent still matches what the user asked for, and the next boot
// acts on it rather than silently reverting.
@@ -1,112 +0,0 @@
/**
* Core's own sync entities, registered the same way a plugin registers one.
*
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
* the tombstone repository, a REFERENCES map in sync-references.ts and a
* frozen array in electron/. Four lists that had to agree and had no test
* saying they did. Now there is one registration per entity and the rest is
* derived.
*
* The order values reproduce the dependency order the Electron array encoded:
* anything referenced by another entity syncs first.
*/
import {
hosts,
sshCredentials,
sshFolders,
userPreferences,
} from "../db/schema.js";
import {
CORE_OWNER,
registerEntity,
listEntities,
} from "../../plugins/sync-registry.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
} as const;
const HOST_REFERENCES = [
CREDENTIAL_REFERENCE,
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
entityType: "hosts",
},
] as const;
let registered = false;
/**
* Registers core's entities. Idempotent, because the sync routes and the
* Electron entity endpoint both need the registry primed and neither can
* assume it ran first.
*/
export function registerCoreSyncEntities(): void {
if (registered && listEntities().length > 0) return;
registered = true;
// 10, 20, 30... so a plugin can slot an entity between two core ones
// without every order value having to be renumbered.
registerEntity(CORE_OWNER, {
type: "sshCredentials",
table: sshCredentials,
order: 10,
});
// vaultProfiles (order 20) moved to the vault plugin. Hosts no longer
// reference it: a host's profile is the plugin's profileId host setting.
registerEntity(CORE_OWNER, {
type: "sshFolders",
table: sshFolders,
order: 30,
references: [CREDENTIAL_REFERENCE],
});
registerEntity(CORE_OWNER, {
type: "hosts",
table: hosts,
order: 50,
// connectionOrigin says how this device reached the host, which is never
// true of the other side.
readOnlyFields: ["connectionOrigin"],
references: HOST_REFERENCES,
encryptedFields: ["ssh_data"],
});
// dashboardServiceLinks (order 70) and homepageItems (order 80) moved to
// the homepage plugin, which registers them itself at activation.
registerEntity(CORE_OWNER, {
type: "userPreferences",
table: userPreferences,
order: 90,
singleton: true,
readOnlyFields: ["storageMode"],
});
}
/** The tables a reference may point at, derived from the registrations. */
export const ENCRYPTED_ENTITY_TABLES: Readonly<Record<string, string>> = {
hosts: "ssh_data",
sshCredentials: "ssh_credentials",
};
@@ -1,84 +0,0 @@
/**
* Host plugin settings over remote 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 {
declaredFields,
getAllSettings,
setSetting,
} from "../../plugins/settings.js";
import { databaseLogger } from "../../utils/logger.js";
import {
hostSettingsPlugins,
type HostPluginSettings,
} from "./host-plugin-settings.js";
export interface PluginHostSettingsSync {
exportValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
}
/** What a host carries to the other side of a sync pair. */
export async function exportHostPluginSettings(
hostId: number,
): Promise<HostPluginSettings> {
const result: HostPluginSettings = {};
for (const manifest of hostSettingsPlugins()) {
const fields = declaredFields(manifest, "host").filter(
(field) => field.type !== "secret",
);
if (fields.length === 0) continue;
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
});
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
const own: Record<string, unknown> = {};
for (const field of fields) {
const value = values[field.key];
own[field.key] = hook?.exportValue
? await hook.exportValue(field.key, value)
: value;
}
result[manifest.id] = own;
}
return result;
}
/** Writes what a synced host carried, translated back to local ids. */
export async function importHostPluginSettings(
hostId: number,
carried: unknown,
): Promise<void> {
if (!carried || typeof carried !== "object") return;
const byPlugin = carried as HostPluginSettings;
for (const manifest of hostSettingsPlugins()) {
const own = byPlugin[manifest.id];
if (!own || typeof own !== "object") continue;
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
for (const field of declaredFields(manifest, "host")) {
if (field.type === "secret" || !(field.key in own)) continue;
try {
const value = hook?.importValue
? await hook.importValue(field.key, own[field.key])
: own[field.key];
await setSetting(manifest, "host", hostId, field.key, value);
} catch (error) {
databaseLogger.warn("Could not apply a synced host plugin setting", {
operation: "sync_host_plugin_settings",
pluginId: manifest.id,
hostId,
key: field.key,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
}
@@ -1,123 +0,0 @@
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import { getEntity } from "../../plugins/sync-registry.js";
/**
* An entity another entity's rows may point at.
*
* A plain string rather than a union: the set is whatever is registered, and a
* plugin can both register an entity and reference one. sync.ts resolves the
* name through the registry, so an unknown one is an error there rather than a
* silent fallthrough.
*/
export type SyncReferenceEntity = string;
interface SyncReference {
field: string;
syncField: string;
entityType: SyncReferenceEntity;
}
export function orderSyncRows(
entityType: SyncEntityType,
rows: Record<string, unknown>[],
): Record<string, unknown>[] {
// Only an entity that references itself needs its rows ordered among
// themselves. Today that is hosts, through parentHostId.
const selfReference = referencesFor(entityType).find(
(reference) => reference.entityType === entityType,
);
if (!selfReference) return rows;
const bySyncId = new Map(
rows
.filter((row) => typeof row.syncId === "string")
.map((row) => [row.syncId as string, row]),
);
const ordered: Record<string, unknown>[] = [];
const visited = new Set<Record<string, unknown>>();
const visiting = new Set<Record<string, unknown>>();
const visit = (row: Record<string, unknown>) => {
if (visited.has(row)) return;
if (visiting.has(row)) return;
visiting.add(row);
const parentSyncId = row[selfReference.syncField];
if (typeof parentSyncId === "string") {
const parent = bySyncId.get(parentSyncId);
if (parent) visit(parent);
}
visiting.delete(row);
visited.add(row);
ordered.push(row);
};
rows.forEach(visit);
return ordered;
}
function referencesFor(entityType: SyncEntityType): readonly SyncReference[] {
return (getEntity(entityType)?.references ?? []) as readonly SyncReference[];
}
export async function serializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveSyncId: (
entityType: SyncReferenceEntity,
id: number,
) => Promise<string | null>,
): Promise<Record<string, unknown>> {
const serialized = { ...row };
const custom = getEntity(entityType)?.serialize;
if (custom) return custom(serialized, resolveSyncId);
for (const reference of referencesFor(entityType)) {
const id = serialized[reference.field];
serialized[reference.syncField] =
typeof id === "number"
? await resolveSyncId(reference.entityType, id)
: null;
delete serialized[reference.field];
}
return serialized;
}
export async function deserializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveId: (
entityType: SyncReferenceEntity,
syncId: string,
) => Promise<number | null>,
): Promise<Record<string, unknown>> {
const deserialized = { ...row };
const custom = getEntity(entityType)?.deserialize;
if (custom) return custom(deserialized, resolveId);
for (const reference of referencesFor(entityType)) {
const syncId = deserialized[reference.syncField];
delete deserialized[reference.syncField];
delete deserialized[reference.field];
if (syncId == null) {
deserialized[reference.field] = null;
continue;
}
if (typeof syncId !== "string") {
throw new Error(`Invalid ${reference.syncField}`);
}
const id = await resolveId(reference.entityType, syncId);
if (id === null) {
throw new Error(
`Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`,
);
}
deserialized[reference.field] = id;
}
return deserialized;
}
-588
View File
@@ -1,588 +0,0 @@
import express, { type Request, type Response } from "express";
import { and, eq, type SQL } from "drizzle-orm";
import { hosts } from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentRepositoryContext,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import {
getEntity,
hasEntity,
listEntities,
} from "../../plugins/sync-registry.js";
import {
ENCRYPTED_ENTITY_TABLES,
registerCoreSyncEntities,
} from "./sync-entities.js";
import {
deserializeSyncReferences,
orderSyncRows,
serializeSyncReferences,
type SyncReferenceEntity,
} from "./sync-references.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";
import {
insertReturningWhere,
updateReturning,
} from "../repositories/returning.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
exportHostPluginSettings,
importHostPluginSettings,
} from "./sync-host-plugin-settings.js";
// Primed at import so every consumer of this module - the routes below, the
// reference resolvers, the Electron entity-types endpoint - sees the same
// registry regardless of which one runs first.
registerCoreSyncEntities();
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* What a sync entity looks like once it is registered.
*
* The table was a hand-maintained union of ten concrete drizzle types, which a
* plugin-provided table could never join. It is the same deliberate
* approximation repositories/database-context.ts documents: the query-builder
* surface used here is identical across the engines and across tables, and
* every use already casts to reach .syncId and .updatedAt.
*/
type SyncTable = typeof hosts;
function entityConfig(entityType: SyncEntityType) {
const entity = getEntity(entityType);
if (!entity) {
throw new Error(`Unknown sync entity "${entityType}"`);
}
return {
table: entity.table as SyncTable,
readOnlyFields: entity.readOnlyFields,
singleton: entity.singleton === true,
shouldSync: (row: Record<string, unknown>) => isSyncedRow(entityType, row),
};
}
/**
* Whether a row takes part in sync. An entity can leave some of its rows out
* (a per-install "last session"): they are not pulled, and a push, update or
* delete aimed at one is refused.
*/
export function isSyncedRow(
entityType: string,
row: Record<string, unknown>,
): boolean {
const entity = getEntity(entityType);
if (!entity?.shouldSync) return true;
return entity.shouldSync(row) !== false;
}
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
export function isValidEntityType(value: unknown): value is SyncEntityType {
if (typeof value !== "string") return false;
registerCoreSyncEntities();
return hasEntity(value);
}
/**
* Locates the stored row a sync payload corresponds to.
*
* Read and write have to agree on this. A singleton entity is keyed on its
* owner rather than a sync id, and `user_preferences` — the only singleton —
* has no `id` column at all, so an update cannot fall back to one: `table.id`
* is undefined there and drizzle emits `WHERE = ?`.
*/
export function locateSyncRow(
entityType: SyncEntityType,
userId: string,
syncId: string,
): SQL {
const { table, singleton } = entityConfig(entityType);
if (singleton) {
return eq(table.userId, userId);
}
return and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
)!;
}
/**
* The syncId of a referenced row.
*
* Generic over the registry rather than an if/else over three known entities.
* The old chain ended in an unguarded else that returned vaultProfiles, so a
* fourth reference target would have silently resolved against the wrong
* table; an unregistered entity is an error here instead.
*/
async function findReferenceSyncId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
id: number,
userId: string,
): Promise<string | null> {
const { table } = entityConfig(entityType as SyncEntityType);
const [row] = await context.drizzle
.select({ syncId: table.syncId })
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
async function findReferenceId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
syncId: string,
userId: string,
): Promise<number | null> {
const { table } = entityConfig(entityType as SyncEntityType);
const [row] = await context.drizzle
.select({ id: table.id })
.from(table)
.where(and(eq(table.syncId, syncId), eq(table.userId, userId)))
.limit(1);
return row?.id ?? null;
}
function requireUserDataKey(userId: string): Buffer {
return DataCrypto.validateUserAccess(userId);
}
function decryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return row;
return DataCrypto.decryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
function encryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = requireUserDataKey(userId);
return DataCrypto.encryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
export function stripWritePayload(
entityType: SyncEntityType,
payload: Record<string, unknown>,
): Record<string, unknown> {
const { readOnlyFields } = entityConfig(entityType);
const clean = { ...payload };
delete clean.id;
delete clean.userId;
delete clean.syncId;
for (const field of readOnlyFields) delete clean[field];
return clean;
}
/**
* @openapi
* /sync/entity-types:
* get:
* summary: List the entity types this server syncs, in dependency order
* description: The desktop app syncs the entities in the order returned here, because an entity referenced by another has to exist on the far side first. A server that predates this endpoint returns 404 and the client falls back to its built-in list.
* tags:
* - Sync
* responses:
* 200:
* description: The ordered entity types.
* 500:
* description: Failed to list entity types.
*/
router.get(
"/entity-types",
authenticateJWT,
async (_req: Request, res: Response) => {
try {
registerCoreSyncEntities();
return res.json({
entityTypes: listEntities().map((entity) => ({
type: entity.type,
order: entity.order,
singleton: entity.singleton === true,
})),
});
} catch (error) {
databaseLogger.error("Failed to list sync entity types", error, {
operation: "sync_entity_types",
});
return res.status(500).json({ error: "Failed to list entity types" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* get:
* summary: Pull synced rows for an entity type
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Rows updated since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch rows.
*/
router.get(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const { table, singleton, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
conditions.push(
timestampAtOrAfter((table as typeof hosts).updatedAt, since),
);
}
const rows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(and(...conditions));
const synced = (rows as Record<string, unknown>[]).filter(shouldSync);
const decrypted = await Promise.all(
synced.map(async (row) => {
const result = await serializeSyncReferences(
entityType,
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
);
if (entityType === "hosts") {
result.pluginSettings = await exportHostPluginSettings(
row.id as number,
);
}
return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
}),
);
res.json({ rows: orderSyncRows(entityType, decrypted) });
} catch (err) {
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
operation: "sync_pull",
entityType,
userId,
});
res.status(500).json({ error: "Failed to fetch rows" });
}
},
);
/**
* @openapi
* /sync/tombstones:
* post:
* summary: Report a deletion from the other side of a sync pair
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
* tags:
* - Sync
* responses:
* 200:
* description: Deletion applied (or row already absent).
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to apply deletion.
*/
router.post(
"/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.body?.entityType;
const syncId = req.body?.syncId;
if (
!isValidEntityType(entityType) ||
typeof syncId !== "string" ||
!syncId
) {
return res.status(400).json({ error: "Missing entityType or syncId" });
}
try {
const { table, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);
const [target] = (await context.drizzle
.select()
.from(table as typeof hosts)
.where(locateRow)
.limit(1)) as Record<string, unknown>[];
if (target && !shouldSync(target)) {
return res.status(400).json({ error: "This row is not synced" });
}
await context.drizzle.delete(table as typeof hosts).where(locateRow);
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to apply sync tombstone", err, {
operation: "sync_tombstone_apply",
entityType,
userId,
});
res.status(500).json({ error: "Failed to apply deletion" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* post:
* summary: Upsert a synced row by syncId
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Row upserted.
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to upsert row.
*/
router.post(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const payload = req.body?.row;
const syncId = payload?.syncId;
if (!payload || typeof syncId !== "string" || !syncId) {
return res.status(400).json({ error: "Missing row.syncId" });
}
try {
// singleton is still needed below: those tables have no sync_id column
// for the insert to populate.
const { table, singleton, shouldSync } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);
if (!shouldSync(payload)) {
return res.status(400).json({ error: "This row is not synced" });
}
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(locateRow)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
if (existing && !shouldSync(existing)) {
return res.status(400).json({ error: "This row is not synced" });
}
const resolvedPayload = await deserializeSyncReferences(
entityType,
payload,
(referenceType, referenceSyncId) =>
findReferenceId(context, referenceType, referenceSyncId, userId),
);
if (
entityType === "hosts" &&
typeof resolvedPayload.parentHostId === "number"
) {
const parentError = await validateParentHostId(
userId,
typeof existing?.id === "number" ? existing.id : null,
resolvedPayload.parentHostId,
);
if (parentError) return res.status(400).json({ error: parentError });
}
const carriedPluginSettings = resolvedPayload.pluginSettings;
delete resolvedPayload.pluginSettings;
const writePayload = stripWritePayload(entityType, resolvedPayload);
const encryptedPayload = encryptIfNeeded(
entityType,
writePayload,
userId,
);
// Through the returning helpers, because MySQL has no RETURNING and a
// bare .returning() throws there.
let resultRow: Record<string, unknown>;
if (existing) {
const updatedRows = await updateReturning(
context,
table as typeof hosts,
encryptedPayload,
locateRow,
);
resultRow = updatedRows[0] as Record<string, unknown>;
} else {
const insertedRows = await insertReturningWhere(
context,
table as typeof hosts,
(singleton
? { ...encryptedPayload, userId }
: {
...encryptedPayload,
userId,
syncId,
}) as typeof hosts.$inferInsert,
locateRow,
);
resultRow = insertedRows[0] as Record<string, unknown>;
}
if (entityType === "hosts" && typeof resultRow?.id === "number") {
await importHostPluginSettings(resultRow.id, carriedPluginSettings);
}
await DatabaseSaveTrigger.forceSave("sync_upsert");
res.json({
row: decryptIfNeeded(entityType, resultRow, userId),
created: !existing,
});
} catch (err) {
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
operation: "sync_upsert",
entityType,
userId,
});
res.status(500).json({ error: "Failed to upsert row" });
}
},
);
/**
* @openapi
* /sync/{entityType}/tombstones:
* get:
* summary: Pull deletion tombstones for an entity type
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Tombstones recorded since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch tombstones.
*/
router.get(
"/:entityType/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
userId,
entityType,
since,
);
res.json({ tombstones });
} catch (err) {
databaseLogger.error(
`Failed to fetch sync tombstones for ${entityType}`,
err,
{ operation: "sync_tombstones_pull", entityType, userId },
);
res.status(500).json({ error: "Failed to fetch tombstones" });
}
},
);
export default router;
+27 -1
View File
@@ -8,6 +8,7 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import {
allowsDesktopAutoSession,
isLoopbackRequest,
extractBearerOrCookieToken,
isNativeTokenExportRequest,
@@ -545,6 +546,30 @@ router.post("/logout", authenticateJWT, async (req, res) => {
}
});
/**
* On a desktop linked to a server, the account it is signed in to there.
* That account is who the user is; the local profile only mirrors it.
*/
async function describeDesktopLink(userId: string) {
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
try {
const { getLink } = await import("../../sync/client/link-store.js");
const link = await getLink();
if (!link || link.userId !== userId) return null;
return {
serverUrl: link.serverUrl,
serverName: link.serverName,
username: link.account?.username ?? link.remoteUsername,
isAdmin: !!link.account?.isAdmin,
roles: link.account?.roles ?? [],
permissions: link.account?.permissions ?? [],
status: link.status,
};
} catch {
return null;
}
}
/**
* @openapi
* /users/me:
@@ -595,6 +620,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
user.id,
),
show_donation_modal: showDonationModal,
linked: await describeDesktopLink(user.id),
});
} catch (err) {
authLogger.error("Failed to get username", err);
@@ -728,7 +754,7 @@ router.get("/setup-required", async (req, res) => {
*/
router.post("/internal/auto-session", async (req, res) => {
try {
if (!isLoopbackRequest(req)) {
if (!allowsDesktopAutoSession() || !isLoopbackRequest(req)) {
authLogger.warn(
"Rejected non-loopback attempt to access auto-session endpoint",
{ source: req.ip },
-35
View File
@@ -1,35 +0,0 @@
import { sql, type SQLWrapper } from "drizzle-orm";
/**
* Sync cursors and stored timestamps do not share a layout.
*
* `updated_at` and `deleted_at` are TEXT columns written both by
* `default(sql`CURRENT_TIMESTAMP`)` ("2026-07-29 10:11:21") and by
* `new Date().toISOString()` ("2026-07-29T10:11:21.123Z"), while the desktop
* sync engine always sends the ISO form as `since`. Comparing those as text is
* decided at position 10, where ' ' (0x20) sorts below 'T' (0x54), so the
* answer depends on which writer produced the row rather than on when it was
* written -- and `column > :isoCursor` is false for every row stored in the
* CURRENT_TIMESTAMP form, however new it is.
*
* Both layouts share a prefix once the separator is levelled, so comparing
* "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace` and
* `substr` are used rather than `datetime()` to keep the expression portable
* across engines.
*/
const CANONICAL_TIMESTAMP_LENGTH = 19;
export function normalizeSyncTimestamp(value: string): string {
return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH);
}
/**
* `>=` rather than `>`: normalising truncates sub-second precision, so a strict
* comparison would permanently skip rows written in the same second as the
* cursor. Re-sending that boundary second costs nothing -- the sync engine only
* pushes a row when one side is strictly newer, so rows equal on both sides are
* a no-op.
*/
export function timestampAtOrAfter(column: SQLWrapper, since: string) {
return sql`substr(replace(${column}, 'T', ' '), 1, ${CANONICAL_TIMESTAMP_LENGTH}) >= ${normalizeSyncTimestamp(since)}`;
}