← 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 →