// Onboarding an app that is already live: everything the stores and the code can prove goes into the config, so the // owner is asked only for what nothing proves. Pure parts of `storebound onboard` (cli/onboard.ts reads the stores): // - importCatalog: the live one-time products and subscriptions of both stores (ids, type, listings per language, // the price in USD: the US price, Apple's base territory as an override when it is not the USA) as the config's // products / subscriptions, so `storebound catalog diff` comes out clean. Store prices are never changed. // - contactProposal: app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app // details and App Store Connect, replacing only template placeholders; the App Review contact for // .storebound/credentials.json (personal data: masked in every output). // - signInDecision: reviewAccess.signInRequired from Apple's review detail and the code's auth signals, else a // question for the owner. // - deletionCandidates / findDeletionUrl: an account deletion page on the app's own website, never invented. import { type Language, playCodes, type Product, type ProductType, type ShipConfig, type Subscription, type SubscriptionGroup, type SubscriptionPeriod } from './config'; import type { StoreItem } from './catalog'; import { isPlaceholder, maskUser, type ReviewContact } from './credentials'; import type { RemoteCatalog } from './listing-pull'; type Store = 'apple' | 'play'; export interface ImportNote { id: string; level: 'info' | 'warn'; msg: string } export interface CatalogImport { products: Product[]; subscriptions: SubscriptionGroup[]; /** ids added (not in the config before) / updated (price, type or listings changed) / kept as they were */ added: string[]; updated: string[]; notes: ImportNote[]; /** items that could not be written, with the reason (only when no store has a usable price or text) */ skipped: { id: string; why: string }[]; } const cents = (n: number) => Math.round(n * 100) / 100; const langOf = (langs: Language[], store: Store, code: string) => langs.find(l => (store === 'apple' ? l.apple === code : playCodes(l).includes(code)))?.id; const PERIODS: SubscriptionPeriod[] = ['P1W', 'P1M', 'P2M', 'P3M', 'P6M', 'P1Y']; /** The USD price a store item shows today: the US region, else the store's own USD price for other regions. */ export function usdOf(i: StoreItem | undefined): number | undefined { if (!i) return undefined; const us = i.prices.find(p => p.region === 'US' && p.currency === 'USD'); return us?.price ?? i.usd; } /** * The config's products and subscriptions from what both stores sell today. Items in the config keep everything that * is not on the store (review screenshots, notes, offers); their USD price, type, platforms and listings follow the * store. An item on one store only is kept and marked for that store (`platforms`). Pure. */ export function importCatalog(config: Pick, s: { apple: StoreItem[] | null; play: StoreItem[] | null; appleTexts?: RemoteCatalog; playTexts?: RemoteCatalog }): CatalogImport { const out: CatalogImport = { products: [], subscriptions: [], added: [], updated: [], notes: [], skipped: [] }; const note = (id: string, msg: string, level: ImportNote['level'] = 'info') => out.notes.push({ id, level, msg }); const both = !!config.apple && !!config.play; const byId = (xs: StoreItem[] | null) => new Map((xs ?? []).map(x => [x.id, x] as const)); const A = byId(s.apple), P = byId(s.play); const oldProducts = new Map((config.products ?? []).map(p => [p.id, p] as const)); const oldSubs = new Map((config.subscriptions ?? []).flatMap(g => g.subscriptions.map(x => [x.id, { group: g, sub: x }] as const))); const ids = [...new Set([...A.keys(), ...P.keys()])]; const groups = new Map(); for (const id of ids) { const a = A.get(id), p = P.get(id); const isSub = a ? a.kind === 'subscription' : p!.kind === 'subscription'; if (a && p && (a.kind === 'subscription') !== (p.kind === 'subscription')) { out.skipped.push({ id, why: `a subscription on one store and a one-time product on the other: one id cannot be both (the stores keep it; fix by hand)` }); continue; } // price: US first (Apple's, else Play's), else the store's own USD price for new regions const aUsd = usdOf(a), pUsd = usdOf(p); const usd = aUsd ?? pUsd; if (usd === undefined) { const base = a?.base ?? p?.prices[0]; out.skipped.push({ id, why: `no USD price on ${a && p ? 'either store' : a ? 'the App Store' : 'Google Play'}${base ? ` (base ${base.region} ${base.price} ${base.currency})` : ''}: the config prices in USD, so it is not written (catalog diff lists it as on the store only)` }); continue; } if (aUsd !== undefined && pUsd !== undefined && cents(aUsd) !== cents(pUsd)) note(id, `US price ${aUsd.toFixed(2)} on the App Store, ${pUsd.toFixed(2)} on Google Play: usd follows the App Store; catalog diff shows the Play difference (store prices are never changed here)`, 'warn'); // Apple's base territory (not the USA): kept as an override when every store that sells the item has that price const overrides: { region: string; currency: string; price: number }[] = []; if (a?.base && a.base.region !== 'US') { const same = !p || p.prices.some(x => x.region === a.base!.region && x.currency === a.base!.currency && cents(x.price) === cents(a.base!.price)); if (same) overrides.push({ region: a.base.region, currency: a.base.currency, price: a.base.price }); else note(id, `App Store base territory ${a.base.region} (${a.base.price} ${a.base.currency}) differs from Google Play there: not kept as an override`, 'warn'); } // listings: the config's, then Play's texts, then Apple's (Apple's limits are the config's) const old = isSub ? oldSubs.get(id)?.sub : oldProducts.get(id); const listings: Record = {}; for (const [lang, l] of Object.entries(old?.listings ?? {})) listings[lang] = { ...l }; for (const store of ['play', 'apple'] as const) { const texts = (store === 'apple' ? s.appleTexts : s.playTexts)?.[id] ?? {}; for (const [code, t] of Object.entries(texts)) { const lang = langOf(config.languages, store, code); if (!lang) { note(id, `${store} language ${code} is not in languages: its texts are left out`); continue; } if (!t.title) continue; if ([...t.title].length > 30) { note(id, `${store} ${code}: title "${t.title}" is over the config's 30 characters: kept the ${listings[lang] ? 'other store\'s' : 'store'} text out of the config`, 'warn'); continue; } listings[lang] = { ...listings[lang], title: t.title, description: t.description || listings[lang]?.description || '', ...(store === 'play' && t.benefits?.length ? { benefits: t.benefits } : {}) }; } } for (const [lang, l] of Object.entries(listings)) if (!l.description) { note(id, `${lang}: no description on the store: the title is used as the description`, 'warn'); l.description = l.title; } if (!Object.keys(listings).length) { out.skipped.push({ id, why: 'no title in any language of the config on the stores' }); continue; } const platforms = both && !(a && p) ? { platforms: [a ? 'ios' as const : 'android' as const] } : {}; if (both && !(a && p)) note(id, `only on ${a ? 'the App Store' : 'Google Play'}: kept and marked platforms: ['${a ? 'ios' : 'android'}']`); if (isSub) { const period = (a?.period ?? p?.period) as SubscriptionPeriod | undefined; if (!period || !PERIODS.includes(period)) { out.skipped.push({ id, why: `billing period ${period ?? 'unknown'} is not one the config supports (${PERIODS.join(', ')})` }); continue; } if (a?.period && p?.period && a.period !== p.period) note(id, `bills every ${a.period} on the App Store, ${p.period} on Google Play: period follows the App Store`, 'warn'); const prev = oldSubs.get(id); const sub: Subscription = { ...(prev?.sub ?? {}), id, period, level: a?.level ?? prev?.sub.level ?? 1, usd: cents(usd), listings, ...platforms, ...(overrides.length || prev?.sub.overrides ? { overrides: mergeOverrides(prev?.sub.overrides, overrides) } : {}) } as Subscription; if (!both || (a && p)) delete sub.platforms; const gname = a?.group ?? prev?.group.name ?? 'Subscriptions'; const g = groups.get(gname) ?? { ...(prev?.group ?? { name: gname, listings: {} }), name: gname, subscriptions: [] }; if (!Object.keys(g.listings).length) { const first = Object.keys(listings)[0]!; g.listings = { [first]: { name: gname } }; note(id, `group "${gname}": display name set to its reference name for ${first} (App Store group localizations are not read; edit if they differ)`); } g.subscriptions.push(sub); groups.set(gname, g); track(out, id, prev?.sub, sub); } else { const prev = oldProducts.get(id); let type: ProductType; if (a && a.kind !== 'one_time' && a.kind !== 'subscription') type = a.kind; else if (prev) type = prev.type; else { type = 'non_consumable'; note(id, 'Google Play does not say whether a one-time product is consumable: type non_consumable; change it to consumable if people buy it again and again', 'warn'); } const prod: Product = { ...(prev ?? {}), id, type, usd: cents(usd), listings: listings as Product['listings'], ...platforms, ...(overrides.length || prev?.overrides ? { overrides: mergeOverrides(prev?.overrides, overrides) } : {}) } as Product; if (!both || (a && p)) delete prod.platforms; out.products.push(prod); track(out, id, prev, prod); } } // config items no store has (not created yet): kept as they are for (const p of config.products ?? []) if (!A.has(p.id) && !P.has(p.id)) { out.products.push(p); note(p.id, 'in the config, on no store yet: kept'); } for (const g of config.subscriptions ?? []) for (const x of g.subscriptions) if (!A.has(x.id) && !P.has(x.id)) { const grp = groups.get(g.name) ?? { ...g, subscriptions: [] }; grp.subscriptions.push(x); groups.set(g.name, grp); note(x.id, 'in the config, on no store yet: kept'); } out.subscriptions = [...groups.values()]; return out; } function mergeOverrides(prev: { region: string; currency: string; price: number; territory?: string }[] | undefined, add: { region: string; currency: string; price: number }[]) { const m = new Map((prev ?? []).map(o => [o.region, o] as const)); for (const o of add) m.set(o.region, { ...m.get(o.region), ...o }); return [...m.values()]; } function track(out: CatalogImport, id: string, prev: unknown, next: unknown): void { if (!prev) out.added.push(id); else if (JSON.stringify(prev) !== JSON.stringify(next)) out.updated.push(id); } // ---- contact, URLs and the App Review contact ---- export interface StoreContact { play?: { contactEmail?: string; contactWebsite?: string; contactPhone?: string } | null; apple?: { supportUrl?: string; marketingUrl?: string; privacyPolicyUrl?: string; review?: AppleReviewDetail | null } | null; } /** appStoreReviewDetails attributes (App Store Connect), as read. */ export interface AppleReviewDetail { contactFirstName?: string | null; contactLastName?: string | null; contactPhone?: string | null; contactEmail?: string | null; demoAccountName?: string | null; demoAccountPassword?: string | null; demoAccountRequired?: boolean | null; notes?: string | null; } export interface FieldProposal { field: string; from: string | null; to: string; source: string; replace: boolean } /** A value that is a template placeholder (example.com / example.org, "your-…"), or missing. */ export const isTemplateValue = (v: string | undefined | null): boolean => // the templates (`storebound init`) write example.com: https://example.com/..., support@example.com !v || isPlaceholder(v, { example: false }) || /^(https?:\/\/)?(www\.)?example\.com(\/|$)/i.test(v.trim()) || /@example\.com$/i.test(v.trim()); const httpsUrl = (u: string | undefined | null): string | null => { const s = (u ?? '').trim(); if (!s) return null; const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`; return /^https:\/\/[^\s/]+\.[^\s/]+/i.test(withScheme) ? withScheme : null; }; /** * app.* values from the stores. `replace` is true only where the config holds a template value (example.com, * support@example.com) or nothing; a real value that differs from the store is reported, never overwritten. Pure. */ export function contactProposal(app: Partial, s: StoreContact): FieldProposal[] { const out: FieldProposal[] = []; const want = (field: keyof ShipConfig['app'], value: string | null | undefined, source: string) => { if (!value) return; if (out.some(o => o.field === field)) return; const cur = app[field] as string | undefined; if (cur === value) return; out.push({ field, from: cur ?? null, to: value, source, replace: isTemplateValue(cur) }); }; const email = s.play?.contactEmail?.trim(); if (email && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) want('contactEmail', email, 'Google Play app details (contactEmail)'); want('website', httpsUrl(s.play?.contactWebsite), 'Google Play app details (contactWebsite)'); want('website', httpsUrl(s.apple?.marketingUrl), 'App Store version (marketingUrl)'); want('supportUrl', httpsUrl(s.apple?.supportUrl), 'App Store version (supportUrl)'); want('supportUrl', httpsUrl(s.play?.contactWebsite), 'Google Play app details (contactWebsite)'); want('privacyPolicyUrl', httpsUrl(s.apple?.privacyPolicyUrl), 'App Store app information (privacyPolicyUrl)'); want('marketingUrl', httpsUrl(s.apple?.marketingUrl), 'App Store version (marketingUrl)'); return out; } /** The App Review contact from Apple's review detail, for .storebound/credentials.json (personal data). */ export function reviewContactOf(r: AppleReviewDetail | null | undefined): ReviewContact | null { if (!r?.contactEmail) return null; return { firstName: r.contactFirstName ?? '', lastName: r.contactLastName ?? '', phone: r.contactPhone ?? '', email: r.contactEmail }; } /** For logs: the name's initials, the masked e-mail, the phone as its last 2 digits only. Never the full values. */ export function maskContact(c: ReviewContact): string { const digits = c.phone.replace(/\D/g, ''); return `${c.firstName ? c.firstName[0] + '.' : ''}${c.lastName ? ` ${c.lastName[0]}.` : ''} ${maskUser(c.email)}${digits ? `, phone ***${digits.slice(-2)}` : ''}`.trim(); } /** * The App Store version whose review detail tells the truth about review access: the LIVE one (READY_FOR_SALE / * READY_FOR_DISTRIBUTION; the newest live one when several report it), else the newest version. A newer draft's empty * review detail must never hide the demo account the live version was reviewed with. */ export function reviewVersion(versions: T[]): T | null { const LIVE = new Set(['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION']); const newest = [...versions].sort((a, b) => (b.attributes?.createdDate ?? '').localeCompare(a.attributes?.createdDate ?? '')); return newest.find(v => LIVE.has(v.attributes?.appVersionState ?? '') || LIVE.has(v.attributes?.appStoreState ?? '')) ?? newest[0] ?? null; } // ---- sign-in for review ---- export interface SignInDecision { signInRequired: boolean | null; /** why, in one line */ because: string; /** Apple's review detail holds a demo account (name + password): import it, never print it */ demoAccount: { username: string; password: string } | null; /** the owner question, only when nothing proves it */ question: string | null; } /** * reviewAccess.signInRequired from what is proven: Apple's live review detail (demoAccountRequired, set by whoever * submitted the app and accepted by App Review) wins; else no auth code in the project means no sign-in; else * it is an owner question. Never defaults to yes. Pure. */ export function signInDecision(o: { auth: string[]; apple: AppleReviewDetail | null | undefined; configured?: boolean }): SignInDecision { const r = o.apple; const demo = r?.demoAccountName && r.demoAccountPassword ? { username: r.demoAccountName, password: r.demoAccountPassword } : null; if (r && r.demoAccountRequired === true) return { signInRequired: true, because: `App Store review detail: demo account required${demo ? ' (a demo account is set there)' : ' (but none is set there)'}`, demoAccount: demo, question: null }; if (r && r.demoAccountRequired === false) return { signInRequired: false, because: `App Store review detail: no demo account required${o.auth.length ? ` (the code has sign-in, ${o.auth[0]}, so it is optional for reviewers)` : ''}`, demoAccount: demo, question: null }; if (!o.auth.length) return { signInRequired: false, because: 'no sign-in or account code in the project (scan auth signals)', demoAccount: demo, question: null }; return { signInRequired: null, because: `the code has sign-in (${o.auth.slice(0, 2).join('; ')}) and no store says whether reviewers need it`, demoAccount: demo, question: 'Can a reviewer use every main feature without signing in? (no: reviewAccess.signInRequired true and a demo account in .storebound/credentials.json reviewDemoAccount)' }; } // ---- account deletion page ---- /** Pages to try on the app's own site, in order (Play's account deletion requirement: a web page to request it). */ export function deletionCandidates(urls: (string | null | undefined)[]): string[] { const out: string[] = []; for (const u of urls) { let base: URL; try { base = new URL(u ?? ''); } catch { continue; } if (base.protocol !== 'https:' || isTemplateValue(base.href)) continue; for (const p of ['/delete-account', '/account/delete', '/delete', '/account-deletion', '/delete-account.html']) { const c = new URL(p, base.origin).href; if (!out.includes(c)) out.push(c); } } return out; } const DELETE_WORDS = /delet|deletion|remove (my|your) (account|data)|hesab[ıi]n[ıi]? sil|konto l[öo]schen|supprim/i; /** Links on a page (privacy policy) whose text or address speaks of deleting an account or data. Pure. */ export function deletionLinks(html: string, pageUrl: string): string[] { const out: string[] = []; for (const m of html.matchAll(/]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)) { const href = m[1]!, text = m[2]!.replace(/<[^>]+>/g, ' '); if (!DELETE_WORDS.test(text) && !DELETE_WORDS.test(href)) continue; let abs: string; try { abs = new URL(href, pageUrl).href; } catch { continue; } if (!/^https:/.test(abs) || out.includes(abs)) continue; out.push(abs); } return out; } export interface DeletionResult { url: string | null; how: string; tried: string[] } /** GET of a page: status and text (a timeout or network error is status null). */ export type PageGet = (url: string) => Promise<{ status: number | null; text: string; finalUrl?: string }>; /** * Looks for an account deletion page: the usual paths on the website and the privacy policy's host, then links in the * privacy policy page that mention delete or deletion, then an anchor on the privacy page itself (#delete...). A * page counts only when it answers 200 and speaks of deleting. Never invents a URL. */ export async function findDeletionUrl(o: { website?: string | null; privacyUrl?: string | null }, get: PageGet): Promise { const tried: string[] = []; const ok = async (u: string) => { tried.push(u); const r = await get(u).catch(() => ({ status: null, text: '' })); return r.status === 200 && DELETE_WORDS.test(r.text.replace(//gi, '')) ? r : null; }; for (const c of deletionCandidates([o.website, o.privacyUrl])) if (await ok(c)) return { url: c, how: `the page ${new URL(c).pathname} on the site answers and speaks of deletion`, tried }; if (o.privacyUrl && !isTemplateValue(o.privacyUrl)) { tried.push(o.privacyUrl); const page = await get(o.privacyUrl).catch(() => ({ status: null, text: '' })); if (page.status === 200) { for (const l of deletionLinks(page.text, o.privacyUrl)) { if (l.split('#')[0] === o.privacyUrl.split('#')[0]) return { url: l, how: 'a section of the privacy policy linked as account / data deletion', tried }; if (await ok(l)) return { url: l, how: 'linked from the privacy policy as account / data deletion', tried }; } const anchor = /\bid\s*=\s*["']([^"']*delet[^"']*)["']/i.exec(page.text)?.[1]; if (anchor) return { url: `${o.privacyUrl.split('#')[0]}#${anchor}`, how: 'a deletion section of the privacy policy page', tried }; } } return { url: null, how: 'not found: a real gap, Play requires a web page to request account deletion (account-deletion skill)', tried }; }