← back to George Gmail

token-bridge.mjs

102 lines

// token-bridge.mjs — the "painless renewal" half of the George OAuth bridge (DTD 2026-07-15: B).
//
// token-age-warn.mjs decides WARN/CRIT per account into data/latest.json but only logs it.
// This reads that verdict and, for any account needing attention, emails Steve a ONE-CLICK
// re-consent link (+ best-effort CNCP card) so renewing a 7-day-expiring consumer token is a
// ~30-second click instead of a silent mid-pipeline death. Throttled to once/20h per account so
// it nudges without spamming. READ-ONLY except its own throttle-state file; sends via George's
// own healthy steve-office account to an INTERNAL recipient (no external-send token needed).
import fs from 'node:fs';

const HERE = new URL('.', import.meta.url).pathname;
const LATEST = `${HERE}data/latest.json`;
const STATE = `${HERE}data/bridge-alert-state.json`;
const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
const TO = process.env.BRIDGE_ALERT_TO || 'steve@designerwallcoverings.com';
const THROTTLE_MS = 20 * 60 * 60 * 1000; // one nudge per account per 20h

// refresh-token env key → George /auth/<route> that re-consents it
const AUTH_ROUTE = {
  GMAIL_REFRESH_TOKEN: 'steve-office',
  INFO_REFRESH_TOKEN: 'info',
  PERSONAL_REFRESH_TOKEN: 'steve-personal',
  AGENTABRAMS_REFRESH_TOKEN: 'agentabrams',
  GOOGLE_CALENDAR_REFRESH_TOKEN: 'steve-office', // calendar re-auths on the steve-office Workspace flow
};

function secret(key) {
  for (const p of ['/Users/macstudio3/Projects/secrets-manager/.env']) {
    try { const m = fs.readFileSync(p, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); if (m) return m[1].trim().replace(/^["']|["']$/g, ''); } catch {}
  }
  return '';
}
const GEORGE_AUTH = (() => { const v = process.env.GEORGE_AUTH || secret('GEORGE_AUTH'); return v ? (v.startsWith('Basic ') ? v : 'Basic ' + Buffer.from(v.includes(':') ? v : 'admin:' + v).toString('base64')) : ''; })();

function load(p, d) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return d; } }

const latest = load(LATEST, null);
if (!latest || !Array.isArray(latest.accounts)) { console.log('[bridge] no latest.json from token-age-warn — run it first'); process.exit(0); }

const state = load(STATE, {});
const now = Date.now();

// Two modes:
//  • CADENCE=1 (the every-6-days job) → always send the two CONSUMER accounts' 1-click renew to
//    steve-office, regardless of exact age. Predictable "click every 6 days" rotation reminder.
//  • default (the 8/2/8 job) → SAFETY NET only: nudge accounts that have gone CRIT (dead) between
//    cadence runs, throttled, so an unexpected death still surfaces fast without routine spam.
const CADENCE = process.env.CADENCE === '1';
const CONSUMER = { PERSONAL_REFRESH_TOKEN: 'Steve Personal (@gmail)', AGENTABRAMS_REFRESH_TOKEN: 'Agent Abrams (@gmail)' };
let due;
if (CADENCE) {
  due = Object.entries(CONSUMER).map(([key, label]) => {
    const a = latest.accounts.find((x) => x.key === key) || {};
    return { key, label, level: a.level === 'CRIT' ? 'CRIT' : 'renew', detail: a.detail || 'periodic 6-day renewal' };
  });
} else {
  const need = latest.accounts.filter((a) => a.level === 'CRIT' && AUTH_ROUTE[a.key]);
  due = need.filter((a) => now - (state[a.key]?.lastAlert || 0) > THROTTLE_MS);
  if (!due.length) { console.log(`[bridge] safety-net: ${need.length} CRIT, 0 due (throttled)`); process.exit(0); }
}

const base = GEORGE.replace(/\/$/, '');
const rows = due.map((a) => {
  const url = `${base}/auth/${AUTH_ROUTE[a.key]}`;
  const tag = a.level === 'CRIT' ? '🔴 DEAD' : a.level === 'renew' ? '🔁 renew' : '🟠 expiring';
  return `<tr><td style="padding:6px 12px 6px 0">${tag}</td><td style="padding:6px 12px 6px 0"><b>${a.label}</b><br><span style="color:#888;font-size:12px">${a.detail || ''}</span></td>`
    + `<td style="padding:6px 0"><a href="${url}" style="background:#8a6d3b;color:#fff;text-decoration:none;padding:7px 14px;border-radius:6px;font-weight:600">Renew now →</a></td></tr>`;
}).join('');
const body = `<div style="font-family:Georgia,serif;max-width:640px">`
  + `<p><b>George Gmail token(s) need a ~30-second renew.</b> Click the button for each — it opens the Google consent screen; approve and it's good for another cycle.</p>`
  + `<p style="color:#a33;font-size:13px">Open these on <b>this Mac</b> (the callback lands on localhost). If a link errors, that account isn't listed as a Test User / the redirect URI isn't registered in the OAuth app — see OAUTH-SERVICE-ACCOUNT-RUNBOOK.md.</p>`
  + `<table style="border-collapse:collapse">${rows}</table>`
  + `<p style="color:#999;font-size:12px;margin-top:16px">Sent by George token-bridge · consumer Gmail (steve-personal, agentabrams) can't auto-renew — this is the painless manual path. Workspace accounts get the permanent fix per the runbook.</p></div>`;

let sent = false;
if (GEORGE_AUTH) {
  try {
    const r = await fetch(`${base}/api/send`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: GEORGE_AUTH },
      body: JSON.stringify({ account: 'steve-office', from: TO, to: TO, subject: `George: renew ${due.length} Gmail token${due.length > 1 ? 's' : ''} (1-click)`, body, no_source_tag: false }),
      signal: AbortSignal.timeout(20000),
    });
    const j = await r.json().catch(() => ({}));
    sent = r.ok && !j.error;
    if (!sent) console.log('[bridge] send failed:', j.error || r.status);
  } catch (e) { console.log('[bridge] send error:', e.message); }
} else { console.log('[bridge] no GEORGE_AUTH — cannot email'); }

// best-effort CNCP card
try {
  await fetch('http://127.0.0.1:3333/api/parking-lot', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: `George: ${due.length} Gmail token(s) need re-consent`, note: due.map((a) => `${a.label} → ${base}/auth/${AUTH_ROUTE[a.key]}`).join(' · '), project: 'george-gmail' }),
    signal: AbortSignal.timeout(6000),
  });
} catch {}

if (sent) for (const a of due) state[a.key] = { lastAlert: now, level: a.level };
fs.writeFileSync(STATE, JSON.stringify(state, null, 2));
console.log(`[bridge] nudged ${due.length} account(s): ${due.map((a) => a.label).join(', ')} · emailed=${sent}`);