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
|
|
@ -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'); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue