// 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 { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions'; import { iphoneDuoNote } 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 = { // 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 = { APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'] }; const LABEL: Record = { 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 = { 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; /** 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 }[]; requiredDisplayTypes: string[]; /** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */ iphoneDuo?: 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 | 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 }[]; 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): { 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[] { 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 '); 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): 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 = { 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 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'); 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 = { 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 = { sevenInchScreenshots: 1, tenInchScreenshots: 1 }; /** Image types validate requires on Play for a project. */ export const playRequiredImages = (tablets: boolean | undefined): Record => (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 = {}; 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 '); 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): 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(); 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 = { 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 = { 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'); }