- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
72 lines
4.9 KiB
TypeScript
72 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';
|
|
import * as rt from '../lib/rt';
|
|
|
|
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) => rt.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),
|
|
};
|
|
}
|