storebound/cli/ads/client.ts

71 lines
4.9 KiB
TypeScript

// Apple Ads Platform API client: OAuth client credentials (an ES256 client secret signed with the key uploaded in
// Apple Ads > Account Settings > API), Bearer calls with X-AP-Context: adAccountId=<id>, retries on 429 / 5xx.
// The access token lives in memory for this process only (never written to disk, never printed).
// Credentials (env, else .storebound/credentials.json): APPLE_ADS_CLIENT_ID, APPLE_ADS_TEAM_ID, APPLE_ADS_KEY_ID,
// APPLE_ADS_PRIVATE_KEY (path to the PEM, or the PEM itself), APPLE_ADS_ACCOUNT_ID (optional for `status`).
// Sources (checked October 2026): see cli/lib/apple-ads.ts; retries per
// https://developer.apple.com/documentation/apple-ads-platform-api/rate-limits (Retry-After on 429).
import { createPrivateKey, type KeyObject } from 'node:crypto';
import { credentialValue, inline } from '../lib/credentials';
import { ADS_API, adsHeaders, type AdsCreds, clientSecret, type J, TOKEN_URL, tokenForm } from '../lib/apple-ads';
import { errorBody } from '../lib/http';
export const CRED_NAMES = ['APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID', 'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY'] as const;
export function adsCreds(root: string): AdsCreds {
// the template's placeholders (templates/credentials.example.json, copied by `storebound init`) count as not set
const v = (n: string) => { const x = credentialValue(root, n); return x && !/00000000-0000-0000|\/absolute\/path\/to\//.test(x) ? x : undefined; };
const missing = CRED_NAMES.filter(n => !v(n));
if (missing.length) throw new Error(`${missing.join(', ')} not set (env, or .storebound/credentials.json); see the apple-ads skill`);
let pem: string;
try { pem = inline(v('APPLE_ADS_PRIVATE_KEY')!, /BEGIN (EC )?PRIVATE KEY/); } catch { throw new Error('APPLE_ADS_PRIVATE_KEY: the private key file could not be read (set a path to the PEM, or the PEM itself)'); }
return { clientId: v('APPLE_ADS_CLIENT_ID')!, teamId: v('APPLE_ADS_TEAM_ID')!, keyId: v('APPLE_ADS_KEY_ID')!, pem, accountId: v('APPLE_ADS_ACCOUNT_ID') };
}
/** Seconds to wait before retry `attempt` (1-based): Retry-After when the server sends it, else 2, 4, 8, 16. */
export const retryWait = (retryAfter: string | null, attempt: number): number => {
const s = Number(retryAfter);
return Number.isFinite(s) && s >= 0 && retryAfter !== null && retryAfter !== '' ? Math.min(s, 60) : Math.min(2 ** attempt, 16);
};
export type Ads = ReturnType<typeof adsClient>;
export function adsClient(root: string, o: { sleep?: (ms: number) => Promise<unknown> } = {}) {
const c = adsCreds(root);
let key: KeyObject;
try { key = createPrivateKey(c.pem); } catch { throw new Error('APPLE_ADS_PRIVATE_KEY is not a valid EC private key (PEM)'); }
const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
let cached = { token: '', until: 0 };
async function token(): Promise<string> {
if (cached.token && Date.now() < cached.until) return cached.token;
const r = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenForm(c.clientId, clientSecret(c, key)).toString() });
const t = await r.text();
// the answer may echo the request; only the status and Apple's error code are shown
if (!r.ok) { let code = ''; try { code = String(JSON.parse(t).error ?? ''); } catch { /* not JSON */ } throw new Error(`Apple Ads token request -> ${r.status}${code ? ' ' + code : ''} (check APPLE_ADS_CLIENT_ID / TEAM_ID / KEY_ID and that the uploaded public key matches APPLE_ADS_PRIVATE_KEY)`); }
const j = JSON.parse(t) as { access_token?: string; expires_in?: number };
if (!j.access_token) throw new Error('Apple Ads token request: no access_token in the answer');
cached = { token: j.access_token, until: Date.now() + Math.max(60, (j.expires_in ?? 3600) - 120) * 1000 };
return cached.token;
}
async function call(method: string, path: string, body?: unknown, scoped = true): Promise<J> {
if (scoped && !c.accountId) throw new Error('APPLE_ADS_ACCOUNT_ID is not set: run `storebound ads apple status` to list your ad accounts');
for (let attempt = 1; ; attempt++) {
const r = await fetch(ADS_API + path, { method, headers: adsHeaders(await token(), scoped ? c.accountId : undefined), body: body === undefined ? undefined : JSON.stringify(body) });
const t = await r.text();
if ((r.status === 429 || r.status >= 500) && attempt < 5) { await sleep(retryWait(r.headers.get('Retry-After'), attempt) * 1000); continue; }
if (r.status === 401 && attempt === 1) { cached = { token: '', until: 0 }; continue; }
if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${errorBody(t, 800)}`);
return t ? JSON.parse(t) : {};
}
}
return {
accountId: c.accountId,
token,
get: (path: string, scoped = true) => call('GET', path, undefined, scoped),
post: (path: string, body: unknown) => call('POST', path, body),
put: (path: string, body: unknown) => call('PUT', path, body),
};
}