// Per-project config (storebound.config.ts at the project root). Everything app-specific lives here; credentials never // do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional. import { existsSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import * as rt from './rt'; import { PROFILE_RE, setConfigAccount } from './credentials'; import { validateCapture, validateRecord } from '../shots/devices'; import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy'; import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events'; import { type IapOfferCodeDef, type PromotedConfig, validateIapOfferCodes, validatePromoted, validateWinBack, type WinBackDef } from './offers'; import { validateSubscriptions } from './subscriptions'; import { type TfConfig, validateTestflight } from './testflight'; import { type AppleSigning, validateSigning } from './signing'; import { type AppleBuildConfig, validateAppleBuild } from './xcode'; import { type PlayBuildConfig, validatePlayBuild } from './gradle'; import { type OriginalityConfig, validateOriginality } from './originality'; /** consumable / non_consumable: both stores. non_renewing: Apple NON_RENEWING_SUBSCRIPTION (access for a fixed time, * you track the expiry); Google Play has no such type, so it becomes a one-time product your app consumes when the * access ends (or model it as a prepaid base plan in Play Console). */ export type ProductType = 'consumable' | 'non_consumable' | 'non_renewing'; /** Which stores sell an item (app identity: one catalog, per-platform availability). Default: every store the config * has a section for (apple = iOS / iPadOS App Store, play = Google Play). */ export type Platform = 'ios' | 'android'; export interface PriceOverride { /** ISO 3166-1 alpha-2 region for Play, e.g. "TR". */ region: string; /** App Store territory (alpha-3), e.g. "TUR". Looked up from `region` when left out. */ territory?: string; /** ISO 4217 currency, e.g. "TRY". */ currency: string; /** Customer price in that currency, e.g. 129.99. Apple needs an existing price point with exactly this value. */ price: number; } export interface ProductListing { title: string; description: string } export interface Product { /** Store product id (same on both stores), e.g. "com.example.game.no_ads". */ id: string; type: ProductType; /** Base price in USD. Play converts it per region (convertRegionPrices); Apple equalizes it from the USA base. */ usd: number; overrides?: PriceOverride[]; /** Language id (see `languages`) -> title + description. Apple: title 30, description 45 characters. */ listings: Record; /** Apple only: internal reference name (defaults to the first listing title). */ referenceName?: string; /** Apple only: note for the reviewer on this product. */ reviewNote?: string; /** Apple only: review screenshot (path relative to the project root). */ reviewScreenshot?: string; familySharable?: boolean; /** Sell this product only on these platforms (default: all of app.platforms). */ platforms?: Platform[]; /** Apple offer codes for this one-time purchase (`storebound apple offer-codes`). */ offerCodes?: IapOfferCodeDef[]; /** The purchase gives a random reward (loot box, gacha pull, mystery pack). The Play IARC questionnaire asks it * (Digital Purchases > random items): any true answers it Yes, every product false answers it No. Brazil's ClassInd * rates such games 18 (seen on a real run, 2026-10-12). */ randomRewards?: boolean; } export interface Language { /** Your id for the language; also the folder name under the renders dir and the key in copy files. */ id: string; /** Play listing language code(s), e.g. "en-US" or ["es-ES", "es-419"]. Leave out to skip on Play. */ play?: string | string[]; /** App Store Connect locale, e.g. "en-US", "tr", "zh-Hans". Leave out to skip on the App Store. */ apple?: string; rtl?: boolean; } export type PlayPurpose = 'functionality' | 'analytics' | 'communications' | 'fraud' | 'advertising' | 'personalization' | 'account'; export interface DataUsage { /** Transferred to a third party that is not your service provider (ad networks usually are "shared"). */ shared: boolean; ephemeral?: boolean; /** Only true when the user can really choose (Play rejects "optional" otherwise). */ optional?: boolean; collect: PlayPurpose[]; share?: PlayPurpose[]; } export interface DataSafety { collectsData: boolean; encryptedInTransit: boolean; /** "Do you provide a way for users to request that their data is deleted?" under the form's old id; now written as * PSL_SUPPORT_DATA_DELETION_BY_USER (true = yes, false = no). dataDeletion and a delete-account URL take precedence. */ deletionRequest?: boolean; /** Response ids for PSL_SUPPORTED_ACCOUNT_CREATION_METHODS. Default ["PSL_ACM_NONE"] (no accounts). */ accountCreation?: string[]; /** * Play "Do you provide a way for users to request that their data is deleted?" (PSL_SUPPORT_DATA_DELETION_BY_USER, a * single choice Play asks once accountCreation is not PSL_ACM_NONE). It is the general question: a delete-account * page (accounts.deletionUrl) answers it with yes, and that is the default. 'no' / 'auto90' (no, but data is deleted * automatically within 90 days) only for an app without one. */ dataDeletion?: 'yes' | 'no' | 'auto90'; /** * Whether people can delete some or all of their data without deleting the account. true: partialDeletionUrl (default * accounts.deletionUrl) is the "Delete data URL". false: the delete-account page is given as that URL. Older configs: * 'auto90' means dataDeletion 'auto90'. */ partialDeletion?: boolean | 'auto90'; /** Web page for that data-only deletion (PSL_DATA_DELETION_URL, required when partialDeletion is true). Default: * accounts.deletionUrl, when that page also lets people delete data without deleting the account. */ partialDeletionUrl?: string; /** Play data type id (e.g. "PSL_DEVICE_ID") -> usage. Types not listed are "not collected". */ data: Record; /** * Extra CSV rows for questions storebound does not model, appended as they are; a row with the same question id as * a generated row replaces that generated question (every response row of it). Google does not publish every * question id: export the form from Play Console (App content > Data safety > Export to CSV), copy the exact ids * from it, e.g. { question: '', value: '...' }. The delete-account URL is generated from * accounts.deletionUrl (PSL_ACCOUNT_DELETION_URL). Every (question, response) pair must be in Play's current form * (cli/data/play-data-safety-form.csv); `check` and `play data-safety --dry` report one that is not. */ extra?: { question: string; response?: string; value: string; label?: string }[]; } export interface ShotBadgeSlot { y: number; side: 'l' | 'r'; icon?: string; iconBg?: string; hot?: boolean; stars?: boolean; rot?: number } export interface ShotFrame { src: string; x?: number; dy?: number; scale?: number; rotate?: number } export interface Palette { bg: [string, string, string]; glow: string; ink: string; shadow: string } export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number } /** A local font file for shots.fonts. license: 'OFL-1.1', 'proprietary' or a short note (shown by `shots fonts`; * `shots check` warns without one). weight / style: the @font-face descriptors (default: all weights, normal). */ export interface LocalFontSpec { family?: string; file: string; weight?: number | string; style?: 'normal' | 'italic'; license?: string } /** A Google Fonts family name, a local font path (relative to the config) or a LocalFontSpec. */ export type FontSpec = string | LocalFontSpec; export interface ShotsConfig { /** Look of the rendered screenshots: game (sunburst, sparkles, badges, bold outlined titles) or app (calm gradient, * device frame, headline + subline, no sparkles or badges). Default: app for the app / subscription-app profiles, * else game. */ style?: 'game' | 'app'; /** JSON file: { [languageId]: { [sceneId]: { eyebrow, title: [line1, line2], sub, badges: [[big, small?]] } } } */ copy: string; /** Raw frames: ///.png */ frames: string; /** Output: ///NN.png and feature/1024x500.png */ out: string; /** Optional logo image for the hero scene and the feature graphic. */ logo?: string; /** Fonts for titles / body text: a Google Fonts family (downloaded and subset by `shots fonts`), or your own font file * (a path relative to the config, TTF / OTF / WOFF / WOFF2, or { family, file, weight?, style?, license? }). * Default: game Paytone One + Montserrat, app Inter (all cover Latin Extended and Vietnamese). `fallback`: fonts you * accept for characters the title / body font lacks, tried in order before the automatic Noto fallbacks (e.g. * ['Noto Sans Display'] or a local file); a render that needs an automatic fallback is an ERROR in `shots check`. */ fonts?: { title?: FontSpec; body?: FontSpec; fallback?: FontSpec[] }; scenes: ShotScene[]; feature?: { frame: string; palette?: string | Palette }; /** Phone screen size of the raw frames in CSS px (default 440 x 902 @3x, iPhone 6.9" minus the 54 px status bar). * `bar` = status bar height the template draws above the frame (and that capture crops off). */ phone?: { w: number; h: number; dpr?: number; bar?: number }; /** Tablet frames: default 1032 x 1352 @2x (13" iPad minus its 24 px bar) with apple.ipad, else 800 x 1240 @2x. */ tablet?: { w: number; h: number; dpr?: number; bar?: number }; /** Optional capture of raw frames (web build, iOS Simulator, Android emulator / device, or your own folder). */ capture?: ShotsCapture; /** A/B variants: other scene orders / copy, rendered into ///... (`shots render --variant b`). */ variants?: Record; /** App preview / promo video from gameplay clips (`shots record`, `shots video`). */ video?: VideoConfig; } /** One raw frame. Web: `path` (+ query). Simulator / emulator: `url` deep link and / or `args` / `extras`. */ export interface ShotSpec { /** Web: URL path after baseUrl, "{lang}" replaced. */ path?: string; /** ios-sim / android-emu: deep link opened after launch, e.g. "mygame://shot/{shot}?lang={lang}". */ url?: string; /** Web: JS expression polled until true. */ waitFor?: string; /** Wait before the screenshot (ms). Default 1000 (web) / 2500 (simulator, emulator). */ delayMs?: number; /** Web: JS run in the page before the screenshot. */ eval?: string[]; device?: 'phone' | 'tablet' | 'both'; /** Web, capture.session "per-language": how this shot is reached in the already booted page. "spa" (default): * history.pushState to its path + a popstate event, then waitFor / eval (switch scenes from those); "reload": * page.goto in the same page. A failing waitFor there falls back to a fresh page. */ navigate?: 'spa' | 'reload'; /** ios-sim: extra launch arguments, e.g. ["-shotScene", "{shot}", "-seed", "1"] (read them with UserDefaults / your engine). */ args?: string[]; /** android-emu: intent extras for `am start` (string / int / float / boolean), "{lang}" replaced in strings. */ extras?: Record; } export interface ShotsCapture { /** Where frames come from. Default "web". */ source?: 'web' | 'ios-sim' | 'android-emu' | 'folder'; /** web: URL of the running app or game, e.g. http://127.0.0.1:5173 */ baseUrl?: string; /** web: appended to every URL, e.g. "lang={lang}&noads=1". */ query?: string; ios?: { /** Simulator name or UDID for phone shots, e.g. "iPhone 17 Pro Max" (6.9"). */ device?: string; devices?: { phone?: string; tablet?: string }; bundleId: string; /** Optional .app to install first (a Simulator build: xcodebuild -sdk iphonesimulator, Unity / Godot / Flutter export). */ app?: string; /** Launch arguments for every shot ("{lang}", "{shot}" replaced). */ args?: string[]; /** Status bar override 9:41, full battery and signal. Default true. */ statusBar?: boolean; /** After storebound boots a simulator: launch the app once and wait this long before the first shot (a cold * first launch is much slower than the next ones). Default 8000 ms; 0 turns it off. */ warmupMs?: number; }; android?: { /** adb serial (adb devices); default: the only device. */ serial?: string; serials?: { phone?: string; tablet?: string }; package: string; /** Launch activity when a shot has no deep link. Default ".MainActivity". */ activity?: string; /** Optional APK to install first. */ apk?: string; /** System UI demo mode (clean status bar). Default true. */ demoMode?: boolean; /** Per-app language via `cmd locale set-app-locales` (Android 13+). Default true. */ appLocale?: boolean; }; /** folder: where your frames are (default shots.frames): ///.png */ folder?: string; /** Rows cut from the top of a simulator / emulator screenshot (its status bar; the template draws one). A number for * every device, or per device class. Default: the frame's bar share of the height (phone 54 / 956 = 162 px on a 6.9" * iPhone, 13" iPad 24 / 1376 = 48 px). */ cropTop?: number | { phone?: number; tablet?: number }; /** Web: parallel browser contexts (one headless Chromium). Default min(3, CPU cores / 4): game boots are GPU-heavy. * `--concurrency N` overrides. */ concurrency?: number; /** Web: "per-shot" (default: new context + full boot per frame) or "per-language" (one page per language and * device, booted once; shots switch in it, see ShotSpec.navigate). */ session?: 'per-shot' | 'per-language'; /** Web, per-language: how long a reused page may take to reach a shot's waitFor before a fresh page is used. * Default 15000 ms. */ reuseTimeoutMs?: number; /** Web: files or folders of the app build (e.g. "apps/game/src" or "apps/game/dist") hashed into the capture cache, * so a code change re-captures. Default: the HTML at baseUrl and the scripts it loads. */ build?: string | string[]; shots: Record; } export interface ShotVariant { /** Scene ids in the order this variant shows them (a subset is fine). Default: the base order. */ scenes?: string[]; /** Copy file merged over the base copy per language and scene (only the scenes / fields that change). */ copy?: string; /** Palette per scene id. */ palettes?: Record; } export interface VideoConfig { /** Gameplay clips: //*.mp4|mov, or /*.mp4|mov shared by every language. Default store/video/clips. */ clips?: string; /** Target length in seconds (15-30 for App Store previews). Default 25. */ seconds?: number; /** Outputs: iphone (886x1920), ipad (1200x1600, iPad apps), youtube (1080x1920), youtube-landscape (1920x1080). */ targets?: string[]; /** Caption overlays from the screenshot copy (scene titles), one per clip. Default true. */ captions?: boolean; /** Scene ids whose titles caption the clips, in order. Default: the scene order. */ captionScenes?: string[]; /** Music / sound file mixed in (looped, faded out). Default: a silent stereo track (Apple wants an audio track). */ audio?: string; /** Poster frame time in seconds (App Store shows it before playback). Default 5. */ poster?: number; /** cover (crop to fill, default) or contain (letterbox on `background`). */ fit?: 'cover' | 'contain'; background?: string; /** Gameplay clips recorded by `storebound shots record` into //NN-.mp4. */ record?: VideoRecord; } /** How `shots record` films clips. Uses shots.capture for the web URL / simulator / emulator settings. */ export interface VideoRecord { /** Default: shots.capture.source (web when unset). folder = only list / check the clips you put there yourself. */ source?: 'web' | 'ios-sim' | 'android-emu' | 'folder'; /** Which frame size / simulator / emulator: phone (default) or tablet. */ device?: 'phone' | 'tablet'; /** Clip name -> what to film, in order. */ clips: Record; } export interface ClipSpec { /** Length in seconds (1-60). */ seconds: number; /** web: URL path after shots.capture.baseUrl ("{lang}" replaced), e.g. "/?level=3&autoplay=1". */ path?: string; /** web: JS expression polled until true before filming starts. */ waitFor?: string; /** web: JS run before filming starts. */ eval?: string[]; /** ios-sim / android-emu: deep link opened after launch. */ url?: string; /** ios-sim: extra launch arguments ("{lang}", "{shot}" = the clip name). */ args?: string[]; /** android-emu: intent extras for `am start`. */ extras?: Record; /** Wait after launch before filming (ms). Default 1000 (web) / 2500 (simulator, emulator). */ delayMs?: number; } export interface IconsConfig { /** 1024 opaque composite (the full icon). */ composite: string; /** 1024 RGBA subject on transparent background (adaptive foreground). */ foreground?: string; /** 1024 opaque background (adaptive background). */ background?: string; /** Output dir for the store icons (app-store-1024.png, play-512.png). */ out: string; /** Web icons (icon-512/192/180, favicon-64/32, maskable-512). */ web?: string; /** @capacitor/assets source dir (icon-only, icon-foreground, icon-background). */ capacitorAssets?: string; /** Command to run after writing the capacitor sources, e.g. "bunx @capacitor/assets generate". */ capacitorCommand?: string; /** Android res dir; legacy ic_launcher{,_round}.png get redrawn from the adaptive layers. */ androidRes?: string; adaptiveScale?: number; maskableScale?: number; } export interface AppleAppInfo { categories?: { primary: string; primarySubOne?: string; primarySubTwo?: string; secondary?: string }; /** Overrides for the age rating declaration; everything else is NONE / false. */ ageRating?: Record; contentRights?: 'DOES_NOT_USE_THIRD_PARTY_CONTENT' | 'USES_THIRD_PARTY_CONTENT'; /** Free app, available in every territory and new ones. */ free?: boolean; reviewNotes?: string; } /** What kind of app this is. Picks the `init` templates, the default screenshot style and the profile checks of * `check` / `validate`. game = free-to-play game with ads + IAP; app = utility / productivity app (optional account, * analytics, no ads); subscription-app = app with accounts and auto-renewable subscriptions. */ export type AppProfile = 'game' | 'app' | 'subscription-app'; export const PROFILES: readonly AppProfile[] = ['app', 'game', 'subscription-app']; /** Accounts in the app (sign-up / sign-in). Both stores then want account deletion: Apple 5.1.1(v) inside the app, * Google Play inside the app and on the web (the URL goes into Data safety). See the account-deletion skill. */ export interface AccountsConfig { /** The app lets people create an account (or sign in with one you create for them). */ enabled: boolean; /** Public https page where people request account + data deletion without the app (Play "Delete account URL"). */ deletionUrl?: string; /** Where deletion starts inside the app, e.g. "Settings > Account > Delete account" (goes into the review notes). */ inApp?: string; /** * How people sign up. Decides which account data `storebound scan` proposes and the Play Data safety "Account * creation" answer it suggests for dataSafety.accountCreation (ACCOUNT_SIGN_UP in declarations.ts): * 'username' = a username or player name picked in the app with no password and no email (the server keeps a token on * the device), 'username-password', 'email-password', 'email-link' (magic link / one-time code by email), 'phone' * (SMS code), 'oauth' (Sign in with Apple / Google / Facebook ...), 'other'. Only 'email-*' means an email address. */ signUp?: AccountSignUp[]; } export type AccountSignUp = 'username' | 'username-password' | 'email-password' | 'email-link' | 'phone' | 'oauth' | 'other'; export const ACCOUNT_SIGN_UPS: readonly AccountSignUp[] = ['username', 'username-password', 'email-password', 'email-link', 'phone', 'oauth', 'other']; /** How a reviewer gets past a sign-in (App Review demo account, Play App content > App access). The credentials * themselves are never in the config: "reviewDemoAccount" in .storebound/credentials.json or REVIEW_DEMO_USER / * REVIEW_DEMO_PASSWORD. See the review-access skill. */ export interface ReviewAccess { /** Some or all features need a sign-in: App Review gets demoAccountRequired + the demo account. */ signInRequired: boolean; /** Steps for the reviewer, appended to the App Review notes, e.g. "Sign in with the demo account, open Projects." */ instructions?: string; } /** App Privacy: does the app (or an SDK in it) track users across other companies' apps and websites? true needs * App Tracking Transparency (NSUserTrackingUsageDescription + the prompt) and a privacy manifest that declares it: * the SDK's own manifest when an SDK tracks (ad network, mediation, attribution), the app's only with trackingDomains. */ export interface AppPrivacyConfig { tracking?: boolean; /** Domains the app's OWN code sends tracking data to: the app manifest's NSPrivacyTrackingDomains. The app manifest * says NSPrivacyTracking true only with this non-empty list (true with an empty list is ITMS-91064 at upload); * SDK domains stay in each SDK's manifest. Usually unset. */ trackingDomains?: string[]; /** The owner's App Store App Privacy answers per data type, keyed by Apple's type id (DEVICE_ID, USER_ID, * GAMEPLAY_CONTENT, ADVERTISING_DATA ...; cli/lib/app-privacy.ts). `console fill apple-app-privacy` fills them as * certain answers and they win over what storebound derives from dataSafety; types with no Play equivalent work * from here alone. `check` compares them with the iOS project (ATT string, PrivacyInfo.xcprivacy). */ types?: Partial>; } export type SubscriptionPeriod = 'P1W' | 'P1M' | 'P2M' | 'P3M' | 'P6M' | 'P1Y'; /** Introductory offer for new subscribers. Apple: subscriptionIntroductoryOffers (first-time subscribers of the group, * per territory). Play: an offer on the base plan for users who never had this subscription. */ export interface IntroOffer { /** free = free trial; pay-as-you-go = a lower price for `periods` billing periods; pay-up-front = one price for `duration`. */ type: 'free' | 'pay-as-you-go' | 'pay-up-front'; /** free / pay-up-front: P3D, P1W, P2W, P1M, P2M, P3M, P6M or P1Y. pay-as-you-go: the subscription period (default). */ duration?: string; /** pay-as-you-go: number of discounted periods (1-12). */ periods?: number; /** pay-as-you-go: price per period, pay-up-front: the whole price, in USD (Apple: equalized price points; Play: converted). */ usd?: number; /** Play offer id (lowercase letters, digits, hyphens). Default "intro". */ playOfferId?: string; } /** A promotional / developer-determined offer. Apple: subscriptionPromotionalOffers (offerCode = id). Play: a base * plan offer with offer tag = id and no acquisition targeting (your app decides who sees it). */ export interface SubscriptionOfferDef { /** Apple offer code / Play offer id: lowercase letters, digits, hyphens (Play rule; Apple accepts it too). */ id: string; /** Apple reference name (default: the id). */ name?: string; type: 'free' | 'pay-as-you-go' | 'pay-up-front'; /** free / pay-up-front: P3D ... P1Y; pay-as-you-go: the subscription period (default). */ duration?: string; /** pay-as-you-go: number of discounted periods (1-12). */ periods?: number; /** USD price (per period for pay-as-you-go, total for pay-up-front). */ usd?: number; } /** Apple subscription offer code campaign. Custom codes (e.g. "SPRING26") are created only from a terminal run that * confirms; one-time-use codes are generated by you in App Store Connect (or later with the API). */ export interface OfferCodeDef { /** Reference name of the offer code campaign. */ name: string; /** Who may redeem: NEW (never subscribed), EXISTING, EXPIRED. */ eligibility: ('NEW' | 'EXISTING' | 'EXPIRED')[]; /** true = stack with the introductory offer (STACK_WITH_INTRO_OFFERS), false = replace it (default). */ stackWithIntro?: boolean; type: 'free' | 'pay-as-you-go' | 'pay-up-front'; duration?: string; periods?: number; usd?: number; /** Custom codes: the code, how many redemptions, optional expiry (YYYY-MM-DD). */ customCodes?: { code: string; count: number; expires?: string }[]; } export interface SubscriptionListing { /** Apple display name (30) / Play title. */ title: string; /** Apple description (45) / Play description. */ description: string; /** Play only: up to 4 benefits shown on the purchase screen (40 characters each). */ benefits?: string[]; } export interface Subscription { /** Product id, same on both stores, e.g. "com.example.app.pro_monthly". */ id: string; period: SubscriptionPeriod; /** Rank inside the group, 1 = highest level of service (Apple groupLevel; up / downgrades follow it). */ level: number; /** Base price per period in USD. Apple: the USA price point and its equalizations; Play: convertRegionPrices. */ usd: number; /** Regions set by hand (Apple needs a price point with exactly that value). */ overrides?: PriceOverride[]; /** Language id -> display name + description (+ Play benefits). */ listings: Record; /** Apple only: internal reference name (defaults to the first listing title). */ referenceName?: string; /** Apple only: note for the reviewer on this subscription. */ reviewNote?: string; /** Apple only: review screenshot of the purchase screen (path relative to the project root). */ reviewScreenshot?: string; familySharable?: boolean; intro?: IntroOffer; /** More offers people get when your app decides (Apple promotional offers, signed by your server; Play offers * without acquisition targeting, picked by offer tag in your app). */ offers?: SubscriptionOfferDef[]; /** Apple subscription offer codes (redeemed in the App Store or in the app), with optional custom codes. */ offerCodes?: OfferCodeDef[]; /** Sell this subscription only on these platforms (default: all of app.platforms). */ platforms?: Platform[]; /** Play base plan id (lowercase letters, digits, hyphens). Default from the period: weekly, monthly, yearly, ... */ basePlanId?: string; /** Play grace period (ISO 8601: P3D, P7D, P14D, P30D). Default: Play's default. */ gracePeriod?: string; /** Apple win-back offers for lapsed subscribers (`storebound apple win-back`; the subscription must be approved). */ winBack?: WinBackDef[]; } /** One subscription group. Apple: a subscription group (people can hold one subscription per group and move between * its levels). Play has no groups: each subscription becomes a Play subscription with one auto-renewing base plan. */ export interface SubscriptionGroup { /** Apple reference name of the group (internal, unique per app). */ name: string; /** Language id -> group display name on the App Store (+ optional custom app name). */ listings: Record; subscriptions: Subscription[]; } /** Play target audience age groups (App content > Target audience and content). */ export type PlayAgeGroup = '0-5' | '6-8' | '9-12' | '13-15' | '16-17' | '18+'; export const PLAY_AGE_GROUPS: readonly PlayAgeGroup[] = ['0-5', '6-8', '9-12', '13-15', '16-17', '18+']; export const PLAY_BILLING_PROGRAMS = ['user-choice-billing', 'alternative-billing', 'external-offers', 'external-content-links'] as const; export interface ShipConfig { app: { name: string; /** game | app | subscription-app (see AppProfile). Default: game for configs written before 0.5.0. */ profile?: AppProfile; /** App identity: the platforms this one app ships on. Default: ios when there is an `apple` section, android when * there is a `play` section. `apple` / `play` hold the per-platform settings; languages, listings, products and * subscriptions are shared, with `platforms` on an item to sell it on one store only. */ platforms?: Platform[]; copyright?: string; privacyPolicyUrl: string; /** Terms of Use / EULA page. Needed with subscriptions (Apple 3.1.2: in the app and in the metadata). */ termsUrl?: string; supportUrl: string; marketingUrl?: string; website: string; contactEmail: string; }; /** The credential profile (~/.storebound/credentials/.json: the Apple team key and Play service account, * shared by every project and the desktop app) this app's store commands use. Default: the only profile on this * machine, or the one named "default". `--account ` overrides it for one run. A name, never a secret. */ account?: string; /** Accounts in the app: account deletion checks (Apple 5.1.1(v), Play Data safety delete-account URL). */ accounts?: AccountsConfig; /** Sign-in for store reviewers (demo account; credentials live in .storebound/ only). */ reviewAccess?: ReviewAccess; /** What players / users can do with each other (chat, sharing, friends). Answers the Play IARC questionnaire's * Miscellaneous > user interaction questions (`console plan play-content-rating`). */ social?: SocialConfig; languages: Language[]; play?: { packageName: string; defaultLanguage?: string; listing?: string; icon?: string; /** The Play developer account id (the digits in play.google.com/console/u/0/developers//...). Optional: with * one Google login in several developer accounts, `storebound console fill` finds the app by package name and * remembers the account (~/.storebound/console-accounts.json); this skips that search. */ developerId?: string; /** The Play Console app id (the digits in /console/u/0/developers//app//app-dashboard, shown * after Create app). Optional: `storebound console plan` deep-links the App content forms with it. */ appId?: string; /** The developer account's display name on Play Console's "Choose a developer account" page (e.g. an * organisation account). `storebound console fill play-app-record` picks the account by it when there is no * package in any account yet and no developerId; ambiguous or unknown -> it stops and lists the accounts. */ developerAccount?: string; /** The Play app name when it differs from app.name (Create app form; at most 30 characters). */ name?: string; /** Free (true) or paid (false) on Play. A free app can never become paid later; storebound flags it for the owner. */ free?: boolean; /** Play Console > Store presence > Store settings > App category, by Play's English name ("Strategy", "Puzzle", * "Tools"; PLAY_GAME_CATEGORIES / PLAY_APP_CATEGORIES). The Play Developer API has no category field (`play * listing` cannot set it): an agent sets it with `storebound console plan play-store-settings`. Default: mapped * from apple.info.categories when there is an exact match (GAMES_STRATEGY -> Strategy). */ category?: string; /** Countries / regions of the production track (ISO 3166-1 alpha-2, e.g. ["US", "GB", "TR"]). Default: all. The API's * edits.countryAvailability is read-only, so an agent sets it in Play Console with `storebound console plan * play-production-countries` before the first production release; `validate` reports a track with 0 countries. */ countries?: string[]; /** Play Console > Publishing overview > Managed publishing, which only the console sets (no API). true: approved * changes wait until the owner publishes them. Off by default on Play: a release goes live automatically as soon as * Google approves it, and `play release` / the release plan say so. Record here what the console shows. */ managedPublishing?: boolean; /** Play 7" and 10" tablet screenshots (renders tab7/ and tab10/). Optional on Play (required: icon, feature graphic, * at least 2 phone screenshots), but without them the app is not eligible for tablet featuring. Default: true when * tablet renders exist under the renders folder, else false. false = `shots render` / `shots check`, `validate` and * `play images` never ask for them; true = missing tablet sets are errors. */ tablets?: boolean; /** Promo video: a YouTube URL (public or unlisted, ads off, embeddable). Play takes no video upload. Sent by `play listing`. */ video?: string; /** Cloud Storage bucket of the Play Console report exports, e.g. "pubsite_prod_rev_" (Play Console > * Download reports > Statistics > Copy Cloud Storage URI). Used by `storebound analytics snapshot`. */ reportsBucket?: string; /** `storebound play build`: Gradle project folder, module, flavor (found automatically when unset). */ build?: PlayBuildConfig; /** Shell command `play build` runs first in the project root; overrides build.prebuild for Play. */ prebuild?: string; /** Play Console > App content > Target audience and content: the age groups you select. Any group under 13 puts * the app under the Families policy (`storebound scan` checks ad SDKs, the advertising ID and location for it). */ targetAges?: PlayAgeGroup[]; /** Developer account type. New personal accounts (created after 2023-11-13) must run a closed test with at least * 12 opted-in testers for 14 days before they can apply for production (`storebound scan` reminds you). */ accountType?: 'personal' | 'organization'; /** When the Play developer account was created (YYYY-MM-DD), for the personal-account testing requirement. */ accountCreated?: string; /** Play billing programs the app is enrolled in that allow another payment method next to Play Billing for * digital goods (user choice billing, alternative billing only, external offers / external content links). */ billingPrograms?: ('user-choice-billing' | 'alternative-billing' | 'external-offers' | 'external-content-links')[]; }; apple?: { bundleId: string; appId?: string; primaryLocale?: string; listing?: string; info?: AppleAppInfo; reviewShots?: string; /** App Store name in the primary locale (at most 30 characters) when it differs from app.name, e.g. because the plain * name is taken on the App Store ("STAR DASH: Stone Age to 2100"). Default app.name. A `### name` block in the * App Store listing file wins for its locale. app.name stays the Play / on-device name. */ name?: string; /** Apple Developer Team ID (10 characters, Membership details). apple-app-site-association and ad-hoc export use it. */ teamId?: string; /** Ad-hoc / release-testing export (`storebound apple adhoc`). */ adhoc?: AdhocConfig; /** * Export compliance answer for every build (`storebound apple encryption`, `validate`). false ONLY when the app uses * no encryption beyond what the OS provides (HTTPS / TLS through the system, standard auth). Never set it to false * to skip the question: a wrong answer is a legal problem, not a store one. Unset = storebound will not answer. */ usesNonExemptEncryption?: boolean; /** Screenshot display types every locale must have (validate). Default ["APP_IPHONE_67"] (+ "APP_IPAD_PRO_3GEN_129" with ipad). */ screenshotTypes?: string[]; /** The app runs on iPad: renders, checks and uploads 13" iPad screenshots (2064 x 2752) and iPad app previews. */ ipad?: boolean; /** * Also render, check and upload a 6.3" iPhone set (Dynamic Island, medium display, 1206 x 2622 -> APP_IPHONE_61). * Off by default: App Store Connect lists a medium-display screenshot as required for iPhone but fills it by * scaling the 6.5" set down when it is missing, and the 6.9" set covers the large displays. Turn it on for * pixel-exact art on 6.3" phones, or if Apple stops scaling. (Screenshot specifications, App Store Connect Help.) */ iphoneMedium?: boolean; /** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */ testflight?: TfConfig; /** Certificates, profiles and capabilities through the API (`storebound apple signing`), signing style of `apple build`. */ signing?: AppleSigning; /** `storebound apple build`: workspace / project, scheme, configuration, output folder (found automatically when unset). */ build?: AppleBuildConfig; /** Shell command `apple build` runs first in the project root; overrides build.prebuild for the App Store. */ prebuild?: string; /** Promoted in-app purchases on the product page, in order (`storebound apple promoted`; at most 20). */ promoted?: PromotedConfig; /** Custom product pages (`storebound apple cpp`; at most 70). */ customProductPages?: CustomProductPageDef[]; /** In-app events (`storebound apple events`). */ events?: AppEventDef[]; }; products?: Product[]; /** Auto-renewable subscriptions (`storebound apple subscriptions`, `storebound play subscriptions`). */ subscriptions?: SubscriptionGroup[]; /** Price sheet for `storebound prices export | import` (product x store x region). */ prices?: { /** Purchasing power parity proposals (`storebound prices ppp`). */ ppp?: PppConfig; /** CSV path. Default store/prices.csv. */ csv?: string; /** App Store base territory of every price schedule (alpha-3). Default: the current one, else USA. */ baseTerritory?: string; }; dataSafety?: DataSafety; /** App Store App Privacy answers storebound cannot derive from dataSafety (App Privacy has no API: the owner enters * them in App Store Connect). `storebound scan` proposes them from the detected SDKs and code. */ appPrivacy?: AppPrivacyConfig; /** `storebound scan`: findings the owner has looked at and accepts, by rule id (with the reason, shown in the report). */ scan?: { ignore?: { rule: string; store?: 'apple' | 'play'; /** Only findings whose TITLE contains this text (one check of a rule that has several), case-insensitive. */ match?: string; /** Only findings with an evidence line ("file:line text", as the report prints it) containing this text, e.g. * "src/main.ts:12" or a code fragment. Combine with match to narrow further. */ evidence?: string; reason: string; }[]; }; /** Ads: `validate` checks that /app-ads.txt is reachable and contains every line listed here. */ ads?: { appAdsTxt?: string[] }; /** Rendered store graphics dir (///NN.png|jpg). Defaults to shots.out. */ renders?: string; shots?: ShotsConfig; icons?: IconsConfig; /** `storebound originality`: the owner's other apps to compare with (folders with a storebound.config.ts, or a * workspace folder holding several), and the score above which a pair is flagged. */ originality?: OriginalityConfig; /** Universal links / app links files, Smart App Banner and a landing page (`storebound web init | check`). */ web?: WebConfig; /** Optional RevenueCat project the catalog is mirrored into (`storebound revenuecat diff | sync`). */ revenuecat?: RevenueCatConfig; /** Owner answers for console forms that have no API (`storebound console fill`); only what the rest of the config * cannot prove. Anything left out stays empty and highlighted in the page. */ console?: ConsoleConfig; /** Native builds (`apple build`, `play build`). */ build?: { /** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a * Capacitor app (the native build ships the web assets cap sync copied last). Printed, output streamed, a nonzero * exit stops the build; `--no-prebuild` skips it; inside one `flow run` it runs once, not per store. * apple.prebuild / play.prebuild override it per store. */ prebuild?: string; }; } // ---- growth extras ---- export interface PppConfig { /** Dataset: worldbank (bundled World Bank ICP PPP factors, default), bigmac (The Economist Big Mac index, downloaded to .storebound/ by `prices ppp --update-bigmac`) or file (`file`). */ source?: 'worldbank' | 'bigmac' | 'file'; /** CSV for source "file": region (ISO alpha-2), factor (local currency per US dollar at PPP), currency, fx (market rate, optional). */ file?: string; /** Lowest price as a share of the US price after the PPP adjustment (default 0.3). */ floor?: number; /** Highest share of the US price (default 1.2). */ ceiling?: number; /** Price sheet the proposal is written to (default store/prices-ppp.csv). */ out?: string; } export interface WebConfig { /** Domain that serves the files, e.g. "example.com" (no scheme, no path). */ domain: string; /** Paths the apps open as universal links / app links (AASA components, Android intent filter). Default ["/*"]. */ paths?: string[]; /** Paths that must stay in the browser (AASA "exclude": true components). */ exclude?: string[]; /** Folder the generated files are written to (default "web"); deploy its contents to the domain root. */ out?: string; /** Play app signing certificate SHA-256 fingerprints (AB:CD:... ; Play Console > Test and release > App signing). */ androidSha256?: string[]; /** Also list the app under "webcredentials" (password AutoFill) in apple-app-site-association. */ webcredentials?: boolean; /** Write the minimal landing page template (index.html with store badges, privacy and support links). */ landing?: boolean; } export interface AdhocConfig { /** ExportOptions.plist method: release-testing (Xcode 15.3+ name) or ad-hoc (older Xcode). Default release-testing. */ method?: 'release-testing' | 'ad-hoc'; /** Devices CSV (name, udid, platform) in .storebound/ (gitignored: device ids are personal data). Default .storebound/devices.csv. */ devices?: string; /** Provisioning profile name for manual signing (default: automatic signing). */ profile?: string; } export interface RevenueCatConfig { /** RevenueCat project id (Project settings), e.g. "proj1ab2c3d4". */ projectId: string; /** RevenueCat app ids of the App Store and Play Store apps in that project (Project settings > Apps). */ apps?: { apple?: string; play?: string }; /** Entitlement lookup key -> product / subscription ids that unlock it. Default: "pro" with every non-consumable, * non-renewing product and subscription. */ entitlements?: Record; /** Lookup key of the offering storebound keeps in sync (default "default"). */ offering?: string; } /** IARC questionnaire topics storebound knows (Play Console > Policy > App content > Content rating). */ export const IARC_QUESTIONS = ['violence', 'fear', 'sexuality', 'language', 'controlledSubstances', 'crudeHumor', 'gambling', 'simulatedGambling', 'lootBoxes', 'userInteraction', 'sharesLocation', 'digitalPurchases', 'unrestrictedInternet'] as const; export type IarcQuestion = typeof IARC_QUESTIONS[number]; /** IARC app categories (the first page of the questionnaire). */ export const RATING_CATEGORIES = ['game', 'social', 'other'] as const; /** Play target age groups (Target audience and content > Target age). */ export const AGE_GROUPS = ['5-under', '6-8', '9-12', '13-15', '16-17', '18-over'] as const; export type AgeGroup = typeof AGE_GROUPS[number]; /** Users interacting with each other (Play IARC Miscellaneous; see ShipConfig.social). */ export interface SocialConfig { /** Users can chat, message, post or share content with each other (in-game chat, comments, shared levels). */ interaction: boolean; /** Users can block other users. */ block?: boolean; /** Users can report other users or their content. */ report?: boolean; /** Chat or shared content is moderated (people or filters). */ moderation?: boolean; /** Interaction is limited to friends the user accepted (no strangers). */ friendsOnly?: boolean; /** The app shares the user's location with other users. */ sharesLocation?: boolean; } /** A follow-up the plan does not model, keyed by the question text as the console shows it: true / false (Yes / No * or tick / untick), a radio option label, or the labels of the checkboxes to tick. */ export type IarcOther = Record; /** * Follow-up questions of the Play IARC questionnaire (one long page: each Yes reveals its follow-ups; recorded on a * real run 2026-10-12). Asked only when the section's answer is Yes (Miscellaneous always). Option labels are the * console's own words (the agent matches them by "starts with"). Anything left out is asked of the owner in the one * batch at the start (`storebound console plan play-content-rating`). */ export interface IarcDetails { violence?: { /** Violence against humans / human-like characters (checkbox). */ humans?: boolean; /** Violence against non-humans: fantasy creatures, animals, robots (checkbox). */ nonHumans?: boolean; /** The setting option as the console labels it (a fantasy or a realistic setting ...). */ setting?: string; /** Cartoon / childlike art style. */ childlike?: boolean; /** The reaction checkboxes to tick (how characters react to harm), console labels ([] = none). */ reactions?: string[]; /** The presentation checkboxes to tick (how violence is shown), console labels ([] = none). */ presentation?: string[]; /** The blood level option, console label. */ blood?: string; /** Set in a historical war. */ historicalWar?: boolean; /** Violence against innocent / defenseless characters. */ innocents?: boolean; /** Fierce or realistic sounds of violence. */ fierceSounds?: boolean; other?: IarcOther; }; fear?: { other?: IarcOther }; sexuality?: { other?: IarcOther }; gambling?: { other?: IarcOther }; language?: { other?: IarcOther }; controlledSubstances?: { other?: IarcOther }; crudeHumor?: { other?: IarcOther }; /** Digital Purchases, after "purchases of digital goods" Yes: random items (default from products[].randomRewards), * then trading between players (default from social). */ digitalPurchases?: { lootBoxes?: boolean; playerTrading?: boolean; other?: IarcOther }; /** Miscellaneous > user interaction follow-ups (default from `social`). */ userInteraction?: { block?: boolean; report?: boolean; moderation?: boolean; friendsOnly?: boolean; other?: IarcOther }; /** The other Miscellaneous questions (always asked). */ miscellaneous?: { naziSymbols?: boolean; koreaHistory?: boolean; terrorism?: boolean; crimes?: boolean; other?: IarcOther }; } export const IARC_DETAIL_SECTIONS = ['violence', 'fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor', 'digitalPurchases', 'userInteraction', 'miscellaneous'] as const; export type IarcDetailSection = typeof IARC_DETAIL_SECTIONS[number]; /** Every key a details section may have and its kind (validation and the answer sheet); `other` is allowed everywhere. */ export const IARC_DETAIL_KEYS: Record> = { violence: { humans: 'boolean', nonHumans: 'boolean', setting: 'label', childlike: 'boolean', reactions: 'labels', presentation: 'labels', blood: 'label', historicalWar: 'boolean', innocents: 'boolean', fierceSounds: 'boolean' }, fear: {}, sexuality: {}, gambling: {}, language: {}, controlledSubstances: {}, crudeHumor: {}, digitalPurchases: { lootBoxes: 'boolean', playerTrading: 'boolean' }, userInteraction: { block: 'boolean', report: 'boolean', moderation: 'boolean', friendsOnly: 'boolean' }, miscellaneous: { naziSymbols: 'boolean', koreaHistory: 'boolean', terrorism: 'boolean', crimes: 'boolean' }, }; /** Play Console > Policy and programs > App content declarations without a form of their own in ConsoleConfig * (`storebound console plan play-app-content` lists every card). */ export interface AppContentConfig { /** Government apps: developed by or on behalf of a government. */ government?: boolean; /** Financial features: false = "My app doesn't provide any financial features"; else the console labels to tick. */ financialFeatures?: false | string[]; /** Health apps: false = no health features; else the console labels to tick. */ healthFeatures?: false | string[]; /** Advertising ID: the app (or an SDK in it) uses the Android advertising ID. Default: yes with ads.appAdsTxt or a * dataSafety Device or other IDs entry. */ advertisingId?: boolean; } export interface ConsoleConfig { /** App Store Connect > Apps > + > New App: the SKU (internal, never shown) and who in the team sees the app. */ appRecord?: { sku?: string; userAccess?: 'full' | 'limited' }; /** Play IARC questionnaire: the category, the answers you decided (true = Yes) and their follow-ups (details). */ contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial>; details?: IarcDetails }; /** Play Target audience and content, and the Ads declaration. */ targetAudience?: { ages?: AgeGroup[]; appealsToChildren?: boolean; containsAds?: boolean }; /** Play App content declarations: government apps, financial features, health apps, advertising ID. */ appContent?: AppContentConfig; } /** Play Console's game categories (Store settings > App category, app type Game). */ export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing', 'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const; /** Play Console's app categories (Store settings > App category, app type App). */ export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating', 'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle', 'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping', 'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const; /** App Store category ids with an exact Play counterpart. */ const APPLE_TO_PLAY: Record = { GAMES_ACTION: 'Action', GAMES_ADVENTURE: 'Adventure', GAMES_BOARD: 'Board', GAMES_CARD: 'Card', GAMES_CASINO: 'Casino', GAMES_CASUAL: 'Casual', GAMES_EDUCATIONAL: 'Educational', GAMES_MUSIC: 'Music', GAMES_PUZZLE: 'Puzzle', GAMES_RACING: 'Racing', GAMES_ROLE_PLAYING: 'Role Playing', GAMES_SIMULATION: 'Simulation', GAMES_SPORTS: 'Sports', GAMES_STRATEGY: 'Strategy', GAMES_TRIVIA: 'Trivia', GAMES_WORD: 'Word', BOOKS: 'Books & Reference', REFERENCE: 'Books & Reference', BUSINESS: 'Business', EDUCATION: 'Education', ENTERTAINMENT: 'Entertainment', FINANCE: 'Finance', FOOD_AND_DRINK: 'Food & Drink', HEALTH_AND_FITNESS: 'Health & Fitness', LIFESTYLE: 'Lifestyle', MEDICAL: 'Medical', MUSIC: 'Music & Audio', NAVIGATION: 'Maps & Navigation', NEWS: 'News & Magazines', PRODUCTIVITY: 'Productivity', SHOPPING: 'Shopping', SOCIAL_NETWORKING: 'Social', SPORTS: 'Sports', TRAVEL: 'Travel & Local', UTILITIES: 'Tools', WEATHER: 'Weather', GRAPHICS_AND_DESIGN: 'Art & Design', }; /** The Play App category and where it comes from: play.category, else an exact match of apple.info.categories (the * first subcategory first, for games), else null. */ export function playCategoryOf(c: Pick): { category: string; source: string } | null { if (c.play?.category) return { category: c.play.category, source: 'play.category' }; const k = c.apple?.info?.categories; for (const id of [k?.primarySubOne, k?.primary]) if (id && APPLE_TO_PLAY[id]) return { category: APPLE_TO_PLAY[id]!, source: `apple.info.categories (${id})` }; return null; } const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; const SHA256_RE = /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/; /** Problems of the growth-extras sections (web, revenuecat, prices.ppp, apple.teamId / adhoc). Pure. */ export function validateExtras(cfg: Partial): string[] { const e: string[] = []; const badCmd = (v: unknown) => v !== undefined && (typeof v !== 'string' || !v.trim()); if (cfg.build !== undefined && (typeof cfg.build !== 'object' || cfg.build === null)) e.push('build: { prebuild?: "" }'); if (badCmd(cfg.build?.prebuild)) e.push('build.prebuild: a shell command run in the project root before apple build / play build, e.g. "npm run build && npx cap sync"'); if (badCmd(cfg.apple?.prebuild)) e.push('apple.prebuild: a shell command (overrides build.prebuild for apple build)'); if (badCmd(cfg.play?.prebuild)) e.push('play.prebuild: a shell command (overrides build.prebuild for play build)'); if (cfg.apple?.teamId !== undefined &&!/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)'); if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/'); if (cfg.play?.appId !== undefined && !/^\d{1,25}$/.test(cfg.play.appId)) e.push('play.appId: the digits of play.google.com/console/u/0/developers//app//app-dashboard'); if (cfg.play?.developerAccount !== undefined && !(typeof cfg.play.developerAccount === 'string' && cfg.play.developerAccount.trim() && cfg.play.developerAccount.length <= 100)) e.push('play.developerAccount: the developer account name as Play Console shows it (1-100 characters)'); if (cfg.play?.name !== undefined && !(typeof cfg.play.name === 'string' && cfg.play.name.trim() && [...cfg.play.name].length <= 30)) e.push('play.name: 1-30 characters (Play app name)'); if (cfg.play?.free !== undefined && typeof cfg.play.free !== 'boolean') e.push('play.free: true (free) | false (paid)'); const ad = cfg.apple?.adhoc; if (ad?.method !== undefined && ad.method !== 'release-testing' && ad.method !== 'ad-hoc') e.push('apple.adhoc.method: release-testing | ad-hoc'); const ppp = cfg.prices?.ppp; if (ppp) { if (ppp.source !== undefined && !['worldbank', 'bigmac', 'file'].includes(ppp.source)) e.push('prices.ppp.source: worldbank | bigmac | file'); if (ppp.source === 'file' && !ppp.file) e.push('prices.ppp.file: needed with source "file"'); for (const k of ['floor', 'ceiling'] as const) if (ppp[k] !== undefined && !(typeof ppp[k] === 'number' && ppp[k]! > 0 && ppp[k]! <= 5)) e.push(`prices.ppp.${k}: a share of the US price, e.g. 0.3`); if (ppp.floor !== undefined && ppp.ceiling !== undefined && ppp.floor > ppp.ceiling) e.push('prices.ppp: floor is above ceiling'); } const w = cfg.web; if (w) { if (!DOMAIN_RE.test(w.domain ?? '')) e.push('web.domain: a domain name like example.com (no https://, no path)'); for (const p of [...(w.paths ?? []), ...(w.exclude ?? [])]) if (typeof p !== 'string' || !p.startsWith('/')) e.push(`web.paths: "${p}" must start with /`); for (const f of w.androidSha256 ?? []) if (!SHA256_RE.test(f)) e.push(`web.androidSha256: "${f}" is not a SHA-256 fingerprint (32 hex pairs, AB:CD:...)`); } const rc = cfg.revenuecat; if (rc) { if (!/^[A-Za-z0-9_-]{4,64}$/.test(rc.projectId ?? '')) e.push('revenuecat.projectId: the RevenueCat project id (Project settings)'); for (const [k, ids] of Object.entries(rc.entitlements ?? {})) { if (!/^[A-Za-z0-9._-]{1,200}$/.test(k)) e.push(`revenuecat.entitlements.${k}: letters, digits, ".", "_", "-"`); if (!Array.isArray(ids) || !ids.length) e.push(`revenuecat.entitlements.${k}: at least one product id`); } if (rc.offering !== undefined && !/^[A-Za-z0-9._-]{1,200}$/.test(rc.offering)) e.push('revenuecat.offering: letters, digits, ".", "_", "-"'); } if (cfg.console) e.push(...validateConsole(cfg.console)); return e; } function validateConsole(c: ConsoleConfig): string[] { const e: string[] = []; const r = c.appRecord, cr = c.contentRating, ta = c.targetAudience; if (r?.sku !== undefined && !/^[A-Za-z0-9._-]{1,100}$/.test(r.sku)) e.push('console.appRecord.sku: letters, digits, ".", "_", "-" (it can never change)'); if (r?.userAccess !== undefined && r.userAccess !== 'full' && r.userAccess !== 'limited') e.push('console.appRecord.userAccess: full | limited'); if (cr?.category !== undefined && !RATING_CATEGORIES.includes(cr.category)) e.push(`console.contentRating.category: one of ${RATING_CATEGORIES.join(', ')}`); for (const [k, v] of Object.entries(cr?.answers ?? {})) if (!(IARC_QUESTIONS as readonly string[]).includes(k) || typeof v !== 'boolean') e.push(`console.contentRating.answers.${k}: one of ${IARC_QUESTIONS.join(', ')}, true | false`); for (const a of ta?.ages ?? []) if (!AGE_GROUPS.includes(a)) e.push(`console.targetAudience.ages: "${a}" is not one of ${AGE_GROUPS.join(', ')}`); for (const k of ['appealsToChildren', 'containsAds'] as const) if (ta?.[k] !== undefined && typeof ta[k] !== 'boolean') e.push(`console.targetAudience.${k}: true | false`); e.push(...validateIarcDetails(cr?.details)); const ac = c.appContent; if (ac !== undefined) { if (!ac || typeof ac !== 'object') e.push('console.appContent: an object'); else { for (const k of ['government', 'advertisingId'] as const) if (ac[k] !== undefined && typeof ac[k] !== 'boolean') e.push(`console.appContent.${k}: true | false`); for (const k of ['financialFeatures', 'healthFeatures'] as const) { const v = ac[k]; if (v !== undefined && v !== false && !(Array.isArray(v) && v.length && v.every(x => typeof x === 'string' && x.trim()))) e.push(`console.appContent.${k}: false (none) or a list of the console's labels to tick`); } } } return e; } function validateIarcDetails(d: IarcDetails | undefined): string[] { if (d === undefined) return []; if (!d || typeof d !== 'object' || Array.isArray(d)) return ['console.contentRating.details: an object']; const e: string[] = []; const text = (x: unknown) => typeof x === 'string' && !!x.trim(); const isOther = (o: unknown) => !!o && typeof o === 'object' && !Array.isArray(o) && Object.entries(o).every(([k, v]) => k.trim() && (typeof v === 'boolean' || text(v) || (Array.isArray(v) && v.every(text)))); for (const [sec, body] of Object.entries(d)) { const at = `console.contentRating.details.${sec}`; const keys = (IARC_DETAIL_KEYS as Record>)[sec]; if (!keys) { e.push(`${at}: unknown section (${IARC_DETAIL_SECTIONS.join(', ')})`); continue; } if (!body || typeof body !== 'object' || Array.isArray(body)) { e.push(`${at}: an object`); continue; } for (const [k, v] of Object.entries(body as Record)) { if (k === 'other') { if (!isOther(v)) e.push(`${at}.other: { '': true | false | '