1import type { SyncMessage } from "./sync-types";23const textEncoder = new TextEncoder();4const textDecoder = new TextDecoder("utf-8", { fatal: true });56export function encodeSyncMessage(message: SyncMessage): string {7 return base64urlEncode(textEncoder.encode(JSON.stringify(message)));8}910export function decodeSyncMessage(data: string): SyncMessage {11 try {12 const raw = textDecoder.decode(base64urlDecode(data));13 const parsed = JSON.parse(raw) as unknown;14 if (isSyncMessage(parsed)) {15 return parsed;16 }17 } catch {18 19 }20 throw new Error("invalid sync message");21}2223export function isSyncMessage(value: unknown): value is SyncMessage {24 if (!isRecord(value) || typeof value.type !== "string") return false;2526 switch (value.type) {27 case "SYNC_HELLO":28 return (29 isString(value.deviceId) &&30 isFiniteNumber(value.clockMs) &&31 isFiniteNumber(value.vaultVersion)32 );33 case "SYNC_MANIFEST":34 return Array.isArray(value.entries) && value.entries.every(isSyncManifestEntry);35 case "SYNC_REQUEST":36 return isStringArray(value.ids);37 case "SYNC_ENTRIES":38 return Array.isArray(value.entries) && value.entries.every(isEncryptedSyncEntry);39 case "SYNC_DONE":40 return true;41 case "SYNC_ERROR":42 return isString(value.code) && isString(value.message);43 case "PAIR_REQUEST":44 return isString(value.edPublicKey) && isString(value.name) && isString(value.code);45 case "PAIR_ACCEPT":46 return isString(value.edPublicKey) && isString(value.name);47 case "PAIR_REJECT":48 return isString(value.reason);49 default:50 return false;51 }52}5354function isSyncManifestEntry(value: unknown): boolean {55 return (56 isRecord(value) &&57 isString(value.id) &&58 isFiniteNumber(value.modifiedAt) &&59 isOptionalNumber(value.deletedAt) &&60 isString(value.hash)61 );62}6364function isEncryptedSyncEntry(value: unknown): boolean {65 return (66 isRecord(value) &&67 isString(value.id) &&68 isFiniteNumber(value.modifiedAt) &&69 isOptionalNumber(value.deletedAt) &&70 isString(value.ciphertext) &&71 isString(value.nonce) &&72 isString(value.tag)73 );74}7576function isRecord(value: unknown): value is Record<string, unknown> {77 return Boolean(value) && typeof value === "object" && !Array.isArray(value);78}7980function isString(value: unknown): value is string {81 return typeof value === "string";82}8384function isStringArray(value: unknown): value is string[] {85 return Array.isArray(value) && value.every(isString);86}8788function isFiniteNumber(value: unknown): value is number {89 return typeof value === "number" && Number.isFinite(value);90}9192function isOptionalNumber(value: unknown): boolean {93 return value === undefined || isFiniteNumber(value);94}9596function base64urlEncode(bytes: Uint8Array): string {97 let binary = "";98 for (const byte of bytes) binary += String.fromCharCode(byte);99 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");100}101102function base64urlDecode(value: string): Uint8Array {103 if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {104 throw new Error("invalid sync message");105 }106 const padded = value107 .replace(/-/g, "+")108 .replace(/_/g, "/")109 .padEnd(Math.ceil(value.length / 4) * 4, "=");110 const binary = atob(padded);111 const out = new Uint8Array(binary.length);112 for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);113 return out;114}