// Apple App Privacy ("privacy nutrition label") data types and purposes, and the config block `appPrivacy.types` that // answers them per type. Pure, no imports from the config (config.ts imports this file). // Sources (checked October 2026): // https://developer.apple.com/app-store/app-privacy-details/ (data types, purposes and their definitions, tracking, // linked to the user) // https://developer.apple.com/documentation/bundleresources/describing-data-use-in-privacy-manifests // (NSPrivacyCollectedDataTypes: the manifest's data type and purpose keys, one per App Privacy type / purpose) // Purpose definitions on the App Privacy details page, which the Play purpose mapping (declarations.ts applePurpose) // follows: // Third-Party Advertising: displaying third-party ads in your app, or sharing data with entities who display // third-party ads. // Developer's Advertising or Marketing: displaying first-party ads in your app, sending marketing communications // directly to your users, or sharing data with entities who will display your ads. // Analytics: evaluating user behavior (effectiveness of features, planning features, audience size). // Product Personalization: customizing what the user sees (recommendations, posts, suggestions). // App Functionality: authenticating the user, enabling features, preventing fraud, security, server up-time, // minimizing crashes, scalability and performance, customer support. // Other Purposes: any other purpose not listed. /** App Privacy data type id -> [category, the name App Store Connect shows, the privacy manifest key]. */ export const APPLE_PRIVACY_TYPES = { NAME: ['Contact Info', 'Name', 'NSPrivacyCollectedDataTypeName'], EMAIL_ADDRESS: ['Contact Info', 'Email Address', 'NSPrivacyCollectedDataTypeEmailAddress'], PHONE_NUMBER: ['Contact Info', 'Phone Number', 'NSPrivacyCollectedDataTypePhoneNumber'], PHYSICAL_ADDRESS: ['Contact Info', 'Physical Address', 'NSPrivacyCollectedDataTypePhysicalAddress'], OTHER_USER_CONTACT_INFO: ['Contact Info', 'Other User Contact Info', 'NSPrivacyCollectedDataTypeOtherUserContactInfo'], HEALTH: ['Health & Fitness', 'Health', 'NSPrivacyCollectedDataTypeHealth'], FITNESS: ['Health & Fitness', 'Fitness', 'NSPrivacyCollectedDataTypeFitness'], PAYMENT_INFO: ['Financial Info', 'Payment Info', 'NSPrivacyCollectedDataTypePaymentInfo'], CREDIT_INFO: ['Financial Info', 'Credit Info', 'NSPrivacyCollectedDataTypeCreditInfo'], OTHER_FINANCIAL_INFO: ['Financial Info', 'Other Financial Info', 'NSPrivacyCollectedDataTypeOtherFinancialInfo'], PRECISE_LOCATION: ['Location', 'Precise Location', 'NSPrivacyCollectedDataTypePreciseLocation'], COARSE_LOCATION: ['Location', 'Coarse Location', 'NSPrivacyCollectedDataTypeCoarseLocation'], SENSITIVE_INFO: ['Sensitive Info', 'Sensitive Info', 'NSPrivacyCollectedDataTypeSensitiveInfo'], CONTACTS: ['Contacts', 'Contacts', 'NSPrivacyCollectedDataTypeContacts'], EMAILS_OR_TEXT_MESSAGES: ['User Content', 'Emails or Text Messages', 'NSPrivacyCollectedDataTypeEmailsOrTextMessages'], PHOTOS_OR_VIDEOS: ['User Content', 'Photos or Videos', 'NSPrivacyCollectedDataTypePhotosorVideos'], AUDIO_DATA: ['User Content', 'Audio Data', 'NSPrivacyCollectedDataTypeAudioData'], GAMEPLAY_CONTENT: ['User Content', 'Gameplay Content', 'NSPrivacyCollectedDataTypeGameplayContent'], CUSTOMER_SUPPORT: ['User Content', 'Customer Support', 'NSPrivacyCollectedDataTypeCustomerSupport'], OTHER_USER_CONTENT: ['User Content', 'Other User Content', 'NSPrivacyCollectedDataTypeOtherUserContent'], BROWSING_HISTORY: ['Browsing History', 'Browsing History', 'NSPrivacyCollectedDataTypeBrowsingHistory'], SEARCH_HISTORY: ['Search History', 'Search History', 'NSPrivacyCollectedDataTypeSearchHistory'], USER_ID: ['Identifiers', 'User ID', 'NSPrivacyCollectedDataTypeUserID'], DEVICE_ID: ['Identifiers', 'Device ID', 'NSPrivacyCollectedDataTypeDeviceID'], PURCHASE_HISTORY: ['Purchases', 'Purchase History', 'NSPrivacyCollectedDataTypePurchaseHistory'], PRODUCT_INTERACTION: ['Usage Data', 'Product Interaction', 'NSPrivacyCollectedDataTypeProductInteraction'], ADVERTISING_DATA: ['Usage Data', 'Advertising Data', 'NSPrivacyCollectedDataTypeAdvertisingData'], OTHER_USAGE_DATA: ['Usage Data', 'Other Usage Data', 'NSPrivacyCollectedDataTypeOtherUsageData'], CRASH_DATA: ['Diagnostics', 'Crash Data', 'NSPrivacyCollectedDataTypeCrashData'], PERFORMANCE_DATA: ['Diagnostics', 'Performance Data', 'NSPrivacyCollectedDataTypePerformanceData'], OTHER_DIAGNOSTIC_DATA: ['Diagnostics', 'Other Diagnostic Data', 'NSPrivacyCollectedDataTypeOtherDiagnosticData'], ENVIRONMENT_SCANNING: ['Surroundings', 'Environment Scanning', 'NSPrivacyCollectedDataTypeEnvironmentScanning'], HANDS: ['Body', 'Hands', 'NSPrivacyCollectedDataTypeHands'], HEAD: ['Body', 'Head', 'NSPrivacyCollectedDataTypeHead'], OTHER_DATA_TYPES: ['Other Data', 'Other Data Types', 'NSPrivacyCollectedDataTypeOtherDataTypes'], } as const satisfies Record; export type AppleDataTypeId = keyof typeof APPLE_PRIVACY_TYPES; export const APPLE_DATA_TYPE_IDS = Object.keys(APPLE_PRIVACY_TYPES) as AppleDataTypeId[]; /** App Privacy purpose id -> [the name App Store Connect shows, the privacy manifest key]. */ export const APPLE_PRIVACY_PURPOSES = { THIRD_PARTY_ADVERTISING: ['Third-Party Advertising', 'NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising'], DEVELOPERS_ADVERTISING: ['Developer\'s Advertising or Marketing', 'NSPrivacyCollectedDataTypePurposeDeveloperAdvertising'], ANALYTICS: ['Analytics', 'NSPrivacyCollectedDataTypePurposeAnalytics'], PRODUCT_PERSONALIZATION: ['Product Personalization', 'NSPrivacyCollectedDataTypePurposeProductPersonalization'], APP_FUNCTIONALITY: ['App Functionality', 'NSPrivacyCollectedDataTypePurposeAppFunctionality'], OTHER_PURPOSES: ['Other Purposes', 'NSPrivacyCollectedDataTypePurposeOther'], } as const satisfies Record; export type AppleAppPrivacyPurpose = keyof typeof APPLE_PRIVACY_PURPOSES; export const APPLE_PURPOSE_IDS = Object.keys(APPLE_PRIVACY_PURPOSES) as AppleAppPrivacyPurpose[]; /** The owner's App Privacy answers for one data type (`appPrivacy.types.`). Unset fields stay questions. */ export interface AppleTypeAnswer { collected?: boolean; linked?: boolean; tracking?: boolean; /** The full purpose list of this type (replaces what storebound derives from dataSafety). */ purposes?: AppleAppPrivacyPurpose[]; } export const typeName = (id: AppleDataTypeId): string => APPLE_PRIVACY_TYPES[id][1]; export const purposeName = (id: AppleAppPrivacyPurpose): string => APPLE_PRIVACY_PURPOSES[id][0]; /** The id of an App Store Connect type name ("Device ID" -> DEVICE_ID), or null. */ export const typeIdOf = (name: string): AppleDataTypeId | null => APPLE_DATA_TYPE_IDS.find(id => APPLE_PRIVACY_TYPES[id][1] === name) ?? null; /** The id of an App Store Connect purpose name, or null. */ export const purposeIdOf = (name: string): AppleAppPrivacyPurpose | null => APPLE_PURPOSE_IDS.find(id => APPLE_PRIVACY_PURPOSES[id][0] === name) ?? null; /** The App Privacy type of a privacy manifest data type key (NSPrivacyCollectedDataTypeDeviceID -> DEVICE_ID), or null. */ export const typeIdOfManifest = (key: string): AppleDataTypeId | null => APPLE_DATA_TYPE_IDS.find(id => APPLE_PRIVACY_TYPES[id][2] === key) ?? null; /** Problems of `appPrivacy.types` (ids and purposes against Apple's lists). */ export function validateAppPrivacyTypes(types: unknown): string[] { if (types === undefined) return []; if (!types || typeof types !== 'object' || Array.isArray(types)) return ['appPrivacy.types: an object keyed by App Privacy data type id (DEVICE_ID, USER_ID ...)']; const e: string[] = []; for (const [id, v] of Object.entries(types as Record)) { if (!(id in APPLE_PRIVACY_TYPES)) { e.push(`appPrivacy.types.${id}: not an App Privacy data type id (one of ${APPLE_DATA_TYPE_IDS.join(', ')})`); continue; } if (!v || typeof v !== 'object' || Array.isArray(v)) { e.push(`appPrivacy.types.${id}: { collected?, linked?, tracking?, purposes? }`); continue; } const a = v as Record; for (const k of Object.keys(a)) if (!['collected', 'linked', 'tracking', 'purposes'].includes(k)) e.push(`appPrivacy.types.${id}.${k}: unknown (collected | linked | tracking | purposes)`); for (const k of ['collected', 'linked', 'tracking'] as const) if (a[k] !== undefined && typeof a[k] !== 'boolean') e.push(`appPrivacy.types.${id}.${k}: true | false`); if (a.purposes !== undefined) { if (!Array.isArray(a.purposes)) e.push(`appPrivacy.types.${id}.purposes: a list of ${APPLE_PURPOSE_IDS.join(' | ')}`); else for (const p of a.purposes) if (!(APPLE_PURPOSE_IDS as string[]).includes(p as string)) e.push(`appPrivacy.types.${id}.purposes: "${String(p)}" is not one of ${APPLE_PURPOSE_IDS.join(' | ')}`); } if (a.collected === false && (a.linked !== undefined || a.tracking !== undefined || (Array.isArray(a.purposes) && a.purposes.length))) e.push(`appPrivacy.types.${id}: collected is false, so linked / tracking / purposes do not apply`); } return e; } export interface PrivacyMismatch { level: 'error' | 'warn'; msg: string } /** * `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. */ export function appPrivacyMismatches(types: Partial>, ios: { attString: boolean; manifest: { path: string; tracking: boolean | null; collectedTypes: string[] } | null }): 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'}` }); } 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` }); } if (!m) return out; const inManifest = new Set(); for (const k of m.collectedTypes) { const id = typeIdOfManifest(k); if (!id) { out.push({ level: 'warn', msg: `${m.path}: unknown NSPrivacyCollectedDataType "${k}"` }); continue; } inManifest.add(id); const a = types[id]; if (a?.collected === false) out.push({ level: 'error', msg: `${m.path} declares ${k} (${typeName(id)}) as collected, but appPrivacy.types.${id}.collected is false` }); else if (!a || a.collected === undefined) out.push({ level: 'warn', msg: `${m.path} declares ${k} (${typeName(id)}) as collected, but appPrivacy.types has no answer for ${id}` }); } for (const [id, a] of entries) if (a.collected === true && !inManifest.has(id)) out.push({ level: 'warn', msg: `appPrivacy.types.${id} is collected, but ${m.path} has no ${APPLE_PRIVACY_TYPES[id][2]} in NSPrivacyCollectedDataTypes (fine if only an SDK collects it and declares it in its own manifest)` }); return out; }