// 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 { 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 { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config'; 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 { playClient } from './play/client'; import { listOneTimeProducts } from './play/products'; import { creativeNote } from './shots/creative'; /** 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, }; } 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 ? '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; 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) screenshots[s.attributes.screenshotDisplayType] = (await c.all(`/v1/appScreenshotSets/${s.id}/appScreenshots?limit=50`)).length; 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, 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, configCopyright: config.app.copyright, build, locales, reviewDetail, ageRating, expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x), requiredDisplayTypes: requiredDisplayTypes(config.apple), iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play, 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')); const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {}); log('validate', `play: read-only edit ${e.id} (deleted afterwards)`); try { const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${e.id}/details`); const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${e.id}/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/${e.id}/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/${e.id}/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/${e.id}/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, }; } finally { await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {}); } } /** 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) { try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); } catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); } } else if (a.has('play')) warn('validate', 'no play section in the config'); // 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; if (a.has('json')) printJson({ blockers, findings }); 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; }