[object Object]

← back to George Token Broker

george-token-broker: add pre-emptive 6-day token-age WARN before the ~7d Testing-mode expiry

a14768c03c89591fb26fa199b23b70c0150aec41 · 2026-07-01 10:38:57 -0700 · Steve Abrams

Broker previously only alerted AFTER invalid_grant (token already dead, pipeline already
silently broken). Now tracks first_good_ts per account (reset on any dead/error->ok
transition = a re-consent minted a fresh token) and emits a CNCP WARN once a live token
crosses warn_days (6), throttled to 1/account/day. Health state + exit code unchanged
(a pre-emptive warn is not a FAIL). SA/DWD-backed accounts skip via cfg.sa_backed, so the
Workspace pair auto-drops out once domain-wide delegation lands. Verified in DRY_RUN:
8.7d token WARNs w/ re-consent URL, 3d stays quiet, second run throttled, overall=ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit a14768c03c89591fb26fa199b23b70c0150aec41
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 1 10:38:57 2026 -0700

    george-token-broker: add pre-emptive 6-day token-age WARN before the ~7d Testing-mode expiry
    
    Broker previously only alerted AFTER invalid_grant (token already dead, pipeline already
    silently broken). Now tracks first_good_ts per account (reset on any dead/error->ok
    transition = a re-consent minted a fresh token) and emits a CNCP WARN once a live token
    crosses warn_days (6), throttled to 1/account/day. Health state + exit code unchanged
    (a pre-emptive warn is not a FAIL). SA/DWD-backed accounts skip via cfg.sa_backed, so the
    Workspace pair auto-drops out once domain-wide delegation lands. Verified in DRY_RUN:
    8.7d token WARNs w/ re-consent URL, 3d stays quiet, second run throttled, overall=ok.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 config.json |  4 +++-
 poll.mjs    | 51 +++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 52 insertions(+), 3 deletions(-)

diff --git a/config.json b/config.json
index 956892a..2c8fa13 100644
--- a/config.json
+++ b/config.json
@@ -4,5 +4,7 @@
   "accounts": ["steve-office", "info", "agentabrams", "steve-personal"],
   "launchd_label": "com.steve.george-gmail",
   "timeout_ms": 8000,
-  "cncp_base": "http://127.0.0.1:3333"
+  "cncp_base": "http://127.0.0.1:3333",
+  "warn_days": 6,
+  "sa_backed": []
 }
diff --git a/poll.mjs b/poll.mjs
index b99c46a..b99c211 100644
--- a/poll.mjs
+++ b/poll.mjs
@@ -26,6 +26,16 @@ const DRY_RUN = process.env.DRY_RUN !== '0'; // default true — safe
 const nowISO = () => new Date().toISOString();
 const authHeader = 'Basic ' + Buffer.from(cfg.auth).toString('base64');
 
+// Pre-emptive token-age WARN config (Council 2026-06-30 #2). The Testing-mode OAuth app
+// caps refresh tokens at ~7 days; we warn at WARN_DAYS so re-consent is a scheduled 30-sec
+// task, not a silent mid-pipeline death. Accounts on service-account/domain-wide-delegation
+// have no 7-day token, so list them in cfg.sa_backed to skip the age warn once DWD is live.
+const WARN_DAYS = Number(cfg.warn_days) || 6;
+const WARN_THROTTLE_MS = 24 * 3600 * 1000; // at most one age-WARN per account per day
+const SA_BACKED = new Set(cfg.sa_backed || []);
+const AGES_FILE = path.join(HERE, 'data', 'token-ages.json');
+const AUTH_PATH = { 'steve-office': '/auth', info: '/auth/info', agentabrams: '/auth/agentabrams', 'steve-personal': '/auth/steve-personal' };
+
 function logLine(s) {
   const line = `[${nowISO()}] ${s}`;
   console.log(line);
@@ -83,6 +93,19 @@ async function alert(summary, detail) {
   } catch (e) { logLine(`alert CNCP failed: ${e}`); }
 }
 
+// Pre-emptive (not a failure) — token still works but is nearing the ~7-day cap.
+async function warnAge(acct, addr, ageDays, authUrl) {
+  const summary = `${acct} (${addr}) token ~${ageDays.toFixed(1)}d old — expires ~${WARN_DAYS + 1}d (Testing-mode). Re-consent soon at ${authUrl}`;
+  if (DRY_RUN) { logLine(`WOULD age-WARN -> "${summary}"`); return; }
+  try {
+    await fetchJSON(`${cfg.cncp_base}/api/parking-lot`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ url: `george-token-broker#${acct}`, note: `⏳ George pre-emptive token-age WARN: ${summary}` })
+    });
+    logLine(`age-WARN -> CNCP parking-lot posted (${acct})`);
+  } catch (e) { logLine(`age-WARN CNCP failed: ${e}`); }
+}
+
 (async () => {
   fs.mkdirSync(path.join(HERE, 'data'), { recursive: true });
   const health = await fetchJSON(`${cfg.george_base}/health`);
@@ -104,13 +127,34 @@ async function alert(summary, detail) {
   else if (deadTokens.length) overall = 'token_dead';
   else if (errored.length) overall = 'degraded';
 
+  // ── Pre-emptive token-age WARN (Council 2026-06-30 #2) ─────────────────────────
+  // first_good_ts = when the CURRENT token generation was first seen healthy; reset it
+  // whenever an account returns to 'ok' from dead/error/absent (a re-consent minted a
+  // fresh token). Warn once a live generation crosses WARN_DAYS, throttled to 1/day.
+  const prevAcct = (() => { try { return JSON.parse(fs.readFileSync(path.join(HERE, 'data', 'latest.json'), 'utf8')).accounts || {}; } catch { return {}; } })();
+  let ages = (() => { try { return JSON.parse(fs.readFileSync(AGES_FILE, 'utf8')); } catch { return {}; } })();
+  const nowMs = Date.now();
+  const warnings = [];
+  for (const [acct, v] of Object.entries(accounts)) {
+    if (v.state !== 'ok' || SA_BACKED.has(acct)) continue;
+    let rec = ages[acct];
+    if (!rec || (prevAcct[acct] && prevAcct[acct].state) !== 'ok') rec = { first_good_ts: nowISO(), last_warn_ts: null }; // fresh generation
+    const ageDays = (nowMs - Date.parse(rec.first_good_ts)) / 86400000;
+    if (ageDays > WARN_DAYS && (!rec.last_warn_ts || nowMs - Date.parse(rec.last_warn_ts) > WARN_THROTTLE_MS)) {
+      warnings.push({ acct, addr: v.detail, age_days: Number(ageDays.toFixed(1)), auth_path: AUTH_PATH[acct] || '/auth' });
+      rec.last_warn_ts = nowISO();
+    }
+    ages[acct] = rec;
+  }
+  try { fs.writeFileSync(AGES_FILE, JSON.stringify(ages, null, 2)); } catch {}
+
   const state = {
     ts: nowISO(), overall, processUp,
     uptime_s: processUp ? health.body.uptime : null,
-    accounts, deadTokens, errored, dry_run: DRY_RUN
+    accounts, deadTokens, errored, warnings, dry_run: DRY_RUN
   };
   fs.writeFileSync(path.join(HERE, 'data', 'latest.json'), JSON.stringify(state, null, 2));
-  logLine(`overall=${overall} processUp=${processUp} dead=[${deadTokens}] err=[${errored}]`);
+  logLine(`overall=${overall} processUp=${processUp} dead=[${deadTokens}] err=[${errored}] ageWarn=[${warnings.map(w => w.acct).join(',')}]`);
 
   // Correct action per failure mode:
   if (!processUp) {
@@ -124,5 +168,8 @@ async function alert(summary, detail) {
     await alert(`probe error for ${errored.join(', ')}`, JSON.stringify(accounts));
   }
 
+  // Pre-emptive age warnings are independent of health — emit even when overall=ok.
+  for (const w of warnings) await warnAge(w.acct, w.addr, w.age_days, `${cfg.george_base}${w.auth_path}`);
+
   process.exit(overall === 'ok' ? 0 : 3);
 })();

← 321b6cf george-token-broker: dry-run health-watchdog scaffold (proce  ·  back to George Token Broker  ·  chore: macstudio3 migration — reconcile from mac2 + repoint 2ae500e →