/** * Comparable form of a host address: bracketed IPv6 literals and hostname * casing are presentation, not identity. */ export function normalizeHostAddress(value: unknown): string { if (typeof value !== "string") return ""; return value .replace(/^\[|\]$/g, "") .trim() .toLowerCase(); } /** * Whether a host id resolved to a different machine than the client meant. * * A client addresses a host by the numeric row id of the database it is * displaying. When the desktop app delegates a connection to a remote sync * server, the id is resolved against *that* server's table instead, and * autoincrement ids need not line up between the two — they diverge as soon as * the sides accumulate inserts and deletes in a different order. * * The resolved row then supplies the address, the credentials, the jump hosts * and the stored host key, so a mismatch opens an interactive shell on a * machine the user did not pick. * * Returns false when the server has no address to compare: the caller falls * back to what the client supplied, which is the behaviour of every setup that * never stored the host server-side. */ export function hostAddressMismatch( clientAddress: unknown, resolvedAddress: unknown, ): boolean { const resolved = normalizeHostAddress(resolvedAddress); if (!resolved) return false; return resolved !== normalizeHostAddress(clientAddress); } /** * Shown to the user on every path that refuses a mismatch, so the wording of * the one thing they can act on does not depend on which feature they used. */ export const HOST_ADDRESS_MISMATCH_MESSAGE = "Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " + "The host ids on this device and on the sync server have drifted apart. " + 'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.'; /** * Shown when the client named a host by sync identity that this server does * not have. Distinct from a mismatch: nothing was resolved at all, so the * remedy is to sync the host across rather than to pick a different origin. */ export const HOST_NOT_ON_THIS_SERVER_MESSAGE = "This host does not exist on the sync server, so the connection was refused. " + 'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.'; export function resolveServerJumpHosts( clientJumpHosts: Array<{ hostId: number }> | undefined, serverJumpHosts: Array<{ hostId: number }> | undefined, hostSyncId?: string | null, ): Array<{ hostId: number }> | undefined { if (hostSyncId) return serverJumpHosts ?? []; return clientJumpHosts?.length ? clientJumpHosts : serverJumpHosts; } export function resolveServerHostId( clientHostId: number | null, resolvedHost: { id?: unknown } | null, ): number | null { return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId; } /** * Thrown where a mismatch is reported by rejecting rather than by messaging * the socket. Callers whose host-resolution is wrapped in a "failed to resolve * credentials, carry on" catch must let this one through: continuing is the * behaviour being prevented. */ export class HostAddressMismatchError extends Error { constructor() { super(HOST_ADDRESS_MISMATCH_MESSAGE); this.name = "HostAddressMismatchError"; } } /** Counterpart of {@link HostAddressMismatchError} for an unknown sync id. */ export class HostNotOnThisServerError extends Error { constructor() { super(HOST_NOT_ON_THIS_SERVER_MESSAGE); this.name = "HostNotOnThisServerError"; } }