← 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}`);