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