// Generic client for every App Store Connect and Google Play operation: the generated rows (apple.gen.ts, play.gen.ts) // plus a few helpers instead of a function per endpoint. Request building is pure (tests cover URLs and parameters); // sending retries 429 (and 5xx / network errors of requests that are safe to send twice: not a POST create) with // backoff (Retry-After honoured) and follows pages. // Auth: the App Store Connect team key JWT from cli/apple/asc.ts, the Play service account token from // cli/lib/google-auth.ts (one scope per Google API). Credentials only come from env or .storebound/. import { AmbiguousWriteError, retrySafe } from '../lib/upsert'; import { APPLE_OPS, APPLE_SPEC } from './apple.gen'; import { PLAY_APIS, PLAY_OPS } from './play.gen'; import type { AppleBody, AppleOp, AppleParams, AppleResponse, ParamValue, PlayBody, PlayOp, PlayParams, PlayResponse } from './types'; import type { Store } from './gate'; import { errorBody } from '../lib/http'; import * as rt from '../lib/rt'; export interface QueryParam { name: string; required: boolean; repeated: boolean } export interface Op { store: Store; id: string; method: string; /** Path template: Apple "/v1/apps/{id}", Google "androidpublisher/v3/applications/{packageName}/edits" */ path: string; query: QueryParam[]; /** Apple: resource type ("type", "type[]", "json", ""). Google: request schema name ("" for none). */ request: string; /** Apple: "many:type" | "one:type" | "json" | "raw:" | "none". Google: response schema name. */ response: string; /** Google: m media upload, d media download, p pageToken paging, t token paging. Apple: "". */ flags: string; /** Google API name (androidpublisher, playdeveloperreporting); "" for Apple. */ api: string; /** Google: the shape of each {+param}, e.g. name -> "apps/* /crashRateMetricSet" (no space; * is one segment). */ shapes: Record; } /** * App Store Connect query parameters the spec declares but the live API answers 400 to, per operation id, with what to * use instead. `storebound api` refuses them, and tests/asc-query-guard.test.ts fails when storebound's own code uses one. */ export const APPLE_LIVE_REJECTED: Record> = { // seen live 2026-10-13 (apple testflight distribute): GET /v1/betaGroups?filter[app]=..&filter[builds]=.. -> 400 betaGroups_getCollection: { 'filter[builds]': 'seen live: 400; read the builds of each group with GET /v1/betaGroups/{id}/relationships/builds' }, }; /** Query parameters every Google API accepts on top of a method's own. */ export const GOOGLE_GLOBAL = ['fields', 'prettyPrint', 'quotaUser']; const parseQuery = (s: string): QueryParam[] => (s ? s.split(' ') : []).map(t => ({ name: t.replace(/[!*]/g, ''), required: t.includes('!'), repeated: t.includes('*') })); const cache: Partial> = {}; export function allOps(store: Store): Op[] { if (cache[store]) return cache[store]!; const rows = store === 'apple' ? APPLE_OPS as Record : PLAY_OPS as Record; return (cache[store] = Object.entries(rows).map(([id, r]) => ({ store, id, method: r[0]!, path: r[1]!, query: parseQuery(r[2]!), request: r[3]!, response: r[4]!, flags: r[5] ?? '', api: store === 'play' ? id.split('.')[0]! : '', shapes: Object.fromEntries((r[6] ?? '').split(' ').filter(Boolean).map(s => [s.slice(0, s.indexOf('=')), s.slice(s.indexOf('=') + 1)])), }))); } export function getOp(store: Store, id: string): Op | undefined { const ops = allOps(store); return ops.find(o => o.id === id) ?? (store === 'play' ? ops.find(o => o.id.slice(o.api.length + 1) === id) : undefined); } const esc = (s: string) => s.replace(/[.*+?^$()|[\]\\]/g, '\\$&'); /** "apps/* /crashRateMetricSet" (no space) -> a regex source; * is one path segment. */ const shapeSource = (shape: string) => shape.split('*').map(esc).join('[^/]+'); /** Path template -> RegExp with one group per path parameter ({+param}s narrowed by their shape). */ function templateRe(op: Op): { re: RegExp; names: string[]; fixed: number } { const names: string[] = []; const body = op.path.replace(/^\//, '').split(/(\{\+?[^}]+\})/).map(part => { const m = /^\{(\+?)([^}]+)\}$/.exec(part); if (!m) return esc(part); names.push(m[2]!); if (!m[1]) return '([^/:]+)'; const shape = op.shapes[m[2]!]; return shape ? `(${shapeSource(shape)})` : '(.+)'; }).join(''); return { re: new RegExp('^' + body + '$'), names, fixed: op.path.split('{')[0]!.length }; } /** * An operation from what the user typed: an operation id (Google ids also without the API prefix), or "METHOD path" * with a concrete path (and query string), whose values become parameters. */ export function resolveOp(store: Store, ref: string, path?: string): { op: Op; params: Record } { if (path === undefined) { const op = getOp(store, ref); if (!op) throw new Error(`${store}: no operation "${ref}" (storebound api list --store ${store} --search )`); return { op, params: {} }; } const method = ref.toUpperCase(); if (!/^(GET|POST|PUT|PATCH|DELETE)$/.test(method)) throw new Error(`"${ref}": expected an operation id or METHOD path`); let p = path.replace(/^https:\/\/[^/]+/, ''); if (store === 'play') p = p.replace(/^\/?upload\//, '/'); const [rawPath, qs = ''] = p.split('?', 2) as [string, string?]; const clean = decodeURI(rawPath).replace(/^\//, ''); const hits = allOps(store).filter(o => o.method === method).map(o => ({ o, t: templateRe(o) })) .map(x => ({ ...x, m: x.t.re.exec(clean) })).filter(x => x.m).sort((a, b) => b.t.fixed - a.t.fixed); const best = hits[0]; if (!best) throw new Error(`${store}: no ${method} operation matches ${rawPath}`); const params: Record = {}; best.t.names.forEach((n, i) => { params[n] = best.m![i + 1]!; }); for (const [k, v] of new URLSearchParams(qs)) params[k] = v; return { op: best.o, params }; } export interface BuiltRequest { op: Op; method: string; url: string; /** JSON text or file bytes */ body?: string | Uint8Array; contentType?: string; /** Apple vendor media types (perfPowerMetrics: application/vnd.apple.xcode-metrics+json, reports: application/a-gzip) */ accept?: string; } const encSeg = (v: string) => encodeURIComponent(v); // Apple writes brackets and commas plainly in its docs and links (filter[app]=1,2); keep them readable const encName = (k: string) => encodeURIComponent(k).replace(/%5B/gi, '[').replace(/%5D/gi, ']'); const encVal = (v: string) => encodeURIComponent(v).replace(/%2C/gi, ','); const str = (v: string | number | boolean) => String(v); /** Builds the URL and body of one call. Throws on a missing path / required parameter or an unknown parameter. Pure. */ export function buildRequest(op: Op, params: Record, body?: unknown, o: { upload?: { bytes: Uint8Array; contentType: string }; download?: boolean } = {}): BuiltRequest { const left = new Map(Object.entries(params).filter(([, v]) => v !== undefined) as [string, ParamValue][]); const path = op.path.replace(/\{(\+?)([^}]+)\}/g, (_, plus: string, name: string) => { const v = left.get(name); if (v === undefined || v === '') throw new Error(`${op.id}: path parameter "${name}" is required`); left.delete(name); const s = Array.isArray(v) ? v.join(',') : str(v as string); const shape = op.shapes[name]; if (shape && !new RegExp(`^${shapeSource(shape)}$`).test(s)) throw new Error(`${op.id}: "${name}" must look like ${shape} (* is one segment), got "${s}"`); return plus ? s.split('/').map(encSeg).join('/') : encSeg(s); }); const known = new Map(op.query.map(q => [q.name, q] as const)); for (const q of op.query) if (q.required && !left.has(q.name)) throw new Error(`${op.id}: query parameter "${q.name}" is required`); const parts: string[] = []; for (const [k, v] of left) { const q = known.get(k); if (!q && !(op.store === 'play' && GOOGLE_GLOBAL.includes(k))) { const names = op.query.map(x => x.name); throw new Error(`${op.id}: unknown parameter "${k}"${names.length ? ` (takes: ${names.slice(0, 30).join(', ')}${names.length > 30 ? ', …' : ''})` : ' (takes no query parameters)'}`); } const refused = op.store === 'apple' ? APPLE_LIVE_REJECTED[op.id]?.[k] : undefined; if (refused) throw new Error(`${op.id}: App Store Connect refuses "${k}" although the spec declares it (${refused})`); const values = Array.isArray(v) ? v.map(str) : [str(v as string)]; if (q?.repeated) for (const x of values) parts.push(`${encName(k)}=${encVal(x)}`); else parts.push(`${encName(k)}=${encVal(values.join(','))}`); } let base: string; if (op.store === 'apple') base = APPLE_SPEC.base + path; else { const api = PLAY_APIS[op.api as keyof typeof PLAY_APIS]; if (!api) throw new Error(`${op.id}: unknown Google API ${op.api}`); const upload = !!o.upload && op.flags.includes('m'); if (o.upload && !upload) throw new Error(`${op.id}: takes no file upload`); base = api.root + (upload ? 'upload/' : '') + path; if (upload) parts.unshift('uploadType=media'); if (o.download) { if (!op.flags.includes('d')) throw new Error(`${op.id}: has no media download`); parts.push('alt=media'); } } const req: BuiltRequest = { op, method: op.method, url: base + (parts.length ? '?' + parts.join('&') : '') }; if (op.response.startsWith('raw:')) req.accept = op.response.slice(4); if (o.upload) { req.body = o.upload.bytes; req.contentType = o.upload.contentType; } else if (body !== undefined) { if (op.method === 'GET' || op.method === 'DELETE' && op.store === 'play') throw new Error(`${op.id}: ${op.method} takes no body`); req.body = JSON.stringify(body); req.contentType = 'application/json'; } return req; } // ---------------------------------------------------------------------------------------------------- sending /** Authorization header value for a request ("Bearer ..."). */ export type Auth = (req: BuiltRequest) => Promise | string; export interface SendOptions { fetch?: (url: string, init: RequestInit) => Promise; sleep?: (ms: number) => Promise; /** attempts for 429 / 5xx / network errors (default 5) */ attempts?: number; /** each retry is logged here (stderr in the CLI) */ onRetry?: (msg: string) => void; } export interface ApiResult { status: number; /** parsed JSON, or null for an empty body */ json?: unknown; /** non-JSON body (gzip reports, media downloads) */ bytes?: Uint8Array; /** Apple's X-Rate-Limit header ("user-hour-lim:3600;user-hour-rem:3412;") */ rateLimit?: string; } export class ApiError extends Error { constructor(readonly status: number, message: string) { super(message); } } /** Wait before retry `attempt` (1-based): Retry-After when the server sent one (max 60 s), else 1, 2, 4, 8 ... s (max 30 s). */ export function backoffMs(attempt: number, retryAfter?: string | null): number { const s = retryAfter ? Number(retryAfter) : NaN; if (Number.isFinite(s) && s >= 0) return Math.min(s, 60) * 1000; return Math.min(1000 * 2 ** (attempt - 1), 30_000); } const short = (url: string) => url.replace(/^https:\/\/[^/]+/, '').replace(/\?.*$/, ''); export async function send(req: BuiltRequest, auth: Auth, o: SendOptions = {}): Promise { const doFetch = o.fetch ?? ((u: string, i: RequestInit) => fetch(u, i)); const sleep = o.sleep ?? ((ms: number) => rt.sleep(ms)); const attempts = o.attempts ?? 5; for (let attempt = 1; ; attempt++) { const headers: Record = { Authorization: await auth(req) }; if (req.contentType) headers['Content-Type'] = req.contentType; if (req.accept) headers.Accept = req.accept; // 5xx and network errors are retried only for requests that end the same when sent twice: a POST create without // a clear answer may have been applied, so it is reported (AmbiguousWriteError) and never sent again by itself const safe = retrySafe(req.method, req.url); let r: Response; try { r = await doFetch(req.url, { method: req.method, headers, body: req.body as BodyInit | undefined }); } catch (e) { if (!safe) throw new AmbiguousWriteError(`${req.method} ${short(req.url)}: no answer (${(e as Error).message}); not sent again: it may have been applied, check with a GET before you run it again`); if (attempt >= attempts) throw e; o.onRetry?.(`${req.method} ${short(req.url)}: ${(e as Error).message}, retry ${attempt}/${attempts - 1}`); await sleep(backoffMs(attempt)); continue; } if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${req.method} ${short(req.url)} -> ${r.status} ${errorBody(await r.text())}; not sent again: it may have been applied, check with a GET before you run it again`, r.status); if ((r.status === 429 || r.status >= 500) && attempt < attempts) { const wait = backoffMs(attempt, r.headers.get('retry-after')); await r.body?.cancel(); o.onRetry?.(`${req.method} ${short(req.url)} -> ${r.status}, retry ${attempt}/${attempts - 1} in ${Math.round(wait / 1000)} s`); await sleep(wait); continue; } const rateLimit = r.headers.get('x-rate-limit') ?? undefined; const type = r.headers.get('content-type') ?? ''; if (!r.ok) throw new ApiError(r.status, `${req.method} ${short(req.url)} -> ${r.status} ${errorBody(await r.text())}`); if (/json/.test(type) || (!type && r.status !== 204)) { const t = await r.text(); return { status: r.status, json: t ? JSON.parse(t) : null, rateLimit }; } if (r.status === 204) return { status: 204, json: null, rateLimit }; return { status: r.status, bytes: new Uint8Array(await r.arrayBuffer()), rateLimit }; } } /** Merges two pages: arrays are concatenated (Apple's included de-duplicated), the page tokens dropped. Pure. */ export function mergePages(a: Record, b: Record): Record { const out: Record = { ...a }; for (const [k, v] of Object.entries(b)) { const cur = out[k]; if (Array.isArray(cur) && Array.isArray(v)) { if (k === 'included') { const seen = new Set(cur.map(x => `${(x as { type?: string }).type}/${(x as { id?: string }).id}`)); out[k] = [...cur, ...v.filter(x => !seen.has(`${(x as { type?: string }).type}/${(x as { id?: string }).id}`))]; } else out[k] = [...cur, ...v]; } else if (cur === undefined) out[k] = v; } delete out.nextPageToken; if (out.tokenPagination) delete out.tokenPagination; if (out.links && typeof out.links === 'object') out.links = { ...(out.links as Record), next: undefined }; // Apple's paging meta of the first page ("nextCursor", the page limit) would say there is more: keep only the total const meta = out.meta as { paging?: Record } | undefined; if (meta?.paging && typeof meta.paging === 'object') { const { nextCursor: _c, limit: _l, ...rest } = meta.paging; out.meta = { ...meta, paging: rest }; } return out; } /** The next page of a result, or null. Apple: links.next (same host only, the token never leaves it); Google: the page token. */ export function nextRequest(req: BuiltRequest, json: unknown): BuiltRequest | null { const j = (json ?? {}) as { links?: { next?: string }; nextPageToken?: string; tokenPagination?: { nextPageToken?: string } }; if (req.op.store === 'apple') { const next = j.links?.next; if (!next) return null; if (!next.startsWith(APPLE_SPEC.base + '/')) throw new Error(`refusing to follow a next link to another host: ${next.slice(0, 80)}`); return { ...req, url: next }; } const [name, token] = req.op.flags.includes('p') ? ['pageToken', j.nextPageToken] : req.op.flags.includes('t') ? ['token', j.tokenPagination?.nextPageToken] : ['', undefined]; if (!token) return null; const u = new URL(req.url); u.searchParams.set(name, token); return { ...req, url: u.toString() }; } /** Every page of a GET (or a paged query), merged. `maxPages` guards against a server that never stops. */ export async function sendAll(req: BuiltRequest, auth: Auth, o: SendOptions & { maxPages?: number; onPage?: (n: number) => void } = {}): Promise { let first = await send(req, auth, o); let pages = 1; let cur: BuiltRequest | null = req; let last = first.json; while ((cur = nextRequest(cur, last))) { if (pages >= (o.maxPages ?? 1000)) throw new Error(`more than ${o.maxPages ?? 1000} pages: stopped`); const r = await send(cur, auth, o); pages++; o.onPage?.(pages); last = r.json; first = { ...first, json: mergePages(first.json as Record, r.json as Record), rateLimit: r.rateLimit ?? first.rateLimit }; } return { ...first, pages }; } // ---------------------------------------------------------------------------------------------------- typed facades /** Typed App Store Connect client: `api.call('apps_getCollection', { 'filter[bundleId]': 'com.example.app' })`. */ export function appleApi(auth: Auth, o: SendOptions = {}) { const build = (op: O, params: AppleParams, body?: AppleBody) => buildRequest(getOp('apple', op)!, params as Record, body); const out = (r: ApiResult) => (r.bytes ?? r.json ?? null); return { build, async call(op: O, params: AppleParams, body?: AppleBody): Promise> { return out(await send(build(op, params, body), auth, o)) as AppleResponse; }, async all(op: O, params: AppleParams): Promise> { return out(await sendAll(build(op, params), auth, o)) as AppleResponse; }, }; } /** Typed Google Play client: `api.call('androidpublisher.edits.tracks.list', { packageName, editId })`. */ export function playApi(auth: Auth, o: SendOptions = {}) { const build = (op: O, params: PlayParams, body?: PlayBody) => body instanceof Uint8Array ? buildRequest(getOp('play', op)!, params as Record, undefined, { upload: { bytes: body, contentType: 'application/octet-stream' } }) : buildRequest(getOp('play', op)!, params as Record, body); const out = (r: ApiResult) => (r.bytes ?? r.json ?? {}); return { build, async call(op: O, params: PlayParams, body?: PlayBody): Promise> { return out(await send(build(op, params, body), auth, o)) as PlayResponse; }, async all(op: O, params: PlayParams): Promise> { return out(await sendAll(build(op, params), auth, o)) as PlayResponse; }, }; } /** Auth from the project's credentials (env or .storebound/): Apple team key JWT, or a Play token for the op's API scope. */ export async function authFor(store: Store, root: string): Promise { if (store === 'apple') { const { ascJwt } = await import('../apple/asc'); const { jwt } = ascJwt(root); return () => 'Bearer ' + jwt(); } const { PLAY_SCOPE, playToken } = await import('../lib/google-auth'); // the credential broker when it has the scope, else the local key; throws now when neither is there const sources = new Map Promise>([[PLAY_SCOPE, playToken(root, PLAY_SCOPE)]]); return async req => { const scope = PLAY_APIS[req.op.api as keyof typeof PLAY_APIS].scope; if (!sources.has(scope)) sources.set(scope, playToken(root, scope)); return 'Bearer ' + (await sources.get(scope)!()); }; }