// 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)[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): 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 = { 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(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 = 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 = { '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'], };