docs(phase5c): enrollment communications documentation + Google Workspace runbook

- PHASE5C_ENROLLMENT_COMMUNICATIONS.md: the full design doc.
- RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md: every REQUIRES GOOGLE WORKSPACE ADMIN ACTION step
  (mailbox, service account, domain-wide delegation scoped to gmail.send only, SPF/DKIM/DMARC),
  environment variables, testing procedure, production secret handling, rotation, revocation,
  troubleshooting, quotas.
- RUNBOOK_STUDENT_ACTIVATION.md: day-to-day operation of the pipeline from /admin.
- WEBSITE_ENROLLMENT_INTEGRATION.md: explicit website-vs-RootNode responsibility boundary,
  the new contact_email field, and closes the 'RootNode doesn't send activation email yet'
  known limitation this doc previously documented.
- RUNBOOK_SELF_PACED_ENROLLMENT.md, RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md: point at the new
  automatic-email behavior and the Resend activation control.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md, ENGINEERING_SOP.md: close the enrollment-
  communication gap; new ENGINEERING_SOP §16.
- PRODUCTION_VALIDATION_CHECKLIST.md, README.md, platform/README.md: cross-references and a
  Phase 5C production-validation section.
This commit is contained in:
2026-08-25 14:45:18 -05:00
parent b38c46aaec
commit 777efd168d
11 changed files with 595 additions and 29 deletions
+31
View File
@@ -388,3 +388,34 @@ Full design: `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`, plus
real capstone completion, integration-endpoint concurrency, and a real Platform API restart.
Migration safety proven explicitly against a fresh Postgres seeded with realistic pre-5B data
(see that doc §14).
## 16. Enrollment Operations & Communications (Phase 5C)
Full design: `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`, plus
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` and `docs/RUNBOOK_STUDENT_ACTIVATION.md`.
- **What it is:** a durable, provider-neutral notification pipeline (`notification_events`, a
`FOR UPDATE SKIP LOCKED` worker polling on a plain `setInterval` — same pattern as the
existing session/login-attempt sweepers, no new infrastructure) that finally sends real email
for activation, enrollment confirmation, password reset, and password-changed — closing the
gap Phase 5A explicitly left open. Google Workspace adapter: Gmail API via a service account
with domain-wide delegation (no human password, no SMTP basic auth — see that doc §2 for why),
behind the same provider interface a deterministic dev provider implements, so no test or dev
run ever reaches a real inbox. Reuses Phase 5A's activation/reset token mechanism entirely — no
competing invitation-token system.
- **A real bug found and fixed:** `request-password-reset` would mint a reset token for a
never-activated student, which doesn't make sense (there's no password yet to reset) and would
have sent a confusing email. Fixed to fall through to the same anti-enumeration response as a
nonexistent account.
- **Tested, not just designed:** `platform/test/e2e-phase5c.js` (28 checks: activation vs.
confirmation routing, duplicate/concurrent webhook idempotency, temporary/permanent/exhausted
retry classification, resend/regenerate token invalidation, production token-secrecy gating
via a real container recreate with `COOKIE_SECURE=1`, minor/contact-email resolution, RBAC
scoping, metrics/audit hygiene, a real restart) and
`infra/scripts/drills/test-phase5c-enrollment-activation.js` (10 checks: one continuous story
from website enrollment through a real rendered/extracted activation email, a real Lab launch
and scenario completion, a second enrollment's confirmation email, and a full password-reset
round trip) — both against a live stack, using only the dev provider (no real network calls).
Live Google Workspace delivery itself is implemented and unit-level ready
(`infra/scripts/gws-email-test.js`, opt-in) but not yet validated against a real Workspace
domain — see that doc's final section.
+228
View File
@@ -0,0 +1,228 @@
# Phase 5C — Enrollment Operations & Communications
**Status: PHASE 5C COMPLETE. ENROLLMENT COMMUNICATIONS COMPLETE. STUDENT ACTIVATION PIPELINE READY.**
**LIVE GOOGLE WORKSPACE DELIVERY VALIDATION PENDING** — see §14.
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
email delivery for activation, enrollment confirmation, and password reset — plus finally
completing the production password-reset delivery Phase 5A explicitly left unfinished.
## 1. Architecture: provider-neutral by design
```
Enrollment/Auth code (integration-routes.js, admin-routes.js, student-auth-routes.js)
↓ (mint token if needed, then) notifications.enqueue()
notification_events row, committed in the SAME request — no network I/O yet
↓
notification-worker.js (a setInterval poller, same pattern as sessions.js's sweeper)
↓
email/provider.js — EMAIL_PROVIDER env var picks the implementation
↓
email/dev-provider.js (default) OR email/gws-provider.js
(never a real network call) (Gmail API, service account)
```
No enrollment/auth code anywhere imports a concrete provider. Swapping Google Workspace for a
different provider later means writing one new file under `platform/api/src/email/` and
changing `EMAIL_PROVIDER` — nothing else changes.
## 2. Google Workspace delivery mechanism: Gmail API + service account domain-wide delegation
**Selected mechanism:** the Gmail API's `users.messages.send`, authenticated as a Google Cloud
service account using Workspace domain-wide delegation to impersonate one dedicated sending
mailbox.
**Why, not SMTP or a human login:**
- **No human password, ever.** Basic SMTP auth (or an "app password") means storing credentials
that log in as an actual person's Workspace account — anyone with that value could read that
mailbox's mail, change its settings, or use it to send as that person. A service account has
no login of its own and no inbox; domain-wide delegation grants it exactly one narrow OAuth
scope (`gmail.send`) against one specific mailbox, nothing more.
- **Least privilege.** `gmail.send` cannot read mail, cannot manage the mailbox, cannot act as
any address other than the one configured. Compare to SMTP relay, which typically wants either
a static allow-listed sending IP (fragile for infrastructure that isn't pinned yet) or a
Workspace login.
- **This is Google's own documented pattern** for "an application needs to send mail as a
specific Workspace address" — see `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` for the exact
setup steps and links.
**No `googleapis`/`google-auth-library` dependency.** The JWT-bearer OAuth flow (RS256-sign a
short JWT, exchange it for an access token, POST one REST call) is ~120 lines using only Node's
built-in `crypto` and `fetch` — consistent with this codebase's established "hand-roll before
adding a dependency" convention (CSRF tokens, Prometheus exposition, and CSV parsing all do the
same). See `platform/api/src/email/gws-provider.js`.
## 3. RootNode email identity
`EMAIL_FROM_NAME` / `EMAIL_FROM_ADDRESS` / `EMAIL_REPLY_TO` — all environment-configured, no
default mailbox address baked into the code (`EMAIL_FROM_NAME` defaults to the display string
"RootNode Academy"; `EMAIL_FROM_ADDRESS` has no default at all — sending fails closed with a
clean `gws_not_configured` error until it's set, rather than guessing a real mailbox that
doesn't exist in this repository's configuration). `EMAIL_FROM_ADDRESS` doubles as the Gmail API
adapter's delegated-user (`sub`) claim — it must be a real mailbox in the Workspace domain,
authorized for delegation (see the runbook).
DNS/domain requirements before any of this can send real mail in production — SPF, DKIM, DMARC,
and Workspace's own sender-authorization step — are **entirely external to this repository**
and are documented, not performed, in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (marked
`REQUIRES GOOGLE WORKSPACE ADMIN ACTION`).
## 4. Notification model
`notification_events` (migration `021_notification_events.sql`): `student_id` (required — every
notification this phase sends is about a specific student), `enrollment_id` (nullable —
password-reset/changed notifications have none), `notification_type`
(`activation`/`enrollment_confirmation`/`password_reset`/`password_changed`), `recipient_email`
(a snapshot, not a live join — see §10), `status`
(`queued`→`sending`→`sent`/`failed`, or `cancelled`, unused this phase but reserved),
`attempt_count`/`next_attempt_at` for bounded exponential backoff, `last_error_code` (a
classified string, never a raw stack trace), and `metadata` JSONB.
**Privacy note on `recipient_email`:** duplicates PII already stored elsewhere (`students.email`
or an enrollment's `contact_email`) purely so the worker and any later delivery audit don't need
to reconstruct "what address would this have gone to" after the student record potentially
changes. Same sensitivity class as data already in this database — never a new exposure, and
never returned unmasked through any admin API (`notifications.js`'s `maskEmail`/`toAdminView`
are the only shapes any route returns).
## 5. The transient-plaintext-token design (read this before touching token handling)
Phase 5A's invariant — only a HASH of an activation/reset token is ever persisted long-term
(`students.credential_token_hash`) — is unchanged and non-negotiable. But an activation/reset
*email* needs the real token to build a working link, and the whole point of this phase is that
the actual send happens later, asynchronously, in the worker — not in the request that
originally minted the token.
The resolution: the triggering request mints the token exactly as Phase 5A already does
(synchronously, in-request), and hands the plaintext to `notifications.enqueue()`, which stores
it in that one row's `metadata.token` **only until the row reaches a terminal state**
(`markSent`/`markFailedOrRetry` both scrub it — see their comments in `notifications.js`). This
is a deliberate, narrow, documented exception for the row that exists specifically to deliver
the plaintext once — not a second credential store, and never returned by any admin API
regardless of whether it's still present (`toAdminView` never includes `metadata` at all).
Activation notifications from a **new enrollment** are the one case where no token is minted
ahead of time and handed in — `notifyForEnrollment()` mints it itself, at enqueue time, because
there's no other synchronous caller already holding one. Everywhere else (admin create/convert/
reset-password, self-service forgot-password, admin resend), the existing Phase 5A minting call
site is reused unchanged and its plaintext is simply also passed to `enqueue()`.
## 6. Activation flow
Reuses Phase 5A's activation token mechanism entirely — no competing invitation-token system was
built. `issueCredentialToken` already unconditionally invalidates any previous outstanding token
on mint (Phase 5A), which means **"resend" and "regenerate" are mechanically the same
operation**: there is never a moment with two simultaneously valid activation links, so a second
button that did something different would have nothing different to do. `POST /api/admin/
students/:id/resend-activation` is the one action that covers both.
```
Enrollment event/admin enrollment → student matched/created
→ credentials already active? → enrollment_confirmation (§7)
→ else → issueCredentialToken('activation') → notifications.enqueue()
→ worker delivers via the configured provider
→ student opens the link → existing Set Password flow (Phase 5A, unchanged)
→ token consumed atomically with setting the password
→ account active, student can log in
```
## 7. Enrollment confirmation flow
Fires whenever a **new enrollment** is created (integration event or admin manual enroll) for a
student who **already has credentials** (`students.password_hash IS NOT NULL`). No token, no
link beyond "go sign in" — `notifyForEnrollment()` (shared by both creation paths) is the single
decision point, so a website-triggered and an admin-triggered enrollment behave identically.
## 8. Password reset flow — now actually delivered
Phase 5A's `POST /api/student/request-password-reset` already minted a token and, only outside
production, echoed it back for local/test use. Phase 5C adds exactly one thing: the same
minted token is now also handed to `notifications.enqueue()`, so a real email goes out. Every
other behavior is unchanged: the response is the same generic anti-enumeration message
regardless of whether the account exists, is inactive, or was never activated (this phase
additionally excludes never-activated accounts, a real fix — see §13); production never returns
the token, dev/test still can. `POST /api/student/reset-password` (consuming the token) now also
enqueues a `password_changed` security-notice email once the new password is set.
## 9. Password-changed / security notice
Fires after both `POST /api/student/change-password` (self-service) and `POST /api/student/
reset-password` (token consumption) succeed — no token, no link, purely "this happened, if it
wasn't you here's what to do."
## 10. Minor/no-email communication design
Phase 5A/5B's minor-safe model (a student may have `login_identifier` with no `email` at all) is
preserved exactly. `enrollments.metadata.contact_email` (an existing JSONB column, not a new
migration) holds an OPTIONAL "who to notify about THIS enrollment" address — a guardian's email
collected at checkout, for example. **It is never written to `students.email`** — that field
stays reserved for the student's own identity/login credential; a guardian is not a student.
`enrollment.js`'s `enrollmentContactEmail()` is the one accessor with that one meaning; recipient
resolution (`notifications.js`'s `resolveRecipient`) tries the student's own email first, falls
back to the enrollment's contact email only for enrollment-scoped notification types, and
correctly has no fallback at all for password-reset/changed (which require the student to
already be able to log in themselves). This is deliberately transitional — not a guardian
account, not a linked identity, no relationship table — and does not block a future real
Guardian Portal from being built on top of it later.
## 11. Website → enrollment → communication
`POST /api/integrations/enrollments` (Phase 5B, unchanged) now also decides and enqueues the
right notification, but **only when its own enrollment-creation call reports `created: true`**
(a genuinely new enrollment, not an idempotent replay). This is the entire duplicate-webhook
safety net for notifications: since Phase 5B's DB-level idempotency already guarantees at most
one caller ever sees `created: true` for a given event/race, at most one notification is ever
queued, with zero new dedup mechanism needed.
## 12. Retry/failure handling
`markFailedOrRetry` classifies every provider error as `temporary` or `permanent` (both
providers' `send()` throw an `Error` with `.errorClass`/`.errorCode` set). Permanent failures
(bad recipient, missing/invalid Google config) go straight to `failed` on the first attempt —
retrying can't fix them. Temporary failures (network/provider hiccups, rate limiting) requeue
with bounded exponential backoff (30s base, doubling, capped at 1 hour) until `MAX_ATTEMPTS` (5)
is exhausted, then `failed` too. Never an infinite loop; every attempt is persisted
(`attempt_count`, `last_error_code`).
## 13. Real bugs found and fixed
- `request-password-reset` would previously mint a `reset` token for a student who had **never
been activated** (`password_hash IS NULL`) — a "reset" for an account with no password to
reset doesn't make semantic sense, and would have sent a confusing "reset your password" email
to someone who needs activation, not reset. Fixed: that branch now also checks
`student.password_hash` and falls through to the same generic anti-enumeration response,
exactly as if the account didn't exist.
- **HTML injection in email templates.** `displayName` (which can come straight from a website
integration event's `student.display_name` — genuinely external, caller-controlled text, not
admin-typed) was interpolated directly into the HTML email body with no escaping. `templates.js`
now escapes every user-controlled value before it reaches HTML (`escapeHtml`); the plain-text
bodies are correctly left unescaped, since there's no markup there to break. Both fixes are
regression-tested in `e2e-phase5c.js`.
## 14. Testing
- `platform/test/e2e-phase5c.js` (28 checks): activation vs enrollment-confirmation decision,
duplicate/concurrent webhook idempotency, temporary/permanent/exhausted retry classification,
resend/regenerate token invalidation, activation/reset single-use and expiration, production
token-secrecy gating (a real container recreate with `COOKIE_SECURE=1`), minor/contact-email
resolution, RBAC scoping, metrics/audit hygiene, and a real Platform API restart.
- `infra/scripts/drills/test-phase5c-enrollment-activation.js` (10 checks): one continuous
story — website enrollment → activation email rendered and its URL extracted exactly like a
real inbox click → password set → real Lab launched → real scenario completed → a second
enrollment gets confirmation not activation → full password-reset round trip with session
revocation → the minor/contact-email path.
- Both against a live `docker-compose.dev.yml` stack, using the deterministic dev email provider
exclusively — **no test in this phase ever makes a real network call to Google or anyone
else.**
- Migration safety proven explicitly against a fresh Postgres taken through every prior phase's
migrations, seeded with realistic Phase 5B data (credentialed student, minor/no-email student,
cohort, enrollment with a contact-email, progress, attempts, achievements, classroom history),
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.
**Live Google Workspace delivery has not been validated** — no service account/domain-wide
delegation exists yet for this RootNode Workspace domain. The adapter (`gws-provider.js`) is
fully implemented and unit-testable at the code level; `infra/scripts/gws-email-test.js` is
ready to run the moment credentials exist (opt-in, `RUN_GWS_EMAIL_TEST=true`, sends exactly one
message to one operator-configured test address — see the runbook).
+20
View File
@@ -82,5 +82,25 @@ can prove in advance.
admin portal, and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` is what the website team
actually integrated against (update it if reality drifted during that integration)
## Enrollment communications / Google Workspace email (Phase 5C — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md)
- [ ] Every step in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` marked REQUIRES GOOGLE WORKSPACE
ADMIN ACTION has actually been completed (sending mailbox, service account, domain-wide
delegation scoped to `gmail.send` only, SPF/DKIM/DMARC)
- [ ] `EMAIL_PROVIDER=gws` set in the real production environment — confirm it is NOT left at
the dev-provider default, which would silently mean no real student ever receives email
- [ ] `GOOGLE_SERVICE_ACCOUNT_JSON`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_REPLY_TO`,
and `PUBLIC_APP_ORIGIN` are all set from the real production secret store, not left at any
dev/example value
- [ ] `infra/scripts/gws-email-test.js` (opt-in, `RUN_GWS_EMAIL_TEST=true`) run once against a
real `TEST_EMAIL_RECIPIENT` and confirmed delivered — the one and only "does this actually
work" proof this checklist can't fake
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms the dev-preview
notification route (`GET /api/admin/notifications/:id/dev-preview`) is unreachable and no
activation/reset token is ever returned by any API response
- [ ] `GET /metrics`'s notification gauges/counters contain no student_id/email/token/
notification_id/external_event_id in any label value on a real production scrape
- [ ] `docs/RUNBOOK_STUDENT_ACTIVATION.md`'s steps still make sense walked against the real
admin portal, including a real (not dev-provider) activation email actually arriving
Only once every box above is checked against the real server does the deployment graduate from
"infrastructure package built" to "actually in production."
+11 -4
View File
@@ -80,11 +80,18 @@ this flow specifically — see the Phase 4.1 report for the full run results.
entitlement (pending/future/expired/cancelled), server-derived completion, and the resulting
self-paced student dashboard.
6. ~~**No enrollment communication.**~~ **Closed in Phase 5C.** An enrolled student now actually
gets told — real activation/enrollment-confirmation/password-reset email, delivered
asynchronously through a provider-neutral notification worker (Google Workspace via the
Gmail API in production, a deterministic dev provider everywhere else). See
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
## What Phase 4.1 deliberately did NOT do here
No signup flow, no student password/email auth implementation, no per-student curriculum
assignment route, no payments, no public course catalog, no held/premium scenario content. All
explicit non-goals per this phase's authorization. Student authentication (Phase 5A) and the
self-paced enrollment/entitlement model (Phase 5B) have both since been built; a public
self-registration *page*, real payment processing, and a Parent/Guardian portal remain
deliberately out of scope — see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §12.
explicit non-goals per this phase's authorization. Student authentication (Phase 5A), the
self-paced enrollment/entitlement model (Phase 5B), and enrollment/account-lifecycle
communication (Phase 5C) have all since been built; a public self-registration *page*, real
payment processing, and a Parent/Guardian portal remain deliberately out of scope — see
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §12 and `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`.
+166
View File
@@ -0,0 +1,166 @@
# Runbook: Google Workspace Email Configuration
Everything in this document happens OUTSIDE this repository, in the Google Admin Console and
Google Cloud Console, and is marked **REQUIRES GOOGLE WORKSPACE ADMIN ACTION**. Nothing here can
be scripted or automated from the codebase — Chase (or whoever holds Workspace super-admin) has
to do these steps by hand, once, before RootNode Academy can send real email.
See `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` §2 for why this mechanism was chosen over SMTP
or a human login.
## 1. Selected mechanism
**Gmail API (`users.messages.send`) via a Google Cloud service account using Workspace
domain-wide delegation**, impersonating one dedicated sending mailbox. No human password is
ever stored by RootNode. The delegated scope is exactly `https://www.googleapis.com/auth/
gmail.send` — nothing broader.
## 2. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: create the sending mailbox
Decide the address RootNode sends as — e.g. `[email protected]` or
`[email protected]`. This repository does not assume or hard-code one; it's whatever
you set `EMAIL_FROM_ADDRESS` to (§5).
1. In Google Admin Console (admin.google.com) → Directory → Users, create this mailbox as a
real Workspace user (or a Google Group with "Collaborative Inbox" if you'd rather route
replies to a shared team inbox — either works with this mechanism, but a Group needs its own
delegation setup; a plain user mailbox is simpler and is what this runbook assumes).
2. Consider giving it a strong, unique, never-reused password and enabling 2-Step Verification
on it anyway, even though RootNode's application will never use that password — this account
still exists as a real Workspace identity and should be secured like any other.
## 3. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: create the service account (Google Cloud Console)
1. In Google Cloud Console (console.cloud.google.com), create or choose a GCP project associated
with your Workspace organization.
2. Enable the **Gmail API** for that project (APIs & Services → Library → search "Gmail API" →
Enable).
3. IAM & Admin → Service Accounts → Create Service Account. Name it something identifiable
(e.g. `rootnode-academy-mailer`). No project-level IAM roles are needed for this — it only
ever calls the Gmail API as the impersonated user, not as itself.
4. On that service account, create a JSON key (Keys → Add Key → Create new key → JSON). This
downloads a file containing `client_email` and `private_key` — **this is the sensitive
credential**, treat it exactly like a password (see §8).
5. Copy the service account's **Client ID** (a long numeric string, on the service account's
detail page) — needed for the next step.
## 4. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: authorize domain-wide delegation
1. Google Admin Console → Security → Access and data control → API controls → Domain-wide
Delegation → Add new.
2. Client ID: the numeric ID from step 3.5 above.
3. OAuth Scopes: `https://www.googleapis.com/auth/gmail.send` — **only this scope**. Do not add
broader Gmail scopes (readonly, modify, etc.) — RootNode's adapter never needs them and
granting them would violate least-privilege for no benefit.
4. Authorize.
This is the step that lets the service account act as `EMAIL_FROM_ADDRESS` specifically — it
does not grant access to any other mailbox in the domain.
## 5. RootNode-side environment variables
Set these on the Platform API's environment (production: real secret storage — see §8; local
dev: `platform/.env`, already gitignored):
| Variable | Value | Notes |
|---|---|---|
| `EMAIL_PROVIDER` | `gws` | Unset/anything else defaults to the dev provider — never real mail. |
| `GOOGLE_SERVICE_ACCOUNT_JSON` | the full JSON key file's contents, as one string | The sensitive credential — see §8. |
| `EMAIL_FROM_ADDRESS` | the mailbox from §2, e.g. `[email protected]` | Also the delegated-user (`sub`) claim. |
| `EMAIL_FROM_NAME` | e.g. `RootNode Academy` | Display name only, not sensitive. |
| `EMAIL_REPLY_TO` | optional, e.g. `[email protected]` | Where a student's reply actually goes, if different from the sending address. |
| `PUBLIC_APP_ORIGIN` | e.g. `https://learn.rootnodeacademy.com` | The origin activation/reset links are built from (§7 of the Phase 5C authorization) — required for any token-bearing email to render a working link. |
## 6. DNS: SPF, DKIM, DMARC (REQUIRES DNS ADMIN ACTION — not performed from this repository)
Google Workspace's own documentation is authoritative here; this is a summary of what must
exist before mail sent via the Gmail API from a Workspace mailbox reliably lands in inboxes
instead of spam:
- **SPF**: your domain's TXT record must include Google's SPF include
(`include:_spf.google.com`) if Workspace is your MX provider — check Admin Console → Apps →
Google Workspace → Gmail → Authenticate email for the exact current record Google recommends
for your domain.
- **DKIM**: Admin Console → Apps → Google Workspace → Gmail → Authenticate email → generate a
DKIM key for the domain, then publish the TXT record it gives you, then return to that page
and turn DKIM signing ON. Gmail API sends from a Workspace mailbox are signed the same way
regular Gmail sends are once this is on.
- **DMARC**: publish a `_dmarc` TXT record (start with `p=none` to monitor before enforcing) once
SPF and DKIM both pass consistently.
**Do not change DNS from this repository or these instructions** — this is exactly the kind of
external, admin-console/DNS-provider action that stays outside the codebase. Verify current
exact record values in Google's own Workspace admin help before publishing anything.
## 7. Testing procedure
**Local/dev — no live credentials needed at all.** `EMAIL_PROVIDER` unset (or anything other
than `gws`) uses the deterministic dev provider (`email/dev-provider.js`) — every notification
still goes through the full queue/worker/retry pipeline, but nothing ever reaches a real
network. `platform/test/e2e-phase5c.js` and the Phase 5C drills run entirely this way.
**Live delivery — opt-in, one message, once credentials exist:**
```bash
RUN_GWS_EMAIL_TEST=true \
TEST_EMAIL_RECIPIENT=[email protected] \
GOOGLE_SERVICE_ACCOUNT_JSON="$(cat /path/to/service-account-key.json)" \
EMAIL_FROM_ADDRESS=[email protected] \
EMAIL_FROM_NAME="RootNode Academy" \
node infra/scripts/gws-email-test.js
```
Sends exactly one clearly-marked test message to `TEST_EMAIL_RECIPIENT` — never inferred from
production/student data, never run automatically as part of any regression suite. Without
`RUN_GWS_EMAIL_TEST=true` it does nothing and exits cleanly.
## 8. Production secret handling
- `GOOGLE_SERVICE_ACCOUNT_JSON` is exactly as sensitive as a password — store it in whatever
secret manager/environment-injection mechanism the production deployment uses (see
`docs/PRODUCTION_DEPLOYMENT.md` for the general pattern this repo follows for other secrets
like `PROVISIONER_SHARED_SECRET`). **Never commit it, never put it in a Docker image layer,
never log it.** `gws-provider.js` reads it fresh from the environment on every send and never
writes it to disk.
- Nothing in this codebase ever returns `GOOGLE_SERVICE_ACCOUNT_JSON`'s contents, an access
token, or any part of it through an API response, log line, or audit event.
## 9. Credential rotation
1. Google Cloud Console → the service account → Keys → Add Key → Create new key (JSON) — this
creates an ADDITIONAL valid key without invalidating the old one yet.
2. Update `GOOGLE_SERVICE_ACCOUNT_JSON` in production's secret store to the new key, redeploy/
restart the Platform API.
3. Confirm delivery still works (§7's live test, or just watch `/api/admin/notifications` for a
real send after the restart).
4. Google Cloud Console → the service account → Keys → delete the OLD key.
Domain-wide delegation authorization (§4) is tied to the service account's Client ID, not to any
individual key — rotating keys does not require re-authorizing delegation.
## 10. Revocation (compromised credential / offboarding)
1. Google Cloud Console → the service account → Keys → delete the affected key immediately —
this invalidates it instantly, before anything else.
2. If the whole service account should stop being able to send at all: Google Admin Console →
Security → API controls → Domain-wide Delegation → remove that Client ID's authorization.
3. Rotate `EMAIL_FROM_ADDRESS`'s own Workspace login password as a precaution even though the
application never used it — a compromised service-account key doesn't imply the mailbox's own
credentials were exposed, but treat it as a "review this account" trigger.
## 11. Troubleshooting
| 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. |
| `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_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. |
| 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. |
## 12. Google Workspace quotas/limits relevant to this mechanism
Gmail API sending inherits the same per-user daily sending limits as regular Gmail/Workspace
sending (limits vary by Workspace edition — check current figures in Google's own Workspace
admin documentation, as these change over time and are not something to hard-code here). For
RootNode Academy's transactional volume (activation/enrollment/reset emails, not bulk/marketing
— explicitly out of scope, see the Phase 5C authorization §5/§28), this should sit comfortably
under any Workspace edition's per-user limit; revisit if enrollment volume grows substantially.
+4
View File
@@ -26,6 +26,10 @@ Optional start/end dates enforce access windows server-side — a future start d
access until then; an end date cuts off new Lab launches after it while leaving prior progress
visible.
As of Phase 5C, this also automatically emails the student — an activation link if they've never
had credentials, or an enrollment confirmation if they already do. See
`docs/RUNBOOK_STUDENT_ACTIVATION.md` for checking delivery status and resending.
**Activate a pending enrollment** (Programs → \[program\] → Enrollments table → Activate):
Only needed if you enrolled as `pending`. This is the moment a student actually gains entry.
+14 -6
View File
@@ -1,15 +1,20 @@
# Runbook: Student Account Lifecycle
Covers creating, converting, resetting, and revoking student accounts. See
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design.
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design and
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`/`docs/RUNBOOK_STUDENT_ACTIVATION.md` for the
automatic-email side of this, added in Phase 5C.
**Create a new credentialed student** (`/admin` → Students → Add student):
Fill in display name plus **either** email (older/self-paced) or login ID (younger/instructor-
managed) — leave both blank for a plain pilot account instead. Submitting shows an activation
link **once**: `https://<domain>/?activate=<token>`. Copy it immediately (print it, read it
aloud, hand it over) — it is never shown again and never stored in plaintext anywhere. The
student opens the link and sets their own password; the instructor never sees it.
link **once**: `https://<domain>/?activate=<token>`. As of Phase 5C, if the student has an
email, RootNode also sends it to them automatically — the on-screen link remains a manual-relay
fallback, useful when there's no deliverable address or the automatic email needs backup. Copy
it if you might need it (print it, read it aloud, hand it over) — it is never shown again and
never stored in plaintext anywhere. The student opens the link and sets their own password; the
instructor never sees it.
**Convert an existing pilot student to a real account** (Students → \[student\] → Convert):
@@ -40,5 +45,8 @@ students list — deactivation takes effect on their very next request, not just
login, because `studentSession` middleware re-checks `status='active'` every time.
**If a student says their activation/reset link "doesn't work":** tokens are single-use and
time-limited (activation 7 days, reset 1 hour). Just issue a new one via Convert or Reset
password — there's no way to "resend" the same token, and that's intentional.
time-limited (activation 7 days, reset 1 hour). Use Students → \[student\] → **Resend
activation** (or Reset password, for an already-activated account) to issue a new one — there's
no way to "resend" the same token, and that's intentional (only the most recent link is ever
valid, so there's never ambiguity about which one to use). See `docs/RUNBOOK_STUDENT_
ACTIVATION.md` for checking whether the automatic email actually delivered.
+71
View File
@@ -0,0 +1,71 @@
# Runbook: Student Activation Pipeline
Covers the day-to-day operation of the enrollment → activation → login pipeline from `/admin`.
See `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` for the underlying design and
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` for the email delivery mechanism itself.
## What happens automatically
Every time a student account gains a new enrollment — whether the website's checkout triggered
it (`POST /api/integrations/enrollments`) or an instructor enrolled them manually from
`/admin` → Programs — RootNode decides and sends the right email on its own:
- **Never activated before** → an activation email, with a one-time link to set their password.
- **Already has real credentials** → an enrollment confirmation email pointing them back to
sign in — no link/token involved.
Nothing needs to be triggered by hand for the normal case. The controls below are for when the
normal case didn't work.
## Checking delivery status
**Per student**: Students → \[student\] → the "Notifications" card shows every notification ever
sent to (or attempted for) that student, with type/status/attempt count. `sent` means it
actually reached the provider successfully; `failed` means it gave up (see below); `queued`/
`sending` means it's still in flight — check back in a few seconds, delivery happens through a
background worker, not the same request that triggered it.
**Everywhere**: `/admin` → Notifications (owner/admin only) — the same information across every
student, most recent first.
## If a student never got their activation email
1. Check Students → \[student\] → Notifications. If it shows `failed`, note the error — a
`no_recipient_address` failure means the student truly has no email or contact address on
file and can only be activated by an instructor manually relaying a link (see below); other
error codes usually mean a delivery-infrastructure issue worth checking
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`'s troubleshooting table for.
2. Students → \[student\] → Credentials → **Resend activation**. This invalidates whatever link
was outstanding and sends a fresh one — safe to click as many times as needed; only the most
recent link is ever valid, so there's never a "which link do I use" confusion for the student.
3. If the student genuinely has no deliverable address at all (a younger student with a
login-only account and no guardian contact email on file), convert/create them without email
in the first place (see `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md`) and relay the one-time
activation link shown in the admin UI directly (print it, read it aloud) — this manual path
still works exactly as it did before Phase 5C added automatic email.
## If a student says they didn't get an enrollment confirmation
Check Students → \[student\] → Notifications for the `enrollment_confirmation` row. If it's
`sent`, the email genuinely went out — check their spam folder, or confirm the address on file
(Students → \[student\]) is actually correct. If none exists for a given enrollment,
Programs → \[program\] → Enrollments → find the enrollment → (student detail page also works)
and use **Send enrollment confirmation** to trigger one explicitly.
## Password reset
Students should use "Forgot password" on the login screen themselves wherever possible — it's
delivered the same way as everything else here and the flow is self-service end to end.
If a student can't complete self-service reset (e.g. they no longer have access to the email a
guardian originally provided and it needs an instructor to re-trigger with a corrected address),
Students → \[student\] → Credentials → **Send password reset** issues a fresh reset link and
revokes all of that student's existing sessions immediately. In production, the instructor
never sees the token itself — the response is a confirmation that an email was queued, not the
link. If automatic delivery still can't reach the student, use `docs/RUNBOOK_STUDENT_ACCOUNT_
LIFECYCLE.md`'s manual-relay procedure, which is unaffected by any of this.
## What "resend" does NOT do
Resending activation does not touch the student's enrollment status, progress, achievements, or
attempt history — it only affects the credential/token side of their account. It's always safe.
+35 -12
View File
@@ -5,6 +5,21 @@ a successful purchase/signup into a real RootNode Academy student enrollment. Th
meant to be enough on its own — you should not need to read the Platform API's source to
integrate against it.
## Responsibility boundary
**The website is responsible for:** marketing, program selection, pricing, checkout/payment (now
or in the future), collecting enrollment/customer information, and sending ONE verified
enrollment event to the endpoint below once payment/signup is confirmed.
**RootNode's platform is responsible for everything after that:** student identity, program
entitlement, account activation, the entire password lifecycle, all operational enrollment
email (activation, enrollment confirmation, password reset — see
`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`), learning, Labs, progress, assessment, and
completion. **The website does not send email for platform activation or any other account
lifecycle event — RootNode owns that entirely, as of Phase 5C.** Do not build a parallel
"welcome" or "here's how to log in" email on the website side; it would either duplicate or
conflict with what RootNode already sends automatically.
## Architecture
```
@@ -67,7 +82,8 @@ X-RootNode-Integration-Key: <the secret>
"student": {
"email": "[email protected]",
"login_identifier": null,
"display_name": "Jamie Rivera"
"display_name": "Jamie Rivera",
"contact_email": null
}
}
```
@@ -81,6 +97,7 @@ X-RootNode-Integration-Key: <the secret>
| `student.email` | one of email/login_identifier required | The student's own email, if they have one. |
| `student.login_identifier` | one of email/login_identifier required | Use this instead of/alongside email for a student who doesn't have their own email address (a minor enrolled by a parent/guardian — see below). Any stable, unique string you control — e.g. an internal user ID from your own system works well. |
| `student.display_name` | no | Shown in the RootNode instructor portal and, once the student logs in, to themselves. Falls back to the email or login_identifier if omitted. |
| `student.contact_email` | no | **Only** for a student with no email of their own (§ below) — where to send THIS enrollment's activation/confirmation email (typically a guardian's address collected at checkout). Never treated as the student's own identity/login. |
### What you must NOT send
@@ -148,24 +165,30 @@ enrolled by a parent/guardian). Send `student.login_identifier` instead of (or i
`email` — any stable string you control, such as an internal account/order reference. You must
send **at least one** of `email` or `login_identifier`; sending neither is rejected.
If the enrolled student has no email at all, send `student.contact_email` too (e.g. the
guardian's address collected at checkout) so RootNode has somewhere to deliver the activation
email — otherwise the enrollment is created but nobody is notified automatically, and an
instructor has to relay the activation link manually. `contact_email` is stored only against
that specific enrollment, never copied into the student's own identity record.
There is no guardian/parent account system yet — the enrollment is created directly against the
student identity you provide. If you need to support "a parent manages multiple children's
enrollments" as a first-class concept later, talk to the RootNode team before building around a
workaround; the schema has room for it but it isn't implemented.
## What the student experiences after you call this
## What the student experiences after you call this (as of Phase 5C)
If `status` mapped to "active": the student can log in immediately (once RootNode's own
activation flow — a one-time setup link — reaches them; RootNode does not send that email/link
for you today, see "Known limitation" below) and sees the program on their dashboard right away.
If `status` mapped to "pending": the enrollment exists but the student can't start until a
RootNode instructor activates it — useful if your flow needs a manual review step before access
is granted.
If `status` mapped to "active": RootNode sends the student (or their enrollment's
`contact_email`) a real email automatically —an activation email with a one-time setup link if
they're new, or an enrollment confirmation if they already have a RootNode account — and they
can log in as soon as they act on it. **You do not need to send any email yourself for this** —
see "Responsibility boundary" above. If `status` mapped to "pending": the enrollment exists but
the student can't start until a RootNode instructor activates it — useful if your flow needs a
manual review step before access is granted; no email goes out until activation happens.
**Known limitation:** RootNode does not yet send the student's activation email itself. If the
student is brand new (no prior RootNode account), your integration is responsible for letting
them know they have access and how to set up their login, until RootNode's own email delivery
exists. Ask the RootNode team for the current interim process.
If the student has neither an `email` nor a `contact_email` on file, RootNode still creates the
account and enrollment, but nothing gets emailed automatically — an instructor has to relay the
activation link by hand. Send whichever address you have whenever you have one.
## Local/staging testing