// Offline profile checks for `storebound check`: what an app with accounts, a sign-in for review or subscriptions // must have in the config before any store sees it. Pure. The online counterparts (URLs answer, the demo account is // on App Review, subscription states) are in validate-rules.ts (profileRules, appleSubscriptionRules). import { profileOf, type ShipConfig } from './config'; import { accountCreationOf } from './declarations'; import { allSubscriptions } from './subscriptions'; export interface ProfileIssue { level: 'error' | 'warn'; msg: string } /** firstRunGate: where the code shows a first-run gate (name entry, code / transfer entry, account transfer UI), from * scan's SIGNALS.firstRunGate; check fills it for a Play app without reviewer instructions. */ export function profileIssues(config: ShipConfig, o: { demoAccount: boolean; firstRunGate?: string[] }): ProfileIssue[] { const out: ProfileIssue[] = []; const err = (msg: string) => out.push({ level: 'error', msg }); const warn = (msg: string) => out.push({ level: 'warn', msg }); const accounts = !!config.accounts?.enabled; if (config.dataSafety) for (const k of ['collectsData', 'encryptedInTransit'] as const) if (typeof config.dataSafety[k] !== 'boolean') err(`dataSafety.${k}: not answered (true | false; ask the owner, store-declarations skill)`); const acm = config.dataSafety?.accountCreation ?? ['PSL_ACM_NONE']; if (accounts) { if (config.play && !config.accounts!.deletionUrl) err('accounts.enabled: set accounts.deletionUrl (Play asks for a web page to request account deletion; account-deletion skill)'); if (config.apple && !config.accounts!.inApp) warn('accounts.enabled: set accounts.inApp to where deletion starts in the app (Apple 5.1.1(v); it goes into the review notes)'); if (config.dataSafety) { if (acm.includes('PSL_ACM_NONE')) { const s = accountCreationOf(config.accounts!.signUp); err(`accounts.enabled but dataSafety.accountCreation says PSL_ACM_NONE: list the sign-up methods you offer${s ? ` (accounts.signUp means accountCreation: ${JSON.stringify(s)})` : ' (accounts.signUp, e.g. [\'username\'] for a username with no password, tells storebound scan which)'}`); } } } else if (config.dataSafety && !acm.includes('PSL_ACM_NONE')) warn('dataSafety.accountCreation lists sign-up methods but accounts.enabled is not set: add accounts { enabled: true, deletionUrl, inApp }'); if (config.reviewAccess?.signInRequired) { if (!o.demoAccount) err('App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill'); if (!config.reviewAccess.instructions) warn('reviewAccess.instructions: tell the reviewer how to sign in and where the main features are'); } else if (accounts && config.reviewAccess === undefined) warn('the app has accounts: set reviewAccess { signInRequired } so App Review and Play App access get a demo account when needed'); // a guest-play app (no sign-in) rejected by Play as a login wall: an empty name field with Continue disabled and an // "I have a code" link on the first screen read as a sign-in. Guest steps in reviewAccess.instructions turn Play's // Sign in details into a "restricted" entry without credentials that tells the reviewer how to get in. const gate = o.firstRunGate ?? []; const iap = !!config.products?.length || allSubscriptions(config.subscriptions).length > 0; if (config.play && !config.reviewAccess?.signInRequired && !config.reviewAccess?.instructions?.trim() && (gate.length || iap)) warn(`Play may reject as a login wall; add guest steps: ${[gate.length && `first-run gate in the code (${gate.slice(0, 2).join(', ')})`, iap && 'in-app purchases'].filter(Boolean).join(' and ')} with no reviewAccess.instructions. Set reviewAccess.instructions to English steps for a reviewer without an account (e.g. "No account needed: tap Continue, the name is pre-filled; the code link is only for moving progress"); the play-sign-in-details plan then declares a guest entry (store-pitfalls: guest-play apps rejected as a login wall)`); const steps = config.reviewAccess?.instructions?.trim() ?? ''; if (config.play && steps.length > 500) warn(`reviewAccess.instructions: ${steps.length} characters; Play's Sign in details "Any other information" takes at most 500`); const subs = allSubscriptions(config.subscriptions); if (subs.length) { if (!config.app.termsUrl) err('subscriptions: set app.termsUrl (Terms of Use / EULA, linked in the app and the metadata; Apple 3.1.2)'); if (config.apple && subs.some(x => !x.sub.reviewScreenshot)) warn(`subscriptions without reviewScreenshot: ${subs.filter(x => !x.sub.reviewScreenshot).map(x => x.sub.id).join(', ')} (App Store review needs one each)`); } const p = profileOf(config); if (p === 'subscription-app' && !subs.length) warn('app.profile is subscription-app but there are no subscriptions in the config'); if (p === 'game' && config.shots?.style === 'app') warn('app.profile is game but shots.style is app (fine if intended)'); return out; }