template compliance answers never reach a store: init writes unanswered (dataSafety, appPrivacy, reviewNotes, ageRating, accounts, reviewAccess), older configs are recognised by the template values; check / validate / scan block, play data-safety (also --dry), console plan / fill, apple review-details and appinfo refuse. onboard --declarations replaces them on a live app: name and copyright from the stores (a copyright with a private term is left out), the rows the code proves (scan proposal, sign-up methods from auth code and login SDKs), the rest as one owner question list. play data-safety --dry prints the answers that change (old -> new, account and deletion first) against the last uploaded CSV; pitfall 60; tests
This commit is contained in:
parent
34c2fa22e5
commit
7219ded3e8
27 changed files with 659 additions and 19 deletions
|
|
@ -367,6 +367,9 @@ export interface AccountsConfig {
|
|||
signUp?: AccountSignUp[];
|
||||
}
|
||||
|
||||
export type TemplateArea = 'dataSafety' | 'appPrivacy' | 'reviewNotes' | 'ageRating' | 'accounts' | 'reviewAccess';
|
||||
export const TEMPLATE_AREAS: readonly TemplateArea[] = ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'];
|
||||
|
||||
export type AccountSignUp = 'username' | 'username-password' | 'email-password' | 'email-link' | 'phone' | 'oauth' | 'other';
|
||||
export const ACCOUNT_SIGN_UPS: readonly AccountSignUp[] = ['username', 'username-password', 'email-password', 'email-link', 'phone', 'oauth', 'other'];
|
||||
|
||||
|
|
@ -645,6 +648,15 @@ export interface ShipConfig {
|
|||
/** App Store base territory of every price schedule (alpha-3). Default: the current one, else USA. */
|
||||
baseTerritory?: string;
|
||||
};
|
||||
/**
|
||||
* Compliance answers that are still the template's (`storebound init`) or not confirmed by the owner yet:
|
||||
* 'dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'. While an area is listed, check
|
||||
* and validate report a blocker and nothing that sends it (play data-safety, apple review-details / appinfo, console
|
||||
* plan / fill) runs. Remove an entry once its answers are the owner's (derived from the code: `storebound scan
|
||||
* --write`, `storebound onboard --declarations`). `[]` = everything answered. Left out (configs before 0.8.4): the
|
||||
* template answers are recognised by their values (cli/lib/template-answers.ts).
|
||||
*/
|
||||
unanswered?: TemplateArea[];
|
||||
dataSafety?: DataSafety;
|
||||
/** App Store App Privacy answers storebound cannot derive from dataSafety (App Privacy has no API: the owner enters
|
||||
* them in App Store Connect). `storebound scan` proposes them from the detected SDKs and code. */
|
||||
|
|
@ -1077,6 +1089,8 @@ export function validateConfig(c: unknown): string[] {
|
|||
e.push(...validateCpp(cfg.apple.customProductPages, ids, Object.keys(cfg.shots?.variants ?? {})));
|
||||
e.push(...validateEvents(cfg.apple.events, ids));
|
||||
}
|
||||
if (cfg.unanswered !== undefined && !(Array.isArray(cfg.unanswered) && cfg.unanswered.every(x => TEMPLATE_AREAS.includes(x))))
|
||||
e.push(`unanswered: a list of ${TEMPLATE_AREAS.join(' | ')}`);
|
||||
if (cfg.appPrivacy) {
|
||||
if (cfg.appPrivacy.tracking !== undefined && typeof cfg.appPrivacy.tracking !== 'boolean') e.push('appPrivacy.tracking: true | false');
|
||||
if (cfg.appPrivacy.trackingDomains && !(Array.isArray(cfg.appPrivacy.trackingDomains) && cfg.appPrivacy.trackingDomains.every(d => typeof d === 'string' && /^[a-z0-9.-]+$/i.test(d))))
|
||||
|
|
|
|||
|
|
@ -263,3 +263,25 @@ export function dataSafetyProblem(rows: string[][]): string | null {
|
|||
const i = dataSafetyIssues(rows);
|
||||
return i.length ? `Data safety CSV is not complete (Play would answer 400):\n${i.map(x => ` - ${x.msg}`).join('\n')}` : null;
|
||||
}
|
||||
|
||||
export interface DataSafetyChange { question: string; response: string; label: string; from: string; to: string }
|
||||
|
||||
/**
|
||||
* The answers that change between two Data safety CSVs (Play cannot read the form back, so `before` is the CSV this
|
||||
* project uploaded last; null = nothing recorded, every answer is new). One entry per (question, response) whose value
|
||||
* differs; a removed answer shows as old -> (empty). Pure.
|
||||
*/
|
||||
export function dataSafetyDiff(before: string | null, rows: string[][]): DataSafetyChange[] {
|
||||
type V = { q: string; r: string; v: string; label: string };
|
||||
const vals = (rs: string[][]) => new Map<string, V>(rs.slice(1).filter(r => r[0]).map(r => [pairKey(r[0]!, r[1] ?? ''), { q: r[0]!, r: r[1] ?? '', v: r[2] ?? '', label: (r[4] ?? '').trim() }]));
|
||||
const old = before ? vals(parseCsv(before)) : new Map<string, V>();
|
||||
const cur = vals(rows);
|
||||
const out: DataSafetyChange[] = [];
|
||||
for (const k of new Set([...old.keys(), ...cur.keys()])) {
|
||||
const o = old.get(k), n = cur.get(k);
|
||||
if ((o?.v ?? '') === (n?.v ?? '')) continue;
|
||||
const x = (n ?? o)!;
|
||||
out.push({ question: x.q, response: x.r, label: x.label || (formRow(x.q, x.r)?.label ?? ''), from: o?.v ?? '', to: n?.v ?? '' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
161
cli/lib/onboard-declarations.ts
Normal file
161
cli/lib/onboard-declarations.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// `storebound onboard` on an app that is already live, the compliance part: the template answers `storebound init`
|
||||
// wrote (Data safety, App Privacy, review notes, age rating, accounts, review access) and the template identity are
|
||||
// taken out, the answers the code proves (the scan's declaration proposal, sign-up methods with evidence) are written
|
||||
// in, and everything else becomes one owner question list. Never invents an answer (AGENTS.md rule 5). Pure: the
|
||||
// caller reads the project and the stores.
|
||||
import type { AccountSignUp, ShipConfig, TemplateArea } from './config';
|
||||
import { accountCreationOf } from './declarations';
|
||||
import { authEvidence } from './project-signals';
|
||||
import { proposeDeclarations } from './scan/declare';
|
||||
import { grepOwn, type Hit, type ProjectFacts } from './scan/project';
|
||||
import { removeKeyPath, upsertBlock, upsertNestedString } from './scan/config-edit';
|
||||
import { SIGNALS } from './scan/rules';
|
||||
import { hasPrivateTerm, TEMPLATE_NAMES, templateAreas, templateIdentity, withoutTemplate } from './template-answers';
|
||||
|
||||
/** What the stores say about the app's identity (public values only). */
|
||||
export interface StoreIdentity { name?: string | null; copyright?: string | null; source?: string }
|
||||
|
||||
export interface DeclarationReset {
|
||||
text: string;
|
||||
/** template answers taken out (area: what) */
|
||||
removed: string[];
|
||||
/** answers the code or the stores proved (what: evidence) */
|
||||
proven: string[];
|
||||
questions: string[];
|
||||
/** the areas left in `unanswered` */
|
||||
unanswered: TemplateArea[];
|
||||
/** the config the rest of onboard works with (template answers out, proven ones in) */
|
||||
config: ShipConfig;
|
||||
}
|
||||
|
||||
const ev = (h: Hit[] | undefined) => (h?.length ? `${h[0]!.file}:${h[0]!.line}` : 'config');
|
||||
|
||||
/** Sign-up methods the code proves, each with its evidence. Built output alone proves nothing. */
|
||||
export function signUpFromCode(p: ProjectFacts): { method: AccountSignUp; evidence: Hit[] }[] {
|
||||
const out: { method: AccountSignUp; evidence: Hit[] }[] = [];
|
||||
const own = (re: RegExp) => { const g = grepOwn(p, re, 2); return g.built ? [] : g.hits; };
|
||||
const add = (method: AccountSignUp, h: Hit[]) => { if (h.length && !out.some(x => x.method === method)) out.push({ method, evidence: h }); };
|
||||
add('email-password', own(/signInWithEmailAndPassword|createUserWithEmailAndPassword|signInWithPassword\(\s*\{\s*email|auth\.signUp\(\s*\{\s*email|loginWithEmail|registerWithEmail/));
|
||||
add('email-link', own(/sendSignInLinkToEmail|signInWithOtp\(\s*\{\s*email|magic-link|magicLink/));
|
||||
add('phone', own(/signInWithPhoneNumber|verifyPhoneNumber|PhoneAuthProvider|signInWithOtp\(\s*\{\s*phone/));
|
||||
// a social login SDK in the dependencies (Google Sign-In, Facebook Login, Sign in with Apple) is OAuth sign-up too
|
||||
const loginSdk = p.sdks.filter(s => s.sdk.category === 'auth' && (s.sdk.login === 'google' || s.sdk.login === 'facebook' || s.sdk.login === 'apple'))
|
||||
.map(s => ({ file: s.deps[0]!.file, line: s.deps[0]!.line, text: s.sdk.name }));
|
||||
add('oauth', [...own(SIGNALS.appleSignIn), ...own(SIGNALS.thirdPartyLogin), ...loginSdk]);
|
||||
add('username', own(/\bcreate(Guest|Anonymous)(Account|User)\b|\bregisterGuest\b|signInAnonymously/));
|
||||
// a password field plus an email field in the own sources, without a recognised SDK call
|
||||
if (!out.length) {
|
||||
const pw = own(/autoComplete=["'](current|new)-password|\btype=["']password["']|textContentType=["']?\.?(password|newPassword)\b|secureTextEntry/);
|
||||
const em = own(SIGNALS.email);
|
||||
if (pw.length && em.length) add('email-password', [...em.slice(0, 1), ...pw.slice(0, 1)]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The config text with the template compliance answers and identity replaced by what the code and the stores prove.
|
||||
* `terms`: private names (never printed) that must not land in public text such as the copyright.
|
||||
*/
|
||||
export function resetDeclarations(o: { config: ShipConfig; text: string; path: string; facts: ProjectFacts; store?: StoreIdentity; terms?: string[] }): DeclarationReset {
|
||||
const { config, path, facts } = o;
|
||||
let text = o.text;
|
||||
const removed: string[] = [], proven: string[] = [], questions: string[] = [];
|
||||
const found = templateAreas(config);
|
||||
const areas = new Set(found.map(f => f.area));
|
||||
const left = new Set<TemplateArea>(Array.isArray(config.unanswered) ? config.unanswered : []);
|
||||
let c = withoutTemplate(config);
|
||||
|
||||
// identity: only the store's public values; a value with a private term is left out, never written
|
||||
const ids = templateIdentity(config);
|
||||
if (ids.length) {
|
||||
const terms = o.terms ?? [];
|
||||
const src = o.store?.source ?? 'the store';
|
||||
if (TEMPLATE_NAMES.has(config.app.name)) {
|
||||
const n = o.store?.name?.trim();
|
||||
if (n && !hasPrivateTerm(n, terms)) { text = upsertNestedString(text, path, 'app', 'name', n); proven.push(`app.name: "${n}" (${src})`); c = { ...c, app: { ...c.app, name: n } }; }
|
||||
else questions.push('App name: no store has a usable name for it; set app.name');
|
||||
removed.push(`app.name "${config.app.name}"`);
|
||||
}
|
||||
if (config.app.copyright && /\bExample Studio\b/i.test(config.app.copyright)) {
|
||||
const cp = o.store?.copyright?.trim();
|
||||
removed.push(`app.copyright "${config.app.copyright}"`);
|
||||
if (cp && !hasPrivateTerm(cp, terms)) { text = upsertNestedString(text, path, 'app', 'copyright', cp); proven.push(`app.copyright: "${cp}" (${src})`); }
|
||||
else {
|
||||
text = removeKeyPath(text, path, ['app', 'copyright']);
|
||||
questions.push(cp ? 'Copyright: the store\'s copyright names a private term, so it was left out of the config; set app.copyright to what may be public (or leave it out)' : 'Copyright: no store has one; set app.copyright (e.g. "2026 <public studio name>") or leave it out');
|
||||
}
|
||||
}
|
||||
if (Object.values(config.apple?.testflight?.description ?? {}).some(d => /\bExample (Game|App)\b/.test(d ?? ''))) {
|
||||
text = removeKeyPath(text, path, ['apple', 'testflight', 'description']);
|
||||
removed.push('apple.testflight.description (the template\'s beta text)');
|
||||
questions.push('TestFlight beta description (what testers should try): set apple.testflight.description or leave it out');
|
||||
}
|
||||
}
|
||||
|
||||
// accounts: from the code (auth signals, sign-up methods), never the template's
|
||||
const auth = authEvidence(facts);
|
||||
const methods = signUpFromCode(facts);
|
||||
const proveAccounts = areas.has('accounts') || (!config.accounts && (auth.length > 0 || methods.length > 0) && (areas.has('dataSafety') || areas.has('identity')));
|
||||
if (proveAccounts) {
|
||||
if (areas.has('accounts')) removed.push('accounts (template: sign-up methods, in-app path, deletion page)');
|
||||
const keepUrl = config.accounts?.deletionUrl && !/example\.com/i.test(config.accounts.deletionUrl) ? config.accounts.deletionUrl : undefined;
|
||||
if (auth.length || methods.length) {
|
||||
const acc = { enabled: true, ...(keepUrl ? { deletionUrl: keepUrl } : {}), ...(methods.length ? { signUp: methods.map(m => m.method) } : {}) };
|
||||
text = upsertBlock(text, path, 'accounts', acc, 'from storebound onboard: sign-in code in the project');
|
||||
c = { ...c, accounts: acc };
|
||||
proven.push(`accounts.enabled: true (${auth[0] ?? ev(methods[0]?.evidence)})`);
|
||||
for (const m of methods) proven.push(`accounts.signUp ${m.method} (${ev(m.evidence)})`);
|
||||
if (!methods.length) { questions.push('How do people sign up (email + password, Sign in with Apple / Google, phone, a username only)? The code has sign-in but no recognisable method: set accounts.signUp'); left.add('accounts'); }
|
||||
else left.delete('accounts');
|
||||
questions.push('Where does account deletion start inside the app (e.g. "Settings > Account > Delete account")? set accounts.inApp');
|
||||
} else {
|
||||
text = removeKeyPath(text, path, ['accounts']);
|
||||
delete c.accounts;
|
||||
proven.push('no accounts: no sign-in or sign-up code in the project');
|
||||
left.delete('accounts');
|
||||
}
|
||||
}
|
||||
if (areas.has('reviewAccess')) {
|
||||
text = removeKeyPath(text, path, ['reviewAccess']);
|
||||
delete c.reviewAccess;
|
||||
removed.push('reviewAccess (template: sign-in required, demo account instructions)');
|
||||
left.delete('reviewAccess'); // onboard sets signInRequired from the store's review detail and the code, or asks
|
||||
}
|
||||
if (areas.has('reviewNotes')) {
|
||||
text = removeKeyPath(text, path, ['apple', 'info', 'reviewNotes']);
|
||||
removed.push('apple.info.reviewNotes (template text)');
|
||||
questions.push('App Review notes: where is every main feature (and the in-app purchases, if any)? write apple.info.reviewNotes');
|
||||
left.delete('reviewNotes');
|
||||
}
|
||||
if (areas.has('ageRating')) {
|
||||
// kept in the file, but unanswered: dropping it would mean "every content question NONE", which is an answer too
|
||||
left.add('ageRating');
|
||||
questions.push('Age rating (App Store age rating questionnaire and Play IARC): violence, gambling, user-generated content, web access, ads ... answer apple.info.ageRating, then remove ageRating from unanswered');
|
||||
}
|
||||
|
||||
// Data safety / App Privacy: the scan's proposal against a config without the template answers
|
||||
if (areas.has('dataSafety') || areas.has('appPrivacy')) {
|
||||
removed.push(`dataSafety (template rows: ${Object.keys(config.dataSafety?.data ?? {}).join(', ') || 'none'}; accountCreation ${JSON.stringify(config.dataSafety?.accountCreation ?? ['PSL_ACM_NONE'])})`);
|
||||
if (config.appPrivacy) removed.push('appPrivacy (derived from the template dataSafety)');
|
||||
const p = proposeDeclarations(facts, c, { path, text });
|
||||
const data = p.dataSafety?.data ?? {};
|
||||
const acm = accountCreationOf(c.accounts?.signUp);
|
||||
const ds = { collectsData: Object.keys(data).length > 0, ...(acm ? { accountCreation: acm } : {}), data };
|
||||
text = upsertBlock(text, path, 'dataSafety', ds, 'from storebound onboard: only the rows the code proves; the owner answers the rest (unanswered)');
|
||||
c = { ...c, dataSafety: ds as ShipConfig['dataSafety'] };
|
||||
for (const d of p.diff.filter(x => x.change !== 'kept')) proven.push(`dataSafety ${d.type}: collect ${d.after.collect.join(', ')}${d.after.shared ? `; shared for ${(d.after.share ?? []).join(', ')}` : ''} (${ev(d.evidence)})`);
|
||||
if (acm) proven.push(`dataSafety.accountCreation ${JSON.stringify(acm)} (from accounts.signUp)`);
|
||||
if (p.appPrivacy) { text = upsertBlock(text, path, 'appPrivacy', p.appPrivacy); c = { ...c, appPrivacy: p.appPrivacy }; }
|
||||
else text = removeKeyPath(text, path, ['appPrivacy']);
|
||||
for (const i of p.items.filter(x => x.status === 'ask' && (x.area === 'dataSafety' || x.area === 'appPrivacy')))
|
||||
questions.push(`${i.area === 'dataSafety' ? 'Data safety' : 'App Privacy'} ${i.what}: ${i.value}${i.note ? ` (${i.note})` : ''}`);
|
||||
questions.push('Data safety / App Privacy: does the app send anything else off the device (own backend, analytics, crash reports, support chat)? The rows above are only what the code proves. Then remove dataSafety and appPrivacy from unanswered');
|
||||
left.add('dataSafety'); left.add('appPrivacy');
|
||||
}
|
||||
|
||||
const unanswered = (['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'] as const).filter(a => left.has(a));
|
||||
if (found.length || config.unanswered !== undefined) {
|
||||
text = upsertBlock(text, path, 'unanswered', unanswered, 'compliance answers the owner has not confirmed yet: check / validate block and nothing sends them');
|
||||
}
|
||||
return { text, removed, proven, questions: [...new Set(questions)], unanswered, config: { ...c, unanswered } };
|
||||
}
|
||||
|
|
@ -132,3 +132,72 @@ function depthAt(s: string, at: number): number {
|
|||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** End of the value that starts at `i` in `s`: the comma or closing bracket at depth 0. */
|
||||
function valueEnd(s: string, i: number): number {
|
||||
let d = 0;
|
||||
for (; i < s.length; i++) {
|
||||
const ch = s[i]!;
|
||||
if (ch === '/' && s[i + 1] === '/') { const n = s.indexOf('\n', i); if (n < 0) return s.length; i = n; continue; }
|
||||
if (ch === '/' && s[i + 1] === '*') { const n = s.indexOf('*/', i + 2); if (n < 0) return s.length; i = n + 1; continue; }
|
||||
if (ch === '"' || ch === '\'' || ch === '`') { for (i++; i < s.length && s[i] !== ch; i++) if (s[i] === '\\') i++; continue; }
|
||||
if (ch === '{' || ch === '[' || ch === '(') d++;
|
||||
else if (ch === '}' || ch === ']' || ch === ')') { if (d === 0) return i; d--; }
|
||||
else if (ch === ',' && d === 0) return i;
|
||||
}
|
||||
return s.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The config text without the property at `path` (['dataSafety'], ['apple', 'info', 'reviewNotes']): the key, its
|
||||
* value (any expression), its trailing comma and the comment lines right above it. Unchanged when the path is not
|
||||
* there. .json is parsed and written back.
|
||||
*/
|
||||
export function removeKeyPath(text: string, file: string, path: string[]): string {
|
||||
if (file.endsWith('.json')) {
|
||||
const j = JSON.parse(text) as Record<string, unknown>;
|
||||
let o: Record<string, unknown> | undefined = j;
|
||||
for (const k of path.slice(0, -1)) o = o?.[k] as Record<string, unknown> | undefined;
|
||||
if (!o || typeof o !== 'object' || !(path.at(-1)! in o)) return text;
|
||||
delete o[path.at(-1)!];
|
||||
return JSON.stringify(j, null, 2) + '\n';
|
||||
}
|
||||
let from = 0, to = text.length;
|
||||
for (let n = 0; n < path.length; n++) {
|
||||
const region = text.slice(from, to);
|
||||
const re = new RegExp(`(^|[{,\\s])${path[n]}\\s*:\\s*`, 'gm');
|
||||
let hit: { at: number; val: number } | null = null, bestInd = Infinity;
|
||||
for (let m = re.exec(region); m; m = re.exec(region)) {
|
||||
const at = m.index + m[1]!.length;
|
||||
const lineStart = region.lastIndexOf('\n', at - 1) + 1;
|
||||
if (region.slice(lineStart, at).includes('//')) continue; // in a comment
|
||||
if (n > 0) { if (depthAt(region, at) !== 0) continue; hit = { at: from + at, val: from + m.index + m[0].length }; break; }
|
||||
if (at - lineStart >= bestInd) continue;
|
||||
hit = { at: from + at, val: from + m.index + m[0].length }; bestInd = at - lineStart;
|
||||
}
|
||||
if (!hit) return text;
|
||||
if (n < path.length - 1) {
|
||||
if (text[hit.val] !== '{') return text; // the parent is not an object literal
|
||||
const close = matchBrace(text, hit.val);
|
||||
if (close < 0) return text;
|
||||
from = hit.val + 1; to = close;
|
||||
continue;
|
||||
}
|
||||
let end = valueEnd(text, hit.val);
|
||||
if (text[end] === ',') end++;
|
||||
let start = hit.at;
|
||||
const ls = text.lastIndexOf('\n', start - 1) + 1;
|
||||
if (!text.slice(ls, start).trim()) {
|
||||
start = ls;
|
||||
while (start > 0) {
|
||||
const prev = text.lastIndexOf('\n', start - 2) + 1;
|
||||
if (!/^\s*\/\//.test(text.slice(prev, start - 1))) break;
|
||||
start = prev;
|
||||
}
|
||||
const nl = /^[ \t]*\n/.exec(text.slice(end));
|
||||
if (nl) end += nl[0].length;
|
||||
}
|
||||
return text.slice(0, start) + text.slice(end);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// `storebound scan` engine: detect the project, run the rejection-risk rules, propose the declarations. Offline and
|
||||
// read-only (the only write is `--write`, done by the command, never here).
|
||||
import { withoutTemplate } from '../template-answers';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { CONFIG_NAMES, loadConfig, type ShipConfig } from '../config';
|
||||
|
|
@ -53,7 +54,7 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise
|
|||
captions: readRel(config?.shots?.copy ?? 'store/shots/copy.json'),
|
||||
};
|
||||
const configFile = configPath ? { path: rel(configPath), text: readFileSync(configPath, 'utf8') } : null;
|
||||
const declarations = proposeDeclarations(facts, config, configFile);
|
||||
const declarations = proposeDeclarations(facts, config ? withoutTemplate(config) : null, configFile);
|
||||
const dataMerges = config?.dataSafety ? declarations.diff.filter(d => d.change === 'merge' && d.before).map(d => {
|
||||
const b = d.before!;
|
||||
const missing = [...d.after.collect.filter(x => !b.collect.includes(x)).map(x => `collected for ${x}`), ...(d.after.shared && !b.shared ? ['shared'] : []),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
|
||||
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
|
||||
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
|
||||
import { templateBlockers } from '../template-answers';
|
||||
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
|
||||
import type { ShipConfig } from '../config';
|
||||
import { allSubscriptions } from '../subscriptions';
|
||||
|
|
@ -560,6 +561,10 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
playRules(inp, { add: f => add({ ...f, store: 'play' }), cfg, sdkHits, listingHits, creates, restorable, paySdk });
|
||||
|
||||
// ---- owner-accepted findings ----
|
||||
// compliance answers that are still the template's (storebound init): never the owner's, never pushed
|
||||
if (c) for (const msg of templateBlockers(c)) for (const store of (['apple', 'play'] as const).filter(x => (x === 'apple' ? c.apple : c.play)))
|
||||
add({ rule: 'template-answers', guideline: store === 'apple' ? '5.1.2 / 2.1' : 'Play: User Data', store, severity: 'blocker', title: `${msg.split(' still holds ')[0]} still holds template answers`,
|
||||
evidence: cfgHits(inp, /unanswered\s*:|dataSafety\s*:|reviewNotes\s*:/), fix: msg, skill: 'store-declarations' });
|
||||
applyIgnore(out, c?.scan?.ignore ?? []);
|
||||
return sortScan(out);
|
||||
}
|
||||
|
|
@ -595,3 +600,11 @@ const RANK: Record<Severity, number> = { blocker: 0, high: 1, medium: 2, low: 3
|
|||
export function sortScan(f: ScanFinding[]): ScanFinding[] {
|
||||
return f.map((x, i) => [x, i] as const).sort(([a, i], [b, j]) => Number(!!a.accepted) - Number(!!b.accepted) || RANK[a.severity] - RANK[b.severity] || i - j).map(([x]) => x);
|
||||
}
|
||||
|
||||
/** The first config line matching `re`, as evidence. */
|
||||
function cfgHits(inp: ScanInput, re: RegExp): Hit[] {
|
||||
const f = inp.configFile;
|
||||
if (!f) return [];
|
||||
const line = f.text.split('\n').findIndex(l => re.test(l));
|
||||
return line < 0 ? [] : [{ file: f.path, line: line + 1, text: f.text.split('\n')[line]!.trim().slice(0, 100) }];
|
||||
}
|
||||
|
|
|
|||
145
cli/lib/template-answers.ts
Normal file
145
cli/lib/template-answers.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// Template compliance answers: what `storebound init` wrote is a starting point, never the owner's answer. A config
|
||||
// whose Data safety, App Privacy, review notes, age rating, accounts or review access still hold the template's values
|
||||
// must never reach a store (a game template declares location, device id and diagnostics as shared for advertising).
|
||||
// - `unanswered: [...]` in the config (the templates write it) lists the areas nobody has answered yet;
|
||||
// - configs written before it (no `unanswered` key) are recognised by the template's values;
|
||||
// - identity (app name, the "Example Studio" copyright, the template's TestFlight text) is always recognised by value.
|
||||
// templateAreas() says what is left; templateRefusal() is the gate every command that sends an answer calls. Pure.
|
||||
import type { ShipConfig, TemplateArea } from './config';
|
||||
import gameTemplate from '../../templates/storebound.config';
|
||||
import appTemplate from '../../templates/profiles/app/storebound.config';
|
||||
import subscriptionTemplate from '../../templates/profiles/subscription-app/storebound.config';
|
||||
|
||||
export type Area = TemplateArea | 'identity';
|
||||
export interface TemplateFinding { area: Area; why: string }
|
||||
|
||||
const TEMPLATES: ShipConfig[] = [gameTemplate, appTemplate, subscriptionTemplate];
|
||||
|
||||
/** Stable JSON (sorted keys) for value comparison. */
|
||||
const canon = (v: unknown): string => JSON.stringify(v, (_k, x) => (x && typeof x === 'object' && !Array.isArray(x)
|
||||
? Object.fromEntries(Object.keys(x).sort().map(k => [k, (x as Record<string, unknown>)[k]])) : x));
|
||||
const sameData = (a: ShipConfig['dataSafety'], b: ShipConfig['dataSafety']) => !!a && !!b
|
||||
&& canon(a.data) === canon(b.data) && canon(a.accountCreation ?? ['PSL_ACM_NONE']) === canon(b.accountCreation ?? ['PSL_ACM_NONE']);
|
||||
|
||||
export const TEMPLATE_NAMES = new Set(TEMPLATES.map(t => t.app.name));
|
||||
const TEMPLATE_COPYRIGHT = /\bExample Studio\b/i;
|
||||
const TEMPLATE_TEXT = /\bExample (Game|App|Studio)\b/;
|
||||
|
||||
/** Identity values that are still the template's (name, copyright, TestFlight beta description). */
|
||||
export function templateIdentity(c: Pick<ShipConfig, 'app' | 'apple'>): string[] {
|
||||
const out: string[] = [];
|
||||
if (TEMPLATE_NAMES.has(c.app.name)) out.push(`app.name "${c.app.name}"`);
|
||||
if (c.app.copyright && TEMPLATE_COPYRIGHT.test(c.app.copyright)) out.push(`app.copyright "${c.app.copyright}"`);
|
||||
if (Object.values(c.apple?.testflight?.description ?? {}).some(d => TEMPLATE_TEXT.test(d ?? ''))) out.push('apple.testflight.description (the template\'s beta text)');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The areas that still hold template (unconfirmed) answers, with the reason. */
|
||||
export function templateAreas(c: ShipConfig): TemplateFinding[] {
|
||||
const out: TemplateFinding[] = [];
|
||||
const id = templateIdentity(c);
|
||||
if (id.length) out.push({ area: 'identity', why: `${id.join(', ')} from the template` });
|
||||
if (Array.isArray(c.unanswered)) {
|
||||
for (const a of c.unanswered) out.push({ area: a, why: 'listed in unanswered (template or unconfirmed answers)' });
|
||||
return out;
|
||||
}
|
||||
// configs from before `unanswered`: recognise the template's own values
|
||||
const ds = TEMPLATES.find(t => sameData(c.dataSafety, t.dataSafety));
|
||||
if (ds) out.push({ area: 'dataSafety', why: `dataSafety is the "${ds.app.profile}" template's (same data types and account answer)` }, { area: 'appPrivacy', why: 'App Privacy is derived from the template dataSafety' });
|
||||
const notes = c.apple?.info?.reviewNotes;
|
||||
const rn = notes ? TEMPLATES.find(t => t.apple?.info?.reviewNotes === notes) : undefined;
|
||||
if (rn) out.push({ area: 'reviewNotes', why: `apple.info.reviewNotes is the "${rn.app.profile}" template's text` });
|
||||
const origin = ds ?? rn;
|
||||
if (origin && c.apple?.info?.ageRating && canon(c.apple.info.ageRating) === canon(origin.apple?.info?.ageRating ?? {}))
|
||||
out.push({ area: 'ageRating', why: `apple.info.ageRating is the "${origin.app.profile}" template's, next to its other answers` });
|
||||
if (c.reviewAccess?.instructions && TEMPLATES.some(t => t.reviewAccess?.instructions === c.reviewAccess!.instructions))
|
||||
out.push({ area: 'reviewAccess', why: 'reviewAccess is the template\'s (its instructions text)' });
|
||||
if (c.accounts && TEMPLATES.some(t => t.accounts && t.accounts.inApp === c.accounts!.inApp && canon(t.accounts.signUp) === canon(c.accounts!.signUp)))
|
||||
out.push({ area: 'accounts', why: 'accounts is the template\'s (its in-app path and sign-up methods)' });
|
||||
return out;
|
||||
}
|
||||
|
||||
const LABEL: Record<Area, string> = {
|
||||
identity: 'identity (name / copyright)', dataSafety: 'Data safety', appPrivacy: 'App Privacy', reviewNotes: 'App Review notes',
|
||||
ageRating: 'age rating', accounts: 'accounts (sign-up methods, deletion)', reviewAccess: 'review access (sign-in for reviewers)',
|
||||
};
|
||||
|
||||
/** How to fix one area, in one line. */
|
||||
export function templateFix(a: Area): string {
|
||||
switch (a) {
|
||||
case 'identity': return 'set app.name / app.copyright to the real ones (storebound onboard --declarations takes them from the stores)';
|
||||
case 'dataSafety': case 'appPrivacy': return 'derive them from the code (storebound scan --write proposes them; storebound onboard --declarations for a live app) or answer them, then remove the entry from unanswered';
|
||||
case 'reviewNotes': return 'write apple.info.reviewNotes for this app (where every feature is), then remove it from unanswered';
|
||||
case 'ageRating': return 'answer apple.info.ageRating for this app (ask the owner), then remove it from unanswered';
|
||||
case 'accounts': return 'set accounts from the code (sign-up methods, deletion page) or remove the block if the app has none, then remove it from unanswered';
|
||||
case 'reviewAccess': return 'set reviewAccess from the code and the store (storebound onboard), then remove it from unanswered';
|
||||
}
|
||||
}
|
||||
|
||||
/** One blocker line per area (check / validate / scan). */
|
||||
export function templateBlockers(c: ShipConfig): string[] {
|
||||
const f = templateAreas(c);
|
||||
const lines: string[] = [];
|
||||
const priv = f.filter(x => x.area === 'dataSafety' || x.area === 'appPrivacy');
|
||||
if (priv.length) lines.push(`Data safety / App Privacy still holds template answers (${priv[0]!.why}): ${templateFix('dataSafety')}`);
|
||||
for (const x of f) if (x.area !== 'dataSafety' && x.area !== 'appPrivacy') lines.push(`${LABEL[x.area]} still holds template answers (${x.why}): ${templateFix(x.area)}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal for a command that would send `needs` to a store, or null when none of them is a template answer.
|
||||
* appPrivacy also needs dataSafety (App Privacy is derived from it).
|
||||
*/
|
||||
export function templateRefusal(c: ShipConfig, needs: Area[], what: string): string | null {
|
||||
const want = new Set<Area>(needs);
|
||||
if (want.has('appPrivacy')) want.add('dataSafety');
|
||||
const hit = templateAreas(c).filter(x => want.has(x.area));
|
||||
if (!hit.length) return null;
|
||||
const areas = [...new Set(hit.map(x => x.area))];
|
||||
return `refused: ${what} would send template answers, not the owner's: ${areas.map(a => LABEL[a]).join(', ')} (${hit[0]!.why}). `
|
||||
+ `Nothing was sent. Fix: ${[...new Set(areas.map(templateFix))].join('; ')}.`;
|
||||
}
|
||||
|
||||
/** Throws the refusal (dry runs too: the dry run must show that the real run would refuse). */
|
||||
export function refuseTemplate(c: ShipConfig, needs: Area[], what: string): void {
|
||||
const r = templateRefusal(c, needs, what);
|
||||
if (r) throw new Error(r);
|
||||
}
|
||||
|
||||
/** The config without the template answers of `areas` (what code evidence is proposed against). Pure. */
|
||||
export function withoutTemplate(c: ShipConfig, areas = templateAreas(c).map(x => x.area)): ShipConfig {
|
||||
const has = (a: Area) => areas.includes(a);
|
||||
const out: ShipConfig = { ...c };
|
||||
if (has('dataSafety')) delete out.dataSafety;
|
||||
if (has('appPrivacy') || has('dataSafety')) delete out.appPrivacy;
|
||||
if (has('accounts')) delete out.accounts;
|
||||
if (has('reviewAccess')) delete out.reviewAccess;
|
||||
if (c.apple?.info && (has('reviewNotes') || has('ageRating'))) {
|
||||
const info = { ...c.apple.info };
|
||||
if (has('reviewNotes')) delete info.reviewNotes;
|
||||
if (has('ageRating')) delete info.ageRating;
|
||||
out.apple = { ...c.apple, info };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Private terms (names the owner keeps out of public text), one per line, from STOREBOUND_PRIVATE_TERMS or
|
||||
* WAYBETTER_FORBIDDEN_TERMS (a file path). Never printed. */
|
||||
export function privateTerms(read: (p: string) => string | null, env: Record<string, string | undefined> = process.env): string[] {
|
||||
const f = env.STOREBOUND_PRIVATE_TERMS || env.WAYBETTER_FORBIDDEN_TERMS;
|
||||
if (!f) return [];
|
||||
const text = read(f);
|
||||
if (text === null) return [];
|
||||
return text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
||||
}
|
||||
|
||||
/** Whether a value contains one of the private terms (case-insensitive). */
|
||||
export const hasPrivateTerm = (v: string, terms: string[]): boolean => terms.some(t => v.toLowerCase().includes(t.toLowerCase()));
|
||||
|
||||
/** The answers each console form sends (console plan / fill refuse while one is a template answer). */
|
||||
export const FORM_AREAS: Record<string, Area[]> = {
|
||||
'apple-app-record': ['identity'], 'play-app-record': ['identity'],
|
||||
'apple-app-privacy': ['dataSafety', 'appPrivacy'], 'apple-age-rating': ['ageRating'], 'play-content-rating': ['ageRating'],
|
||||
'play-app-content': ['dataSafety', 'appPrivacy', 'ageRating', 'accounts', 'reviewAccess'],
|
||||
'play-ads': ['dataSafety'], 'play-advertising-id': ['dataSafety'], 'play-sign-in-details': ['reviewAccess', 'accounts'],
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue