From dbd9f2973c6854e95137d4581b774e0fec993b6e Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 12 Oct 2026 09:46:58 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 8 ++++ cli/check.ts | 2 - cli/lib/config.ts | 22 ++++++----- cli/lib/data-safety.ts | 39 +++++++++++++------ examples/demo-app/storebound.config.ts | 2 +- media/teaser/script.js | 2 +- skills/account-deletion/SKILL.md | 11 +++--- skills/store-declarations/SKILL.md | 11 +++--- skills/store-pitfalls/SKILL.md | 5 ++- templates/profiles/app/storebound.config.ts | 5 +-- .../subscription-app/storebound.config.ts | 5 +-- templates/storebound.config.ts | 7 ++-- tests/app-privacy.test.ts | 5 +-- tests/idempotency.test.ts | 3 +- tests/pure.test.ts | 34 +++++++++------- 15 files changed, 95 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edc53d..02f8d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/check.ts b/cli/check.ts index 53df71a..423575b 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -102,8 +102,6 @@ export async function run(ctx: Ctx): Promise { // 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)) { diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 25b050d..8d046a8 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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: "", value: "..." }`); diff --git a/cli/lib/data-safety.ts b/cli/lib/data-safety.ts index cf7b32b..d79d2f3 100644 --- a/cli/lib/data-safety.ts +++ b/cli/lib/data-safety.ts @@ -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 | 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'); } diff --git a/examples/demo-app/storebound.config.ts b/examples/demo-app/storebound.config.ts index d74292f..62acb8d 100644 --- a/examples/demo-app/storebound.config.ts +++ b/examples/demo-app/storebound.config.ts @@ -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'] }, diff --git a/media/teaser/script.js b/media/teaser/script.js index fe28654..ddd07da 100644 --- a/media/teaser/script.js +++ b/media/teaser/script.js @@ -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 ---------- diff --git a/skills/account-deletion/SKILL.md b/skills/account-deletion/SKILL.md index 4c4325d..0bbf8c6 100644 --- a/skills/account-deletion/SKILL.md +++ b/skills/account-deletion/SKILL.md @@ -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: (... set )` 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). diff --git a/skills/store-declarations/SKILL.md b/skills/store-declarations/SKILL.md index 3b8f7d1..743d234 100644 --- a/skills/store-declarations/SKILL.md +++ b/skills/store-declarations/SKILL.md @@ -82,11 +82,12 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun " (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: (; set )` and the real run stops before sending. Rules: diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 4ada816..5a3adf9 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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: ` 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`. diff --git a/templates/profiles/app/storebound.config.ts b/templates/profiles/app/storebound.config.ts index 31f2b6e..291e2fa 100644 --- a/templates/profiles/app/storebound.config.ts +++ b/templates/profiles/app/storebound.config.ts @@ -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'] }, diff --git a/templates/profiles/subscription-app/storebound.config.ts b/templates/profiles/subscription-app/storebound.config.ts index d2f2c4d..c68f315 100644 --- a/templates/profiles/subscription-app/storebound.config.ts +++ b/templates/profiles/subscription-app/storebound.config.ts @@ -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'] }, diff --git a/templates/storebound.config.ts b/templates/storebound.config.ts index 1c44e9d..3341e78 100644 --- a/templates/storebound.config.ts +++ b/templates/storebound.config.ts @@ -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: { diff --git a/tests/app-privacy.test.ts b/tests/app-privacy.test.ts index 49aaf0d..d81df87 100644 --- a/tests/app-privacy.test.ts +++ b/tests/app-privacy.test.ts @@ -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 }); } }); diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts index b5867c5..ba26cfe 100644 --- a/tests/idempotency.test.ts +++ b/tests/idempotency.test.ts @@ -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'); }); diff --git a/tests/pure.test.ts b/tests/pure.test.ts index 1fefda1..72584e3 100644 --- a/tests/pure.test.ts +++ b/tests/pure.test.ts @@ -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 });