// 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 { basename } from 'node:path'; 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'; import { partHeaders } from './screenshot-health'; 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 }; } /** App Store Connect answered no appInfos for the app (an app record always has one): a clear error, not a TypeError. */ export const noAppInfo = (appId: string): string => `App Store Connect returned no app info for app ${appId}: the app record is not readable with this key (check apple.appId and the key's app access in Users and Access), or the record was just created and is not listed yet (run it again in a minute)`; 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; } /** The editable version of one platform (iOS unless named: an app's macOS version has its own state). */ async function editableVersion(platform = 'IOS'): Promise { const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=${platform}&limit=50`); return vs.find(v => EDITABLE.has(v.attributes.appVersionState ?? v.attributes.appStoreState)) ?? null; } async function editableAppInfo(): Promise { const id = await app(); const infos = await all(`/v1/apps/${id}/appInfos`); const info = infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0]; if (!info) throw new Error(noAppInfo(id)); return info; } /** Reserve -> PUT the parts -> commit with the MD5 (uploadAssetVia). */ const uploadAsset = (type: string, rel: Record, file: string, attrs: Record = {}): Promise => uploadAssetVia({ asc, fetch: (u, i) => fetch(u, i) }, type, rel, file, attrs); /** * 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 }; } /** One upload part may take at most this long. */ export const PART_TIMEOUT_MS = 300_000; /** * Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots, app previews). `attrs` go into * the reservation (app previews: mimeType, previewFrameTimeCode). Parts go without `Expect: 100-continue` * (partHeaders) and anything but a 2xx is a failed part. A failed part never leads to a commit: the reservation * (AWAITING_UPLOAD) is deleted and the whole upload tried once more with a new one; a second failure throws. */ export async function uploadAssetVia(d: { asc: (method: string, path: string, body?: unknown) => Promise; fetch: (url: string, init: RequestInit) => Promise }, type: string, rel: Record, file: string, attrs: Record = {}): Promise { const buf = readFileSync(file); // basename, not split('/'): a Windows path has backslashes, and the store would get the whole path as the file name const name = basename(file); for (let attempt = 1; ; attempt++) { const res = (await d.asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length, ...attrs }, relationships: rel } })).data; let failed = ''; for (const op of (res.attributes.uploadOperations ?? []) as J[]) { try { const r = await d.fetch(op.url, { method: op.method, headers: partHeaders(op), body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(PART_TIMEOUT_MS) }); if (r.status < 200 || r.status > 299) failed = `part ${op.offset} -> ${r.status}`; } catch (e) { failed = `part ${op.offset} -> ${(e as Error).message}`; } if (failed) break; } if (!failed) { await d.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true, sourceFileChecksum: md5(buf) } } }); return res.id; } // never commit after a failed part: remove the stranded AWAITING_UPLOAD reservation, then one more try try { await d.asc('DELETE', `/v1/${type}/${res.id}`); } catch { /* the next sync or --repair-stuck removes it */ } if (attempt >= 2) throw new Error(`upload ${name} ${failed} (twice; the reservation was deleted, nothing committed)`); } }