storebound/cli/apple/asc.ts

193 lines
12 KiB
TypeScript

// 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 { AmbiguousWriteError, retrySafe } from '../lib/upsert';
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<ReturnType<typeof ascClient>>;
/** 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<J> {
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 Bun.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;
}
const t = await r.text();
if ((r.status === 429 || (r.status >= 500 && safe)) && attempt < 5) { await Bun.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<J> {
for (let attempt = 1; ; attempt++) {
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
const t = await r.text();
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await Bun.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<Uint8Array | null> {
for (let attempt = 1; ; attempt++) {
const r = await fetch('https://api.appstoreconnect.apple.com' + path, { headers: { Authorization: 'Bearer ' + jwt(), Accept: accept } });
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await r.body?.cancel(); await Bun.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<J[]> {
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<J | null> {
try { return await asc('GET', path); } catch (e) { if (/-> 404/.test(String(e))) return null; throw e; }
}
let appId = appIdHint ?? '';
async function app(): Promise<string> {
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<J | null> {
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<J> {
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<string, unknown>, file: string, attrs: Record<string, unknown> = {}): Promise<string> {
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<string, string> = {};
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<string, unknown>; 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 };
}