import { z } from 'zod' export const PAIRING_OFFER_VERSION = 2 export const PairingOfferSchema = z.object({ v: z.literal(PAIRING_OFFER_VERSION), endpoint: z.string().min(1), deviceToken: z.string().min(1), // Why: the desktop's Curve25519 public key, base64-encoded. The mobile client // uses this to derive a shared secret via ECDH for end-to-end encryption. publicKeyB64: z.string().min(1) }) export type PairingOffer = z.infer export function encodePairingOffer(offer: PairingOffer): string { const json = JSON.stringify(offer) const base64url = Buffer.from(json, 'utf-8') .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') // Why: Android camera intents and Expo Router preserve query params more // reliably than URL fragments when launching a custom-scheme app. return `orca://pair?code=${base64url}` } export function decodePairingOffer(url: string): PairingOffer { const code = extractPairingCodeFromUrl(url) if (!code) { throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code') } return decodePairingBase64(code) } function extractPairingCodeFromUrl(url: string): string | null { let parsed: URL try { parsed = new URL(url) } catch { return null } // Why: prefix checks accepted routes like `orca://pairing?...`; only the // pairing deep-link host may carry runtime auth material. if (parsed.protocol !== 'orca:' || parsed.hostname !== 'pair') { return null } if (parsed.pathname !== '' && parsed.pathname !== '/') { return null } const code = parsed.searchParams.get('code') if (code) { return code } return parsed.hash ? parsed.hash.slice(1) || null : null } // Why: accept either an `orca://pair?...` URL or the bare base64 // string so the mobile paste-pair flow can take whichever the user // actually copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { const trimmed = input.trim() if (!trimmed) { return null } try { if (trimmed.toLowerCase().startsWith('orca://')) { return decodePairingOffer(trimmed) } return decodePairingBase64(trimmed) } catch { return null } } function decodePairingBase64(base64url: string): PairingOffer { const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') const json = Buffer.from(base64, 'base64').toString('utf-8') return PairingOfferSchema.parse(JSON.parse(json)) }