// `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(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 ") 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 } }; }