7 Commits
Author SHA1 Message Date
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
ced4568 b38c46aaec test(phase5c): permanent regression suite + enrollment/activation dress rehearsal
- platform/test/e2e-phase5c.js (29 checks): activation-vs-confirmation routing,
  duplicate/concurrent webhook idempotency, temporary/permanent/exhausted retry
  classification, resend/regenerate token invalidation, activation/reset single-use and
  expiration, HTML-injection regression, production token-secrecy gating via 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 through a real rendered/extracted activation email, a real Lab
  launch and scenario completion, a second enrollment's confirmation email, a full
  password-reset round trip, and the minor/no-email contact-email path.
- infra/scripts/gws-email-test.js: the opt-in (RUN_GWS_EMAIL_TEST=true) live Google Workspace
  delivery test — does nothing without the explicit flag, sends exactly one message to one
  operator-configured recipient when enabled.
2026-08-25 14:44:25 -05:00
ced4568 be92be81cd feat(observability): notification delivery metrics, no PII/high-cardinality labels
- rootnode_notifications_total{notification_type,status} (computed fresh from the DB, like
  the existing enrollment_breakdown gauge — never drifts from what's actually stored),
  rootnode_notification_queue_depth, rootnode_notification_oldest_queued_seconds,
  rootnode_notification_delivery_attempts_total{provider,result} (a real in-process counter —
  per-attempt outcomes aren't reconstructable from the DB after the fact),
  rootnode_student_activations_{success,invalid}_total. Every label is a bounded enum —
  never student_id/email/token/notification_id/external_event_id.
2026-08-25 14:43:48 -05:00
ced4568 f25535027c feat(admin): notification operations — resend activation, delivery status, RBAC-scoped views
- notification-routes.js: global list (owner/admin only), per-student list (scoped via
  requireStudentRelationshipAccess), resend-activation (mechanically the same as
  regenerate — Phase 5A's token model already guarantees only the latest is ever valid, so one
  action covers both), a dev-only preview route (unconditionally refuses when
  COOKIE_SECURE=1, mirroring student-auth-routes.js's existing dev_reset_token gate).
- NotificationsView.jsx: owner/admin global notification list, paginated, matching
  AuditView.jsx's exact convention.
- StudentsView.jsx: student detail gained a Resend activation control and a Notifications
  history card; Credentials card now explains the automatic-email behavior.
- AdminPortal.jsx: new Notifications nav entry (owner/admin only).
2026-08-25 14:43:10 -05:00
ced4568 3c7f0ca59c feat(activation): wire notifications into enrollment/auth, real password-reset delivery
- integration-routes.js: accepts student.contact_email, calls notifyForEnrollment on a
  genuinely new enrollment only (never on an idempotent replay — inherits Phase 5B's proven
  enrollment-level idempotency, no separate notification dedup needed).
- admin-routes.js: create-student/convert/reset-password now enqueue the corresponding
  notification using the SAME token already minted synchronously (never mints twice, which
  would silently invalidate the token just handed back in the response). Fixed a real bug:
  request-password-reset would mint a reset token for a never-activated student, which makes
  no sense and would confuse them — now falls through to the generic anti-enumeration response.
- admin-programs-routes.js: manual enrollment triggers the same notification decision as the
  website path.
- student-auth-routes.js: /activate records a metric; /change-password and /reset-password
  both queue a password_changed security notice; /request-password-reset now actually delivers
  email (production still never returns the token — unchanged anti-enumeration/isProduction
  gating, just now with a real send behind it).
- rbac.js: requireStudentRelationshipAccess/checkStudentRelationshipAccess — the notification
  visibility rule (cohort-shared or an enrollment they created), reusable beyond just
  enrollments.
- server.js: wires notification-routes.js and starts the notification worker in main().
- docker-compose.dev.yml: PUBLIC_APP_ORIGIN, EMAIL_FROM_NAME, and a parametrized COOKIE_SECURE
  (was hard-commented-out; needed so e2e-phase5c.js can actually prove production gating).
2026-08-25 14:42:34 -05:00
ced4568 9d382c95c5 feat(email): provider-neutral email service — Google Workspace (Gmail API) + dev provider
- email/provider.js: the seam. EMAIL_PROVIDER unset always defaults to the dev provider,
  regardless of any other setting — misconfiguration means email silently doesn't send for
  real, never that a dev/test run accidentally reaches a real student.
- email/gws-provider.js: Gmail API users.messages.send via a service account with Workspace
  domain-wide delegation, impersonating one dedicated mailbox. No human password, no SMTP
  basic auth, no googleapis/google-auth-library dependency — a hand-rolled RS256 JWT-bearer
  OAuth exchange + one REST call (~120 lines), matching this codebase's established
  hand-roll-before-adding-a-dependency convention.
- email/dev-provider.js: never makes a network call, returns the rendered message back to the
  caller for dev/test inspection. Supports magic +forcetempfail/+forcepermfail recipient
  markers so the permanent test suite can deterministically exercise retry/failure
  classification without needing a real flaky provider.
- email/templates.js: activation/enrollment-confirmation/password-reset/password-changed,
  HTML + plain-text, no external images/tracking/JS. Every interpolated value is HTML-escaped
  in the HTML body (a real injection finding — display_name can come straight from a website
  integration event).
- notification-worker.js: a setInterval poller (same pattern as sessions.js's sweeper), claims
  via notifications.js, renders via templates.js, builds activation/reset URLs from
  PUBLIC_APP_ORIGIN, stores a dev-only preview (never in production) so tests can extract the
  real URL without a mailbox.
2026-08-25 14:42:01 -05:00
ced4568 5183a6af09 feat(notifications): durable notification lifecycle + minor-safe recipient resolution
- migrations/021: notification_events (queued/sending/sent/failed/cancelled, bounded
  exponential backoff via attempt_count/next_attempt_at, a classified last_error_code, never a
  raw stack trace or provider credential).
- notifications.js: enqueue (records a no-recipient case as already-failed rather than
  silently dropping it), claimNext (FOR UPDATE SKIP LOCKED — safe if two worker processes ever
  run), markSent/markFailedOrRetry (bounded retry, terminal-state token scrubbing),
  notifyForEnrollment (the shared activation-vs-confirmation decision every enrollment-creation
  path calls), maskEmail/toAdminView (the only shapes any admin API ever returns).
- enrollment.js: enrollmentContactEmail() — reads back the minor/no-email transitional
  contact-email convention (stored in the existing enrollments.metadata JSONB, never written to
  students.email) that createFromIntegrationEvent/createManualEnrollment now accept.
2026-08-25 14:41:29 -05:00
36 changed files with 2322 additions and 60 deletions
+7 -1
View File
@@ -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
+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
+3
View File
@@ -10,6 +10,7 @@ import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView'
import ProgramsView from './ProgramsView'
import NotificationsView from './NotificationsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
@@ -26,6 +27,7 @@ const NAV = [
{ key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'notifications', label: 'Notifications', roles: ['owner', 'admin'] },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
]
@@ -67,6 +69,7 @@ export default function AdminPortal() {
{view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
{view === 'notifications' && ['owner', 'admin'].includes(instructor.role) && <NotificationsView />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
{view === 'account' && <AccountView instructor={instructor} />}
+52
View File
@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Phase 5C §16 — owner/admin only (enforced server-side too; a plain instructor's notification
// visibility is per-student, on the Students tab, never this global view — see
// rbac.js's requireStudentRelationshipAccess). No recipient email in full, no token, no
// metadata — everything here comes from notifications.js's toAdminView.
export default function NotificationsView() {
const [page, setPage] = useState(1)
const [result, setResult] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
setResult(null)
adminApi.notifications(page).then(setResult).catch((e) => setError(e.message))
}, [page])
const items = result?.items || []
return (
<div className={styles.card}>
<h2>Notifications</h2>
<p className={styles.muted}>{result ? `${result.total} total` : 'Activation, enrollment, and password-reset emails — most recent first.'}</p>
{error && <div className={styles.errorBox}>{error}</div>}
{!result ? <p className={styles.muted}>Loading…</p> : (
<>
<table>
<thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Attempts</th><th>Error</th></tr></thead>
<tbody>
{items.map((n) => (
<tr key={n.notification_id}>
<td>{new Date(n.created_at).toLocaleString()}</td>
<td>{n.notification_type.replace(/_/g, ' ')}</td>
<td className={styles.muted}>{n.recipient_email_masked || '—'}</td>
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
<td className={styles.muted}>{n.attempt_count}</td>
<td className={styles.muted}>{n.last_error_code || '—'}</td>
</tr>
))}
</tbody>
</table>
<div className={styles.pager}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
<span className={styles.muted}>Page {result.page} of {result.total_pages}</span>
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</>
)}
</div>
)
}
+32 -3
View File
@@ -168,6 +168,7 @@ function StudentDetail({ studentId, canManage, onBack }) {
const [events, setEvents] = useState(null)
const [sessions, setSessions] = useState(null)
const [enrollments, setEnrollments] = useState(null)
const [notifications, setNotifications] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
@@ -184,6 +185,8 @@ function StudentDetail({ studentId, canManage, onBack }) {
// Phase 5B §11 — scoped server-side the same way as everything else here: a plain
// instructor only sees enrollments they created or that share a cohort with them.
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
// Phase 5C §16 — same scoping rule (rbac.js's requireStudentRelationshipAccess).
adminApi.studentNotifications(studentId).then(setNotifications).catch(() => {})
}, [studentId])
useEffect(reload, [reload])
@@ -209,21 +212,47 @@ function StudentDetail({ studentId, canManage, onBack }) {
<h3>Credentials</h3>
{student?.has_credentials ? (
<>
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''}</p>
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''} An email is sent automatically wherever possible — see Notifications below.</p>
<div className={styles.formRow}>
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
const { reset_token } = await adminApi.resetStudentPassword(studentId)
setNotice(`Password reset. Link (shown once): ${window.location.origin}/?reset=${reset_token}`)
})}>Reset password</button>
setNotice(`Password reset. Link (shown once, for manual relay if email can't reach them): ${window.location.origin}/?reset=${reset_token}`)
})}>Send password reset</button>
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
</div>
</>
) : student?.email || student?.login_identifier ? (
<>
<p className={styles.muted}>Not yet activated. An activation link was queued — use this if the student never got it or it expired.</p>
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resendActivation(studentId))}>Resend activation</button>
</>
) : (
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
)}
</div>
)}
{canManage && (
<div className={styles.card}>
<h3>Notifications</h3>
{!notifications ? <p className={styles.muted}>Loading…</p> : notifications.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>When</th><th>Type</th><th>Status</th><th>Attempts</th></tr></thead>
<tbody>
{notifications.map((n) => (
<tr key={n.notification_id}>
<td>{new Date(n.created_at).toLocaleString()}</td>
<td>{n.notification_type.replace(/_/g, ' ')}</td>
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
<td className={styles.muted}>{n.attempt_count}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className={styles.card}>
<h3>Environment</h3>
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
+6
View File
@@ -128,4 +128,10 @@ export const adminApi = {
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
// ── Phase 5C §15/§16: enrollment operations & communications ────────────────────────────
notifications: (page = 1) => get(`/api/admin/notifications?page=${page}`),
studentNotifications: (studentId) => get(`/api/admin/students/${studentId}/notifications`),
resendActivation: (studentId) => post(`/api/admin/students/${studentId}/resend-activation`),
sendEnrollmentConfirmation: (studentId, enrollmentId) => post(`/api/admin/students/${studentId}/send-enrollment-confirmation`, enrollmentId ? { enrollment_id: enrollmentId } : undefined),
}
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Orchestrates the Phase 5C §25 enrollment/activation dress rehearsal: brings up the real dev
# stack, bootstraps an owner, runs test-phase5c-enrollment-activation.js, tears down.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}"
docker compose -f docker-compose.dev.yml up -d --build
cleanup() {
echo "==> Tearing down"
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "==> Waiting for the stack"
for _ in $(seq 1 60); do
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
sleep 2
done
echo "==> Bootstrapping the rehearsal owner"
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "P5C Rehearsal Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
INTEGRATION_KEY="$INTEGRATION_SHARED_SECRET" \
node ../infra/scripts/drills/test-phase5c-enrollment-activation.js
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
// Phase 5C §25 — the enrollment/activation dress rehearsal. One continuous realistic story: a
// simulated website enrollment event creates a brand-new student, the activation notification
// is queued and delivered through the (deterministic, never-real-network) dev email provider,
// the rendered email is inspected and its activation URL extracted exactly the way a real
// inbox/click would work, the student sets their password, logs in, sees their program, uses
// Continue Learning to launch a REAL Lab, completes one real scenario, and an admin can see
// both the enrollment and its delivery state. Then a second enrollment for the SAME
// (now-activated) student proves it gets an enrollment confirmation, not another activation.
// Then a full password-reset round trip, including session revocation. Then the minor/no-email
// contact-email path. No framework, matching project convention (see
// test-phase5b-selfpaced-rehearsal.js).
//
// Run via run-phase5c-enrollment-activation-drill.sh, which brings up
// platform/docker-compose.dev.yml first.
const assert = require('assert');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production';
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
}
function fireIntegrationEvent(payload) {
return fetch(`${GW}/api/integrations/enrollments`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY },
body: JSON.stringify(payload),
}).then(async (r) => ({ status: r.status, body: await r.json() }));
}
async function waitForSent(owner, studentId, notificationId, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
const row = notificationId ? body.find((n) => n.notification_id === notificationId) : body[0];
if (row?.status === 'sent') return row;
if (row?.status === 'failed') throw new Error(`notification failed: ${row.last_error_code}`);
await new Promise((r) => setTimeout(r, 500));
}
throw new Error('timed out waiting for delivery');
}
async function main() {
console.log('Phase 5C enrollment/activation dress rehearsal — slow, real k3s boots\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
let studentId, enrollmentId;
await step('website enrollment event creates a brand-new student + self-paced enrollment', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `p5c-rehearsal-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { email: `p5c-rehearsal-${RUN_ID}@example.com`, display_name: 'Rehearsal Student' },
});
assert.equal(status, 201);
studentId = body.student_id; enrollmentId = body.enrollment_id;
});
let activationUrl;
await step('an activation notification is queued and delivered through the dev provider', async () => {
const sent = await waitForSent(owner, studentId, null);
assert.equal(sent.notification_type, 'activation');
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
assert.equal(preview.to, `p5c-rehearsal-${RUN_ID}@example.com`);
assert.ok(preview.subject.includes('Activate'));
const match = preview.text.match(/(http:\/\/\S+\/\?activate=[A-Za-z0-9_-]+)/);
assert.ok(match, 'expected a real activation URL in the rendered email body');
activationUrl = match[1];
});
const student = makeSession(GW);
await step('student "clicks" the extracted URL and sets a password', async () => {
const token = new URL(activationUrl).searchParams.get('activate');
const { status } = await student.request('POST', '/api/student/activate', { token, password: 'RehearsalPass123!' });
assert.equal(status, 200);
});
let launchEnrollmentId, envId;
await step('student sees the Root program and Continue Learning works', async () => {
const { body: programs } = await student.request('GET', '/api/student/programs');
assert.equal(programs.length, 1);
assert.equal(programs[0].can_launch, true);
launchEnrollmentId = programs[0].enrollment_id;
assert.equal(launchEnrollmentId, enrollmentId);
});
await step('launches a REAL Lab and completes one real scenario', async () => {
const { status, body } = await student.request('POST', `/api/student/programs/${launchEnrollmentId}/launch`);
assert.equal(status, 200);
assert.equal(body.status, 'running');
const envRow = (await owner.request('GET', `/api/admin/students/${studentId}/environment`)).body.find((e) => e.status === 'running');
envId = envRow.environment_id;
const complete = await student.request('POST', '/api/progress/record', { scenario_id: 'pod-basics-mcq', status: 'completed', environment_id: envId });
assert.equal(complete.status, 200);
});
await step('progress persists and the admin sees both the enrollment and its delivery state', async () => {
const { body } = await student.request('GET', `/api/student/programs/${launchEnrollmentId}`);
assert.ok(body.progress.percent_complete > 0);
const { body: adminEnrollments } = await owner.request('GET', `/api/admin/students/${studentId}/enrollments`);
assert.equal(adminEnrollments[0].status, 'active');
const { body: adminNotifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
assert.ok(adminNotifs.every((n) => n.status === 'sent'));
});
// ── A second enrollment for the SAME, now-activated student -> confirmation, not activation ──
await step('admin creates a second program and enrolls the SAME student — enrollment confirmation, not another activation', async () => {
await owner.request('POST', '/api/admin/programs', { program_id: `p5c-rehearsal-second-${RUN_ID}`, name: 'Second Rehearsal Program', family: 'root' });
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: studentId, program_id: `p5c-rehearsal-second-${RUN_ID}`, status: 'active' });
assert.equal(status, 201);
const sent = await waitForSent(owner, studentId, null);
const { body: notifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
const confirmation = notifs.find((n) => n.enrollment_id === body.enrollment_id);
assert.equal(confirmation.notification_type, 'enrollment_confirmation');
});
// ── Password reset round trip ─────────────────────────────────────────────────────────────
let resetToken;
await step('password reset is requested, delivered, and the old session is revoked once used', async () => {
const res = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-rehearsal-${RUN_ID}@example.com` });
resetToken = res.body.dev_reset_token;
assert.ok(resetToken);
await waitForSent(owner, studentId, null);
const before = await student.request('GET', '/api/student/profile');
assert.equal(before.status, 200);
const resetRes = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'RehearsalReset123!' });
assert.equal(resetRes.status, 200);
const after = await student.request('GET', '/api/student/profile');
assert.notEqual(after.status, 200); // the OLD session is revoked
});
await step('the new password works', async () => {
const s = makeSession(GW);
const { status } = await s.request('POST', '/api/student/login', { identifier: `p5c-rehearsal-${RUN_ID}@example.com`, password: 'RehearsalReset123!' });
assert.equal(status, 200);
});
// ── Minor / no-email path ────────────────────────────────────────────────────────────────
await step('minor/no-email student: guardian contact_email receives the activation, never stored as students.email', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `p5c-rehearsal-minor-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { login_identifier: `p5c-rehearsal-minor-${RUN_ID}`, display_name: 'Rehearsal Minor', contact_email: `p5c-rehearsal-guardian-${RUN_ID}@example.com` },
});
assert.equal(status, 201);
const sent = await waitForSent(owner, body.student_id, null);
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
assert.equal(preview.to, `p5c-rehearsal-guardian-${RUN_ID}@example.com`);
const { body: students } = await owner.request('GET', '/api/admin/students');
const minorRow = students.find((s) => s.student_id === body.student_id);
assert.equal(minorRow.email, null);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
// Phase 5C §24 — the OPTIONAL, opt-in Google Workspace live-delivery test. Does NOTHING unless
// explicitly enabled — no test/regression run ever sends real email by accident.
//
// Usage (only when you actually have real Workspace credentials configured):
// RUN_GWS_EMAIL_TEST=true \
// [email protected] \
// GOOGLE_SERVICE_ACCOUNT_JSON='{"client_email":"...","private_key":"..."}' \
// [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 a
// production/student address, never inferred from anything in the database. Run it once after
// setting up domain-wide delegation to confirm the adapter actually works against the real
// Gmail API, not as part of any regular regression pass (see docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md).
if (process.env.RUN_GWS_EMAIL_TEST !== 'true') {
console.log('Skipped: set RUN_GWS_EMAIL_TEST=true to actually send a live test email. Nothing was sent.');
process.exit(0);
}
const recipient = process.env.TEST_EMAIL_RECIPIENT;
if (!recipient) {
console.error('Set TEST_EMAIL_RECIPIENT to the ONE address this test should send to — it is never inferred from anything else.');
process.exit(1);
}
const provider = require('../../platform/api/src/email/gws-provider');
async function main() {
console.log(`Sending one live test message via the Gmail API adapter to ${recipient}...`);
const result = await provider.send({
to: recipient,
fromName: process.env.EMAIL_FROM_NAME || 'RootNode Academy',
fromAddress: process.env.EMAIL_FROM_ADDRESS,
replyTo: process.env.EMAIL_REPLY_TO,
subject: '[RootNode Academy] Google Workspace adapter test',
text: `This is a one-off test of RootNode Academy's Google Workspace email adapter (Gmail API, service account domain-wide delegation). If you received this, live delivery is working. Sent at ${new Date().toISOString()}.`,
html: `<p>This is a one-off test of RootNode Academy's Google Workspace email adapter (Gmail API, service account domain-wide delegation).</p><p>If you received this, live delivery is working. Sent at ${new Date().toISOString()}.</p>`,
});
console.log('Provider accepted the message.');
console.log('Provider message ID:', result.providerMessageId);
console.log('\nGWS LIVE DELIVERY TEST: PASSED');
}
main().catch((e) => {
console.error('GWS LIVE DELIVERY TEST: FAILED');
console.error(` ${e.errorCode || 'error'}: ${e.message}`);
process.exit(1);
});
+8 -6
View File
@@ -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:
@@ -0,0 +1,44 @@
-- Phase 5C §4 — durable notification lifecycle. Every "we should email this student" moment
-- (activation, enrollment confirmation, password reset, password-changed) becomes a row here
-- FIRST, committed in the same transaction/request as the business action, and a separate
-- worker (notification-worker.js) is what actually talks to the email provider — the HTTP
-- request that triggers a notification never waits on outbound email network calls.
--
-- recipient_email is a SNAPSHOT taken at enqueue time, not a live join to students.email —
-- documented privacy note: this duplicates PII already stored on `students`/in an enrollment's
-- metadata (the minor/no-email contact address, see 020_enrollments.sql's `metadata` column,
-- Phase 5C's `contact_email` convention) purely so the worker and any later delivery audit
-- don't need to reconstruct "what address would this have gone to" after the fact — same
-- sensitivity class as data already in this database, not a new exposure. Never rendered
-- unmasked through admin APIs (see notifications.js's maskEmail).
--
-- metadata MAY transiently hold plaintext activation/reset token material between enqueue and
-- the worker's terminal (sent/failed) update — see notifications.js's header comment for the
-- exact reasoning. It is scrubbed the moment a row reaches a terminal state. NEVER a raw error
-- stack trace, NEVER a Google credential, NEVER a full provider payload.
CREATE TABLE IF NOT EXISTS notification_events (
notification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id TEXT NOT NULL REFERENCES students(student_id),
enrollment_id UUID REFERENCES enrollments(enrollment_id),
notification_type TEXT NOT NULL CHECK (notification_type IN ('activation', 'enrollment_confirmation', 'password_reset', 'password_changed')),
channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email')),
recipient_email TEXT,
provider TEXT,
provider_message_id TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sending', 'sent', 'failed', 'cancelled')),
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB
);
CREATE INDEX IF NOT EXISTS idx_notification_events_student ON notification_events(student_id);
CREATE INDEX IF NOT EXISTS idx_notification_events_enrollment ON notification_events(enrollment_id);
CREATE INDEX IF NOT EXISTS idx_notification_events_status ON notification_events(status);
-- The worker's claim query — partial index scoped to exactly the rows it ever selects from.
CREATE INDEX IF NOT EXISTS idx_notification_events_claimable ON notification_events(next_attempt_at) WHERE status = 'queued';
+17 -4
View File
@@ -9,6 +9,8 @@
const programsLib = require('./programs');
const enrollmentLib = require('./enrollment');
const notificationsLib = require('./notifications');
const studentsAuth = require('./students-auth');
const { buildProgramStatus } = require('./program-status');
function enrollmentErrorStatus(code) {
@@ -104,19 +106,30 @@ module.exports = function registerAdminProgramsRoutes(app, deps) {
});
admin.post('/api/admin/enrollments', requireAdminOrOwner, async (req, res) => {
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, metadata } = req.body || {};
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, contact_email, metadata } = req.body || {};
if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' });
// Phase 5C §10 — same transitional contact-email convention as the integration endpoint;
// folded into the same metadata JSONB an admin could already pass, not a new column.
const mergedMetadata = contact_email ? { ...(metadata || {}), contact_email: String(contact_email).trim() } : metadata;
let created;
try {
const created = await enrollmentLib.createManualEnrollment(getPool(), {
created = await enrollmentLib.createManualEnrollment(getPool(), {
studentId: student_id, programId: program_id, status, startsAt: starts_at, accessEndsAt: access_ends_at,
deliveryMode: delivery_mode, externalReference: external_reference, metadata, createdBy: req.instructor.instructor_id,
deliveryMode: delivery_mode, externalReference: external_reference, metadata: mergedMetadata, createdBy: req.instructor.instructor_id,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_created', resourceType: 'enrollment', resourceId: created.enrollment_id, result: 'success', sourceIp: req.ip, metadata: { student_id, program_id } });
res.status(201).json(created);
} catch (e) {
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
// §11 — an admin-created enrollment notifies exactly like a website-triggered one: activation
// for a not-yet-credentialed student, enrollment confirmation for an already-active one.
const program = await programsLib.getProgram(getPool(), program_id);
const notification = await notificationsLib.notifyForEnrollment(getPool(), studentsAuth, {
studentId: student_id, enrollmentId: created.enrollment_id, programName: program?.name,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: `notification_queued_${notification?.notification_type || 'unknown'}`, resourceType: 'notification', resourceId: notification?.notification_id, result: 'success', sourceIp: req.ip });
res.status(201).json(created);
});
admin.get('/api/admin/enrollments/:id', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
+25 -2
View File
@@ -153,6 +153,15 @@ module.exports = function registerAdminRoutes(app, deps) {
if (email || login_identifier) {
const issued = await issueCredentialToken(pool, studentId, 'activation');
activationToken = issued.token;
// Phase 5C §5/§6 — reuses the SAME token just minted above (never mints a second one,
// which would silently invalidate the one this response is about to hand the admin for
// manual relay). A student with only login_identifier and no email has no deliverable
// address here — enqueue() records that as an already-failed row (never silently
// dropped), and the admin's manual-relay token in the response still works either way.
await require('./notifications').enqueue(pool, {
studentId, notificationType: 'activation', plaintextToken: activationToken,
metadata: { display_name: display_name.trim() },
});
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
@@ -179,20 +188,34 @@ module.exports = function registerAdminRoutes(app, deps) {
throw e;
}
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
// Phase 5C — same token, same "never mint twice" reasoning as create-student above.
const { rows: nameRows } = await pool.query(`SELECT display_name FROM students WHERE student_id = $1`, [req.params.id]);
await require('./notifications').enqueue(pool, {
studentId: req.params.id, notificationType: 'activation', plaintextToken: token,
metadata: { display_name: nameRows[0]?.display_name },
});
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, activation_token: token });
});
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
// token and revokes every existing session, same "instructor never needs to know the
// student's password" principle as activation.
// student's password" principle as activation. Phase 5C §8/§15: also queues a real
// password-reset email whenever the student has a deliverable address — the response still
// returns reset_token unchanged (a deliberate, documented exception to "admin cannot view
// token" for the population with no deliverable address at all — see notification-routes.js's
// header comment).
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
await require('./notifications').enqueue(pool, {
studentId: req.params.id, notificationType: 'password_reset', plaintextToken: token,
metadata: { display_name: rows[0].display_name },
});
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ student_id: req.params.id, reset_token: token });
});
+35
View File
@@ -0,0 +1,35 @@
// Phase 5C §20 — the deterministic development/test email provider. NEVER makes a network
// call, NEVER reaches a real inbox, no matter what recipient it's given — this is the fail-safe
// default (see provider.js) precisely so a dev machine or a CI run can never accidentally email
// a real student. Returns the fully rendered message back to the caller so
// notifications.js can stash a preview (dev/test only — see its isProduction gate, mirroring
// student-auth-routes.js's existing dev_reset_token pattern) for tests/admins to inspect the
// activation/reset URL without a real mailbox.
async function send({ to, subject, text, html }) {
if (!to) {
const e = new Error('no recipient address');
e.errorClass = 'permanent'; e.errorCode = 'no_recipient_address';
throw e;
}
// Deterministic failure-injection for the permanent test suite (e2e-phase5c.js) — magic
// recipient markers, the same pattern test payment sandboxes use for "this card always
// declines." Only recognized by THIS provider; a real GWS send never looks at the address
// this way, so there's no risk of this leaking into production behavior.
if (to.includes('+forcetempfail')) {
const e = new Error('simulated temporary provider failure'); e.errorClass = 'temporary'; e.errorCode = 'simulated_temporary_failure';
throw e;
}
if (to.includes('+forcepermfail')) {
const e = new Error('simulated permanent provider failure'); e.errorClass = 'permanent'; e.errorCode = 'simulated_permanent_failure';
throw e;
}
console.log(`[dev-email] to=${to} subject="${subject}"`);
return {
ok: true,
providerMessageId: `dev-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
preview: { to, subject, text, html },
};
}
module.exports = { send };
+114
View File
@@ -0,0 +1,114 @@
// Phase 5C §2 — the Google Workspace adapter. Sends via the Gmail API
// (users.messages.send), authenticated as a Google Cloud service account using Workspace
// domain-wide delegation to impersonate a dedicated sending mailbox (EMAIL_FROM_ADDRESS).
//
// WHY this mechanism, not SMTP or a human login: Google's own guidance for a backend
// application sending mail as a specific Workspace address is a service account + domain-wide
// delegation + the Gmail API — no human password is ever involved, nothing is stored that
// could log in to the Workspace Admin Console or read the mailbox's other mail (the delegated
// scope here is exactly `gmail.send`, nothing broader), and it never touches or weakens the
// impersonated mailbox's own login credentials. Plain SMTP with a username/password (or an app
// password) was explicitly ruled out — see docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md §2 for the
// full comparison and Google Admin Console setup steps this requires (REQUIRES GOOGLE
// WORKSPACE ADMIN ACTION — cannot be completed from this repository).
//
// No googleapis/google-auth-library dependency — this is a small, auditable amount of code
// (RS256 JWT assertion -> OAuth2 token exchange -> one REST call), consistent with this
// codebase's existing "hand-roll before adding a dependency" convention (CSRF tokens,
// Prometheus exposition, and CSV parsing all do the same — see their own header comments).
const crypto = require('crypto');
function configError(message) {
return Object.assign(new Error(message), { errorClass: 'permanent', errorCode: 'gws_not_configured' });
}
// GOOGLE_SERVICE_ACCOUNT_JSON holds the standard JSON key Google issues for a service account —
// an environment/secret-manager value, loaded fresh on every send (no key material cached to
// disk by this code). Never a file committed to git, never a human password.
function loadServiceAccount() {
const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
if (!raw) throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is not set');
let parsed;
try { parsed = JSON.parse(raw); } catch { throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is not valid JSON'); }
if (!parsed.client_email || !parsed.private_key) throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is missing client_email/private_key');
return parsed;
}
// A short-lived (1 hour) OAuth2 access token via the JWT-bearer grant — minted fresh per send
// rather than cached, since this codebase's send volume doesn't justify the added state a
// cache would need to get right (token refresh races, expiry skew). See RFC 7523 / Google's
// "Using OAuth 2.0 for Server to Server Applications" documentation for the exact flow this
// implements.
async function getAccessToken() {
const sa = loadServiceAccount();
const impersonate = process.env.EMAIL_FROM_ADDRESS;
if (!impersonate) throw configError('EMAIL_FROM_ADDRESS is not set (required as the delegated-user "sub" claim)');
const now = Math.floor(Date.now() / 1000);
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({
iss: sa.client_email, sub: impersonate, scope: 'https://www.googleapis.com/auth/gmail.send',
aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600,
})).toString('base64url');
const signature = crypto.createSign('RSA-SHA256').update(`${header}.${payload}`).sign(sa.private_key).toString('base64url');
const assertion = `${header}.${payload}.${signature}`;
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const e = new Error(`Google OAuth token exchange failed: ${body.error_description || body.error || res.status}`);
e.errorClass = res.status >= 500 ? 'temporary' : 'permanent';
e.errorCode = 'gws_auth_failed';
throw e;
}
return body.access_token;
}
// A minimal, correct-enough RFC 2822 multipart/alternative message (text + HTML) — no MIME
// library dependency for the same "small and auditable" reason as the rest of this file.
function buildMimeMessage({ fromName, fromAddress, replyTo, to, subject, text, html }) {
const boundary = `rootnode-${crypto.randomBytes(12).toString('hex')}`;
const headers = [
`From: ${fromName} <${fromAddress}>`,
`To: ${to}`,
replyTo ? `Reply-To: ${replyTo}` : null,
`Subject: =?UTF-8?B?${Buffer.from(subject, 'utf8').toString('base64')}?=`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`,
].filter(Boolean).join('\r\n');
const body = [
`--${boundary}`, 'Content-Type: text/plain; charset="UTF-8"', '', text, '',
`--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', '', html, '',
`--${boundary}--`,
].join('\r\n');
return `${headers}\r\n\r\n${body}`;
}
async function send({ to, fromName, fromAddress, replyTo, subject, text, html }) {
if (!to) {
const e = new Error('no recipient address');
e.errorClass = 'permanent'; e.errorCode = 'no_recipient_address';
throw e;
}
const accessToken = await getAccessToken();
const raw = Buffer.from(buildMimeMessage({ fromName, fromAddress, replyTo, to, subject, text, html })).toString('base64url');
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ raw }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const e = new Error(`Gmail API send failed: ${body.error?.message || res.status}`);
e.errorClass = res.status >= 500 || res.status === 429 ? 'temporary' : 'permanent';
e.errorCode = res.status === 429 ? 'gws_rate_limited' : `gws_http_${res.status}`;
throw e;
}
return { ok: true, providerMessageId: body.id };
}
module.exports = { send };
+20
View File
@@ -0,0 +1,20 @@
// Phase 5C §1 — the provider-neutral seam. Business logic (notifications.js, the worker) never
// imports a concrete provider directly; it asks getEmailProvider() for whichever one
// EMAIL_PROVIDER selects. Swapping Google Workspace for a different provider later never
// touches enrollment/auth/notification code — only this file's require() target changes.
//
// Fail-safe default: EMAIL_PROVIDER unset -> 'dev', REGARDLESS of any other environment
// setting. Misconfiguring this in a real deployment means email silently doesn't send for
// real, never that a dev/test run accidentally reaches a real student's inbox (§20's explicit
// safety requirement) — the failure direction is deliberately the safe one.
const EMAIL_PROVIDER = (process.env.EMAIL_PROVIDER || 'dev').toLowerCase();
function getEmailProvider() {
if (EMAIL_PROVIDER === 'gws') return require('./gws-provider');
if (EMAIL_PROVIDER !== 'dev') {
console.error(`[email] Unknown EMAIL_PROVIDER "${EMAIL_PROVIDER}" — falling back to the dev provider (no real mail will send).`);
}
return require('./dev-provider');
}
module.exports = { getEmailProvider, EMAIL_PROVIDER };
+134
View File
@@ -0,0 +1,134 @@
// Phase 5C §9 — RootNode-branded transactional email templates. Every template returns
// { subject, text, html }: plain-text is never a second-class fallback, both are written
// deliberately. No external images, no tracking pixels, no JavaScript, no marketing content —
// this module is transactional/operational only (§5's explicit boundary).
//
// escapeHtml matters here more than in most of this codebase's server-rendered HTML: displayName
// can come straight from a website integration event's `student.display_name` (§21 "no
// arbitrary email template injection" — this is genuinely external, caller-controlled text, not
// admin-typed), and programName from an admin-created program name. Every value interpolated
// into an HTML template goes through this first; the plain-text bodies deliberately do NOT
// escape (there's no markup to break there — escaping would just show a student literal "&amp;").
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const BRAND = 'RootNode Academy';
const GREEN = '#2ee6a6';
const BG = '#0b0f0e';
const TEXT = '#e8f5ef';
const MUTED = '#8fa89c';
function wrap(bodyHtml) {
return `<!doctype html><html><body style="margin:0;padding:0;background:${BG};font-family:-apple-system,Segoe UI,Roboto,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${BG};padding:2rem 0;">
<tr><td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#121815;border:1px solid #1e2b26;border-radius:12px;padding:2rem;">
<tr><td style="font-family:monospace;color:${GREEN};font-size:1.1rem;font-weight:600;padding-bottom:1.25rem;">⎈ ${BRAND}</td></tr>
<tr><td style="color:${TEXT};font-size:0.95rem;line-height:1.6;">${bodyHtml}</td></tr>
<tr><td style="color:${MUTED};font-size:0.75rem;padding-top:2rem;border-top:1px solid #1e2b26;margin-top:1.5rem;">
This is an automated message from ${BRAND}. If you weren't expecting it, you can safely ignore it — no action is required.
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
}
// href is always a URL this module itself built from a token/origin, never caller-supplied text
// — still escaped for attribute-safety hygiene, not because it's expected to contain markup.
function button(url, label) {
return `<a href="${escapeHtml(url)}" style="display:inline-block;background:${GREEN};color:#05130a;font-weight:600;padding:0.65rem 1.25rem;border-radius:6px;text-decoration:none;margin:1rem 0;">${label}</a>`;
}
// A. Account activation / welcome — §9A. Fires for a brand-new credentialed account OR an
// existing pilot/unconverted student gaining their first real credential, always via the same
// existing Phase 5A activation-token mechanism.
function activation({ displayName, programName, activationUrl, expiresInDays }) {
const subject = `Welcome to ${BRAND} — Activate Your Account`;
const safeName = escapeHtml(displayName);
const safeProgram = escapeHtml(programName);
const programLine = programName ? `You've been enrolled in <strong>${safeProgram}</strong>. ` : '';
const text = `Hi ${displayName},
You've been given access to ${BRAND}. ${programName ? `You've been enrolled in ${programName}. ` : ''}To get started, set your password using the link below:
${activationUrl}
This link is valid for ${expiresInDays} day${expiresInDays === 1 ? '' : 's'} and can only be used once. Setting your password activates your account and signs you in.
If you weren't expecting this, you can ignore this email — no account will be created without you clicking the link above.
— ${BRAND}`;
const html = wrap(`
<p>Hi ${safeName},</p>
<p>You've been given access to ${BRAND}. ${programLine}To get started, set your password:</p>
<p>${button(activationUrl, 'Activate Your Account')}</p>
<p style="color:${MUTED};font-size:0.85rem;">This link is valid for ${expiresInDays} day${expiresInDays === 1 ? '' : 's'} and can only be used once. Setting your password activates your account and signs you in.</p>
<p style="color:${MUTED};font-size:0.85rem;">If you weren't expecting this, you can ignore this email — no account will be created without you clicking the link above.</p>`);
return { subject, text, html };
}
// B. Existing student, new program enrollment — §9B. No token, no link needed beyond "go sign in."
function enrollmentConfirmation({ displayName, programName, signInUrl }) {
const subject = `You're Enrolled — ${programName}`;
const safeName = escapeHtml(displayName);
const safeProgram = escapeHtml(programName);
const text = `Hi ${displayName},
You've been enrolled in ${programName} at ${BRAND}. Sign in to get started:
${signInUrl}
— ${BRAND}`;
const html = wrap(`
<p>Hi ${safeName},</p>
<p>You've been enrolled in <strong>${safeProgram}</strong> at ${BRAND}.</p>
<p>${button(signInUrl, 'Sign In and Start Learning')}</p>`);
return { subject, text, html };
}
// C. Password reset — §9C. Same anti-enumeration principle as the API itself: this template
// only ever renders for an account that actually exists and requested it — the enumeration
// protection lives in whether this notification gets queued at all (student-auth-routes.js),
// not in the template.
function passwordReset({ displayName, resetUrl, expiresInMinutes }) {
const subject = `Reset Your ${BRAND} Password`;
const safeName = escapeHtml(displayName);
const text = `Hi ${displayName},
We received a request to reset your ${BRAND} password. Use the link below to set a new one:
${resetUrl}
This link is valid for ${expiresInMinutes} minutes and can only be used once. If you didn't request this, you can ignore this email — your password will not change unless you click the link above.
— ${BRAND}`;
const html = wrap(`
<p>Hi ${safeName},</p>
<p>We received a request to reset your ${BRAND} password.</p>
<p>${button(resetUrl, 'Reset Your Password')}</p>
<p style="color:${MUTED};font-size:0.85rem;">This link is valid for ${expiresInMinutes} minutes and can only be used once. If you didn't request this, you can ignore this email — your password will not change unless you click the link above.</p>`);
return { subject, text, html };
}
// D. Password changed / security notice — §9D. No link at all, purely informational.
function passwordChanged({ displayName }) {
const subject = `Your ${BRAND} Password Was Changed`;
const safeName = escapeHtml(displayName);
const text = `Hi ${displayName},
Your ${BRAND} password was just changed. If this was you, no action is needed.
If you didn't make this change, contact your instructor right away — your other sessions have already been signed out as a precaution.
— ${BRAND}`;
const html = wrap(`
<p>Hi ${safeName},</p>
<p>Your ${BRAND} password was just changed. If this was you, no action is needed.</p>
<p style="color:${MUTED};font-size:0.85rem;">If you didn't make this change, contact your instructor right away — your other sessions have already been signed out as a precaution.</p>`);
return { subject, text, html };
}
module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml };
+22 -5
View File
@@ -88,7 +88,7 @@ async function createManualEnrollment(pool, { studentId, programId, status, star
// machinery admin-created students already use (students-auth.js) — this endpoint is a
// trusted, authenticated, server-to-server caller, the same trust level an instructor's
// "add student" admin action already has, not a public signup form.
async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, studentEmail, studentLoginIdentifier, studentDisplayName, programId, status }) {
async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, studentEmail, studentLoginIdentifier, studentDisplayName, contactEmail, programId, status }) {
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent enrollment events.');
const { rows: programRows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
const program = programRows[0];
@@ -104,14 +104,22 @@ async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source,
const student = await resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName });
const initialStatus = status === 'active' ? 'active' : 'pending';
// §10 — contactEmail (an integration-event-supplied "who to notify about THIS enrollment,"
// e.g. a guardian's address for a minor with no email of their own) is stored on the
// enrollment's existing metadata JSONB, never written to students.email — see
// enrollmentContactEmail() below and notifications.js's resolveRecipient, the only two places
// this value is ever read back out. Not a guardian account, not a linked identity — just a
// delivery address for this one enrollment's notifications.
const metadata = contactEmail ? { contact_email: String(contactEmail).trim() } : null;
try {
const { rows } = await pool.query(
`INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_event_id, activated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_event_id, activated_at, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (external_event_id) WHERE external_event_id IS NOT NULL DO NOTHING
RETURNING *`,
[student.student_id, programId, program.delivery_mode, program.version, program.curriculum_path, initialStatus,
source || 'integration', eventId || null, initialStatus === 'active' ? new Date() : null]
source || 'integration', eventId || null, initialStatus === 'active' ? new Date() : null, metadata ? JSON.stringify(metadata) : null]
);
if (rows[0]) return { enrollment: rows[0], created: true, student };
// Lost the ON CONFLICT race (concurrent identical replay) — the winner's row is now there.
@@ -220,8 +228,17 @@ async function maybeCompleteEnrollment(pool, studentId, programId = null) {
return completed;
}
// Phase 5C §10 — the one place `enrollments.metadata.contact_email` is read back out (its only
// writer is createFromIntegrationEvent/createManualEnrollment above). Kept as a named accessor
// rather than inline `metadata->>'contact_email'` scattered across notifications.js so this
// transitional, non-identity field has exactly one documented meaning and one call site per
// direction (write here, read here).
function enrollmentContactEmail(enrollment) {
return enrollment?.metadata?.contact_email || null;
}
module.exports = {
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
maybeCompleteEnrollment,
maybeCompleteEnrollment, enrollmentContactEmail,
};
+19 -1
View File
@@ -14,6 +14,8 @@
const enrollment = require('./enrollment');
const studentsAuth = require('./students-auth');
const notifications = require('./notifications');
const programsLib = require('./programs');
const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
@@ -47,6 +49,10 @@ module.exports = function registerIntegrationRoutes(app, deps) {
const { event_id, source, student, program_slug, status } = req.body || {};
const email = student?.email ? String(student.email).trim() : null;
const loginIdentifier = student?.login_identifier ? String(student.login_identifier).trim() : null;
// Phase 5C §10 — an OPTIONAL enrollment-contact address, for a minor with no email of their
// own (e.g. a guardian's address collected at checkout). Never treated as the student's own
// identity — see enrollment.js's enrollmentContactEmail.
const contactEmail = student?.contact_email ? String(student.contact_email).trim() : null;
const mappedStatus = STATUS_MAP[status];
if (!event_id || typeof event_id !== 'string') {
@@ -70,7 +76,7 @@ module.exports = function registerIntegrationRoutes(app, deps) {
try {
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
studentDisplayName: student?.display_name, programId: program_slug, status: mappedStatus,
studentDisplayName: student?.display_name, contactEmail, programId: program_slug, status: mappedStatus,
});
} catch (e) {
metrics.recordEnrollmentEvent('rejected');
@@ -88,6 +94,18 @@ module.exports = function registerIntegrationRoutes(app, deps) {
metadata: { event_id, program_slug, student_id: result.enrollment.student_id },
});
// Phase 5C §11/§12 — notification decision, ONLY on a genuinely new enrollment
// (result.created), never on an idempotent replay. This is the entire duplicate-webhook
// safety net: the enrollment's own DB-level idempotency (proven in Phase 5B) already
// guarantees at most one caller ever sees created:true for a given event/race, so at most
// one notification is ever queued, with no separate notification-level dedup needed.
if (result.created) {
const program = await programsLib.getProgram(getPool(), program_slug);
await notifications.notifyForEnrollment(getPool(), studentsAuth, {
studentId: result.enrollment.student_id, enrollmentId: result.enrollment.enrollment_id, programName: program?.name,
});
}
res.status(result.created ? 201 : 200).json({
enrollment_id: result.enrollment.enrollment_id,
student_id: result.enrollment.student_id,
+52 -1
View File
@@ -25,6 +25,22 @@ function recordEnrollmentEvent(result) {
if (result in enrollmentEventTotals) enrollmentEventTotals[result]++;
}
// Phase 5C §18 — per-attempt delivery outcomes (a real counter, since "how many send attempts
// happened" is an ephemeral event count, not something a DB GROUP BY can reconstruct after the
// fact the way notifications_total below can). `provider` is 'dev'|'gws' (bounded), `result` is
// 'sent'|'retry'|'failed' (bounded) — never a notification_id/student_id/provider message id.
const notificationAttemptTotals = {};
function recordNotificationAttempt(provider, result) {
const key = `${provider}|${result}`;
notificationAttemptTotals[key] = (notificationAttemptTotals[key] || 0) + 1;
}
// student.activate outcomes — 'success' | 'invalid_or_expired_token'. Bounded, no student id.
const studentActivationTotals = {};
function recordStudentActivation(result) {
studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1;
}
// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a
// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
// keep in sync with reality).
@@ -58,6 +74,15 @@ async function collectGauges(pool, provisioner) {
pool.query(`SELECT program_id, count(*)::int AS n FROM enrollments WHERE status = 'completed' GROUP BY program_id`),
]);
// Phase 5C — notification lifecycle, computed fresh from the DB (like enrollment_breakdown
// above) rather than an in-process counter, so "how many notifications are in each
// type/status right now" can never drift from what's actually stored.
const [{ rows: notificationBreakdown }, { rows: queueStats }] = await Promise.all([
pool.query(`SELECT notification_type, status, count(*)::int AS n FROM notification_events GROUP BY notification_type, status`),
pool.query(`SELECT count(*)::int AS depth, COALESCE(extract(epoch FROM now() - min(created_at)), 0)::int AS oldest_seconds
FROM notification_events WHERE status = 'queued'`),
]);
return {
classroom_sessions_live: liveClassrooms.length,
classroom_participants_online: onlineParticipants,
@@ -68,6 +93,9 @@ async function collectGauges(pool, provisioner) {
enrollment_breakdown: enrollmentBreakdown,
selfpaced_active_enrollments: selfPacedActive[0].n,
completions_by_program: completionsByProgram,
notification_breakdown: notificationBreakdown,
notification_queue_depth: queueStats[0].depth,
notification_oldest_queued_seconds: queueStats[0].oldest_seconds,
};
}
@@ -85,6 +113,20 @@ function labeledGauge(name, help, rows, labelCols, valueCol) {
return header + body + '\n';
}
// Same idea as labeledGauge, for the flat "label-key -> count" dicts the in-process counters
// above use (e.g. notificationAttemptTotals's `${provider}|${result}` keys).
function labeledCounter(name, help, totals, labelNames) {
const header = `# HELP ${name} ${help}\n# TYPE ${name} counter\n`;
const keys = Object.keys(totals);
if (!keys.length) return header;
const body = keys.map((key) => {
const values = key.split('|');
const labels = labelNames.map((n, i) => `${n}="${values[i].replace(/"/g, '\\"')}"`).join(',');
return `${name}{${labels}} ${totals[key]}`;
}).join('\n');
return header + body + '\n';
}
async function renderMetrics(pool, provisioner) {
const gauges = await collectGauges(pool, provisioner);
return [
@@ -102,7 +144,16 @@ async function renderMetrics(pool, provisioner) {
line('rootnode_enrollment_events_created_total', 'Integration enrollment events that created a new enrollment.', 'counter', enrollmentEventTotals.created),
line('rootnode_enrollment_events_duplicate_total', 'Integration enrollment events that were idempotent replays of an already-processed event.', 'counter', enrollmentEventTotals.duplicate),
line('rootnode_enrollment_events_rejected_total', 'Integration enrollment events rejected (bad auth, unknown program, invalid payload).', 'counter', enrollmentEventTotals.rejected),
labeledGauge('rootnode_notifications_total', 'Number of notification rows by type and lifecycle status.', gauges.notification_breakdown, ['notification_type', 'status'], 'n'),
line('rootnode_notification_queue_depth', 'Number of notifications currently queued for delivery.', 'gauge', gauges.notification_queue_depth),
line('rootnode_notification_oldest_queued_seconds', 'Age in seconds of the oldest still-queued notification (0 if none queued).', 'gauge', gauges.notification_oldest_queued_seconds),
labeledCounter('rootnode_notification_delivery_attempts_total', 'Notification delivery attempts by provider and outcome.', notificationAttemptTotals, ['provider', 'result']),
line('rootnode_student_activations_success_total', 'Student accounts successfully activated.', 'counter', studentActivationTotals.success || 0),
line('rootnode_student_activations_invalid_total', 'Activation attempts with an invalid or expired token.', 'counter', studentActivationTotals.invalid_or_expired_token || 0),
].join('\n');
}
module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent, renderMetrics };
module.exports = {
recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent,
recordNotificationAttempt, recordStudentActivation, renderMetrics,
};
+118
View File
@@ -0,0 +1,118 @@
// Phase 5C §15/§16 — instructor/admin communication operations. Global notification visibility
// is owner/admin only (§16: "Do not grant global notification visibility to every instructor");
// a plain instructor can still see/act on notifications for a SPECIFIC student they're
// authorized to manage via rbac.js's requireStudentRelationshipAccess (cohort-shared or an
// enrollment they created — the same rule admin-programs-routes.js already established for
// enrollment visibility). Every response here goes through notifications.js's toAdminView,
// which masks the recipient address and omits `metadata` entirely — no token, no full email,
// ever, through any route in this file.
const notificationsLib = require('./notifications');
const enrollmentLib = require('./enrollment');
const programsLib = require('./programs');
const studentsAuth = require('./students-auth');
const { parsePagination, paginatedResponse } = require('./pagination');
module.exports = function registerNotificationRoutes(app, deps) {
const { instructorSession, rbac, getPool, recordAudit } = deps;
const { requireAdminOrOwner, requireStudentRelationshipAccess } = rbac;
function wrap(fn) {
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
}
const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
};
// Global operational view — owner/admin only.
admin.get('/api/admin/notifications', requireAdminOrOwner, async (req, res) => {
const pool = getPool();
const { page, pageSize, offset } = parsePagination(req);
const [{ rows }, { rows: countRows }] = await Promise.all([
pool.query(`SELECT * FROM notification_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset]),
pool.query(`SELECT count(*)::int AS n FROM notification_events`),
]);
res.json(paginatedResponse(rows.map(notificationsLib.toAdminView), countRows[0].n, { page, pageSize }));
});
// Per-student view — the scoped surface a plain instructor can actually reach.
admin.get('/api/admin/students/:id/notifications', requireStudentRelationshipAccess('id'), async (req, res) => {
const rows = await notificationsLib.listForStudent(getPool(), req.params.id);
res.json(rows.map(notificationsLib.toAdminView));
});
// §20 — dev/test ONLY: lets a test script (or a developer) see exactly what the dev provider
// would have sent, including the activation/reset URL, without a real inbox. Unconditionally
// refuses in production regardless of what's stored (mirrors student-auth-routes.js's
// dev_reset_token gate on the same COOKIE_SECURE-derived signal) — this is never how an
// admin inspects a real delivery, only how a dev/CI run verifies one.
admin.get('/api/admin/notifications/:id/dev-preview', requireAdminOrOwner, async (req, res) => {
if (process.env.COOKIE_SECURE === '1') return res.status(404).json({ error: 'not_found' });
const preview = await notificationsLib.getDevPreview(getPool(), req.params.id);
if (!preview) return res.status(404).json({ error: 'no_preview_available' });
res.json(preview);
});
// §15 — one action covers both "resend" and "regenerate": Phase 5A's issueCredentialToken
// already unconditionally invalidates any previous outstanding token on mint, so there is
// never a moment with two simultaneously valid activation links — a second button that did
// something different would have nothing different to do. See
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6 for the full reasoning.
admin.post('/api/admin/students/:id/resend-activation', requireStudentRelationshipAccess('id'), async (req, res) => {
const pool = getPool();
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (rows[0].password_hash) return res.status(409).json({ error: 'already_activated', message: 'This student already has real credentials — use Send Password Reset instead.' });
const { rows: enrollmentRows } = await pool.query(
`SELECT enrollment_id, program_id FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id]
);
const enrollmentId = enrollmentRows[0]?.enrollment_id || null;
const program = enrollmentRows[0] ? await programsLib.getProgram(pool, enrollmentRows[0].program_id) : null;
const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, {
studentId: req.params.id, enrollmentId, programName: program?.name, isResend: true,
});
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'activation_resent', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json(notificationsLib.toAdminView(notification));
});
admin.post('/api/admin/students/:id/send-enrollment-confirmation', requireStudentRelationshipAccess('id'), async (req, res) => {
const pool = getPool();
const { enrollment_id } = req.body || {};
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_activated', message: 'This student has no credentials yet — use Resend Activation instead.' });
let targetEnrollmentId = enrollment_id;
if (!targetEnrollmentId) {
const { rows: latest } = await pool.query(
`SELECT enrollment_id FROM enrollments WHERE student_id = $1 AND status = 'active' ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id]
);
targetEnrollmentId = latest[0]?.enrollment_id;
}
if (!targetEnrollmentId) return res.status(400).json({ error: 'no_active_enrollment', message: 'This student has no active enrollment to confirm.' });
const enrollmentRow = await enrollmentLib.getEnrollment(pool, targetEnrollmentId);
const program = await programsLib.getProgram(pool, enrollmentRow.program_id);
const notification = await notificationsLib.enqueue(pool, {
studentId: req.params.id, enrollmentId: targetEnrollmentId, notificationType: 'enrollment_confirmation',
metadata: { display_name: rows[0].display_name, program_name: program?.name },
});
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_confirmation_sent', resourceType: 'enrollment', resourceId: targetEnrollmentId, result: 'success', sourceIp: req.ip });
res.json(notificationsLib.toAdminView(notification));
});
// §8/§15's "admin may need a safe SEND PASSWORD RESET operation" is the EXISTING
// POST /api/admin/students/:id/reset-password (admin-routes.js, Phase 5A) — Phase 5C extended
// it to also enqueue a real email (see that route) rather than duplicating a second endpoint
// here. It still returns `reset_token` in its response, unchanged from Phase 5A: for a
// student with no deliverable address at all (no email, no enrollment contact), manually
// relaying that token is the ONLY way they can ever get in, so removing it would leave that
// population with no recovery path. §21's "admin cannot view token" is enforced for
// resend-activation/send-enrollment-confirmation above, which have no legitimate reason to
// ever expose one — reset-password's token-in-response is a deliberate, documented,
// unchanged exception for exactly the case automatic delivery can't cover. See
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6.
};
+104
View File
@@ -0,0 +1,104 @@
// Phase 5C §14 — a simple durable worker, same pattern this codebase already uses for
// sessions.js's session sweeper and security.js's login-attempt sweeper: a plain setInterval
// poll against Postgres, no Kafka/RabbitMQ/Redis. notifications.js's claimNext() does the
// actual concurrency-safety work (FOR UPDATE SKIP LOCKED) — this file just calls it on a timer
// and hands the claimed row to the configured email provider.
const notifications = require('./notifications');
const templates = require('./email/templates');
const { getEmailProvider } = require('./email/provider');
const metrics = require('./metrics');
const POLL_MS = Number(process.env.NOTIFICATION_WORKER_POLL_MS) || 3000;
function buildAppUrl(path) {
const origin = process.env.PUBLIC_APP_ORIGIN;
if (!origin) return null;
try {
const u = new URL(origin);
return `${u.origin}${path}`;
} catch {
return null; // malformed origin — treated as unconfigured, not a crash
}
}
// Renders the right template for a queued row. Returns null (a permanent, non-retryable
// failure) if required config (PUBLIC_APP_ORIGIN for a token-bearing type) is missing — §7's
// "production should fail safely if required public-origin configuration is missing."
function renderFor(row) {
const displayName = row.metadata?.display_name || 'there';
if (row.notification_type === 'activation') {
const url = buildAppUrl(`/?activate=${encodeURIComponent(row.metadata?.token || '')}`);
if (!url || !row.metadata?.token) return null;
return templates.activation({ displayName, programName: row.metadata?.program_name, activationUrl: url, expiresInDays: 7 });
}
if (row.notification_type === 'password_reset') {
const url = buildAppUrl(`/?reset=${encodeURIComponent(row.metadata?.token || '')}`);
if (!url || !row.metadata?.token) return null;
return templates.passwordReset({ displayName, resetUrl: url, expiresInMinutes: 60 });
}
if (row.notification_type === 'enrollment_confirmation') {
const url = buildAppUrl('/') || '/';
return templates.enrollmentConfirmation({ displayName, programName: row.metadata?.program_name || 'your program', signInUrl: url });
}
if (row.notification_type === 'password_changed') {
return templates.passwordChanged({ displayName });
}
return null;
}
async function processOne(pool) {
const row = await notifications.claimNext(pool);
if (!row) return false;
const { EMAIL_PROVIDER } = require('./email/provider');
const rendered = renderFor(row);
if (!rendered) {
await notifications.markFailedOrRetry(pool, row, { errorCode: 'template_or_config_error', retryable: false });
metrics.recordNotificationAttempt(EMAIL_PROVIDER, 'failed');
return true;
}
const provider = getEmailProvider();
try {
const result = await provider.send({
to: row.recipient_email,
fromName: process.env.EMAIL_FROM_NAME || 'RootNode Academy',
fromAddress: process.env.EMAIL_FROM_ADDRESS,
replyTo: process.env.EMAIL_REPLY_TO,
subject: rendered.subject, text: rendered.text, html: rendered.html,
});
// §20 — a dev-only preview (never in production, regardless of provider) so a test/admin
// can inspect exactly what would have been sent, including the activation/reset URL,
// through the dedicated dev-only route — never through the normal admin notification view.
const isProduction = process.env.COOKIE_SECURE === '1';
const devPreview = (!isProduction && result.preview) ? result.preview : null;
await notifications.markSent(pool, row.notification_id, result.providerMessageId, EMAIL_PROVIDER, devPreview);
metrics.recordNotificationAttempt(EMAIL_PROVIDER, 'sent');
} catch (e) {
const retryable = e.errorClass !== 'permanent';
const outcome = await notifications.markFailedOrRetry(pool, row, { errorCode: e.errorCode || 'unknown_error', retryable });
metrics.recordNotificationAttempt(EMAIL_PROVIDER, outcome === 'retrying' ? 'retry' : 'failed');
console.error(`[notification-worker] send failed (${row.notification_type}, ${outcome}): ${e.errorCode || e.message}`);
}
return true;
}
function startNotificationWorker(pool) {
const tick = async () => {
try {
// Drain everything currently claimable before waiting out the next poll interval —
// keeps a burst (e.g. a CSV import's worth of activations) from trickling out one per
// POLL_MS when they could all go immediately.
while (await processOne(pool)) { /* keep draining */ }
} catch (e) {
console.error('[notification-worker] tick failed:', e.message);
}
};
const handle = setInterval(tick, POLL_MS);
handle.unref(); // never keeps the process alive on its own, matching sessions.js's sweeper
tick(); // don't wait a full interval for the first pass
return handle;
}
module.exports = { startNotificationWorker, processOne, renderFor, buildAppUrl };
+189
View File
@@ -0,0 +1,189 @@
// Phase 5C — durable notification lifecycle: enqueue (fast, synchronous, no network I/O),
// claim (worker-side, concurrency-safe), and terminal-state transitions.
//
// Transient plaintext token handling: activation and password-reset emails need the ACTUAL
// token value to build a working link, but Phase 5A's invariant is that only the HASH is ever
// persisted long-term (students.credential_token_hash). The token is minted synchronously by
// whichever request triggers it (admin create/convert/reset-password, self-service
// forgot-password) — exactly as Phase 5A already does — and its plaintext is handed to
// enqueueWithToken() here, which stores it in this row's `metadata` ONLY until the worker
// reaches a terminal state (sent/failed), then scrubs it (see markSent/markFailedOrRetry).
// This is a deliberate, narrow exception to "hashed at rest" for the row that exists
// specifically to deliver the plaintext once — not the canonical credential store, which
// remains hash-only and untouched by any of this. It is never returned by the admin
// notification API regardless of whether it's still present (see toAdminView).
const MAX_ATTEMPTS = 5;
const RETRYABLE_BACKOFF_BASE_SEC = 30;
const RETRYABLE_BACKOFF_MAX_SEC = 3600;
function backoffSeconds(attemptCount) {
return Math.min(RETRYABLE_BACKOFF_BASE_SEC * 2 ** attemptCount, RETRYABLE_BACKOFF_MAX_SEC);
}
// §10 — minor/no-email recipient resolution. A student's OWN email always wins when present.
// Otherwise, for an enrollment-scoped notification, fall back to that ENROLLMENT's contact
// email (enrollments.metadata.contact_email — see enrollment.js's enrollmentContactEmail) —
// deliberately never written to students.email, which stays reserved for the student's own
// identity. A password-reset/password-changed notification has no enrollment to fall back to
// and, correctly, no fallback recipient for a no-email student — those actions require the
// student to already be able to log in, which requires an email or a login_identifier they use
// directly, not a guardian contact.
async function resolveRecipient(pool, studentId, enrollmentId) {
const { rows: studentRows } = await pool.query(`SELECT email FROM students WHERE student_id = $1`, [studentId]);
if (studentRows[0]?.email) return studentRows[0].email;
if (enrollmentId) {
const { enrollmentContactEmail } = require('./enrollment');
const { rows: enrollmentRows } = await pool.query(`SELECT metadata FROM enrollments WHERE enrollment_id = $1`, [enrollmentId]);
const contact = enrollmentContactEmail(enrollmentRows[0]);
if (contact) return contact;
}
return null;
}
// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if
// given, is the ONLY place its value is persisted anywhere — purged on the row's terminal
// state. A notification with no resolvable recipient is still recorded (never silently
// dropped, §13) but created already-failed, so the worker never wastes an attempt on it.
async function enqueue(pool, { studentId, enrollmentId, notificationType, plaintextToken, metadata }) {
const recipient = await resolveRecipient(pool, studentId, enrollmentId);
const meta = { ...(metadata || {}) };
if (plaintextToken) meta.token = plaintextToken;
if (!recipient) {
const { rows } = await pool.query(
`INSERT INTO notification_events (student_id, enrollment_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata)
VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`,
[studentId, enrollmentId || null, notificationType, JSON.stringify({})] // no token stored — nothing to deliver, nothing to leak
);
return rows[0];
}
const { rows } = await pool.query(
`INSERT INTO notification_events (student_id, enrollment_id, notification_type, recipient_email, metadata)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[studentId, enrollmentId || null, notificationType, recipient, JSON.stringify(meta)]
);
return rows[0];
}
// Atomic claim: one UPDATE, no separate transaction/client management needed. FOR UPDATE SKIP
// LOCKED inside the subquery is what makes this safe if two worker processes ever poll
// concurrently — each gets a disjoint set of rows, never the same one twice.
async function claimNext(pool) {
const { rows } = await pool.query(
`UPDATE notification_events SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
WHERE notification_id = (
SELECT notification_id FROM notification_events
WHERE status = 'queued' AND next_attempt_at <= now()
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED
) RETURNING *`
);
return rows[0] || null;
}
// devPreview is ONLY ever populated by the caller when both the dev provider was used AND
// production is off (see notification-worker.js) — it replaces the row's metadata entirely
// (the token is gone either way), so a dev/test run can recover exactly what would have been
// sent without a real inbox. Never populated, never present, in a production deployment — see
// the dedicated dev-only GET route this exists for (notification-routes.js), which itself
// refuses to run in production regardless of what's actually stored.
async function markSent(pool, notificationId, providerMessageId, provider, devPreview) {
await pool.query(
`UPDATE notification_events SET status = 'sent', sent_at = now(), updated_at = now(),
provider = $2, provider_message_id = $3, metadata = $4
WHERE notification_id = $1`,
[notificationId, provider, providerMessageId || null, devPreview ? JSON.stringify({ dev_preview: devPreview }) : '{}']
);
}
// Permanent failures (bad recipient, misconfiguration) skip straight to 'failed' on the first
// attempt — retrying can't fix them and would just burn the attempt budget. Temporary failures
// (network/provider hiccup) go back to 'queued' with bounded exponential backoff until
// MAX_ATTEMPTS is exhausted, then 'failed' too — never an infinite loop.
async function markFailedOrRetry(pool, row, { errorCode, retryable }) {
const exhausted = row.attempt_count >= MAX_ATTEMPTS;
if (retryable && !exhausted) {
await pool.query(
`UPDATE notification_events SET status = 'queued', next_attempt_at = now() + ($2 || ' seconds')::interval,
last_error_code = $3, updated_at = now() WHERE notification_id = $1`,
[row.notification_id, backoffSeconds(row.attempt_count), errorCode]
);
return 'retrying';
}
await pool.query(
`UPDATE notification_events SET status = 'failed', failed_at = now(), last_error_code = $2,
updated_at = now(), metadata = metadata - 'token' WHERE notification_id = $1`,
[row.notification_id, errorCode]
);
return 'failed';
}
// §10/§5 — the shared decision point every enrollment-creation path (integration + admin
// manual enroll) calls right after a NEW enrollment is actually created (never on an idempotent
// replay — see integration-routes.js/admin-programs-routes.js, which only call this when their
// own creation call reported created:true). Reused for the admin "Resend Activation"/"Send
// Enrollment Confirmation" actions too, parameterized by isResend for audit-action naming only.
async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId, programName, isResend }) {
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [studentId]);
const student = rows[0];
if (!student) return null;
if (student.password_hash) {
return enqueue(pool, {
studentId, enrollmentId, notificationType: 'enrollment_confirmation',
metadata: { display_name: student.display_name, program_name: programName },
});
}
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation');
return enqueue(pool, {
studentId, enrollmentId, notificationType: 'activation', plaintextToken: token,
metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend },
});
}
async function listForStudent(pool, studentId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
return rows;
}
async function getById(pool, notificationId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
return rows[0] || null;
}
// Phase 5C §20 — the ONLY function that ever returns a dev preview (recipient/subject/text/
// activation-or-reset URL), and only the caller (notification-routes.js's dedicated dev-only
// route) decides whether to expose it at all — this function itself does no production check,
// it just returns whatever's there, which is nothing at all once a real (non-dev) provider was
// used or the row has moved past 'sent' via markFailedOrRetry (which never writes dev_preview).
async function getDevPreview(pool, notificationId) {
const row = await getById(pool, notificationId);
return row?.metadata?.dev_preview || null;
}
// §16 — safe display: the local part is truncated, never the full address, and this is the
// ONLY shape any admin API ever returns for recipient_email. Nothing this module exports ever
// hands back an unmasked address or the metadata column at all.
function maskEmail(email) {
if (!email) return null;
const [local, domain] = email.split('@');
if (!domain) return '***';
const visible = local.slice(0, 1);
return `${visible}${'*'.repeat(Math.max(local.length - 1, 2))}@${domain}`;
}
// The one and only shape returned by any notification-related API — deliberately excludes
// `metadata` entirely (which is where a still-in-flight token transiently lives) and masks the
// recipient address.
function toAdminView(row) {
return {
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id,
notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email),
provider: row.provider, status: row.status, attempt_count: row.attempt_count, last_error_code: row.last_error_code,
created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
};
}
module.exports = {
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
};
+33 -1
View File
@@ -179,6 +179,38 @@ module.exports = function createRbac(pool) {
};
}
// Phase 5C §16 — same documented rule as requireEnrollmentAccess below, keyed by student_id
// directly instead of resolving through one enrollment first: an instructor may see/manage a
// student's notifications if they share a cohort with that student OR they created at least
// one of that student's enrollments — otherwise 403, never blanket visibility into every
// self-paced student's notifications ("Do not grant global notification visibility to every
// instructor," §16). Used by the per-student notification list and the resend-activation/
// send-enrollment-confirmation/send-password-reset admin actions.
async function checkStudentRelationshipAccess(req, res, studentId) {
const instructor = req.instructor;
if (['owner', 'admin'].includes(instructor.role)) return true;
const { rowCount } = await pool.query(
`SELECT 1 FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2
UNION SELECT 1 FROM enrollments WHERE student_id = $1 AND created_by = $2`,
[studentId, instructor.instructor_id]
);
if (!rowCount) {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip,
});
res.status(403).json({ error: 'forbidden' });
return false;
}
return true;
}
function requireStudentRelationshipAccess(studentIdParam = 'id') {
return async (req, res, next) => {
if (await checkStudentRelationshipAccess(req, res, req.params[studentIdParam])) next();
};
}
// Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so
// requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The
// documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER
@@ -216,6 +248,6 @@ module.exports = function createRbac(pool) {
requireCohortAccess, requireStudentCohortAccess,
requireCohortWriteAccess, requireStudentCohortWriteAccess,
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
requireEnrollmentAccess,
requireEnrollmentAccess, requireStudentRelationshipAccess,
};
};
+12
View File
@@ -395,6 +395,14 @@ require('./admin-programs-routes')(app, {
});
require('./integration-routes')(app, { getPool, recordAudit, wrap });
// Phase 5C — enrollment operations & communications. notification-routes.js is the
// instructor/admin half (list, per-student view, resend-activation, send-enrollment-
// confirmation); the actual delivery worker starts in main() below, alongside the existing
// session/login-attempt sweepers — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md.
require('./notification-routes')(app, {
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
});
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
@@ -414,6 +422,10 @@ async function main() {
if (applied.length) console.log('Applied migrations:', applied.join(', '));
sessions.startSessionSweeper(getPool());
startLoginAttemptSweeper(getPool()); // Phase 4.1 §15
const { startNotificationWorker } = require('./notification-worker');
const { EMAIL_PROVIDER } = require('./email/provider');
console.log(`[notification-worker] starting (EMAIL_PROVIDER=${EMAIL_PROVIDER})`);
startNotificationWorker(getPool()); // Phase 5C §14
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
}
+22 -13
View File
@@ -14,7 +14,8 @@ const {
const { recordAudit } = require('./audit');
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
const { buildCookie, clearCookie } = require('./cookies');
const { recordStudentLoginSuccess, recordStudentLoginFailure } = require('./metrics'); // Phase 5A §34
const { recordStudentLoginSuccess, recordStudentLoginFailure, recordStudentActivation } = require('./metrics'); // Phase 5A §34, Phase 5C
const notifications = require('./notifications');
const STUDENT_COOKIE = 'rn_session';
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
@@ -69,7 +70,11 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
}
const student = await consumeCredentialToken(pool, token, 'activation', password);
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
if (!student) {
recordStudentActivation('invalid_or_expired_token');
return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
}
recordStudentActivation('success');
const sessionToken = await sessions.createStudentSession(pool, student.student_id);
const csrf = makeCsrfToken();
@@ -105,27 +110,30 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
const newHash = hashPassword(new_password);
await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]);
await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken);
// Phase 5C §5D — a security-notice email, no token, purely informational.
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
// §9/§6 — self-service "forgot password." No email delivery infrastructure exists yet
// (explicitly not required this phase), so this deliberately does NOT invent fake email
// sending and does NOT hand the reset token back to whoever asked (that would let anyone
// "reset" any account just by guessing an identifier — a real vulnerability, not a shortcut).
// Always returns the same generic response regardless of whether the identifier exists
// (anti-enumeration). In non-production, the real token is included so local/test flows can
// complete the loop without pretending email exists; in production it is never returned by
// this endpoint — completing a real self-service reset needs email delivery, a documented,
// not-yet-built follow-up (see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5). The
// realistic path today is instructor-triggered reset (POST /api/admin/students/:id/reset-password).
// §9/§6/Phase 5C §8 — self-service "forgot password." Anti-enumeration is unchanged: the
// SAME generic response returns regardless of whether the identifier exists, is inactive, or
// is a genuine hit — the only difference a caller can ever observe is whether an email
// eventually arrives, never anything in this response. Production NEVER returns the token
// (isProduction, same COOKIE_SECURE-derived signal Phase 5A already established — see
// server.js); the dev-only echo exists purely so a local/CI run can complete the loop without
// a real inbox, exactly as before. What Phase 5C actually closes: the token is now also
// handed to notifications.enqueue() so a real email goes out — see
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §8 for why the token is minted here
// (synchronously, as before) rather than by the worker.
app.post('/api/student/request-password-reset', async (req, res) => {
const { identifier } = req.body || {};
const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' };
if (!identifier) return res.json(generic);
const student = await getStudentByIdentifier(pool, identifier);
if (!student || student.status !== 'active') return res.json(generic);
if (!student || student.status !== 'active' || !student.password_hash) return res.json(generic);
const { token } = await issueCredentialToken(pool, student.student_id, 'reset');
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_reset', plaintextToken: token, metadata: { display_name: student.display_name } });
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip });
if (!isProduction) return res.json({ ...generic, dev_reset_token: token });
res.json(generic);
@@ -139,6 +147,7 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
const student = await consumeCredentialToken(pool, token, 'reset', password);
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' });
await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access"
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
+12 -1
View File
@@ -73,10 +73,21 @@ services:
# POST /api/integrations/enrollments refuses every request (fails closed), which is fine
# for anyone not exercising that boundary locally.
INTEGRATION_SHARED_SECRET: ${INTEGRATION_SHARED_SECRET:-}
# Phase 5C — activation/reset email links are built from this origin (see
# docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §7). The Gateway's own dev URL, since that's
# what a browser actually hits locally. EMAIL_PROVIDER is deliberately left unset here —
# it defaults to the dev provider (email/dev-provider.js), which never makes a real
# network call no matter what recipient it's given.
PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200}
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy}
PLATFORM_API_PORT: "4300"
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
# COOKIE_SECURE: "1" # set once served over real HTTPS
# Empty by default — unset/empty means the exact same dev behavior as before this was
# parametrized (dev_reset_token echoed, dev-preview route reachable). Phase 5C's
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for
# real, then unsets it again — see that test's own comment.
COOKIE_SECURE: ${COOKIE_SECURE:-}
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
depends_on:
postgres:
+413
View File
@@ -0,0 +1,413 @@
#!/usr/bin/env node
// Phase 5C — enrollment operations & communications acceptance test. Covers the notification
// lifecycle (queue/claim/sent/failed/retry), activation/enrollment-confirmation/password-reset/
// password-changed delivery, duplicate-webhook idempotency, retry/failure classification and
// exhaustion, minor/no-email contact-email resolution, admin operations (resend, RBAC scoping),
// security (token secrecy, production gating), and a real Platform API restart. Uses the
// deterministic dev email provider throughout — no real network call is ever made by this
// suite. Same conventions as e2e-phase5b.js: assert-based, no framework, live
// docker-compose.dev.yml stack.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... INTEGRATION_KEY=... node test/e2e-phase5c.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production';
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
}
function fireIntegrationEvent(payload) {
return fetch(`${GW}/api/integrations/enrollments`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY },
body: JSON.stringify(payload),
}).then(async (r) => ({ status: r.status, body: await r.json() }));
}
async function waitForStatus(owner, studentId, wantStatus, timeoutMs = 8000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
const match = body.find((n) => n.status === wantStatus);
if (match) return match;
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`timed out waiting for a notification with status=${wantStatus}`);
}
// Same idea, but for one SPECIFIC notification_id — waitForStatus alone isn't enough once a
// student has more than one notification (e.g. right after a resend), since it would happily
// match an EARLIER notification that already reached the wanted status instead of the new one.
async function waitForNotificationStatus(owner, studentId, notificationId, wantStatus, timeoutMs = 8000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
const row = body.find((n) => n.notification_id === notificationId);
if (row?.status === wantStatus) return row;
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`timed out waiting for notification ${notificationId} to reach status=${wantStatus}`);
}
// Nudges a still-queued row's next_attempt_at to now, so a test doesn't have to sit through
// real exponential backoff (up to an hour) to observe retry/exhaustion behavior.
function nudgeRetry() {
execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`,
{ cwd: `${__dirname}/..`, stdio: 'pipe' }
);
}
async function main() {
console.log('Phase 5C enrollment operations & communications acceptance test\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
// ── §1-2: activation vs enrollment-confirmation ─────────────────────────────────────────
let newStudentId, newEnrollmentId;
await test('new student integration enrollment queues an activation notification', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `p5c-${RUN_ID}-new`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { email: `p5c-new-${RUN_ID}@example.com`, display_name: 'New Student' },
});
assert.equal(status, 201);
newStudentId = body.student_id; newEnrollmentId = body.enrollment_id;
const sent = await waitForStatus(owner, newStudentId, 'sent');
assert.equal(sent.notification_type, 'activation');
});
await test('existing active student gets enrollment_confirmation instead of activation on a second program', async () => {
// Activate the student from the previous test first (credentials required for this branch).
const { body: preview } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
const activationNotif = preview.find((n) => n.notification_type === 'activation');
const { body: devPreview } = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
const token = devPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
const student = makeSession(GW);
await student.request('POST', '/api/student/activate', { token, password: 'NewStudentPass123!' });
await owner.request('POST', '/api/admin/programs', { program_id: `p5c-second-${RUN_ID}`, name: 'Second Program', family: 'root' });
const { status, body } = await fireIntegrationEvent({
event_id: `p5c-${RUN_ID}-second`, source: 'website', program_slug: `p5c-second-${RUN_ID}`, status: 'paid',
student: { email: `p5c-new-${RUN_ID}@example.com` },
});
assert.equal(status, 201);
const sent = await waitForStatus(owner, newStudentId, 'sent', 8000);
// waitForStatus returns the FIRST sent row it finds — there are now two (activation +
// confirmation); assert the confirmation specifically exists among this student's notifications.
const { body: all } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
const confirmation = all.find((n) => n.notification_type === 'enrollment_confirmation' && n.enrollment_id === body.enrollment_id);
assert.ok(confirmation, 'expected an enrollment_confirmation notification for the already-activated student');
});
// ── §3-4: duplicate/concurrent webhook -> no notification storm ────────────────────────
await test('duplicate integration event sends no duplicate notification', async () => {
const { body: before } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-new`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-new-${RUN_ID}@example.com` } });
await new Promise((r) => setTimeout(r, 1500));
const { body: after } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
assert.equal(after.length, before.length);
});
await test('concurrent duplicate integration events send exactly one logical initial notification', async () => {
const eventId = `p5c-${RUN_ID}-race`;
const email = `p5c-racer-${RUN_ID}@example.com`;
const fire = () => fireIntegrationEvent({ event_id: eventId, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email } });
const [a, b, c] = await Promise.all([fire(), fire(), fire()]);
const studentId = a.body.student_id;
await waitForStatus(owner, studentId, 'sent');
const { body: notifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`);
assert.equal(notifs.length, 1, `expected exactly one notification, got ${notifs.length}`);
});
// ── §5-10: worker delivery, retry, permanent failure, exhaustion ────────────────────────
await test('temporary provider failure retries (does not immediately fail)', async () => {
const { body } = await fireIntegrationEvent({
event_id: `p5c-${RUN_ID}-temp`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { email: `p5c-temp-${RUN_ID}[email protected]` },
});
await new Promise((r) => setTimeout(r, 3500)); // let the first (failing) attempt run
const { body: notifs } = await owner.request('GET', `/api/admin/students/${body.student_id}/notifications`);
const notif = notifs[0];
assert.ok(['queued', 'sending'].includes(notif.status), `expected still-retrying status, got ${notif.status}`);
assert.ok(notif.attempt_count >= 1);
assert.equal(notif.last_error_code, 'simulated_temporary_failure');
});
await test('permanent failure becomes failed on the first attempt (no wasted retries)', async () => {
const { body } = await fireIntegrationEvent({
event_id: `p5c-${RUN_ID}-perm`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { email: `p5c-perm-${RUN_ID}[email protected]` },
});
const failed = await waitForStatus(owner, body.student_id, 'failed');
assert.equal(failed.attempt_count, 1);
assert.equal(failed.last_error_code, 'simulated_permanent_failure');
});
await test('retry exhaustion: a persistently-failing temporary error eventually becomes failed, bounded', async () => {
const { body } = await fireIntegrationEvent({
event_id: `p5c-${RUN_ID}-exhaust`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { email: `p5c-exhaust-${RUN_ID}[email protected]` },
});
let finalStatus = null, finalAttempts = null;
// MAX_ATTEMPTS is 5 (notifications.js) and the worker polls every NOTIFICATION_WORKER_POLL_MS
// (default 3000ms) — each loop pass must outlast one poll cycle for the nudge to actually be
// picked up before the next nudge, or this just re-nudges a row the worker hasn't touched yet.
for (let i = 0; i < 10 && finalStatus !== 'failed'; i++) {
nudgeRetry();
await new Promise((r) => setTimeout(r, 3500));
const { body: notifs } = await owner.request('GET', `/api/admin/students/${body.student_id}/notifications`);
finalStatus = notifs[0]?.status;
finalAttempts = notifs[0]?.attempt_count;
}
assert.equal(finalStatus, 'failed', `expected exhaustion to reach failed within the nudge loop (last status: ${finalStatus}, attempts: ${finalAttempts})`);
assert.ok(finalAttempts <= 5, `expected a bounded attempt count, got ${finalAttempts}`);
});
// ── §11-15: admin visibility, resend/regenerate ─────────────────────────────────────────
await test('admin sees delivery status via both the global and per-student views', async () => {
const { body: global_ } = await owner.request('GET', '/api/admin/notifications');
assert.ok(global_.items.length > 0);
const { body: perStudent } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`);
assert.ok(perStudent.length > 0);
assert.ok(!('metadata' in perStudent[0]));
assert.ok(!('recipient_email' in perStudent[0])); // only the masked field is ever present
});
let pendingStudentId, firstActivationNotifId, resentNotifId;
await test('resend creates an intentional NEW notification attempt (not a mutation of the old row)', async () => {
const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Resend Test', email: `p5c-resend-${RUN_ID}@example.com` });
pendingStudentId = created.student_id;
await waitForStatus(owner, pendingStudentId, 'sent');
const { body: before } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
firstActivationNotifId = before[0].notification_id;
const { status, body: resent } = await owner.request('POST', `/api/admin/students/${pendingStudentId}/resend-activation`);
assert.equal(status, 200);
assert.notEqual(resent.notification_id, firstActivationNotifId);
resentNotifId = resent.notification_id;
});
let oldToken, newToken;
await test('regenerate (resend) invalidates the previous activation token; old token now fails, new one works', async () => {
const { status: previewStatus, body: firstPreview } = await owner.request('GET', `/api/admin/notifications/${firstActivationNotifId}/dev-preview`);
if (previewStatus !== 200) throw new Error(`dev-preview for ${firstActivationNotifId} returned ${previewStatus}: ${JSON.stringify(firstPreview)}`);
oldToken = firstPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
await waitForNotificationStatus(owner, pendingStudentId, resentNotifId, 'sent'); // the resend's OWN delivery, not just "any" sent notification
const { status: secondPreviewStatus, body: secondPreview } = await owner.request('GET', `/api/admin/notifications/${resentNotifId}/dev-preview`);
if (secondPreviewStatus !== 200) throw new Error(`dev-preview for ${resentNotifId} returned ${secondPreviewStatus}: ${JSON.stringify(secondPreview)}`);
newToken = secondPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
assert.notEqual(oldToken, newToken);
const failWithOld = await makeSession(GW).request('POST', '/api/student/activate', { token: oldToken, password: 'ShouldNotWork123!' });
assert.equal(failWithOld.status, 400);
const okWithNew = await makeSession(GW).request('POST', '/api/student/activate', { token: newToken, password: 'ResendPass123!' });
assert.equal(okWithNew.status, 200);
});
await test('activation token is single-use (the new token, already consumed above, fails a second time)', async () => {
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: newToken, password: 'AnotherPass123!' });
assert.equal(status, 400);
});
await test('activation token expiration is enforced server-side', async () => {
const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Expiry Test', email: `p5c-expiry-${RUN_ID}@example.com` });
execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE students SET credential_token_expires_at = now() - interval '1 hour' WHERE student_id = '${created.student_id}'"`,
{ cwd: `${__dirname}/..` }
);
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: created.activation_token, password: 'ExpiredPass123!' });
assert.equal(status, 400);
});
// ── §18/§20/§21: token secrecy ───────────────────────────────────────────────────────────
await test('no activation/reset plaintext ever appears in any admin notification API response', async () => {
const { body: list } = await owner.request('GET', '/api/admin/notifications');
const { body: perStudent } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
const serialized = JSON.stringify(list) + JSON.stringify(perStudent);
assert.ok(!serialized.includes(newToken) && !serialized.includes(oldToken));
});
// ── §19-24: password reset ───────────────────────────────────────────────────────────────
await test('reset anti-enumeration preserved (identical response for real vs unknown identifier)', async () => {
const real = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-resend-${RUN_ID}@example.com` });
const fake = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `nobody-${RUN_ID}@example.com` });
assert.equal(real.body.message, fake.body.message);
});
let resetToken;
await test('reset email is queued and delivered for a valid account', async () => {
const res = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-resend-${RUN_ID}@example.com` });
resetToken = res.body.dev_reset_token;
assert.ok(resetToken);
const sent = await waitForStatus(owner, pendingStudentId, 'sent', 8000);
});
await test('reset token is single-use and expiration is enforced', async () => {
const good = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'ResetPassNew123!' });
assert.equal(good.status, 200);
const reuse = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'AnotherOne123!' });
assert.equal(reuse.status, 400);
});
await test('password reset revokes prior sessions and the new password works', async () => {
const login = await makeSession(GW).request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
assert.equal(login.status, 200);
});
// ── §25-26: minor/no-email contact-email path ───────────────────────────────────────────
await test('minor/no-email recipient flow: contact_email delivers the activation notification', async () => {
const { status, body } = await fireIntegrationEvent({
event_id: `p5c-${RUN_ID}-minor`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { login_identifier: `p5c-minor-${RUN_ID}`, display_name: 'Minor Student', contact_email: `p5c-guardian-${RUN_ID}@example.com` },
});
assert.equal(status, 201);
const sent = await waitForStatus(owner, body.student_id, 'sent');
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
assert.equal(preview.to, `p5c-guardian-${RUN_ID}@example.com`);
});
await test('the guardian contact email is NEVER written to students.email', async () => {
const out = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -tA -c "SELECT email FROM students WHERE login_identifier = 'p5c-minor-${RUN_ID}'"`,
{ cwd: `${__dirname}/..` }
).toString().trim();
assert.equal(out, '');
});
// ── §27-30: integration auth, cross-student isolation, RBAC scoping ────────────────────
await test('integration auth remains separate from student/admin session auth', async () => {
const res = await fetch(`${GW}/api/integrations/enrollments`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` },
body: JSON.stringify({ event_id: 'x', program_slug: PROGRAM, status: 'paid', student: { email: '[email protected]' } }),
});
assert.equal(res.status, 401);
});
await test('a student session cannot invoke any admin notification route', async () => {
const student = makeSession(GW);
await student.request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
const { status } = await student.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
assert.ok([401, 403].includes(status));
});
// ── Regression: no arbitrary email template injection (§21) ────────────────────────────
// Found and fixed during this phase — display_name flows straight from a website
// integration event (genuinely external, caller-controlled text) into the activation
// email's HTML. templates.js now escapes every interpolated value in the HTML body.
await test('a display_name containing HTML markup is escaped in the rendered email, not injected', async () => {
const maliciousName = '<img src=x onerror=alert(1)>Injected';
const { status, body } = await fireIntegrationEvent({
event_id: `p5c-${RUN_ID}-injection`, source: 'website', program_slug: PROGRAM, status: 'paid',
student: { email: `p5c-injection-${RUN_ID}@example.com`, display_name: maliciousName },
});
assert.equal(status, 201);
const sent = await waitForStatus(owner, body.student_id, 'sent');
const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`);
assert.ok(!preview.html.includes('<img src=x onerror=alert(1)>'), 'raw markup must never appear unescaped in the HTML body');
assert.ok(preview.html.includes('&lt;img'), 'the escaped form must be present instead');
});
const { body: unrelatedInstr } = await owner.request('POST', '/api/admin/instructors', {
email: `p5c-unrelated-${RUN_ID}@rootnode.test`, display_name: 'Unrelated', role: 'instructor', password: 'UnrelatedPass123!',
});
await test('instructor RBAC/scoping preserved for notification visibility', async () => {
const unrelated = makeSession(GW);
await unrelated.request('POST', '/api/auth/instructor/login', { email: `p5c-unrelated-${RUN_ID}@rootnode.test`, password: 'UnrelatedPass123!' });
const scopedList = await unrelated.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
assert.equal(scopedList.status, 403);
const globalList = await unrelated.request('GET', '/api/admin/notifications');
assert.equal(globalList.status, 403); // global view is owner/admin only
});
// ── §33-34: metrics / audit hygiene ──────────────────────────────────────────────────────
await test('metrics contain notification gauges/counters with no PII', async () => {
const res = await fetch(`${API}/metrics`);
const text = await res.text();
assert.ok(text.includes('rootnode_notifications_total'));
assert.ok(text.includes('rootnode_notification_delivery_attempts_total'));
assert.ok(!text.includes(`p5c-resend-${RUN_ID}@example.com`));
assert.ok(!text.includes(pendingStudentId));
assert.ok(!text.includes(newToken || ''));
});
await test('audit events contain no tokens/secrets', async () => {
const { body } = await owner.request('GET', '/api/admin/audit-events?page=1');
const serialized = JSON.stringify(body);
assert.ok(!serialized.includes(newToken) && !serialized.includes(oldToken) && !serialized.includes(resetToken));
assert.ok(!/password_hash|credential_token_hash/i.test(serialized));
});
// ── production gating (§8/§21) — recreate the SAME api service with COOKIE_SECURE=1 via the
// standard compose env-file mechanism (no ad hoc container/port juggling under
// network_mode: host, which doesn't tolerate a second container easily) ─────────────────
await test('production mode never returns dev_reset_token or exposes the dev-preview route', async () => {
const cwd = `${__dirname}/..`;
execSync(`bash -c "echo COOKIE_SECURE=1 >> .env"`, { cwd, stdio: 'pipe' });
try {
execSync('docker compose -f docker-compose.dev.yml up -d --force-recreate api', { cwd, stdio: 'pipe' });
await new Promise((r) => setTimeout(r, 3000));
const reset = await fetch(`${API}/api/student/request-password-reset`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: `p5c-resend-${RUN_ID}@example.com` }),
}).then((r) => r.json());
assert.ok(!('dev_reset_token' in reset), 'production must never echo the reset token');
// The dev-preview route also refuses outright once COOKIE_SECURE=1, regardless of what a
// notification row happens to hold.
const preview = await fetch(`${API}/api/admin/notifications/${firstActivationNotifId}/dev-preview`, { headers: { Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` } });
assert.equal(preview.status, 404);
} finally {
execSync(`bash -c "grep -v '^COOKIE_SECURE=' .env > .env.tmp && mv .env.tmp .env"`, { cwd, stdio: 'pipe' });
execSync('docker compose -f docker-compose.dev.yml up -d --force-recreate api', { cwd, stdio: 'pipe' });
await new Promise((r) => setTimeout(r, 3000));
}
});
// ── §31-32/§35-36: restart persistence + regression smoke ───────────────────────────────
if (!SKIP_RESTART_TEST) {
await test('a real Platform API restart preserves the notification queue and does not duplicate an already-sent notification', async () => {
const { body: before } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' });
await new Promise((r) => setTimeout(r, 4000));
const reLogin = makeSession(GW);
await reLogin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const { body: after } = await reLogin.request('GET', `/api/admin/students/${pendingStudentId}/notifications`);
assert.equal(after.length, before.length, 'restart must not duplicate any notification row');
const sentCount = after.filter((n) => n.status === 'sent').length;
const sentCountBefore = before.filter((n) => n.status === 'sent').length;
assert.equal(sentCount, sentCountBefore);
});
} else {
console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)');
}
await test('Phase 5B enrollment/resume still works unmodified', async () => {
const s = makeSession(GW);
await s.request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' });
const { status, body } = await s.request('GET', '/api/student/programs');
assert.equal(status, 200);
});
await test('Phase 5A instructor authentication still works unmodified', async () => {
const s = makeSession(GW);
const { status } = await s.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(status, 200);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });