ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree in every privacy manifest (true needs domains, domains need true); check, scan and privacy-manifest fail on either mismatch with the exact ITMS text; App Privacy tracking no longer demands the app manifest's true: SDK manifests that declare tracking (Pods, frameworks, SwiftPM, Carthage, plugins, the built IPA) are info, known tracking SDKs info, else a warning; privacy-manifest --propose writes true only with appPrivacy.trackingDomains; apple upload checks every manifest in the IPA (XML or binary) before any network call, apple build after the export. Pitfall 29

This commit is contained in:
CRTkafa 2026-10-12 10:27:21 +08:00
parent b7bfe89151
commit c8213be2b6
19 changed files with 631 additions and 44 deletions

View file

@ -2,6 +2,16 @@
## Unreleased
- ITMS-91064 (pitfall 29): a real build was refused as Invalid Binary a minute after the upload because the app's
PrivacyInfo.xcprivacy had NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (tracking came from an ad
SDK whose own manifest declared it). Rule in every manifest: true needs a non-empty list, a non-empty list needs
true; `check`, `scan` and `privacy-manifest` fail on either mismatch with the exact ITMS text. App Privacy
"used for tracking" no longer requires the app manifest's true: `check` finds SDK manifests that declare tracking
(Pods, frameworks, SwiftPM checkouts, Carthage, Capacitor plugins, the last built IPA) and says so as info, names
known tracking SDKs from the dependencies, and only warns when nothing declares it. `privacy-manifest --propose
[--tracking-domains]` prints the app manifest with true only for `appPrivacy.trackingDomains` (the app's own
domains); the scan proposal says false for SDK tracking. `apple upload` reads every manifest in the IPA (app root,
Frameworks, bundles; XML or binary plists) and stops before any network call; `apple build` checks the export.
- Play console steps without an API, from a real first release (2026-10-12; pitfalls 27 and 28). New agent-only
`console plan` / `console verify` forms (also in the MCP `console_plan` / `console_verify` lists):
`play-store-settings` (the App category: `play listing` cannot set it, so a new app showed "Not selected"; the

View file

@ -23,6 +23,7 @@ import { nextBuildNumber } from '../lib/release';
import { exportOptions, profileDirs } from '../lib/signing';
import { capacitorProject, webStep } from '../lib/web-assets';
import { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
import { ascClient } from './asc';
import { findBundleId } from './signing';
@ -155,6 +156,9 @@ export async function run(ctx: Ctx): Promise<void> {
const info = ipaInfo(join(exportPath, ipa));
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`);
// ITMS-91064 (Invalid Binary after the upload): NSPrivacyTracking / NSPrivacyTrackingDomains of every manifest in the IPA
const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa)));
if (privacy.length) throw new Error(`${relative(root, join(exportPath, ipa))} was built, but Apple would refuse it at upload:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest and build again.`);
const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle };
if (json) printJson(result);
done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`);

View file

@ -2,7 +2,9 @@
// storebound apple upload [<app.ipa>] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
// Default IPA: the newest in <apple.build.out or build/ios>/export. The bundle id, version and build number come from
// the IPA's Info.plist; the upload is refused when that build number is already on App Store Connect for the app
// (`apple next-build` / `apple build` pick a free one).
// (`apple next-build` / `apple build` pick a free one). Before any network call every PrivacyInfo.xcprivacy in the IPA
// (app root, Frameworks/*.framework, bundles; XML or binary) is checked: NSPrivacyTracking and NSPrivacyTrackingDomains
// must agree (ITMS-91064, Invalid Binary a minute after the upload), else nothing is uploaded.
// --via api (default): the App Store Connect API build upload (Build Uploads, API 4.1+): POST /v1/buildUploads,
// reserve /v1/buildUploadFiles, PUT the parts, commit with the MD5, then poll the upload state. Same API key, no
// Xcode needed, works on any OS.
@ -19,6 +21,7 @@ import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
import { altoolArgs, buildUploadReqs, ipaInfo, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode';
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
import { BUILD_WAIT_EXIT } from '../lib/build-ops';
import { type Asc, ascClient, type J } from './asc';
import { waitForBuild, waitMessage } from './testflight';
@ -207,6 +210,10 @@ export async function run(ctx: Ctx): Promise<void> {
} else ipa = resolve(ipa);
const info = ipaInfo(ipa);
if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`);
// every PrivacyInfo.xcprivacy in the app (root, Frameworks, bundles): Apple refuses the build minutes after the
// upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call
const privacy = bundleTrackingIssues(ipaManifests(ipa));
if (privacy.length) throw new Error(`${relative(root, ipa)}: not uploaded, Apple would refuse it as Invalid Binary:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest, rebuild (storebound apple build) and upload the new IPA.`);
const size = statSync(ipa).size;
const dry = a.has('dry');
const json = a.has('json');

View file

@ -4,9 +4,11 @@
// storebound check everything above
// 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 { existsSync, readFileSync } from 'node:fs';
import { dirname, relative } from 'node:path';
import { appPrivacyMismatches } from './lib/app-privacy';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources } from './lib/app-privacy';
import { type BundleManifest, bundleTrackingIssues, ipaManifests, projectSdkManifests, sdkOfProjectPath } from './lib/privacy-files';
import { trackingIssue } from './lib/privacy-manifest';
import { type AsoFinding, auditAso } from './lib/aso';
import { draftLocales } from './lib/aso-intel';
import { appleName, playCodes } from './lib/config';
@ -23,6 +25,11 @@ import { allSubscriptions } from './lib/subscriptions';
import { type CopyFile, validateCopy } from './shots/lib';
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
/** The newest .ipa in a folder (apple build's export), or null. */
function newestIpa(dir: string): string | null {
try { return readdirSync(dir).filter(f => f.endsWith('.ipa')).map(f => join(dir, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0] ?? null; } catch { return null; }
}
function aso(ctx: Ctx): void {
const { config, resolve } = ctx.loaded;
const appleMd = config.apple ? readFileSync(resolve(config.apple.listing ?? 'store/appstore.md'), 'utf8') : '';
@ -85,18 +92,39 @@ export async function run(ctx: Ctx): Promise<void> {
if (todo.length) note(`${relative(process.cwd(), f) || f}: not translated yet (status=translate): ${todo.join(', ')}`);
if (drafts.length) note(`${relative(process.cwd(), f) || f}: machine-translated drafts not reviewed: ${drafts.join(', ')} (storebound listing check, then listing reviewed --lang <ids>)`);
}
// App Privacy answers (appPrivacy.types) against the iOS project: ATT string, PrivacyInfo.xcprivacy tracking and types
// Privacy manifests (ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains agree in every manifest, the app's,
// the SDKs' and the last built IPA's) and the App Privacy answers against the iOS project: ATT string, a manifest
// that declares tracking (the app's or an SDK's), PrivacyInfo.xcprivacy types
const apTypes = config.appPrivacy?.types;
if (config.apple && apTypes && Object.keys(apTypes).length) {
const facts = detectProject(ctx.loaded.root);
if (!facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
else {
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
const found = appPrivacyMismatches(apTypes, { attString: facts.plist.has('NSUserTrackingUsageDescription'), manifest: own });
for (const f of found) if (f.level === 'error') problems.push(f.msg); else note(f.msg);
items.push({ what: 'app privacy', detail: `${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project` });
log('check', `app privacy: ${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project`);
const hasTypes = !!apTypes && Object.keys(apTypes).length > 0;
const facts = config.apple ? detectProject(ctx.loaded.root) : null;
if (hasTypes && facts && !facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
if (facts?.ios) {
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
const ownPaths = new Set(facts.privacyManifests.map(m => m.path));
const sdkMs = projectSdkManifests(ctx.loaded.root).filter(s => !ownPaths.has(s.manifest.path));
for (const m of facts.privacyManifests) { const i = trackingIssue(m, sdkOfProjectPath(m.path)); if (i) problems.push(i); }
for (const s of sdkMs) { const i = trackingIssue(s.manifest, s.sdk); if (i) problems.push(i); }
const ipa = newestIpa(resolve(join(config.apple!.build?.out ?? 'build/ios', 'export')));
let built: BundleManifest[] = [];
if (ipa) {
try { built = ipaManifests(ipa); } catch (e) { note(`${relative(process.cwd(), ipa)}: privacy manifests not readable (${(e as Error).message})`); }
for (const i of bundleTrackingIssues(built)) problems.push(`${relative(process.cwd(), ipa)}: ${i} Rebuild after the fix (storebound apple build).`);
}
const tracking: TrackingSources = {
sdkManifests: [...sdkMs.filter(s => s.manifest.tracking === true).map(s => s.sdk), ...built.filter(b => b.sdk && b.manifest.tracking === true).map(b => b.sdk!)],
trackerSdks: facts.sdks.filter(s => s.sdk.tracking).map(s => s.sdk.name),
};
const say = (f: PrivacyMismatch) => { if (f.level === 'error') problems.push(f.msg); else if (f.level === 'warn') note(f.msg); else { hints.push(f.msg); log('check', `hint: ${f.msg}`); } };
if (hasTypes) {
const found = appPrivacyMismatches(apTypes!, { attString: facts.plist.has('NSUserTrackingUsageDescription'), manifest: own, tracking });
found.forEach(say);
const n = found.filter(f => f.level !== 'info').length;
items.push({ what: 'app privacy', detail: `${Object.keys(apTypes!).length} types answered, ${n} mismatch(es) with the iOS project` });
log('check', `app privacy: ${Object.keys(apTypes!).length} types answered, ${n} mismatch(es) with the iOS project`);
}
const typesTrack = hasTypes && Object.values(apTypes!).some(a => a?.tracking === true);
if (config.appPrivacy?.tracking === true && !typesTrack && own) trackingDeclaredIssues('appPrivacy.tracking is true', own, tracking).forEach(say);
}
if (config.dataSafety) tryDo('data safety', () => {
const rows = dataSafetyRows(config.dataSafety!, config.accounts);

View file

@ -484,7 +484,9 @@
],
"privacy-manifest": [
"ios",
"sdk-dirs"
"propose",
"sdk-dirs",
"tracking-domains"
],
"release": [
"all",
@ -807,6 +809,7 @@
"production",
"profile",
"project",
"propose",
"prune",
"q",
"rating",
@ -846,6 +849,7 @@
"to",
"top",
"track",
"tracking-domains",
"traffic",
"translate",
"type",
@ -1120,7 +1124,8 @@
"storebound prices ppp [--base <USD>] [...] purchasing power price sheet (prices-ppp.ts)"
],
"privacy-manifest": [
"storebound privacy-manifest --ios ios/App [--sdk-dirs node_modules/@capacitor,node_modules/some-plugin] [--json]"
"storebound privacy-manifest --ios ios/App [--sdk-dirs node_modules/@capacitor,node_modules/some-plugin] [--json]",
"storebound privacy-manifest --ios ios/App --propose [--tracking-domains a.example.com,b.example.com]"
],
"reports": [
"storebound reports apple-sales [--type SALES] [--frequency DAILY|WEEKLY|MONTHLY|YEARLY] [--date ...] [--vendor N]",

View file

@ -109,21 +109,43 @@ export function validateAppPrivacyTypes(types: unknown): string[] {
return e;
}
export interface PrivacyMismatch { level: 'error' | 'warn'; msg: string }
export interface PrivacyMismatch { level: 'error' | 'warn' | 'info'; msg: string }
/** Where tracking is declared besides the app's own manifest. */
export interface TrackingSources {
/** SDKs whose own PrivacyInfo.xcprivacy declares NSPrivacyTracking true (Pods, frameworks, plugins, built app). */
sdkManifests?: string[];
/** Tracking-capable SDKs found in the project's dependencies (ads, mediation, attribution) whose manifest was not
* found locally (Pods not installed, binary only in the build). */
trackerSdks?: string[];
}
/** App Privacy "used for tracking" against the privacy manifests. The app's own manifest does NOT have to say
* NSPrivacyTracking true when the tracking comes from an SDK: the SDK's manifest declares it (and true with an empty
* NSPrivacyTrackingDomains is ITMS-91064). `what`: the App Privacy answer, e.g. "appPrivacy.tracking is true". */
export function trackingDeclaredIssues(what: string, appManifest: { path: string; tracking: boolean | null } | null, src: TrackingSources): PrivacyMismatch[] {
if (appManifest?.tracking === true) return [];
const own = appManifest ? `${appManifest.path} says NSPrivacyTracking ${appManifest.tracking === false ? 'false' : 'missing'}` : 'no app manifest';
const by = [...new Set(src.sdkManifests ?? [])];
if (by.length) return [{ level: 'info', msg: `${what}: tracking declared by the ${by.join(', ')} manifest${by.length > 1 ? 's' : ''} (${own}, right when only the SDK tracks)` }];
const known = [...new Set(src.trackerSdks ?? [])];
if (known.length) return [{ level: 'info', msg: `${what}: tracking comes from ${known.join(', ')}; its own privacy manifest declares it (not found in this folder: install Pods / resolve packages, or \`storebound apple upload --dry\` checks the built app). ${own}, right when only the SDK tracks` }];
return [{ level: 'warn', msg: `${what}, but no privacy manifest declares NSPrivacyTracking true (${own}, and no SDK manifest that declares tracking was found): check that the tracking SDK ships a manifest that declares it. Do not set the app's manifest to true without NSPrivacyTrackingDomains (ITMS-91064)` }];
}
/**
* `appPrivacy.types` against the iOS project (both ways): any type used for tracking <-> Info.plist
* NSUserTrackingUsageDescription and the app manifest's NSPrivacyTracking true; the app manifest's
* NSPrivacyCollectedDataTypes <-> the types marked collected. `manifest` null: no PrivacyInfo.xcprivacy found.
* NSUserTrackingUsageDescription and a manifest that declares tracking (the app's, or an SDK's: `tracking`); the app
* manifest's NSPrivacyCollectedDataTypes <-> the types marked collected. `manifest` null: no PrivacyInfo.xcprivacy found.
*/
export function appPrivacyMismatches(types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>>, ios: { attString: boolean; manifest: { path: string; tracking: boolean | null; collectedTypes: string[] } | null }): PrivacyMismatch[] {
export function appPrivacyMismatches(types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>>, ios: { attString: boolean; manifest: { path: string; tracking: boolean | null; collectedTypes: string[] } | null; tracking?: TrackingSources }): PrivacyMismatch[] {
const out: PrivacyMismatch[] = [];
const entries = Object.entries(types) as [AppleDataTypeId, AppleTypeAnswer][];
const tracked = entries.filter(([, a]) => a.tracking === true).map(([id]) => id);
const m = ios.manifest;
if (tracked.length) {
if (!ios.attString) out.push({ level: 'error', msg: `appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking, but Info.plist has no NSUserTrackingUsageDescription (App Tracking Transparency is required before tracking)` });
if (m && m.tracking !== true) out.push({ level: 'error', msg: `appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking, but ${m.path} has NSPrivacyTracking ${m.tracking === false ? 'false' : 'missing'}` });
if (m) out.push(...trackingDeclaredIssues(`appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking`, m, ios.tracking ?? {}));
} else if (entries.some(([, a]) => a.tracking === false) && entries.every(([, a]) => a.collected === false || a.tracking === false)) {
if (ios.attString) out.push({ level: 'warn', msg: 'Info.plist has NSUserTrackingUsageDescription, but appPrivacy.types marks no collected type as used for tracking' });
if (m?.tracking === true) out.push({ level: 'warn', msg: `${m.path} has NSPrivacyTracking true, but appPrivacy.types marks no collected type as used for tracking` });

View file

@ -375,10 +375,13 @@ export interface ReviewAccess {
}
/** 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 the privacy manifest's NSPrivacyTracking. */
* 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 that receive tracking data (privacy manifest NSPrivacyTrackingDomains). */
/** 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

115
cli/lib/privacy-files.ts Normal file
View file

@ -0,0 +1,115 @@
// Privacy manifests on disk and inside a built app: the SDK manifests of an iOS project (Pods, .framework /
// .xcframework, SwiftPM checkouts, Capacitor / Cordova / React Native plugins in node_modules, a built .app's
// Frameworks and bundles) and every PrivacyInfo.xcprivacy inside an .ipa or .app (XML or binary). Read-only; the
// directory walk is bounded.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join, relative, sep } from 'node:path';
import { nativeIosPlugins } from './node-packages';
import { parseBinaryPlist } from './plist';
import { type Manifest, manifestFromPlist, parseManifest, trackingIssue } from './privacy-manifest';
import { zipEntries, zipRead } from './zip';
/** A manifest read from bytes: binary plists (common in built apps) and XML. */
export function manifestFromBytes(path: string, b: Buffer): Manifest {
if (b.subarray(0, 8).toString('latin1') === 'bplist00') return manifestFromPlist(path, parseBinaryPlist(b));
return parseManifest(path, b.toString('utf8'));
}
export interface BundleManifest {
/** Path inside the app bundle ("PrivacyInfo.xcprivacy", "Frameworks/X.framework/PrivacyInfo.xcprivacy"). */
inApp: string;
/** The SDK the manifest belongs to (framework, bundle, plugin), null for the app's own. */
sdk: string | null;
manifest: Manifest;
}
/** The SDK name of a path inside an app bundle (null: the app's own manifest at the bundle root). */
export function sdkOfBundlePath(inApp: string): string | null {
const parts = inApp.split('/');
for (let k = parts.length - 2; k >= 0; k--) {
const m = /^(.+)\.(framework|bundle|appex|xctest)$/.exec(parts[k]!);
if (m) return m[1]!;
}
return null;
}
/** Every PrivacyInfo.xcprivacy inside an .ipa (Payload/<App>.app/...). */
export function ipaManifests(ipa: string | Buffer): BundleManifest[] {
const out: BundleManifest[] = [];
for (const e of zipEntries(ipa)) {
const m = /^Payload\/[^/]+\.app\/(.*PrivacyInfo\.xcprivacy)$/.exec(e.name);
if (!m || e.name.endsWith('/')) continue;
out.push({ inApp: m[1]!, sdk: sdkOfBundlePath(m[1]!), manifest: manifestFromBytes(e.name, zipRead(ipa, e)) });
}
return out;
}
/** Every PrivacyInfo.xcprivacy inside a built .app folder. */
export function appDirManifests(app: string): BundleManifest[] {
const out: BundleManifest[] = [];
walk(app, (p, isDir) => {
if (isDir) return true;
if (basename(p) !== 'PrivacyInfo.xcprivacy') return;
const inApp = relative(app, p).split(sep).join('/');
try { out.push({ inApp, sdk: sdkOfBundlePath(inApp), manifest: manifestFromBytes(p, readFileSync(p)) }); } catch { /* unreadable */ }
});
return out;
}
/** ITMS-91064 problems of a built app's manifests (app root, Frameworks/*.framework, bundles), one message each. */
export function bundleTrackingIssues(list: BundleManifest[]): string[] {
return list.flatMap(b => { const i = trackingIssue(b.manifest, b.sdk, b.inApp); return i ? [i] : []; });
}
function walk(dir: string, visit: (p: string, isDir: boolean) => boolean | void, depth = 0, budget = { n: 60_000 }): void {
if (depth > 16 || budget.n <= 0) return;
let names: string[] = [];
try { names = readdirSync(dir).sort(); } catch { return; }
for (const n of names) {
if (--budget.n <= 0) return;
const p = join(dir, n);
let st;
try { st = statSync(p); } catch { continue; }
const descend = visit(p, st.isDirectory());
if (st.isDirectory() && descend !== false) walk(p, visit, depth + 1, budget);
}
}
const SKIP = new Set(['.git', 'node_modules', '.gradle', '.dart_tool', '.storebound', '.claude', '.cursor', 'android', '.next', 'coverage', 'xcuserdata']);
/** The SDK a manifest path in a project belongs to, or null when it is the app's own (not under an SDK folder). */
export function sdkOfProjectPath(rel: string): string | null {
const parts = rel.split('/');
for (let k = parts.length - 2; k >= 0; k--) {
const p = parts[k]!;
const fw = /^(.+)\.(xc)?framework$|^(.+)\.bundle$/.exec(p);
if (fw) return fw[1] ?? fw[3]!;
if (k > 0 && (parts[k - 1] === 'Pods' || parts[k - 1] === 'checkouts' || parts[k - 1] === 'Carthage')) return p;
}
return null;
}
export interface SdkManifest { sdk: string; manifest: Manifest }
/** SDK manifests of an iOS / cross-platform project: Pods, .framework / .xcframework, SwiftPM checkouts, Carthage,
* built .app Frameworks, and the native plugins found through node_modules. Paths relative to `root`. */
export function projectSdkManifests(root: string): SdkManifest[] {
const out: SdkManifest[] = [];
const seen = new Set<string>();
const add = (abs: string, sdk: string) => {
if (seen.has(abs)) return;
seen.add(abs);
try { out.push({ sdk, manifest: manifestFromBytes(relative(root, abs).split(sep).join('/'), readFileSync(abs)) }); } catch { /* unreadable */ }
};
walk(root, (p, isDir) => {
if (isDir) return !SKIP.has(basename(p));
if (basename(p) !== 'PrivacyInfo.xcprivacy') return;
const sdk = sdkOfProjectPath(relative(root, p).split(sep).join('/'));
if (sdk) add(p, sdk);
});
for (const pl of nativeIosPlugins(root)) walk(pl.dir, (p, isDir) => {
if (isDir) return basename(p) !== 'node_modules' && basename(p) !== '.git';
if (basename(p) === 'PrivacyInfo.xcprivacy') add(p, pl.name);
});
return out;
}

View file

@ -46,6 +46,77 @@ export const LISTED_SDKS = ['Abseil', 'AFNetworking', 'Alamofire', 'AppAuth', 'B
export interface Manifest { path: string; tracking: boolean | null; trackingDomains: string[]; apis: Record<string, string[]>; collectedTypes: string[] }
/** Apple's upload error for a manifest whose NSPrivacyTracking and NSPrivacyTrackingDomains disagree. Apple's wording
* names one direction (domains listed, tracking false), but a real build was refused one minute after submission for
* the other one (NSPrivacyTracking true with an empty NSPrivacyTrackingDomains), so both are invalid. */
export const itms91064 = (path: string): string =>
`ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "${path}". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid.`;
export type TrackingProblem = 'true-without-domains' | 'domains-without-true';
/** Rule: a manifest's NSPrivacyTracking and NSPrivacyTrackingDomains agree. true needs a non-empty list (the domains
* the code of THAT bundle sends tracking data to); a non-empty list needs true. */
export function trackingProblem(m: { tracking: boolean | null; trackingDomains: string[] }): TrackingProblem | null {
if (m.tracking === true && !m.trackingDomains.length) return 'true-without-domains';
if (m.tracking !== true && m.trackingDomains.length) return 'domains-without-true';
return null;
}
/** The ITMS-91064 message for one manifest, or null when it is valid. `sdk`: the SDK (framework, bundle, pod) the
* manifest belongs to; null for the app's own manifest. `bundlePath`: the path inside the built app, for the ITMS text. */
export function trackingIssue(m: { path: string; tracking: boolean | null; trackingDomains: string[] }, sdk: string | null = null, bundlePath = 'PrivacyInfo.xcprivacy'): string | null {
const p = trackingProblem(m);
if (!p) return null;
const what = p === 'true-without-domains'
? 'NSPrivacyTracking true with an empty NSPrivacyTrackingDomains'
: `NSPrivacyTrackingDomains lists ${m.trackingDomains.join(', ')} but NSPrivacyTracking is ${m.tracking === false ? 'false' : 'missing'}`;
const fix = sdk
? `this is ${sdk}'s own manifest: update ${sdk} to a version with a valid manifest (or report it to the vendor); never patch a vendor manifest in place`
: p === 'true-without-domains'
? 'set NSPrivacyTracking false in the app\'s manifest when the tracking comes from an SDK (ad network, mediation, attribution: its own manifest declares it, and App Privacy in App Store Connect still says "used for tracking"); true only with the domains the app\'s own code sends tracking data to (appPrivacy.trackingDomains)'
: 'set NSPrivacyTracking true only if the app\'s own code tracks through these domains, else remove NSPrivacyTrackingDomains';
return `${m.path}: ${what}. Apple refuses the build at upload with ${itms91064(bundlePath)} Fix: ${fix}.`;
}
/** Manifest keys from a parsed plist (a binary manifest in a built app). */
export function manifestFromPlist(path: string, v: unknown): Manifest {
const d = (v && typeof v === 'object' && !Array.isArray(v) ? v : {}) as Record<string, unknown>;
const strs = (x: unknown): string[] => (Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string').map(s => s.trim()) : []);
const apis: Record<string, string[]> = {};
for (const a of Array.isArray(d.NSPrivacyAccessedAPITypes) ? d.NSPrivacyAccessedAPITypes : []) {
const r = a as Record<string, unknown>;
if (typeof r?.NSPrivacyAccessedAPIType === 'string') apis[r.NSPrivacyAccessedAPIType.trim()] = strs(r.NSPrivacyAccessedAPITypeReasons);
}
const collected = (Array.isArray(d.NSPrivacyCollectedDataTypes) ? d.NSPrivacyCollectedDataTypes : [])
.map(x => (x as Record<string, unknown>)?.NSPrivacyCollectedDataType).filter((s): s is string => typeof s === 'string').map(s => s.trim());
return { path, tracking: typeof d.NSPrivacyTracking === 'boolean' ? d.NSPrivacyTracking : null, trackingDomains: strs(d.NSPrivacyTrackingDomains), apis, collectedTypes: collected };
}
/** The typical reason of each required-reason category (first in the note), for a proposed manifest. */
const TYPICAL_REASON: Record<string, string> = {
NSPrivacyAccessedAPICategoryUserDefaults: 'CA92.1', NSPrivacyAccessedAPICategoryFileTimestamp: 'C617.1',
NSPrivacyAccessedAPICategorySystemBootTime: '35F9.1', NSPrivacyAccessedAPICategoryDiskSpace: 'E174.1',
};
/**
* The app's own PrivacyInfo.xcprivacy as storebound proposes it. NSPrivacyTracking is true only with a non-empty list
* of app-level tracking domains (appPrivacy.trackingDomains); tracking done by an SDK is declared in the SDK's own
* manifest, so `appPrivacy.tracking: true` alone writes false (true with an empty list is ITMS-91064).
* `apis`: declared categories and reasons to keep; `used`: categories found in the sources (typical reason added);
* `collected`: the existing NSPrivacyCollectedDataTypes entries, kept as they are (the owner's answers).
*/
export function proposeAppManifest(o: { trackingDomains?: string[]; apis?: Record<string, string[]>; used?: Iterable<string>; collected?: unknown[] }): Record<string, unknown> {
const domains = [...new Set((o.trackingDomains ?? []).map(s => s.trim()).filter(Boolean))];
const apis: Record<string, string[]> = { ...(o.apis ?? {}) };
for (const cat of o.used ?? []) if (!apis[cat]?.length && TYPICAL_REASON[cat]) apis[cat] = [TYPICAL_REASON[cat]!];
return {
NSPrivacyTracking: domains.length > 0,
NSPrivacyTrackingDomains: domains,
NSPrivacyCollectedDataTypes: o.collected ?? [],
NSPrivacyAccessedAPITypes: Object.entries(apis).sort(([a], [b]) => a.localeCompare(b)).map(([cat, reasons]) => ({ NSPrivacyAccessedAPIType: cat, NSPrivacyAccessedAPITypeReasons: reasons })),
};
}
/** Minimal plist reader for the keys a privacy manifest uses (no plist dependency). */
export function parseManifest(path: string, xml: string): Manifest {
const apis: Record<string, string[]> = {};
@ -73,7 +144,7 @@ export function scanSources(files: { path: string; text: string }[]): Map<string
return hits;
}
export interface SdkInfo { name: string; path: string; hasManifest: boolean }
export interface SdkInfo { name: string; path: string; hasManifest: boolean; /** The SDK's own manifests, parsed. */ manifests?: Manifest[] }
export type PmLevel = 'blocker' | 'warn' | 'ok' | 'info';
export interface PmFinding { level: PmLevel; msg: string }
@ -85,6 +156,9 @@ export interface PmInput {
asksTracking: boolean;
}
/** SDKs whose own manifest declares NSPrivacyTracking true. */
export const sdkTrackers = (sdks: SdkInfo[]): string[] => [...new Set(sdks.filter(s => s.manifests?.some(m => m.tracking === true)).map(s => s.name))];
export function evaluatePrivacyManifest(i: PmInput): PmFinding[] {
const out: PmFinding[] = [];
if (!i.appManifest) out.push({ level: 'blocker', msg: 'no PrivacyInfo.xcprivacy in the app target (Xcode > New File > App Privacy; add it to the app target)' });
@ -103,10 +177,19 @@ export function evaluatePrivacyManifest(i: PmInput): PmFinding[] {
}
}
for (const cat of Object.keys(declared)) if (!i.sourceHits.has(cat)) out.push({ level: 'info', msg: `${cat} declared; no use found in the scanned app sources (fine if an SDK or plugin compiled into the app uses it)` });
if (i.asksTracking && i.appManifest && i.appManifest.tracking !== true)
out.push({ level: 'warn', msg: 'Info.plist asks for tracking (NSUserTrackingUsageDescription) but the manifest has NSPrivacyTracking false / missing; keep it consistent with App Privacy "used for tracking"' });
if (i.appManifest?.tracking && !i.appManifest.trackingDomains.length)
out.push({ level: 'info', msg: 'NSPrivacyTracking true without NSPrivacyTrackingDomains: list the tracking domains if your SDKs connect to them' });
// ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree, in the app's manifest and in every SDK's
const appIssue = i.appManifest ? trackingIssue(i.appManifest) : null;
if (appIssue) out.push({ level: 'blocker', msg: appIssue });
for (const s of i.sdks) for (const m of s.manifests ?? []) {
const issue = trackingIssue(m, s.name);
if (issue) out.push({ level: 'blocker', msg: issue });
}
if (i.asksTracking && i.appManifest && i.appManifest.tracking !== true) {
const by = sdkTrackers(i.sdks);
out.push(by.length
? { level: 'info', msg: `tracking declared by the ${by.join(', ')} manifest${by.length > 1 ? 's' : ''}; the app's own manifest says NSPrivacyTracking ${i.appManifest.tracking === false ? 'false' : 'missing'}, which is right when only the SDK tracks` }
: { level: 'warn', msg: 'Info.plist asks for tracking (NSUserTrackingUsageDescription), but no privacy manifest found here declares NSPrivacyTracking true: check that the tracking SDK (ad network, mediation, attribution) ships a manifest that declares it (install Pods / resolve packages, or check the built app with `storebound apple upload --dry`). Do not set the app\'s manifest to true without NSPrivacyTrackingDomains (ITMS-91064)' });
}
for (const s of i.sdks) {
const listed = LISTED_SDKS.includes(s.name);
if (s.hasManifest) continue;

View file

@ -169,7 +169,7 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
if (attStr) items.push({ area: 'att', what: 'NSUserTrackingUsageDescription', value: String(attStr.value).slice(0, 80), status: 'detected', evidence: [{ ...attStr.at, text: 'NSUserTrackingUsageDescription' }] });
else if (trackers.length || attReq.length) items.push({ area: 'att', what: 'NSUserTrackingUsageDescription', value: 'needed if anything tracks, e.g. "Your data is used to show ads that fit your interests."', status: 'ask', evidence: [] });
if (attReq.length) items.push({ area: 'att', what: 'ATT request', value: 'found', status: 'detected', evidence: attReq });
if (trackers.length || attReq.length) items.push({ area: 'appPrivacy', what: 'trackingDomains', value: 'the SDKs\' tracking domains (from each SDK\'s privacy manifest)', status: 'ask', evidence: [] });
if (trackers.length || attReq.length) items.push({ area: 'appPrivacy', what: 'trackingDomains', value: 'only the domains the app\'s OWN code sends tracking data to (usually none: SDK domains stay in each SDK\'s manifest)', status: 'ask', evidence: [] });
// privacy manifest
if (p.ios) {
@ -183,7 +183,14 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
items.push({ area: 'privacyManifest', what: cat, value: have?.length ? `declared: ${have.join(', ')}` : `add with a reason: ${def.note}`, status: have?.length ? 'detected' : 'ask', evidence: h });
}
const m = p.privacyManifests[0];
if (tracking !== null || m) items.push({ area: 'privacyManifest', what: 'NSPrivacyTracking', value: tracking ? 'true' : m ? String(m.tracking) : '?', status: tracking ? 'detected' : 'ask', evidence: m ? [{ file: m.path, line: 1, text: 'PrivacyInfo.xcprivacy' }] : [] });
// the app's own manifest says true only with app-level tracking domains: tracking by an SDK is declared in the SDK's
// manifest, and true with an empty NSPrivacyTrackingDomains is refused at upload (ITMS-91064)
const appDomains = c?.appPrivacy?.trackingDomains ?? [];
if (tracking !== null || m) items.push({ area: 'privacyManifest', what: 'NSPrivacyTracking',
value: tracking && appDomains.length ? `true (NSPrivacyTrackingDomains: ${appDomains.join(', ')})`
: tracking ? 'false in the app\'s manifest (the tracking SDKs declare it in their own manifests; true needs the app\'s own NSPrivacyTrackingDomains, else ITMS-91064)'
: m ? String(m.tracking) : '?',
status: tracking ? 'detected' : 'ask', evidence: m ? [{ file: m.path, line: 1, text: 'PrivacyInfo.xcprivacy' }] : [] });
if (diff.length) items.push({ area: 'privacyManifest', what: 'NSPrivacyCollectedDataTypes', value: uniq(diff.map(d => d.apple.split(' / ')[1]!)).join(', '), status: 'detected', evidence: diff.flatMap(d => d.evidence).slice(0, 1), note: 'the app\'s own collection; SDKs declare theirs in their manifests' });
}

View file

@ -35,7 +35,8 @@
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
import type { ShipConfig } from '../config';
import { allSubscriptions } from '../subscriptions';
import { LISTED_SDKS, REQUIRED_REASON_APIS } from '../privacy-manifest';
import { sdkOfProjectPath } from '../privacy-files';
import { itms91064, LISTED_SDKS, REQUIRED_REASON_APIS, trackingIssue, trackingProblem } from '../privacy-manifest';
import { IOS_PERMISSIONS, vaguePurpose } from './permissions';
import { playRules } from './play-rules';
import { grep, grepOwn, type Hit, lineOf, type ProjectFacts } from './project';
@ -304,6 +305,17 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
else if (apiHits.length)
add({ rule: 'privacy-manifest', guideline: 'Privacy manifest (ITMS-91053)', store: 'apple', severity: 'blocker', title: `Required-reason APIs used but not declared in PrivacyInfo.xcprivacy: ${apiHits.map(h => h.text.split(':')[0]).join(', ')}`, evidence: apiHits.slice(0, 4),
fix: 'Add each category to NSPrivacyAccessedAPITypes with an approved reason code (for app data in UserDefaults: CA92.1).', skill: 'privacy-manifest', ref: 'https://developer.apple.com/documentation/bundleresources/privacy-manifest-files/describing-use-of-required-reason-api' });
// ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree (true + empty list was refused at upload too)
for (const m of p.privacyManifests) {
const problem = trackingProblem(m);
if (!problem) continue;
const sdk = sdkOfProjectPath(m.path);
add({ rule: 'privacy-manifest', guideline: 'Privacy manifest (ITMS-91064)', store: 'apple', severity: 'blocker',
title: problem === 'true-without-domains' ? `${m.path}: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (Apple refuses the build: ITMS-91064)` : `${m.path}: NSPrivacyTrackingDomains listed with NSPrivacyTracking ${m.tracking === false ? 'false' : 'missing'} (Apple refuses the build: ITMS-91064)`,
evidence: [{ file: m.path, line: 1, text: problem === 'true-without-domains' ? 'NSPrivacyTracking true, NSPrivacyTrackingDomains empty' : `NSPrivacyTrackingDomains: ${m.trackingDomains.join(', ')}` }],
fix: `${trackingIssue(m, sdk)!.split(' Fix: ')[1]} Apple: ${itms91064('PrivacyInfo.xcprivacy')}`, skill: 'privacy-manifest',
ref: 'https://developer.apple.com/documentation/bundleresources/privacy-manifest-files' });
}
for (const s of p.sdks) {
if (!s.sdk.manifestSince) continue;
for (const d of s.deps.filter(x => (x.eco === 'cocoapods' || x.eco === 'spm') && x.version)) {

View file

@ -1,16 +1,25 @@
// Local scan of an iOS project for Apple's privacy manifest rules (no network, no credentials, no config needed).
// storebound privacy-manifest --ios ios/App [--sdk-dirs node_modules/@capacitor,node_modules/some-plugin] [--json]
// storebound privacy-manifest --ios ios/App --propose [--tracking-domains a.example.com,b.example.com]
// Reports: the app's PrivacyInfo.xcprivacy, required-reason API categories used in app sources vs declared, tracking
// flag vs the ATT usage string, and every SDK (CocoaPods, .xcframework / .framework, SwiftPM checkouts, local SwiftPM
// packages, the native plugins of a Capacitor / Cordova / React Native app found through node_modules and workspaces
// the way Node resolves them, so a monorepo's apps/native/node_modules works without flags, and --sdk-dirs)
// without its own manifest. Heuristic text scan: it can miss uses inside binaries and flag harmless matches.
// ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree in the app's manifest and in every SDK's (true
// needs a non-empty list, a non-empty list needs true); a mismatch is a blocker.
// --propose prints the app's PrivacyInfo.xcprivacy as storebound proposes it (declared + found API categories, the
// collected types it has): NSPrivacyTracking true only with app-level tracking domains (--tracking-domains, else
// appPrivacy.trackingDomains of storebound.config.ts when there is one), else false; tracking done by an SDK is
// declared in the SDK's own manifest.
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { basename, join, relative, resolve } from 'node:path';
import type { Args } from './lib/args';
import { log } from './lib/log';
import { nativeIosPlugins } from './lib/node-packages';
import { evaluatePrivacyManifest, type Manifest, parseManifest, scanSources, type SdkInfo } from './lib/privacy-manifest';
import { parsePlist, toXmlPlist, type PlistValue } from './lib/plist';
import { manifestFromBytes } from './lib/privacy-files';
import { evaluatePrivacyManifest, type Manifest, parseManifest, proposeAppManifest, scanSources, type SdkInfo } from './lib/privacy-manifest';
const SKIP = new Set(['.git', 'build', 'DerivedData', 'node_modules', '.build', 'xcuserdata']);
const SRC = /\.(swift|m|mm|c|cc|cpp|h|hpp)$/;
@ -30,10 +39,26 @@ function walk(dir: string, visit: (p: string, isDir: boolean) => boolean | void,
}
}
function hasManifest(dir: string): boolean {
let found = false;
walk(dir, p => { if (basename(p) === 'PrivacyInfo.xcprivacy') found = true; return !found; });
return found;
function manifestsIn(dir: string): Manifest[] {
const out: Manifest[] = [];
walk(dir, p => {
if (basename(p) !== 'PrivacyInfo.xcprivacy') return;
try { out.push(manifestFromBytes(relative(process.cwd(), p), readFileSync(p))); } catch { /* unreadable */ }
});
return out;
}
/** The NSPrivacyCollectedDataTypes entries of the app's manifest as they are (kept by --propose). */
function existingCollected(path: string | undefined): PlistValue[] {
if (!path) return [];
try { const v = parsePlist(readFileSync(path)) as Record<string, PlistValue>; return Array.isArray(v.NSPrivacyCollectedDataTypes) ? v.NSPrivacyCollectedDataTypes : []; } catch { return []; }
}
async function configTrackingDomains(): Promise<string[]> {
try {
const { loadConfig } = await import('./lib/config');
return (await loadConfig()).config.appPrivacy?.trackingDomains ?? [];
} catch { return []; }
}
export async function run(args: Args): Promise<void> {
@ -49,7 +74,8 @@ export async function run(args: Args): Promise<void> {
try { key = realpathSync(p); } catch { /* keep p */ }
if (seen.has(key)) return;
seen.add(key); sdkRoots.add(p);
sdks.push({ name: name ?? basename(p).replace(/\.(xc)?framework$/, ''), path: relative(process.cwd(), p), hasManifest: hasManifest(p) });
const manifests = manifestsIn(p);
sdks.push({ name: name ?? basename(p).replace(/\.(xc)?framework$/, ''), path: relative(process.cwd(), p), hasManifest: manifests.length > 0, manifests });
};
const addPods = (dir: string) => { for (const n of readdirSync(dir)) if (!POD_META.has(n) && statSync(join(dir, n)).isDirectory()) addSdk(join(dir, n)); };
@ -82,7 +108,14 @@ export async function run(args: Args): Promise<void> {
});
const appManifest: Manifest | null = manifests[0] ? parseManifest(relative(process.cwd(), manifests[0]), readFileSync(manifests[0], 'utf8')) : null;
log('privacy', `${sources.length} source files, ${sdks.length} SDKs, ${manifests.length} app manifest(s)`);
const findings = evaluatePrivacyManifest({ appManifest, sourceHits: scanSources(sources), sdks, asksTracking: /NSUserTrackingUsageDescription/.test(infoPlist) });
const sourceHits = scanSources(sources);
if (args.has('propose')) {
const domains = args.list('tracking-domains').length ? args.list('tracking-domains') : await configTrackingDomains();
console.log(toXmlPlist(proposeAppManifest({ trackingDomains: domains, apis: appManifest?.apis, used: sourceHits.keys(), collected: existingCollected(manifests[0]) }) as PlistValue).trimEnd());
log('privacy', domains.length ? `NSPrivacyTracking true with ${domains.length} app-level tracking domain(s)` : 'NSPrivacyTracking false: no app-level tracking domains (tracking SDKs declare theirs in their own manifests; true with an empty list is ITMS-91064)');
return;
}
const findings = evaluatePrivacyManifest({ appManifest, sourceHits, sdks, asksTracking: /NSUserTrackingUsageDescription/.test(infoPlist) });
if (args.has('json')) console.log(JSON.stringify({ appManifest, sdks, findings }, null, 2));
else for (const f of findings) console.log(`${f.level.toUpperCase().padEnd(7)} ${f.msg}`);
if (findings.some(f => f.level === 'blocker')) process.exitCode = 1;

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 29 | 2026-10 | Apple refused build 1 as Invalid Binary one minute after submission: "ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid." The app's own manifest had NSPrivacyTracking true with an EMPTY NSPrivacyTrackingDomains, as storebound recommended from `appPrivacy.tracking: true` although the tracking came from an ad SDK (LevelPlay) whose own manifest declared it. Apple's wording names the other direction (domains listed, tracking false); both are invalid. After the fix (app manifest false, App Privacy still "used for tracking") `check` wrongly demanded the app manifest say true. | Rule (`trackingProblem` / `trackingIssue` / `itms91064` in `cli/lib/privacy-manifest.ts`): a manifest's NSPrivacyTracking and NSPrivacyTrackingDomains agree, in the app's and every SDK's; `check`, `scan` (blocker) and `privacy-manifest` (blocker) fail on either mismatch with the exact ITMS text and never suggest true without domains. App Privacy tracking no longer needs the app manifest's true: `check` looks for SDK manifests (Pods, frameworks, SwiftPM checkouts, Carthage, Capacitor plugins, the last built IPA; `cli/lib/privacy-files.ts`) that declare tracking (info), known tracking SDKs in the dependencies (info), else warns. `privacy-manifest --propose` and the scan proposal write true only with `appPrivacy.trackingDomains` (the app's own domains), else false. `apple upload` reads every PrivacyInfo.xcprivacy in the IPA (app root, Frameworks, bundles; XML or binary) and stops before any network call; `apple build` checks the exported IPA. `tests/privacy-tracking.test.ts`. |
| 28 | 2026-10 | First App Store release with IAPs: the console route failed an agent (the IAP page's "Add for Review" menu ignored scripted clicks; the version page no longer has an "In-App Purchases and Subscriptions" section), while `apple iap-submit --with-version --yes` added the IAPs to the draft submission that held 1.0.0 and submitted them first try. App Privacy's Publish opens a "Publish Your App Privacy Responses?" dialog and leaves a "Published … ago by …" line. | appstore-first-release and store-iap-setup point to `apple iap-submit --with-version` (the owner's yes: it submits), never the console. `console verify apple-app-privacy` reads the "Published …" line as the published signal; the plan names the dialog. `tests/console-play-plans.test.ts`. |
| 27 | 2026-10 | A real Play first release (an agent with a browser tool) found the steps no API does: the App category stayed "Not selected" (`play listing` cannot set it: edits.details has no category field); production had 0 countries (edits.countryAvailability is read-only); App content had ten cards with their own pages; the IARC questionnaire is one long page whose Yes answers reveal follow-ups, with an IARC Terms of Use agreement on page 1 and a final "Save" (not "Submit") on the Summary; Play's material checkboxes, like its radios, ignore native input; long evaluate calls timed out at 45 s; "Send changes for review" ignored a JS click; managed publishing is off by default, so approved means live. | Plans `play-store-settings`, `play-app-content` (+ one per card), `play-production-countries`, `play-production-release`; config `play.category` (Play's lists), `play.countries`, `play.managedPublishing`, `console.contentRating.details`, `console.appContent`, `social`, `products[].randomRewards`; every ask-owner answer in one batch; `check` warns without a category and counts the open App content answers; `validate` reports the category reminder and "production has 0 countries"; `play status` and `play release` say what is console-only; the IARC Terms box is never-press; material checkboxes get the inner [role=checkbox] click (plan and `console fill`); every plan ships `inPageLoop`. `tests/console-play-plans.test.ts`. |
| 26 | 2026-10 | An agent using storebound spent hours handing the owner one click, command or answer at a time ("press Next", "download the CSV template", "now run the upload", a compliance question in the middle of a run): skills said "let the owner do it" / "by hand" for navigation, exports and uploads. | The first rule "Do it, don't ask" heads `docs/agents-core.md` (every generated adapter), README and the store-pitfalls skill: the agent does API calls, uploads, builds, exports, console navigation and forms itself; the owner presses only the final public or irreversible buttons and answers the facts no file proves in one batch at the start. `tests/do-it-dont-ask.test.ts` checks the rule is there and fails on skill text that hands navigation, draft saves, exports, uploads or form filling to the owner. |

View file

@ -7,7 +7,8 @@ description: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for a
`storebound scan` runs the same checks as part of its report (no manifest, undeclared required-reason APIs, SDKs older than their first manifest version) and proposes NSPrivacyTracking and the collected data types from the detected SDKs.
Scan: `storebound privacy-manifest --ios ios/App` (local, no credentials). Capacitor / Cordova / React Native plugins
Scan: `storebound privacy-manifest --ios ios/App` (local, no credentials; `--propose` prints the app manifest
storebound proposes). Capacitor / Cordova / React Native plugins
live outside the iOS folder; they are found the way Node resolves them: the dependencies of the app's package.json
(next to capacitor.config) in the node_modules there and in every folder above it up to the repo root (so a monorepo's
`apps/native/node_modules` works), workspace packages (package.json `workspaces`, Bun / npm / yarn), local SwiftPM
@ -15,6 +16,10 @@ packages (Capacitor 8's CapApp-SPM `Package.swift`), Pods and SwiftPM checkouts.
cannot find. Exit 1 when something blocks upload.
## What Apple checks at upload
- **ITMS-91064 Invalid tracking information**: "ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid." Raised for
true with an empty list as well as for domains with false, in the app's manifest or any SDK's. `storebound
apple upload` checks every manifest in the IPA (app root, Frameworks, bundles; XML or binary) before sending it,
`apple build` after the export, `check` / `scan` / `privacy-manifest` in the project.
- **ITMS-91053 Missing API declaration**: the binary uses a required-reason API and no manifest in the app or in the
SDK that calls it declares the category with a reason.
- **ITMS-91061 Missing privacy manifest**: an SDK from Apple's list of commonly used SDKs (Firebase, Google utilities,
@ -31,9 +36,14 @@ cannot find. Exit 1 when something blocks upload.
- `NSPrivacyAccessedAPICategorySystemBootTime` + `35F9.1` (elapsed time between events)
- `NSPrivacyAccessedAPICategoryDiskSpace` + `E174.1` (check space before writing)
Pick the reason that is true; read Apple's reason list ("Describing use of required reason API") if unsure.
3. Tracking: `NSPrivacyTracking` true only if the app tracks (ad SDK with IDFA after ATT); then list
`NSPrivacyTrackingDomains` the SDK docs name. Keep it consistent with App Privacy "used for tracking" and the ATT
prompt (`store-declarations`).
3. Tracking (ITMS-91064): in every manifest `NSPrivacyTracking` and `NSPrivacyTrackingDomains` must agree: true
needs a non-empty list of the domains the code of THAT bundle sends tracking data to, and a non-empty list needs
true. When the tracking comes from an SDK (ad network, mediation such as LevelPlay / AppLovin MAX / AdMob,
attribution), the SDK's own manifest declares it and the app's manifest says **false**, while App Privacy in App
Store Connect still says "used for tracking" and the ATT prompt stays (`store-declarations`). Never set the app's
manifest to true with an empty list: Apple refuses the build as Invalid Binary a minute after the upload. Only
when the app's own code tracks, list its domains in `appPrivacy.trackingDomains`;
`storebound privacy-manifest --ios ios/App --propose` prints the manifest with true only then.
4. `NSPrivacyCollectedDataTypes`: what the app itself collects; SDK manifests cover their own collection. Xcode's
Privacy Report (Product > Archive > Generate Privacy Report) merges all manifests; compare it with App Privacy.
5. SDKs flagged "ships no PrivacyInfo.xcprivacy": update to a version that has one (ad networks and mediation
@ -44,5 +54,8 @@ cannot find. Exit 1 when something blocks upload.
- The scan is a text search: it can miss calls inside binary frameworks (their manifest must cover them) and flag a
harmless symbol name; confirm before adding a category you do not use.
- A manifest must be in the **app target's** Copy Bundle Resources, not only in the project.
- `check` saying "tracking declared by the <SDK> manifest" (info) is the correct setup for SDK tracking; a warning
that no manifest declares tracking means: check the SDK version ships its manifest (`pod install`, resolve
packages), not "set the app's manifest to true".
- Static libraries cannot carry a manifest bundle by themselves; the vendor ships a resource bundle or you merge their
declarations into the app's manifest as their docs say.

View file

@ -62,7 +62,9 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolc
(Advertising Data, Gameplay Content, Customer Support, Environment Scanning, Hands, Head) are answered only here.
`scan --write` proposes `collected` + `purposes` for detected types the block does not answer yet; `linked` and
`tracking` are never proposed (ask the owner). `storebound check` compares the block with the iOS project:
tracking true <-> `NSUserTrackingUsageDescription` and `NSPrivacyTracking` true; the app's
tracking true <-> `NSUserTrackingUsageDescription` and a manifest that declares tracking: the tracking SDK's own
manifest (Pods, frameworks, plugins, the built IPA; info) or the app's, which says true only with its own
`NSPrivacyTrackingDomains` (true with an empty list is ITMS-91064, see `privacy-manifest`); the app's
`NSPrivacyCollectedDataTypes` <-> the types marked collected (mismatches both ways).
6. **Export compliance.** `export-compliance` skill (`ITSAppUsesNonExemptEncryption` in Info.plist, `apple encryption`).
7. **Privacy manifest (iOS).** `privacy-manifest` skill; keep it consistent with App Privacy.

View file

@ -376,3 +376,19 @@ that fails without the fix (see CONTRIBUTING.md).
Responses?" (Cancel / Publish); afterwards "Published … ago by …" shows, which `console verify apple-app-privacy`
reads as the published signal.
- **Guard:** appstore-first-release and store-iap-setup skills; `tests/console-play-plans.test.ts`.
## 29. ITMS-91064: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains
- **Symptom:** Apple marks the build Invalid Binary a minute after the upload: "ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid."
- **Cause:** the app's own PrivacyInfo.xcprivacy said NSPrivacyTracking true with an empty NSPrivacyTrackingDomains
because App Privacy says "used for tracking", but the tracking came from an ad SDK (mediation, attribution) whose own
manifest declares it. Apple's text names the other direction (domains listed with false); both are refused.
- **Do:** in every manifest, NSPrivacyTracking true needs a non-empty NSPrivacyTrackingDomains (domains the code of
THAT bundle sends tracking data to), and a non-empty list needs true. When only an SDK tracks, the app's manifest
says false; App Privacy in App Store Connect still says tracking = yes and ATT stays. Set
`appPrivacy.trackingDomains` only for the app's own tracking domains. A bad SDK manifest: update the SDK. Rebuild
and upload a new build number.
- **Guard:** `check`, `scan` and `privacy-manifest` fail on either mismatch with the ITMS text; `check` reports
tracking declared by an SDK manifest as info and warns only when no manifest declares it; `privacy-manifest
--propose` writes true only with `appPrivacy.trackingDomains`; `apple upload` checks every manifest in the IPA
(app root, Frameworks, bundles) before any network call, `apple build` after the export;
`tests/privacy-tracking.test.ts`.

View file

@ -176,7 +176,9 @@ describe('check: appPrivacy.types against the iOS project', () => {
const r = JSON.parse(out.at(-1)!);
const problems = (r.problems as string[]).join('\n');
expect(problems).toContain('Info.plist has no NSUserTrackingUsageDescription');
expect(problems).toContain('NSPrivacyTracking false');
// tracking from an SDK is declared in the SDK's manifest: the app's own false is not an error (ITMS-91064, pitfall 29)
expect(problems).not.toContain('NSPrivacyTracking false');
expect((r.warnings as string[]).join('\n')).toContain('no privacy manifest declares NSPrivacyTracking true');
expect(r.items.some((i: { what: string }) => i.what === 'app privacy')).toBe(true);
} finally { rmSync(dir, { recursive: true, force: true }); }
});

Binary file not shown.

View file

@ -0,0 +1,224 @@
// ITMS-91064 (pitfall 29): a privacy manifest's NSPrivacyTracking and NSPrivacyTrackingDomains must agree. A real
// build was refused as Invalid Binary one minute after the upload for NSPrivacyTracking true with an empty
// NSPrivacyTrackingDomains in the app's own manifest (the tracking came from an ad SDK, whose manifest declared it).
// Covers the rule, the SDK-declared tracking in check, the proposed manifest, the built IPA (binary manifests) and
// `apple upload` stopping before any network call. Placeholder data only.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { run as check } from '../cli/check';
import { appPrivacyMismatches, trackingDeclaredIssues } from '../cli/lib/app-privacy';
import { parseArgs } from '../cli/lib/args';
import type { ShipConfig } from '../cli/lib/config';
import { toXmlPlist, type PlistValue } from '../cli/lib/plist';
import { bundleTrackingIssues, ipaManifests, manifestFromBytes, projectSdkManifests, sdkOfBundlePath } from '../cli/lib/privacy-files';
import { evaluatePrivacyManifest, itms91064, parseManifest, proposeAppManifest, trackingIssue, trackingProblem } from '../cli/lib/privacy-manifest';
import { detectProject } from '../cli/lib/scan/project';
import { scanRules } from '../cli/lib/scan/rules';
import { makeZip } from './build-fixtures';
const ROOT = join(import.meta.dir, '..');
const FIX = join(import.meta.dir, 'fixtures', 'build');
const tmp = mkdtempSync(join(tmpdir(), 'sb-privacy-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
const touch = (base: string, rel: string, text: string | Buffer = ''): void => { mkdirSync(join(base, rel, '..'), { recursive: true }); writeFileSync(join(base, rel), text); };
const MANIFEST = (tracking: boolean, domains: string[] = [], types: string[] = []) => `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>NSPrivacyTracking</key><${tracking}/>
<key>NSPrivacyTrackingDomains</key><array>${domains.map(d => `<string>${d}</string>`).join('')}</array>
<key>NSPrivacyCollectedDataTypes</key><array>${types.map(t => `<dict><key>NSPrivacyCollectedDataType</key><string>${t}</string></dict>`).join('')}</array>
</dict></plist>`;
const ITMS = 'ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn\'t empty. Keys and values in your app\'s privacy manifests must be valid.';
describe('rule: NSPrivacyTracking and NSPrivacyTrackingDomains agree', () => {
test('true + empty list -> error with the exact ITMS-91064 text, never "set true"', () => {
const m = parseManifest('App/PrivacyInfo.xcprivacy', MANIFEST(true));
expect(trackingProblem(m)).toBe('true-without-domains');
const msg = trackingIssue(m)!;
expect(msg).toContain(ITMS);
expect(msg).toContain('set NSPrivacyTracking false');
expect(itms91064('PrivacyInfo.xcprivacy')).toBe(ITMS);
});
test('false + domains -> error; missing + domains -> error', () => {
expect(trackingProblem(parseManifest('p', MANIFEST(false, ['t.example.com'])))).toBe('domains-without-true');
expect(trackingIssue(parseManifest('p', MANIFEST(false, ['t.example.com'])))).toContain('NSPrivacyTrackingDomains lists t.example.com but NSPrivacyTracking is false');
expect(trackingProblem({ tracking: null, trackingDomains: ['t.example.com'] })).toBe('domains-without-true');
});
test('true + domains, false + empty -> ok', () => {
expect(trackingIssue(parseManifest('p', MANIFEST(true, ['t.example.com'])))).toBeNull();
expect(trackingIssue(parseManifest('p', MANIFEST(false)))).toBeNull();
expect(trackingProblem({ tracking: null, trackingDomains: [] })).toBeNull();
});
test('an SDK manifest: fix is updating the SDK', () => {
expect(trackingIssue(parseManifest('Pods/AdSdk/PrivacyInfo.xcprivacy', MANIFEST(true)), 'AdSdk')).toContain('this is AdSdk\'s own manifest: update AdSdk');
});
});
describe('privacy-manifest scan', () => {
const app = (t: boolean, d: string[] = []) => parseManifest('App/PrivacyInfo.xcprivacy', MANIFEST(t, d));
const ads = { name: 'AdSdk', path: 'Pods/AdSdk', hasManifest: true, manifests: [parseManifest('Pods/AdSdk/PrivacyInfo.xcprivacy', MANIFEST(true, ['ads.example.com']))] };
test('app true + empty -> blocker with ITMS-91064', () => {
const f = evaluatePrivacyManifest({ appManifest: app(true), sourceHits: new Map(), sdks: [], asksTracking: true });
expect(f[0]!.level).toBe('blocker');
expect(f[0]!.msg).toContain(ITMS);
});
test('SDK manifest declares tracking, app false -> info, no blocker', () => {
const f = evaluatePrivacyManifest({ appManifest: app(false), sourceHits: new Map(), sdks: [ads], asksTracking: true });
expect(f.filter(x => x.level === 'blocker' || x.level === 'warn')).toEqual([]);
expect(f.find(x => x.level === 'info')!.msg).toContain('tracking declared by the AdSdk manifest');
});
test('ATT string, nothing declares tracking -> warn that never says to set the app to true without domains', () => {
const f = evaluatePrivacyManifest({ appManifest: app(false), sourceHits: new Map(), sdks: [], asksTracking: true });
const w = f.find(x => x.level === 'warn')!;
expect(w.msg).toContain('no privacy manifest found here declares NSPrivacyTracking true');
expect(w.msg).toContain('Do not set the app\'s manifest to true without NSPrivacyTrackingDomains');
});
test('an SDK manifest with false + domains -> blocker', () => {
const bad = { ...ads, manifests: [parseManifest('Pods/AdSdk/PrivacyInfo.xcprivacy', MANIFEST(false, ['ads.example.com']))] };
const f = evaluatePrivacyManifest({ appManifest: app(false), sourceHits: new Map(), sdks: [bad], asksTracking: false });
expect(f[0]!.level).toBe('blocker');
expect(f[0]!.msg).toContain('update AdSdk');
});
});
describe('App Privacy tracking vs manifests (check)', () => {
const m = { path: 'App/PrivacyInfo.xcprivacy', tracking: false, collectedTypes: ['NSPrivacyCollectedDataTypeDeviceID'] };
const types = { DEVICE_ID: { collected: true, tracking: true }, COARSE_LOCATION: { collected: true, tracking: true }, ADVERTISING_DATA: { collected: true, tracking: true } } as const;
test('SDK manifest declares tracking, app manifest false -> info, no error', () => {
const r = appPrivacyMismatches(types, { attString: true, manifest: m, tracking: { sdkManifests: ['IronSource'] } });
expect(r.filter(x => x.level === 'error')).toEqual([]);
expect(r.find(x => x.level === 'info')!.msg).toContain('tracking declared by the IronSource manifest');
});
test('known tracking SDK in the dependencies -> info', () => {
const r = appPrivacyMismatches(types, { attString: true, manifest: m, tracking: { trackerSdks: ['Unity LevelPlay (ironSource)'] } });
expect(r.filter(x => x.level === 'error')).toEqual([]);
expect(r.find(x => x.level === 'info')!.msg).toContain('tracking comes from Unity LevelPlay (ironSource)');
});
test('App Privacy tracking with no manifest declaring it -> warning, not an error', () => {
const r = appPrivacyMismatches(types, { attString: true, manifest: m });
expect(r.filter(x => x.level === 'error')).toEqual([]);
expect(r.find(x => x.level === 'warn' && x.msg.includes('no privacy manifest declares NSPrivacyTracking true'))).toBeDefined();
expect(trackingDeclaredIssues('x', { path: 'p', tracking: true }, {})).toEqual([]);
});
const runCheck = async (dir: string, c: ShipConfig) => {
const out: string[] = [];
const orig = console.log;
console.log = (s: string) => { out.push(s); };
try { await check({ loaded: { config: c, root: dir, path: join(dir, 'storebound.config.ts'), resolve: p => join(dir, p) }, args: parseArgs(['--json']) }); }
finally { console.log = orig; process.exitCode = 0; }
return JSON.parse(out.at(-1)!) as { problems: string[]; warnings: string[]; hints: string[] };
};
const project = (name: string, appManifest: string) => {
const dir = join(tmp, name);
touch(dir, 'ios/App.xcodeproj/project.pbxproj', '// placeholder\n');
touch(dir, 'ios/Info.plist', '<?xml version="1.0"?><plist version="1.0"><dict><key>NSUserTrackingUsageDescription</key><string>Your data is used to show ads that fit your interests.</string></dict></plist>');
touch(dir, 'ios/PrivacyInfo.xcprivacy', appManifest);
touch(dir, '.gitignore', '.storebound/\n');
return dir;
};
const cfg = { app: { name: 'Example Game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }], apple: { bundleId: 'com.example.game' }, appPrivacy: { tracking: true, types } } as unknown as ShipConfig;
test('storebound check: app false + a Pods SDK manifest that declares tracking -> no problem, a hint', async () => {
const dir = project('sdk-tracks', MANIFEST(false, [], ['NSPrivacyCollectedDataTypeDeviceID', 'NSPrivacyCollectedDataTypeCoarseLocation', 'NSPrivacyCollectedDataTypeAdvertisingData']));
touch(dir, 'ios/Pods/IronSourceSDK/PrivacyInfo.xcprivacy', MANIFEST(true, ['ads.example.com']));
expect(projectSdkManifests(dir).map(s => s.sdk)).toEqual(['IronSourceSDK']);
const r = await runCheck(dir, cfg);
expect(r.problems.join('\n')).not.toContain('NSPrivacyTracking');
expect(r.hints.join('\n')).toContain('tracking declared by the IronSourceSDK manifest');
});
test('storebound check: app manifest true + empty list -> problem with ITMS-91064', async () => {
const dir = project('app-true-empty', MANIFEST(true, [], ['NSPrivacyCollectedDataTypeDeviceID', 'NSPrivacyCollectedDataTypeCoarseLocation', 'NSPrivacyCollectedDataTypeAdvertisingData']));
const r = await runCheck(dir, cfg);
expect(r.problems.join('\n')).toContain(ITMS);
});
test('storebound check: the last built IPA with a bad framework manifest -> problem', async () => {
const dir = project('built-ipa', MANIFEST(false));
touch(dir, 'build/ios/export/Example.ipa', makeZip([
{ name: 'Payload/Example.app/Info.plist', data: readFileSync(join(FIX, 'Info.plist')) },
{ name: 'Payload/Example.app/Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy', data: readFileSync(join(FIX, 'PrivacyInfo-tracking-no-domains.bplist')) },
]));
const r = await runCheck(dir, cfg);
expect(r.problems.join('\n')).toContain('build/ios/export/Example.ipa');
expect(r.problems.join('\n')).toContain('Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy');
});
test('scan: ITMS-91064 is a blocker', () => {
const dir = project('scan', MANIFEST(true));
const f = scanRules({ facts: detectProject(dir), config: null, configFile: null, listings: {}, demoAccount: false });
const hit = f.find(x => x.guideline === 'Privacy manifest (ITMS-91064)')!;
expect(hit.severity).toBe('blocker');
expect(hit.fix).toContain(ITMS);
});
});
describe('proposed app manifest', () => {
test('tracking without app-level domains -> NSPrivacyTracking false; with domains -> true', () => {
const none = proposeAppManifest({ used: ['NSPrivacyAccessedAPICategoryUserDefaults'] });
expect(none.NSPrivacyTracking).toBe(false);
expect(none.NSPrivacyTrackingDomains).toEqual([]);
expect(none.NSPrivacyAccessedAPITypes).toEqual([{ NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', NSPrivacyAccessedAPITypeReasons: ['CA92.1'] }]);
const some = proposeAppManifest({ trackingDomains: ['t.example.com', ' '], apis: { NSPrivacyAccessedAPICategoryUserDefaults: ['1C8F.1'] }, used: ['NSPrivacyAccessedAPICategoryUserDefaults'] });
expect(some.NSPrivacyTracking).toBe(true);
expect(some.NSPrivacyTrackingDomains).toEqual(['t.example.com']);
expect(some.NSPrivacyAccessedAPITypes).toEqual([{ NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', NSPrivacyAccessedAPITypeReasons: ['1C8F.1'] }]);
for (const v of [none, some]) expect(trackingIssue(parseManifest('p', toXmlPlist(v as PlistValue)))).toBeNull();
});
test('privacy-manifest --propose writes false without domains (config field appPrivacy.trackingDomains)', () => {
const dir = join(tmp, 'propose');
touch(dir, 'ios/App/App/PrivacyInfo.xcprivacy', MANIFEST(true));
touch(dir, 'ios/App/App/Save.swift', 'UserDefaults.standard.set(1, forKey: "x")');
const cli = (args: string[]) => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'privacy-manifest', '--ios', 'ios/App', ...args], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
let r = cli(['--propose']);
let m = parseManifest('p', r.stdout.toString());
expect(m.tracking).toBe(false);
expect(m.apis).toEqual({ NSPrivacyAccessedAPICategoryUserDefaults: ['CA92.1'] });
r = cli(['--propose', '--tracking-domains', 't.example.com']);
m = parseManifest('p', r.stdout.toString());
expect(m.tracking).toBe(true);
expect(m.trackingDomains).toEqual(['t.example.com']);
r = cli([]);
expect(r.exitCode).toBe(1);
expect(r.stdout.toString()).toContain('ITMS-91064');
});
});
describe('built app (IPA)', () => {
const ipa = (fw: Buffer | string) => makeZip([
{ name: 'Payload/Example.app/Info.plist', data: readFileSync(join(FIX, 'Info.plist')) },
{ name: 'Payload/Example.app/PrivacyInfo.xcprivacy', data: MANIFEST(false) },
{ name: 'Payload/Example.app/Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy', data: fw },
{ name: 'Payload/Example.app/Frameworks/Ok.framework/Res.bundle/PrivacyInfo.xcprivacy', data: MANIFEST(true, ['ok.example.com']) },
]);
test('binary manifests are read', () => {
const m = manifestFromBytes('x', readFileSync(join(FIX, 'PrivacyInfo-tracking-no-domains.bplist')));
expect(m.tracking).toBe(true);
expect(m.trackingDomains).toEqual([]);
expect(m.apis).toEqual({ NSPrivacyAccessedAPICategoryUserDefaults: ['CA92.1'] });
});
test('every manifest in the IPA: app root, frameworks, bundles', () => {
const list = ipaManifests(ipa(readFileSync(join(FIX, 'PrivacyInfo-tracking-no-domains.bplist'))));
expect(list.map(x => [x.inApp, x.sdk])).toEqual([['PrivacyInfo.xcprivacy', null], ['Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy', 'AdSdk'], ['Frameworks/Ok.framework/Res.bundle/PrivacyInfo.xcprivacy', 'Res']]);
const issues = bundleTrackingIssues(list);
expect(issues.length).toBe(1);
expect(issues[0]).toContain('"Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy"');
expect(sdkOfBundlePath('PrivacyInfo.xcprivacy')).toBeNull();
});
test('apple upload stops before any network call', () => {
const dir = join(tmp, 'upload');
touch(dir, 'storebound.config.ts', `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
languages: [{ id: 'en', apple: 'en-US' }], apple: { bundleId: 'com.example.game' } };\n`);
touch(dir, 'app.ipa', ipa(readFileSync(join(FIX, 'PrivacyInfo-tracking-no-domains.bplist'))));
// a credential that would work offline up to the first request: the check must stop before ascClient
const r = Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'apple', 'upload', 'app.ipa', '--dry'], { cwd: dir, stdout: 'pipe', stderr: 'pipe',
env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', HOME: join(tmp, 'home') } });
const err = r.stderr.toString() + r.stdout.toString();
expect(r.exitCode).toBe(1);
expect(err).toContain('not uploaded, Apple would refuse it as Invalid Binary');
expect(err).toContain('ITMS-91064');
expect(err).toContain('Frameworks/AdSdk.framework/PrivacyInfo.xcprivacy');
expect(err).not.toMatch(/ASC_KEY_ID|credentials|api key/i);
});
});