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

@ -2,6 +2,19 @@
## Unreleased ## Unreleased
- Fix (`play data-safety`): apps with accounts got Play 400 "Invalid safety labels declaration: Response missing for
PSL_ACCOUNT_DELETION_URL". When `dataSafety.accountCreation` is not `PSL_ACM_NONE`, the CSV now carries
`PSL_ACCOUNT_DELETION_URL` = `accounts.deletionUrl` and `PSL_SUPPORT_DATA_DELETION_BY_USER` = the new
`dataSafety.partialDeletion` (delete some or all data without deleting the account; ask the owner), plus
`PSL_DATA_DELETION_URL` = new `dataSafety.partialDeletionUrl` (default `accounts.deletionUrl`) when that is true.
- Required-row rules in one table (`DATA_SAFETY_RULES`, per data type `typeRules`, `dataSafetyIssues`): account
creation -> deletion rows, a selected data type -> collected / shared, ephemeral, required / optional, purposes,
`PSL_HAS_OUTSIDE_APP_ACCOUNTS` refused with sign-up methods. `storebound check`, `project.declarations` and
`play data-safety --dry` say `missing: <id> (<when>; set <config field>)`; the real run stops before the POST.
- A `dataSafety.extra` row with a generated question id now replaces that question instead of adding a second one.
- Sources: support.google.com/googleplay/android-developer/answer/10787469 ,
support.google.com/googleplay/android-developer/answer/13327111 . Pitfall 21.
- Agent-first console mode. `storebound console plan <form> [--json]` (read-only, offline; `--lookup` asks the App - Agent-first console mode. `storebound console plan <form> [--json]` (read-only, offline; `--lookup` asks the App
Store Connect API for the app id) gives an AI agent everything to fill a console form with its own browser tool: Store Connect API for the app id) gives an AI agent everything to fill a console form with its own browser tool:
the start URL (deep links from apple.appId, play.developerId, the new play.appId and the remembered ids), ordered the start URL (deep links from apple.appId, play.developerId, the new play.appId and the remembered ids), ordered

View file

@ -12,7 +12,7 @@ import { draftLocales } from './lib/aso-intel';
import { appleName, playCodes } from './lib/config'; import { appleName, playCodes } from './lib/config';
import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy'; import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy';
import type { Ctx } from './lib/ctx'; import type { Ctx } from './lib/ctx';
import { dataSafetyRows } from './lib/data-safety'; import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety';
import { reviewDemoAccount } from './lib/credentials'; import { reviewDemoAccount } from './lib/credentials';
import { log, printJson, warn } from './lib/log'; import { log, printJson, warn } from './lib/log';
import { profileIssues } from './lib/profile'; import { profileIssues } from './lib/profile';
@ -96,7 +96,12 @@ export async function run(ctx: Ctx): Promise<void> {
log('check', `app privacy: ${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project`); log('check', `app privacy: ${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project`);
} }
} }
if (config.dataSafety) tryDo('data safety', () => `${dataSafetyRows(config.dataSafety!).length - 1} CSV rows`); if (config.dataSafety) tryDo('data safety', () => {
const rows = dataSafetyRows(config.dataSafety!, config.accounts);
// conditional required rows (e.g. the delete-account URL once accounts exist): Play answers 400 without them
for (const i of dataSafetyIssues(rows)) problems.push(`data safety: ${i.msg}`);
return `${rows.length - 1} CSV rows`;
});
for (const p of config.products ?? []) for (const [lang, l] of Object.entries(p.listings)) { for (const p of config.products ?? []) for (const [lang, l] of Object.entries(p.listings)) {
if (config.apple && len(l.description) > APPLE_IAP_LIMITS.description) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > ${APPLE_IAP_LIMITS.description} (App Store limit)`); if (config.apple && len(l.description) > APPLE_IAP_LIMITS.description) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > ${APPLE_IAP_LIMITS.description} (App Store limit)`);
if (len(l.description) > 200) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > 200 (Play limit)`); if (len(l.description) > 200) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > 200 (Play limit)`);

View file

@ -85,13 +85,23 @@ export interface DataSafety {
deletionRequest: boolean; deletionRequest: boolean;
/** Response ids for PSL_SUPPORTED_ACCOUNT_CREATION_METHODS. Default ["PSL_ACM_NONE"] (no accounts). */ /** Response ids for PSL_SUPPORTED_ACCOUNT_CREATION_METHODS. Default ["PSL_ACM_NONE"] (no accounts). */
accountCreation?: string[]; 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". */ /** Play data type id (e.g. "PSL_DEVICE_ID") -> usage. Types not listed are "not collected". */
data: Record<string, DataUsage>; data: Record<string, DataUsage>;
/** /**
* Extra CSV rows for questions storebound does not model, appended as they are. Google does not publish every * Extra CSV rows for questions storebound does not model, appended as they are; a row with the same question id as
* question id (e.g. the delete-account URL asked when accountCreation is not PSL_ACM_NONE): export the form from * a generated row replaces that generated question (every response row of it). Google does not publish every
* Play Console (App content > Data safety > Export to CSV), copy the exact ids from it, e.g. * question id: export the form from Play Console (App content > Data safety > Export to CSV), copy the exact ids
* { question: '<id from the export>', value: 'https://example.com/delete-account' }. * 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 }[]; 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.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 (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') 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: "..." }`); 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 // 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. // 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>" 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> = { export const PURPOSE_IDS: Record<PlayPurpose, string> = {
functionality: 'PSL_APP_FUNCTIONALITY', 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 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}`); 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 b = (v: boolean) => (v ? 'TRUE' : 'FALSE');
const pick = (v: boolean) => (v ? 'TRUE' : ''); 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_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?']); 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. // 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']) const acm = ds.accountCreation ?? ['PSL_ACM_NONE'];
rows.push(['PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', id, 'TRUE', 'MULTIPLE_CHOICE', `Account creation / ${id}`]); 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 [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) { for (const [, types] of CATEGORIES) for (const t of types) {
const u = ds.data[t]; 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_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 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]); // an extra row for a question storebound generates replaces that question (all its response rows)
return 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. // config does not prove is an "ask the owner" item, never a guess (AGENTS.md rule 5). Pure.
import { parseCsv } from './csv'; import { parseCsv } from './csv';
import { type AccountSignUp, type DataSafety, type PlayPurpose, profileOf, type ShipConfig } from './config'; 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 type { DeclarationsResult, DeclAsk } from './desktop-protocol';
import { NOTES_MAX_BYTES, reviewNotes } from './review-details'; 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 (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) { if (ds) {
let csv = ''; 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 ?? [] : [])] })); 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 = { dataSafety = {
collectsData: ds.collectsData, encryptedInTransit: ds.encryptedInTransit, deletionRequest: ds.deletionRequest, accountCreation: ds.accountCreation ?? ['PSL_ACM_NONE'], collectsData: ds.collectsData, encryptedInTransit: ds.encryptedInTransit, deletionRequest: ds.deletionRequest, accountCreation: ds.accountCreation ?? ['PSL_ACM_NONE'],

View file

@ -1,6 +1,7 @@
// Play Data safety form through applications.dataSafety (CSV import; replaces every earlier answer). // Play Data safety form through applications.dataSafety (CSV import; replaces every earlier answer).
// storebound play data-safety [--csv out.csv] [--dry] [--force] // storebound play data-safety [--csv out.csv] [--dry] [--force]
// Answers come from `dataSafety` in storebound.config.ts. --dry only writes / prints the CSV. // Answers come from `dataSafety` (and accounts.deletionUrl) in storebound.config.ts. --dry only writes / prints the CSV.
// Both --dry and a real run stop on a missing required row (dataSafetyIssues) before anything is sent.
// Play cannot read the form back, so a run compares with what this project uploaded last: the sha256 of that CSV // Play cannot read the form back, so a run compares with what this project uploaded last: the sha256 of that CSV
// (per package) is kept in .storebound/data-safety.sha256 (gitignored). The same CSV is not sent again; --force sends it // (per package) is kept in .storebound/data-safety.sha256 (gitignored). The same CSV is not sent again; --force sends it
// anyway (e.g. after the form was changed in Play Console by hand). // anyway (e.g. after the form was changed in Play Console by hand).
@ -8,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
import { dataSafetyCsv } from '../lib/data-safety'; import { dataSafetyCsv, dataSafetyProblem, dataSafetyRows } from '../lib/data-safety';
import { DATA_SAFETY_UPLOADED } from '../lib/declarations'; import { DATA_SAFETY_UPLOADED } from '../lib/declarations';
import { done, log } from '../lib/log'; import { done, log } from '../lib/log';
import { playClient } from './client'; import { playClient } from './client';
@ -36,7 +37,9 @@ function rememberUpload(root: string, pkg: string, sha256: string): void {
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
const csv = dataSafetyCsv(need(config.dataSafety, 'dataSafety')); const ds = need(config.dataSafety, 'dataSafety');
const csv = dataSafetyCsv(ds, config.accounts);
const problem = dataSafetyProblem(dataSafetyRows(ds, config.accounts));
const out = ctx.args.get('csv'); const out = ctx.args.get('csv');
if (out) { writeFileSync(resolve(out), csv); log('play', `wrote ${out}`); } if (out) { writeFileSync(resolve(out), csv); log('play', `wrote ${out}`); }
const types = Object.keys(config.dataSafety!.data); const types = Object.keys(config.dataSafety!.data);
@ -47,9 +50,11 @@ export async function run(ctx: Ctx): Promise<void> {
const same = last?.sha256 === hash; const same = last?.sha256 === hash;
if (ctx.args.has('dry')) { if (ctx.args.has('dry')) {
if (!out) process.stdout.write(csv); if (!out) process.stdout.write(csv);
if (problem) throw new Error(problem);
log('play', same ? `the same CSV was uploaded ${last!.at.slice(0, 16).replace('T', ' ')} UTC: a real run sends nothing (--force sends it again)` : 'a real run uploads it (it differs from the last upload, or none is recorded)'); log('play', same ? `the same CSV was uploaded ${last!.at.slice(0, 16).replace('T', ' ')} UTC: a real run sends nothing (--force sends it again)` : 'a real run uploads it (it differs from the last upload, or none is recorded)');
return; return;
} }
if (problem) throw new Error(problem);
if (same && !ctx.args.has('force')) { if (same && !ctx.args.has('force')) {
done('play', `data safety unchanged since the upload of ${last!.at.slice(0, 16).replace('T', ' ')} UTC: nothing sent (--force sends it again, e.g. after a change in Play Console)`); done('play', `data safety unchanged since the upload of ${last!.at.slice(0, 16).replace('T', ' ')} UTC: nothing sent (--force sends it again, e.g. after a change in Play Console)`);
return; return;

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard | | # | date | lesson | guard |
|---|---|---|---| |---|---|---|---|
| 21 | 2026-10 | Play Data safety: conditional required rows. `play data-safety` for a game with username accounts (`accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']`) got 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL": once account creation is not `PSL_ACM_NONE`, Play requires the delete-account URL and the data-only deletion question (`PSL_SUPPORT_DATA_DELETION_BY_USER`, and `PSL_DATA_DELETION_URL` when it is yes), and refuses `PSL_HAS_OUTSIDE_APP_ACCOUNTS`; storebound wrote none of them. | `dataSafetyRows` writes them from `accounts.deletionUrl` and `dataSafety.partialDeletion` / `partialDeletionUrl`; `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts` check every conditional row in `check`, `project.declarations` and `play data-safety` (dry and real, before the POST); `tests/pure.test.ts` (conditional required rows), `tests/idempotency.test.ts`, `tests/app-privacy.test.ts`. |
| 20 | 2026-10 | Hours of selector-fix cycles of the scripted console engine (`console fill`): the real console kept differing from the model (App Privacy "Set Up" as plain text, purpose checkboxes without an accessible name, two information steps in a type dialog, Play's material radios that ignore the native input). | Agent-first console mode: `console plan <form>` gives an AI agent the start URL, steps, answer sheet with the console's labels, never-press list and verified UI facts; the agent drives the page with its own browser tool and stops before the final button; `console verify <form>` checks the result (App Privacy summaries from the page text, Play Create app from input states, API for the App Store record and age rating). The scripted fill stays a fast path (and now handles the verified facts). MCP: `console_plan`, `console_verify` (read-only). `tests/console-plan.test.ts`, `tests/console-flow.test.ts`. | | 20 | 2026-10 | Hours of selector-fix cycles of the scripted console engine (`console fill`): the real console kept differing from the model (App Privacy "Set Up" as plain text, purpose checkboxes without an accessible name, two information steps in a type dialog, Play's material radios that ignore the native input). | Agent-first console mode: `console plan <form>` gives an AI agent the start URL, steps, answer sheet with the console's labels, never-press list and verified UI facts; the agent drives the page with its own browser tool and stops before the final button; `console verify <form>` checks the result (App Privacy summaries from the page text, Play Create app from input states, API for the App Store record and age rating). The scripted fill stays a fast path (and now handles the verified facts). MCP: `console_plan`, `console_verify` (read-only). `tests/console-plan.test.ts`, `tests/console-flow.test.ts`. |
| 19 | 2026-10 | `play images --dry` aborted with "en-US: no sevenInchScreenshots ... (render first)" on a phone-only project: Play's 7" and 10" tablet screenshots are optional (required: 512 icon, 1024x500 feature graphic, at least 2 screenshots; Play Console Help answer 9866151), but every image type was treated as required. | `play images` uploads what exists (icon, feature graphic, phone screenshots, tablet / TV / Wear only when rendered), prints an info line for missing optional sets, never deletes what Play has for them without `--prune`; `play.tablets` (default: tablet renders exist) switches tablets for `shots render` / `shots check` / `shots gallery` / `validate` / `play images`, and `true` with missing renders is an error (`cli/play/images.ts` `langImages`, `resolvedTablets` in `cli/shots/report.ts`, `tests/play-post-reads.test.ts`). | | 19 | 2026-10 | `play images --dry` aborted with "en-US: no sevenInchScreenshots ... (render first)" on a phone-only project: Play's 7" and 10" tablet screenshots are optional (required: 512 icon, 1024x500 feature graphic, at least 2 screenshots; Play Console Help answer 9866151), but every image type was treated as required. | `play images` uploads what exists (icon, feature graphic, phone screenshots, tablet / TV / Wear only when rendered), prints an info line for missing optional sets, never deletes what Play has for them without `--prune`; `play.tablets` (default: tablet renders exist) switches tablets for `shots render` / `shots check` / `shots gallery` / `validate` / `play images`, and `true` with missing renders is an error (`cli/play/images.ts` `langImages`, `resolvedTablets` in `cli/shots/report.ts`, `tests/play-post-reads.test.ts`). |
| 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). Confirmed 2026-10-12: the earlier default copy DID sign the owner out of Play Console in their real Chrome. | | 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). Confirmed 2026-10-12: the earlier default copy DID sign the owner out of Play Console in their real Chrome. |

View file

@ -37,10 +37,13 @@ deletion requirements" (https://support.google.com/googleplay/android-developer/
authentication" (`PSL_ACM_USER_ID_OTHER_AUTH`) with User ID as the only account data; `username-password` and authentication" (`PSL_ACM_USER_ID_OTHER_AUTH`) with User ID as the only account data; `username-password` and
`email-password` are "Username and password" (`PSL_ACM_USER_ID_PASSWORD`); `email-link` and `phone` are `email-password` are "Username and password" (`PSL_ACM_USER_ID_PASSWORD`); `email-link` and `phone` are
`PSL_ACM_USER_ID_OTHER_AUTH`; `oauth` is `PSL_ACM_OAUTH`; `other` is `PSL_ACM_OTHER`. An account alone never means `PSL_ACM_USER_ID_OTHER_AUTH`; `oauth` is `PSL_ACM_OAUTH`; `other` is `PSL_ACM_OTHER`. An account alone never means
an email address: declare Email only when the app asks for one. Google does not publish the question ids for the delete-account URL: export the form once an email address: declare Email only when the app asks for one. With accounts Play requires more rows, and storebound
(Play Console > App content > Data safety > Export to CSV), copy the id, and add it as writes them: the delete-account URL (`PSL_ACCOUNT_DELETION_URL` = `accounts.deletionUrl`) and "can people delete
`dataSafety.extra: [{ question: '<id>', value: '<deletionUrl>' }]`; otherwise enter the URL in the Console after some or all of their data without deleting the account?" (`PSL_SUPPORT_DATA_DELETION_BY_USER` =
`storebound play data-safety`. `dataSafety.partialDeletion`; ask the owner, never guess), plus its web page when the answer is yes
(`PSL_DATA_DELETION_URL` = `dataSafety.partialDeletionUrl`, default `accounts.deletionUrl`). `storebound check` and
`play data-safety --dry` say `missing: <id> (... set <field>)` for any of them; without them Play answers 400
"Response missing for PSL_ACCOUNT_DELETION_URL".
5. Privacy policy: a "Deleting your account" section with both paths (`privacy-policy` skill). 5. Privacy policy: a "Deleting your account" section with both paths (`privacy-policy` skill).
6. Test it on a fresh account before every submission; the reviewer will. 6. Test it on a fresh account before every submission; the reviewer will.

View file

@ -73,6 +73,16 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolc
- The Data safety CSV import **replaces** the whole form. - The Data safety CSV import **replaces** the whole form.
- `PSL_SUPPORTED_ACCOUNT_CREATION_METHODS` (e.g. `PSL_ACM_NONE`) is required even though Google's older sample CSV - `PSL_SUPPORTED_ACCOUNT_CREATION_METHODS` (e.g. `PSL_ACM_NONE`) is required even though Google's older sample CSV
omits it: without it the import fails with "Response missing". storebound always writes it. omits it: without it the import fails with "Response missing". storebound always writes it.
- Conditional required rows: some questions become required by another answer, and Play refuses the import with
"Response missing for <id>" (400). Account creation not `PSL_ACM_NONE` -> `PSL_ACCOUNT_DELETION_URL` (from
`accounts.deletionUrl`) and `PSL_SUPPORT_DATA_DELETION_BY_USER` ("delete some or all data without deleting the
account?", `dataSafety.partialDeletion: true | false`, ask the owner); that answer true -> `PSL_DATA_DELETION_URL`
(`dataSafety.partialDeletionUrl`, default `accounts.deletionUrl`); `PSL_HAS_OUTSIDE_APP_ACCOUNTS` is refused next
to sign-up methods. A selected data type needs collected / shared, ephemeral, required / optional and a purpose
(a sharing purpose when shared). `storebound check` and `play data-safety --dry` name each missing row as
`missing: <id> (<when>; set <config field>)` and the real run stops before sending. Rules:
`DATA_SAFETY_RULES` / `typeRules` in `cli/lib/data-safety.ts`. A `dataSafety.extra` row with a generated
question id replaces that question (use it when your Play Console export shows other ids).
- "Optional" data is only allowed when the user can really choose for that data; otherwise required. - "Optional" data is only allowed when the user can really choose for that data; otherwise required.
- Ads SDKs: read each network's own Play data safety guidance; adding a network can add data types. - Ads SDKs: read each network's own Play data safety guidance; adding a network can add data types.
- Target audience under 13 pulls the app into the Families policy (certified ad networks, no ad id): only if true. - Target audience under 13 pulls the app into the Families policy (certified ad networks, no ad id): only if true.

View file

@ -230,6 +230,23 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
- **Guard:** `console plan` / `console verify` (CLI and the read-only MCP tools `console_plan`, `console_verify`), the - **Guard:** `console plan` / `console verify` (CLI and the read-only MCP tools `console_plan`, `console_verify`), the
console-autofill skill puts them first; `tests/console-plan.test.ts`. console-autofill skill puts them first; `tests/console-plan.test.ts`.
## 21. Play Data safety: conditional required rows
- **Symptom:** `play data-safety` answers 400 "Invalid safety labels declaration: Response missing for
PSL_ACCOUNT_DELETION_URL" (or another `PSL_...` id) although `--dry` looked fine.
- **Cause:** some questions are required only after another answer. Account creation not `PSL_ACM_NONE` requires the
delete-account URL (`PSL_ACCOUNT_DELETION_URL`) and the data-only deletion question
(`PSL_SUPPORT_DATA_DELETION_BY_USER`; when yes, `PSL_DATA_DELETION_URL`), and Play then refuses
`PSL_HAS_OUTSIDE_APP_ACCOUNTS`. A selected data type needs its whole usage block.
- **Do:** set `accounts.deletionUrl`, ask the owner for `dataSafety.partialDeletion: true | false` (and
`partialDeletionUrl` when true and it is another page), run `storebound check` / `play data-safety --dry` until no
`missing: <id>` is left. If Play names an id storebound does not know, export the form (Play Console > App content >
Data safety > Export to CSV) and add the row in `dataSafety.extra` (same id = replaces the generated row).
- **Guard:** `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts`; `check`,
`project.declarations` and `play data-safety` (dry and real, before the POST) report them.
- **Sources:** https://support.google.com/googleplay/android-developer/answer/10787469 (Data safety form, CSV import),
https://support.google.com/googleplay/android-developer/answer/13327111 (account deletion: delete-account link;
deleting other data is a separate question).
## Adding a lesson ## Adding a lesson
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
that fails without the fix (see CONTRIBUTING.md). that fails without the fix (see CONTRIBUTING.md).

View file

@ -102,7 +102,9 @@ const config: ShipConfig = {
deletionRequest: true, // e.g. "email us"; no accounts, so no account deletion URL needed deletionRequest: true, // e.g. "email us"; no accounts, so no account deletion URL needed
// No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no // No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no
// email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's // email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's
// "Username and other authentication"; `storebound scan` proposes it). // "Username and other authentication"; `storebound scan` proposes it). With accounts Play also requires the
// delete-account URL (from accounts.deletionUrl) and partialDeletion: true | false (delete data without deleting the
// account; ask the owner), plus partialDeletionUrl when true (default accounts.deletionUrl).
accountCreation: ['PSL_ACM_NONE'], accountCreation: ['PSL_ACM_NONE'],
data: { data: {
PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] }, PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },

View file

@ -139,6 +139,24 @@ describe('check: appPrivacy.types against the iOS project', () => {
expect(msgs(r)).toContain('appPrivacy.types.GAMEPLAY_CONTENT is collected, but App/PrivacyInfo.xcprivacy has no NSPrivacyCollectedDataTypeGameplayContent'); expect(msgs(r)).toContain('appPrivacy.types.GAMEPLAY_CONTENT is collected, but App/PrivacyInfo.xcprivacy has no NSPrivacyCollectedDataTypeGameplayContent');
expect(appPrivacyMismatches({ DEVICE_ID: { collected: true, tracking: true } }, { attString: true, manifest: m(true, ['NSPrivacyCollectedDataTypeDeviceID']) })).toEqual([]); expect(appPrivacyMismatches({ DEVICE_ID: { collected: true, tracking: true } }, { attString: true, manifest: m(true, ['NSPrivacyCollectedDataTypeDeviceID']) })).toEqual([]);
}); });
test('storebound check: a missing accounts.deletionUrl is a Data safety problem (Play 400 otherwise)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'storebound-ds-'));
try {
writeFileSync(join(dir, '.gitignore'), '.storebound/\n');
const c = { app: base.app, languages: base.languages, accounts: { enabled: true, signUp: ['username'] },
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } } } as ShipConfig;
const out: string[] = [];
const orig = console.log;
console.log = (s: string) => { out.push(s); };
try { await check({ loaded: { config: c, root: dir, path: join(dir, 'storebound.config.ts'), resolve: p => join(dir, p) }, args: parseArgs(['--json']) }); }
finally { console.log = orig; process.exitCode = 0; }
const problems = (JSON.parse(out.at(-1)!).problems as string[]).join('\n');
expect(problems).toContain('data safety: missing: PSL_ACCOUNT_DELETION_URL');
expect(problems).toContain('set accounts.deletionUrl');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('storebound check reports the mismatches (errors are problems)', async () => { test('storebound check reports the mismatches (errors are problems)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'storebound-ap-')); const dir = mkdtempSync(join(tmpdir(), 'storebound-ap-'));
try { try {

View file

@ -347,6 +347,21 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
expect(s.play.dataSafety).toHaveLength(3); expect(s.play.dataSafety).toHaveLength(3);
}); });
test('play data-safety: a missing conditional row stops --dry and the real run before any POST', async () => {
// STAR DASH: accounts with a username, no deletion URL in the CSV -> Play 400 "Response missing for PSL_ACCOUNT_DELETION_URL"
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } };
const p = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'] }, dataSafety: ds as never });
const before = s.calls.length;
await expect(capture(() => playDataSafety(p.ctx(['--dry'])))).rejects.toThrow('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl');
await expect(capture(() => playDataSafety(p.ctx([])))).rejects.toThrow('PSL_ACCOUNT_DELETION_URL');
expect(writes(s.calls.slice(before))).toEqual([]);
const q = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'], deletionUrl: 'https://example.com/delete-account' }, dataSafety: ds as never });
await capture(() => playDataSafety(q.ctx([])));
expect(s.play.dataSafety.at(-1)).toContain('PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,');
expect(s.play.dataSafety.at(-1)).toContain('PSL_SUPPORT_DATA_DELETION_BY_USER,,FALSE,');
});
test('apple promoted: created and ordered once', async () => { test('apple promoted: created and ordered once', async () => {
s.asc.put({ type: 'inAppPurchases', id: 'iap-pro', attributes: { productId: 'com.example.notes.pro', state: 'APPROVED' }, rels: { app: APP } }); s.asc.put({ type: 'inAppPurchases', id: 'iap-pro', attributes: { productId: 'com.example.notes.pro', state: 'APPROVED' }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', state: 'APPROVED' }, rels: { app: APP } }); s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', state: 'APPROVED' }, rels: { app: APP } });

View file

@ -4,7 +4,8 @@ import { join } from 'node:path';
import { parseArgs } from '../cli/lib/args'; import { parseArgs } from '../cli/lib/args';
import { loadConfig, validateConfig } from '../cli/lib/config'; import { loadConfig, validateConfig } from '../cli/lib/config';
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy'; import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
import { dataSafetyCsv, dataSafetyRows } from '../cli/lib/data-safety'; import { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from '../cli/lib/data-safety';
import { parseCsv } from '../cli/lib/csv';
import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money'; import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money';
import { maskExpr } from '../cli/icons'; import { maskExpr } from '../cli/icons';
import { ffmpegQ } from '../cli/shots/jpeg'; import { ffmpegQ } from '../cli/shots/jpeg';
@ -99,6 +100,53 @@ describe('data safety csv', () => {
}); });
}); });
describe('data safety: conditional required rows', () => {
const acc = { ...{ collectsData: true, encryptedInTransit: true, deletionRequest: true }, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'],
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account' as const] } } };
const url = 'https://example.com/delete-account';
const get = (rows: string[][], q: string) => rows.find(r => r[0] === q);
test('accounts + username: the delete-account URL row carries accounts.deletionUrl', () => {
const rows = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url });
expect(get(rows, 'PSL_ACCOUNT_DELETION_URL')![2]).toBe(url);
expect(get(rows, 'PSL_SUPPORT_DATA_DELETION_BY_USER')![2]).toBe('FALSE');
expect(get(rows, 'PSL_DATA_DELETION_URL')).toBeUndefined();
expect(dataSafetyIssues(rows)).toEqual([]);
const yes = dataSafetyRows({ ...acc, partialDeletion: true }, { deletionUrl: url });
expect(get(yes, 'PSL_DATA_DELETION_URL')![2]).toBe(url);
expect(get(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: 'https://example.com/data' }, { deletionUrl: url }), 'PSL_DATA_DELETION_URL')![2]).toBe('https://example.com/data');
});
test('no accounts: no deletion rows, no issues', () => {
const rows = dataSafetyRows({ ...acc, accountCreation: ['PSL_ACM_NONE'] }, { deletionUrl: url });
for (const q of ['PSL_ACCOUNT_DELETION_URL', 'PSL_SUPPORT_DATA_DELETION_BY_USER', 'PSL_DATA_DELETION_URL']) expect(get(rows, q)).toBeUndefined();
expect(dataSafetyIssues(rows)).toEqual([]);
expect(get(dataSafetyRows({ ...acc, accountCreation: undefined }), 'PSL_ACCOUNT_DELETION_URL')).toBeUndefined();
});
test('missing deletionUrl / partialDeletion: named with the config field to set', () => {
const msgs = dataSafetyIssues(dataSafetyRows(acc)).map(i => i.msg);
expect(msgs[0]).toBe('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl, a public https page to request account + data deletion (account-deletion skill))');
expect(msgs[1]).toContain('missing: PSL_SUPPORT_DATA_DELETION_BY_USER');
expect(msgs[1]).toContain('dataSafety.partialDeletion');
expect(dataSafetyIssues(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: undefined }, {})).map(i => i.question)).toEqual(['PSL_ACCOUNT_DELETION_URL', 'PSL_DATA_DELETION_URL']);
});
test('extra replaces a generated question; refused and per-type rows', () => {
const rows = dataSafetyRows({ ...acc, partialDeletion: false, extra: [{ question: 'PSL_ACCOUNT_DELETION_URL', value: url }] });
expect(rows.filter(r => r[0] === 'PSL_ACCOUNT_DELETION_URL')).toHaveLength(1);
expect(dataSafetyIssues(rows)).toEqual([]);
const outside = dataSafetyRows({ ...acc, partialDeletion: false, extra: [{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'FALSE' }] }, { deletionUrl: url });
expect(dataSafetyIssues(outside).map(i => i.msg.split(' (')[0])).toEqual(['not allowed: PSL_HAS_OUTSIDE_APP_ACCOUNTS']);
// a type selected but its purpose rows emptied (e.g. a hand-edited export)
const cut = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url })
.map(r => r[0] === 'PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE' ? [r[0], r[1]!, '', r[3]!, r[4]!] : r);
expect(dataSafetyIssues(cut).map(i => i.question)).toEqual(['PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE']);
});
test('the template passes the rules and only uses known question ids', () => {
const rows = parseCsv(readFileSync(join(import.meta.dir, '..', 'templates', 'data-safety.csv'), 'utf8'));
expect(dataSafetyIssues(rows)).toEqual([]);
const known = new Set(DATA_SAFETY_RULES.map(r => r.question));
for (const r of rows.slice(1)) expect(known.has(r[0]!) || /^PSL_DATA_TYPES_|^PSL_DATA_USAGE_RESPONSES:PSL_[A-Z_]+:/.test(r[0]!)).toBe(true);
});
});
describe('prices', () => { describe('prices', () => {
test('toMoney without float noise', () => { test('toMoney without float noise', () => {
expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 }); expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 });