1import {2 buildRendezvousUrl,3 generatePairingRendezvousToken,4 normalizeRelayUrl,5} from "./rendezvous";67export const PAIRING_INVITATION_PREFIX = "peach-pair-v1:";89export type VaultStatus = "empty" | "populated";1011export interface PairingInvitation {12 relayUrl: string;13 token: string;14 15161718192021222324 vaultStatus: VaultStatus;25 noiseStaticPublicKeyHex?: string;26}27282930313233export function createPairingInvitation(34 relayUrl: string,35 token = generatePairingRendezvousToken(),36 vaultStatus: VaultStatus = "populated",37 noiseStaticPublicKeyHex?: string,38): string {39 const normalized = normalizeRelayUrl(relayUrl);40 assertToken(token);41 if (noiseStaticPublicKeyHex) assertStaticKey(noiseStaticPublicKeyHex);42 const json = JSON.stringify({43 relay: normalized,44 token,45 vault: vaultStatus,46 ...(noiseStaticPublicKeyHex ? { static: noiseStaticPublicKeyHex } : {}),47 });48 return `${PAIRING_INVITATION_PREFIX}${base64urlEncode(new TextEncoder().encode(json))}`;49}5051export function parsePairingInvitation(input: string): PairingInvitation | null {52 const value = input.trim();53 if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null;54 try {55 const json = new TextDecoder("utf-8", { fatal: true }).decode(56 base64urlDecode(value.slice(PAIRING_INVITATION_PREFIX.length)),57 );58 const parsed = JSON.parse(json) as {59 relay?: unknown;60 token?: unknown;61 vault?: unknown;62 static?: unknown;63 };64 if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null;65 assertToken(parsed.token);66 if (parsed.static !== undefined) {67 if (typeof parsed.static !== "string") return null;68 assertStaticKey(parsed.static);69 }70 return {71 relayUrl: normalizeRelayUrl(parsed.relay),72 token: parsed.token,73 vaultStatus: parseVaultStatus(parsed.vault),74 noiseStaticPublicKeyHex: parsed.static,75 };76 } catch {77 return null;78 }79}8081export function pairingInvitationUrl(invitation: PairingInvitation): string {82 return buildRendezvousUrl(invitation.relayUrl, invitation.token);83}8485function parseVaultStatus(value: unknown): VaultStatus {86 return value === "empty" ? "empty" : "populated";87}8889function assertToken(token: string): void {90 91 if (!/^[A-Za-z0-9_-]{22}$/.test(token)) {92 throw new Error("pairing invitation token must be 128-bit base64url");93 }94}9596function assertStaticKey(key: string): void {97 if (!/^[0-9a-f]{64}$/.test(key)) throw new Error("invalid Noise static public key");98}99100function base64urlEncode(bytes: Uint8Array): string {101 let binary = "";102 for (const byte of bytes) binary += String.fromCharCode(byte);103 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");104}105106function base64urlDecode(value: string): Uint8Array {107 if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");108 const padded = value.replace(/-/g, "+").replace(/_/g, "/")109 .padEnd(Math.ceil(value.length / 4) * 4, "=");110 const binary = atob(padded);111 return Uint8Array.from(binary, (char) => char.charCodeAt(0));112}