← back to George Gmail
george: pre-emptive OAuth token-age monitor + durable-migration decision memo
9e11f3d2799b6f128c5d4a30609450fc533049f5 · 2026-07-01 09:54:40 -0700 · Steve Abrams
Council #3 — end the weekly-token treadmill. Read-only token-age-warn.mjs probes each
refresh token against Google's token endpoint (idempotent refresh, never writes creds) and
combines validity with a hash-tracked age; WARNs ~1d before the 7d Testing-mode expiry,
CRIT on invalid_grant. Alerts on worsening transition only (CNCP + George email via run.sh).
First run surfaced a live finding: GOOGLE_CALENDAR_REFRESH_TOKEN is DEAD (appointments push
silently broken). Migration paths (service-account/DWD or Internal user-type) in
PLAN-OAUTH-DURABLE-TOKENS.md — those steps are Steve-gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M .gitignoreA PLAN-OAUTH-DURABLE-TOKENS.mdA token-age-warn.mjsA token-age-warn.sh
Diff
commit 9e11f3d2799b6f128c5d4a30609450fc533049f5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 1 09:54:40 2026 -0700
george: pre-emptive OAuth token-age monitor + durable-migration decision memo
Council #3 — end the weekly-token treadmill. Read-only token-age-warn.mjs probes each
refresh token against Google's token endpoint (idempotent refresh, never writes creds) and
combines validity with a hash-tracked age; WARNs ~1d before the 7d Testing-mode expiry,
CRIT on invalid_grant. Alerts on worsening transition only (CNCP + George email via run.sh).
First run surfaced a live finding: GOOGLE_CALENDAR_REFRESH_TOKEN is DEAD (appointments push
silently broken). Migration paths (service-account/DWD or Internal user-type) in
PLAN-OAUTH-DURABLE-TOKENS.md — those steps are Steve-gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 4 ++
PLAN-OAUTH-DURABLE-TOKENS.md | 79 +++++++++++++++++++++++++++++
token-age-warn.mjs | 115 +++++++++++++++++++++++++++++++++++++++++++
token-age-warn.sh | 36 ++++++++++++++
4 files changed, 234 insertions(+)
diff --git a/.gitignore b/.gitignore
index 2eefefc..e26cb84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,7 @@ build/
data/fleet-autoresponder-ledger.json
data/fleet-autoresponder-runlog.jsonl
data/fleet-autoresponder-state.json
+# token-age-warn runtime data (token-hash prefixes + per-account state — not source)
+data/token-age-state.json
+data/latest.json
+data/email-last.json
diff --git a/PLAN-OAUTH-DURABLE-TOKENS.md b/PLAN-OAUTH-DURABLE-TOKENS.md
new file mode 100644
index 0000000..1d92f77
--- /dev/null
+++ b/PLAN-OAUTH-DURABLE-TOKENS.md
@@ -0,0 +1,79 @@
+# George OAuth — ending the weekly-token treadmill
+
+_Decision memo. Read-only inventory + migration paths. The migration itself is
+Steve-gated (Google Cloud console + Workspace admin console + possible CASA spend)._
+
+Authored 2026-07-01, Council #3 (vp-engineering, "kill the George OAuth weekly-token treadmill").
+
+## The problem
+
+The Cloud OAuth app is in **"Testing" publishing status → refresh tokens expire in 7 days.**
+Every week the tokens die, calendar push + fleet mail silently stop, and the
+`dw-appointments-oauth-canary` just *watches the symptom*. This is monitoring debt:
+we built an alarm for a wound instead of removing the cause.
+
+## Inventory (from `server.js`)
+
+**ONE OAuth client** (`GMAIL_CLIENT_ID` / `GMAIL_CLIENT_SECRET`) is reused across **4 accounts**,
+each holding its own `*_REFRESH_TOKEN` that the app rewrites into `.env` on re-consent:
+
+| Account key | Address | Type | Notes |
+|---|---|---|---|
+| GMAIL | steve@designerwallcoverings.com | **Workspace** | fleet send path (daily-overnight-skus, digests, reminders) |
+| INFO | info@designerwallcoverings.com | **Workspace** | the 209-domain catch-all forwards here |
+| PERSONAL | steveabramsdesigns@gmail.com | consumer @gmail | |
+| AGENTABRAMS | theagentabrams@gmail.com | consumer @gmail | |
+
+**Flow:** `access_type: 'offline'` + `prompt: 'consent'`, Web-application client (redirect URIs
+present). Standard installed/web OAuth — NOT a service account.
+
+**Scopes requested:**
+- **Restricted:** `gmail.modify`, `gmail.settings.basic`, `gmail.settings.sharing`, `drive`
+- **Sensitive:** `calendar`, `documents`, `spreadsheets`, `presentations`, `forms.body`,
+ `forms.responses.readonly`, `tasks`
+
+## The decisive constraint = restricted scopes
+
+An **External + Production** app that keeps restricted scopes (`gmail.modify`, `drive`)
+triggers Google's **annual CASA third-party security assessment** (~$500–$4,500/yr) + brand
+verification. So "just push the app to Production" is the expensive path. Avoid it if possible.
+
+## Recommended migration — split by account type
+
+### Workspace accounts (steve@ + info@) — the accounts that actually matter
+These carry the entire fleet send path and the 209-domain catch-all. Two verification-free fixes:
+
+1. **BEST — Service account + domain-wide delegation (DWD)** in the Workspace admin console.
+ Tokens are minted per-request from the SA key: **no refresh token, no 7-day expiry, no
+ consent screen, no verification, $0 ongoing.** Requires a small code change (swap the
+ OAuth2Client for a JWT/SA client that impersonates the two Workspace users).
+2. **CHEAPEST if the Cloud project lives in the designerwallcoverings.com org — flip consent
+ screen User Type → "Internal".** One console toggle makes Workspace tokens durable, **no
+ code change.** Caveat: Internal apps can only be authorized by users *in the org*, so the
+ two consumer @gmail accounts break and need their own separate (External) client — which
+ they need anyway once the client is split.
+
+### Consumer @gmail accounts (personal + agentabrams)
+Can't use SA/DWD or Internal. Options, cheapest first:
+- **(a)** Fold their duties onto the Workspace (e.g. a send-as alias on steve@) and **retire
+ consumer OAuth entirely** → everything becomes SA/DWD. Cleanest end state.
+- **(b)** Give them a separate OAuth client requesting only **non-restricted** scopes to dodge
+ CASA (leaves just free brand verification).
+- **(c)** Keep weekly re-consent but add a **pre-emptive 6-day WARN** so it's a scheduled
+ 30-second task, not a silent failure. (Shipped now — see `token-age-warn.mjs`, this is the
+ reversible stopgap while the migration decision is pending.)
+
+## The gated fork — two questions decide the plan (Steve answers)
+
+1. **Is the Cloud OAuth project inside the designerwallcoverings.com Workspace org?**
+ - Yes → the "Internal" toggle is a ~30-second durable fix for steve@ + info@.
+ - No / consumer-project → go straight to service-account + DWD for the two Workspace users.
+2. **For the 2 consumer accounts — fold into Workspace (retire their OAuth), or keep them on
+ weekly re-consent with the new WARN?**
+
+## What shipped now (ungated, reversible, local)
+- `token-age-warn.mjs` — read-only pre-emptive token-age clock. On each run it records the
+ last-good timestamp per account and emits a CNCP WARN + George email when any token crosses
+ ~6 days while still valid, so Steve can re-consent on schedule instead of mid-pipeline. This
+ removes the *silent* part of the failure; it does NOT remove the treadmill (the migration
+ above does).
diff --git a/token-age-warn.mjs b/token-age-warn.mjs
new file mode 100644
index 0000000..0b173e9
--- /dev/null
+++ b/token-age-warn.mjs
@@ -0,0 +1,115 @@
+// 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 = 6, DEAD_DAYS = 7; // Testing-mode refresh tokens die at 7d
+
+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}`);
diff --git a/token-age-warn.sh b/token-age-warn.sh
new file mode 100755
index 0000000..1946cc6
--- /dev/null
+++ b/token-age-warn.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# token-age-warn run — probe George OAuth token ages; on a WORSENING transition, post a
+# CNCP card + email Steve. READ-ONLY (probes Google's token endpoint, never writes creds).
+set -uo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"; DATA="$HERE/data"; mkdir -p "$DATA"; LOG="$DATA/run.log"
+log(){ echo "[$(date -Iseconds)] $1" | tee -a "$LOG"; }
+START_TS=$(date +%s)
+node "$HERE/token-age-warn.mjs" 2>&1 | tee -a "$LOG"
+LATEST="$DATA/latest.json"; [ -f "$LATEST" ] || { log "no output"; exit 1; }
+# Freshness guard: .mjs writes latest.json only on a completed run. If it crashed, the OLD
+# latest.json remains — don't re-alert on stale data; exit non-zero so the heartbeat reflects it.
+LATEST_MT=$(stat -f %m "$LATEST" 2>/dev/null || stat -c %Y "$LATEST" 2>/dev/null)
+if [ -n "$LATEST_MT" ] && [ "$LATEST_MT" -lt "$START_TS" ]; then
+ log "no fresh output this run (latest.json stale) — not alerting"; exit 1
+fi
+ALERT=$(jq -r '.alert' "$LATEST")
+log "token-age: worst=$(jq -r '.worst' "$LATEST") · $(jq -r '.warn' "$LATEST") WARN · $(jq -r '.crit' "$LATEST") CRIT · alert=$ALERT"
+[ "$ALERT" != "true" ] && { log "no worsening transition — silent"; exit 0; }
+
+CNCP="${CNCP_URL:-http://localhost:3333}"
+note="[GEORGE TOKEN-AGE $(date +%Y-%m-%d)] $(jq -r '[.accounts[]|select(.worsened)|.label+" → "+.level+" ("+.detail+")"]|join(" · ")' "$LATEST") — re-consent at George /auth, or decide the durable migration (PLAN-OAUTH-DURABLE-TOKENS.md)."
+curl -sS --max-time 10 "$CNCP/api/parking-lot" -H 'Content-Type: application/json' \
+ -d "$(jq -n --arg u "https://kamatera.tail79cb8e.ts.net:9850" --arg note "$note" '{url:$u,note:$note}')" >/dev/null 2>&1 \
+ && log "CNCP posted" || log "CNCP post failed"
+
+if [ -f "$HOME/.claude/skills/_shared/george-send.sh" ]; then
+ . "$HOME/.claude/skills/_shared/george-send.sh"
+ TO="${TOKEN_AGE_TO:-steve@designerwallcoverings.com}"
+ BODY=$(jq -r '"<div style=\"font-family:-apple-system,Helvetica,sans-serif;color:#222\"><h3 style=\"color:#b23b3b\">⚠ George OAuth token-age — worst="+.worst+"</h3><p style=\"font-size:13px;color:#444\">Testing-mode tokens die 7d after issuance. Re-consent at George /auth, or move to the durable path (service-account/DWD or Internal user-type — PLAN-OAUTH-DURABLE-TOKENS.md).</p>" + ([.accounts[]|"<div style=\"margin:6px 0;padding:8px 12px;border:1px solid #eee;border-radius:6px\"><b>"+.label+"</b> — "+.level+"<br><span style=\"font-size:12px;color:#666\">"+.detail+"</span></div>"]|join("")) + "</div>"' "$LATEST")
+ RESP="$(george_send steve-office "$TO" "⚠ George OAuth token-age — worst=$(jq -r '.worst' "$LATEST") — $(date +%Y-%m-%d)" "$BODY")"
+ echo "$RESP" > "$DATA/email-last.json"
+ echo "$RESP" | grep -q '"success":true' && log "alert emailed to $TO" || log "email failed"
+else
+ log "george-send.sh not found — CNCP-only alert"
+fi
+log "surfaced worsening token-age transition"
← 3608276 Add OAuth durability decision memo — retire the weekly-token
·
back to George Gmail
·
george-gmail: pre-write dormant SA/DWD (domain-wide delegati 769c500 →