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