storebound/cli/lib/declarations.ts

200 lines
17 KiB
TypeScript

// 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 { consoleLink, consoleUrl } from './console-links';
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<string, string> = {
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<AccountSignUp, { acm: string; data: string[] }> = {
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<string, [string, string]> = {
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.<ID>.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<string, string> {
const m = new Map<string, string>();
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<string, { apple: string; category: string; from: string[]; purposes: Set<string>; 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<string>(), 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 (${consoleLink('asc.app-privacy')}) cannot be suggested without dataSafety answers in the config.`, url: consoleUrl('asc.app-privacy'), 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 ${consoleLink('asc.app-privacy')} for each type below (tracking also needs App Tracking Transparency in the app).`, url: consoleUrl('asc.app-privacy') });
}
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 };
}