storebound/cli/lib/adattribution.ts
CRT d972c36fa8 Merge store-ops: TestFlight wait / expire, IAP review submissions, pulls, offers, CPP, events, reports, triage, ASO rank, ads scan
Conflicts resolved for both sides:
- cli/lib/plist.ts, cli/lib/zip.ts: one implementation each. plist reads XML (text or
  bytes, attributes tolerated) and binary plists; ads scan now reads bytes, so a binary
  Info.plist works too. zip gains unzip() for the Play report exports on top of
  zipEntries / zipRead.
- apple upload waits with testflight wait's loop (waitForBuild) instead of its own copy;
  BUILD_DONE removed.
- config, credentials (profiles, keystore, ASC_VENDOR_NUMBER, now also a profile key),
  command table, MCP tools, credentials template, README, CHANGELOG: union.
- MCP: --write stays forbidden; the three pull tools are the listed local-file exception
  (LOCAL_WRITE_TOOLS), so scan --write remains unreachable.
- Adapters and API coverage tables regenerated.
2026-10-12 00:07:26 +08:00

172 lines
15 KiB
TypeScript

// AdAttributionKit / SKAdNetwork checks of an iOS app's Info.plist and sources, as pure rules. The command
// (cli/ads.ts scan) finds the files, parses them (cli/lib/plist.ts) and prints the findings. Text checks only.
// Sources (Apple developer documentation, checked October 2026):
// https://developer.apple.com/documentation/adattributionkit
// Publisher app: "add the ad network IDs" under `AdNetworkIdentifiers` (array of strings); "Lowercase the ad network ID
// string; otherwise, the system doesn't recognize it as a valid network."
// https://developer.apple.com/documentation/adattributionkit/configuring-a-publisher-app
// https://developer.apple.com/documentation/bundleresources/information-property-list/adnetworkidentifiers
// SKAdNetwork: `SKAdNetworkItems`, one dictionary with `SKAdNetworkIdentifier` per network; "Ad network IDs are
// case-sensitive and are in lowercase." `.adattributionkit` and `.skadnetwork` ids work with both frameworks.
// https://developer.apple.com/documentation/bundleresources/information-property-list/skadnetworkitems
// https://developer.apple.com/documentation/adattributionkit/adattributionkit-skadnetwork-interoperability
// Advertised app: copies of winning postbacks with `AttributionCopyEndpoint` (Xcode shows it as "AdAttributionKit -
// Postback Copy URL"; Apple's property list reference lists the key at the top level, Xcode's editor files it under
// an `AdAttributionKit` dictionary: both are read here), "a valid URL in the format of https://example.com"; the
// system uses the registrable domain and posts to https://<domain>/.well-known/appattribution/report-attribution/.
// Re-engagement copies: `EligibleForAdAttributionKitReengagementPostbackCopies` (Boolean YES). The app has to call
// updateConversionValue at least once to start a postback's conversion window.
// https://developer.apple.com/documentation/adattributionkit/configuring-an-advertised-app
// https://developer.apple.com/documentation/bundleresources/information-property-list/attributioncopyendpoint
// SKAdNetwork postback copies and Private Click Measurement: `NSAdvertisingAttributionReportEndpoint`.
// https://developer.apple.com/documentation/bundleresources/information-property-list/nsadvertisingattributionreportendpoint
// Attribution rules: `AdAttributionKitConfigurations` > `AttributionWindows` > `global` | <ad network id> > `install` >
// `click` (1-30 days) / `view` (1-7 days) / `ignoreInteractionType` (view | click; ad network entries only);
// `AttributionCooldown` > `install-cooldown-hours` / `re-engagement-cooldown-hours` (0-720).
// https://developer.apple.com/documentation/adattributionkit/configuring-attribution-rules-for-your-app
// AdAttributionKit needs no App Tracking Transparency prompt.
import type { PlistValue } from './plist';
export type AakLevel = 'blocker' | 'warn' | 'ok' | 'info';
export interface AakFinding { level: AakLevel; code: string; msg: string }
type Dict = { [k: string]: PlistValue };
const isDict = (v: PlistValue | undefined): v is Dict => !!v && typeof v === 'object' && !Array.isArray(v) && !Buffer.isBuffer(v);
const ID_RE = /^[a-z0-9]+\.(skadnetwork|adattributionkit)$/;
export interface SourceSignals {
/** `import AdAttributionKit` (or the framework linked). */
aakImport: boolean;
/** updateConversionValue / updatePostbackConversionValue calls (app code or an attribution SDK). */
conversionUpdates: boolean;
/** Ad SDKs found (Podfile.lock, Package.resolved, frameworks): these show ads, so the app is a publisher. */
adSdks: string[];
/** Mobile measurement SDKs that update conversion values for you (AppsFlyer, Adjust, Branch, Singular, Kochava). */
mmpSdks: string[];
}
export const AD_SDKS: [RegExp, string][] = [
[/Google-Mobile-Ads-SDK|GoogleMobileAds|swift-package-manager-google-mobile-ads/i, 'Google Mobile Ads (AdMob)'], [/AppLovinSDK|applovin-ios-sdk/i, 'AppLovin MAX'],
[/IronSourceSDK|IronSource|LevelPlay/i, 'Unity LevelPlay / ironSource'], [/UnityAds/i, 'Unity Ads'], [/FBAudienceNetwork/i, 'Meta Audience Network'],
[/Vungle|LiftoffMonetize/i, 'Liftoff Monetize'], [/InMobiSDK/i, 'InMobi'], [/MintegralAdSDK/i, 'Mintegral'], [/Pangle|Ads-Global|PAGAdSDK/i, 'Pangle'], [/ChartboostSDK/i, 'Chartboost'],
];
export const MMP_SDKS: [RegExp, string][] = [[/AppsFlyer/i, 'AppsFlyer'], [/\bAdjust(Sdk|Signature)?\b/i, 'Adjust'], [/\bBranch\b|BranchSDK/i, 'Branch'], [/Singular/i, 'Singular'], [/KochavaNetworking|KochavaTracker/i, 'Kochava']];
/** Ad / MMP SDK names and AdAttributionKit signals in dependency lock files and sources. Pure. */
export function sourceSignals(files: { path: string; text: string }[]): SourceSignals {
const deps = files.filter(f => /(Podfile\.lock|Package\.resolved|Cartfile\.resolved|project\.pbxproj)$/.test(f.path)).map(f => f.text).join('\n');
const code = files.filter(f => /\.(swift|m|mm)$/.test(f.path)).map(f => f.text).join('\n');
return {
aakImport: /^\s*import\s+AdAttributionKit\b|@import\s+AdAttributionKit|AdAttributionKit\.framework/m.test(code + deps),
conversionUpdates: /\bupdateConversionValue\s*\(|\bupdatePostbackConversionValue\s*\(/.test(code),
adSdks: AD_SDKS.filter(([re]) => re.test(deps)).map(([, n]) => n),
mmpSdks: MMP_SDKS.filter(([re]) => re.test(deps)).map(([, n]) => n),
};
}
function checkUrl(v: PlistValue | undefined, key: string, f: AakFinding[]): string | null {
if (v === undefined) return null;
if (typeof v !== 'string' || !/^https:\/\/[^\s/]+(\/.*)?$/i.test(v.trim())) { f.push({ level: 'blocker', code: 'endpoint-url', msg: `${key}: "${String(v)}" is not an https URL like https://example.com` }); return null; }
const host = new URL(v.trim()).hostname;
if (new URL(v.trim()).pathname.replace(/\/$/, '')) f.push({ level: 'warn', code: 'endpoint-path', msg: `${key}: the path is ignored; the system only uses the domain (${host})` });
return host;
}
/** Registrable-ish domain (last two labels; good enough to compare two endpoints of one app). */
const domainOf = (host: string): string => host.split('.').slice(-2).join('.');
/** Every finding for one Info.plist (+ the source signals of the project). Pure. */
export function evaluateAdAttribution(plist: Dict, s: SourceSignals): AakFinding[] {
const f: AakFinding[] = [];
// ---- publisher: ad network ids ----
const skItems = plist.SKAdNetworkItems;
const skIds: string[] = [];
if (skItems !== undefined) {
if (!Array.isArray(skItems)) f.push({ level: 'blocker', code: 'skadnetwork-type', msg: 'SKAdNetworkItems must be an array of dictionaries with SKAdNetworkIdentifier' });
else for (const [i, it] of skItems.entries()) {
const id = isDict(it) ? it.SKAdNetworkIdentifier : undefined;
if (typeof id !== 'string') { f.push({ level: 'blocker', code: 'skadnetwork-entry', msg: `SKAdNetworkItems[${i}]: no SKAdNetworkIdentifier string` }); continue; }
skIds.push(id);
}
}
const aak = plist.AdNetworkIdentifiers;
const aakIds: string[] = [];
if (aak !== undefined) {
if (!Array.isArray(aak) || !aak.every(x => typeof x === 'string')) f.push({ level: 'blocker', code: 'aak-ids-type', msg: 'AdNetworkIdentifiers must be an array of strings' });
else aakIds.push(...(aak as string[]));
}
for (const [list, name] of [[skIds, 'SKAdNetworkItems'], [aakIds, 'AdNetworkIdentifiers']] as const) {
const upper = list.filter(id => id !== id.toLowerCase());
if (upper.length) f.push({ level: 'blocker', code: 'id-case', msg: `${name}: ${upper.length} id(s) with capital letters (${upper.slice(0, 3).join(', ')}): the system ignores them; ad network ids are lowercase` });
const odd = list.filter(id => id === id.toLowerCase() && !ID_RE.test(id));
if (odd.length) f.push({ level: 'warn', code: 'id-format', msg: `${name}: ${odd.slice(0, 3).join(', ')} do not look like ad network ids (<id>.skadnetwork / <id>.adattributionkit)` });
const dup = list.filter((id, i) => list.indexOf(id) !== i);
if (dup.length) f.push({ level: 'info', code: 'id-duplicate', msg: `${name}: listed twice: ${[...new Set(dup)].slice(0, 3).join(', ')}` });
}
if (s.adSdks.length && !skIds.length && !aakIds.length)
f.push({ level: 'blocker', code: 'no-network-ids', msg: `ad SDKs (${s.adSdks.join(', ')}) but no SKAdNetworkItems / AdNetworkIdentifiers: installs from those ads cannot be attributed; add each network's ids from its list` });
if (skIds.length && !aakIds.length)
f.push({ level: 'warn', code: 'aak-ids-missing', msg: `SKAdNetworkItems has ${skIds.length} id(s) but there is no AdNetworkIdentifiers: impressions that networks sign with AdAttributionKit are only eligible for networks in AdNetworkIdentifiers (Configuring a publisher app); the same ids work for both` });
else if (aakIds.length && skIds.length) {
const onlySk = skIds.filter(x => !aakIds.includes(x));
if (onlySk.length) f.push({ level: 'info', code: 'aak-ids-partial', msg: `${onlySk.length} id(s) only in SKAdNetworkItems (${onlySk.slice(0, 3).join(', ')}${onlySk.length > 3 ? ', ...' : ''}): add them to AdNetworkIdentifiers too if those networks use AdAttributionKit` });
}
if (skIds.length || aakIds.length) f.push({ level: 'ok', code: 'network-ids', msg: `${new Set([...skIds, ...aakIds]).size} ad network id(s) (SKAdNetworkItems ${skIds.length}, AdNetworkIdentifiers ${aakIds.length})` });
// ---- advertised app: postback copies ----
const group = isDict(plist.AdAttributionKit) ? plist.AdAttributionKit : undefined;
const copyRaw = group?.AttributionCopyEndpoint ?? plist.AttributionCopyEndpoint;
const reRaw = group?.EligibleForAdAttributionKitReengagementPostbackCopies ?? plist.EligibleForAdAttributionKitReengagementPostbackCopies;
const copyHost = checkUrl(copyRaw, 'AttributionCopyEndpoint', f);
if (copyHost) f.push({ level: 'ok', code: 'copy-endpoint', msg: `AdAttributionKit postback copies go to https://${domainOf(copyHost)}/.well-known/appattribution/report-attribution/ (needs a valid TLS certificate and to accept HTTPS POST)` });
if (reRaw !== undefined && typeof reRaw !== 'boolean') f.push({ level: 'blocker', code: 'reengagement-type', msg: 'EligibleForAdAttributionKitReengagementPostbackCopies must be a Boolean (YES)' });
if (reRaw === true && !copyHost) f.push({ level: 'warn', code: 'reengagement-without-endpoint', msg: 'EligibleForAdAttributionKitReengagementPostbackCopies is on but there is no AttributionCopyEndpoint: no copy can be sent' });
const skHost = checkUrl(plist.NSAdvertisingAttributionReportEndpoint, 'NSAdvertisingAttributionReportEndpoint', f);
if (skHost) f.push({ level: 'ok', code: 'skan-endpoint', msg: `SKAdNetwork postback copies (and Private Click Measurement) go to https://${domainOf(skHost)}/.well-known/...` });
if (copyHost && skHost && domainOf(copyHost) !== domainOf(skHost)) f.push({ level: 'info', code: 'endpoint-domains', msg: `AdAttributionKit copies go to ${domainOf(copyHost)}, SKAdNetwork copies to ${domainOf(skHost)}` });
if (skHost && !copyHost && (s.aakImport || aakIds.length)) f.push({ level: 'info', code: 'copy-endpoint-missing', msg: 'SKAdNetwork postback copies are set up but AdAttributionKit ones are not: add AttributionCopyEndpoint for AdAttributionKit copies' });
if ((copyHost || s.aakImport) && !s.conversionUpdates && !s.mmpSdks.length)
f.push({ level: 'warn', code: 'no-conversion-update', msg: 'no updateConversionValue / updatePostbackConversionValue call and no attribution SDK found: the advertised app must update the conversion value at least once (best at first launch) to start the conversion window' });
// ---- attribution rules ----
const conf = plist.AdAttributionKitConfigurations;
if (conf !== undefined) {
if (!isDict(conf)) f.push({ level: 'blocker', code: 'config-type', msg: 'AdAttributionKitConfigurations must be a dictionary' });
else {
const known = new Set([...skIds, ...aakIds]);
const windows = conf.AttributionWindows;
if (windows !== undefined && !isDict(windows)) f.push({ level: 'blocker', code: 'windows-type', msg: 'AttributionWindows must be a dictionary' });
if (isDict(windows)) for (const [who, cfg] of Object.entries(windows)) {
const at = `AttributionWindows.${who}`;
if (who !== 'global') {
if (who !== who.toLowerCase()) f.push({ level: 'blocker', code: 'windows-id-case', msg: `${at}: ad network ids are lowercase` });
else if (known.size && !known.has(who)) f.push({ level: 'warn', code: 'windows-unknown-network', msg: `${at}: not in AdNetworkIdentifiers / SKAdNetworkItems` });
}
const install = isDict(cfg) ? cfg.install : undefined;
if (!isDict(install)) { f.push({ level: 'warn', code: 'windows-install', msg: `${at}: expected an "install" dictionary` }); continue; }
for (const [k, v] of Object.entries(install)) {
if (k === 'click' && !(typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 30)) f.push({ level: 'blocker', code: 'window-click', msg: `${at}.install.click: whole days 1-30` });
else if (k === 'view' && !(typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 7)) f.push({ level: 'blocker', code: 'window-view', msg: `${at}.install.view: whole days 1-7` });
else if (k === 'ignoreInteractionType') {
if (v !== 'view' && v !== 'click') f.push({ level: 'blocker', code: 'ignore-type', msg: `${at}.install.ignoreInteractionType: view | click` });
if (who === 'global') f.push({ level: 'warn', code: 'ignore-global', msg: `${at}.install.ignoreInteractionType: only read in ad network entries, not in global` });
} else if (!['click', 'view', 'ignoreInteractionType'].includes(k)) f.push({ level: 'info', code: 'window-key', msg: `${at}.install.${k}: not a documented key` });
}
}
const cool = conf.AttributionCooldown;
if (cool !== undefined) {
if (!isDict(cool)) f.push({ level: 'blocker', code: 'cooldown-type', msg: 'AttributionCooldown must be a dictionary' });
else for (const [k, v] of Object.entries(cool)) {
if (k !== 'install-cooldown-hours' && k !== 're-engagement-cooldown-hours') f.push({ level: 'info', code: 'cooldown-key', msg: `AttributionCooldown.${k}: not a documented key` });
else if (!(typeof v === 'number' && v >= 0 && v <= 720)) f.push({ level: 'blocker', code: 'cooldown-range', msg: `AttributionCooldown.${k}: hours 0-720` });
}
}
for (const k of Object.keys(conf)) if (!['AttributionWindows', 'AttributionCooldown'].includes(k)) f.push({ level: 'info', code: 'config-key', msg: `AdAttributionKitConfigurations.${k}: not a documented key` });
}
}
if (!skIds.length && !aakIds.length && !copyHost && !skHost && conf === undefined && !s.aakImport && !s.adSdks.length)
f.push({ level: 'info', code: 'none', msg: 'no ad attribution setup found (fine for an app without ads and without ad campaigns)' });
if (typeof plist.NSUserTrackingUsageDescription === 'string' && !s.adSdks.length && !s.mmpSdks.length)
f.push({ level: 'info', code: 'att-only-for-aak', msg: 'NSUserTrackingUsageDescription is set; AdAttributionKit and SKAdNetwork need no ATT prompt (keep it only if you track across apps)' });
return f;
}