// `storebound console verify
`: checks what a console form shows against the config's answers, read-only. Pure // (the command in cli/console/verify.ts feeds it page text, an input-state JSON or API attributes). // Sources per form: // apple-app-privacy page text only (the App Store Connect API has no App Privacy endpoint): the Data Types // section after each type was saved shows "Edit" and a summary per type: "Used for ", // "Linked to the user’s identity" / "Not linked …", "Used for tracking purposes" / "Not used …" // (verified 2026-10-12). A summary without a linked / tracking line is read as No (marked). // play-app-record the Create app form's input states (JSON from the plan's capture snippet: text fields, the // checked [role=radio] labels, the page text for "Select …" hints) or plain page text // apple-app-record the API (the app by bundle id: name, primary locale, SKU) or page text // apple-age-rating the API (ageRatingDeclaration) // play-content-rating, play-target-audience page text (summary pages: a label line, its answer on the same or the // next line). Modeled, not recorded: rows that cannot be found are "not found", never a match. import type { ShipConfig } from './config'; import { type Answer, consoleAnswers, type FormId, valueText } from './console-answers'; import { APPLE_PRIVACY_TYPES } from './app-privacy'; import { typeLabels } from './console-forms'; import { APPLE_PURPOSES } from './console-answers'; export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner'; export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string } export interface VerifyResult { form: FormId; source: 'page-text' | 'inputs' | 'api'; rows: VerifyRow[]; mismatches: number; matched: number; /** true when nothing mismatches and at least one row matched. */ ok: boolean; notes: string[]; } const lines = (text: string): string[] => text.normalize('NFC').split(/\r?\n/).map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean); /** Straight and curly quotes as one, case and spaces aside: for comparing labels. */ export const fold = (s: string): string => s.normalize('NFC').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim().toLowerCase(); function result(form: FormId, source: VerifyResult['source'], rows: VerifyRow[], notes: string[] = []): VerifyResult { const mismatches = rows.filter(r => r.status === 'mismatch').length, matched = rows.filter(r => r.status === 'match').length; return { form, source, rows, mismatches, matched, ok: mismatches === 0 && matched > 0, notes }; } const yn = (v: boolean | null | undefined): string => (v === true ? 'Yes' : v === false ? 'No' : '?'); // ---- App Privacy ---- export interface PrivacyTypeSeen { /** "Set Up " is still shown: not answered yet. */ setUp: boolean; /** Purposes from "Used for …" (Apple's names), null when no such line. */ purposes: string[] | null; linked: boolean | null; tracking: boolean | null; /** linked / tracking were not shown and are read as No. */ inferred: string[]; } /** Every console label of a data type -> Apple's name (Purchases -> Purchase History, Other Data -> Other Data Types). */ const TYPE_OF = new Map(Object.values(APPLE_PRIVACY_TYPES).flatMap(([, n]) => typeLabels(n).map(l => [fold(l), n] as [string, string]))); const PURPOSES_FOLDED = APPLE_PURPOSES.map(p => [fold(p), p] as const); /** The per-type summaries of App Privacy's Data Types section (innerText of the page or of the section). */ export function parseAppPrivacy(text: string): Map { let ls = lines(text); const at = ls.findIndex(l => fold(l) === 'data types'); if (at >= 0) ls = ls.slice(at + 1); const out = new Map(); const entry = (t: string) => { let e = out.get(t); if (!e) { e = { setUp: false, purposes: null, linked: null, tracking: null, inferred: [] }; out.set(t, e); } return e; }; let cur: string | null = null; for (const l of ls) { const f = fold(l); const set = /^set up (.+)$/.exec(f); if (set && TYPE_OF.has(set[1]!)) { cur = TYPE_OF.get(set[1]!)!; entry(cur).setUp = true; continue; } if (TYPE_OF.has(f)) { cur = TYPE_OF.get(f)!; continue; } if (!cur) continue; if (/^used for (?!tracking)/.test(f)) { const rest = f.slice('used for '.length); entry(cur).purposes = PURPOSES_FOLDED.filter(([k]) => rest.includes(k)).map(([, p]) => p); } else if (/^not linked to the user/.test(f)) entry(cur).linked = false; else if (/^linked to the user/.test(f)) entry(cur).linked = true; else if (/^not used for tracking/.test(f)) entry(cur).tracking = false; else if (/^used for tracking/.test(f)) entry(cur).tracking = true; } // a type with a summary but without a linked / tracking line: the console shows only what applies for (const e of out.values()) { if (e.purposes === null || e.setUp) continue; if (e.linked === null) { e.linked = false; e.inferred.push('linked'); } if (e.tracking === null) { e.tracking = false; e.inferred.push('tracking'); } } // a label followed by nothing (a category line equal to a type name, "Purchases" above "Set Up Purchase History") for (const [t, e] of [...out]) if (!e.setUp && e.purposes === null && e.linked === null && e.tracking === null) out.delete(t); return out; } export function verifyAppPrivacy(config: ShipConfig, text: string): VerifyResult { const answers = consoleAnswers('apple-app-privacy', config); const by = new Map(answers.map(a => [a.id, a])); const seen = parseAppPrivacy(text); const rows: VerifyRow[] = []; const notes = ['The App Store Connect API has no App Privacy endpoint: checked from the page text.']; const types = answers.filter(a => a.id.startsWith('type:')); for (const a of types) { const t = a.id.slice(5), s = seen.get(t); if (a.value === false) { if (s) rows.push({ item: t, expected: 'not collected', seen: s.setUp ? 'listed (Set Up)' : 'listed with a summary', status: a.certain ? 'mismatch' : 'ask-owner' }); continue; } if (a.value === null) { if (s) rows.push({ item: t, expected: '(owner decides)', seen: 'listed', status: 'ask-owner', ...(a.note ? { note: a.note } : {}) }); continue; } if (!s) { rows.push({ item: t, expected: 'collected', seen: 'not on the page', status: a.certain ? 'mismatch' : 'ask-owner' }); continue; } if (s.setUp) { rows.push({ item: t, expected: 'set up', seen: `"Set Up ${t}" still shown`, status: 'mismatch' }); continue; } const purposes = answers.filter(x => x.id.startsWith(`purpose:${t}:`)); const want = purposes.filter(x => x.value === true).map(x => x.id.split(':')[2]!); const unsure = purposes.some(x => !x.certain); const got = s.purposes ?? []; const same = want.length === got.length && want.every(p => got.includes(p)); rows.push({ item: `${t}: purposes`, expected: want.join(', ') || '(none)', seen: s.purposes ? got.join(', ') || '(none)' : 'no "Used for" line', status: s.purposes === null ? 'not-found' : unsure && !same ? 'ask-owner' : same ? 'match' : 'mismatch' }); for (const k of ['linked', 'tracking'] as const) { const x = by.get(`${k}:${t}`); const v = s[k]; const label = k === 'linked' ? 'linked to the user' : 'used for tracking'; const inferred = s.inferred.includes(k) ? 'not shown, read as No' : undefined; if (!x || x.value === null) rows.push({ item: `${t}: ${label}`, expected: '(owner decides)', seen: yn(v), status: 'ask-owner', ...(inferred ? { note: inferred } : {}) }); else rows.push({ item: `${t}: ${label}`, expected: yn(x.value as boolean), seen: yn(v), status: v === null ? 'not-found' : v === x.value ? 'match' : 'mismatch', ...(inferred ? { note: inferred } : {}) }); } } return result('apple-app-privacy', 'page-text', rows, notes); } // ---- Play Create app ---- /** The input states the plan's capture snippet returns. */ export interface InputStates { /** Text inputs by their label (or aria-label). */ fields?: Record; /** Labels of the [role=radio] elements with aria-checked=true (and checked native radios). */ checked?: string[]; /** The visible page text (for "Select …" hints and availability messages). */ text?: string; } /** The capture snippet an agent runs in the page for `console verify play-app-record --from-text -` (prints JSON). */ export const PLAY_CREATE_CAPTURE = `(() => { const t = e => (e?.innerText ?? e?.textContent ?? '').trim().replace(/\\s+/g, ' '); const name = e => e.getAttribute('aria-label') || t(e.labels?.[0]) || (e.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => t(document.getElementById(id))).join(' ').trim() || t(e.closest('label')) || t(e.parentElement); const fields = {}; for (const i of document.querySelectorAll('input:not([type]), input[type=text], textarea')) if (i.offsetParent) fields[name(i)] = i.value; const checked = [...document.querySelectorAll('[role=radio][aria-checked=true], input[type=radio]:checked')].map(r => name(r) || t(r.closest('material-radio'))); return JSON.stringify({ fields, checked, text: (document.querySelector('main') ?? document.body).innerText }); })()`; export function parseInputStates(raw: string): InputStates | null { const s = raw.trim(); if (!s.startsWith('{')) return null; try { const j = JSON.parse(s) as Record; const fields = j.fields && typeof j.fields === 'object' ? Object.fromEntries(Object.entries(j.fields as Record).filter(([, v]) => typeof v === 'string')) as Record : undefined; const checked = Array.isArray(j.checked) ? j.checked.filter((x): x is string => typeof x === 'string') : undefined; return { ...(fields ? { fields } : {}), ...(checked ? { checked } : {}), ...(typeof j.text === 'string' ? { text: j.text } : {}) }; } catch { return null; } } /** A "Select …" hint the Create app form shows while a radio group has no value in its model ("Select app or game"). */ export const SELECT_HINT = /^select (app or game|free or paid|an? .+)$/i; export function verifyPlayAppRecord(config: ShipConfig, raw: string): VerifyResult { const answers = consoleAnswers('play-app-record', config).filter(a => !a.agreement); const st = parseInputStates(raw); const text = st?.text ?? (st ? '' : raw); const ls = lines(text); const rows: VerifyRow[] = []; const fieldValue = (labels: string[]): string | null => { if (st?.fields) for (const [k, v] of Object.entries(st.fields)) if (labels.some(l => fold(k).startsWith(fold(l)))) return v; return null; }; const checked = (st?.checked ?? []).map(fold); for (const a of answers) { const exp = a.value === null ? null : String(a.value); if (a.id === 'name' || a.id === 'packageName') { const labels = a.id === 'name' ? ['App name', 'Uygulama adı'] : ['App package name', 'Package name', 'Uygulama paket adı']; const v = fieldValue(labels); const inText = v === null && exp !== null && ls.some(l => l === exp || l.endsWith(` ${exp}`)); const seen = v ?? (inText ? exp! : null); rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: seen ?? 'not found', status: exp === null ? 'ask-owner' : seen === null ? 'not-found' : seen === exp ? 'match' : 'mismatch' }); } else if (a.id === 'type' || a.id === 'price') { const opts = a.id === 'type' ? ['App', 'Game'] : ['Free', 'Paid']; const got = st?.checked ? opts.filter(o => checked.some(c => c === fold(o) || c.startsWith(`${fold(o)} `))) : []; const seen = got.length === 1 ? got[0]! : got.length ? got.join(' + ') : null; rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: seen ?? (st?.checked ? 'nothing selected' : 'not readable from page text: pass the input states (plan capture)'), status: exp === null ? 'ask-owner' : seen === null ? (st?.checked ? 'mismatch' : 'not-found') : seen === exp ? 'match' : 'mismatch' }); } else if (a.id === 'language') { const v = fieldValue(['Default language', 'Varsayılan dil']); const inText = exp !== null && ls.some(l => l.includes(exp)); rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: v ?? (inText ? exp! : 'not found'), status: exp === null ? 'ask-owner' : v !== null ? (v.includes(exp) ? 'match' : 'mismatch') : inText ? 'match' : 'not-found' }); } } const hints = ls.filter(l => SELECT_HINT.test(l)); rows.push({ item: '"Select …" hints (a radio set on the input but not in the form\'s model)', expected: 'none', seen: hints.length ? hints.join('; ') : 'none', status: hints.length ? 'mismatch' : text ? 'match' : 'not-found', ...(hints.length ? { note: 'click the inner [role=radio] of that material-radio (a JS click), then check again' } : {}) }); if (ls.some(l => /package name (is )?available/i.test(l))) rows.push({ item: 'Check availability', expected: 'Package name available', seen: 'Package name available', status: 'match' }); return result('play-app-record', st ? 'inputs' : 'page-text', rows, st ? [] : ['Page text only: radios cannot be read from text. For a full check pass the JSON of the plan\'s capture snippet.']); } // ---- label / answer pairs from page text (summaries) ---- /** The answer next to a label: on the same line after the label, or the next line. */ function valueAfter(ls: string[], label: RegExp): string | null { for (let i = 0; i < ls.length; i++) { const l = ls[i]!; const m = label.exec(l); if (!m) continue; const rest = l.slice(m.index + m[0].length).replace(/^[\s:–-]+/, '').trim(); return rest || ls[i + 1] || null; } return null; } const yesNoOf = (s: string | null): boolean | null => (s === null ? null : /^(yes|evet)\b/i.test(s) ? true : /^(no|hayır)\b/i.test(s) ? false : null); function labelRows(answers: Answer[], ls: string[], labelOf: (a: Answer) => RegExp | null): VerifyRow[] { const rows: VerifyRow[] = []; for (const a of answers) { if (a.agreement) continue; const re = labelOf(a); if (!re) continue; const raw = valueAfter(ls, re); if (typeof a.value === 'boolean' || a.value === null && raw !== null && yesNoOf(raw) !== null) { const v = yesNoOf(raw); rows.push({ item: a.label, expected: a.value === null ? '(owner decides)' : yn(a.value as boolean), seen: v === null ? (raw ?? 'not found') : yn(v), status: a.value === null ? 'ask-owner' : v === null ? 'not-found' : v === a.value ? 'match' : 'mismatch' }); } else { const exp = a.value === null ? null : String(a.value); rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: raw ?? 'not found', status: exp === null ? 'ask-owner' : raw === null ? 'not-found' : fold(raw).includes(fold(exp)) ? 'match' : 'mismatch' }); } } return rows; } const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export function verifyContentRating(config: ShipConfig, text: string): VerifyResult { const answers = consoleAnswers('play-content-rating', config).filter(a => a.id !== 'email'); const TOPIC: Record = { violence: /violen/i, fear: /(fear|horror|scary)/i, sexuality: /(sexual|nudity)/i, language: /(profan|crude language)/i, controlledSubstances: /(drug|alcohol|tobacco|controlled substance)/i, crudeHumor: /crude humou?r/i, gambling: /real[- ](money|currency)/i, simulatedGambling: /simulated gambling/i, lootBoxes: /(loot box|random(ized)? (paid )?items)/i, userInteraction: /(interact|communicate|exchange)/i, sharesLocation: /location/i, digitalPurchases: /digital (goods|purchases)|purchase digital/i, unrestrictedInternet: /(unrestricted (internet|web)|web browser)/i, }; const rows = labelRows(answers, lines(text), a => (a.id === 'category' ? /^(category|app category)\b/i : a.id.startsWith('q:') ? TOPIC[a.id.slice(2)] ?? null : null)); return result('play-content-rating', 'page-text', rows, ['Modeled summary wording: a question that is not found is reported, never counted as a match.']); } export function verifyTargetAudience(config: ShipConfig, text: string): VerifyResult { const answers = consoleAnswers('play-target-audience', config); const ls = lines(text); const rows: VerifyRow[] = []; const ages = answers.filter(a => a.id.startsWith('age:')); const blob = fold(ls.join('\n')); if (ages.length) { const want = ages.filter(a => a.value === true).map(a => a.label.replace(/^Target age: /, '')); const shown = ages.map(a => a.label.replace(/^Target age: /, '')).filter(l => blob.includes(fold(l)) || blob.includes(fold(l.replace(/^Ages /, '')))); const unsure = ages.some(a => !a.certain); const same = want.length === shown.length && want.every(w => shown.includes(w)); rows.push({ item: 'Target age groups', expected: unsure ? '(owner decides)' : want.join(', '), seen: shown.join(', ') || 'not found', status: unsure ? 'ask-owner' : !shown.length ? 'not-found' : same ? 'match' : 'mismatch' }); } rows.push(...labelRows(answers.filter(a => a.id === 'appeal'), ls, () => /appeal to children/i)); const ads = answers.find(a => a.id === 'ads'); if (ads) { const no = ls.some(l => /(does not|doesn't) contain ads|no,? (my app )?(does not|doesn't) contain ads|^no ads$/i.test(l)); const yes = !no && ls.some(l => /contains ads|^ads$|yes,? my app contains ads/i.test(l)); const seen = no ? false : yes ? true : null; rows.push({ item: ads.label, expected: ads.value === null ? '(owner decides)' : yn(ads.value as boolean), seen: yn(seen), status: ads.value === null ? 'ask-owner' : seen === null ? 'not-found' : seen === ads.value ? 'match' : 'mismatch' }); } return result('play-target-audience', 'page-text', rows, ['Modeled summary wording: rows that are not found are reported, never counted as a match.']); } export function verifyAppRecordText(config: ShipConfig, text: string): VerifyResult { const answers = consoleAnswers('apple-app-record', config).filter(a => ['name', 'language', 'bundleId', 'sku'].includes(a.id)); const rows = labelRows(answers, lines(text), a => new RegExp(`^${escRe(a.label)}\\b`, 'i')); return result('apple-app-record', 'page-text', rows); } /** apple-app-record against the API's app attributes (GET /v1/apps?filter[bundleId]). null: no such app. */ export function verifyAppRecordApi(config: ShipConfig, app: { name?: string; bundleId?: string; sku?: string; primaryLocale?: string } | null): VerifyResult { const answers = consoleAnswers('apple-app-record', config); const by = new Map(answers.map(a => [a.id, a])); if (!app) return result('apple-app-record', 'api', [{ item: 'App record', expected: `an app with bundle id ${config.apple?.bundleId ?? '?'}`, seen: 'none', status: 'mismatch' }]); const locale = config.apple?.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? null; const row = (item: string, exp: string | null, seen: string | undefined, certain = true): VerifyRow => ({ item, expected: exp ?? '(owner decides)', seen: seen ?? 'not set', status: exp === null || !certain ? 'ask-owner' : seen === undefined ? 'not-found' : seen === exp ? 'match' : 'mismatch' }); const name = by.get('name'), sku = by.get('sku'); return result('apple-app-record', 'api', [ row('Bundle ID', config.apple?.bundleId ?? null, app.bundleId), row('Name', name?.value === null || name === undefined ? null : String(name.value), app.name), row('Primary Language (locale)', locale, app.primaryLocale), row('SKU', sku?.certain ? String(sku.value) : null, app.sku), ]); } /** apple-age-rating against the API's ageRatingDeclaration attributes. */ export function verifyAgeRatingApi(config: ShipConfig, attrs: Record | null): VerifyResult { const answers = consoleAnswers('apple-age-rating', config); if (!attrs) return result('apple-age-rating', 'api', [{ item: 'Age rating declaration', expected: 'set', seen: 'none', status: 'mismatch' }]); const rows = answers.map(a => { const seen = attrs[a.id]; const s = seen === undefined || seen === null ? undefined : String(seen); return { item: a.id, expected: a.certain ? valueText(a) : '(owner decides)', seen: s ?? 'not set', status: (!a.certain ? 'ask-owner' : s === undefined ? 'not-found' : s === String(a.value) ? 'match' : 'mismatch') as RowStatus }; }); return result('apple-age-rating', 'api', rows); } /** Page-text verification of any form that has one. */ export function verifyText(form: FormId, config: ShipConfig, text: string): VerifyResult { switch (form) { case 'apple-app-privacy': return verifyAppPrivacy(config, text); case 'play-app-record': return verifyPlayAppRecord(config, text); case 'play-content-rating': return verifyContentRating(config, text); case 'play-target-audience': return verifyTargetAudience(config, text); case 'apple-app-record': return verifyAppRecordText(config, text); case 'apple-age-rating': throw new Error('console verify apple-age-rating reads the App Store Connect API (no --from-text)'); } } export function verifyTable(r: VerifyResult): string { const w = Math.min(48, Math.max(4, ...r.rows.map(x => x.item.length))); const L = [`${r.form} (${r.source})`, '']; for (const x of r.rows) L.push(` ${x.status.padEnd(9)} ${x.item.padEnd(w)} expected ${x.expected} | page ${x.seen}${x.note ? ` (${x.note})` : ''}`); L.push('', `${r.matched} match, ${r.mismatches} mismatch, ${r.rows.filter(x => x.status === 'not-found').length} not found, ${r.rows.filter(x => x.status === 'ask-owner').length} for the owner`, ...r.notes.map(n => `note: ${n}`)); if (!r.ok) L.push(r.mismatches ? 'MISMATCH: fix the page (or the config), then verify again.' : 'Nothing could be matched: pass the page text of the finished form.'); return L.join('\n'); }