storebound/cli/lib/validate-rules.ts

469 lines
36 KiB
TypeScript

// Store readiness rules for `storebound validate`. Pure: the command collects a snapshot from both APIs (read-only),
// these functions turn it into an ordered list of findings. Order = the order you would fix things in.
import { consoleLink, consoleUrl } from './console-links';
import { describePending, type Pending, repairable } from '../apple/screenshot-health';
import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions';
import { iphoneDuoNote, macNote } from '../shots/spec';
export type Level = 'blocker' | 'warn' | 'ok';
/** Screenshot sets App Store Connect takes instead of a required one (App Store Connect Help, "Screenshot
* specifications", checked 2026-10-13): iPhone needs the 6.9" set or, without it, the 6.5" set; iPad needs the 13" set
* (either 13" display type). The smaller sizes are scaled down from these. A live app with only the 6.5" set is fine
* (store-pitfalls 44). */
export const ACCEPTED_INSTEAD: Record<string, string[]> = {
// the console's required iPhone class (medium display): smaller sizes scale down from a larger set automatically
APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'],
APP_IPHONE_67: ['APP_IPHONE_65'],
APP_IPHONE_65: ['APP_IPHONE_67'],
APP_IPAD_PRO_3GEN_129: ['APP_IPAD_PRO_129'],
APP_IPAD_PRO_129: ['APP_IPAD_PRO_3GEN_129'],
};
/** Required class -> larger sets App Store Connect scales down into it ("View All Sizes in Media Manager"). */
const SCALES_DOWN: Record<string, string[]> = { APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'] };
const LABEL: Record<string, string> = { APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPAD_PRO_3GEN_129: 'iPad 13" display' };
export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string;
/** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */
url?: string;
/** The desktop's Fix button: a write fully determined by an existing command (lib/fix-actions.ts). */
action?: FixAction }
export interface UrlCheck { url: string; status: number | null; error?: string; body?: string }
/** One problem `shots check` found in the local renders folder (cli/shots/spec.ts rules), tagged with its file / set. */
export interface LocalIssue { where: string; level: 'error' | 'warn'; code: string; msg: string }
/** Local renders checked before upload: how many images were looked at and what is wrong with them. */
export interface RendersCheck { dir: string; images: number; issues: LocalIssue[] }
const RENDER_FIX: Record<string, string> = {
size: 'storebound shots render (writes the exact store sizes), or re-export the image at a listed size',
alpha: 'storebound shots render writes 24-bit RGB; for your own images flatten to RGB (ffmpeg -i in.png -pix_fmt rgb24 out.png)',
format: 'JPEG or 24-bit PNG: storebound shots jpeg, or ffmpeg -pix_fmt rgb24',
bytes: 'storebound shots jpeg (q92 JPEG copies are about 4x smaller)',
count: 'add or remove scenes (shots.scenes), then storebound shots render',
overflow: 'shorten the copy in shots.copy, then storebound shots render and look at the contact sheet',
video: 'storebound shots video',
};
/** Local renders -> findings: store errors are blockers (the upload would fail or the store would reject the set),
* text overflow flags and other warnings stay warnings. */
export function rendersFindings(store: 'apple' | 'play', r: RendersCheck | undefined): Finding[] {
if (!r) return [];
const f: Finding[] = r.issues.map(i => ({ store, level: i.level === 'error' ? 'blocker' : 'warn', code: `renders-${i.code}`,
msg: `${i.where}: ${i.msg}`, fix: RENDER_FIX[i.code] }));
if (!f.length && r.images) f.push({ store, level: 'ok', code: 'renders', msg: `${r.images} local store images pass the size / alpha / count checks (${r.dir})` });
return f;
}
export interface AppleSnapshot {
version: { versionString: string; state: string; copyright?: string | null; platform?: string } | null;
/** Header and Search Results files uploaded but not approved yet, per locale and placement (apple creative-assets). */
creativeWaiting?: { locale: string; placement: string; file: string; text: string }[];
/** Screenshots of the version that are not COMPLETE (cli/apple/screenshot-health.ts): a submit answers 409
* ASSET_IN_POST_PROCESSING while any is left. */
screenshotsPending?: Pending[];
/** Live version's device families vs the Xcode project and apple.ipad (cli/lib/device-families.ts familyVerdicts); undefined: not iOS. */
deviceFamilies?: { level: 'ok' | 'blocker' | 'warn'; msg: string; fix?: string }[];
/** Editable or rejected versions of the app's other platforms (an app's macOS version has its own state). */
otherPlatformVersions?: { versionString: string; state: string; platform: string }[];
/** app.copyright from the config (undefined: not set). */
configCopyright?: string;
build: { number: string; processingState: string; usesNonExemptEncryption: boolean | null; declarationState: string | null } | null;
/** Locales the config expects (apple: entries of `languages`). */
expectedLocales: string[];
locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; screenshots: Record<string, number> }[];
requiredDisplayTypes: string[];
/** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */
iphoneDuo?: boolean;
/** The app has a macOS App Store version (any state), and apple.mac (Mac screenshots on), for the Mac note. */
macVersion?: boolean;
mac?: boolean;
play?: boolean;
today?: Date;
reviewDetail: { contactEmail?: string | null; contactPhone?: string | null; contactFirstName?: string | null; contactLastName?: string | null; demoAccountRequired?: boolean; demoAccountName?: string | null; notes?: string | null } | null;
/** Age rating declaration attributes (null = not readable). */
ageRating: Record<string, unknown> | null;
priceSchedule: boolean;
availability: boolean;
iaps: { productId: string; state: string; reviewScreenshot: boolean }[];
expectedProducts: string[];
/** First in-app purchases / subscriptions that never passed review and are not in the app's review submission
* (firstProductGaps, cli/lib/review-submit.ts; pitfall 70). */
firstProductGaps?: { level: 'error' | 'warn'; msg: string; fix: string }[];
privacyUrl: UrlCheck;
/** App ID capabilities vs apple.signing.capabilities vs the iOS project's entitlements (cli/lib/signing.ts compareCapabilities). */
capabilityMismatches?: { msg: string; fix: string }[];
/** Local renders (Apple sizes only), checked before upload. Optional: validate fills it when a renders dir exists. */
renders?: RendersCheck;
/** Auto-renewable subscriptions on App Store Connect (validate reads them when the config has subscriptions). */
subscriptions?: AppleSubSnapshot[];
/** Subscription product ids of the config, with the App Store locales their listings need. */
expectedSubscriptions?: { id: string; group: string; locales: string[] }[];
/** App profile facts (accounts, sign-in for review, terms URL); see profileRules. */
profile?: ProfileFacts;
}
export interface AppleSubSnapshot {
productId: string;
group: string;
state: string;
locales: { locale: string; state?: string }[];
groupLocales: { locale: string; state?: string }[];
reviewScreenshot: boolean;
}
/** What the profile checks need, collected by validate (local config + credentials + public URL checks). */
export interface ProfileFacts {
profile: 'game' | 'app' | 'subscription-app';
/** accounts.enabled */
accounts: boolean;
/** accounts.inApp is set (where in-app deletion is). */
inAppDeletion: boolean;
/** accounts.deletionUrl, checked (null = not set). */
deletionUrl: UrlCheck | null;
/** reviewAccess.signInRequired */
signInRequired: boolean;
/** A demo account exists locally (.storebound/credentials.json reviewDemoAccount or REVIEW_DEMO_USER / _PASSWORD). */
demoAccountLocal: boolean;
/** The config has subscriptions. */
subscriptions: boolean;
/** app.termsUrl, checked (null = not set). */
termsUrl: UrlCheck | null;
}
export interface PlaySnapshot {
defaultLanguage: string;
expectedLanguages: string[];
listings: { language: string; title?: string; shortDescription?: string; fullDescription?: string; images: Record<string, number> }[];
privacyUrl: UrlCheck;
website: string | null;
appAds: UrlCheck | null;
appAdsLines: string[];
adsLikely: boolean;
tracks: { track: string; releases: { status: string; versionCodes: string[]; userFraction?: number }[] }[];
products: { productId: string; states: string[] }[];
expectedProducts: string[];
/** Local renders (Play sizes only), checked before upload. */
renders?: RendersCheck;
/** Resolved play.tablets: true = 7" and 10" tablet screenshots are required too. */
tablets?: boolean;
/** Play subscriptions and their base plans (validate reads them when the config has subscriptions). */
subscriptions?: { productId: string; basePlans: { basePlanId: string; state?: string }[] }[];
expectedSubscriptions?: { id: string; basePlanId: string }[];
profile?: ProfileFacts;
/** The Play App category the config gives (play.category or the App Store mapping), null when none: the API cannot
* read or set it, so validate only reminds that Store settings is a console step. */
category?: { category: string; source: string } | null;
/** Countries / regions of the production track (edits.countryAvailability GET; -1 = rest of world too), null when
* it could not be read. */
productionCountries?: number | null;
}
const EDITABLE = ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY'];
const IN_FLIGHT = ['WAITING_FOR_REVIEW', 'IN_REVIEW', 'PENDING_DEVELOPER_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'ACCEPTED'];
const IAP_OK = ['READY_TO_SUBMIT', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'APPROVED'];
/** Age rating questions whose null means "not answered" (kidsAgeBand null = not a kids app). */
// Left null on apps App Review accepted: gracRatingClassificationNumber (a Korean GRAC rating number, for apps GRAC
// rated), socialMediaAgeRestricted (asked only when socialMedia is true). socialMedia itself is a newer question that
// App Store Connect let submissions through without: a warning, not a blocker.
const AGE_OPTIONAL = new Set(['kidsAgeBand', 'ageRatingOverride', 'ageRatingOverrideV2', 'koreaAgeRatingOverride', 'developerAgeRatingInfoUrl', 'gracRatingClassificationNumber']);
const AGE_SOFT = new Set(['socialMedia']);
const ageOpen = (r: Record<string, unknown>): { open: string[]; soft: string[] } => {
const unset = Object.entries(r).filter(([k, v]) => v === null && !AGE_OPTIONAL.has(k) && !(k === 'socialMediaAgeRestricted' && r.socialMedia !== true)).map(([k]) => k);
return { open: unset.filter(k => !AGE_SOFT.has(k)), soft: unset.filter(k => AGE_SOFT.has(k)) };
};
const urlOk = (u: UrlCheck) => u.status !== null && u.status >= 200 && u.status < 300;
const urlMsg = (u: UrlCheck) => (u.status === null ? `unreachable (${u.error ?? 'no answer'})` : `HTTP ${u.status}`);
/** The scaffolder's own placeholder values (templates/*storebound.config.ts) that would reach a store as the app's
* legal and contact details: "2026 Example Studio", example.com / example.org / example.net URLs and addresses
* (the bare domain; subdomains such as notes.example.com are a project's own test fixtures).
* Returns the dotted paths that still hold one. Pure. */
export function templatePlaceholders(config: { app?: object; accounts?: object; reviewAccess?: object } & Record<string, unknown>): string[] {
const out: string[] = [];
const bad = (v: string) => /\bexample studio\b/i.test(v) || /(^|@|\/\/)(www\.)?example\.(com|org|net)(?![\w.-])/i.test(v);
const walk = (v: unknown, path: string) => {
if (typeof v === 'string') { if (bad(v)) out.push(path); }
else if (Array.isArray(v)) v.forEach((x, i) => walk(x, `${path}[${i}]`));
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`);
};
for (const k of ['app', 'accounts', 'reviewAccess'] as const) walk(config[k], k);
return out;
}
export const COPYRIGHT_MISSING = "app.copyright is missing: App Store Connect requires it. Use your public brand (e.g. '2026 <brand>'); never a company name you keep private.";
/** Copyright: App Store Connect refuses to submit a version without one; storebound never fills it in. */
export function copyrightRules(s: Pick<AppleSnapshot, 'version' | 'configCopyright'>): Finding[] {
if (!s.version || s.version.copyright === undefined) return [];
const live = s.version.copyright?.trim();
const cfg = s.configCopyright?.trim();
if (live) return [{ store: 'apple', level: cfg ? 'ok' : 'warn', code: 'copyright', msg: cfg ? `copyright on the store: ${live}` : `copyright on the store: ${live}; ${COPYRIGHT_MISSING}`, fix: cfg ? undefined : 'set app.copyright in storebound.config.ts' }];
return [{ store: 'apple', level: 'blocker', code: 'copyright', msg: `version ${s.version.versionString} has no copyright on the store${cfg ? ` (config has '${cfg}': storebound apple listing sends it)` : `; ${COPYRIGHT_MISSING}`}`, fix: cfg ? 'storebound apple listing --version x.y.z' : 'set app.copyright in storebound.config.ts, then storebound apple listing',
...(cfg && EDITABLE.includes(s.version.state) ? { action: fixAppleListing(s.version.versionString) } : {}) }];
}
const PLATFORM_NAMES: Record<string, string> = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
export function appleRules(s: AppleSnapshot): Finding[] {
const f: Finding[] = [];
const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction, url?: string) => f.push({ store: 'apple', level, code, msg, fix, ...(action ? { action } : {}), ...(url ? { url } : {}) });
const listing = s.version && EDITABLE.includes(s.version.state) ? fixAppleListing(s.version.versionString) : undefined;
const vl = s.version ? `${s.version.versionString} (${PLATFORM_NAMES[s.version.platform ?? 'IOS'] ?? s.version.platform})` : '';
if (!s.version) add('blocker', 'version', 'no App Store version being prepared', 'storebound apple listing --version x.y.z');
else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${vl}: ${s.version.state}`);
else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${vl} is ${s.version.state} (nothing to prepare)`);
else add('warn', 'version', `version ${vl}: ${s.version.state}`);
for (const d of s.deviceFamilies ?? []) add(d.level, 'device-families', d.msg, d.fix);
{
const pend = s.screenshotsPending ?? [];
const lines = describePending(pend);
pend.forEach((p, i) => repairable(p)
? add('blocker', 'screenshot-stuck', lines[i]!, 'storebound apple screenshots --repair-stuck --dry, then without --dry (replaces only this screenshot at its position)')
: add('warn', 'screenshot-processing', lines[i]!, 'wait a few minutes: a submit answers 409 ASSET_IN_POST_PROCESSING until it is COMPLETE'));
}
for (const w of s.creativeWaiting ?? []) add('warn', 'creative-waiting', `${w.locale} ${w.placement}: ${w.file} ${w.text}`, 'storebound apple creative-assets (a re-run places it once approved; --add-for-review sends it to review)');
for (const o of s.otherPlatformVersions ?? []) {
const name = PLATFORM_NAMES[o.platform] ?? o.platform;
add('warn', 'version-platform', `version ${o.versionString} (${name}) is ${o.state} too: this report covers ${vl || 'iOS'} only`, `storebound validate --platform ${name.toLowerCase()}; storebound apple status lists every platform`);
}
f.push(...copyrightRules(s));
if (!s.build) add('blocker', 'build', 'no build attached to the version', 'storebound apple listing --version x.y.z --build N (pick the build number you just uploaded)');
else {
if (s.build.processingState !== 'VALID') add('blocker', 'build', `build ${s.build.number} is ${s.build.processingState}`, 'wait for VALID (storebound apple status), or upload a new build');
else add('ok', 'build', `build ${s.build.number} VALID and attached`);
if (s.build.usesNonExemptEncryption === null && !s.build.declarationState)
add('blocker', 'encryption', `build ${s.build.number}: export compliance not answered`, 'ITSAppUsesNonExemptEncryption in Info.plist, or storebound apple encryption (export-compliance skill)');
else if (s.build.usesNonExemptEncryption && s.build.declarationState !== 'APPROVED')
add('blocker', 'encryption', `build ${s.build.number} uses non-exempt encryption; declaration ${s.build.declarationState ?? 'missing'}`, 'export-compliance skill');
else add('ok', 'encryption', 'export compliance answered');
}
const have = new Map(s.locales.map(l => [l.locale, l] as const));
for (const loc of s.expectedLocales) if (!have.has(loc)) add('blocker', 'locale', `${loc}: no localization on the version`, 'storebound apple listing', listing);
for (const l of s.locales) {
const miss = (['description', 'keywords', 'subtitle'] as const).filter(k => !l[k]);
if (miss.length) add('blocker', 'metadata', `${l.locale}: missing ${miss.join(', ')}`, 'store/appstore.md, then storebound apple listing');
for (const t of s.requiredDisplayTypes) {
if (l.screenshots[t]) continue;
const alt = (ACCEPTED_INSTEAD[t] ?? []).find(x => l.screenshots[x]);
if (alt && SCALES_DOWN[t]?.includes(alt)) add('ok', 'screenshots-size', `${l.locale}: ${LABEL[t] ?? t} filled by scaling the ${alt} set down (${l.screenshots[alt]} screenshots)`);
else if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots');
else add('blocker', 'screenshots', `${l.locale}: no ${t} screenshots${ACCEPTED_INSTEAD[t] ? ` (or ${ACCEPTED_INSTEAD[t]!.join(' / ')})` : ''}`, 'storebound apple screenshots');
}
}
if (s.locales.length && !f.some(x => x.code === 'metadata' || x.code === 'screenshots' || x.code === 'locale')) add('ok', 'metadata', `${s.locales.length} locales with text and screenshots`);
const r = s.reviewDetail;
if (!r) add('blocker', 'review-details', 'no App Review information', 'storebound apple review-details', fixReviewDetails());
else {
const miss = (['contactFirstName', 'contactLastName', 'contactPhone', 'contactEmail'] as const).filter(k => !r[k]);
if (miss.length) add('blocker', 'review-details', `review contact missing ${miss.join(', ')}`, 'storebound apple review-details', fixReviewDetails());
else if (r.demoAccountRequired && !r.demoAccountName) add('blocker', 'review-details', 'demo account required but not given');
else add('ok', 'review-details', 'review contact set');
if (!r.notes) add('warn', 'review-notes', 'no review notes: say where IAPs, Restore Purchases, ads and the privacy policy are', 'apple.info.reviewNotes');
}
if (!s.ageRating) add('blocker', 'age-rating', 'age rating declaration not readable / missing', 'storebound apple appinfo');
else {
const { open, soft } = ageOpen(s.ageRating);
if (open.length) add('blocker', 'age-rating', `age rating unanswered: ${open.join(', ')}`, 'storebound apple appinfo');
else add('ok', 'age-rating', 'age rating answered');
if (soft.length) add('warn', 'age-rating', `age rating question not answered yet: ${soft.join(', ')} (${consoleLink('asc.age-rating')})`, undefined, undefined, consoleUrl('asc.age-rating'));
}
add(s.priceSchedule ? 'ok' : 'blocker', 'price', s.priceSchedule ? 'app price set' : 'no app price schedule', 'storebound apple appinfo');
add(s.availability ? 'ok' : 'blocker', 'availability', s.availability ? 'availability set' : 'no territory availability',
'storebound apple appinfo; if the API refuses the first availability record, set it once in ' + consoleLink('asc.pricing'));
const iaps = new Map(s.iaps.map(i => [i.productId, i] as const));
for (const id of s.expectedProducts) if (!iaps.has(id)) add('blocker', 'iap', `${id}: not on App Store Connect`, 'storebound apple iap');
for (const i of s.iaps) {
if (!IAP_OK.includes(i.state)) add('blocker', 'iap', `${i.productId}: ${i.state}`, i.state === 'MISSING_METADATA' ? 'storebound apple iap + apple iap-review' : 'see the review-rejections skill');
else if (!i.reviewScreenshot && i.state !== 'APPROVED') add('blocker', 'iap', `${i.productId}: no review screenshot`, 'storebound apple iap-review');
}
for (const g of s.firstProductGaps ?? []) add(g.level === 'error' ? 'blocker' : 'warn', 'iap-first', g.msg, g.fix);
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
f.push(...appleSubscriptionRules(s));
for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix);
if (s.capabilityMismatches && !s.capabilityMismatches.length) add('ok', 'capabilities', 'capabilities match (App ID, config, project entitlements)');
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) }));
f.push(...rendersFindings('apple', s.renders));
// iPhone Duo: optional until April 2027, so a passing note (never a blocker); a warning after that date
const duo = iphoneDuoNote({ apple: true, duo: !!s.iphoneDuo, play: s.play, today: s.today });
if (duo) add(duo.level === 'info' ? 'ok' : 'warn', 'screenshots-duo', duo.msg, 'apple.iphoneDuo: true, then storebound shots render');
// a macOS version without Mac screenshots: an info note (the macOS version's own checks run with --platform macos)
const mac = macNote({ macVersion: !!s.macVersion, mac: !!s.mac });
if (mac) add('ok', 'screenshots-mac', mac, 'apple.mac: true, storebound shots render --size mac, apple screenshots --platform macos');
return sortFindings(f);
}
// Play Console Help, "Add preview assets to showcase your app" (answer 9866151, checked October 2026): an app icon and a
// feature graphic are required, and at least 2 screenshots; 7" / 10" tablet screenshots are optional (play.tablets).
export const PLAY_REQUIRED_IMAGES: Record<string, number> = { icon: 1, featureGraphic: 1, phoneScreenshots: 2 };
/** Play tablet image types, required only with play.tablets (resolved: explicit, or tablet renders exist). */
export const PLAY_TABLET_IMAGES: Record<string, number> = { sevenInchScreenshots: 1, tenInchScreenshots: 1 };
/** Image types validate requires on Play for a project. */
export const playRequiredImages = (tablets: boolean | undefined): Record<string, number> => (tablets ? { ...PLAY_REQUIRED_IMAGES, ...PLAY_TABLET_IMAGES } : PLAY_REQUIRED_IMAGES);
/** app-ads.txt lines compared without spaces and case (the format is case-insensitive except the ids, which we keep). */
export const normAdsLine = (l: string) => l.split('#')[0]!.split(',').map(p => p.trim().toLowerCase()).filter(Boolean).join(',');
export function playRules(s: PlaySnapshot): Finding[] {
const f: Finding[] = [];
const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction) => f.push({ store: 'play', level, code, msg, fix, ...(action ? { action } : {}) });
const have = new Map(s.listings.map(l => [l.language, l] as const));
const fallback: Record<string, string[]> = {};
if (!have.has(s.defaultLanguage)) add('blocker', 'listing', `no listing for the default language ${s.defaultLanguage}`, 'storebound play listing', fixPlayListing());
for (const lang of s.expectedLanguages) if (!have.has(lang)) add('blocker', 'listing', `${lang}: no listing`, 'storebound play listing', fixPlayListing());
for (const l of s.listings) {
const miss = (['title', 'shortDescription', 'fullDescription'] as const).filter(k => !l[k]);
if (miss.length) add('blocker', 'listing', `${l.language}: missing ${miss.join(', ')}`, 'storebound play listing', fixPlayListing());
for (const [type, n] of Object.entries(playRequiredImages(s.tablets))) {
if ((l.images[type] ?? 0) >= n) continue;
// Play shows the default language's graphics in a language that has none of its own (store-pitfalls 45): only
// the default language's missing graphics block; a language with fewer than needed but more than none is
// a real gap (Play takes them as they are).
const def = have.get(s.defaultLanguage);
if (l.language !== s.defaultLanguage && !(l.images[type] ?? 0) && def && (def.images[type] ?? 0) >= n) {
(fallback[type] ??= []).push(l.language);
continue;
}
add('blocker', 'images', `${l.language}: ${type} ${l.images[type] ?? 0}/${n}${PLAY_TABLET_IMAGES[type] ? ' (play.tablets is on)' : ''}`, 'storebound play images');
}
}
for (const [type, langs] of Object.entries(fallback))
add('ok', 'images-fallback', `${type}: ${langs.length} language(s) without their own use the default language's (${s.defaultLanguage}) graphics: ${langs.slice(0, 6).join(', ')}${langs.length > 6 ? ' ...' : ''}`);
if (s.listings.length && !f.some(x => x.code === 'listing' || x.code === 'images')) add('ok', 'listing', `${s.listings.length} listings with text and graphics`);
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
if (s.appAdsLines.length || s.adsLikely) {
if (!s.website) add('blocker', 'app-ads', 'no developer website on the listing (app-ads.txt is looked up there)', 'storebound play listing (app.website)');
else if (!s.appAds || !urlOk(s.appAds)) add(s.appAdsLines.length ? 'blocker' : 'warn', 'app-ads', `${s.website.replace(/\/$/, '')}/app-ads.txt: ${s.appAds ? urlMsg(s.appAds) : 'not checked'}`, 'ads-mediation-checks skill');
else if (s.appAdsLines.length) {
const body = new Set((s.appAds.body ?? '').split(/\r?\n/).map(normAdsLine).filter(Boolean));
const missing = s.appAdsLines.filter(l => !body.has(normAdsLine(l)));
if (missing.length) add('blocker', 'app-ads', `app-ads.txt lacks ${missing.length} line(s): ${missing.slice(0, 3).join(' | ')}${missing.length > 3 ? ' ...' : ''}`, 'ads-mediation-checks skill');
else add('ok', 'app-ads', `app-ads.txt has all ${s.appAdsLines.length} lines`);
} else add('warn', 'app-ads', 'app-ads.txt reachable; set ads.appAdsTxt in the config to check its lines');
}
if (s.category !== undefined) {
if (s.category) add('ok', 'category', `App category ${s.category.category} (${s.category.source}): the Play API cannot read it; check Store settings shows it`, 'storebound console plan play-store-settings');
else add('warn', 'category', 'no play.category: Store settings > App category stays "Not selected" (the Play API cannot set it)', 'set play.category, then storebound console plan play-store-settings');
}
if (s.productionCountries === 0) {
const prod = s.tracks.some(t => t.track === 'production' && t.releases.length);
add(prod ? 'blocker' : 'warn', 'countries', 'production has 0 countries / regions (the API cannot set them)', 'storebound console plan play-production-countries');
} else if (typeof s.productionCountries === 'number') add('ok', 'countries', `production: ${s.productionCountries < 0 ? 'every country / region' : `${s.productionCountries} countries / regions`}`);
const anyRelease = s.tracks.some(t => t.releases.length);
if (!anyRelease) add('blocker', 'tracks', 'no release on any track', 'storebound play release <aab>');
for (const t of s.tracks) for (const r of t.releases) {
if (r.status === 'halted') add('warn', 'tracks', `${t.track}: release ${r.versionCodes.join(',')} is halted`, 'storebound play rollout --resume (owner)');
if (r.status === 'inProgress') add('ok', 'tracks', `${t.track}: rollout ${Math.round((r.userFraction ?? 0) * 100)}% of ${r.versionCodes.join(',')}`);
}
if (anyRelease && !f.some(x => x.code === 'tracks')) add('ok', 'tracks', s.tracks.filter(t => t.releases.length).map(t => `${t.track}: ${t.releases.map(r => r.status).join('/')}`).join(', '));
const prods = new Map(s.products.map(p => [p.productId, p] as const));
for (const id of s.expectedProducts) {
const p = prods.get(id);
if (!p) add('blocker', 'products', `${id}: not on Play`, 'storebound play products');
else if (!p.states.includes('ACTIVE')) add('blocker', 'products', `${id}: purchase option ${p.states.join(',') || 'no state'}`, 'storebound play products');
}
if (s.expectedProducts.length && !f.some(x => x.code === 'products')) add('ok', 'products', `${s.expectedProducts.length} products active`);
const subs = new Map((s.subscriptions ?? []).map(x => [x.productId, x] as const));
for (const e of s.expectedSubscriptions ?? []) {
const cur = subs.get(e.id);
const bp = cur?.basePlans.find(b => b.basePlanId === e.basePlanId);
if (!cur) add('blocker', 'subscriptions', `${e.id}: subscription not on Play`, 'storebound play subscriptions');
else if (!bp) add('blocker', 'subscriptions', `${e.id}: no base plan ${e.basePlanId}`, 'storebound play subscriptions');
else if (bp.state !== 'ACTIVE') add('blocker', 'subscriptions', `${e.id}: base plan ${e.basePlanId} is ${bp.state ?? 'not active'}`, 'storebound play subscriptions (activates it)');
}
if (s.expectedSubscriptions?.length && !f.some(x => x.code === 'subscriptions')) add('ok', 'subscriptions', `${s.expectedSubscriptions.length} subscriptions with an active base plan`);
if (s.profile) f.push(...profileRules('play', s.profile, {}));
f.push(...rendersFindings('play', s.renders));
return sortFindings(f);
}
const SUB_OK = ['READY_TO_SUBMIT', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'APPROVED', 'PENDING_BINARY_APPROVAL'];
/** Subscriptions on App Store Connect vs the config: present, state, localizations (and their review state), group
* display names, review screenshot. */
export function appleSubscriptionRules(s: Pick<AppleSnapshot, 'subscriptions' | 'expectedSubscriptions'>): Finding[] {
const f: Finding[] = [];
const add = (level: Level, msg: string, fix?: string) => f.push({ store: 'apple', level, code: 'subscriptions', msg, fix });
const have = new Map((s.subscriptions ?? []).map(x => [x.productId, x] as const));
const groupsDone = new Set<string>();
for (const e of s.expectedSubscriptions ?? []) {
const cur = have.get(e.id);
if (!cur) { add('blocker', `${e.id}: subscription not on App Store Connect`, 'storebound apple subscriptions'); continue; }
if (!groupsDone.has(cur.group)) {
groupsDone.add(cur.group);
if (!cur.groupLocales.length) add('blocker', `group "${cur.group}": no localization (display name)`, 'storebound apple subscriptions');
for (const l of cur.groupLocales) if (l.state === 'REJECTED') add('blocker', `group "${cur.group}" ${l.locale}: localization rejected`, 'fix the group display name, then storebound apple subscriptions');
}
if (cur.state === 'MISSING_METADATA') add('blocker', `${e.id}: MISSING_METADATA (localization, price, availability or review screenshot missing)`, 'storebound apple subscriptions');
else if (!SUB_OK.includes(cur.state)) add('blocker', `${e.id}: ${cur.state}`, cur.state === 'REJECTED' || cur.state === 'DEVELOPER_ACTION_NEEDED' ? 'review-rejections skill; fix and resubmit with a version' : 'store-subscriptions skill');
const locs = new Map(cur.locales.map(l => [l.locale, l] as const));
for (const loc of e.locales) if (!locs.has(loc)) add('blocker', `${e.id}: no ${loc} localization`, 'storebound apple subscriptions');
for (const l of cur.locales) if (l.state === 'REJECTED') add('blocker', `${e.id} ${l.locale}: localization rejected`, 'fix the display name / description, then storebound apple subscriptions');
if (!cur.reviewScreenshot && cur.state !== 'APPROVED') add('blocker', `${e.id}: no review screenshot`, 'subscriptions[].reviewScreenshot, then storebound apple subscriptions');
}
if (s.expectedSubscriptions?.length && !f.length) add('ok', `${s.expectedSubscriptions.length} subscriptions ready (state, localizations, review screenshots)`);
return f;
}
/**
* App profile checks. Accounts: Apple 5.1.1(v) wants deletion inside the app (no API can see it: a reminder unless
* accounts.inApp says where), Play wants a reachable delete-account URL as well. Sign-in for review: the demo account
* must exist locally and be on the App Review details. Subscriptions: a reachable Terms of Use (EULA) URL, and Apple
* metadata (description) that links it (3.1.2).
*/
export function profileRules(store: 'apple' | 'play', p: ProfileFacts, o: { reviewDetail?: AppleSnapshot['reviewDetail']; descriptions?: { locale: string; text: string }[] }): Finding[] {
const f: Finding[] = [];
const add = (level: Level, code: string, msg: string, fix?: string, _action?: undefined, url?: string) => f.push({ store, level, code, msg, fix, ...(url ? { url } : {}) });
if (p.accounts) {
if (store === 'play') {
if (!p.deletionUrl) add('blocker', 'account-deletion', 'the app has accounts but accounts.deletionUrl is not set (Play Data safety asks for a delete-account URL)', 'account-deletion skill');
else if (!urlOk(p.deletionUrl)) add('blocker', 'account-deletion', `delete-account page ${p.deletionUrl.url}: ${urlMsg(p.deletionUrl)}`, 'account-deletion skill');
else add('ok', 'account-deletion', `delete-account page ${p.deletionUrl.url} answers`);
} else if (!p.inAppDeletion) add('warn', 'account-deletion', 'the app has accounts: deletion must start inside the app (5.1.1(v)); set accounts.inApp to where it is, it goes into the review notes', 'account-deletion skill');
else add('ok', 'account-deletion', 'in-app account deletion named in the config (5.1.1(v); test it before review)');
}
if (p.signInRequired) {
if (!p.demoAccountLocal) add('blocker', 'demo-account', 'sign-in required for review but no demo account in .storebound/credentials.json (reviewDemoAccount) or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD', 'review-access skill');
if (store === 'apple' && o.reviewDetail && !(o.reviewDetail.demoAccountRequired && o.reviewDetail.demoAccountName))
add('blocker', 'demo-account', 'App Review information has no demo account although the app needs a sign-in (Guideline 2.1)', 'storebound apple review-details');
if (store === 'play') add('warn', 'app-access', `sign-in required: ${consoleLink('play.app-access')} must list the demo credentials and steps (no API)`, 'review-access skill', undefined, consoleUrl('play.app-access'));
if (!f.some(x => x.code === 'demo-account')) add('ok', 'demo-account', 'demo account for review present');
}
if (p.subscriptions) {
if (!p.termsUrl) add('blocker', 'terms-url', 'subscriptions need a Terms of Use (EULA) link in the app and the metadata: app.termsUrl is not set', 'store-subscriptions skill');
else if (!urlOk(p.termsUrl)) add('blocker', 'terms-url', `terms of use ${p.termsUrl.url}: ${urlMsg(p.termsUrl)}`, 'store-subscriptions skill');
else {
add('ok', 'terms-url', `terms of use ${p.termsUrl.url} answers`);
const missing = (o.descriptions ?? []).filter(d => d.text && !d.text.includes(p.termsUrl!.url) && !/terms of use|\beula\b|license agreement/i.test(d.text)).map(d => d.locale);
if (store === 'apple' && missing.length) add('warn', 'terms-url', `description without a Terms of Use link: ${missing.join(', ')} (3.1.2; or set a custom EULA in App Information)`, 'store-subscriptions skill');
}
}
return f;
}
/** Blockers first, then warnings, then ok; stable inside a level (rule order). */
export function sortFindings(f: Finding[]): Finding[] {
const rank: Record<Level, number> = { blocker: 0, warn: 1, ok: 2 };
return f.map((x, i) => [x, i] as const).sort(([a, i], [b, j]) => rank[a.level] - rank[b.level] || i - j).map(([x]) => x);
}
export function formatFindings(f: Finding[]): string {
const tag: Record<Level, string> = { blocker: 'BLOCKER', warn: 'warn ', ok: 'ok ' };
return f.map((x, i) => `${String(i + 1).padStart(2)}. ${tag[x.level]} [${x.store}] ${x.msg}${x.fix && x.level !== 'ok' ? `\n fix: ${x.fix}` : ''}`).join('\n');
}