Files
zpan/server/services/licensing-cloud.ts
T
Jasper VanandClaude Opus 4.8 e6e67c5fe4 feat(licensing): harden cloud pairing — env keys, clear errors, confirm handshake (#424)
Three related robustness fixes for the cloud pairing flow:

1. Trusted license public keys are env-configurable (ZPAN_LICENSE_PUBLIC_KEYS)
   instead of hardcoding dev keys in source — a leaked dev key is rotated via
   config and never baked into production builds. Registered in all platform
   factories.
2. Certificate verification surfaces a specific rejection reason
   (signature/issuer/instance/expired/host), and the pairing modal distinguishes
   a cert-verification failure from a genuine timeout instead of showing both as
   "expired". On failure the poll handler rolls back the orphaned cloud binding.
3. After verifying + storing the certificate, the instance confirms the binding
   to the cloud (zpan-cloud-sdk 2.1.0's POST /licenses/:id/confirm) so the cloud
   pairing page resolves to success only once the instance actually accepted it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:43:46 -04:00

227 lines
7.0 KiB
TypeScript

import type { z } from 'zod'
import { type CloudClient, createCloudClient } from 'zpan-cloud-sdk'
const CLOUD_REQUEST_TIMEOUT_MS = 10_000
const JSON_HEADERS = { 'content-type': 'application/json' }
export interface PairingResponse {
code: string
pairingUrl: string
expiresAt: string
}
export interface PairingPollResponse {
status: 'pending' | 'approved' | 'denied' | 'expired'
refreshToken?: string
certificate?: string
binding?: LicenseBindingInfo
account?: LicenseAccountInfo
}
export interface EntitlementRefreshResponse {
refreshToken: string
certificate: string
binding: LicenseBindingInfo
account: LicenseAccountInfo
nextRefreshAfter?: string
}
export interface LicenseBindingInfo {
id: string
instanceId: string
storeId: string
authorizedHosts: string[]
}
export interface LicenseAccountInfo {
id: string
email?: string | null
}
// The payload ZPan Cloud expects for pairing/refresh. Its `runtime` shape is
// fixed by zpan-cloud-sdk and is intentionally decoupled from the richer
// InstanceInfo the About page consumes (which has flat runtime + platform).
export interface CloudInstanceInfo {
id: string
name: string
url: string
version: string
commit?: string | null
runtime?: {
provider: 'cloudflare' | 'node'
target: 'cloudflare-worker' | 'node/docker'
} | null
server?: { os?: { platform?: string | null; arch?: string | null; release?: string | null } | null } | null
node?: { version?: string | null } | null
}
export class CloudInvalidResponseError extends Error {
constructor() {
super('Cloud response missing certificate')
this.name = 'CloudInvalidResponseError'
}
}
export class CloudUnboundError extends Error {
constructor() {
super('Instance unbound from cloud')
this.name = 'CloudUnboundError'
}
}
export class CloudNetworkError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : 'Cloud network error')
this.name = 'CloudNetworkError'
}
}
function cloudApiBaseUrl(baseUrl: string): string {
return `${baseUrl.replace(/\/$/, '')}/api`
}
export function createBoundCloudClient(baseUrl: string, refreshToken: string): CloudClient {
return createCloudClient({ baseUrl: cloudApiBaseUrl(baseUrl), token: refreshToken, headers: JSON_HEADERS })
}
function createAnonymousCloudClient(baseUrl: string): CloudClient {
return createCloudClient({ baseUrl: cloudApiBaseUrl(baseUrl), headers: JSON_HEADERS })
}
async function cloudResponse<
T extends { ok: boolean; status: number; json(): Promise<unknown>; text(): Promise<string> },
>(response: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
response,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error('cloud_request_timeout')), CLOUD_REQUEST_TIMEOUT_MS)
}),
])
} catch (err) {
throw new CloudNetworkError(err)
} finally {
if (timeout) clearTimeout(timeout)
}
}
export async function unwrapCloudResponse<T, U = T>(
response: {
status: number
ok: boolean
json(): Promise<T>
},
responseSchema?: z.ZodType<U>,
): Promise<U> {
if (response.status === 204) return null as U
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(cloudErrorCode(data) ?? `cloud_request_failed_${response.status}`)
const payload = data && typeof data === 'object' && 'data' in data ? data.data : data
if (!responseSchema) return payload as U
const parsed = responseSchema.safeParse(payload)
if (!parsed.success) throw new Error('invalid_cloud_response')
return parsed.data
}
export async function requestCloudJson<T, U = T>(
response: Promise<{
status: number
ok: boolean
json(): Promise<T>
text(): Promise<string>
}>,
responseSchema?: z.ZodType<U>,
): Promise<U> {
return unwrapCloudResponse(await cloudResponse(response), responseSchema)
}
export async function createPairing(baseUrl: string, instance: CloudInstanceInfo): Promise<PairingResponse> {
const res = await cloudResponse(
createAnonymousCloudClient(baseUrl).pairings.$post({
json: { instance },
}),
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Cloud pairing failed: ${res.status} ${text}`)
}
return unwrapCloudData<PairingResponse>(await res.json())
}
export async function pollPairing(baseUrl: string, code: string): Promise<PairingPollResponse> {
const res = await cloudResponse(createAnonymousCloudClient(baseUrl).pairings[':code'].$get({ param: { code } }))
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Cloud poll failed: ${res.status} ${text}`)
}
return unwrapCloudData<PairingPollResponse>(await res.json())
}
// Throws CloudUnboundError on 401 (instance was unbound from cloud side).
// Throws CloudNetworkError on network failure.
export async function refreshEntitlement(
baseUrl: string,
refreshToken: string,
instance?: CloudInstanceInfo,
): Promise<EntitlementRefreshResponse> {
const client = createBoundCloudClient(baseUrl, refreshToken)
const res = await cloudResponse(
instance ? client.entitlements.$post({ json: { instance } }) : client.entitlements.$post({ json: undefined }),
)
if ((res.status as number) === 401) {
throw new CloudUnboundError()
}
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Cloud refresh failed: ${res.status} ${text}`)
}
const data = unwrapCloudData<EntitlementRefreshResponse>(await res.json())
if (!data.certificate) throw new CloudInvalidResponseError()
return data
}
export async function unbindCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise<void> {
const res = await cloudResponse(
createBoundCloudClient(baseUrl, refreshToken).licenses[':id'].$delete({ param: { id: licenseId } }),
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Cloud unbind failed: ${res.status} ${text}`)
}
}
// Tell the cloud this instance verified + stored the certificate, so the pairing
// page can resolve to success instead of claiming success the moment it approves.
export async function confirmCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise<void> {
const res = await cloudResponse(
createBoundCloudClient(baseUrl, refreshToken).licenses[':id'].confirm.$post({ param: { id: licenseId } }),
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Cloud confirm failed: ${res.status} ${text}`)
}
}
function unwrapCloudData<T>(data: unknown): T {
if (data && typeof data === 'object' && 'data' in data) return data.data as T
return data as T
}
function cloudErrorCode(data: unknown) {
if (!data || typeof data !== 'object' || !('error' in data)) return null
const error = data.error
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') return error.code
return null
}