198 lines
14 KiB
TypeScript
198 lines
14 KiB
TypeScript
// Store-side app lists and account health, for `storebound apps list | check` and the desktop's "import from the
|
|
// stores". Pure over an injected HTTP getter, so tests run on recorded responses. Read-only: GET requests only, plus
|
|
// (Play icons, opt-in) an edit that is opened and deleted without a commit.
|
|
//
|
|
// App Store Connect: GET /v1/apps (paged), the newest appStoreVersions per app (platform, version, state) and, with
|
|
// icons, the newest build's iconAssetToken (an image template on Apple's CDN, fetched here and returned as a data URL:
|
|
// the desktop window loads no remote content).
|
|
// https://developer.apple.com/documentation/appstoreconnectapi/list_apps
|
|
// https://developer.apple.com/documentation/appstoreconnectapi/build (attribute iconAssetToken)
|
|
// Google Play: the Play Developer Reporting API `apps:search` lists every app the service account can see, across all
|
|
// developer accounts it was invited to.
|
|
// https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/apps/search
|
|
import type { AccountCheck, StoreApp } from './desktop-protocol';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type J = any;
|
|
|
|
/** GET a JSON path (ASC: relative to the API root, or a full `links.next` URL). Throws Error("... -> <status> <body>"). */
|
|
export type Get = (path: string) => Promise<J>;
|
|
/** Bytes of an image URL (icons). */
|
|
export type Bytes = (url: string) => Promise<{ type: string; data: Uint8Array } | null>;
|
|
|
|
/** Largest icon kept as a data URL; bigger ones are dropped (the window shows a monogram instead). */
|
|
export const MAX_ICON_BYTES = 200_000;
|
|
|
|
export const dataUrl = (type: string, data: Uint8Array): string => `data:${type};base64,${Buffer.from(data).toString('base64')}`;
|
|
|
|
/** ASC iconAssetToken.templateUrl -> a 128 px PNG URL. */
|
|
export const iconUrl = (templateUrl: string, px = 128): string =>
|
|
templateUrl.replace('{w}', String(px)).replace('{h}', String(px)).replace('{c}', 'bb').replace('{f}', 'png');
|
|
|
|
const ASC_STATE = (v: J): string => v?.attributes?.appVersionState ?? v?.attributes?.appStoreState ?? '';
|
|
|
|
/** Every App Store Connect app the key's team can see, with platforms, newest version and (opt-in) icon. */
|
|
export async function listAppleApps(get: Get, o: { icons?: boolean; bytes?: Bytes; limit?: number } = {}): Promise<{ apps: StoreApp[]; warnings: string[] }> {
|
|
const warnings: string[] = [];
|
|
const raw: J[] = [];
|
|
let url: string | null = '/v1/apps?fields[apps]=name,bundleId,sku,primaryLocale&limit=200';
|
|
while (url && raw.length < (o.limit ?? 1000)) {
|
|
const page: J = await get(url);
|
|
raw.push(...(page.data ?? []));
|
|
url = page.links?.next ?? null;
|
|
}
|
|
const one = async (a: J): Promise<StoreApp> => {
|
|
const app: StoreApp = {
|
|
store: 'ios', id: String(a.id), bundleId: String(a.attributes?.bundleId ?? ''), name: String(a.attributes?.name ?? a.attributes?.bundleId ?? a.id),
|
|
platforms: [], sku: a.attributes?.sku ?? null, primaryLocale: a.attributes?.primaryLocale ?? null, latest: null, icon: null,
|
|
};
|
|
try {
|
|
// App Store Connect groups this list by platform (not by date), so a short page can hide a platform and its
|
|
// first item is not the newest version: read them all (small, fields only) and sort by createdDate.
|
|
const vs: J[] = (await get(`/v1/apps/${a.id}/appStoreVersions?limit=200&fields[appStoreVersions]=platform,versionString,appStoreState,appVersionState,createdDate`)).data ?? [];
|
|
app.platforms = [...new Set(vs.map(v => String(v.attributes?.platform ?? '')).filter(Boolean))];
|
|
app.latest = latestVersion(vs);
|
|
} catch (e) { warns.set(a, [...(warns.get(a) ?? []), `${app.bundleId}: versions not read (${short(e)})`]); }
|
|
if (o.icons && o.bytes) {
|
|
try {
|
|
const b: J = (await get(`/v1/builds?filter[app]=${a.id}&sort=-uploadedDate&limit=1&fields[builds]=iconAssetToken,version`)).data?.[0];
|
|
const tpl = b?.attributes?.iconAssetToken?.templateUrl;
|
|
if (typeof tpl === 'string' && /^https:\/\/[^/]+\.mzstatic\.com\//.test(tpl)) {
|
|
const img = await o.bytes(iconUrl(tpl));
|
|
if (img && img.data.length <= MAX_ICON_BYTES) app.icon = dataUrl(img.type, img.data);
|
|
}
|
|
} catch (e) { warns.set(a, [...(warns.get(a) ?? []), `${app.bundleId}: icon not read (${short(e)})`]); }
|
|
}
|
|
return app;
|
|
};
|
|
const warns = new Map<J, string[]>();
|
|
const apps = await pool(raw, APPLE_CONCURRENCY, one);
|
|
for (const a of raw) warnings.push(...(warns.get(a) ?? []));
|
|
return { apps: apps.sort((x, y) => x.name.localeCompare(y.name)), warnings };
|
|
}
|
|
|
|
/** Per-app App Store Connect reads in flight at once (a 50-app team took a minute one by one). */
|
|
export const APPLE_CONCURRENCY = 4;
|
|
|
|
/** fn over items, `n` at a time, results in input order. */
|
|
export async function pool<T, R>(items: T[], n: number, fn: (x: T) => Promise<R>): Promise<R[]> {
|
|
const out: R[] = new Array(items.length);
|
|
let next = 0;
|
|
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]!); } }));
|
|
return out;
|
|
}
|
|
|
|
/** Newest App Store version by createdDate; the iOS one when the app has iOS versions (the store-side twin of an
|
|
* Android app), else the newest of any platform. */
|
|
export function latestVersion(vs: J[]): StoreApp['latest'] {
|
|
const sorted = [...vs].sort((x, y) => String(y.attributes?.createdDate ?? '').localeCompare(String(x.attributes?.createdDate ?? '')));
|
|
const v = sorted.find(x => x.attributes?.platform === 'IOS') ?? sorted[0];
|
|
return v ? { version: String(v.attributes?.versionString ?? ''), state: ASC_STATE(v), platform: v.attributes?.platform ?? null } : null;
|
|
}
|
|
|
|
/** Every app a Play service account can see (all developer accounts that invited it), from the Reporting API. */
|
|
export async function listPlayApps(get: Get, o: { icon?: (pkg: string) => Promise<string | null> } = {}): Promise<{ apps: StoreApp[]; warnings: string[] }> {
|
|
const warnings: string[] = [];
|
|
const raw: J[] = [];
|
|
let token = '';
|
|
for (let i = 0; i < 50; i++) {
|
|
const page: J = await get(`/apps:search?pageSize=1000${token ? `&pageToken=${encodeURIComponent(token)}` : ''}`);
|
|
raw.push(...(page.apps ?? []));
|
|
token = page.nextPageToken ?? '';
|
|
if (!token) break;
|
|
}
|
|
const apps: StoreApp[] = [];
|
|
for (const a of raw) {
|
|
const pkg = String(a.packageName ?? String(a.name ?? '').replace(/^apps\//, ''));
|
|
if (!pkg) continue;
|
|
const app: StoreApp = { store: 'android', id: pkg, bundleId: pkg, name: String(a.displayName || pkg), platforms: ['ANDROID'], sku: null, primaryLocale: null, latest: null, icon: null };
|
|
if (o.icon) {
|
|
try { app.icon = await o.icon(pkg); } catch (e) { warnings.push(`${pkg}: icon not read (${short(e)})`); }
|
|
}
|
|
apps.push(app);
|
|
}
|
|
return { apps: apps.sort((x, y) => x.name.localeCompare(y.name)), warnings };
|
|
}
|
|
|
|
/** One readable line of an error: the API's own message (Google error.message, App Store Connect errors[].detail)
|
|
* after the "GET ... -> 403" head when the body is JSON, else the first line. */
|
|
const short = (e: unknown): string => {
|
|
const m = String((e as Error)?.message ?? e);
|
|
const api = /^(.*?-> \d{3}) .*?"(?:detail|message)":"((?:[^"\\]|\\.)*)"/.exec(m);
|
|
return (api ? `${api[1]} ${api[2]!.replace(/\\(.)/g, '$1')}` : m.split('\n')[0]!).slice(0, 200);
|
|
};
|
|
const statusOf = (e: unknown): number | null => { const m = /-> (\d{3})\b/.exec(String((e as Error)?.message ?? e)); return m ? Number(m[1]) : null; };
|
|
|
|
export const ASC_KEYS_URL = 'https://appstoreconnect.apple.com/access/integrations/api';
|
|
export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users-and-permissions';
|
|
export const REPORTING_API_URL = 'https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com';
|
|
|
|
/** App Store Connect key health from a GET /v1/apps?limit=1 outcome. */
|
|
export function appleCheck(outcome: { apps: number } | { error: unknown }): AccountCheck {
|
|
if ('apps' in outcome) {
|
|
return outcome.apps > 0
|
|
? { store: 'ios', status: 'ok', principal: null, apps: outcome.apps, message: 'The key works and can read the team\'s apps.', fix: null, docs: null }
|
|
: { store: 'ios', status: 'no-apps', principal: null, apps: 0, message: 'The key works, but the team has no apps it can see.', fix: 'Create the app record in App Store Connect (Apps > + > New App), or check that this is the team you meant.', docs: null };
|
|
}
|
|
const code = statusOf(outcome.error), msg = short(outcome.error);
|
|
if (/not set|not found|PRIVATE KEY|asn1|decoder|key/i.test(msg) && code === null)
|
|
return { store: 'ios', status: 'error', principal: null, apps: null, message: `The key could not be used: ${msg}`, fix: 'Add the key again: key id, issuer id and the AuthKey_<id>.p8 file from App Store Connect.', docs: ASC_KEYS_URL };
|
|
if (code === 401)
|
|
return { store: 'ios', status: 'revoked', principal: null, apps: null, message: 'App Store Connect refused the key (401): it was revoked, or the key id / issuer id do not belong to this .p8.',
|
|
fix: 'Users and Access > Integrations > App Store Connect API: check the key is still active, or generate a new one (role App Manager or Admin) and add it here again.', docs: ASC_KEYS_URL };
|
|
if (code === 403)
|
|
return { store: 'ios', status: 'permission', principal: null, apps: null, message: 'The key works but its role may not read apps (403).',
|
|
fix: 'Use a team key with the App Manager or Admin role (Users and Access > Integrations > App Store Connect API), or ask the Account Holder for one.', docs: ASC_KEYS_URL };
|
|
return { store: 'ios', status: 'error', principal: null, apps: null, message: `Could not reach App Store Connect: ${msg}`, fix: 'Check the connection and try again.', docs: null };
|
|
}
|
|
|
|
/**
|
|
* Where to switch the Play Developer Reporting API on for the caller's Cloud project: the project from Google's 403
|
|
* ("has not been used in project 123…", consumer "projects/123…", activationUrl "?project=…"), else from the service
|
|
* account e-mail (<name>@<project-id>.iam.gserviceaccount.com). Pure.
|
|
*/
|
|
export function reportingEnableUrl(errorText: string, principal: string | null): { project: string | null; url: string } {
|
|
const project = /[?&]project=([a-z0-9-]+)/i.exec(errorText)?.[1] ?? /projects\/([a-z0-9-]+)/i.exec(errorText)?.[1] ?? /in project ([a-z0-9-]+)/i.exec(errorText)?.[1]
|
|
?? (principal ? /@([a-z][a-z0-9-]{4,61}[a-z0-9])\.iam\.gserviceaccount\.com$/.exec(principal)?.[1] : undefined) ?? null;
|
|
return { project, url: `${REPORTING_API_URL}${project ? `?project=${project}` : ''}` };
|
|
}
|
|
|
|
/** Is this error Google's "API not enabled in the Cloud project" 403 (SERVICE_DISABLED)? */
|
|
export const isServiceDisabled = (e: unknown): boolean => {
|
|
const full = String((e as Error)?.message ?? e);
|
|
return statusOf(e) === 403 && /SERVICE_DISABLED|has not been used|is disabled|accessNotConfigured/i.test(full);
|
|
};
|
|
|
|
/** Play apps known on this machine without the Reporting API: the package names of the app folders' configs and the
|
|
* store-only Play entries of ~/.storebound/projects.json. Unverified on the store (marked in `sku`). Pure. */
|
|
export function localPlayApps(known: { packageName: string; name: string; source: string }[]): StoreApp[] {
|
|
const out: StoreApp[] = [];
|
|
for (const k of known) if (k.packageName && !out.some(a => a.id === k.packageName))
|
|
out.push({ store: 'android', id: k.packageName, bundleId: k.packageName, name: k.name || k.packageName, platforms: ['ANDROID'], sku: null, primaryLocale: null, latest: null, icon: null });
|
|
return out.sort((x, y) => x.name.localeCompare(y.name));
|
|
}
|
|
|
|
/** Play service account health from the token exchange and an `apps:search` outcome. */
|
|
export function playCheck(principal: string | null, outcome: { apps: number } | { error: unknown }): AccountCheck {
|
|
const who = principal ?? 'the service account';
|
|
const invite = `Play Console > Users and permissions > Invite new users: add ${who}. Account permissions: "View app information and download bulk reports (read-only)"; per app, as needed: "Manage store presence", "Manage testing tracks", "Release to production, exclude devices and use Play App Signing" (rollouts), "Reply to reviews", "Manage orders and subscriptions". One service account can be invited to several developer accounts.`;
|
|
if ('apps' in outcome) {
|
|
return outcome.apps > 0
|
|
? { store: 'android', status: 'ok', principal, apps: outcome.apps, message: `The service account can see ${outcome.apps} app${outcome.apps === 1 ? '' : 's'}.`, fix: null, docs: null }
|
|
: { store: 'android', status: 'permission', principal, apps: 0, message: 'The key works, but no developer account has invited this service account (it sees no apps).', fix: invite, docs: PLAY_USERS_URL };
|
|
}
|
|
const code = statusOf(outcome.error), msg = short(outcome.error), full = String((outcome.error as Error)?.message ?? outcome.error);
|
|
if (code === 403 && /SERVICE_DISABLED|has not been used|is disabled|accessNotConfigured/i.test(full)) {
|
|
const on = reportingEnableUrl(full, principal);
|
|
return { store: 'android', status: 'api-disabled', principal, apps: null, message: `The Google Play Developer Reporting API is off in this service account's Google Cloud project${on.project ? ` (${on.project})` : ''}.`,
|
|
fix: `Enable "Google Play Developer Reporting API" for that project: ${on.url} (press Enable; also "Google Play Android Developer API" if it is off), wait a few minutes, test again.`, docs: on.url };
|
|
}
|
|
if (code === null && /invalid_grant|Invalid JWT|account not found|disabled/i.test(full))
|
|
return { store: 'android', status: 'revoked', principal, apps: null, message: `Google refused the key: ${msg}`,
|
|
fix: 'The JSON key was deleted or the service account disabled. Google Cloud console > IAM & Admin > Service accounts > Keys: add a new JSON key, then add it here again.', docs: null };
|
|
if (code === 403 || code === 401)
|
|
return { store: 'android', status: 'permission', principal, apps: null, message: `The service account is missing a permission (${code}).`, fix: invite, docs: PLAY_USERS_URL };
|
|
if (/not a Google service account|PLAY_SERVICE_ACCOUNT/i.test(full))
|
|
return { store: 'android', status: 'error', principal, apps: null, message: msg, fix: 'Add the service account JSON key again (Google Cloud console > IAM & Admin > Service accounts > Keys > Add key > JSON).', docs: null };
|
|
return { store: 'android', status: 'error', principal, apps: null, message: `Could not reach Google: ${msg}`, fix: 'Check the connection and try again.', docs: null };
|
|
}
|