/** * Core's own sync entities, registered the same way a plugin registers one. * * Order values leave room between them (10, 30, 50...) so a plugin can slot * an entity in without renumbering; anything referenced syncs first. */ import { and, eq, getTableColumns } from "drizzle-orm"; import type { SyncRow } from "@termix-ssh/plugin-sdk/backend"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; import { hosts, sshCredentials, sshFolders, userPreferences, } from "../database/db/schema.js"; import { CORE_OWNER, listEntities, registerEntity, } from "../plugins/sync-registry.js"; import { FieldCrypto } from "../utils/field-crypto.js"; import { syncLogger } from "../utils/logger.js"; import { getPluginRuntime } from "../plugins/index.js"; import { pluginEvents, TOPICS } from "../plugins/events.js"; import { singletonSyncId } from "./wire.js"; import { parseDefaultOverrides } from "../../types/host-defaults.js"; import { exportHostPluginSettings, importHostPluginSettings, } from "./host-plugin-settings.js"; import { exportProtocolLogins, exportSharedProtocolLogins, importProtocolLogins, } from "./host-protocol-auth.js"; import { coversHostDefault, eraseHostDefault, loadAdminDefaults, loadHostDefaults, writeAdminDefaults, writeHostDefault, } from "./host-defaults.js"; const CREDENTIAL_REFERENCE = { field: "credentialId", syncField: "credentialSyncId", entityType: "sshCredentials", } as const; const HOST_REFERENCES = [ CREDENTIAL_REFERENCE, { field: "parentHostId", syncField: "parentHostSyncId", entityType: "hosts", }, { field: "jumpHosts[].hostId", entityType: "hosts" }, ] as const; /** Columns that only mean something on the device that holds them. */ const HOST_LOCAL_FIELDS = [ "connectionOrigin", "localOnly", "sharedSource", "hostKeyLastVerified", ]; /** * Columns 26.10.0 dropped. A 2.9 desktop still sends them; they never reach * the table. */ const HOST_RETIRED_FIELDS = ["quickActions", "terminalConfig"]; let registered = false; /** Registers core's entities. Idempotent. */ export function registerCoreSyncEntities(): void { if ( registered && listEntities().some((entity) => entity.owner === CORE_OWNER) ) return; registered = true; registerEntity( CORE_OWNER, { type: "sshCredentials", table: sshCredentials, order: 10, encryptedFields: FieldCrypto.fieldsFor("ssh_credentials"), readOnlyFields: ["usageCount", "lastUsed", "sharedSource"], shouldSync: (row) => !row.sharedSource, permissions: { create: "credentials.create", update: "credentials.edit", delete: "credentials.delete", }, afterWrite: async ({ id, userId, created }) => { if (id === null || created) return; const { SharedHostSecretsManager } = await import("../utils/shared-host-secrets-manager.js"); await SharedHostSecretsManager.getInstance().resyncHostsForCredential( id, userId, ); const { SharedCredentialSecretsManager } = await import("../utils/shared-credential-secrets-manager.js"); await SharedCredentialSecretsManager.getInstance().resyncCredential( id, userId, ); }, remove: async (row, userId) => { const { deleteOwnedCredential } = await import("../hosts/delete-credential.js"); await deleteOwnedCredential(userId, row.id as number); }, }, { // A credential shared with the user can back their own host. canReference: async (userId, _ownerId, id) => { const { findUsableCredential } = await import("../hosts/usable-credential.js"); return (await findUsableCredential(id, userId)) !== null; }, }, ); // vaultProfiles (order 20) belongs to the vault plugin. registerEntity(CORE_OWNER, { type: "sshFolders", table: sshFolders, order: 30, references: [CREDENTIAL_REFERENCE], readOnlyFields: ["localOnly"], shouldSync: (row) => !row.localOnly, permissions: { create: "hosts.create", update: "hosts.edit", delete: "hosts.delete", }, }); registerEntity( CORE_OWNER, { type: "hosts", table: hosts, order: 50, encryptedFields: FieldCrypto.fieldsFor("ssh_data"), readOnlyFields: [...HOST_LOCAL_FIELDS, ...HOST_RETIRED_FIELDS], references: HOST_REFERENCES, shouldSync: (row) => !row.localOnly && !row.sharedSource, permissions: { create: "hosts.create", update: "hosts.edit", delete: "hosts.delete", }, serialize: async (row, resolveSyncId) => ({ ...row, pluginSettings: typeof row.id === "number" ? await exportHostPluginSettings(row.id) : {}, protocolAuth: typeof row.id === "number" ? await exportProtocolLogins( row.id, String(row.userId), resolveSyncId, ) : {}, }), afterWrite: async ({ id, userId, wire, created, resolveId }) => { if (id === null) return; // The server owns host defaults: it keeps what the host set itself // and resolves the rest. A desktop takes the server's values as sent. const { getLink } = await import("./client/link-store.js"); const onServer = !(await getLink().catch(() => null)); await importHostPluginSettings( id, wire.pluginSettings, onServer ? parseDefaultOverrides(wire.defaultOverrides) : null, ); await importProtocolLogins(id, userId, wire.protocolAuth, resolveId); await dropInvalidParent(userId, id); if (onServer) { const { applyDefaultsAfterHostWrite } = await import("../hosts/defaults/index.js"); await applyDefaultsAfterHostWrite(id); } const { SharedHostSecretsManager } = await import("../utils/shared-host-secrets-manager.js"); await SharedHostSecretsManager.getInstance().resyncHost(id); if (created) { pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId }); } }, remove: async (row, userId) => { const { deleteOwnedHost } = await import("../hosts/delete-host.js"); await deleteOwnedHost(userId, row.id as number); }, }, { canReference: async (userId, _ownerId, id) => { const { PermissionManager } = await import("../utils/permission-manager.js"); const access = await PermissionManager.getInstance().canAccessHost( userId, id, "view", ); return access.hasAccess; }, }, ); // After hosts: a folder's jump chain names hosts by syncId. registerEntity( CORE_OWNER, { type: "hostDefaults", table: null, order: 60 }, { load: loadHostDefaults, write: writeHostDefault, erase: eraseHostDefault, covers: (syncId) => coversHostDefault(syncId, activeManifest), }, ); registerEntity( CORE_OWNER, { type: "hostDefaultsAdmin", table: null, order: 7, singleton: true }, { readOnly: true, load: loadAdminDefaults, write: writeAdminDefaults, erase: async () => {}, }, ); // dashboardServiceLinks (70) and homepageItems (80) belong to homepage. registerEntity(CORE_OWNER, { type: "userPreferences", table: userPreferences, order: 90, singleton: true, readOnlyFields: ["storageMode"], // A keybinding parameter a plugin declares with a syncEntity holds a // local row id of that entity; the wire carries its syncId. serialize: (row, resolveSyncId) => mapKeybindingReferences(row, async (entityType, value) => { const id = Number(value); return Number.isInteger(id) ? resolveSyncId(entityType, id) : null; }), deserialize: (row, resolveId) => mapKeybindingReferences(row, async (entityType, value) => { const id = await resolveId(entityType, value); return id === null ? null : String(id); }), }); registerEntity( CORE_OWNER, { type: "pluginUserSettings", table: null, order: 95 }, { load: loadPluginUserSettings, write: writePluginUserSetting, erase: erasePluginUserSetting, covers: (syncId) => !!activeManifest(syncId.split(":")[0]), }, ); registerEntity( CORE_OWNER, { type: "accountProfile", table: null, order: 5, singleton: true }, { readOnly: true, load: loadAccountProfile, write: writeAccountProfile, erase: async () => {}, }, ); registerEntity( CORE_OWNER, { type: "sharedCredentials", table: null, order: 15 }, { readOnly: true, load: loadSharedCredentials, write: (userId, wire) => writeSharedCopy("sshCredentials", userId, wire), erase: (userId, syncId) => eraseSharedCopy("sshCredentials", userId, syncId), }, ); registerEntity( CORE_OWNER, { type: "sharedHosts", table: null, order: 55 }, { readOnly: true, load: loadSharedHosts, write: (userId, wire) => writeSharedCopy("hosts", userId, wire), erase: (userId, syncId) => eraseSharedCopy("hosts", userId, syncId), }, ); } /** * Rewrites every keybinding parameter whose declaration names a sync entity, * keeping the column's storage form. A value that does not map becomes null. */ export async function mapKeybindingReferences( row: SyncRow, map: (entityType: string, value: string) => Promise, ): Promise { const stored = row.customKeybindings; if (typeof stored !== "string" || !stored) return row; let bindings: unknown; try { bindings = JSON.parse(stored); } catch { return row; } if (!Array.isArray(bindings)) return row; const { findKeybindingAction } = await import("../database/routes/keybinding-validation.js"); let changed = false; for (const binding of bindings) { const action = (binding as { action?: Record } | null) ?.action; if (!action || typeof action.type !== "string") continue; const params = findKeybindingAction(action.type)?.params ?? {}; for (const [name, param] of Object.entries(params)) { const value = action[name]; if (!param.syncEntity || typeof value !== "string" || !value) continue; action[name] = await map(param.syncEntity, value); changed = true; } } return changed ? { ...row, customKeybindings: JSON.stringify(bindings) } : row; } /** A synced parent link that would make a cycle here is dropped. */ async function dropInvalidParent(userId: string, hostId: number) { const { createCurrentRepositoryContext } = await import("../database/repositories/factory.js"); const db = createCurrentRepositoryContext().drizzle; const [row] = await db .select({ parentHostId: hosts.parentHostId }) .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) .limit(1); if (typeof row?.parentHostId !== "number") return; const { validateParentHostId } = await import("../database/routes/host-parent-validation.js"); if (await validateParentHostId(userId, hostId, row.parentHostId)) { await db .update(hosts) .set({ parentHostId: null }) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))); } } function activeManifests(): PluginManifest[] { try { return getPluginRuntime() .loader.list() .filter((plugin) => plugin.state === "active" && plugin.manifest) .map((plugin) => plugin.manifest as PluginManifest); } catch { return []; } } function activeManifest(pluginId: string): PluginManifest | undefined { return activeManifests().find((manifest) => manifest.id === pluginId); } async function loadPluginUserSettings(userId: string): Promise { const { declaredFields, getSetting } = await import("../plugins/settings.js"); const { createCurrentPluginSettingsRepository } = await import("../database/repositories/factory.js"); const repository = createCurrentPluginSettingsRepository(); const rows: SyncRow[] = []; for (const manifest of activeManifests()) { const fields = declaredFields(manifest, "user"); if (fields.length === 0) continue; const stored = new Set( (await repository.getAll(manifest.id, "user", userId)) .filter((row) => row.value !== null) .map((row) => row.key), ); for (const field of fields) { if (!stored.has(field.key)) continue; rows.push({ syncId: `${manifest.id}:${field.key}`, pluginId: manifest.id, key: field.key, value: await getSetting(manifest, "user", userId, field.key), }); } } return rows; } async function writePluginUserSetting(userId: string, wire: SyncRow) { const manifest = activeManifest(String(wire.pluginId)); if (!manifest) return; const { setSetting } = await import("../plugins/settings.js"); const error = await setSetting( manifest, "user", userId, String(wire.key), wire.value, ); if (error) throw new Error(error); } async function erasePluginUserSetting(userId: string, syncId: string) { const [pluginId, ...rest] = syncId.split(":"); const { createCurrentPluginSettingsRepository } = await import("../database/repositories/factory.js"); await createCurrentPluginSettingsRepository().delete( pluginId, "user", userId, rest.join(":"), ); } async function loadAccountProfile(userId: string): Promise { const { createCurrentUserRepository, createCurrentRoleRepository } = await import("../database/repositories/factory.js"); const user = await createCurrentUserRepository().findById(userId); if (!user) return []; const { PermissionManager } = await import("../utils/permission-manager.js"); const roles = await createCurrentRoleRepository().listUserRoles(userId); return [ { syncId: singletonSyncId("accountProfile"), remoteUserId: userId, username: user.username, isAdmin: !!user.isAdmin, roles: roles.map((role) => role.roleName).sort(), permissions: ( await PermissionManager.getInstance().getUserPermissions(userId) ).sort(), }, ]; } async function writeAccountProfile(userId: string, wire: SyncRow) { const { updateLink } = await import("./client/link-store.js"); await updateLink({ remoteUserId: (wire.remoteUserId as string) ?? null, remoteUsername: (wire.username as string) ?? null, account: { remoteUserId: wire.remoteUserId as string, username: wire.username as string, isAdmin: !!wire.isAdmin, roles: (wire.roles as string[]) ?? [], permissions: (wire.permissions as string[]) ?? [], }, }); if (typeof wire.username === "string" && wire.username) { const { createCurrentUserRepository } = await import("../database/repositories/factory.js"); const users = createCurrentUserRepository(); const current = await users.findById(userId); if (current && current.username !== wire.username) { await users.update(userId, { username: wire.username }); } } } /** Host columns a shared copy never carries as the owner has them. */ const SHARED_HOST_DROP = [ "id", "userId", "credentialId", "parentHostId", "pluginSettings", "protocolAuth", "quickActions", "createdAt", "updatedAt", "isShared", "permissionLevel", ...HOST_LOCAL_FIELDS, ]; const HOST_TEXT_COLUMNS = new Set( Object.entries(getTableColumns(hosts)) .filter(([, column]) => column.dataType === "string") .map(([field]) => field), ); /** * A resolved host field back in the form its column stores, as an owned host * sends it: the resolver parses JSON columns and turns the "true"/"false" of * forceKeyboardInteractive into a boolean, which a text column cannot bind. */ function storedForm(field: string, value: unknown): unknown { if (value !== null && typeof value === "object") return JSON.stringify(value); if (typeof value === "boolean" && HOST_TEXT_COLUMNS.has(field)) { return String(value); } return value; } async function loadSharedHosts(userId: string): Promise { const { createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentHostResolutionRepository, createCurrentUserRepository, } = await import("../database/repositories/factory.js"); const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); const entries = await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( userId, roleIds, ); const hostRepository = createCurrentHostResolutionRepository(); const owned = await hostRepository.listOwnedHostIds(userId); const levels = new Map(); for (const entry of entries) { if (owned.has(entry.hostId)) continue; if (!levels.has(entry.hostId)) { levels.set(entry.hostId, entry.permissionLevel); } } if (levels.size === 0) return []; const { resolveHostById } = await import("../hosts/host-resolver.js"); const users = createCurrentUserRepository(); const ownerNames = new Map(); const rows: SyncRow[] = []; for (const [hostId, permissionLevel] of levels) { try { const host = (await resolveHostById( hostId, userId, )) as unknown as SyncRow | null; if (!host || typeof host.syncId !== "string") continue; const ownerId = String(host.userId); if (!ownerNames.has(ownerId)) { ownerNames.set( ownerId, (await users.findById(ownerId))?.username ?? "", ); } const wire: SyncRow = {}; for (const [field, value] of Object.entries(host)) { if (SHARED_HOST_DROP.includes(field)) continue; wire[field] = storedForm(field, value); } wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts); wire.pluginSettings = await exportHostPluginSettings(hostId, { userId, permissionLevel, }); wire.protocolAuth = await exportSharedProtocolLogins(host, userId); wire.syncId = host.syncId; wire.shared = { hostId, owner: ownerNames.get(ownerId) ?? "", permissionLevel, }; rows.push(wire); } catch (error) { syncLogger.warn("Could not include a shared host in sync", { operation: "sync_shared_host", hostId, error: error instanceof Error ? error.message : String(error), }); } } return rows; } async function jumpHostsToSyncIds(value: unknown): Promise { const list = Array.isArray(value) ? value : []; if (list.length === 0) return null; const { createCurrentHostRepository } = await import("../database/repositories/factory.js"); const repository = createCurrentHostRepository(); const mapped = []; for (const item of list) { const hostId = (item as { hostId?: unknown })?.hostId; if (typeof hostId !== "number") continue; const target = await repository.findById(hostId); if (target?.syncId) mapped.push({ ...item, hostId: target.syncId }); } return JSON.stringify(mapped); } async function loadSharedCredentials(userId: string): Promise { const { createCurrentCredentialAccessRepository, createCurrentCredentialRepository, createCurrentRoleRepository, createCurrentUserRepository, } = await import("../database/repositories/factory.js"); const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); const grants = await createCurrentCredentialAccessRepository().listSharedWithUser( userId, roleIds, ); if (grants.length === 0) return []; const { findUsableCredential } = await import("../hosts/usable-credential.js"); const credentials = createCurrentCredentialRepository(); const users = createCurrentUserRepository(); const rows: SyncRow[] = []; for (const grant of grants) { try { const row = (await credentials.findById( grant.credentialId, )) as SyncRow | null; const usable = (await findUsableCredential( grant.credentialId, userId, )) as unknown as SyncRow | null; if (!row || !usable || typeof row.syncId !== "string") continue; rows.push({ syncId: row.syncId, name: row.name, description: row.description ?? null, tags: row.tags ?? null, authType: usable.authType, username: usable.username ?? null, password: usable.password ?? null, privateKey: usable.privateKey ?? null, keyPassword: usable.keyPassword ?? null, keyType: usable.keyType ?? null, publicKey: usable.publicKey ?? null, certPublicKey: usable.certPublicKey ?? null, shared: { credentialId: grant.credentialId, owner: (await users.findById(grant.ownerId))?.username ?? "", permissionLevel: grant.permissionLevel, }, }); } catch (error) { syncLogger.warn("Could not include a shared credential in sync", { operation: "sync_shared_credential", credentialId: grant.credentialId, error: error instanceof Error ? error.message : String(error), }); } } return rows; } /** Stores a read-only copy of something shared with the linked account. */ async function writeSharedCopy( entityType: "hosts" | "sshCredentials", userId: string, wire: SyncRow, ): Promise { const { getEntity } = await import("../plugins/sync-registry.js"); const { writeWireRow, createResolvers } = await import("./store.js"); const entity = getEntity(entityType); if (!entity) return; const { shared, ...rest } = wire; await writeWireRow(entity, userId, rest, createResolvers(userId), { sharedSource: JSON.stringify(shared ?? {}), }); } async function eraseSharedCopy( entityType: "hosts" | "sshCredentials", userId: string, syncId: string, ): Promise { const { getEntity } = await import("../plugins/sync-registry.js"); const { deleteStoredRow, findStoredRow } = await import("./store.js"); const entity = getEntity(entityType); if (!entity) return; const row = await findStoredRow(entity, userId, syncId); if (!row?.sharedSource) return; await deleteStoredRow(entity, userId, syncId); }