// Optional RevenueCat adapter: keep a RevenueCat project's catalog in step with the config. // storebound revenuecat diff [--json] read-only parity report; exit 1 when out of sync // storebound revenuecat sync [--dry] [--yes] [--current] [--json] // sync creates and attaches only (products, entitlements, their products, the offering, its packages and their // products), in dependency order, after a confirmation. It never deletes, detaches, archives or updates anything: // what RevenueCat has beyond the config is listed as "extra, left alone". --current also makes the offering the // current one (that changes what the apps show by default, so it is opt-in). Re-running is safe: the plan is // computed from a fresh read every time. // Out of scope (RevenueCat's job): runtime entitlements, receipt / purchase token validation, customer info, // webhooks, paywalls, store server notifications setup, the App Store / Play credentials inside RevenueCat. // Key: REVENUECAT_SECRET_KEY (env or .storebound/credentials.json), a v2 secret key with project configuration // permissions (apps read; products, entitlements, offerings, packages read, or read_write for sync). Never printed. // Config: revenuecat { projectId, apps?: { apple, play }, entitlements?, offering? }. Pure parts: lib/revenuecat.ts. // Sources (checked October 2026): // https://www.revenuecat.com/docs/api-v2 (auth, pagination, rate limits: 429 + Retry-After seconds) // https://www.revenuecat.com/docs/api-v2/app GET /projects/{project_id}/apps // https://www.revenuecat.com/docs/api-v2/product GET / POST /projects/{project_id}/products // https://www.revenuecat.com/docs/api-v2/entitlement GET / POST .../entitlements, GET .../entitlements/{id}/products, // POST .../entitlements/{id}/actions/attach_products // https://www.revenuecat.com/docs/api-v2/offering GET / POST .../offerings, POST .../offerings/{id} (is_current) // https://www.revenuecat.com/docs/api-v2/package GET / POST .../offerings/{id}/packages, GET .../packages/{id}/products, // POST .../packages/{id}/actions/attach_products import { credentialValue } from './lib/credentials'; import { confirm, type Ctx, need } from './lib/ctx'; import { AmbiguousWriteError, createOnce } from './lib/upsert'; import { errorBody } from './lib/http'; import { done, log, printJson, warn } from './lib/log'; import { outOfSync, RC_SCOPE, type RcApp, type RcEntitlement, type RcOffering, type RcPackage, rcDiff, rcPlan, type RcProduct, type RcRow, type RcState, type RcStep, resolveApps, wantedRevenueCat, } from './lib/revenuecat'; import { textTable } from './lib/table'; import * as rt from './lib/rt'; const TAG = 'revenuecat'; const ORIGIN = 'https://api.revenuecat.com'; const BASE = `${ORIGIN}/v2`; const USAGE = 'usage: storebound revenuecat diff [--json] | sync [--dry] [--yes] [--current] [--json]'; type J = Record; // eslint-disable-line @typescript-eslint/no-explicit-any export interface RcClient { get(path: string): Promise; all(path: string): Promise; post(path: string, body: unknown): Promise } /** Small v2 client: Bearer auth, next_page pagination (only ever followed on api.revenuecat.com), 429 retries with * Retry-After. Errors name the method, path and status; the key never appears in them. */ export function rcClient(key: string, o: { retries?: number } = {}): RcClient { const redact = (s: string) => (key ? s.split(key).join('***') : s); async function call(method: 'GET' | 'POST', url: string, body?: unknown): Promise { const u = new URL(url, ORIGIN); if (u.origin !== ORIGIN) throw new Error(`RevenueCat: refusing to send the key to ${u.origin}`); for (let attempt = 0; ; attempt++) { let r: Response; try { r = await fetch(u, { method, signal: AbortSignal.timeout(30_000), headers: { 'Authorization': `Bearer ${key}`, 'Accept': 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body), }); } catch (e) { // a POST without an answer may have been applied: callers look before sending it again (createOnce) if (method === 'POST') throw new AmbiguousWriteError(redact(`RevenueCat ${method} ${u.pathname}: no answer (${(e as Error).message})`)); throw e; } if (r.status === 429 && attempt < (o.retries ?? 3)) { const s = Math.min(60, Math.max(0, Number(r.headers.get('retry-after') ?? '5') || 0)); warn(TAG, `rate limited, waiting ${s}s`); await r.body?.cancel(); await rt.sleep(s * 1000); continue; } const text = await r.text(); if (!r.ok && r.status >= 500 && method === 'POST') throw new AmbiguousWriteError(redact(`RevenueCat ${method} ${u.pathname}: ${r.status} ${errorBody(text, 500)}`), r.status); if (!r.ok) { const hint = r.status === 401 || r.status === 403 ? ' (REVENUECAT_SECRET_KEY: a v2 secret key with project configuration permissions for this project)' : ''; throw new Error(redact(`RevenueCat ${method} ${u.pathname}: ${r.status} ${errorBody(text, 500)}${hint}`)); } return text ? JSON.parse(text) as J : {}; } } const abs = (p: string) => (p.startsWith('http') ? p : p.startsWith('/v2/') ? ORIGIN + p : BASE + p); return { get: p => call('GET', abs(p)), post: (p, b) => call('POST', abs(p), b), async all(p) { const out: J[] = []; for (let url: string | null = abs(p); url;) { const r = await call('GET', url); out.push(...(r.items ?? [])); url = typeof r.next_page === 'string' && r.next_page ? abs(r.next_page) : null; } return out; }, }; } /** Everything the diff and the plan need, read-only. Attachments are read only for the configured entitlements and * the configured offering. */ export async function readState(c: RcClient, projectId: string, want: { entitlements: string[]; offering: string }): Promise { const P = `/projects/${encodeURIComponent(projectId)}`; const products = (await c.all(`${P}/products?limit=100`)) as RcProduct[]; log(TAG, `${products.length} product(s)`); const entitlements: RcEntitlement[] = []; for (const e of await c.all(`${P}/entitlements?limit=100`)) { const productIds = want.entitlements.includes(e.lookup_key) ? (await c.all(`${P}/entitlements/${encodeURIComponent(e.id)}/products?limit=100`)).map(p => p.id as string) : []; entitlements.push({ id: e.id, lookup_key: e.lookup_key, display_name: e.display_name, state: e.state, productIds }); } log(TAG, `${entitlements.length} entitlement(s)`); const offerings: RcOffering[] = []; for (const o of await c.all(`${P}/offerings?limit=100`)) { const packages: RcPackage[] = []; if (o.lookup_key === want.offering) { for (const p of await c.all(`${P}/offerings/${encodeURIComponent(o.id)}/packages?limit=100`)) { const items = await c.all(`${P}/packages/${encodeURIComponent(p.id)}/products?limit=100`); packages.push({ id: p.id, lookup_key: p.lookup_key, display_name: p.display_name, position: p.position, products: items.map(i => ({ product_id: typeof i.product === 'string' ? i.product : i.product?.id, eligibility_criteria: i.eligibility_criteria })) }); } } offerings.push({ id: o.id, lookup_key: o.lookup_key, display_name: o.display_name, is_current: o.is_current, state: o.state, packages }); } log(TAG, `${offerings.length} offering(s)`); return { products, entitlements, offerings }; } export function formatRows(rows: RcRow[]): string { if (!rows.length) return 'nothing to compare'; return textTable(['status', 'what', 'key', 'detail'], rows.map(r => [r.status, r.section, r.key, r.msg])); } async function load(ctx: Ctx) { const { config, root } = ctx.loaded; const rc = need(config.revenuecat, 'revenuecat'); const key = credentialValue(root, 'REVENUECAT_SECRET_KEY'); if (!key) throw new Error('REVENUECAT_SECRET_KEY is not set (env, or .storebound/credentials.json): a RevenueCat v2 secret key with project configuration permissions'); const c = rcClient(key); const want = wantedRevenueCat(config); const needApps = (['apple', 'play'] as const).some(s => !rc.apps?.[s]); const apps = needApps ? (await c.all(`/projects/${encodeURIComponent(rc.projectId)}/apps?limit=100`)) as RcApp[] : null; const resolved = resolveApps(config, apps); const state = await readState(c, rc.projectId, { entitlements: want.entitlements.map(e => e.lookupKey), offering: want.offering.lookupKey }); return { rc, c, want, resolved, state }; } export async function run(ctx: Ctx): Promise { const a = ctx.args; const sub = a._[0]; if (sub !== 'diff' && sub !== 'sync') throw new Error(USAGE); const { rc, c, want, resolved, state } = await load(ctx); const rows = rcDiff(want, state, resolved.ids, resolved.problems); const bad = outOfSync(rows); if (sub === 'diff') { if (a.has('json')) printJson({ projectId: rc.projectId, apps: resolved.ids, outOfSync: bad, rows, scope: RC_SCOPE }); else console.log(`${formatRows(rows)}\n\n${RC_SCOPE}`); done(TAG, `${bad ? `${bad} difference(s): run storebound revenuecat sync --dry` : 'in sync'}; ${rows.filter(r => r.status === 'extra').length} extra (left alone)`); if (bad) process.exitCode = 1; return; } // sync const plan = rcPlan(want, state, resolved.ids, { current: a.has('current') }); const blockers = rows.filter(r => r.section === 'config' || r.section === 'app'); for (const b of blockers) warn(TAG, b.msg); const left = rows.filter(r => r.status === 'extra' || (r.status === 'mismatch' && r.section !== 'config')); if (a.has('json') && a.has('dry')) { printJson({ projectId: rc.projectId, apps: resolved.ids, plan, leftAlone: left }); return; } console.log(plan.length ? plan.map((s, i) => `${String(i + 1).padStart(String(plan.length).length)}. ${s.what}`).join('\n') : 'nothing to create or attach'); if (left.length) console.log(`\nleft alone (sync never deletes, detaches or changes):\n${left.map(r => ` ${r.status} ${r.section} ${r.key}: ${r.msg}`).join('\n')}`); console.log(`\n${RC_SCOPE}`); if (!plan.length) { done(TAG, 'nothing to do'); return; } if (a.has('dry')) { done(TAG, `dry run: ${plan.length} step(s), nothing sent`); return; } if (!(await confirm(ctx, `Run these ${plan.length} step(s) on RevenueCat project ${rc.projectId}?`))) { log(TAG, 'not confirmed, nothing sent'); return; } await execute(c, rc.projectId, plan, state, resolved.ids, want.offering.lookupKey); done(TAG, `${plan.length} step(s) done; run storebound revenuecat diff to check`); } /** Runs the plan in order, resolving product / entitlement / offering / package ids from the state and what it creates. */ export async function execute(c: RcClient, projectId: string, plan: RcStep[], state: RcState, apps: Partial>, offeringKey: string): Promise { const P = `/projects/${encodeURIComponent(projectId)}`; const storeOf = new Map(Object.entries(apps).map(([s, id]) => [id, s] as const)); const productId = new Map(); for (const p of state.products) { const s = storeOf.get(p.app_id); if (s) productId.set(`${s}:${p.store_identifier}`, p.id); } const entId = new Map(state.entitlements.map(e => [e.lookup_key, e.id] as const)); const current = state.offerings.find(o => o.lookup_key === offeringKey); let offering = current?.id ?? ''; const pkgId = new Map((current?.packages ?? []).map(p => [p.lookup_key, p.id] as const)); const pid = (k: string) => { const id = productId.get(k); if (!id) throw new Error(`RevenueCat product ${k} was not created`); return id; }; // creates go through createOnce: a 409 (lookup key / store identifier taken) or an unclear answer finds the existing // one by its key with a fresh list, never a second one; attaching and is_current are the same when sent twice const once = async (what: string, list: string, match: (x: J) => boolean, create: () => Promise): Promise => (await createOnce({ what, tag: TAG, unique: true, find: async () => (await c.all(list)).find(match) ?? null, create })).value.id as string; for (const [i, s] of plan.entries()) { log(TAG, `${i + 1}/${plan.length} ${s.what}`); switch (s.op) { case 'create-product': productId.set(s.key, await once(s.what, `${P}/products?limit=100`, x => x.app_id === s.body.app_id && x.store_identifier === s.body.store_identifier, () => c.post(`${P}/products`, s.body))); break; case 'create-entitlement': entId.set(s.lookupKey, await once(s.what, `${P}/entitlements?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(`${P}/entitlements`, s.body))); break; case 'attach-entitlement': await c.post(`${P}/entitlements/${encodeURIComponent(entId.get(s.lookupKey)!)}/actions/attach_products`, { product_ids: s.products.map(pid) }); break; case 'create-offering': offering = await once(s.what, `${P}/offerings?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(`${P}/offerings`, s.body)); break; case 'create-package': { const pkgs = `${P}/offerings/${encodeURIComponent(offering)}/packages`; pkgId.set(s.lookupKey, await once(s.what, `${pkgs}?limit=100`, x => x.lookup_key === s.lookupKey, () => c.post(pkgs, s.body))); break; } case 'attach-package': await c.post(`${P}/packages/${encodeURIComponent(pkgId.get(s.lookupKey)!)}/actions/attach_products`, { products: s.products.map(k => ({ product_id: pid(k), eligibility_criteria: 'all' })) }); break; case 'set-current': await c.post(`${P}/offerings/${encodeURIComponent(offering)}`, { is_current: true }); break; } } }