← back to George Gmail
feat(bridge): painless George token renewal — WARN/CRIT accounts get a one-click re-consent email (DTD verdict B, 2026-07-15)
7b49e787e87e3e75a7ffc996b91a5e22d1aa1717 · 2026-07-15 11:58:02 -0700 · Steve
token-age-warn.mjs computed WARN/CRIT but never alerted. token-bridge.mjs reads its latest.json and emails Steve a "Renew now" button (George /auth/<account>) for each aging/dead token, throttled 1/20h per account, +CNCP card. 3x/day launchd plist runs monitor then bridge. Verified: emailed 4 accounts (steve-personal dead, calendar dead, steve@/info@ aging).
Files touched
A com.steve.george-token-bridge.plistA token-bridge.mjs
Diff
commit 7b49e787e87e3e75a7ffc996b91a5e22d1aa1717
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 15 11:58:02 2026 -0700
feat(bridge): painless George token renewal — WARN/CRIT accounts get a one-click re-consent email (DTD verdict B, 2026-07-15)
token-age-warn.mjs computed WARN/CRIT but never alerted. token-bridge.mjs reads its latest.json and emails Steve a "Renew now" button (George /auth/<account>) for each aging/dead token, throttled 1/20h per account, +CNCP card. 3x/day launchd plist runs monitor then bridge. Verified: emailed 4 accounts (steve-personal dead, calendar dead, steve@/info@ aging).
---
com.steve.george-token-bridge.plist | 33 ++++++++++++++
token-bridge.mjs | 88 +++++++++++++++++++++++++++++++++++++
2 files changed, 121 insertions(+)
diff --git a/com.steve.george-token-bridge.plist b/com.steve.george-token-bridge.plist
new file mode 100644
index 0000000..8c1882d
--- /dev/null
+++ b/com.steve.george-token-bridge.plist
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.george-token-bridge</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/bin/sh</string>
+ <string>-c</string>
+ <string>/opt/homebrew/bin/node token-age-warn.mjs; /opt/homebrew/bin/node token-bridge.mjs</string>
+ </array>
+ <key>WorkingDirectory</key>
+ <string>/Users/macstudio3/Projects/george-gmail</string>
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>PATH</key>
+ <string>/opt/homebrew/bin:/usr/bin:/bin</string>
+ </dict>
+ <key>StartCalendarInterval</key>
+ <array>
+ <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>
+ <dict><key>Hour</key><integer>14</integer><key>Minute</key><integer>0</integer></dict>
+ <dict><key>Hour</key><integer>20</integer><key>Minute</key><integer>0</integer></dict>
+ </array>
+ <key>RunAtLoad</key>
+ <false/>
+ <key>StandardOutPath</key>
+ <string>/Users/macstudio3/Library/Logs/george-token-bridge.log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/macstudio3/Library/Logs/george-token-bridge.log</string>
+</dict>
+</plist>
diff --git a/token-bridge.mjs b/token-bridge.mjs
new file mode 100644
index 0000000..e8572fc
--- /dev/null
+++ b/token-bridge.mjs
@@ -0,0 +1,88 @@
+// 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();
+// Accounts needing a click: CRIT (dead) or WARN (aging past the horizon) AND that have a re-consent route.
+const need = latest.accounts.filter((a) => (a.level === 'CRIT' || a.level === 'WARN') && AUTH_ROUTE[a.key]);
+// Throttle: only re-nudge an account if we haven't in the last 20h.
+const due = need.filter((a) => now - (state[a.key]?.lastAlert || 0) > THROTTLE_MS);
+
+if (!due.length) { console.log(`[bridge] ${need.length} account(s) need renewal, 0 due to nudge (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' : '🟠 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}`);
← 76f3426 docs: lead the OAuth runbook with the Internal-flip fast pat
·
back to George Gmail
·
auto-save: 2026-07-15T12:06:24 (1 files) — data/ bb1ae3a →