// Store readiness report (read-only on both stores): what still blocks a submission, in the order to fix it. // storebound validate [--apple] [--play] [--json] [--src store/renders] [--no-local] [--no-health] [--no-scan] // Without --apple / --play: every store the config has. Exit code 1 when there is at least one blocker. // Reads only: GETs on App Store Connect, a Play edit that is opened, read and deleted (never committed), and plain GETs // of the public privacy policy and app-ads.txt URLs. Also checks the local renders folder (if there is one) with the // `shots check` rules: wrong pixel size, alpha, too few / many images, Play 8 MB and feature graphic format are // blockers (the upload would fail), text overflow flags from the last render are warnings. --no-local skips it. // Subscriptions (when the config has them): App Store states, localizations, group names, review screenshots; Play // subscriptions with an active base plan. App profile: delete-account URL answers (accounts), demo account locally and // on App Review (sign-in required), Terms of Use URL answers and is linked in the description (subscriptions). // Project scan (offline, `storebound scan`): rejection risks proven by the project's own files (sign-in without a demo // account, no account deletion, no Restore Purchases, ATT, purpose strings, privacy manifest, target SDK ...): open // blockers stay blockers, high and medium risks are warnings, owner-accepted ones (scan.ignore) are left out. --no-scan skips. // Live apps: crash / ANR vitals (Play Reporting API) and App Store diagnostics, as in `storebound health`: over a Play // bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips. import { templateBlockers } from './lib/template-answers'; import { gameServicesFindings, gameServicesStatus } from './games-status'; import { ascClient, EDITABLE as APPLE_EDITABLE, type J } from './apple/asc'; import { allVersions, type AscPlatform, parsePlatform, platformOf, stateOf, versionsPath } from './apple/platform'; import { capabilityReport } from './apple/signing'; import { firstProductReport } from './apple/first-products'; import { requiredDisplayTypes } from './apple/screenshots'; import { pendingScreenshots, setRows, type ShotRow, updateLedger } from './apple/screenshot-health'; import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config'; import { reviewNotes } from './lib/review-details'; import { reviewDemoAccount } from './lib/credentials'; import { allSubscriptions, basePlanId } from './lib/subscriptions'; import type { Ctx } from './lib/ctx'; import { checkUrl } from './lib/http'; import { collectHealth } from './health'; import { healthFindings } from './lib/health'; import { log, printJson, warn } from './lib/log'; import { type AppleSnapshot, appleRules, type Finding, formatFindings, type PlaySnapshot, playRequiredImages, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules'; import { rendersCheck } from './shots/check'; import { resolvedTablets } from './shots/report'; import { runScan } from './lib/scan'; import type { ScanFinding } from './lib/scan/rules'; import { EDIT_GONE, playClient } from './play/client'; import { listOneTimeProducts } from './play/products'; import { creativeNote } from './shots/creative'; import { creativeWaiting, localCreative } from './apple/creative-assets'; import { liveDeviceFamilies } from './apple/device-families'; import { type FamilyVerdict, familyVerdicts, projectFamilies } from './lib/device-families'; import { fixFor, GUIDELINE, hitLine } from './lib/cross-store'; import { listingFileMentions } from './lib/listing-mentions'; import { brokerConfig, brokerPlayInfo, enabledApisOf, grantsOf, type PlayNeed, playGaps, sdkConfigGaps } from './lib/broker'; import { playCredentials } from './lib/google-auth'; import { usesPlayGames } from './lib/game-services'; /** What the config's Play work needs from the service account: uploads and the listing always, products, * declarations and Play Games when the config has them. */ export function playNeedsOf(config: Loaded['config']): PlayNeed[] { const needs: PlayNeed[] = ['upload', 'listing']; if ((config.products ?? []).some(p => sellsOn(config, p, 'play')) || allSubscriptions(config.subscriptions).some(x => sellsOn(config, x.sub, 'play'))) needs.push('iap'); if (config.dataSafety) needs.push('declarations'); if (usesPlayGames(config)) needs.push('games'); return needs; } /** With a credential broker: the Play permissions and enabled APIs it lists for the package, the gaps for what the * config needs (blockers: the commands would get a 403), and SDK config drift. Nothing without a broker. */ export async function brokerFindings(loaded: Loaded): Promise { const { config, root } = loaded; const pkg = config.play?.packageName; if (!pkg) return []; let cfg; try { cfg = brokerConfig(root); } catch (e) { return [{ store: 'play', level: 'warn', code: 'broker-config', msg: (e as Error).message, fix: '~/.storebound/broker.json: { "url", "apiKeyFile" }' }]; } if (!cfg) return []; const r = await brokerPlayInfo(cfg, pkg); if (r.kind === 'not-registered') return [{ store: 'play', level: 'warn', code: 'broker-not-registered', msg: r.message, fix: 'register the package on the credential broker (google_play_set)' }]; if (r.kind !== 'ok') return [{ store: 'play', level: 'warn', code: 'broker-unavailable', msg: `${r.message}: permission checks skipped`, fix: 'check the credential broker url and API key (~/.storebound/broker.json)' }]; const out: Finding[] = []; const grants = grantsOf(r.info), apis = enabledApisOf(r.info); out.push({ store: 'play', level: 'ok', code: 'broker-access', msg: `credential broker: ${r.info.account ?? 'service account'} on ${pkg}: Play permissions ${grants ? (grants.join(', ') || 'none') : 'not listed'}; enabled APIs ${apis ? (apis.join(', ') || 'none') : 'not listed'}` }); for (const g of playGaps(r.info, playNeedsOf(config))) out.push({ store: 'play', level: 'blocker', code: g.kind === 'grant' ? 'broker-grant' : 'broker-api', msg: g.message, fix: g.fix, url: g.url }); for (const m of sdkConfigGaps(r.info, root)) out.push({ store: 'play', level: 'warn', code: 'sdk-config', msg: m }); return out; } /** Profile facts for the checks: config + local credentials + public URL checks (no store API). */ export async function collectProfile(loaded: Loaded): Promise { const { config, root } = loaded; return { profile: profileOf(config), accounts: !!config.accounts?.enabled, inAppDeletion: !!config.accounts?.inApp, deletionUrl: config.accounts?.enabled && config.accounts.deletionUrl ? await checkUrl(config.accounts.deletionUrl) : null, signInRequired: !!config.reviewAccess?.signInRequired, demoAccountLocal: !!reviewDemoAccount(root), subscriptions: !!allSubscriptions(config.subscriptions).length, termsUrl: config.app.termsUrl ? await checkUrl(config.app.termsUrl) : null, }; } /** Header and Search Results files uploaded but not approved yet (the slots take only approved assets). Never fails * validate: a local file problem or an unreadable library is a note for creative-assets itself. */ async function creativeWaitingOf(ctx: Ctx, c: Awaited>, appId: string): Promise { try { const { local } = localCreative(ctx); return local.size ? await creativeWaiting(c, appId, local) : []; } catch { return []; } } /** Live version's device families against the project and apple.ipad (cli/lib/device-families.ts). A failed read is a warning, never a blocker. */ async function deviceFamilyVerdicts(ctx: Ctx, c: Awaited>, appId: string): Promise { const { config, root } = ctx.loaded; try { return familyVerdicts({ live: await liveDeviceFamilies(c, appId), project: projectFamilies(root, config.apple!.bundleId, config.apple?.build?.project), ipad: config.apple?.ipad }); } catch (e) { return [{ level: 'warn', msg: `device families not compared: ${(e as Error).message.slice(0, 200)}` }]; } } export async function collectApple(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId); const appId = await c.app(); log('validate', 'apple: reading version, build, localizations'); // the platform: --platform, else the one with an editable version (several: iOS, and the others are named) const every = await allVersions(c, appId); const editableOf = (x: J) => APPLE_EDITABLE.has(stateOf(x)); const withEditable = [...new Set(every.filter(editableOf).map(platformOf))]; const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length || every.some(x => platformOf(x) === 'IOS') ? 'IOS' : platformOf(every[0])); const versions = await c.all(versionsPath(appId, platform, 10)); const v: J = (await c.editableVersion(platform)) ?? versions[0] ?? null; const otherPlatformVersions = every.filter(x => platformOf(x) !== platform && editableOf(x)).map(x => ({ versionString: String(x.attributes.versionString), state: stateOf(x), platform: platformOf(x) })); let build: AppleSnapshot['build'] = null; const locales: AppleSnapshot['locales'] = []; let reviewDetail: AppleSnapshot['reviewDetail'] = null; const shotRows: ShotRow[] = []; if (v) { const b = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data; if (b) { const decl = (await c.maybe(`/v1/builds/${b.id}/appEncryptionDeclaration`))?.data; build = { number: b.attributes.version, processingState: b.attributes.processingState, usesNonExemptEncryption: b.attributes.usesNonExemptEncryption ?? null, declarationState: decl?.attributes?.appEncryptionDeclarationState ?? null }; } const info = await c.editableAppInfo(); const infoLocs = new Map((await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=50`)).map((l: J) => [l.attributes.locale, l.attributes] as const)); for (const l of await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`)) { const sets = await c.all(`/v1/appStoreVersionLocalizations/${l.id}/appScreenshotSets?limit=50`); const screenshots: Record = {}; for (const s of sets) { const shots = await c.all(`/v1/appScreenshotSets/${s.id}/appScreenshots?limit=50`); screenshots[s.attributes.screenshotDisplayType] = shots.length; shotRows.push(...setRows(shots, l.attributes.locale, s.attributes.screenshotDisplayType)); } const ia = infoLocs.get(l.attributes.locale) ?? {}; locales.push({ locale: l.attributes.locale, name: ia.name, subtitle: ia.subtitle, description: l.attributes.description, keywords: l.attributes.keywords, promotionalText: l.attributes.promotionalText ?? undefined, whatsNew: l.attributes.whatsNew ?? undefined, screenshots }); } reviewDetail = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null; } log('validate', 'apple: age rating, price, availability, in-app purchases'); const info = await c.editableAppInfo(); const ageRating = (await c.maybe(`/v1/appInfos/${info.id}/ageRatingDeclaration`))?.data?.attributes ?? null; const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`); const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`); const iaps: AppleSnapshot['iaps'] = []; const iapList = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`); const configIds = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)]; const firstProductGaps = configIds.length ? await firstProductReport(c, appId, { iaps: iapList, only: configIds, versionState: v ? (v.attributes.appVersionState ?? v.attributes.appStoreState) : undefined }) : []; for (const p of iapList) { const shot = (await c.maybe(`/v2/inAppPurchases/${p.id}/appStoreReviewScreenshot`))?.data; iaps.push({ productId: p.attributes.productId, state: p.attributes.state, reviewScreenshot: !!shot }); } const wantSubs = allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')); const subscriptions: NonNullable = []; if (wantSubs.length) { log('validate', 'apple: subscriptions'); for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) { const groupLocales = (await c.all(`/v1/subscriptionGroups/${g.id}/subscriptionGroupLocalizations?limit=200`)).map((l: J) => ({ locale: l.attributes.locale, state: l.attributes.state })); for (const sub of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) { const locales = (await c.all(`/v1/subscriptions/${sub.id}/subscriptionLocalizations?limit=200`)).map((l: J) => ({ locale: l.attributes.locale, state: l.attributes.state })); const shot = !!(await c.maybe(`/v1/subscriptions/${sub.id}/appStoreReviewScreenshot`))?.data; subscriptions.push({ productId: sub.attributes.productId, group: g.attributes.referenceName, state: sub.attributes.state, locales, groupLocales, reviewScreenshot: shot }); } } } const appleLocale = new Map(config.languages.map(l => [l.id, l.apple] as const)); return { subscriptions, expectedSubscriptions: wantSubs.map(({ group, sub }) => ({ id: sub.id, group: group.name, locales: Object.keys(sub.listings).map(id => appleLocale.get(id)).filter((x): x is string => !!x) })), profile: await collectProfile(ctx.loaded), version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState, copyright: v.attributes.copyright ?? null, platform } : null, otherPlatformVersions, creativeWaiting: await creativeWaitingOf(ctx, c, appId), screenshotsPending: pendingScreenshots(shotRows, updateLedger(root, shotRows), Date.now()), configCopyright: config.app.copyright, deviceFamilies: platform === 'IOS' ? await deviceFamilyVerdicts(ctx, c, appId) : undefined, build, locales, reviewDetail, ageRating, expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x), requiredDisplayTypes: requiredDisplayTypes(config.apple, platform), iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play, macos: platform === 'MAC_OS' ? { notes: reviewNotes(config, 'macos'), gameCenter: config.apple?.macos?.gameCenter, noIap: config.apple?.macos?.noIap } : undefined, macVersion: every.some(x => platformOf(x) === 'MAC_OS'), mac: !!config.apple?.mac, priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'), availability: !!avail?.data, firstProductGaps, iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), privacyUrl: await checkUrl(config.app.privacyPolicyUrl), capabilityMismatches: await capabilityReport(c, config, root).then(r => r.mismatches.map(m => ({ msg: m.msg, fix: m.fix })), e => [{ msg: `capabilities not compared: ${(e as Error).message.slice(0, 200)}`, fix: 'storebound apple signing status' }]), }; } export async function collectPlay(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const play = await playClient(root, config.play!.packageName); const tablets = resolvedTablets(config, ctx.loaded.resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders')); // play.read: one edit under the machine lock, deleted only after every read inside has finished, and opened anew once // when Play invalidates it meanwhile ("This Edit has been deleted"). A bare POST /edits here was neither locked nor // retried, so another storebound process (or the Console) ending the edit failed the screenshot reads with a false blocker. return play.read(async editId => { log('validate', `play: read-only edit ${editId} (deleted afterwards)`); const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${editId}/details`); const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${editId}/listings`)).listings ?? []; const listings: PlaySnapshot['listings'] = []; for (const l of raw) { const images: Record = {}; for (const type of Object.keys(playRequiredImages(tablets))) images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${editId}/listings/${l.language}/${type}`)).images ?? []).length; listings.push({ ...l, images }); } const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${editId}/tracks`)).tracks ?? []) .map(t => ({ track: t.track, releases: (t.releases ?? []).map(r => ({ status: r.status, versionCodes: r.versionCodes ?? [], userFraction: r.userFraction })) })); const products = (await listOneTimeProducts(play)).map(p => ({ productId: p.productId, states: (p.purchaseOptions ?? []).map(o => o.state ?? '') })); // the production track's countries (read-only in the API: set in the console, `console plan play-production-countries`) const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${editId}/countryAvailability/production`) .then(r => (r.restOfWorld ? -1 : (r.countries ?? []).length)).catch(() => null); const website = details.contactWebsite ?? null; const appAdsLines = config.ads?.appAdsTxt ?? []; const adsLikely = !!config.ads || Object.values(config.dataSafety?.data ?? {}).some(u => u.collect.includes('advertising')); let appAds = null; if (website && (appAdsLines.length || adsLikely)) { try { appAds = await checkUrl(new URL('/app-ads.txt', website).href, { body: true }); } catch { appAds = { url: website, status: null, error: 'bad website URL' }; } } const wantSubs = allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'play')); const subscriptions: NonNullable = []; if (wantSubs.length) { let page = ''; do { const r = await play.call<{ subscriptions?: { productId: string; basePlans?: { basePlanId: string; state?: string }[] }[]; nextPageToken?: string }>('GET', `${play.API}/subscriptions?pageSize=100${page ? '&pageToken=' + page : ''}`); for (const x of r.subscriptions ?? []) subscriptions.push({ productId: x.productId, basePlans: (x.basePlans ?? []).map(b => ({ basePlanId: b.basePlanId, state: b.state })) }); page = r.nextPageToken ?? ''; } while (page); } return { tablets, subscriptions, expectedSubscriptions: wantSubs.map(({ sub }) => ({ id: sub.id, basePlanId: basePlanId(sub) })), profile: await collectProfile(ctx.loaded), defaultLanguage: details.defaultLanguage ?? config.play?.defaultLanguage ?? 'en-US', expectedLanguages: config.languages.flatMap(playCodes), listings, tracks, products, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'play')).map(p => p.id), privacyUrl: await checkUrl(config.app.privacyPolicyUrl), website, appAds, appAdsLines, adsLikely, category: playCategoryOf(config), productionCountries, }; }); } /** Scan findings as validate findings: blocker stays blocker, high / medium become warnings; low and owner-accepted * ones are left out (they are in the full `storebound scan` report). */ export function scanAsFindings(f: ScanFinding[], stores: { apple: boolean; play: boolean }): Finding[] { return f.filter(x => !x.accepted && x.severity !== 'low' && stores[x.store]).map(x => ({ store: x.store, level: x.severity === 'blocker' ? 'blocker' as const : 'warn' as const, code: `scan-${x.rule}`, msg: `${x.guideline}: ${x.title}${x.evidence[0] ? ` (${x.evidence[0].file}:${x.evidence[0].line})` : ''}`, fix: `${x.fix} ยท ${x.skill} skill`, ...(x.action ? { action: x.action } : {}), })); } export async function run(ctx: Ctx): Promise { const { config } = ctx.loaded; const a = ctx.args; const both = !a.has('apple') && !a.has('play'); const findings: Finding[] = []; const dir = ctx.loaded.resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); const local = a.has('no-local') ? null : rendersCheck(config, dir); if (local) log('validate', `local renders: ${dir}`); if ((both || a.has('apple')) && config.apple) { try { findings.push(...appleRules({ ...(await collectApple(ctx)), renders: local?.apple })); } catch (e) { findings.push({ store: 'apple', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('apple', local?.apple)); } const creative = creativeNote(config, dir); if (creative) findings.push({ store: 'apple', level: 'ok', code: 'creative-assets', msg: creative, fix: 'storebound apple creative-assets --dry' }); } else if (a.has('apple')) warn('validate', 'no apple section in the config'); if ((both || a.has('play')) && config.play) { // the broker's grants first: a missing permission is named before the edit reads hit the same 403 const access = await brokerFindings(ctx.loaded).catch(e => [{ store: 'play' as const, level: 'warn' as const, code: 'broker-unavailable', msg: (e as Error).message }]); findings.push(...access); try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); } catch (e) { // a Play edit that vanished under the reads is a tool problem (the retry inside play.read failed too), never a store blocker const gone = EDIT_GONE.test((e as Error).message); findings.push(gone ? { store: 'play', level: 'warn', code: 'edit-gone', msg: `Play validate could not finish its read-only edit (${(e as Error).message.slice(0, 300)}): a tool problem, not a store finding`, fix: 'storebound validate --play (run it again; close other storebound runs and Console saves first)' } : { store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play)); } } else if (a.has('play')) warn('validate', 'no play section in the config'); // Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config if (config.games || config.apple) { try { findings.push(...gameServicesFindings(await gameServicesStatus(ctx.loaded, { apple: both || a.has('apple'), play: both || a.has('play') }))); } catch (e) { warn('validate', `game services: ${(e as Error).message}`); } } // other platforms named in a store's listing text (App Store 2.3.10, Play Metadata): blockers (pitfall 77) try { for (const h of listingFileMentions(ctx.loaded, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play })) findings.push({ store: h.store, level: 'blocker', code: 'other-platforms', msg: `${GUIDELINE[h.store]}: ${hitLine(h)}`, fix: fixFor(h.store, h.field) }); } catch (e) { warn('validate', `platform mentions: ${(e as Error).message}`); } // template compliance answers: the scan reports them; without the scan, here if (a.has('no-scan')) for (const msg of templateBlockers(config)) findings.push({ store: config.apple ? 'apple' : 'play', level: 'blocker', code: 'template-answers', msg }); if (!a.has('no-scan')) { log('validate', 'project scan (offline): rejection risks in the project files'); try { const { report } = await runScan(ctx.loaded.root); findings.push(...sortFindings(scanAsFindings(report.findings, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play }))); } catch (e) { warn('validate', `project scan failed: ${(e as Error).message}`); } } if (!a.has('no-health') && (config.apple || config.play)) { log('validate', 'vitals and diagnostics (last 7 days)'); const h = await collectHealth(ctx, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play, days: 7 }); findings.push(...sortFindings(healthFindings(h, { unreadable: 'ok' }))); } const blockers = findings.filter(f => f.level === 'blocker').length; // where Play tokens come from (MCP validate reads it): "broker" or "file", never a value const credentials = config.play ? { play: { source: playCredentials(ctx.loaded.root).source } } : {}; if (a.has('json')) printJson({ blockers, findings, credentials }); else { console.log(formatFindings(findings)); log('validate', blockers ? `${blockers} blocker(s)` : 'no blockers (UI-only steps are not visible to the API: App Privacy, Play App content)'); } if (blockers) process.exitCode = 1; }