mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Fixes #5508. Rejects mobile-scope phone-QR pairing in the full web client, keeps runtime browser access links on the instant startup path via advisory pairing scope metadata, and surfaces forbidden runtime scope errors instead of rendering empty workspaces or retry-looping setup checks. Security note: pairing offer scope is UI metadata only. Runtime RPC authorization remains based on the server-side device token registry and mobile allowlist.
85 lines
2.7 KiB
TypeScript
85 lines
2.7 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
export const PAIRING_OFFER_VERSION = 2
|
|
const PairingScopeSchema = z.enum(['mobile', 'runtime'])
|
|
|
|
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),
|
|
// Why: advisory UI metadata lets the web client reject phone-QR offers before
|
|
// opening a socket; the runtime still authorizes solely from deviceToken.
|
|
scope: PairingScopeSchema.optional()
|
|
})
|
|
|
|
export type PairingOffer = z.infer<typeof PairingOfferSchema>
|
|
|
|
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))
|
|
}
|