import { DEFAULT_SERVICE_DESCRIPTOR, type AuthDescriptor, type DeviceAuthDescriptor, type ServiceDescriptor } from '../../domain/models/ServiceDescriptor' import { HttpStatusError } from '../http/HttpTextClient' import type { StoreHttpClient } from '../http/StoreHttpClient' import { asRecord, readBoolean, readNumber, readOptionalString, readRecord } from '../json/JsonRecord' const SERVICE_PATH = '/api/service' /** * `GET /api/service`: what this catalog's server is, asked before anything else. * * A missing descriptor is an answer, not a failure. Every WarpEngine before 0.5 has no * such endpoint, so a 404 means "an older engine" — a plain catalog with nothing gated * and nobody to sign in as, which is exactly what this client assumed for its whole * life before now. Same for a network that is simply down: the store still works * offline from its cached catalog, and refusing to open because we could not ask the * server about itself would be a worse client than the one we had. */ export class ServiceDescriptorClient { public constructor ( private readonly http: StoreHttpClient, private readonly baseUrl: string, private readonly log: (line: string) => void ) {} public async fetchDescriptor (): Promise { const url = `${this.baseUrl}${SERVICE_PATH}` try { const { json } = await this.http.requestJson(url) const record = asRecord(json) if (record === null) return DEFAULT_SERVICE_DESCRIPTOR const descriptor: ServiceDescriptor = { engineVersion: readOptionalString(record, 'version'), catalogGated: readBoolean(readRecord(record, 'catalog') ?? {}, 'gated', false), auth: readAuth(record, this.baseUrl) } this.log(describe(descriptor)) return descriptor } catch (error: unknown) { if (error instanceof HttpStatusError && error.statusCode === 404) { this.log('the catalog has no service descriptor — an engine older than 0.5') } else { this.log(`warning: could not read ${url} — carrying on as a plain catalog`) } return DEFAULT_SERVICE_DESCRIPTOR } } } function readAuth (record: Readonly>, baseUrl: string): AuthDescriptor | null { const auth = readRecord(record, 'auth') if (auth === null) return null const device = readRecord(auth, 'device') if (device === null) return null const authorizeUrl = absolute(readOptionalString(device, 'authorizeUrl'), baseUrl) const tokenUrl = absolute(readOptionalString(device, 'tokenUrl'), baseUrl) const verificationUrl = absolute(readOptionalString(device, 'verificationUrl'), baseUrl) // Two of the three are the flow itself and the third is where a person goes. Without // all three there is no sign-in to offer, and half a flow is worse than none. if (authorizeUrl === null || tokenUrl === null || verificationUrl === null) return null const descriptor: DeviceAuthDescriptor = { authorizeUrl, tokenUrl, revokeUrl: absolute(readOptionalString(device, 'revokeUrl'), baseUrl), verificationUrl, interval: Math.max(1, readNumber(device, 'interval', 5)) } return { device: descriptor } } /** A server may answer with a path; it knows its own address better than we do. */ function absolute (value: string | null, baseUrl: string): string | null { if (value === null || value.length === 0) return null if (value.startsWith('http://') || value.startsWith('https://')) return value return `${baseUrl.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}` } function describe (descriptor: ServiceDescriptor): string { const version = descriptor.engineVersion ?? 'an unnamed version' const gated = descriptor.catalogGated ? 'some titles need an entitlement' : 'nothing is gated' const auth = descriptor.auth === null ? 'no sign-in' : 'sign-in available' return `catalog served by WarpEngine ${version} — ${gated}, ${auth}` }