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