storebound/cli/lib/profile.ts

52 lines
5 KiB
TypeScript

// 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;
}