import type { SyncMessage } from "./sync-types"; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder("utf-8", { fatal: true }); export function encodeSyncMessage(message: SyncMessage): string { return base64urlEncode(textEncoder.encode(JSON.stringify(message))); } export function decodeSyncMessage(data: string): SyncMessage { try { const raw = textDecoder.decode(base64urlDecode(data)); const parsed = JSON.parse(raw) as unknown; if (isSyncMessage(parsed)) { return parsed; } } catch { // Normalize malformed transport payloads so callers do not branch on parser details. } throw new Error("invalid sync message"); } export function isSyncMessage(value: unknown): value is SyncMessage { if (!isRecord(value) || typeof value.type !== "string") return false; switch (value.type) { case "SYNC_HELLO": return ( isString(value.deviceId) && isFiniteNumber(value.clockMs) && isFiniteNumber(value.vaultVersion) ); case "SYNC_MANIFEST": return Array.isArray(value.entries) && value.entries.every(isSyncManifestEntry); case "SYNC_REQUEST": return isStringArray(value.ids); case "SYNC_ENTRIES": return Array.isArray(value.entries) && value.entries.every(isEncryptedSyncEntry); case "SYNC_DONE": return true; case "SYNC_ERROR": return isString(value.code) && isString(value.message); case "PAIR_REQUEST": return isString(value.edPublicKey) && isString(value.name) && isString(value.code); case "PAIR_ACCEPT": return isString(value.edPublicKey) && isString(value.name); case "PAIR_REJECT": return isString(value.reason); default: return false; } } function isSyncManifestEntry(value: unknown): boolean { return ( isRecord(value) && isString(value.id) && isFiniteNumber(value.modifiedAt) && isOptionalNumber(value.deletedAt) && isString(value.hash) ); } function isEncryptedSyncEntry(value: unknown): boolean { return ( isRecord(value) && isString(value.id) && isFiniteNumber(value.modifiedAt) && isOptionalNumber(value.deletedAt) && isString(value.ciphertext) && isString(value.nonce) && isString(value.tag) ); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function isString(value: unknown): value is string { return typeof value === "string"; } function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every(isString); } function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } function isOptionalNumber(value: unknown): boolean { return value === undefined || isFiniteNumber(value); } function base64urlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function base64urlDecode(value: string): Uint8Array { if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) { throw new Error("invalid sync message"); } const padded = value .replace(/-/g, "+") .replace(/_/g, "/") .padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(padded); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); return out; }