From 113eb5619c3f69f1967d246fd70244f136e70778 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 28 Jul 2026 01:47:25 +0800 Subject: [PATCH] fix: preserve remote sync references (#1092) --- electron/remote-sync.cjs | 8 +- .../database/routes/sync-references.ts | 95 +++++++++++++++++++ src/backend/database/routes/sync.ts | 78 ++++++++++++++- .../tests/database/sync-references.test.ts | 70 ++++++++++++++ 4 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 src/backend/database/routes/sync-references.ts create mode 100644 src/backend/tests/database/sync-references.test.ts diff --git a/electron/remote-sync.cjs b/electron/remote-sync.cjs index b34dd037..b059731d 100644 --- a/electron/remote-sync.cjs +++ b/electron/remote-sync.cjs @@ -16,12 +16,12 @@ const fs = require("fs"); const path = require("path"); const SYNCED_ENTITY_TYPES = [ - "hosts", "sshCredentials", - "sshFolders", - "snippets", - "snippetFolders", "vaultProfiles", + "sshFolders", + "snippetFolders", + "hosts", + "snippets", "dashboardServiceLinks", "homepageItems", ]; diff --git a/src/backend/database/routes/sync-references.ts b/src/backend/database/routes/sync-references.ts new file mode 100644 index 00000000..1ed573ca --- /dev/null +++ b/src/backend/database/routes/sync-references.ts @@ -0,0 +1,95 @@ +import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js"; + +export type SyncReferenceEntity = "sshCredentials" | "vaultProfiles"; + +interface SyncReference { + field: string; + syncField: string; + entityType: SyncReferenceEntity; +} + +const HOST_REFERENCES: SyncReference[] = [ + { + field: "credentialId", + syncField: "credentialSyncId", + entityType: "sshCredentials", + }, + { + field: "rdpCredentialId", + syncField: "rdpCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "vncCredentialId", + syncField: "vncCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "telnetCredentialId", + syncField: "telnetCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "vaultProfileId", + syncField: "vaultProfileSyncId", + entityType: "vaultProfiles", + }, +]; + +const REFERENCES: Partial> = { + hosts: HOST_REFERENCES, + sshFolders: [HOST_REFERENCES[0]], +}; + +export async function serializeSyncReferences( + entityType: SyncEntityType, + row: Record, + resolveSyncId: ( + entityType: SyncReferenceEntity, + id: number, + ) => Promise, +): Promise> { + const serialized = { ...row }; + for (const reference of REFERENCES[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, + resolveId: ( + entityType: SyncReferenceEntity, + syncId: string, + ) => Promise, +): Promise> { + const deserialized = { ...row }; + for (const reference of REFERENCES[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; +} diff --git a/src/backend/database/routes/sync.ts b/src/backend/database/routes/sync.ts index 3f1198c4..cc9030a0 100644 --- a/src/backend/database/routes/sync.ts +++ b/src/backend/database/routes/sync.ts @@ -21,6 +21,11 @@ import { createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js"; +import { + deserializeSyncReferences, + serializeSyncReferences, + type SyncReferenceEntity, +} from "./sync-references.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); @@ -65,11 +70,65 @@ const ENTITY_CONFIG: Record = { }; const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG)); +type RepositoryContext = ReturnType; export function isValidEntityType(value: unknown): value is SyncEntityType { return typeof value === "string" && VALID_ENTITY_TYPES.has(value); } +async function findReferenceSyncId( + context: RepositoryContext, + entityType: SyncReferenceEntity, + id: number, + userId: string, +): Promise { + if (entityType === "sshCredentials") { + const [row] = await context.drizzle + .select({ syncId: sshCredentials.syncId }) + .from(sshCredentials) + .where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId))) + .limit(1); + return row?.syncId ?? null; + } + + const [row] = await context.drizzle + .select({ syncId: vaultProfiles.syncId }) + .from(vaultProfiles) + .where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId))) + .limit(1); + return row?.syncId ?? null; +} + +async function findReferenceId( + context: RepositoryContext, + entityType: SyncReferenceEntity, + syncId: string, + userId: string, +): Promise { + if (entityType === "sshCredentials") { + const [row] = await context.drizzle + .select({ id: sshCredentials.id }) + .from(sshCredentials) + .where( + and( + eq(sshCredentials.syncId, syncId), + eq(sshCredentials.userId, userId), + ), + ) + .limit(1); + return row?.id ?? null; + } + + const [row] = await context.drizzle + .select({ id: vaultProfiles.id }) + .from(vaultProfiles) + .where( + and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)), + ) + .limit(1); + return row?.id ?? null; +} + function requireUserDataKey(userId: string): Buffer { return DataCrypto.validateUserAccess(userId); } @@ -173,8 +232,15 @@ router.get( .from(table as typeof hosts) .where(and(...conditions)); - const decrypted = rows.map((row) => - decryptIfNeeded(entityType, row as Record, userId), + const decrypted = await Promise.all( + rows.map((row) => + serializeSyncReferences( + entityType, + decryptIfNeeded(entityType, row as Record, userId), + (referenceType, id) => + findReferenceSyncId(context, referenceType, id, userId), + ), + ), ); res.json({ rows: decrypted }); @@ -242,7 +308,13 @@ router.post( .limit(1); const existing = existingRows[0] as Record | undefined; - const writePayload = stripWritePayload(entityType, payload); + const resolvedPayload = await deserializeSyncReferences( + entityType, + payload, + (referenceType, referenceSyncId) => + findReferenceId(context, referenceType, referenceSyncId, userId), + ); + const writePayload = stripWritePayload(entityType, resolvedPayload); const encryptedPayload = encryptIfNeeded( entityType, writePayload, diff --git a/src/backend/tests/database/sync-references.test.ts b/src/backend/tests/database/sync-references.test.ts new file mode 100644 index 00000000..ac25805e --- /dev/null +++ b/src/backend/tests/database/sync-references.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + deserializeSyncReferences, + serializeSyncReferences, +} from "../../database/routes/sync-references.js"; + +describe("sync references", () => { + 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, + vaultProfileId: 4, + }, + async (entityType, id) => `${entityType}-${id}`, + ); + + expect(row).toMatchObject({ + credentialSyncId: "sshCredentials-12", + rdpCredentialSyncId: "sshCredentials-13", + vncCredentialSyncId: null, + telnetCredentialSyncId: null, + vaultProfileSyncId: "vaultProfiles-4", + }); + expect(row).not.toHaveProperty("credentialId"); + expect(row).not.toHaveProperty("vaultProfileId"); + }); + + it("resolves stable sync IDs to IDs from the receiving database", async () => { + const ids = new Map([ + ["sshCredentials:credential-sync", 91], + ["vaultProfiles:vault-sync", 37], + ]); + const row = await deserializeSyncReferences( + "hosts", + { + credentialId: 12, + credentialSyncId: "credential-sync", + rdpCredentialSyncId: null, + vncCredentialSyncId: null, + telnetCredentialSyncId: null, + vaultProfileSyncId: "vault-sync", + }, + async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null, + ); + + expect(row).toMatchObject({ + credentialId: 91, + rdpCredentialId: null, + vncCredentialId: null, + telnetCredentialId: null, + vaultProfileId: 37, + }); + expect(row).not.toHaveProperty("credentialSyncId"); + }); + + it("rejects a row whose referenced dependency has not synced", async () => { + await expect( + deserializeSyncReferences( + "sshFolders", + { credentialSyncId: "missing" }, + async () => null, + ), + ).rejects.toThrow("Missing sshCredentials dependency"); + }); +});