ci/woodpecker/push/woodpecker Pipeline was successful
A store with paid titles had nothing to tell this client and no way for it to listen: the catalog carried no price, no entitlement and no sign-in, so a gated download could only come back 403 and leave the window guessing why. The knowledge belongs on the server, not here. This client serves whichever catalog a registry names, so anything it knew about a particular shop would be a rule that breaks every other one. WarpEngine 0.5 answers GET /api/service with what it offers and puts an `access` block on every entry; this reads both. There is no store name anywhere in the diff. - **0.5 is a dialect of its own**, the older shape with `access` added. The version list is exhaustive over the selector, so adding it was a compile error until somebody said what it reads like — which is what that switch is for. - **A card shows a price and a Buy button** when a title is not yours, opening the store's own page. Buying stays in a browser: a checkout rebuilt here would be a second place to get card handling wrong. - **Signing in is the device grant**: a short code, the person's own browser, and no password crossing this window. The token goes in the OS keychain through safeStorage — one per store — and where no keychain exists it is not stored at all rather than written out in the clear. - **Owned / To buy** join the categories, since owning something is not the same as having installed it. Three things worth stating about the shape: The bearer token stops at the origin that issued it. A gated download redirects to signed storage — often somebody else's host — and some object stores refuse a request outright when an Authorization header arrives alongside the signature. An absent access block is not "free". It is an engine too old to have an opinion, and only one of those two is a reason to offer somebody a sign-in, so the three states are kept apart all the way to the card. state.json does not carry entitlement. Whether somebody may download a title is the server's answer to a question asked now; a copy on disk would go stale on the next purchase or refund, and a stale yes is the dangerous direction. A store with no sign-in shows none, and every WarpEngine before 0.5 is such a store: no Account block, no prices, no new categories. The smoke test against the live catalog reports exactly that — `sign-in: not offered`, `access: open:13`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
90 lines
3.9 KiB
TypeScript
90 lines
3.9 KiB
TypeScript
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<ServiceDescriptor> {
|
|
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<Record<string, unknown>>, 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}`
|
|
}
|