play data-safety: PSL_SUPPORT_DATA_DELETION_BY_USER is the general 'can users request deletion?' question; a delete-account URL answers yes (with PSL_DATA_DELETION_URL), dataSafety.dataDeletion for apps without one; check flags a no next to a delete-account URL

This commit is contained in:
CRTkafa 2026-10-12 09:46:58 +08:00
parent e01a27d7b7
commit dbd9f2973c
15 changed files with 95 additions and 66 deletions

View file

@ -2,6 +2,14 @@
## Unreleased
- Fix (`play data-safety`): `PSL_SUPPORT_DATA_DELETION_BY_USER` is Play's general "Do you provide a way for users to
request that their data is deleted?". storebound answered it from `partialDeletion` (data deletion without deleting
the account), so an app with a delete-account page declared "no". Now `accounts.deletionUrl` answers yes, with
`PSL_DATA_DELETION_URL` set to `partialDeletionUrl` or the delete-account page. Without a page, the answer comes
from the new `dataSafety.dataDeletion: 'yes' | 'no' | 'auto90'`, or else from `deletionRequest` (the same question
under the form's old id, no longer deprecated). `check` flags any answer other than yes next to a delete-account
URL.
- Price changes of existing products need `--allow-price-changes` (`play products`, `play subscriptions`,
`apple subscriptions`, `prices import`; `apple iap` never changes an existing price). `--yes`, also the one
`flow run --apply --yes` adds to write steps, confirms creates, first prices and other updates only. Without the

View file

@ -102,8 +102,6 @@ export async function run(ctx: Ctx): Promise<void> {
// conditional required rows (e.g. the delete-account URL once accounts exist): Play answers 400 without them
// and pairs not in Play's current form (cli/data/play-data-safety-form.csv): Play answers 400 for those too
for (const i of dataSafetyIssues(rows)) problems.push(`data safety: ${i.msg}`);
if (config.dataSafety!.deletionRequest !== undefined)
note('dataSafety.deletionRequest is no longer used: Play removed that question (PSL_DATA_COLLECTION_USER_REQUEST_DELETE) from the Data safety form; remove it from the config (with accounts, Play asks dataSafety.partialDeletion)');
return `${rows.length - 1} CSV rows`;
});
for (const p of config.products ?? []) for (const [lang, l] of Object.entries(p.listings)) {

View file

@ -82,21 +82,22 @@ export interface DataUsage {
export interface DataSafety {
collectsData: boolean;
encryptedInTransit: 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.
*/
/** "Do you provide a way for users to request that their data is deleted?" under the form's old id; now written as
* PSL_SUPPORT_DATA_DELETION_BY_USER (true = yes, false = no). dataDeletion and a delete-account URL take precedence. */
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 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.
* single choice Play asks once accountCreation is not PSL_ACM_NONE). It is the general question: a delete-account
* page (accounts.deletionUrl) answers it with yes, and that is the default. 'no' / 'auto90' (no, but data is deleted
* automatically within 90 days) only for an app without one.
*/
dataDeletion?: 'yes' | 'no' | 'auto90';
/**
* Whether people can delete some or all of their data without deleting the account. true: partialDeletionUrl (default
* accounts.deletionUrl) is the "Delete data URL". false: the delete-account page is given as that URL. Older configs:
* 'auto90' means dataDeletion 'auto90'.
*/
partialDeletion?: boolean | 'auto90';
/** Web page for that data-only deletion (PSL_DATA_DELETION_URL, required when partialDeletion is true). Default:
@ -911,6 +912,7 @@ export function validateConfig(c: unknown): string[] {
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' && cfg.dataSafety.partialDeletion !== 'auto90') e.push("dataSafety.partialDeletion: true | false | 'auto90'");
if (cfg.dataSafety.dataDeletion !== undefined && !['yes', 'no', 'auto90'].includes(cfg.dataSafety.dataDeletion)) e.push("dataSafety.dataDeletion: 'yes' | 'no' | '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

@ -68,10 +68,22 @@ const HEADER = ['Question ID (machine readable)', 'Response ID (machine readable
/** 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;
/** PSL_SUPPORT_DATA_DELETION_BY_USER responses ("Do you provide a way for users to request that their data is deleted?"). */
export const DATA_DELETION_RESPONSE = { yes: 'DATA_DELETION_YES', no: 'DATA_DELETION_NO', auto90: 'DATA_DELETION_NO_AUTO_DELETED' } as const;
/** @deprecated name kept for imports */
export const PARTIAL_DELETION_RESPONSE = DATA_DELETION_RESPONSE;
/** The answer to the general data deletion question. It is the general question, not "without deleting the account":
* a delete-account page (accounts.deletionUrl) is a way to request deletion, so it means yes. Order: dataSafety.dataDeletion,
* then a delete-account URL -> yes, then deletionRequest (the same question under the form's old id), then the older partialDeletion values (true -> yes, 'auto90'). Pure. */
export function dataDeletionResponse(ds: DataSafety, accounts?: DataSafetyAccounts): string | undefined {
if (ds.dataDeletion) return DATA_DELETION_RESPONSE[ds.dataDeletion];
if (accounts?.deletionUrl) return DATA_DELETION_RESPONSE.yes;
if (ds.deletionRequest !== undefined) return ds.deletionRequest ? DATA_DELETION_RESPONSE.yes : DATA_DELETION_RESPONSE.no;
if (ds.partialDeletion === true) return DATA_DELETION_RESPONSE.yes;
if (ds.partialDeletion === 'auto90') return DATA_DELETION_RESPONSE.auto90;
return 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}`);
@ -87,7 +99,8 @@ export function dataSafetyRows(ds: DataSafety, accounts?: DataSafetyAccounts): s
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.
// dataSafety.deletionRequest: the form's old id PSL_DATA_COLLECTION_USER_REQUEST_DELETE is gone; the same question is
// PSL_SUPPORT_DATA_DELETION_BY_USER below (dataDeletionResponse).
const acm = ds.accountCreation ?? ['PSL_ACM_NONE'];
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).
@ -95,10 +108,12 @@ export function dataSafetyRows(ds: DataSafety, accounts?: DataSafetyAccounts): s
if (!acm.includes('PSL_ACM_NONE')) {
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');
const picked = dataDeletionResponse(ds, accounts);
for (const r of Object.values(DATA_DELETION_RESPONSE))
add('PSL_SUPPORT_DATA_DELETION_BY_USER', r, pick(picked === r), 'SINGLE_CHOICE', `Data deletion / ${r}`);
// "Delete data URL": the data-only page when there is one (partialDeletionUrl), else the delete-account page, which
// also deletes the data (Play answered 400 "Response missing for PSL_DATA_DELETION_URL" to a yes without it)
if (picked === DATA_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) add(cat, t, pick(!!ds.data[t]), 'MULTIPLE_CHOICE', `${cat} / ${t}`);
for (const [, types] of CATEGORIES) for (const t of types) {
@ -174,7 +189,7 @@ export interface DataSafetyRule {
when: (a: DataSafetyAnswers) => boolean;
/** The condition in words, for messages and docs. */
condition: string;
need: 'bool' | 'url' | 'choice' | 'one-choice' | 'absent';
need: 'bool' | 'url' | 'choice' | 'one-choice' | 'absent' | 'yes';
/** What to set in storebound.config.ts. */
fix: string;
}
@ -191,7 +206,8 @@ export const DATA_SAFETY_RULES: DataSafetyRule[] = [
{ question: 'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', when: a => a.collects, condition: 'the app collects data', need: 'bool', fix: 'dataSafety.encryptedInTransit' },
{ 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: '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_SUPPORT_DATA_DELETION_BY_USER', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE', need: 'one-choice', fix: "accounts.deletionUrl (a delete-account page answers yes), or dataSafety.dataDeletion: 'yes' | 'no' | 'auto90'" },
{ question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', when: a => !!a.value('PSL_ACCOUNT_DELETION_URL') && a.chosen('PSL_SUPPORT_DATA_DELETION_BY_USER').length > 0 && !a.chosen('PSL_SUPPORT_DATA_DELETION_BY_USER').includes('DATA_DELETION_YES'), condition: 'PSL_ACCOUNT_DELETION_URL is set: a delete-account page is a way to request deletion, so the answer must be DATA_DELETION_YES', need: 'yes', fix: "dataSafety.dataDeletion: 'yes', or leave it out" },
{ 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' },
];
@ -227,6 +243,7 @@ export function dataSafetyIssues(rows: string[][]): DataSafetyIssue[] {
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 === 'yes') { say(`must be DATA_DELETION_YES (is ${c.join(', ')})`); continue; }
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'); }

View file

@ -26,7 +26,7 @@ const config: ShipConfig = {
collectsData: true,
encryptedInTransit: true,
accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],
partialDeletion: false, // Play: can people delete data without deleting the account? true | false | 'auto90'
partialDeletion: false, // a page that deletes data without deleting the account? (true needs partialDeletionUrl)
data: {
PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },
PSL_USER_ACCOUNT: { shared: false, collect: ['functionality', 'account'] },

View file

@ -184,7 +184,7 @@ export const DECL_AT = 0.4, DECL_STEP = 0.25, ASK_AT = 1.5;
export const DATA_SAFETY_ROWS = ['Device or other IDs', 'Approximate location', 'App interactions', 'Diagnostics', 'Name', 'Email address', 'User IDs'];
export const FORM_AT = 1.9, FORM_STEP = 0.14;
/** Data safety CSV rows `storebound check` builds from the config. */
export const DECL_TOTAL = 767;
export const DECL_TOTAL = 768;
export const FORMS_PILL = { at: 3.0, text: 'Data safety answers' };
// ---------- 7 rejections: three findings with a plain reason each, then fixed ----------

View file

@ -38,11 +38,12 @@ deletion requirements" (https://support.google.com/googleplay/android-developer/
`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
an email address: declare Email only when the app asks for one. With accounts Play requires more rows, and storebound
writes them: the delete-account URL (`PSL_ACCOUNT_DELETION_URL` = `accounts.deletionUrl`) and "can people request
that some or all of their data is deleted without deleting the account?" (`PSL_SUPPORT_DATA_DELETION_BY_USER`, a
single choice = `dataSafety.partialDeletion`: `true` yes, `false` no, `'auto90'` no but the data is deleted
automatically within 90 days; 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
writes them: the delete-account URL (`PSL_ACCOUNT_DELETION_URL` = `accounts.deletionUrl`) and "Do you provide a way
for users to request that their data is deleted?" (`PSL_SUPPORT_DATA_DELETION_BY_USER`, a single choice). The
delete-account page is such a way, so the answer is yes (`DATA_DELETION_YES`); answering no next to a delete-account
URL contradicts it and `check` flags it. The yes needs a "Delete data URL" (`PSL_DATA_DELETION_URL`):
`dataSafety.partialDeletionUrl` when there is a page that deletes data without deleting the account
(`partialDeletion: true`), else the delete-account page. `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". Play removed the old "users can request deletion" question, so
`dataSafety.deletionRequest` is no longer used (`check` notes it; remove it).

View file

@ -82,11 +82,12 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolc
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?", a single choice: `dataSafety.partialDeletion: true | false | 'auto90'` = `DATA_DELETION_YES` /
`DATA_DELETION_NO` / `DATA_DELETION_NO_AUTO_DELETED` "no, but deleted automatically within 90 days", ask the
owner); that answer true -> `PSL_DATA_DELETION_URL`
(`dataSafety.partialDeletionUrl`, default `accounts.deletionUrl`); `PSL_HAS_OUTSIDE_APP_ACCOUNTS` is refused next
`accounts.deletionUrl`) and `PSL_SUPPORT_DATA_DELETION_BY_USER`, the GENERAL "Do you provide a way for users to
request that their data is deleted?" (single choice `DATA_DELETION_YES` / `DATA_DELETION_NO` /
`DATA_DELETION_NO_AUTO_DELETED`). A delete-account page is such a way, so with `accounts.deletionUrl` storebound
answers yes, and `check` flags anything else next to a delete-account URL. Without one: `dataSafety.dataDeletion:
'yes' | 'no' | 'auto90'`. A yes needs `PSL_DATA_DELETION_URL` ("Delete data URL"): `dataSafety.partialDeletionUrl`
for a page that deletes data without deleting the account (`partialDeletion: true`), else the delete-account page; `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:

View file

@ -252,8 +252,9 @@ testers) and answers, in ONE batch at the start, only the facts no file proves (
form's Response IDs: `PSL_SUPPORT_DATA_DELETION_BY_USER` became a single choice (`DATA_DELETION_YES` /
`DATA_DELETION_NO` / `DATA_DELETION_NO_AUTO_DELETED`) and a bare FALSE got 400 "Line 7: Response ID missing";
`PSL_DATA_COLLECTION_USER_REQUEST_DELETE` was removed from the form.
- **Do:** set `accounts.deletionUrl`, ask the owner for `dataSafety.partialDeletion: true | false | 'auto90'` (and
`partialDeletionUrl` when true and it is another page), run `storebound check` / `play data-safety --dry` until no
- **Do:** set `accounts.deletionUrl` (it also answers `PSL_SUPPORT_DATA_DELETION_BY_USER` with yes: that is the
general "can users request deletion?" question, not "without deleting the account"; a no next to a delete-account
URL was a false declaration storebound wrote once), set `partialDeletionUrl` only for a data-only 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).
When Play changes the form, export it empty and save it over `cli/data/play-data-safety-form.csv`.

View file

@ -83,9 +83,8 @@ const config: ShipConfig = {
// Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from
// accounts.deletionUrl; `storebound check` names any other row Play requires.
accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],
// Play asks this once there are accounts (PSL_SUPPORT_DATA_DELETION_BY_USER, one choice): can people request that some
// or all of their data is deleted without deleting the account? true | false | 'auto90' (no, but it is deleted
// automatically within 90 days). Ask the owner. true also needs a page: partialDeletionUrl (default accounts.deletionUrl).
// Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.
// Play's "can users request deletion?" is answered yes by accounts.deletionUrl. Ask the owner.
partialDeletion: false,
data: {
PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },

View file

@ -111,9 +111,8 @@ const config: ShipConfig = {
// Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from
// accounts.deletionUrl; `storebound check` names any other row Play requires.
accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],
// Play asks this once there are accounts (PSL_SUPPORT_DATA_DELETION_BY_USER, one choice): can people request that some
// or all of their data is deleted without deleting the account? true | false | 'auto90' (no, but it is deleted
// automatically within 90 days). Ask the owner. true also needs a page: partialDeletionUrl (default accounts.deletionUrl).
// Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.
// Play's "can users request deletion?" is answered yes by accounts.deletionUrl. Ask the owner.
partialDeletion: false,
data: {
PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },

View file

@ -102,10 +102,9 @@ const config: ShipConfig = {
// 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
// "Username and other authentication"; `storebound scan` proposes it). With accounts Play also requires the
// delete-account URL (from accounts.deletionUrl) and partialDeletion: true | false | 'auto90' (people can request
// that data is deleted without deleting the account / they cannot / it is deleted automatically within 90 days; ask
// the owner), plus partialDeletionUrl when true (default accounts.deletionUrl). Play removed the old "users can request
// deletion" question, so there is no deletionRequest any more. `storebound check` compares every row with Play's
// delete-account URL (from accounts.deletionUrl). That page also answers "can users request that their data is
// deleted?" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true +
// partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's
// current form (cli/data/play-data-safety-form.csv).
accountCreation: ['PSL_ACM_NONE'],
data: {

View file

@ -144,7 +144,7 @@ describe('check: appPrivacy.types against the iOS project', () => {
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, // deletionRequest: deprecated, a note
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;
@ -155,8 +155,7 @@ describe('check: appPrivacy.types against the iOS project', () => {
const problems = res.problems.join('\n');
expect(problems).toContain('data safety: missing: PSL_ACCOUNT_DELETION_URL');
expect(problems).toContain('set accounts.deletionUrl');
expect(problems).not.toContain('PSL_SUPPORT_DATA_DELETION_BY_USER');
expect(res.warnings.join('\n')).toContain('dataSafety.deletionRequest is no longer used: Play removed that question');
expect(problems).not.toContain('missing: PSL_SUPPORT_DATA_DELETION_BY_USER'); // deletionRequest: true answers it
} finally { rmSync(dir, { recursive: true, force: true }); }
});

View file

@ -360,7 +360,8 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
await capture(() => playDataSafety(q.ctx([])));
expect(s.play.dataSafety.at(-1)).toContain('PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,');
// a single choice: the picked option carries TRUE (a bare FALSE was Play's 400 "Line 7: Response ID missing")
expect(s.play.dataSafety.at(-1)).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,');
// a delete-account page answers the general deletion question with yes
expect(s.play.dataSafety.at(-1)).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,');
expect(s.play.dataSafety.at(-1)).not.toContain('PSL_SUPPORT_DATA_DELETION_BY_USER,,');
expect(s.play.dataSafety.at(-1)).not.toContain('PSL_DATA_COLLECTION_USER_REQUEST_DELETE');
});

View file

@ -108,28 +108,32 @@ describe('data safety: conditional required rows', () => {
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', () => {
test('accounts + username: the delete-account URL row carries accounts.deletionUrl, and the deletion question is yes', () => {
// PSL_SUPPORT_DATA_DELETION_BY_USER is the general "Do you provide a way for users to request that their data is
// deleted?": a delete-account page is such a way. Answering NO next to a delete-account URL contradicts it.
const rows = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url });
expect(get(rows, 'PSL_ACCOUNT_DELETION_URL')![2]).toBe(url);
// a SINGLE_CHOICE: one row per option, TRUE on the picked one (a value with no Response ID was Play's
// "Line 7: Response ID missing")
expect(rows.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER').map(r => r.slice(0, 4).join(','))).toEqual([
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,,SINGLE_CHOICE',
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,SINGLE_CHOICE',
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,SINGLE_CHOICE',
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,,SINGLE_CHOICE',
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO_AUTO_DELETED,,SINGLE_CHOICE',
]);
expect(dataSafetyCsv({ ...acc, partialDeletion: false }, { deletionUrl: url })).toContain('\nPSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,TRUE,');
expect(get(rows, 'PSL_DATA_DELETION_URL')).toBeUndefined();
// the "Delete data URL" a yes needs: the delete-account page (it deletes the data too) unless a data-only page is set
expect(get(rows, 'PSL_DATA_DELETION_URL')![2]).toBe(url);
expect(dataSafetyIssues(rows)).toEqual([]);
const yes = dataSafetyRows({ ...acc, partialDeletion: true }, { deletionUrl: url });
expect(get(yes, 'PSL_DATA_DELETION_URL')![2]).toBe(url);
expect(yes.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1])).toEqual(['DATA_DELETION_YES']);
expect(dataSafetyIssues(yes)).toEqual([]);
const auto = dataSafetyRows({ ...acc, partialDeletion: 'auto90' }, { deletionUrl: url });
expect(auto.filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1])).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
expect(get(auto, 'PSL_DATA_DELETION_URL')).toBeUndefined();
expect(dataSafetyIssues(auto)).toEqual([]);
expect(get(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: 'https://example.com/data' }, { deletionUrl: url }), 'PSL_DATA_DELETION_URL')![2]).toBe('https://example.com/data');
// no delete-account URL: the explicit answer, deletionRequest or the old partialDeletion values decide
const picked = (ds: object) => dataSafetyRows({ ...acc, ...ds }).filter(r => r[0] === 'PSL_SUPPORT_DATA_DELETION_BY_USER' && r[2] === 'TRUE').map(r => r[1]);
expect(picked({ dataDeletion: 'auto90' })).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
expect(picked({ partialDeletion: 'auto90' })).toEqual(['DATA_DELETION_NO_AUTO_DELETED']);
expect(picked({ deletionRequest: true })).toEqual(['DATA_DELETION_YES']);
expect(picked({ deletionRequest: false })).toEqual(['DATA_DELETION_NO']);
expect(picked({})).toEqual([]);
// a delete-account URL with anything but yes is a problem before Play sees it
const no = dataSafetyRows({ ...acc, dataDeletion: 'no' }, { deletionUrl: url });
expect(dataSafetyIssues(no).map(i => i.msg).join('\n')).toContain('must be DATA_DELETION_YES (is DATA_DELETION_NO): PSL_SUPPORT_DATA_DELETION_BY_USER (PSL_ACCOUNT_DELETION_URL is set');
});
test('no accounts: no deletion rows, no issues', () => {
const rows = dataSafetyRows({ ...acc, accountCreation: ['PSL_ACM_NONE'] }, { deletionUrl: url });
@ -141,11 +145,11 @@ describe('data safety: conditional required rows', () => {
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(msgs[1]).toContain('dataSafety.dataDeletion');
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 }] });
const rows = dataSafetyRows({ ...acc, dataDeletion: 'yes', partialDeletionUrl: url, 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 });