storebound/cli/lib/data-safety.ts

70 lines
5.4 KiB
TypeScript

// Google Play Data safety form as Play's CSV import format (the same layout Play Console exports from the Data safety
// page: "Export to CSV"). One row per question / choice. Importing replaces every earlier answer. Pure.
// Play names a missing answer "Response missing for <ID>" and a wrong choice "Invalid response ID <ID>".
import type { DataSafety, PlayPurpose } from './config';
export const PURPOSE_IDS: Record<PlayPurpose, string> = {
functionality: 'PSL_APP_FUNCTIONALITY',
analytics: 'PSL_ANALYTICS',
communications: 'PSL_DEVELOPER_COMMUNICATIONS',
fraud: 'PSL_FRAUD_PREVENTION_SECURITY',
advertising: 'PSL_ADVERTISING',
personalization: 'PSL_PERSONALIZATION',
account: 'PSL_ACCOUNT_MANAGEMENT',
};
const PURPOSES = Object.values(PURPOSE_IDS);
/** Every category and data type of the form, in form order. */
export const CATEGORIES: [string, string[]][] = [
['PSL_DATA_TYPES_PERSONAL', ['PSL_NAME', 'PSL_EMAIL', 'PSL_USER_ACCOUNT', 'PSL_ADDRESS', 'PSL_PHONE', 'PSL_RACE_ETHNICITY', 'PSL_POLITICAL_RELIGIOUS', 'PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY', 'PSL_OTHER_PERSONAL']],
['PSL_DATA_TYPES_FINANCIAL', ['PSL_PURCHASE_HISTORY', 'PSL_CREDIT_SCORE', 'PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER', 'PSL_OTHER']],
['PSL_DATA_TYPES_LOCATION', ['PSL_APPROX_LOCATION', 'PSL_PRECISE_LOCATION']],
['PSL_DATA_TYPES_SEARCH_AND_BROWSING', ['PSL_WEB_BROWSING_HISTORY']],
['PSL_DATA_TYPES_EMAIL_AND_TEXT', ['PSL_EMAILS', 'PSL_SMS_CALL_LOG', 'PSL_OTHER_MESSAGES']],
['PSL_DATA_TYPES_PHOTOS_AND_VIDEOS', ['PSL_PHOTOS', 'PSL_VIDEOS']],
['PSL_DATA_TYPES_AUDIO', ['PSL_AUDIO', 'PSL_MUSIC', 'PSL_OTHER_AUDIO']],
['PSL_DATA_TYPES_HEALTH_AND_FITNESS', ['PSL_HEALTH', 'PSL_FITNESS']],
['PSL_DATA_TYPES_CONTACTS', ['PSL_CONTACTS']],
['PSL_DATA_TYPES_CALENDAR', ['PSL_CALENDAR']],
['PSL_DATA_TYPES_APP_PERFORMANCE', ['PSL_CRASH_LOGS', 'PSL_PERFORMANCE_DIAGNOSTICS', 'PSL_OTHER_PERFORMANCE']],
['PSL_DATA_TYPES_FILES_AND_DOCS', ['PSL_FILES_AND_DOCS']],
['PSL_DATA_TYPES_APP_ACTIVITY', ['PSL_USER_INTERACTION', 'PSL_IN_APP_SEARCH_HISTORY', 'PSL_APPS_ON_DEVICE', 'PSL_USER_GENERATED_CONTENT', 'PSL_OTHER_APP_ACTIVITY']],
['PSL_DATA_TYPES_IDENTIFIERS', ['PSL_DEVICE_ID']],
];
export const ALL_TYPES = new Set(CATEGORIES.flatMap(([, t]) => t));
const HEADER = ['Question ID (machine readable)', 'Response ID (machine readable)', 'Response value', 'Answer requirement', 'Human-friendly question label'];
export function csvCell(s: string): string { return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
export function dataSafetyRows(ds: DataSafety): string[][] {
for (const t of Object.keys(ds.data)) if (!ALL_TYPES.has(t)) throw new Error(`dataSafety: unknown data type ${t}`);
const b = (v: boolean) => (v ? 'TRUE' : 'FALSE');
const pick = (v: boolean) => (v ? 'TRUE' : '');
const rows: string[][] = [HEADER];
rows.push(['PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA', '', b(ds.collectsData), 'REQUIRED', 'Does your app collect or share any of the required user data types?']);
rows.push(['PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', '', b(ds.encryptedInTransit), 'MAYBE_REQUIRED', 'Is all of the user data collected by your app encrypted in transit?']);
rows.push(['PSL_DATA_COLLECTION_USER_REQUEST_DELETE', '', b(ds.deletionRequest), 'MAYBE_REQUIRED', 'Do you provide a way for users to request that their data is deleted?']);
// Newer form question that Google's older sample CSV does not have; the import fails without it.
for (const id of ds.accountCreation ?? ['PSL_ACM_NONE'])
rows.push(['PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', id, 'TRUE', 'MULTIPLE_CHOICE', `Account creation / ${id}`]);
for (const [cat, types] of CATEGORIES) for (const t of types) rows.push([cat, t, pick(!!ds.data[t]), 'MULTIPLE_CHOICE', `${cat} / ${t}`]);
for (const [, types] of CATEGORIES) for (const t of types) {
const u = ds.data[t];
const q = (s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`;
const label = `Data usage and handling (${t})`;
const collect = new Set((u?.collect ?? []).map(p => PURPOSE_IDS[p]));
const share = new Set((u?.shared ? u.share ?? [] : []).map(p => PURPOSE_IDS[p]));
rows.push([q('PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_COLLECTED', pick(!!u), 'MULTIPLE_CHOICE', label + ' / Collected']);
rows.push([q('PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_SHARED', pick(!!u?.shared), 'MULTIPLE_CHOICE', label + ' / Shared']);
rows.push([q('PSL_DATA_USAGE_EPHEMERAL'), '', u ? b(!!u.ephemeral) : '', 'MAYBE_REQUIRED', label + ' / Processed ephemerally']);
rows.push([q('DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_OPTIONAL', pick(!!u?.optional), 'SINGLE_CHOICE', label + ' / Optional']);
rows.push([q('DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_REQUIRED', pick(!!u && !u.optional), 'SINGLE_CHOICE', label + ' / Required']);
for (const p of PURPOSES) rows.push([q('DATA_USAGE_COLLECTION_PURPOSE'), p, pick(collect.has(p)), 'MULTIPLE_CHOICE', label + ' / Collection purpose']);
for (const p of PURPOSES) rows.push([q('DATA_USAGE_SHARING_PURPOSE'), p, pick(share.has(p)), 'MULTIPLE_CHOICE', label + ' / Sharing purpose']);
}
for (const x of ds.extra ?? []) rows.push([x.question, x.response ?? '', x.value, x.response ? 'MULTIPLE_CHOICE' : 'MAYBE_REQUIRED', x.label ?? x.question]);
return rows;
}
export const dataSafetyCsv = (ds: DataSafety): string => dataSafetyRows(ds).map(r => r.map(csvCell).join(',')).join('\n') + '\n';