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:
parent
e01a27d7b7
commit
dbd9f2973c
15 changed files with 95 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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: "..." }`);
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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 ----------
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue