play data-safety: PSL_SUPPORT_DATA_DELETION_BY_USER as Play's single choice (partialDeletion true | false | 'auto90'), drop the removed PSL_DATA_COLLECTION_USER_REQUEST_DELETE (deletionRequest deprecated, check notes it); ship Google's empty form (cli/data/play-data-safety-form.csv, exported 2026-10-12), take every Answer requirement from it and report pairs not in it (formPairIssues) in check, declarations and play data-safety. Pitfall 21

This commit is contained in:
CRTkafa 2026-10-12 09:33:56 +08:00
parent dfc28c15f8
commit b5b8f2da9d
35 changed files with 1824 additions and 859 deletions

View file

@ -82,15 +82,23 @@ export interface DataUsage {
export interface DataSafety {
collectsData: boolean;
encryptedInTransit: boolean;
deletionRequest: boolean;
/**
* @deprecated Not written any more: Play removed "Do you provide a way for users to request that their data is
* deleted?" (PSL_DATA_COLLECTION_USER_REQUEST_DELETE) from the Data safety form. `check` notes it; remove it. With
* accounts, Play asks partialDeletion instead.
*/
deletionRequest?: boolean;
/** Response ids for PSL_SUPPORTED_ACCOUNT_CREATION_METHODS. Default ["PSL_ACM_NONE"] (no accounts). */
accountCreation?: string[];
/**
* Play "Do you provide a way for users to request that some or all of their data is deleted, without requiring them
* to delete their account?" (PSL_SUPPORT_DATA_DELETION_BY_USER). Play requires it once accountCreation is not
* PSL_ACM_NONE. Only the owner knows the answer: leave it out and `check` / `play data-safety --dry` say it is missing.
* Play "Do you provide a way for users to request that their data is deleted?" (PSL_SUPPORT_DATA_DELETION_BY_USER, a
* single choice Play asks once accountCreation is not PSL_ACM_NONE): true = yes, people can request that some or all
* of their data is deleted without deleting the account (DATA_DELETION_YES, plus partialDeletionUrl); false = no
* (DATA_DELETION_NO); 'auto90' = no, but user data is automatically deleted within 90 days
* (DATA_DELETION_NO_AUTO_DELETED). Only the owner knows the answer: leave it out and `check` /
* `play data-safety --dry` say it is missing.
*/
partialDeletion?: boolean;
partialDeletion?: boolean | 'auto90';
/** Web page for that data-only deletion (PSL_DATA_DELETION_URL, required when partialDeletion is true). Default:
* accounts.deletionUrl, when that page also lets people delete data without deleting the account. */
partialDeletionUrl?: string;
@ -101,7 +109,8 @@ export interface DataSafety {
* a generated row replaces that generated question (every response row of it). Google does not publish every
* question id: export the form from Play Console (App content > Data safety > Export to CSV), copy the exact ids
* from it, e.g. { question: '<id from the export>', value: '...' }. The delete-account URL is generated from
* accounts.deletionUrl (PSL_ACCOUNT_DELETION_URL).
* accounts.deletionUrl (PSL_ACCOUNT_DELETION_URL). Every (question, response) pair must be in Play's current form
* (cli/data/play-data-safety-form.csv); `check` and `play data-safety --dry` report one that is not.
*/
extra?: { question: string; response?: string; value: string; label?: string }[];
}
@ -901,7 +910,7 @@ export function validateConfig(c: unknown): string[] {
if (!u.collect?.length) e.push(`dataSafety.data.${t}: at least one collection purpose`);
if (u.shared && !u.share?.length) e.push(`dataSafety.data.${t}: shared, so it needs sharing purposes`);
}
if (cfg.dataSafety.partialDeletion !== undefined && typeof cfg.dataSafety.partialDeletion !== 'boolean') e.push('dataSafety.partialDeletion: true | false');
if (cfg.dataSafety.partialDeletion !== undefined && typeof cfg.dataSafety.partialDeletion !== 'boolean' && cfg.dataSafety.partialDeletion !== 'auto90') e.push("dataSafety.partialDeletion: true | false | 'auto90'");
if (cfg.dataSafety.partialDeletionUrl !== undefined && !URL_RE.test(cfg.dataSafety.partialDeletionUrl)) e.push('dataSafety.partialDeletionUrl: must be an https URL');
for (const [i, x] of (cfg.dataSafety.extra ?? []).entries()) if (!/^[A-Z][A-Z0-9_:]+$/.test(x?.question ?? '') || typeof x.value !== 'string')
e.push(`dataSafety.extra[${i}]: { question: "<PSL_... id from a Play Console export>", value: "..." }`);

View file

@ -1,6 +1,8 @@
// Small RFC 4180 CSV reader / writer (quotes, doubled quotes, CR LF, a UTF-8 BOM). Pure. Used for tester lists,
// price sheets and the Play statistics exports.
import { csvCell } from './data-safety';
/** One CSV cell, quoted when it holds a quote, comma or newline. */
export function csvCell(s: string): string { return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
/** Rows of cells. Empty lines are dropped. */
export function parseCsv(text: string, sep = ','): string[][] {

View file

@ -1,9 +1,37 @@
// 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>".
// Play names a missing answer "Response missing for <ID>", a wrong choice "Invalid response ID <ID>" and a row whose
// question has choices but no Response ID "Line N: Response ID missing".
// Some questions are required only after another answer (DATA_SAFETY_RULES below); dataSafetyIssues finds a missing
// one offline, so a 400 like "Response missing for PSL_ACCOUNT_DELETION_URL" shows up in --dry and `check`.
// Every (Question ID, Response ID) pair and its Answer requirement come from Google's own empty form,
// cli/data/play-data-safety-form.csv (exported PLAY_FORM_EXPORTED). Refresh it when Play changes the form: Play Console >
// App content > Data safety > Export to CSV with nothing answered, saved over that file unchanged (docs/pitfalls.md 21).
import type { AccountsConfig, DataSafety, PlayPurpose } from './config';
import { csvCell, parseCsv } from './csv';
import FORM_TEXT from '../data/play-data-safety-form.csv' with { type: 'text' };
export { csvCell };
/** Date of the Play Console export in cli/data/play-data-safety-form.csv. */
export const PLAY_FORM_EXPORTED = '2026-10-12';
export const PLAY_FORM_FILE = 'cli/data/play-data-safety-form.csv';
export interface PlayFormRow { question: string; response: string; requirement: string; label: string }
let formCache: Map<string, PlayFormRow> | null = null;
const pairKey = (q: string, r: string) => `${q}\u0000${r}`;
/** Google's empty Data safety form: every valid (Question ID, Response ID) pair with its Answer requirement and label. */
export function playForm(): Map<string, PlayFormRow> {
if (formCache) return formCache;
const m = new Map<string, PlayFormRow>();
for (const r of parseCsv(FORM_TEXT as unknown as string).slice(1)) {
const [question = '', response = '', , requirement = '', label = ''] = r;
m.set(pairKey(question, response), { question, response, requirement, label: label.trim() });
}
return (formCache = m);
}
export const formRow = (q: string, r = ''): PlayFormRow | undefined => playForm().get(pairKey(q, r));
export const PURPOSE_IDS: Record<PlayPurpose, string> = {
functionality: 'PSL_APP_FUNCTIONALITY',
@ -37,49 +65,78 @@ 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; }
/** The accounts part of the config the form needs (the delete-account URL). */
export type DataSafetyAccounts = Pick<AccountsConfig, 'deletionUrl'> | undefined;
/** PSL_SUPPORT_DATA_DELETION_BY_USER response for dataSafety.partialDeletion. */
export const PARTIAL_DELETION_RESPONSE = { yes: 'DATA_DELETION_YES', no: 'DATA_DELETION_NO', auto90: 'DATA_DELETION_NO_AUTO_DELETED' } as const;
const partialDeletionResponse = (v: DataSafety['partialDeletion']): string | undefined =>
v === true ? PARTIAL_DELETION_RESPONSE.yes : v === false ? PARTIAL_DELETION_RESPONSE.no : v === 'auto90' ? PARTIAL_DELETION_RESPONSE.auto90 : undefined;
export function dataSafetyRows(ds: DataSafety, accounts?: DataSafetyAccounts): 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.
// Answer requirement and label come from Play's exported form; a pair it does not have keeps `fallback` and
// formPairIssues reports it (Play would answer 400).
const row = (q: string, r: string, v: string, fallback: string, label: string): string[] => {
const f = formRow(q, r);
return [q, r, v, f?.requirement ?? fallback, f?.label || label];
};
const add = (q: string, r: string, v: string, fallback: string, label: string) => { rows.push(row(q, r, v, fallback, label)); };
add('PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA', '', b(ds.collectsData), 'REQUIRED', 'Does your app collect or share any of the required user data types?');
add('PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', '', b(ds.encryptedInTransit), 'MAYBE_REQUIRED', 'Is all of the user data collected by your app encrypted in transit?');
// dataSafety.deletionRequest is not written: Play removed PSL_DATA_COLLECTION_USER_REQUEST_DELETE from the form.
const acm = ds.accountCreation ?? ['PSL_ACM_NONE'];
for (const id of acm) rows.push(['PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', id, 'TRUE', 'MULTIPLE_CHOICE', `Account creation / ${id}`]);
for (const id of acm) add('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', id, 'TRUE', 'MULTIPLE_CHOICE', `Account creation / ${id}`);
// Data deletion questions Play asks once the app lets people create an account (Play Console Help 13327111).
// An empty value is a missing answer: dataSafetyIssues names it with the config field to set.
if (!acm.includes('PSL_ACM_NONE')) {
rows.push(['PSL_ACCOUNT_DELETION_URL', '', accounts?.deletionUrl ?? '', 'MAYBE_REQUIRED', 'Delete account URL']);
rows.push(['PSL_SUPPORT_DATA_DELETION_BY_USER', '', ds.partialDeletion === undefined ? '' : b(ds.partialDeletion), 'MAYBE_REQUIRED',
'Do you provide a way for users to request that some or all of their data is deleted, without requiring them to delete their account?']);
if (ds.partialDeletion) rows.push(['PSL_DATA_DELETION_URL', '', ds.partialDeletionUrl ?? accounts?.deletionUrl ?? '', 'MAYBE_REQUIRED', 'Delete data URL']);
add('PSL_ACCOUNT_DELETION_URL', '', accounts?.deletionUrl ?? '', 'MAYBE_REQUIRED', 'Delete account URL');
// a SINGLE_CHOICE question: one row per option, TRUE on the picked one (a value without a Response ID is a 400)
const picked = partialDeletionResponse(ds.partialDeletion);
for (const r of Object.values(PARTIAL_DELETION_RESPONSE))
add('PSL_SUPPORT_DATA_DELETION_BY_USER', r, pick(picked === r), 'SINGLE_CHOICE', `Data deletion without deleting the account / ${r}`);
if (picked === PARTIAL_DELETION_RESPONSE.yes) add('PSL_DATA_DELETION_URL', '', ds.partialDeletionUrl ?? accounts?.deletionUrl ?? '', 'MAYBE_REQUIRED', 'Delete data URL');
}
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 [cat, types] of CATEGORIES) for (const t of types) add(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']);
add(q('PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_COLLECTED', pick(!!u), 'MULTIPLE_CHOICE', label + ' / Collected');
add(q('PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_SHARED', pick(!!u?.shared), 'MULTIPLE_CHOICE', label + ' / Shared');
add(q('PSL_DATA_USAGE_EPHEMERAL'), '', u ? b(!!u.ephemeral) : '', 'MAYBE_REQUIRED', label + ' / Processed ephemerally');
add(q('DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_OPTIONAL', pick(!!u?.optional), 'SINGLE_CHOICE', label + ' / Optional');
add(q('DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_REQUIRED', pick(!!u && !u.optional), 'SINGLE_CHOICE', label + ' / Required');
for (const p of PURPOSES) add(q('DATA_USAGE_COLLECTION_PURPOSE'), p, pick(collect.has(p)), 'MULTIPLE_CHOICE', label + ' / Collection purpose');
for (const p of PURPOSES) add(q('DATA_USAGE_SHARING_PURPOSE'), p, pick(share.has(p)), 'MULTIPLE_CHOICE', label + ' / Sharing purpose');
}
// an extra row for a question storebound generates replaces that question (all its response rows)
const replaced = new Set((ds.extra ?? []).map(x => x.question));
const out = rows.filter((r, i) => i === 0 || !replaced.has(r[0]!));
for (const x of ds.extra ?? []) out.push([x.question, x.response ?? '', x.value, x.response ? 'MULTIPLE_CHOICE' : 'MAYBE_REQUIRED', x.label ?? x.question]);
for (const x of ds.extra ?? []) {
const r = row(x.question, x.response ?? '', x.value, x.response ? 'MULTIPLE_CHOICE' : 'MAYBE_REQUIRED', x.question);
if (x.label) r[4] = x.label;
out.push(r);
}
return out;
}
/**
* (Question ID, Response ID) pairs of a CSV that are not in Play's current form (cli/data/play-data-safety-form.csv).
* Play refuses the whole import for one of them ("Response ID missing", "Invalid response ID"). Pure.
*/
export function formPairIssues(rows: string[][]): DataSafetyIssue[] {
const out: DataSafetyIssue[] = [];
for (const r of rows) {
const q = r[0] ?? '', resp = r[1] ?? '';
if (!q || q === HEADER[0] || formRow(q, resp)) continue;
out.push({ question: q, msg: `${q},${resp} is not in Play's current Data safety form (${PLAY_FORM_FILE}, exported ${PLAY_FORM_EXPORTED}); Play answers 400` });
}
return out;
}
@ -132,11 +189,10 @@ export interface DataSafetyRule {
export const DATA_SAFETY_RULES: DataSafetyRule[] = [
{ question: 'PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA', when: () => true, condition: 'always', need: 'bool', fix: 'dataSafety.collectsData' },
{ question: 'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', when: a => a.collects, condition: 'the app collects data', need: 'bool', fix: 'dataSafety.encryptedInTransit' },
{ question: 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE', when: a => a.collects, condition: 'the app collects data', need: 'bool', fix: 'dataSafety.deletionRequest' },
{ question: 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', when: () => true, condition: 'always', need: 'choice', fix: "dataSafety.accountCreation (['PSL_ACM_NONE'] when there are no accounts)" },
{ question: 'PSL_ACCOUNT_DELETION_URL', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE', need: 'url', fix: 'accounts.deletionUrl, a public https page to request account + data deletion (account-deletion skill)' },
{ question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE', need: 'bool', fix: 'dataSafety.partialDeletion: true | false, whether people can delete some or all data without deleting the account (ask the owner)' },
{ question: 'PSL_DATA_DELETION_URL', when: a => a.value('PSL_SUPPORT_DATA_DELETION_BY_USER').toUpperCase() === 'TRUE', condition: 'PSL_SUPPORT_DATA_DELETION_BY_USER is TRUE', need: 'url', fix: 'dataSafety.partialDeletionUrl (default accounts.deletionUrl)' },
{ question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE', need: 'one-choice', fix: "dataSafety.partialDeletion: true | false | 'auto90', whether people can delete some or all data without deleting the account, or it is deleted automatically within 90 days (ask the owner)" },
{ question: 'PSL_DATA_DELETION_URL', when: a => a.chosen('PSL_SUPPORT_DATA_DELETION_BY_USER').includes('DATA_DELETION_YES'), condition: 'PSL_SUPPORT_DATA_DELETION_BY_USER is DATA_DELETION_YES', need: 'url', fix: 'dataSafety.partialDeletionUrl (default accounts.deletionUrl)' },
{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE; Play accepts it only with PSL_ACM_NONE', need: 'absent', fix: 'remove it from dataSafety.extra' },
];
@ -160,10 +216,13 @@ export function typeRules(t: string): DataSafetyRule[] {
export interface DataSafetyIssue { question: string; msg: string }
/** Required-row check of a Data safety CSV (rows as dataSafetyRows returns them, or a parsed Play export). Pure. */
/**
* Check of a Data safety CSV (rows as dataSafetyRows returns them, or a parsed Play export): every pair is in Play's
* current form (formPairIssues) and every conditionally required row is there. Pure.
*/
export function dataSafetyIssues(rows: string[][]): DataSafetyIssue[] {
const a = new DataSafetyAnswers(rows);
const out: DataSafetyIssue[] = [];
const out: DataSafetyIssue[] = formPairIssues(rows);
for (const r of [...DATA_SAFETY_RULES, ...[...ALL_TYPES].flatMap(typeRules)]) {
if (!r.when(a)) continue;
const v = a.value(r.question), c = a.chosen(r.question);

View file

@ -127,7 +127,7 @@ export function declarations(config: ShipConfig, inp: DeclarationInputs): Declar
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, deletionRequest: ds.deletionRequest, accountCreation: ds.accountCreation ?? ['PSL_ACM_NONE'],
collectsData: ds.collectsData, encryptedInTransit: ds.encryptedInTransit, partialDeletion: ds.partialDeletion ?? null, accountCreation: ds.accountCreation ?? ['PSL_ACM_NONE'],
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,
};
@ -135,7 +135,6 @@ export function declarations(config: ShipConfig, inp: DeclarationInputs): Declar
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.` });
if (!ds.deletionRequest && config.accounts?.enabled) ask.push({ area: 'dataSafety', id: 'ds-deletion', text: 'Users can create accounts but deletionRequest is false.', fix: 'Both stores want account deletion (account-deletion skill); then set deletionRequest: true.' });
}
// App Privacy: suggested from the Data safety answers; linked / tracking are questions only the owner can answer

View file

@ -268,7 +268,9 @@ export interface DeclarationsResult {
profile: string;
stores: { apple: boolean; play: boolean };
dataSafety: null | {
collectsData: boolean; encryptedInTransit: boolean; deletionRequest: boolean; accountCreation: string[];
collectsData: boolean; encryptedInTransit: boolean; accountCreation: string[];
/** dataSafety.partialDeletion (PSL_SUPPORT_DATA_DELETION_BY_USER; asked once there are accounts); null: not answered. */
partialDeletion: boolean | 'auto90' | null;
types: { id: string; category: string; shared: boolean; ephemeral: boolean; optional: boolean; collect: string[]; share: string[] }[];
/** Extra CSV rows of questions storebound does not model. */
extra: number;

View file

@ -12,7 +12,7 @@ export function profileIssues(config: ShipConfig, o: { demoAccount: boolean }):
const err = (msg: string) => out.push({ level: 'error', msg });
const warn = (msg: string) => out.push({ level: 'warn', msg });
const accounts = !!config.accounts?.enabled;
if (config.dataSafety) for (const k of ['collectsData', 'encryptedInTransit', 'deletionRequest'] as const)
if (config.dataSafety) for (const k of ['collectsData', 'encryptedInTransit'] as const)
if (typeof config.dataSafety[k] !== 'boolean') err(`dataSafety.${k}: not answered (true | false; ask the owner, store-declarations skill)`);
const acm = config.dataSafety?.accountCreation ?? ['PSL_ACM_NONE'];
if (accounts) {
@ -23,7 +23,6 @@ export function profileIssues(config: ShipConfig, o: { demoAccount: boolean }):
const s = accountCreationOf(config.accounts!.signUp);
err(`accounts.enabled but dataSafety.accountCreation says PSL_ACM_NONE: list the sign-up methods you offer${s ? ` (accounts.signUp means accountCreation: ${JSON.stringify(s)})` : ' (accounts.signUp, e.g. [\'username\'] for a username with no password, tells storebound scan which)'}`);
}
if (!config.dataSafety.deletionRequest) err('accounts.enabled but dataSafety.deletionRequest is false: people must be able to delete their account and data');
}
} else if (config.dataSafety && !acm.includes('PSL_ACM_NONE')) warn('dataSafety.accountCreation lists sign-up methods but accounts.enabled is not set: add accounts { enabled: true, deletionUrl, inApp }');
if (config.reviewAccess?.signInRequired) {

View file

@ -124,8 +124,9 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
// top-level Data safety answers
const anyData = Object.keys(out).length > 0;
if (anyData) items.push({ area: 'dataSafety', what: 'collectsData', value: 'true', status: 'detected', evidence: diff.flatMap(d => d.evidence).slice(0, 1) });
for (const k of ['encryptedInTransit', 'deletionRequest'] as const)
items.push({ area: 'dataSafety', what: k, value: c?.dataSafety ? String(c.dataSafety[k]) : '?', status: 'ask', evidence: [], note: k === 'encryptedInTransit' ? 'all traffic over HTTPS / TLS? (ATS and the SDKs\' own endpoints)' : 'can users ask for their data to be deleted?' });
items.push({ area: 'dataSafety', what: 'encryptedInTransit', value: c?.dataSafety ? String(c.dataSafety.encryptedInTransit) : '?', status: 'ask', evidence: [], note: 'all traffic over HTTPS / TLS? (ATS and the SDKs\' own endpoints)' });
if (accounts) items.push({ area: 'dataSafety', what: 'partialDeletion', value: c?.dataSafety?.partialDeletion !== undefined ? String(c.dataSafety.partialDeletion) : '?', status: 'ask', evidence: [],
note: "can people request that some or all of their data is deleted without deleting the account? true | false | 'auto90' (deleted automatically within 90 days)" });
const acmFromCfg = accountCreationOf(signUpCfg);
const acmNone = (c?.dataSafety?.accountCreation ?? ['PSL_ACM_NONE']).includes('PSL_ACM_NONE');
let accountCreation: string[] | null = null;

View file

@ -199,7 +199,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
if (play && !c?.accounts?.deletionUrl)
add({ rule: 'play-deletion-url', guideline: 'Play: Account deletion', store: 'play', severity: c?.accounts?.enabled || signUpCode.length ? 'blocker' : 'high',
title: 'Accounts can be created but there is no web page to request account deletion (Data safety asks for it)', evidence: creates.slice(0, 4),
fix: 'Publish a page where people request account and data deletion without the app, set accounts.deletionUrl (and dataSafety.deletionRequest: true); Play also wants the in-app path.', skill: 'account-deletion', ref: `${PLAY}13327111` });
fix: 'Publish a page where people request account and data deletion without the app, set accounts.deletionUrl (and answer dataSafety.partialDeletion); Play also wants the in-app path.', skill: 'account-deletion', ref: `${PLAY}13327111` });
}
if (apple && thirdParty.length && !siwa.length)
add({ rule: 'login-services', guideline: '4.8', store: 'apple', severity: 'high', title: 'Third-party login (Google / Facebook / ...) without Sign in with Apple or another equivalent login option', evidence: thirdParty.slice(0, 4),