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

@@ -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,101 +0,0 @@
/**
* Host plugin settings over remote sync: non-secret values travel with the
* host, and a plugin can translate a local row id to its syncId and back.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
const h = vi.hoisted(() => ({
stored: new Map<string, unknown>(),
writes: [] as Array<[string, number, string, unknown]>,
hooks: new Map<string, unknown>(),
manifests: [] as PluginManifest[],
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => h.manifests,
}));
vi.mock("../../../plugins/registry.js", () => ({
consume: (key: string) => h.hooks.get(key),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../plugins/settings.js", () => ({
declaredFields: (manifest: PluginManifest) =>
manifest.contributes!.settings!.host!.fields,
getAllSettings: async (
manifest: PluginManifest,
_scope: string,
id: number,
) =>
Object.fromEntries(
manifest.contributes!.settings!.host!.fields.map((field) => [
field.key,
h.stored.get(`${manifest.id}:${id}:${field.key}`),
]),
),
setSetting: async (
manifest: PluginManifest,
_scope: string,
id: number,
key: string,
value: unknown,
) => {
h.writes.push([manifest.id, id, key, value]);
return null;
},
}));
const { exportHostPluginSettings, importHostPluginSettings } =
await import("../../../database/routes/sync-host-plugin-settings.js");
const VAULT = {
id: "vault",
contributes: {
settings: {
host: {
fields: [
{ key: "profileId", type: "number", labelKey: "k" },
{ key: "token", type: "secret", labelKey: "k" },
],
},
},
},
} as unknown as PluginManifest;
beforeEach(() => {
h.stored.clear();
h.writes.length = 0;
h.hooks.clear();
h.manifests = [VAULT];
h.hooks.set("vault.hostSettingsSync", {
exportValue: (key: string, value: unknown) =>
key === "profileId" && value === 4 ? "profile-sync-4" : value,
importValue: (key: string, value: unknown) =>
key === "profileId" && value === "profile-sync-4" ? 11 : value,
});
});
describe("host plugin settings over sync", () => {
it("exports non-secret values through the plugin's translation", async () => {
h.stored.set("vault:1:profileId", 4);
h.stored.set("vault:1:token", "secret");
expect(await exportHostPluginSettings(1)).toEqual({
vault: { profileId: "profile-sync-4" },
});
});
it("imports them back to local ids", async () => {
await importHostPluginSettings(9, {
vault: { profileId: "profile-sync-4", token: "ignored" },
});
expect(h.writes).toEqual([["vault", 9, "profileId", 11]]);
});
it("ignores a payload without plugin settings", async () => {
await importHostPluginSettings(9, undefined);
expect(h.writes).toEqual([]);
});
});
@@ -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",