// Play Data safety form through applications.dataSafety (CSV import; replaces every earlier answer). // storebound play data-safety [--csv out.csv] [--dry] [--force] // --dry prints the answers that change (question [response]: old -> new, account and deletion answers first) against // the CSV uploaded last (Play cannot read the form back), then the whole CSV (or --csv writes it). Template answers // (cli/lib/template-answers.ts) are refused, in the dry run too. // Answers come from `dataSafety` (and accounts.deletionUrl) in storebound.config.ts. --dry only writes / prints the CSV. // Both --dry and a real run stop on a missing required row (dataSafetyIssues) before anything is sent. // Play cannot read the form back, so a run compares with what this project uploaded last: the sha256 of that CSV // (per package) is kept in .storebound/data-safety.sha256 (gitignored). The same CSV is not sent again; --force sends it // anyway (e.g. after the form was changed in Play Console by hand). import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { type DataSafetyChange, dataSafetyCsv, dataSafetyDiff, dataSafetyProblem, dataSafetyRows } from '../lib/data-safety'; import { refuseTemplate } from '../lib/template-answers'; import { DATA_SAFETY_UPLOADED } from '../lib/declarations'; import { done, log } from '../lib/log'; import { playClient } from './client'; import * as rt from '../lib/rt'; export const DATA_SAFETY_HASH = '.storebound/data-safety.sha256'; /** sha256 of the CSV for one package. Pure. */ export const dataSafetyHash = (pkg: string, csv: string): string => rt.hasher('sha256').update(`${pkg}\n${csv}`).digest('hex'); /** The hash of the last upload of this package, null when none is recorded. */ export function lastUpload(root: string, pkg: string): { sha256: string; at: string } | null { const f = join(root, DATA_SAFETY_HASH); if (!existsSync(f)) return null; try { return (JSON.parse(readFileSync(f, 'utf8')) as Record)[pkg] ?? null; } catch { return null; } } function rememberUpload(root: string, pkg: string, sha256: string): void { const f = join(root, DATA_SAFETY_HASH); let all: Record = {}; try { if (existsSync(f)) all = JSON.parse(readFileSync(f, 'utf8')); } catch { all = {}; } all[pkg] = { sha256, at: new Date().toISOString() }; mkdirSync(join(root, '.storebound'), { recursive: true }); writeFileSync(f, JSON.stringify(all, null, 2) + '\n'); } const ACCOUNT_Q = /ACCOUNT|DELETION/; /** The changed answers, account and deletion answers first, as `question [response]: old -> new` lines. Pure. */ export function changeLines(changes: DataSafetyChange[]): string[] { const show = (v: string) => (v ? v : '(empty)'); const line = (c: DataSafetyChange) => ` ${c.question}${c.response ? ` [${c.response}]` : ''}: ${show(c.from)} -> ${show(c.to)}${c.label ? ` (${c.label.slice(0, 70)})` : ''}`; const acc = changes.filter(c => ACCOUNT_Q.test(c.question)), rest = changes.filter(c => !ACCOUNT_Q.test(c.question)); return [...(acc.length ? [' account and deletion answers:', ...acc.map(line)] : []), ...(rest.length ? [' data types and other answers:', ...rest.map(line)] : [])]; } /** The dry-run line about the state it compares with. Pure. */ export const dataSafetyDryNote = (last: { at: string } | null): string => `Play has no read API for the Data safety form: this compares with the last upload from this project (${DATA_SAFETY_HASH} sha256${last ? `, uploaded ${last.at.slice(0, 16).replace('T', ' ')} UTC` : ', none recorded'}), not with the live form; --force re-sends the CSV`; export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const ds = need(config.dataSafety, 'dataSafety'); // template answers are never sent, and the dry run refuses the same way (it shows what a real run would do) refuseTemplate(config, ['dataSafety', 'accounts'], `play data-safety${ctx.args.has('dry') ? ' --dry' : ''}`); const csv = dataSafetyCsv(ds, config.accounts); const rows = dataSafetyRows(ds, config.accounts); const problem = dataSafetyProblem(rows); const out = ctx.args.get('csv'); if (out) { writeFileSync(resolve(out), csv); log('play', `wrote ${out}`); } const types = Object.keys(config.dataSafety!.data); log('play', `data safety: ${types.length} data types declared (${types.join(', ') || 'none'})`); const pkg = need(config.play?.packageName, 'play.packageName'); const hash = dataSafetyHash(pkg, csv); const last = lastUpload(root, pkg); const same = last?.sha256 === hash; if (ctx.args.has('dry')) { const upFile = join(root, DATA_SAFETY_UPLOADED); const before = existsSync(upFile) ? readFileSync(upFile, 'utf8') : null; log('play', dataSafetyDryNote(last)); const changes = dataSafetyDiff(before, rows); log('play', `changes against ${before ? `the last uploaded CSV (${DATA_SAFETY_UPLOADED}; Play cannot read the form back)` : 'nothing (no upload recorded: every answer is new)'}: ${changes.length} answer(s)`); for (const l of changeLines(changes)) console.log(l); if (!out) process.stdout.write(csv); if (problem) throw new Error(problem); log('play', same ? `the same CSV was uploaded ${last!.at.slice(0, 16).replace('T', ' ')} UTC: a real run sends nothing (--force sends it again)` : 'a real run uploads it (it differs from the last upload, or none is recorded)'); return; } if (problem) throw new Error(problem); if (same && !ctx.args.has('force')) { done('play', `data safety unchanged since the upload of ${last!.at.slice(0, 16).replace('T', ' ')} UTC: nothing sent (--force sends it again, e.g. after a change in Play Console)`); return; } const play = await playClient(root, pkg); await play.call('POST', `${play.API}/dataSafety`, { safetyLabels: csv }); // keep what went up: the hash skips the next identical upload, the CSV lets the desktop app show what changed since try { rememberUpload(root, pkg, hash); writeFileSync(join(root, DATA_SAFETY_UPLOADED), csv); } catch { /* the upload itself worked */ } done('play', `data safety updated (${csv.split('\n').length - 2} rows)`); }