← back to George Gmail

token-age-warn.mjs

116 lines

// token-age-warn.mjs — pre-emptive George OAuth token-age monitor. READ-ONLY.
//
// The Cloud OAuth app is in "Testing" publishing status → refresh tokens expire 7 days
// after issuance. The dw-appointments-oauth-canary only notices AFTER a token is already
// dead. This monitor warns ~1 day BEFORE, so Steve can re-consent on schedule instead of
// mid-pipeline. It is the reversible stopgap while the durable migration (service-account /
// DWD or Internal user-type — see PLAN-OAUTH-DURABLE-TOKENS.md) is decided.
//
// How it stays honest, not guessing:
//   • VALIDITY — it exchanges each refresh_token for a short-lived access_token at Google's
//     token endpoint. This is the exact idempotent refresh George does on every send; for an
//     "offline" web client Google does NOT return a new refresh_token, so nothing rotates and
//     we NEVER write .env. invalid_grant => the token is genuinely dead (CRIT).
//   • AGE — we sha256 each refresh_token (store only a 12-char prefix, never the token) and
//     stamp issued_ts when that hash first appears / changes (a re-consent). age = now-issued.
//     First-ever run stamps now (age 0) and self-corrects forward.
// Verdict per account: DEAD(valid=false)=CRIT · valid & age>=6d=WARN · else OK · missing=UNKNOWN.
// Alerts fire on a WORSENING transition only (OK→WARN→CRIT), never on steady-state or recovery.
import fs from 'node:fs';
import crypto from 'node:crypto';

const HOME = process.env.HOME;
const HERE = new URL('.', import.meta.url).pathname;
const ENV_PATH = process.env.GEORGE_ENV_PATH || `${HOME}/Projects/Designer-Wallcoverings/DW-MCP/.env`;
const DATA = `${HERE}data`; fs.mkdirSync(DATA, { recursive: true });
const STATE_FILE = `${DATA}/token-age-state.json`;
const WARN_DAYS = 5, DEAD_DAYS = 7; // Testing-mode refresh tokens die at 7d; nudge at day 5 for 2d runway

const ACCOUNTS = [
  { label: 'Steve Office (steve@dw)',      key: 'GMAIL_REFRESH_TOKEN',           workspace: true  },
  { label: 'Info@ catch-all',              key: 'INFO_REFRESH_TOKEN',            workspace: true  },
  { label: 'Steve Personal (@gmail)',      key: 'PERSONAL_REFRESH_TOKEN',        workspace: false },
  { label: 'Agent Abrams (@gmail)',        key: 'AGENTABRAMS_REFRESH_TOKEN',     workspace: false },
  { label: 'Calendar / Appointments',      key: 'GOOGLE_CALENDAR_REFRESH_TOKEN', workspace: true  },
];
const RANK = { OK: 0, UNKNOWN: 0, WARN: 1, CRIT: 2 };

function loadEnv(p) {
  const out = {};
  try {
    for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
      if (m) out[m[1]] = m[2].trim();
    }
  } catch (e) { /* handled by caller: missing env => all UNKNOWN */ }
  return out;
}

async function probeValid(clientId, clientSecret, refreshToken) {
  // Read-only: mints an access token; does not rotate/consume the refresh token.
  try {
    const r = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' }),
      signal: AbortSignal.timeout(15000),
    });
    if (r.ok) return { valid: true };
    let err = ''; try { err = (await r.json()).error || ''; } catch {}
    // invalid_grant = expired/revoked (genuinely dead). Any other error (network, quota,
    // invalid_client) is inconclusive — treat as UNKNOWN, never as a false DEAD.
    return err === 'invalid_grant' ? { valid: false } : { valid: null, err: err || `http_${r.status}` };
  } catch (e) { return { valid: null, err: e.name || 'fetch_error' }; }
}

const creds = loadEnv(ENV_PATH);
const clientId = creds.GMAIL_CLIENT_ID, clientSecret = creds.GMAIL_CLIENT_SECRET;
const state = fs.existsSync(STATE_FILE) ? JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) : {};
const now = Date.now();
const accounts = [];
let anyAlert = false;

for (const a of ACCOUNTS) {
  const tok = creds[a.key];
  if (!tok) { accounts.push({ ...a, level: 'UNKNOWN', detail: 'not configured in env' }); continue; }
  const hash = crypto.createHash('sha256').update(tok).digest('hex').slice(0, 12);
  const prev = state[a.key] || {};
  const rotated = prev.hash !== hash;                 // new/changed token = a re-consent
  const issued_ts = rotated ? now : (prev.issued_ts || now);
  const ageDays = (now - issued_ts) / 86400000;

  let valid = null, err;
  if (clientId && clientSecret) ({ valid, err } = await probeValid(clientId, clientSecret, tok));

  let level, detail;
  if (valid === false) { level = 'CRIT'; detail = `token DEAD (invalid_grant) — re-consent needed`; }
  else if (valid === null && !clientId) { level = 'UNKNOWN'; detail = 'no client id/secret to probe'; }
  else if (valid === null) { level = 'UNKNOWN'; detail = `probe inconclusive (${err})`; }
  else if (ageDays >= DEAD_DAYS) { level = 'WARN'; detail = `valid but ${ageDays.toFixed(1)}d old — past the 7d Testing horizon (Production/Internal?)`; }
  else if (ageDays >= WARN_DAYS) { level = 'WARN'; detail = `valid, ${ageDays.toFixed(1)}d old — expires ~${(DEAD_DAYS - ageDays).toFixed(1)}d if app still in Testing`; }
  else { level = 'OK'; detail = `valid, ${ageDays.toFixed(1)}d old`; }

  const prevLevel = prev.level || 'OK';
  const worsened = RANK[level] > RANK[prevLevel];   // OK→WARN, WARN→CRIT, OK→CRIT
  if (worsened) anyAlert = true;
  accounts.push({ label: a.label, key: a.key, workspace: a.workspace, hash, ageDays: +ageDays.toFixed(2), level, detail, worsened });
  state[a.key] = { hash, issued_ts, level };
}

fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
const worst = accounts.reduce((w, a) => RANK[a.level] > RANK[w] ? a.level : w, 'OK');
const out = {
  scanned_at: new Date().toISOString(),
  alert: anyAlert,
  worst,
  warn: accounts.filter(a => a.level === 'WARN').length,
  crit: accounts.filter(a => a.level === 'CRIT').length,
  accounts,
};
fs.writeFileSync(`${DATA}/latest.json`, JSON.stringify(out, null, 2));
for (const a of accounts) {
  const tag = a.level === 'CRIT' ? '✗' : a.level === 'WARN' ? '△' : a.level === 'UNKNOWN' ? '?' : '✓';
  console.log(`  ${tag} ${a.level.padEnd(7)} ${a.label.padEnd(26)} ${a.detail}`);
}
console.log(`\n[token-age] worst=${worst} · ${out.warn} WARN · ${out.crit} CRIT · alert=${anyAlert}`);