play data-safety: write the conditional required rows (PSL_ACCOUNT_DELETION_URL from accounts.deletionUrl, PSL_SUPPORT_DATA_DELETION_BY_USER from dataSafety.partialDeletion, PSL_DATA_DELETION_URL); a rule table checks every conditional row in check, declarations and --dry before Play answers 400

This commit is contained in:
CRTkafa 2026-10-12 08:25:14 +08:00
parent c9a7f095c4
commit 97ab76e9c8
14 changed files with 295 additions and 24 deletions

View file

@ -85,13 +85,23 @@ export interface DataSafety {
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.
*/
partialDeletion?: boolean;
/** 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;
/** Play data type id (e.g. "PSL_DEVICE_ID") -> usage. Types not listed are "not collected". */
data: Record<string, DataUsage>;
/**
* Extra CSV rows for questions storebound does not model, appended as they are. Google does not publish every
* question id (e.g. the delete-account URL asked when accountCreation is not PSL_ACM_NONE): 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: 'https://example.com/delete-account' }.
* Extra CSV rows for questions storebound does not model, appended as they are; a row with the same question id as
* 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).
*/
extra?: { question: string; response?: string; value: string; label?: string }[];
}
@ -874,6 +884,8 @@ 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.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,7 +1,9 @@
// 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';
// 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`.
import type { AccountsConfig, DataSafety, PlayPurpose } from './config';
export const PURPOSE_IDS: Record<PlayPurpose, string> = {
functionality: 'PSL_APP_FUNCTIONALITY',
@ -37,7 +39,10 @@ const HEADER = ['Question ID (machine readable)', 'Response ID (machine readable
export function csvCell(s: string): string { return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
export function dataSafetyRows(ds: DataSafety): string[][] {
/** The accounts part of the config the form needs (the delete-account URL). */
export type DataSafetyAccounts = Pick<AccountsConfig, 'deletionUrl'> | 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' : '');
@ -46,8 +51,16 @@ export function dataSafetyRows(ds: DataSafety): string[][] {
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}`]);
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}`]);
// 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']);
}
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];
@ -63,8 +76,114 @@ export function dataSafetyRows(ds: DataSafety): string[][] {
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;
// 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]);
return out;
}
export const dataSafetyCsv = (ds: DataSafety): string => dataSafetyRows(ds).map(r => r.map(csvCell).join(',')).join('\n') + '\n';
export const dataSafetyCsv = (ds: DataSafety, accounts?: DataSafetyAccounts): string => dataSafetyRows(ds, accounts).map(r => r.map(csvCell).join(',')).join('\n') + '\n';
// ---- required-row rules ----
/** Answers of a CSV, read back from its rows (the header row is skipped). */
export class DataSafetyAnswers {
private readonly values = new Map<string, string>();
private readonly picks = new Map<string, string[]>();
readonly questions = new Set<string>();
constructor(rows: string[][]) {
for (const r of rows) {
const q = r[0] ?? '', resp = r[1] ?? '', v = (r[2] ?? '').trim();
if (!q || q === HEADER[0]) continue;
this.questions.add(q);
if (!resp) this.values.set(q, v);
else if (v.toUpperCase() === 'TRUE') this.picks.set(q, [...(this.picks.get(q) ?? []), resp]);
}
}
/** The value of a free-text / TRUE-FALSE question ('' when missing or empty). */
value(q: string): string { return this.values.get(q) ?? ''; }
/** Response ids of a choice question marked TRUE. */
chosen(q: string): string[] { return this.picks.get(q) ?? []; }
answered(q: string): boolean { return this.questions.has(q); }
/** Account creation answered with a method other than "none". */
get accounts(): boolean { const c = this.chosen('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS'); return c.length > 0 && !c.includes('PSL_ACM_NONE'); }
get collects(): boolean { return this.value('PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA').toUpperCase() === 'TRUE'; }
}
export interface DataSafetyRule {
question: string;
/** When Play requires the answer (for need 'absent': when Play refuses the question). */
when: (a: DataSafetyAnswers) => boolean;
/** The condition in words, for messages and docs. */
condition: string;
need: 'bool' | 'url' | 'choice' | 'one-choice' | 'absent';
/** What to set in storebound.config.ts. */
fix: string;
}
/**
* Form-level rules: question ids Play requires (or refuses) depending on other answers. Sources: Play Console Help
* 10787469 (Data safety form, CSV import / export) and 13327111 (account deletion: apps with account creation give a
* delete-account web link; data-only deletion is a separate question), plus Play's own 400 answers to imports
* ("Response missing for PSL_ACCOUNT_DELETION_URL", "Response missing for PSL_DATA_DELETION_URL",
* "You cannot answer PSL_HAS_OUTSIDE_APP_ACCOUNTS").
*/
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_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' },
];
const usage = (t: string, s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`;
/** Rules for one data type, derived from the form's structure (each type of CATEGORIES has the same usage block). */
export function typeRules(t: string): DataSafetyRule[] {
const how = (a: DataSafetyAnswers) => a.chosen(usage(t, 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'));
const collected = (a: DataSafetyAnswers) => how(a).includes('PSL_DATA_USAGE_ONLY_COLLECTED');
const shared = (a: DataSafetyAnswers) => how(a).includes('PSL_DATA_USAGE_ONLY_SHARED');
const selected = (a: DataSafetyAnswers) => CATEGORIES.some(([c]) => a.chosen(c).includes(t));
const fix = `dataSafety.data.${t}`;
return [
{ question: usage(t, 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'), when: selected, condition: `${t} is selected`, need: 'choice', fix: `${fix} (collected and / or shared)` },
{ question: usage(t, 'PSL_DATA_USAGE_EPHEMERAL'), when: collected, condition: `${t} is collected`, need: 'bool', fix: `${fix}.ephemeral` },
{ question: usage(t, 'DATA_USAGE_USER_CONTROL'), when: collected, condition: `${t} is collected`, need: 'one-choice', fix: `${fix}.optional` },
{ question: usage(t, 'DATA_USAGE_COLLECTION_PURPOSE'), when: collected, condition: `${t} is collected`, need: 'choice', fix: `${fix}.collect` },
{ question: usage(t, 'DATA_USAGE_SHARING_PURPOSE'), when: shared, condition: `${t} is shared`, need: 'choice', fix: `${fix}.share` },
];
}
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. */
export function dataSafetyIssues(rows: string[][]): DataSafetyIssue[] {
const a = new DataSafetyAnswers(rows);
const out: DataSafetyIssue[] = [];
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);
const say = (what: string) => out.push({ question: r.question, msg: `${what}: ${r.question} (${r.condition}; set ${r.fix})` });
if (r.need === 'absent') { if (a.answered(r.question)) say('not allowed'); }
else if (r.need === 'bool') { if (!/^(TRUE|FALSE)$/i.test(v)) say(v ? `not TRUE / FALSE ("${v}")` : 'missing'); }
else if (r.need === 'url') { if (!/^https:\/\/\S+$/.test(v)) say(v ? `not an https URL ("${v}")` : 'missing'); }
else if (r.need === 'choice') { if (!c.length) say('missing'); }
else if (c.length !== 1) say(c.length ? `${c.length} answers to a single choice` : 'missing');
}
const acm = a.chosen('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS');
if (acm.includes('PSL_ACM_NONE') && acm.length > 1)
out.push({ question: 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', msg: 'PSL_ACM_NONE together with sign-up methods: PSL_SUPPORTED_ACCOUNT_CREATION_METHODS (set dataSafety.accountCreation to PSL_ACM_NONE or to the methods)' });
if (a.collects && !CATEGORIES.some(([cat]) => a.chosen(cat).length))
out.push({ question: 'PSL_DATA_TYPES_*', msg: 'missing: a data type (collectsData is true; list the types in dataSafety.data or set collectsData: false)' });
return out;
}
/** The issues as one error message, or null. */
export function dataSafetyProblem(rows: string[][]): string | null {
const i = dataSafetyIssues(rows);
return i.length ? `Data safety CSV is not complete (Play would answer 400):\n${i.map(x => ` - ${x.msg}`).join('\n')}` : null;
}

View file

@ -6,7 +6,7 @@
// 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, dataSafetyCsv } from './data-safety';
import { CATEGORIES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from './data-safety';
import type { DeclarationsResult, DeclAsk } from './desktop-protocol';
import { NOTES_MAX_BYTES, reviewNotes } from './review-details';
@ -121,7 +121,10 @@ export function declarations(config: ShipConfig, inp: DeclarationInputs): Declar
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); } 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.' }); }
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, deletionRequest: ds.deletionRequest, accountCreation: ds.accountCreation ?? ['PSL_ACM_NONE'],