145 lines
9.2 KiB
TypeScript
145 lines
9.2 KiB
TypeScript
// 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'],
|
|
};
|