check blocks an apple app without app.copyright (App Store Connect requires it; never filled in automatically); validate reads the live version's copyright and says whether the store has one; round-trip test that the config writers (upsertBlock, upsertNestedString, removeKeyPath) keep unknown keys and comments; tests

This commit is contained in:
CRTkafa 2026-10-14 14:37:47 +08:00
parent 73db689386
commit e9507d8884
5 changed files with 79 additions and 3 deletions

View file

@ -5,6 +5,7 @@
// storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
import { templateBlockers } from './lib/template-answers';
import { COPYRIGHT_MISSING } from './lib/validate-rules';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources, typeIdOf } from './lib/app-privacy';
@ -143,6 +144,7 @@ export async function run(ctx: Ctx): Promise<void> {
const { runScan } = await import('./lib/scan');
try { for (const n of (await runScan(ctx.loaded.root, { config: ctx.loaded.path })).report.notes) if (n.startsWith('scan.ignore[')) note(n); } catch (e) { note(`scan.ignore not checked: ${(e as Error).message}`); }
}
if (config.apple && !config.app.copyright?.trim()) problems.push(COPYRIGHT_MISSING);
if (config.dataSafety) tryDo('data safety', () => {
const rows = dataSafetyRows(config.dataSafety!, config.accounts);
// conditional required rows (e.g. the delete-account URL once accounts exist): Play answers 400 without them

View file

@ -43,7 +43,9 @@ export function rendersFindings(store: 'apple' | 'play', r: RendersCheck | undef
}
export interface AppleSnapshot {
version: { versionString: string; state: string } | null;
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[];
@ -140,6 +142,17 @@ const ageOpen = (r: Record<string, unknown>): { open: string[]; soft: string[] }
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}`);
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' }];
}
export function appleRules(s: AppleSnapshot): Finding[] {
const f: Finding[] = [];
const add = (level: Level, code: string, msg: string, fix?: string) => f.push({ store: 'apple', level, code, msg, fix });
@ -148,6 +161,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
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 {

View file

@ -103,7 +103,8 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
subscriptions, expectedSubscriptions: wantSubs.map(({ group, sub }) => ({ id: sub.id, group: group.name,
locales: Object.keys(sub.listings).map(id => appleLocale.get(id)).filter((x): x is string => !!x) })),
profile: await collectProfile(ctx.loaded),
version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState } : null,
version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState, copyright: v.attributes.copyright ?? null } : null,
configCopyright: config.app.copyright,
build, locales, reviewDetail, ageRating,
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
requiredDisplayTypes: requiredDisplayTypes(config.apple),