storebound/cli/validate.ts
CRT ae58948abd storebound scan: offline rejection-risk scan of the project itself
Detects the stack (native iOS / Android, React Native, Expo, Capacitor, Cordova,
Flutter, Unity, Godot) and the SDKs from a maintained table with versions,
file:line and each vendor's disclosure page, then reports App Store and Google
Play rejection risks with guideline, evidence, fix, severity and skill: 2.1(a)
demo account and placeholder text, 5.1.1(v) account deletion, 4.8 login
services, 3.1.1 restore and outside payments, 3.1.2 subscription info, 5.1.2(i)
ATT, purpose strings, privacy link, privacy manifest, 2.3.10, 2.3.7, 2.3.8, 4.2
web wrappers, 2.5.1, 2.5.5, ATS, export compliance, 1.3 kids ads, review
prompts, privacy labels vs SDKs; Play target SDK 36, delete-account URL, App
access, foreground services, exact alarms, QUERY_ALL_PACKAGES, SMS / call log,
com.example package name.

Proposes Data safety / App Privacy / ATT / privacy manifest answers (detected
file:line or ask owner) as a diff against the config; --write adds only the
detected ones. scan.ignore records owner-accepted findings. MCP tool scan
(read-only), validate includes open findings, desktop Checks tab shows them.
Fixtures for iOS, Android, Capacitor, Unity, Flutter, clean and teaser.
2026-10-11 23:20:55 +08:00

208 lines
14 KiB
TypeScript

// 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 { ascClient, type J } from './apple/asc';
import { requiredDisplayTypes } from './apple/screenshots';
import { type Loaded, 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, PLAY_REQUIRED_IMAGES, type PlaySnapshot, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules';
import { rendersCheck } from './shots/check';
import { runScan } from './lib/scan';
import type { ScanFinding } from './lib/scan/rules';
import { playClient } from './play/client';
import { listOneTimeProducts } from './play/products';
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
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<AppleSnapshot> {
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');
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
const v: J = (await c.editableVersion()) ?? versions[0] ?? null;
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<string, number> = {};
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'] = [];
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
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<AppleSnapshot['subscriptions']> = [];
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 } : null,
build, locales, reviewDetail, ageRating,
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
requiredDisplayTypes: requiredDisplayTypes(config.apple),
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
availability: !!avail?.data,
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
};
}
export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName);
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<string, number> = {};
for (const type of Object.keys(PLAY_REQUIRED_IMAGES))
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 ?? '') }));
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<PlaySnapshot['subscriptions']> = [];
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 {
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,
};
} 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`,
}));
}
export async function run(ctx: Ctx): Promise<void> {
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)); }
} 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');
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;
}