Files
rootnode-academy/docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md
T
ced4568 777efd168d 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.
2026-08-25 14:45:18 -05:00

229 lines
14 KiB
Markdown

# 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).