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:
CRTkafa 2026-10-14 11:36:30 +08:00
parent 34c2fa22e5
commit 7219ded3e8
27 changed files with 659 additions and 19 deletions

View file

@ -221,6 +221,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'androidpublisher.edits.images.list': 'apps art play/images validate',
'androidpublisher.edits.images.upload': 'play/images',
'androidpublisher.edits.insert': 'apps onboard play/client validate',
'androidpublisher.edits.listings.get': 'onboard',
'androidpublisher.edits.listings.list': 'art listing-store play/listing play/status validate',
'androidpublisher.edits.listings.patch': 'listing-store',
'androidpublisher.edits.listings.update': 'listing-store play/listing',

View file

@ -7,6 +7,7 @@
// - content rights declaration
// - price Free (USA base) and availability in every territory + new ones (when apple.info.free !== false)
// App Privacy ("nutrition label") has no API: web UI only (store-declarations skill).
import { refuseTemplate } from '../lib/template-answers';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { type ItemPlan, itemLines, planItem } from '../lib/listing-plan';
@ -69,6 +70,7 @@ export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const info = config.apple?.info ?? {};
const dry = ctx.args.has('dry');
refuseTemplate(config, ['ageRating'], 'apple appinfo (age rating declaration)');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const p = await planAppInfo(ctx, c, appId);

View file

@ -5,6 +5,7 @@
// Demo account (reviewAccess.signInRequired: true): "reviewDemoAccount" in .storebound/credentials.json or
// REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD; masked in every log line. Notes: apple.info.reviewNotes plus
// reviewAccess.instructions and accounts.inApp (lib/review-details.ts).
import { refuseTemplate } from '../lib/template-answers';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { reviewContact, reviewDemoAccount } from '../lib/credentials';
@ -15,6 +16,7 @@ import { ascClient } from './asc';
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const version = await c.editableVersion();
if (!version) throw new Error('no editable App Store version (apple listing --version x.y.z creates it)');

View file

@ -4,6 +4,7 @@
// storebound check everything above
// storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
import { templateBlockers } from './lib/template-answers';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources, typeIdOf } from './lib/app-privacy';
@ -215,6 +216,8 @@ export async function run(ctx: Ctx): Promise<void> {
if (fromStores && renderSources.has(f)) note(`render source not there: ${f} (only needed to render icons / screenshots again; the store art is pulled)`);
else problems.push(`missing file: ${f}`);
}
// template compliance answers (storebound init) never count as the owner's
for (const b of templateBlockers(config)) problems.push(b);
if (existsSync(resolve('.gitignore')) && !/^\/?\.storebound\/?$/m.test(readFileSync(resolve('.gitignore'), 'utf8')))
problems.push('.gitignore does not ignore .storebound/ (credentials live there)');

View file

@ -27,6 +27,7 @@
// Sources (checked October 2026): see cli/lib/console-answers.ts; Playwright launchPersistentContext, getByRole,
// getByLabel: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context ,
// https://playwright.dev/docs/locators
import { FORM_AREAS, refuseTemplate } from '../lib/template-answers';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { Ctx } from '../lib/ctx';
@ -905,6 +906,7 @@ export async function run(ctx: Ctx): Promise<void> {
return;
}
if (!isForm(form)) throw new Error(`console fill: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`);
refuseTemplate(config, FORM_AREAS[form] ?? [], `console fill ${form}`);
const meta = formMeta(form, config);
const store = args.get('store');
if (store && store !== 'apple' && store !== 'play') throw new Error('--store: apple | play');

View file

@ -7,6 +7,7 @@
// apple.appId / play.developerId / play.appId, the ids cached by earlier runs (.storebound/console-app-ids.json,
// ~/.storebound/console-accounts.json) and the workspace's account ids; --lookup also asks the App Store Connect API
// for the app id by bundle id (a GET; needs the API key). Pure logic in cli/lib/console-plan.ts. MCP: console_plan.
import { FORM_AREAS, refuseTemplate } from '../lib/template-answers';
import type { Ctx } from '../lib/ctx';
import { formMeta, isForm, isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
import { reviewDemoAccount } from '../lib/credentials';
@ -28,6 +29,7 @@ export async function run(ctx: Ctx): Promise<void> {
return;
}
if (!isPlanForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${PLAN_FORM_IDS.join(', ')})`);
refuseTemplate(config, FORM_AREAS[form] ?? [], `console plan ${form}`);
const { withAccountIds, readAccounts } = await import('./accounts');
const cfg = withAccountIds(config);
const pkg = cfg.play?.packageName;

View file

@ -459,6 +459,7 @@
],
"mcp": [],
"onboard": [
"accounts",
"all",
"allow-price-changes",
"apple",
@ -469,6 +470,7 @@
"cordova-android",
"cordova-ios",
"csv",
"declarations",
"expo",
"file",
"floor",
@ -476,6 +478,7 @@
"force",
"from",
"godot",
"identity",
"no-art",
"no-catalog",
"no-subscriptions",
@ -806,6 +809,7 @@
],
"ws": [
"aab",
"accounts",
"adgroup",
"against",
"agent",
@ -887,6 +891,7 @@
"hook",
"icon",
"icons",
"identity",
"include-drafts",
"internal",
"internal-only",
@ -1260,7 +1265,8 @@
"storebound listing live [--json] | set --store play|apple --lang <code> --fields '<json>' [--dry] the texts straight on"
],
"onboard": [
"storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art]"
"storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art]",
"storebound onboard --declarations [--write] only the template compliance answers and identity (no catalog, no art)"
],
"originality": [
"storebound originality [--against <folder>[,<folder>]] [--threshold 0.7] [--json]"

View file

@ -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))))

View file

@ -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

View 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 } };
}

View file

@ -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;
}

View file

@ -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'] : []),

View file

@ -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
View 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'],
};

View file

@ -1,6 +1,7 @@
// Onboarding an app that is already live on a store (after `storebound init` and `listing pull`): reads what the
// stores and the code prove and puts it into the project, so the owner is asked only what nothing proves.
// storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art]
// storebound onboard --declarations [--write] only the template compliance answers and identity (no catalog, no art)
// Read-only on both stores (GETs; a Play edit that is opened, read and deleted, never committed). Without --write it
// prints the plan; --write (never with --dry) changes only local files:
// 1. products / subscriptions in storebound.config.ts from the live catalog of both stores: ids, type, listings per
@ -16,6 +17,10 @@
// 5. the live store art (art pull): App Store screenshot sets, previews and icon, Play icon, feature graphic and
// screenshots into store/renders with pulled.json, so check / validate / uploads treat them like rendered art;
// local files that differ are kept (art pull --force replaces them).
// 6. the template compliance answers and identity `storebound init` wrote (cli/lib/template-answers.ts): name and
// copyright from the stores (a copyright with a private term, STOREBOUND_PRIVATE_TERMS / WAYBETTER_FORBIDDEN_TERMS,
// is left out), the template's Data safety / App Privacy / review notes / accounts / review access out, the
// answers the code proves in (scan's declaration proposal, sign-up methods), the rest stays in `unanswered`.
// Then the owner questions that are left (one batch).
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
@ -32,6 +37,8 @@ import { detectProject } from './lib/scan/project';
import { upsertBlock, upsertNestedString } from './lib/scan/config-edit';
import { playClient } from './play/client';
import { textTable } from './lib/table';
import { resetDeclarations, type StoreIdentity } from './lib/onboard-declarations';
import { privateTerms, TEMPLATE_NAMES } from './lib/template-answers';
const TAG = 'onboard';
const TIMEOUT_MS = 10_000;
@ -47,7 +54,7 @@ export const pageGet: PageGet = async url => {
} catch { return { status: null, text: '' }; } finally { clearTimeout(t); }
};
async function appleFacts(ctx: Ctx): Promise<NonNullable<StoreContact['apple']>> {
async function appleFacts(ctx: Ctx): Promise<NonNullable<StoreContact['apple']> & { name?: string; copyright?: string }> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const appId = await c.app();
@ -68,16 +75,19 @@ async function appleFacts(ctx: Ctx): Promise<NonNullable<StoreContact['apple']>>
const info = await c.editableAppInfo();
const infoLocs = info ? await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=50`) : [];
const il = infoLocs.find((x: J) => x.attributes.locale === primary) ?? infoLocs[0];
return { supportUrl, marketingUrl, privacyPolicyUrl: il?.attributes?.privacyPolicyUrl ?? undefined, review };
return { supportUrl, marketingUrl, privacyPolicyUrl: il?.attributes?.privacyPolicyUrl ?? undefined, review, name: il?.attributes?.name ?? undefined, copyright: v?.attributes?.copyright ?? undefined };
}
async function playFacts(ctx: Ctx): Promise<NonNullable<StoreContact['play']>> {
async function playFacts(ctx: Ctx): Promise<NonNullable<StoreContact['play']> & { title?: string }> {
const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName);
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
try {
const d = await play.call<{ contactEmail?: string; contactWebsite?: string; contactPhone?: string }>('GET', `${play.API}/edits/${e.id}/details`);
return { contactEmail: d.contactEmail, contactWebsite: d.contactWebsite, contactPhone: d.contactPhone };
const d = await play.call<{ contactEmail?: string; contactWebsite?: string; contactPhone?: string; defaultLanguage?: string }>('GET', `${play.API}/edits/${e.id}/details`);
const lang = d.defaultLanguage ?? config.play!.defaultLanguage ?? 'en-US';
// the title only when the config still has the template name (onboard replaces it with the store's)
const title = TEMPLATE_NAMES.has(config.app.name) ? (await play.call<{ title?: string }>('GET', `${play.API}/edits/${e.id}/listings/${lang}`).catch(() => null))?.title : undefined;
return { contactEmail: d.contactEmail, contactWebsite: d.contactWebsite, contactPhone: d.contactPhone, title };
} finally {
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
}
@ -94,8 +104,10 @@ function setProjectSecret(root: string, key: 'reviewContact' | 'reviewDemoAccoun
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, path: configPath } = ctx.loaded;
const { root, path: configPath } = ctx.loaded;
let config = ctx.loaded.config;
const a = ctx.args;
const declOnly = a.has('declarations');
const store = a.get('store');
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
const useApple = (!store || store === 'apple') && !!config.apple;
@ -108,7 +120,7 @@ export async function run(ctx: Ctx): Promise<void> {
const before = text;
// 1. catalog
if (!a.has('no-catalog')) {
if (!a.has('no-catalog') && !declOnly) {
log(TAG, 'reading the live products and subscriptions');
const read = async <T>(what: string, f: () => Promise<T>): Promise<T | null> => { try { return await f(); } catch (e) { warn(TAG, `${what}: ${(e as Error).message.slice(0, 300)}`); return null; } };
const apple: StoreItem[] | null = useApple ? await read('App Store products', () => appleItems(ctx)) : null;
@ -135,6 +147,22 @@ export async function run(ctx: Ctx): Promise<void> {
let appleF: Awaited<ReturnType<typeof appleFacts>> | null = null, playF: Awaited<ReturnType<typeof playFacts>> | null = null;
if (useApple) { try { appleF = await appleFacts(ctx); } catch (e) { warn(TAG, `App Store app details: ${(e as Error).message.slice(0, 300)}`); } }
if (usePlay) { try { playF = await playFacts(ctx); } catch (e) { warn(TAG, `Play app details: ${(e as Error).message.slice(0, 300)}`); } }
// 6. template compliance answers and identity (before the steps below read accounts / reviewAccess)
const facts = detectProject(root);
const termsRead = (f: string) => { try { return readFileSync(f, 'utf8'); } catch { return null; } };
const storeId: StoreIdentity = appleF?.name ? { name: appleF.name, copyright: appleF.copyright, source: 'App Store Connect' }
: { name: playF?.title, copyright: appleF?.copyright, source: playF?.title ? 'Google Play listing' : 'App Store Connect' };
const reset = resetDeclarations({ config, text, path: configPath, facts, store: storeId, terms: privateTerms(termsRead) });
text = reset.text;
config = reset.config;
report.declarations = { removed: reset.removed, proven: reset.proven, unanswered: reset.unanswered };
if (!json) {
for (const r of reset.removed) warn(TAG, `template answer out: ${r}`);
for (const p of reset.proven) log(TAG, `proven: ${p}`);
if (reset.unanswered.length) warn(TAG, `unanswered (check / validate block until the owner answers): ${reset.unanswered.join(', ')}`);
}
questions.push(...reset.questions);
const contact = contactProposal(config.app, { apple: appleF, play: playF });
report.contact = contact;
for (const c of contact) {
@ -147,7 +175,6 @@ export async function run(ctx: Ctx): Promise<void> {
if (rc && !json) log(TAG, `App Review contact ${maskContact(rc)}: ${haveRc ? 'already in .storebound/credentials.json (kept)' : 'into .storebound/credentials.json reviewContact'}`);
if (!rc && useApple && !haveRc) questions.push('App Review contact (first and last name, phone, e-mail): App Store Connect has none; put it in .storebound/credentials.json reviewContact');
const facts = detectProject(root);
const auth = authEvidence(facts);
const sign = signInDecision({ auth, apple: appleF?.review });
report.signIn = { signInRequired: sign.signInRequired, because: sign.because, demoAccountOnStore: !!sign.demoAccount, configured: config.reviewAccess?.signInRequired ?? null };
@ -178,7 +205,7 @@ export async function run(ctx: Ctx): Promise<void> {
// 5. the store art (icon, screenshots, previews, feature graphic) from the live stores into store/renders, so
// nothing has to be rendered again for an app that is already live (art pull; upsert, local files kept)
if (!a.has('no-art')) {
if (!a.has('no-art') && !declOnly) {
try {
const art = await artPull(ctx, { store, write, force: false, quiet: json });
report.art = { summary: art.summary, conflicts: art.conflicts, unmapped: art.unmapped, written: art.written, stores: art.stores };

View file

@ -1,5 +1,8 @@
// 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
@ -9,7 +12,8 @@ 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 { dataSafetyCsv, dataSafetyProblem, dataSafetyRows } from '../lib/data-safety';
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';
@ -36,11 +40,24 @@ function rememberUpload(root: string, pkg: string, sha256: string): void {
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)] : [])];
}
export async function run(ctx: Ctx): Promise<void> {
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 problem = dataSafetyProblem(dataSafetyRows(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);
@ -50,6 +67,11 @@ export async function run(ctx: Ctx): Promise<void> {
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;
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)');

View file

@ -13,6 +13,7 @@
// blockers stay blockers, high and medium risks are warnings, owner-accepted ones (scan.ignore) are left out. --no-scan skips.
// Live apps: crash / ANR vitals (Play Reporting API) and App Store diagnostics, as in `storebound health`: over a Play
// bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips.
import { templateBlockers } from './lib/template-answers';
import { ascClient, type J } from './apple/asc';
import { requiredDisplayTypes } from './apple/screenshots';
import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config';
@ -191,6 +192,8 @@ export async function run(ctx: Ctx): Promise<void> {
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); }
} else if (a.has('play')) warn('validate', 'no play section in the config');
// template compliance answers: the scan reports them; without the scan, here
if (a.has('no-scan')) for (const msg of templateBlockers(config)) findings.push({ store: config.apple ? 'apple' : 'play', level: 'blocker', code: 'template-answers', msg });
if (!a.has('no-scan')) {
log('validate', 'project scan (offline): rejection risks in the project files');
try {

View file

@ -13,7 +13,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| API | operations | GET | other | wrapped by a command | owner only |
|---|---:|---:|---:|---:|---:|
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 204 | 166 |
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 34 | 49 |
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 35 | 49 |
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
## App Store Connect API 4.5.1
@ -1597,7 +1597,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| **androidpublisher.edits.listings** (6) | | | | |
| `androidpublisher.edits.listings.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | owner only (typed confirm) | |
| `androidpublisher.edits.listings.deleteall` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | owner only (typed confirm) | |
| `androidpublisher.edits.listings.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | read | |
| `androidpublisher.edits.listings.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | read | onboard |
| `androidpublisher.edits.listings.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | read | art, listing-store, play/listing, play/status, validate |
| `androidpublisher.edits.listings.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store |
| `androidpublisher.edits.listings.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store, play/listing |

View file

@ -37,6 +37,7 @@ const config: ShipConfig = {
// `storebound scan` findings that are true for this fictional demo and accepted on purpose (README.md)
scan: {
ignore: [
{ rule: 'template-answers', reason: 'fictional demo app: its Example Studio copyright is the placeholder of a project that is never uploaded' },
{ rule: 'demo-account', reason: 'fictional demo app without a store record: there is no backend to sign in to, so no App Review demo account' },
{ rule: 'play-app-access', reason: 'fictional demo app without a store record: no Play App access credentials' },
{ rule: 'play-package-name', reason: 'fictional demo app: com.example.* is the repo placeholder and is never uploaded' },

View file

@ -22,6 +22,7 @@ const config: ShipConfig = {
// `storebound scan` findings that are true for this fictional demo and accepted on purpose (README.md)
scan: {
ignore: [
{ rule: 'template-answers', reason: 'fictional demo game: its Example Studio copyright is the placeholder of a project that is never uploaded' },
{ rule: 'play-package-name', reason: 'fictional demo game: com.example.* is the repo placeholder and is never uploaded' },
{ rule: 'play-testing-requirement', reason: 'fictional demo game without a Play developer account, so there is no account type to set' },
],

View file

@ -700,3 +700,15 @@ that fails without the fix (see CONTRIBUTING.md).
- **Guard:** `cli/art.ts`, `cli/lib/art-pull.ts` (`decide`, `pulledChecksum`, `storeEmptySets`), the pulled-file rules
in `scanRenders` (`cli/shots/report.ts`) and `check`, `tests/art-pull.test.ts` (recorded ASC screenshot sets with
`templateUrl` and Play `images.list`, the layout, the upsert and `--force`).
## 60. `init` + `onboard` on live apps left the template compliance answers in the config
- **Do:** treat every answer `storebound init` wrote as unanswered. The game template declares approximate location,
app interactions, diagnostics and device id as shared for advertising and `PSL_ACM_NONE`; pushing that from a live
app is a false declaration. Templates list the areas in `unanswered`; configs from before it are recognised by the
template values. `check`, `validate` and `scan` block; `play data-safety` (also `--dry`), `console plan` / `fill`,
`apple review-details` and `apple appinfo` refuse. For a live app run `storebound onboard --declarations --write`:
name and copyright from the stores (a copyright with a private term from `STOREBOUND_PRIVATE_TERMS` is left out),
the template answers out, the rows the code proves in (scan proposal, sign-up methods from auth code), the rest as
one owner question list. Remove an area from `unanswered` only after the owner answered it.
- **Guard:** `cli/lib/template-answers.ts`, `cli/lib/onboard-declarations.ts`, the `template-answers` scan rule,
`tests/template-answers.test.ts` (init blocks, data-safety --dry refuses, onboard on auth code without an ad SDK).

View file

@ -5,6 +5,11 @@
import type { ShipConfig } from 'storebound';
const config: ShipConfig = {
// Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block
// and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is
// already live: storebound onboard --declarations), answer the rest, then remove the area from this list.
unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'],
app: {
name: 'Example App',
profile: 'app',

View file

@ -5,6 +5,11 @@
import type { ShipConfig } from 'storebound';
const config: ShipConfig = {
// Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block
// and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is
// already live: storebound onboard --declarations), answer the rest, then remove the area from this list.
unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'],
app: {
name: 'Example App',
profile: 'subscription-app',

View file

@ -4,6 +4,11 @@
import type { ShipConfig } from 'storebound';
const config: ShipConfig = {
// Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block
// and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is
// already live: storebound onboard --declarations), answer the rest, then remove the area from this list.
unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'],
app: {
name: 'Example Game',
profile: 'game',

View file

@ -62,7 +62,8 @@ beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'sb-teaser-'));
expect(run(['init'], dir).code).toBe(0);
const cfg = join(dir, 'storebound.config.ts');
writeFileSync(cfg, readFileSync(cfg, 'utf8').replace(' dataSafety: {', ` products: [
// the teaser's app has answered its declarations (a fresh init lists them as unanswered and nothing is sent)
writeFileSync(cfg, readFileSync(cfg, 'utf8').replace(/^ unanswered: \[[^\]]*\],$/m, ' unanswered: [],').replace(' dataSafety: {', ` products: [
{ id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } },
{ id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } },
],

View file

@ -0,0 +1,115 @@
// Template compliance answers never reach a store: `storebound init` marks them unanswered, check blocks, play
// data-safety (also --dry), console plan, apple review-details / appinfo refuse; onboard replaces them with what the
// code proves (no advertising rows without an ad SDK, a real account answer with auth code) and the template identity
// with the store's. Plus the data-safety dry run's changed-rows diff.
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { parseArgs } from '../cli/lib/args';
import { loadConfig, type ShipConfig } from '../cli/lib/config';
import { dataSafetyDiff, dataSafetyRows } from '../cli/lib/data-safety';
import { resetDeclarations } from '../cli/lib/onboard-declarations';
import { removeKeyPath } from '../cli/lib/scan/config-edit';
import { detectProject } from '../cli/lib/scan/project';
import { templateAreas, templateRefusal } from '../cli/lib/template-answers';
import { run as init } from '../cli/init';
import { run as check } from '../cli/check';
import { changeLines, run as dataSafety } from '../cli/play/data-safety';
import { run as consolePlan } from '../cli/console/plan';
import { run as reviewDetails } from '../cli/apple/review-details';
import { capture } from './helpers/store-mock';
const tmp = () => mkdtempSync(join(tmpdir(), 'sb-tpl-'));
async function initProject(profile: string) {
const root = tmp();
await capture(() => init(parseArgs(['--dir', root, '--profile', profile])));
const loaded = await loadConfig(join(root, 'storebound.config.ts'));
return { root, loaded, ctx: (argv: string[]) => ({ loaded, args: parseArgs(argv) }) };
}
describe('template answers', () => {
test('init (every profile) marks the compliance answers unanswered and check blocks on them', async () => {
for (const profile of ['game', 'app', 'subscription-app']) {
const p = await initProject(profile);
expect(p.loaded.config.unanswered).toContain('dataSafety');
const areas = templateAreas(p.loaded.config).map(a => a.area);
expect(areas).toEqual(expect.arrayContaining(['identity', 'dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating']));
const r = await capture(() => check(p.ctx(['--json'])));
const out = JSON.parse(r.out.slice(r.out.indexOf('{\n'))) as { problems: string[] };
expect(out.problems.some(x => x.startsWith('Data safety / App Privacy still holds template answers') && x.includes('storebound scan --write'))).toBe(true);
expect(r.exit ?? process.exitCode).not.toBe(0);
}
});
test('play data-safety --dry, console plan apple-app-privacy and apple review-details refuse template answers', async () => {
const p = await initProject('game');
await expect(dataSafety(p.ctx(['--dry']))).rejects.toThrow(/refused: play data-safety --dry would send template answers.*Data safety.*Nothing was sent/);
await expect(dataSafety(p.ctx([]))).rejects.toThrow(/refused: play data-safety would send template answers/);
await expect(consolePlan(p.ctx(['apple-app-privacy']))).rejects.toThrow(/refused: console plan apple-app-privacy/);
await expect(reviewDetails(p.ctx(['--dry']))).rejects.toThrow(/refused: apple review-details.*App Review notes/);
});
test('a config from before `unanswered` is recognised by the template values; [] means answered', () => {
const legacy = { app: { name: 'Demo Blast', copyright: '2026 Example Studio' }, apple: { bundleId: 'x', info: { reviewNotes: 'mine', ageRating: { advertising: true } } },
dataSafety: { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_NONE'], data: {
PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },
PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },
PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] } } } } as unknown as ShipConfig;
expect(templateAreas(legacy).map(a => a.area)).toEqual(['identity', 'dataSafety', 'appPrivacy', 'ageRating']);
expect(templateRefusal(legacy, ['appPrivacy'], 'x')).toContain('Data safety');
const answered = { ...legacy, app: { ...legacy.app, copyright: '2026 Someone' }, unanswered: [] } as ShipConfig;
expect(templateAreas(answered)).toEqual([]);
});
test('onboard --declarations: no ad SDK + auth code gives no advertising rows, a real account answer, no template identity', async () => {
const p = await initProject('game');
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'demo', dependencies: { firebase: '10.0.0' } }),
'src/auth.ts': "import { getAuth, signInWithEmailAndPassword, createUserWithEmailAndPassword } from 'firebase/auth';\nexport const login = (e: string, pw: string) => signInWithEmailAndPassword(getAuth(), e, pw);\nexport const signUp = (e: string, pw: string) => createUserWithEmailAndPassword(getAuth(), e, pw);\nexport const form = { email: e0 };\nconst e0 = '';\n",
};
for (const [f, t] of Object.entries(files)) { mkdirSync(dirname(join(p.root, f)), { recursive: true }); writeFileSync(join(p.root, f), t); }
const path = p.loaded.path;
const r = resetDeclarations({ config: p.loaded.config, text: readFileSync(path, 'utf8'), path, facts: detectProject(p.root),
store: { name: 'Real Name', copyright: '2026 Hidden Co', source: 'App Store Connect' }, terms: ['hidden co'] });
const out = join(p.root, 'after.config.ts'); // a new path: the first import of the config is cached
writeFileSync(out, r.text);
const after = (await loadConfig(out)).config;
const rows = Object.entries(after.dataSafety!.data);
expect(rows.some(([, u]) => u.collect.includes('advertising') || (u.share ?? []).includes('advertising'))).toBe(false);
expect(after.dataSafety!.data.PSL_APPROX_LOCATION).toBeUndefined();
expect(after.dataSafety!.accountCreation).toEqual(['PSL_ACM_USER_ID_PASSWORD']);
expect(after.accounts).toMatchObject({ enabled: true, signUp: ['email-password'] });
expect(after.app.name).toBe('Real Name');
expect(after.app.copyright).toBeUndefined(); // the store's copyright has a private term: left out
expect(r.text).not.toContain('Example Game');
expect(after.apple?.info?.reviewNotes).toBeUndefined();
expect(after.unanswered).toEqual(expect.arrayContaining(['dataSafety', 'appPrivacy', 'ageRating']));
expect(r.questions.some(q => q.startsWith('Age rating'))).toBe(true);
expect(r.questions.some(q => /encryptedInTransit/.test(q))).toBe(true);
// still refused until the owner confirms
expect(templateRefusal(after, ['dataSafety'], 'x')).not.toBeNull();
});
test('removeKeyPath drops a nested expression value and its comments', () => {
const t = "const c = {\n app: { name: 'a' },\n apple: {\n info: {\n // notes\n reviewNotes: ['a', 'b'].join('\\n\\n'),\n free: true,\n },\n },\n};\n";
expect(removeKeyPath(t, 'x.ts', ['apple', 'info', 'reviewNotes'])).toBe("const c = {\n app: { name: 'a' },\n apple: {\n info: {\n free: true,\n },\n },\n};\n");
});
test('data-safety dry run diff: old -> new per question, account answers first', () => {
const ds = { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_NONE'], data: { PSL_DEVICE_ID: { shared: false, collect: ['analytics' as const] } } };
const before = dataSafetyRows(ds).map(r => r.join(',')).join('\n');
const next = { ...ds, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], data: { PSL_CRASH_LOGS: { shared: false, collect: ['analytics' as const] } } };
const changes = dataSafetyDiff(before, dataSafetyRows(next, { deletionUrl: 'https://example.org/delete' }));
expect(changes.some(c => c.response === 'PSL_ACM_NONE' && c.from === 'TRUE' && c.to === '')).toBe(true);
expect(changes.some(c => c.response === 'PSL_ACM_USER_ID_PASSWORD' && c.to === 'TRUE')).toBe(true);
expect(changes.some(c => c.question.includes('PSL_DEVICE_ID') && c.from && !c.to)).toBe(true);
const lines = changeLines(changes);
expect(lines[0]).toBe(' account and deletion answers:');
expect(lines.join('\n')).toMatch(/PSL_ACM_NONE\]: TRUE -> \(empty\)/);
expect(dataSafetyDiff(null, dataSafetyRows(ds)).length).toBeGreaterThan(2);
});
});