From 777efd168d63239c35afc5b89f9a7ed160caff7c Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 14:45:18 -0500 Subject: [PATCH] docs(phase5c): enrollment communications documentation + Google Workspace runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- README.md | 8 +- docs/ENGINEERING_SOP.md | 31 +++ docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md | 228 +++++++++++++++++++++ docs/PRODUCTION_VALIDATION_CHECKLIST.md | 20 ++ docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md | 15 +- docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md | 166 +++++++++++++++ docs/RUNBOOK_SELF_PACED_ENROLLMENT.md | 4 + docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md | 20 +- docs/RUNBOOK_STUDENT_ACTIVATION.md | 71 +++++++ docs/WEBSITE_ENROLLMENT_INTEGRATION.md | 47 +++-- platform/README.md | 14 +- 11 files changed, 595 insertions(+), 29 deletions(-) create mode 100644 docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md create mode 100644 docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md create mode 100644 docs/RUNBOOK_STUDENT_ACTIVATION.md diff --git a/README.md b/README.md index 16d4bc1..cdf15eb 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,11 @@ its core: boundary — server-to-server only, no browser session involved — is what the future public `rootnodeacademy.com` website/checkout will call to create these enrollments; see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`. +- As of Phase 5C, RootNode actually tells students what's happening — real activation, + enrollment-confirmation, and 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 — no test/dev run ever emails a real person). See + `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` and `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`. **Never publish the raw privileged container** — front it with isolation (that's what the Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays @@ -208,7 +213,8 @@ Key properties of the platform (not this image): Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`, -`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`. +`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`, +`docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md`. The production infrastructure package (Proxmox/Ansible/Terraform/firewall/monitoring/backup/CI) that runs all of this on real hardware lives in `infra/` — see diff --git a/docs/ENGINEERING_SOP.md b/docs/ENGINEERING_SOP.md index b311e49..acd955a 100644 --- a/docs/ENGINEERING_SOP.md +++ b/docs/ENGINEERING_SOP.md @@ -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. diff --git a/docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md b/docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md new file mode 100644 index 0000000..fd65dd2 --- /dev/null +++ b/docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md @@ -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). diff --git a/docs/PRODUCTION_VALIDATION_CHECKLIST.md b/docs/PRODUCTION_VALIDATION_CHECKLIST.md index 8474002..4686db2 100644 --- a/docs/PRODUCTION_VALIDATION_CHECKLIST.md +++ b/docs/PRODUCTION_VALIDATION_CHECKLIST.md @@ -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." diff --git a/docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md b/docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md index 94ba873..fc2fe3f 100644 --- a/docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md +++ b/docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md @@ -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`. diff --git a/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md b/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md new file mode 100644 index 0000000..8c3cb34 --- /dev/null +++ b/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md @@ -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. `noreply@rootnodeacademy.com` or +`academy@rootnodeacademy.com`. 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. `noreply@rootnodeacademy.com` | Also the delegated-user (`sub`) claim. | +| `EMAIL_FROM_NAME` | e.g. `RootNode Academy` | Display name only, not sensitive. | +| `EMAIL_REPLY_TO` | optional, e.g. `support@rootnodeacademy.com` | 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=you@example.com \ +GOOGLE_SERVICE_ACCOUNT_JSON="$(cat /path/to/service-account-key.json)" \ +EMAIL_FROM_ADDRESS=noreply@rootnodeacademy.com \ +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. diff --git a/docs/RUNBOOK_SELF_PACED_ENROLLMENT.md b/docs/RUNBOOK_SELF_PACED_ENROLLMENT.md index a48deb8..a65170c 100644 --- a/docs/RUNBOOK_SELF_PACED_ENROLLMENT.md +++ b/docs/RUNBOOK_SELF_PACED_ENROLLMENT.md @@ -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. diff --git a/docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md b/docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md index 8dfaa18..63c08c6 100644 --- a/docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md +++ b/docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md @@ -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:///?activate=`. 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:///?activate=`. 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. diff --git a/docs/RUNBOOK_STUDENT_ACTIVATION.md b/docs/RUNBOOK_STUDENT_ACTIVATION.md new file mode 100644 index 0000000..b6978d4 --- /dev/null +++ b/docs/RUNBOOK_STUDENT_ACTIVATION.md @@ -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. diff --git a/docs/WEBSITE_ENROLLMENT_INTEGRATION.md b/docs/WEBSITE_ENROLLMENT_INTEGRATION.md index 1a0c87e..63ea14c 100644 --- a/docs/WEBSITE_ENROLLMENT_INTEGRATION.md +++ b/docs/WEBSITE_ENROLLMENT_INTEGRATION.md @@ -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: "student": { "email": "jamie@example.com", "login_identifier": null, - "display_name": "Jamie Rivera" + "display_name": "Jamie Rivera", + "contact_email": null } } ``` @@ -81,6 +97,7 @@ X-RootNode-Integration-Key: | `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 diff --git a/platform/README.md b/platform/README.md index 4048360..157e397 100644 --- a/platform/README.md +++ b/platform/README.md @@ -11,16 +11,18 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s detailed scenario attempt telemetry, progressive hints, curriculum completion rules, the exam + capstone assessment engine, achievements, per-instructor accounts, persistent student/ instructor sessions, RBAC, audit logging, real student credentials + remote classrooms - (Phase 5A), and self-paced programs/enrollment + the website integration boundary (Phase 5B), - and the instructor/admin control plane. No k3s, no `--privileged` — this replaced Phase 2A's - "identity" instance, which was a full Lab image booting an unused k3s cluster just to serve - these APIs. Design: + (Phase 5A), self-paced programs/enrollment + the website integration boundary (Phase 5B), and + a provider-neutral enrollment-communications pipeline (activation/enrollment-confirmation/ + password-reset email, Phase 5C), and the instructor/admin control plane. No k3s, no + `--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image + booting an unused k3s cluster just to serve these APIs. Design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split + Postgres migration), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts, RBAC, audit), `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine), `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom), - `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` (programs, enrollment, website integration boundary). - Operational reference: `api/README.md`. + `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` (programs, enrollment, website integration boundary), + `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` (notifications, email delivery). Operational + reference: `api/README.md`. - `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware routing derived from a server-controlled session — not a client-suppliable header/param — and, as of Phase 3B, server-side gating of progressive hint reveal). Design: