// The store declarations of one app as data, from the config and the local files only (the desktop "App info & // privacy" tab, `storebound inspect --declarations --json`): Play Data safety answers (and what changed since the // last upload, because Play has no API that reads the form back), the App Store App Privacy answers they suggest // (App Privacy has no API at all: the owner enters it in App Store Connect), app info (categories, age rating, // content rights), the export compliance answer and the App Review details (presence only, masked). Every answer the // config does not prove is an "ask the owner" item, never a guess (AGENTS.md rule 5). Pure. import { parseCsv } from './csv'; import { type AccountSignUp, type DataSafety, type PlayPurpose, profileOf, type ShipConfig } from './config'; import { CATEGORIES, DATA_DELETION_RESPONSE, dataDeletionResponse, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from './data-safety'; import type { DeclarationsResult, DeclAsk } from './desktop-protocol'; import { NOTES_MAX_BYTES, reviewNotes } from './review-details'; /** Where `storebound play data-safety` keeps the CSV it uploaded last (gitignored). */ export const DATA_SAFETY_UPLOADED = '.storebound/data-safety.uploaded.csv'; /** * Play Data safety "Account creation" (question PSL_SUPPORTED_ACCOUNT_CREATION_METHODS): the options Play Console * shows (Data safety > Data collection and security > Account creation) and the response ids storebound writes. * Google publishes no list of these ids (docs/project.md "Not verified"): compare with a Console export (App content > Data * safety > Export to CSV) when an import is refused. */ export const ACCOUNT_CREATION: Record = { PSL_ACM_USER_ID_PASSWORD: 'Username and password', PSL_ACM_USER_ID_OTHER_AUTH: 'Username and other authentication', PSL_ACM_USER_ID_PASSWORD_OTHER_AUTH: 'Username, password and other authentication', PSL_ACM_OAUTH: 'OAuth', PSL_ACM_OTHER: 'Other', PSL_ACM_NONE: 'My app does not allow users to create an account', }; /** * accounts.signUp -> the closest Play "Account creation" option and the account data it means (Play type ids). * A username with no password and a server token kept on the device is "Username and other authentication" (the * token is the other authentication; there is no "username only" option): User ID, and no email. */ export const ACCOUNT_SIGN_UP: Record = { username: { acm: 'PSL_ACM_USER_ID_OTHER_AUTH', data: ['PSL_USER_ACCOUNT'] }, 'username-password': { acm: 'PSL_ACM_USER_ID_PASSWORD', data: ['PSL_USER_ACCOUNT'] }, 'email-password': { acm: 'PSL_ACM_USER_ID_PASSWORD', data: ['PSL_EMAIL', 'PSL_USER_ACCOUNT'] }, 'email-link': { acm: 'PSL_ACM_USER_ID_OTHER_AUTH', data: ['PSL_EMAIL', 'PSL_USER_ACCOUNT'] }, phone: { acm: 'PSL_ACM_USER_ID_OTHER_AUTH', data: ['PSL_PHONE', 'PSL_USER_ACCOUNT'] }, oauth: { acm: 'PSL_ACM_OAUTH', data: ['PSL_USER_ACCOUNT'] }, other: { acm: 'PSL_ACM_OTHER', data: ['PSL_USER_ACCOUNT'] }, }; /** The accountCreation answer accounts.signUp implies (null when signUp is not set). */ export function accountCreationOf(signUp: readonly AccountSignUp[] | undefined): string[] | null { if (!signUp?.length) return null; return [...new Set(signUp.map(s => ACCOUNT_SIGN_UP[s]?.acm).filter((x): x is string => !!x))]; } /** Play data type -> the App Store App Privacy data type it usually is (Apple's own names). */ export const APPLE_PRIVACY: Record = { PSL_NAME: ['Contact Info', 'Name'], PSL_EMAIL: ['Contact Info', 'Email Address'], PSL_PHONE: ['Contact Info', 'Phone Number'], PSL_ADDRESS: ['Contact Info', 'Physical Address'], PSL_OTHER_PERSONAL: ['Contact Info', 'Other User Contact Info'], PSL_USER_ACCOUNT: ['Identifiers', 'User ID'], PSL_RACE_ETHNICITY: ['Sensitive Info', 'Sensitive Info'], PSL_POLITICAL_RELIGIOUS: ['Sensitive Info', 'Sensitive Info'], PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY: ['Sensitive Info', 'Sensitive Info'], PSL_PURCHASE_HISTORY: ['Purchases', 'Purchase History'], PSL_CREDIT_SCORE: ['Financial Info', 'Credit Info'], PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER: ['Financial Info', 'Payment Info'], PSL_OTHER: ['Financial Info', 'Other Financial Info'], PSL_APPROX_LOCATION: ['Location', 'Coarse Location'], PSL_PRECISE_LOCATION: ['Location', 'Precise Location'], PSL_WEB_BROWSING_HISTORY: ['Browsing History', 'Browsing History'], PSL_EMAILS: ['User Content', 'Emails or Text Messages'], PSL_SMS_CALL_LOG: ['User Content', 'Emails or Text Messages'], PSL_OTHER_MESSAGES: ['User Content', 'Emails or Text Messages'], PSL_PHOTOS: ['User Content', 'Photos or Videos'], PSL_VIDEOS: ['User Content', 'Photos or Videos'], PSL_AUDIO: ['User Content', 'Audio Data'], PSL_MUSIC: ['User Content', 'Audio Data'], PSL_OTHER_AUDIO: ['User Content', 'Audio Data'], PSL_HEALTH: ['Health & Fitness', 'Health'], PSL_FITNESS: ['Health & Fitness', 'Fitness'], PSL_CONTACTS: ['Contacts', 'Contacts'], PSL_CALENDAR: ['Other Data', 'Other Data Types'], PSL_CRASH_LOGS: ['Diagnostics', 'Crash Data'], PSL_PERFORMANCE_DIAGNOSTICS: ['Diagnostics', 'Performance Data'], PSL_OTHER_PERFORMANCE: ['Diagnostics', 'Other Diagnostic Data'], PSL_FILES_AND_DOCS: ['User Content', 'Other User Content'], PSL_USER_INTERACTION: ['Usage Data', 'Product Interaction'], PSL_IN_APP_SEARCH_HISTORY: ['Search History', 'Search History'], PSL_APPS_ON_DEVICE: ['Other Data', 'Other Data Types'], PSL_USER_GENERATED_CONTENT: ['User Content', 'Other User Content'], PSL_OTHER_APP_ACTIVITY: ['Usage Data', 'Other Usage Data'], PSL_DEVICE_ID: ['Identifiers', 'Device ID'], }; /** * Play purpose -> the ONE App Privacy purpose it means (definitions: developer.apple.com/app-store/app-privacy-details/, * quoted in cli/lib/app-privacy.ts). Play "advertising" is ads in the app, which in a mobile app or game come from an * ad SDK: Third-Party Advertising, never also Developer's Advertising or Marketing (first-party ads, marketing to your * own users): that one is only the owner's answer (appPrivacy.types..purposes: DEVELOPERS_ADVERTISING). Play * "communications" (news and notifications about the app) is App Functionality; marketing messages are the owner's * call (console fill asks). Analytics is Analytics only, not Product Personalization; fraud prevention, account * management and functionality are App Functionality. */ export function applePurpose(p: PlayPurpose): string { switch (p) { case 'analytics': return 'Analytics'; case 'advertising': return 'Third-Party Advertising'; case 'personalization': return 'Product Personalization'; default: return 'App Functionality'; // functionality, fraud, account, communications } } const categoryOf = (type: string): string => CATEGORIES.find(([, ts]) => ts.includes(type))?.[0].replace('PSL_DATA_TYPES_', '').toLowerCase().replace(/_/g, ' ') ?? 'other'; /** Rows of a Data safety CSV keyed by question + response -> value (header dropped). */ export function dataSafetyAnswers(csv: string): Map { const m = new Map(); for (const r of parseCsv(csv).slice(1)) m.set(`${r[0] ?? ''}|${r[1] ?? ''}`, (r[2] ?? '').trim()); return m; } /** What differs between the CSV uploaded last and the one the config makes now (answers only, labels ignored). */ export function dataSafetyChanges(uploaded: string, now: string): { question: string; response: string; from: string; to: string }[] { const a = dataSafetyAnswers(uploaded), b = dataSafetyAnswers(now); const out: { question: string; response: string; from: string; to: string }[] = []; for (const k of new Set([...a.keys(), ...b.keys()])) { const from = a.get(k) ?? '', to = b.get(k) ?? ''; if (from !== to) { const [question, response] = k.split('|') as [string, string]; out.push({ question, response, from, to }); } } return out; } export interface DeclarationInputs { /** The demo account and review contact, masked (credentials.json / env): presence only, never the secrets. */ demoUser: string | null; contactEmail: string | null; /** The last uploaded Data safety CSV and its time, when there is one. */ uploaded: { csv: string; at: string } | null; } /** The general data deletion question (PSL_SUPPORT_DATA_DELETION_BY_USER) as the CSV answers it, where the answer * comes from and the "Delete data URL" it sends. Pure. */ export function deletionAnswer(ds: DataSafety, deletionUrl?: string): { dataDeletion: 'yes' | 'no' | 'auto90' | null; dataDeletionSource: string | null; dataDeletionUrl: string | null } { const r = dataDeletionResponse(ds, deletionUrl ? { deletionUrl } : undefined); const dataDeletion = (Object.entries(DATA_DELETION_RESPONSE).find(([, v]) => v === r)?.[0] ?? null) as 'yes' | 'no' | 'auto90' | null; const dataDeletionSource = ds.dataDeletion ? 'dataSafety.dataDeletion' : deletionUrl ? 'accounts.deletionUrl' : ds.deletionRequest !== undefined ? 'dataSafety.deletionRequest' : ds.partialDeletion === true || ds.partialDeletion === 'auto90' ? 'dataSafety.partialDeletion' : null; return { dataDeletion, dataDeletionSource, dataDeletionUrl: dataDeletion === 'yes' ? ds.partialDeletionUrl ?? deletionUrl ?? null : null }; } export function declarations(config: ShipConfig, inp: DeclarationInputs): DeclarationsResult { const ask: DeclAsk[] = []; const play = !!config.play, apple = !!config.apple; const profile = profileOf(config); const ds: DataSafety | undefined = config.dataSafety; let dataSafety: DeclarationsResult['dataSafety'] = null; if (play && !ds) ask.push({ area: 'dataSafety', id: 'ds-missing', text: 'The config has no dataSafety answers, so storebound cannot fill the Play Data safety form.', fix: 'Answer what the app and its SDKs collect (store-declarations skill), then add dataSafety to storebound.config.ts.' }); if (ds) { let csv = ''; try { csv = dataSafetyCsv(ds, config.accounts); } catch (e) { ask.push({ area: 'dataSafety', id: 'ds-invalid', text: (e as Error).message, fix: 'Use the Play data type ids (PSL_...) in dataSafety.data.' }); } // conditional required rows Play would refuse the import without (delete-account URL, data-only deletion, ...) if (csv) for (const i of dataSafetyIssues(dataSafetyRows(ds, config.accounts)).filter(x => x.question !== 'PSL_DATA_TYPES_*')) // ds-no-types says it ask.push({ area: 'dataSafety', id: `ds-row-${i.question}`, text: i.msg, fix: i.msg.replace(/^.*; set /, 'Set ').replace(/\)$/, '.') }); const types = Object.entries(ds.data).map(([id, u]) => ({ id, category: categoryOf(id), shared: !!u.shared, ephemeral: !!u.ephemeral, optional: !!u.optional, collect: [...u.collect], share: [...(u.shared ? u.share ?? [] : [])] })); dataSafety = { collectsData: ds.collectsData, encryptedInTransit: ds.encryptedInTransit, partialDeletion: ds.partialDeletion ?? null, accountCreation: ds.accountCreation ?? ['PSL_ACM_NONE'], ...deletionAnswer(ds, config.accounts?.deletionUrl), types, extra: ds.extra?.length ?? 0, csv, rows: csv ? csv.trim().split('\n').length - 1 : 0, uploaded: inp.uploaded && csv ? { at: inp.uploaded.at, changes: dataSafetyChanges(inp.uploaded.csv, csv) } : null, }; if (ds.collectsData && !types.length) ask.push({ area: 'dataSafety', id: 'ds-no-types', text: 'collectsData is true but no data type is listed.', fix: 'List each data type the app or its SDKs collect in dataSafety.data, or set collectsData: false if nothing is.' }); if (!ds.collectsData && types.length) ask.push({ area: 'dataSafety', id: 'ds-contradiction', text: 'collectsData is false but data types are listed.', fix: 'Decide which is true; Play rejects a form that says both.' }); if (profile === 'game' && !ds.data.PSL_DEVICE_ID) ask.push({ area: 'dataSafety', id: 'ds-ads-id', text: 'A game profile usually ships an ads or analytics SDK that reads the advertising id, but PSL_DEVICE_ID is not declared.', fix: 'Check every SDK\'s data disclosure; declare PSL_DEVICE_ID (advertising, analytics) if any reads it.' }); if (config.accounts?.enabled && (ds.accountCreation ?? ['PSL_ACM_NONE']).includes('PSL_ACM_NONE')) ask.push({ area: 'dataSafety', id: 'ds-accounts', text: 'The app has accounts, but the form says no account creation.', fix: `Set dataSafety.accountCreation to the methods the app offers${accountCreationOf(config.accounts.signUp) ? ` (accounts.signUp means ${JSON.stringify(accountCreationOf(config.accounts.signUp))})` : ' (or set accounts.signUp and run storebound scan)'}, and the delete-account URL in dataSafety.extra.` }); } // App Privacy: suggested from the Data safety answers; linked / tracking are questions only the owner can answer const byType = new Map; thirdParty: boolean }>(); for (const [id, u] of Object.entries(ds?.data ?? {})) { const [category, name] = APPLE_PRIVACY[id] ?? ['Other Data', 'Other Data Types']; const e = byType.get(name) ?? { apple: name, category, from: [], purposes: new Set(), thirdParty: false }; e.from.push(id); for (const p of [...u.collect, ...(u.shared ? u.share ?? [] : [])]) e.purposes.add(applePurpose(p)); e.thirdParty ||= !!u.shared; byType.set(name, e); } const appPrivacy: DeclarationsResult['appPrivacy'] = apple ? { items: [...byType.values()].map(e => ({ apple: e.apple, category: e.category, from: e.from, purposes: [...e.purposes].sort(), thirdParty: e.thirdParty })), fromDataSafety: !!ds, } : null; if (apple) { if (!ds) ask.push({ area: 'appPrivacy', id: 'ap-unknown', text: 'App Privacy (App Store Connect > App Privacy) cannot be suggested without dataSafety answers in the config.', fix: 'Answer the data collection once in dataSafety; storebound then lists the matching App Privacy answers to enter.' }); else ask.push({ area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: 'Answer both in App Store Connect > App Privacy for each type below (tracking also needs App Tracking Transparency in the app).' }); } const info = config.apple?.info; const appInfo: DeclarationsResult['appInfo'] = apple ? { categories: info?.categories ?? null, ageRating: Object.entries(info?.ageRating ?? {}).map(([q, v]) => ({ question: q, value: v === null ? 'none' : String(v) })), contentRights: info?.contentRights ?? null, free: info?.free !== false, } : null; if (apple) { if (!info?.categories) ask.push({ area: 'appInfo', id: 'ai-categories', text: 'No App Store category in the config.', fix: 'Set apple.info.categories.primary (for example GAMES with primarySubOne GAMES_PUZZLE).' }); if (!info?.ageRating) ask.push({ area: 'appInfo', id: 'ai-age', text: 'No age rating answers: storebound would answer every content question "none".', fix: 'Confirm that is true (ads, user-generated content, chat, loot boxes, web access count), or set apple.info.ageRating overrides.' }); if (!info?.contentRights) ask.push({ area: 'appInfo', id: 'ai-rights', text: 'Content rights are not declared.', fix: 'Set apple.info.contentRights: DOES_NOT_USE_THIRD_PARTY_CONTENT or USES_THIRD_PARTY_CONTENT (you hold the rights).' }); } const encryption = apple ? config.apple?.usesNonExemptEncryption ?? null : null; if (apple && encryption === null) ask.push({ area: 'encryption', id: 'enc-unset', text: 'Export compliance is not answered (apple.usesNonExemptEncryption).', fix: 'Decide it with the export-compliance skill; false only when the app uses no encryption beyond the OS. storebound never guesses it.' }); const notes = reviewNotes(config); const signIn = config.reviewAccess ? config.reviewAccess.signInRequired : null; const review: DeclarationsResult['review'] = { signInRequired: signIn, accounts: !!config.accounts?.enabled, contact: inp.contactEmail, demoAccount: inp.demoUser, notes, notesBytes: new TextEncoder().encode(notes).length, notesMax: NOTES_MAX_BYTES, }; if (apple) { if (signIn === null) ask.push({ area: 'review', id: 'rv-signin', text: 'The config does not say whether reviewers need to sign in.', fix: 'Set reviewAccess.signInRequired (true needs a demo account; review-access skill).' }); if (signIn && !inp.demoUser) ask.push({ area: 'review', id: 'rv-demo', text: 'Sign-in is required but there is no demo account.', fix: 'Add "reviewDemoAccount": { "username", "password" } to .storebound/credentials.json (it stays on this machine).' }); if (!inp.contactEmail) ask.push({ area: 'review', id: 'rv-contact', text: 'No App Review contact.', fix: 'Add "reviewContact": { firstName, lastName, phone, email } to .storebound/credentials.json (personal data stays there).' }); if (review.notesBytes > NOTES_MAX_BYTES) ask.push({ area: 'review', id: 'rv-notes', text: `Review notes are ${review.notesBytes} bytes; App Store Connect takes ${NOTES_MAX_BYTES}.`, fix: 'Shorten apple.info.reviewNotes or reviewAccess.instructions.' }); } return { profile, stores: { apple, play }, dataSafety, appPrivacy, appInfo, encryption, review, ask }; }