fix: retry Google's transient domain-wide-delegation propagation-delay error
Release to Docker Hub / Build & Push Docker Image (push) Canceled after 0s
Release to Docker Hub / Create GitHub Release (push) Canceled after 0s

Live Google Workspace delivery validation surfaced a real, self-resolving
Google-side condition: freshly-authorized domain-wide delegation can take
up to about a minute to propagate across Google's auth infrastructure,
producing intermittent unauthorized_client on the OAuth token exchange
even with correct configuration. gws-provider.js now classifies
unauthorized_client as a temporary (retryable) error instead of permanent,
so the existing bounded-backoff worker absorbs it automatically.

Also documents the alias-vs-delegated-user env passthrough in
docker-compose.dev.yml, and records both real findings (this one and the
GOOGLE_DELEGATED_USER alias distinction) plus the full live-delivery
validation outcome in the runbook and Phase 5C doc.

Validated live: isolated gws-email-test.js send (DKIM/SPF passing,
delivered) and a full real lifecycle test (create student, enrollment,
real activation email, activate, login, see program, real password-reset
email, reset, login with new password) via [email protected].
This commit is contained in:
2026-08-25 21:36:12 -05:00
parent 7baf7074e5
commit 4c0a950afb
4 changed files with 43 additions and 8 deletions
+26 -6
View File
@@ -1,7 +1,9 @@
# Phase 5C — Enrollment Operations & Communications # Phase 5C — Enrollment Operations & Communications
**Status: PHASE 5C COMPLETE. ENROLLMENT COMMUNICATIONS COMPLETE. STUDENT ACTIVATION PIPELINE READY.** **Status: PHASE 5C COMPLETE. ENROLLMENT COMMUNICATIONS COMPLETE. STUDENT ACTIVATION PIPELINE READY.**
**LIVE GOOGLE WORKSPACE DELIVERY VALIDATION PENDING** — see §14. **LIVE GOOGLE WORKSPACE DELIVERY VALIDATED** — real activation and password-reset emails
delivered end to end via `[email protected]` (a Workspace alias), DKIM/SPF passing,
inbox delivery confirmed. See §14.
Phase 5B built the enrollment/entitlement model and the website integration boundary. What was Phase 5B built the enrollment/entitlement model and the website integration boundary. What was
still missing for a real student: nobody ever told them. This phase closes that gap — real still missing for a real student: nobody ever told them. This phase closes that gap — real
@@ -221,8 +223,26 @@ is exhausted, then `failed` too. Never an infinite loop; every attempt is persis
then this phase's migration applied — every prior record intact; a new notification correctly then this phase's migration applied — every prior record intact; a new notification correctly
links to the pre-existing student/enrollment; re-running the migration set is a no-op. links to the pre-existing student/enrollment; re-running the migration set is a no-op.
**Live Google Workspace delivery has not been validated** — no service account/domain-wide **Live Google Workspace delivery has been validated for real**, both in isolation
delegation exists yet for this RootNode Workspace domain. The adapter (`gws-provider.js`) is (`infra/scripts/gws-email-test.js`, one message, DKIM/SPF passing, delivered) and through the
fully implemented and unit-testable at the code level; `infra/scripts/gws-email-test.js` is full genuine flow: a real student created, enrolled, receiving a real activation email, setting
ready to run the moment credentials exist (opt-in, `RUN_GWS_EMAIL_TEST=true`, sends exactly one their password from the real link, logging in, seeing their enrolled program, requesting and
message to one operator-configured test address — see the runbook). receiving a real password-reset email, and completing the reset — every step against the live
Platform API, `EMAIL_PROVIDER=gws`, nothing simulated.
**One real Google Workspace limitation found and handled:** newly-authorized domain-wide
delegation does not take effect instantly across all of Google's auth infrastructure — the
identical token request succeeded, then failed with `unauthorized_client`, then succeeded again,
over the span of about a minute, before settling. This is Google-side propagation delay, not a
configuration error, and it self-resolved. `gws-provider.js` now classifies `unauthorized_client`
as a *temporary* failure (retried with the existing bounded backoff) rather than permanent —
this is exactly the failure mode that backoff exists for, and it means a real deployment doesn't
need a human to notice and manually retry during that propagation window. See
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`'s troubleshooting section.
**A second real-world detail found during setup, not a bug:** the sending address
(`[email protected]`) is a Workspace *alias* of an existing account rather than its
own paid seat — a deliberate cost-saving choice. Domain-wide delegation authorizes a real
account, not an arbitrary alias string, so `GOOGLE_DELEGATED_USER` (the alias's real owner) was
added as a setting distinct from `EMAIL_FROM_ADDRESS` (the visible sending address) — see §2 and
the runbook §5.
+1 -1
View File
@@ -171,7 +171,7 @@ individual key — rotating keys does not require re-authorizing delegation.
| Symptom | Likely cause | | Symptom | Likely cause |
|---|---| |---|---|
| Every send fails with `gws_not_configured` | `GOOGLE_SERVICE_ACCOUNT_JSON` or `EMAIL_FROM_ADDRESS` unset/malformed in the Platform API's environment. | | Every send fails with `gws_not_configured` | `GOOGLE_SERVICE_ACCOUNT_JSON` or `EMAIL_FROM_ADDRESS` unset/malformed in the Platform API's environment. |
| `gws_auth_failed` | Domain-wide delegation not authorized for this Client ID/scope, or the service account key was deleted/revoked. Re-check §4. | | `gws_auth_failed` | Domain-wide delegation not authorized for this Client ID/scope, or the service account key was deleted/revoked. Re-check §4. **If this happens intermittently right after newly authorizing delegation** (the exact same request succeeds, then fails with `unauthorized_client`, then succeeds again, over roughly a minute) — that's Google's own auth infrastructure propagation delay, not a config error. It self-resolves; the notification worker's existing retry/backoff already absorbs it (`gws-provider.js` classifies `unauthorized_client` as retryable), so no action is needed beyond waiting. |
| `gws_http_403` | The delegated `sub` (EMAIL_FROM_ADDRESS) isn't a real mailbox in this Workspace domain, or the Gmail API isn't enabled on the GCP project (§3.2). | | `gws_http_403` | The delegated `sub` (EMAIL_FROM_ADDRESS) isn't a real mailbox in this Workspace domain, or the Gmail API isn't enabled on the GCP project (§3.2). |
| `gws_rate_limited` (HTTP 429) | Gmail API per-user sending limits hit — see §12. The notification worker retries this automatically with backoff. | | `gws_rate_limited` (HTTP 429) | Gmail API per-user sending limits hit — see §12. The notification worker retries this automatically with backoff. |
| Mail sends successfully but lands in spam | SPF/DKIM/DMARC (§6) not fully configured/passing yet — this is a deliverability issue, not an application error. | | Mail sends successfully but lands in spam | SPF/DKIM/DMARC (§6) not fully configured/passing yet — this is a deliverability issue, not an application error. |
+9 -1
View File
@@ -71,7 +71,15 @@ async function getAccessToken() {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
const e = new Error(`Google OAuth token exchange failed: ${body.error_description || body.error || res.status}`); const e = new Error(`Google OAuth token exchange failed: ${body.error_description || body.error || res.status}`);
e.errorClass = res.status >= 500 ? 'temporary' : 'permanent'; // 'unauthorized_client' is retried, not treated as permanent — real-world observation
// during Phase 5C setup: freshly-authorized domain-wide delegation can take a few minutes
// to propagate across Google's auth infrastructure, producing this EXACT error
// intermittently (the identical request succeeded, then failed, then failed again, over a
// span of about a minute) even though the delegation is correctly configured. A config
// that's genuinely wrong still ends up 'failed' once MAX_ATTEMPTS is exhausted — this only
// buys the propagation-delay case a real chance to self-resolve instead of giving up on
// the very first attempt.
e.errorClass = (res.status >= 500 || body.error === 'unauthorized_client') ? 'temporary' : 'permanent';
e.errorCode = 'gws_auth_failed'; e.errorCode = 'gws_auth_failed';
throw e; throw e;
} }
+7
View File
@@ -80,6 +80,13 @@ services:
# network call no matter what recipient it's given. # network call no matter what recipient it's given.
PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200} PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200}
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy} EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy}
# Empty/unset by default (fail-safe: dev provider, no real email — see email/provider.js).
# Set all four in platform/.env only for a deliberate one-off live-delivery test; unset
# again afterward so routine dev/test runs never risk a real send.
EMAIL_PROVIDER: ${EMAIL_PROVIDER:-}
GOOGLE_SERVICE_ACCOUNT_JSON: ${GOOGLE_SERVICE_ACCOUNT_JSON:-}
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-}
GOOGLE_DELEGATED_USER: ${GOOGLE_DELEGATED_USER:-}
PLATFORM_API_PORT: "4300" PLATFORM_API_PORT: "4300"
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via # No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
# `npm run bootstrap-owner` for the first owner and the admin portal after that. # `npm run bootstrap-owner` for the first owner and the admin portal after that.