fix: retry Google's transient domain-wide-delegation propagation-delay error
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:
@@ -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.
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user