← back to Bhv Review Poller

check.mjs

72 lines

// Beverly Hills Videos — App Store review-state poller (READ-ONLY against Apple).
// Emits one JSON line to stdout: {state, prev, changed, versionString, reviewState, stamp, meaning}
// Persists last state to data/last-state.txt and a heartbeat to data/latest.json.
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const DIR = path.dirname(fileURLToPath(import.meta.url));
const KEY_ID = '72Y2TZT54R';
const ISS = 'cfbd63ed-301b-465c-aad7-49e94420ad70';
const APP = '6798763975';
const P8 = '/Users/macstudio3/.appstoreconnect/private_keys/AuthKey_72Y2TZT54R.p8';

// Human meaning for each terminal/interesting state
const MEANING = {
  WAITING_FOR_REVIEW: 'In Apple’s queue, not yet picked up.',
  IN_REVIEW: 'Apple has STARTED reviewing the app.',
  PENDING_DEVELOPER_RELEASE: 'APPROVED — waiting for you to release it.',
  PENDING_APPLE_RELEASE: 'APPROVED — scheduled for Apple release.',
  READY_FOR_SALE: 'APPROVED and LIVE on the App Store.',
  REJECTED: 'REJECTED by Apple — needs changes + resubmit.',
  METADATA_REJECTED: 'Metadata REJECTED — fix listing text/screenshots + resubmit.',
  DEVELOPER_REJECTED: 'You rejected the build (removed from review).',
  INVALID_BINARY: 'Binary invalid — needs a new build.',
  PENDING_CONTRACT: 'Blocked on an Apple agreement/contract.',
};
const APPROVED = new Set(['PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE', 'READY_FOR_SALE']);
const REJECTED = new Set(['REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY']);

function jwt() {
  const b64u = o => Buffer.from(typeof o === 'string' ? o : JSON.stringify(o)).toString('base64url');
  const now = Math.floor(Date.now() / 1000);
  const si = `${b64u({ alg: 'ES256', kid: KEY_ID, typ: 'JWT' })}.${b64u({ iss: ISS, iat: now, exp: now + 600, aud: 'appstoreconnect-v1' })}`;
  const sig = crypto.sign('sha256', Buffer.from(si), { key: fs.readFileSync(P8, 'utf8'), dsaEncoding: 'ieee-p1363' });
  return `${si}.${sig.toString('base64url')}`;
}

async function main() {
  const t = jwt();
  const g = async u => {
    const r = await fetch('https://api.appstoreconnect.apple.com' + u, { headers: { Authorization: `Bearer ${t}` } });
    return await r.json();
  };
  const vresp = await g(`/v1/apps/${APP}/appStoreVersions?limit=1&fields[appStoreVersions]=versionString,appStoreState`);
  const v = vresp.data && vresp.data[0];
  if (!v) throw new Error('no appStoreVersion returned: ' + JSON.stringify(vresp).slice(0, 200));
  const state = v.attributes.appStoreState;
  const rs = ((await g(`/v1/reviewSubmissions?filter[app]=${APP}&filter[platform]=IOS`)).data || []).find(x => x.attributes.submittedDate);

  const stateFile = path.join(DIR, 'data', 'last-state.txt');
  let prev = null;
  try { prev = fs.readFileSync(stateFile, 'utf8').trim(); } catch {}
  const changed = !!prev && prev !== state;
  fs.writeFileSync(stateFile, state);

  const stamp = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' });
  const out = {
    state, prev, changed,
    firstRun: !prev,
    versionString: v.attributes.versionString,
    reviewState: rs ? rs.attributes.state : null,
    approved: APPROVED.has(state),
    rejected: REJECTED.has(state),
    meaning: MEANING[state] || state,
    stamp,
  };
  fs.writeFileSync(path.join(DIR, 'data', 'latest.json'), JSON.stringify(out, null, 2));
  process.stdout.write(JSON.stringify(out) + '\n');
}
main().catch(e => { process.stderr.write('ERR ' + e.message + '\n'); process.exit(1); });