// App Store Connect API client: ES256 JWT from a team API key (Users and Access > Integrations > App Store Connect // API; role Admin or App Manager), paging, retries, the asset upload protocol, and the app / version lookups. import { createHash, createPrivateKey, sign } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { ascKey } from '../lib/credentials'; import { errorBody } from '../lib/http'; import { noteAsc } from '../lib/quota'; import { AmbiguousWriteError, retrySafe } from '../lib/upsert'; import * as rt from '../lib/rt'; const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url'); /** One App Store Connect API call may take at most this long (then retried when safe, else AmbiguousWriteError). */ export const ASC_REQUEST_TIMEOUT_MS = 120_000; export const md5 = (b: Buffer | Uint8Array): string => createHash('md5').update(b).digest('hex'); // eslint-disable-next-line @typescript-eslint/no-explicit-any export type J = any; export type Asc = Awaited>; /** Versions that can still be edited (being prepared or sent back). */ export const EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY']); /** Delivery states after which an uploaded screenshot / preview counts as there. */ const DELIVERED = ['COMPLETE', 'UPLOAD_COMPLETE', 'PROCESSING']; /** A screenshot / preview set already holds exactly these files (same MD5s, same order, none failed or half uploaded): * then a sync leaves it alone. Pure. */ export function setUpToDate(existing: { checksum?: string | null; state?: string | null }[], want: string[]): boolean { return existing.length === want.length && existing.every((e, i) => e.checksum === want[i] && DELIVERED.includes(e.state ?? '')); } /** How many leading assets of a set already hold the wanted files (same MD5 at the same position, delivered): those * stay when the set is synced. Pure. */ export function keptPrefix(existing: { checksum?: string | null; state?: string | null }[], want: string[]): number { let k = 0; while (k < existing.length && k < want.length && existing[k]!.checksum === want[k] && DELIVERED.includes(existing[k]!.state ?? '')) k++; return k; } /** Cached ES256 token getter for the team API key (env or .storebound/): valid 18 minutes, renewed after 15. Shared by * ascClient and the generic `storebound api apple` client (cli/api/client.ts). */ export function ascJwt(root: string): { jwt: () => string; keyId: string } { const k = ascKey(root); const key = createPrivateKey(k.pem); let cached = { jwt: '', until: 0 }; function jwt(): string { const now = Math.floor(Date.now() / 1000); if (cached.jwt && now < cached.until) return cached.jwt; const h = b64(JSON.stringify({ alg: 'ES256', kid: k.keyId, typ: 'JWT' })); const p = b64(JSON.stringify({ iss: k.issuerId, iat: now, exp: now + 1100, aud: 'appstoreconnect-v1' })); cached = { jwt: `${h}.${p}.${b64(sign('sha256', Buffer.from(h + '.' + p), { key, dsaEncoding: 'ieee-p1363' }))}`, until: now + 900 }; return cached.jwt; } return { jwt, keyId: k.keyId }; } export async function ascClient(root: string, bundleId: string, appIdHint?: string) { const k = ascJwt(root); const jwt = k.jwt; /** * One API request. 429 is retried (the request was refused, not applied). 5xx and network errors are retried only * for requests that end the same when sent twice (retrySafe: GET, PATCH, DELETE, relationship adds, the "create or * replace" singletons); a POST create then throws AmbiguousWriteError instead, so callers look before they create * again (cli/lib/upsert.ts). A DELETE answered 404 after a retry was applied by the first attempt. */ async function asc(method: string, path: string, body?: unknown, attempt = 1): Promise { const safe = retrySafe(method, path); let r: Response; try { r = await fetch(path.startsWith('http') ? path : 'https://api.appstoreconnect.apple.com' + path, { method, headers: { Authorization: 'Bearer ' + jwt(), 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(ASC_REQUEST_TIMEOUT_MS), // a hung connection is a network error, never a silent wait }); } catch (e) { if (safe && attempt < 5) { await rt.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); } if (!safe) throw new AmbiguousWriteError(`${method} ${path} -> no answer (${(e as Error).message})`); throw e; } noteAsc(r); const t = await r.text(); if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 5) { await rt.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); } if (r.status >= 500 && !safe) throw new AmbiguousWriteError(`${method} ${path} -> ${r.status} ${errorBody(t)}`, r.status); if (r.status === 404 && method === 'DELETE' && attempt > 1) return {}; if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t)}`); return t ? JSON.parse(t) : {}; } /** GET with a vendor media type, e.g. perfPowerMetrics (application/vnd.apple.xcode-metrics+json). Parsed JSON. */ async function getAs(path: string, accept: string): Promise { for (let attempt = 1; ; attempt++) { const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } }); noteAsc(r); const t = await r.text(); if ((r.status >= 500 || r.status === 429) && attempt < 5) { await rt.sleep(2000 * attempt); continue; } if (!r.ok) throw new Error(`GET ${path} -> ${r.status} ${errorBody(t, 800)}`); return t ? JSON.parse(t) : {}; } } /** GET of a binary answer (sales / finance reports: application/a-gzip). null on 404 (Apple answers 404 when there * is no report for that date: no sales, or not published yet). */ async function bytes(path: string, accept = 'application/a-gzip'): Promise { for (let attempt = 1; ; attempt++) { const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } }); noteAsc(r); if ((r.status >= 500 || r.status === 429) && attempt < 5) { await r.body?.cancel(); await rt.sleep(2000 * attempt); continue; } if (r.status === 404) { await r.body?.cancel(); return null; } if (!r.ok) throw new Error(`GET ${path.replace(/\?.*$/, '')} -> ${r.status} ${errorBody(await r.text(), 600)}`); return new Uint8Array(await r.arrayBuffer()); } } /** Every item of a paged GET. */ async function all(path: string): Promise { const out: J[] = []; let url: string | null = path; while (url) { const page: J = await asc('GET', url); out.push(...(page.data ?? [])); url = page.links?.next ?? null; } return out; } /** null on 404 (relationship not created yet). */ async function maybe(path: string): Promise { try { return await asc('GET', path); } catch (e) { if (/-> 404/.test(String(e))) return null; throw e; } } let appId = appIdHint ?? ''; async function app(): Promise { if (appId) return appId; const found = await asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`); const hit = (found.data as J[]).find(a => a.attributes.bundleId === bundleId); if (!hit) throw new Error(`no App Store Connect app with bundle id ${bundleId}: create the app record in the web UI first (Apps > + > New App); see the appstore-first-release skill`); appId = hit.id; return appId; } async function editableVersion(): Promise { const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=IOS&limit=50`); return vs.find(v => EDITABLE.has(v.attributes.appVersionState ?? v.attributes.appStoreState)) ?? null; } async function editableAppInfo(): Promise { const infos = await all(`/v1/apps/${await app()}/appInfos`); return infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0]; } /** Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots, app previews). `attrs` go into * the reservation (app previews: mimeType, previewFrameTimeCode). */ async function uploadAsset(type: string, rel: Record, file: string, attrs: Record = {}): Promise { const buf = readFileSync(file); const name = file.split('/').pop()!; const res = (await asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length, ...attrs }, relationships: rel } })).data; for (const op of res.attributes.uploadOperations as J[]) { const headers: Record = {}; for (const h of op.requestHeaders) headers[h.name] = h.value; const r = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) }); if (!r.ok) throw new Error(`upload ${name} part ${op.offset} -> ${r.status}`); } await asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true, sourceFileChecksum: md5(buf) } } }); return res.id; } /** * Makes a screenshot or preview set hold exactly `files`, in this order, touching as little as possible: the leading * assets that already match stay, everything after them is deleted, the rest is uploaded in order. A crash midway * leaves a prefix of the wanted files (plus maybe one half-uploaded asset), which the next run keeps or removes, so * re-runs converge and never duplicate. A reservation with an unknown outcome is cleaned up (the set is read again and * any asset this run does not know is deleted) and sent once more. At the end the order is read back and, if Apple * shows another one, set through the set's relationship. */ async function syncAssets(o: { set: { type: 'appScreenshotSets' | 'appPreviewSets'; id: string }; existing: J[]; files: string[]; want: string[]; state: (x: J) => string | undefined; attrs?: (file: string) => Record; onUpload?: (file: string) => void }): Promise<{ uploaded: number; removed: number; kept: number }> { const asset = o.set.type === 'appScreenshotSets' ? 'appScreenshots' : 'appPreviews'; const rel = { [o.set.type === 'appScreenshotSets' ? 'appScreenshotSet' : 'appPreviewSet']: { data: { type: o.set.type, id: o.set.id } } }; const k = keptPrefix(o.existing.map(x => ({ checksum: x.attributes?.sourceFileChecksum, state: o.state(x) })), o.want); const ids: string[] = o.existing.slice(0, k).map(x => x.id); const extra = o.existing.slice(k); for (const x of extra) await asc('DELETE', `/v1/${asset}/${x.id}`); for (const f of o.files.slice(k)) { o.onUpload?.(f); try { ids.push(await uploadAsset(asset, rel, f, o.attrs?.(f) ?? {})); } catch (e) { if (!(e as { ambiguous?: boolean }).ambiguous) throw e; // the reservation may exist: remove whatever this run does not know, then upload this file once more for (const x of await all(`/v1/${o.set.type}/${o.set.id}/${asset}?limit=50`)) if (!ids.includes(x.id)) await asc('DELETE', `/v1/${asset}/${x.id}`); ids.push(await uploadAsset(asset, rel, f, o.attrs?.(f) ?? {})); } } if (o.files.length > k) { const now = (await all(`/v1/${o.set.type}/${o.set.id}/${asset}?limit=50`)).map(x => x.id as string); if (now.join() !== ids.join() && ids.every(id => now.includes(id))) await asc('PATCH', `/v1/${o.set.type}/${o.set.id}/relationships/${asset}`, { data: ids.map(id => ({ type: asset, id })) }); } return { uploaded: o.files.length - k, removed: extra.length, kept: k }; } return { asc, all, maybe, getAs, bytes, app, editableVersion, editableAppInfo, uploadAsset, syncAssets, keyId: k.keyId }; }