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}26272829303132export function createPairingInvitation(33 relayUrl: string,34 token = generatePairingRendezvousToken(),35 vaultStatus: VaultStatus = "populated",36): string {37 const normalized = normalizeRelayUrl(relayUrl);38 assertToken(token);39 const json = JSON.stringify({ relay: normalized, token, vault: vaultStatus });40 return `${PAIRING_INVITATION_PREFIX}${base64urlEncode(new TextEncoder().encode(json))}`;41}4243export function parsePairingInvitation(input: string): PairingInvitation | null {44 const value = input.trim();45 if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null;46 try {47 const json = new TextDecoder("utf-8", { fatal: true }).decode(48 base64urlDecode(value.slice(PAIRING_INVITATION_PREFIX.length)),49 );50 const parsed = JSON.parse(json) as {51 relay?: unknown;52 token?: unknown;53 vault?: unknown;54 };55 if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null;56 assertToken(parsed.token);57 return {58 relayUrl: normalizeRelayUrl(parsed.relay),59 token: parsed.token,60 vaultStatus: parseVaultStatus(parsed.vault),61 };62 } catch {63 return null;64 }65}6667export function pairingInvitationUrl(invitation: PairingInvitation): string {68 return buildRendezvousUrl(invitation.relayUrl, invitation.token);69}7071function parseVaultStatus(value: unknown): VaultStatus {72 return value === "empty" ? "empty" : "populated";73}7475function assertToken(token: string): void {76 77 if (!/^[A-Za-z0-9_-]{22}$/.test(token)) {78 throw new Error("pairing invitation token must be 128-bit base64url");79 }80}8182function base64urlEncode(bytes: Uint8Array): string {83 let binary = "";84 for (const byte of bytes) binary += String.fromCharCode(byte);85 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");86}8788function base64urlDecode(value: string): Uint8Array {89 if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");90 const padded = value.replace(/-/g, "+").replace(/_/g, "/")91 .padEnd(Math.ceil(value.length / 4) * 4, "=");92 const binary = atob(padded);93 return Uint8Array.from(binary, (char) => char.charCodeAt(0));94}