← back to Ga4 Fleet

provision.mjs

141 lines

#!/usr/bin/env node
// GA4 fleet provisioner — idempotent property + web-stream creation via the Analytics Admin API.
// Operator-run: authenticates with a service-account key that the OPERATOR provides explicitly
// via GA4_SA_KEY. It does NOT search for or discover credentials — you point it at one key file
// you already control, and it uses only that. The SA must be granted Admin on the target account.
//
// IDEMPOTENT BY DESIGN (the guard whose absence duplicated wallpapersback.com):
//   1. Lists every existing property + stream the SA can see, maps domain -> measurementId.
//   2. Only creates a property when the domain has no existing stream. Re-runs never duplicate.
//
// USAGE (operator runs this — set GA4_SA_KEY to your own key path):
//   GA4_SA_KEY=/path/key.json node provision.mjs --account accounts/15714274             # DRY RUN
//   GA4_SA_KEY=/path/key.json node provision.mjs --account accounts/15714274 --commit     # create
//   GA4_SA_KEY=/path/key.json node provision.mjs --account accounts/15714274 --limit 10   # first N

import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { createSign } from 'node:crypto';

const DIR = '/Users/macstudio3/Projects/ga4-fleet';
const API = 'https://analyticsadmin.googleapis.com/v1beta';
const SCOPE = 'https://www.googleapis.com/auth/analytics.edit';

// ---- args ----
const args = process.argv.slice(2);
const has = (f) => args.includes(f);
const val = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
const COMMIT = has('--commit');
const ACCOUNT = val('--account', null); // e.g. accounts/15714274 ; null = auto-list first accessible
const LIMIT = Number(val('--limit', '0')) || Infinity;

// ---- Auth: SIMPLEST first. If GA4_TOKEN is set (your own gcloud access token), use it
// directly — no service account, no key file. You already own the GA4 accounts, so:
//   GA4_TOKEN=$(gcloud auth print-access-token) node provision.mjs ...
// Fallback: GA4_SA_KEY=/path/key.json for a service-account key (JWT), if you prefer that.
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
async function getToken() {
  if (process.env.GA4_TOKEN) return process.env.GA4_TOKEN;   // your gcloud login token — simplest path
  const p = process.env.GA4_SA_KEY;
  if (!p || !existsSync(p)) throw new Error('Set GA4_TOKEN=$(gcloud auth print-access-token) — or GA4_SA_KEY=/path/key.json for a service account.');
  const key = JSON.parse(readFileSync(p, 'utf8'));
  const now = Math.floor(Date.now() / 1000);
  const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const claim = b64url(JSON.stringify({
    iss: key.client_email, scope: SCOPE, aud: 'https://oauth2.googleapis.com/token',
    iat: now, exp: now + 3600,
  }));
  const sig = createSign('RSA-SHA256').update(`${header}.${claim}`).sign(key.private_key);
  const jwt = `${header}.${claim}.${b64url(sig)}`;
  const r = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`,
  });
  const j = await r.json();
  if (!j.access_token) throw new Error('token exchange failed: ' + JSON.stringify(j));
  return j.access_token;
}

let TOKEN;
async function api(path, method = 'GET', body) {
  const r = await fetch(`${API}/${path}`, {
    method, headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const j = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${JSON.stringify(j)}`);
  return j;
}

// ---- existing coverage: domain -> measurementId across all visible streams ----
async function existingByDomain() {
  const map = {};
  // ALWAYS scan every accessible account for the existence check — a domain's property may
  // live under a different account than --account (which only sets where NEW ones get created).
  // Scoping this to --account is how you mint a duplicate of a property that already exists elsewhere.
  const accts = (await api('accounts')).accounts || [];
  for (const a of accts) {
    let pageToken;
    do {
      const pr = await api(`properties?filter=parent:${a.name}${pageToken ? `&pageToken=${pageToken}` : ''}`);
      for (const p of pr.properties || []) {
        const ds = await api(`${p.name}/dataStreams`);
        for (const s of ds.dataStreams || []) {
          const uri = s.webStreamData?.defaultUri || '';
          const mid = s.webStreamData?.measurementId || '';
          const host = uri.replace(/^https?:\/\//, '').replace(/\/$/, '').replace(/^www\./, '');
          if (host && mid) map[host] = mid;
        }
      }
      pageToken = pr.nextPageToken;
    } while (pageToken);
  }
  return map;
}

async function createPropertyAndStream(account, domain, displayName) {
  const prop = await api('properties', 'POST', {
    parent: account, displayName, timeZone: 'America/Los_Angeles', currencyCode: 'USD',
    industryCategory: 'HOME_AND_GARDEN',
  });
  const stream = await api(`${prop.name}/dataStreams`, 'POST', {
    type: 'WEB_DATA_STREAM', displayName: `${displayName} - Web`,
    webStreamData: { defaultUri: `https://${domain}` },
  });
  return { property: prop.name, measurementId: stream.webStreamData?.measurementId };
}

function niceName(domain) {
  return domain.replace(/\.(com|net|org|co)$/, '').replace(/[.-]/g, ' ')
    .replace(/\b\w/g, (c) => c.toUpperCase()).replace(/Designerwallcoverings/i, 'DW').slice(0, 80);
}

// ---- main ----
(async () => {
  const domains = readFileSync(`${DIR}/domains-public.txt`, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean);
  const registryPath = `${DIR}/measurement-ids.json`;
  const registry = existsSync(registryPath) ? JSON.parse(readFileSync(registryPath, 'utf8')) : {};

  console.log(`${COMMIT ? 'COMMIT' : 'DRY RUN'} · ${domains.length} domains · account=${ACCOUNT || 'auto'}`);
  TOKEN = await getToken();
  console.log('SA authenticated ✓');
  const existing = await existingByDomain();
  console.log(`existing streams found: ${Object.keys(existing).length}`);

  let created = 0, skipped = 0, planned = 0;
  for (const domain of domains.slice(0, LIMIT === Infinity ? domains.length : LIMIT)) {
    const key = domain.replace(/^www\./, '');
    if (existing[key]) { registry[key] = existing[key]; skipped++; console.log(`SKIP  ${key} — already has ${existing[key]}`); continue; }
    if (registry[key] && registry[key].startsWith('G-')) { skipped++; console.log(`SKIP  ${key} — in registry ${registry[key]}`); continue; }
    if (!COMMIT) { planned++; console.log(`PLAN  ${key} — would create property + web stream`); continue; }
    try {
      const acct = ACCOUNT || (await api('accounts')).accounts?.[0]?.name;
      const { property, measurementId } = await createPropertyAndStream(acct, key, niceName(key));
      registry[key] = measurementId;
      writeFileSync(registryPath, JSON.stringify(registry, null, 2));
      created++; console.log(`CREATE ${key} -> ${measurementId} (${property})`);
    } catch (e) { console.log(`ERROR ${key}: ${e.message}`); }
  }
  writeFileSync(registryPath, JSON.stringify(registry, null, 2));
  console.log(`\ndone · created=${created} · skipped(existing)=${skipped} · planned=${planned}`);
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });