22 Commits
Author SHA1 Message Date
ced4568 e6a5902ddf docs(website): record that the website-side registration/checkout flow is now built
ced4568/RootNode-academy-website (separate GitHub repo, Chase-authorized access) now implements
docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md's full contract on its own side: public offering
lookup, registration (adult + guardian/minor), agreement acceptance, checkout creation, and a
status-polling success page. Its own stale pricing was also corrected to match docs/
ROOTNODE_PRICING_AND_OFFERS_V1.md. It has nothing to talk to yet -- no real Platform API exists
(Gate 4's hardware blocker, unchanged) -- so every RootNode-facing route there returns a clean
503 until ROOTNODE_API_URL/ROOTNODE_INTEGRATION_KEY point at a real deployment.

Updates PHASE10_LAUNCH_READINESS.md Gate 10 and its matrix row, and WINTER_BREAK_LAUNCH_CHECKLIST.md
and WINTER_BREAK_CAMP_OPERATIONS.md's own website-status notes, all of which previously said
'nobody has built the website-side form yet.'
2026-08-28 15:22:10 -05:00
ced4568 bfae2aafe0 docs(business): canonicalize RootNode V1 pricing matrix
docs/ROOTNODE_PRICING_AND_OFFERS_V1.md — the founder-approved, locked V1 pricing matrix (seasonal
camps, instructor-led programs, self-paced, mentoring/workshops, sibling policy, Winter Break
launch terms) recovered from synced conversation history after the original Phase 9.5 commit was
confirmed absent from canonical Git history. Cross-validated against the independently-prepared
RootNode Academy WHM+WHMCS Agent Handoff's own price guardrail table — identical figures.

Does not resolve or invent dates, venue, capacity, tax treatment, refund/cancellation policy, or
legal agreement language — those remain separate, open launch gates.

PHASE10_CANONICAL_STATE_AUDIT.md updated to reflect the recovery — the original Phase 9.5 commit
is still confirmed missing from history, but the pricing decision itself is now canonically
recorded rather than resting only on verbal confirmation. PC #2 inspection remains useful only to
check for an earlier local copy to compare against, not to locate the pricing itself anymore.
2026-08-28 14:06:03 -05:00
ced4568 74033b4cb8 docs(audit): reconcile Phase 10 findings on Phase 9.5, Infisical, and website pricing
Phase 9.5: reconfirmed no canonical commit exists (broader content search across full history —
still zero hits outside this session's own commits); Chase confirmed the real document lives on
PC #2, unpushed. Not duplicated here per Chase's explicit instruction — he owns bringing it to
canonical Gitea.

Infisical: Phase 10's 'project does not exist' finding was incomplete, not wrong about what it
checked — a proper org-scoped API enumeration (8 projects) still shows no RootNode Academy
project visible to the claude-code-migration machine identity, even though Chase confirms it
exists. Root cause narrowed to a missing per-project access grant (Infisical scopes identity
visibility per-project, separate from org membership) rather than the project not existing.
Corrected docs/SECRETS_AND_INFISICAL.md and docs/PHASE10_CANONICAL_STATE_AUDIT.md accordingly,
with the exact one-step fix (grant the identity access via Project Settings -> Access) and the
identity id needed to do it. No duplicate project was created.

Website pricing: a read-only fetch of rootnodeacademy.com found stale founding-price RANGES that
don't match the exact fixed V1 prices Chase gave directly this phase; Chase confirmed the fixed
prices in this repo's docs are correct and current. Noted as a real discrepancy for his own
awareness, not something this repo's scope covers fixing.

Updated the Phase 10 launch matrix with a Category column (TECHNICAL READY / OWNER DECISION
REQUIRED / EXTERNAL BLOCKER / HARDWARE BLOCKER) distinguishing who needs to act on each
remaining gate. No application code changed — audit/documentation reconciliation only.
2026-08-28 13:09:05 -05:00
ced4568 0b2303a3e2 docs(phase10): canonical state audit, secrets inventory, production/security/launch readiness
Gate 1 — canonical state audit: this workstation's HEAD/origin/main and every claimed milestone
SHA (Phase 2A through 9.1) verified consistent; no stranded branches/tags on Gitea; full-history
secret scan clean (no rotation required); Phase 9.5 confirmed absent from canonical history
(pricing was given directly by Chase and used without it); second-PC reconciliation commands
provided since this session cannot see that machine.

Gate 3 — secrets: real inventory derived from process.env greps across every service (not
guessed); Infisical auth fixed (wrong domain was the actual root cause of Phase 9.1's failures —
the identity belongs to infisical.cedshomelab.com, not app.infisical.com) but no RootNode
Academy project exists there yet, contradicting this phase's own authorization — flagged for
Chase to create.

Gates 4-9/15-16 — production: verified, not assumed, that no dedicated server/Proxmox VM has
been provisioned (empty terraform.tfvars, no state file, placeholder group_vars/production.yml)
— IaC validated (tofu validate, ansible --syntax-check both clean) and left unexecuted per this
phase's own 'do not fake production deployment' instruction. rootnodeacademy.com confirmed live
(Cloudflare, real HTTPS) — the separate, not-RootNode-owned website; no Platform API subdomain
exists yet.

Gate 18 — security audit: full review against every regression suite rerun this phase; no
launch-critical defect found.

Gates 19/20 — full regression (131 checks: e2e-phase5b/5c/6f/7/8/9, content validation,
migrations, frontend build) and the Winter Break commerce rehearsal, both rerun clean against
current main.

Gates 10-14/21 — website integration, Winter Break pricing (99/49, real, Stripe-validated),
agreements/legal, tax, and refund policy all reviewed; legal text/tax/refund policy/dates/venue/
capacity correctly left as open owner decisions, not invented.

No application code changed this phase — audit/launch-prep only.
2026-08-28 12:07:25 -05:00
ced4568 a4c0058f8c docs(stripe): document real Stripe TEST-mode validation results
PHASE9_1_STRIPE_TEST_VALIDATION.md rewritten from BLOCKED to the real, proven results: a real
Stripe TEST Checkout, a real TEST payment at the founder-approved $399 Founding Cohort price, a
genuine signed webhook, the existing registration->enrollment conversion, a real
duplicate-delivery proof, a real automated abandoned-checkout proof, a real refund with the
non-cascade rule reconfirmed, full Stripe<->RootNode reconciliation IDs, and the one real defect
found and fixed. STRIPE_INTEGRATION_RUNBOOK.md updated (real-Stripe local testing is no longer
'not yet exercised'; notes the no-sudo Stripe CLI install path and --api-key/--print-secret
usage). WINTER_BREAK_LAUNCH_CHECKLIST.md: Stripe TEST validation checked, price checked ($399
launch / $449 retail per Chase directly), live-mode items correctly still unchecked.
PHASE9_COMMERCE_AND_LAUNCH.md's Phase 9.1 addendum updated to reflect completion.
2026-08-28 09:47:11 -05:00
ced4568 fbf52d8334 test(stripe): add real-Stripe regression coverage and complete the opt-in TEST-mode check
e2e-phase9.js: one new deterministic regression test proving the webhook-correlation fix — a
test event referencing a transaction's own UUID (the exact shape a real Stripe
payment_intent.payment_failed/charge.refunded event now produces) is still matched. 20/20.

stripe-integration-test.js: configurable price/currency (STRIPE_TEST_PRICE_CENTS/
STRIPE_TEST_CURRENCY) and a STRIPE_TEST_MINOR=true guardian+minor path. Once a real payment
lands, now fully automates deep post-payment verification: exactly-one payment_transactions
record, registration/enrollment/cohort/guardian reconciliation, a genuine duplicate-delivery
proof (re-signing the REAL event fetched via stripe.events.retrieve — never a fabricated one),
real student activation + program-visibility, and a real refund with the non-cascade rule
reconfirmed — plus a fully automated, browser-free abandoned-checkout proof via Stripe's own
sessions.expire() API. Still opt-in, still a guaranteed no-op by default, still refuses
live-mode keys.
2026-08-28 09:47:01 -05:00
ced4568 c86b3de611 fix(payment): correlate PaymentIntent/Charge-referencing webhooks to the right transaction
Real Stripe TEST traffic (Phase 9.1) exposed a genuine defect the deterministic TestProvider
never could: payment_intent.payment_failed and charge.refunded events reference Stripe's
PaymentIntent/Charge object, never the Checkout Session, but payment_transactions.
provider_reference only ever stored the Session id. A real failure or refund webhook would have
silently failed to match any transaction (UNKNOWN_TRANSACTION).

- payment-provider.js: session creation now also sets payment_intent_data.metadata (the same
  metadata already carrying our transaction_id), so it propagates onto the PaymentIntent Stripe
  creates. normalizeEvent() (now async) reads transaction_id back from the PaymentIntent's own
  metadata directly for payment_intent.payment_failed, and via one extra paymentIntents.retrieve
  call for charge.refunded (Charges don't inherit PaymentIntent metadata automatically).
- commerce.js: the transaction lookup now matches provider_reference OR transaction_id — one
  additional OR clause, no schema/migration change.
- webhook-routes.js: added the now-required await on verifyAndParseWebhook (TestProvider's
  synchronous return is unaffected).
- docker-compose.dev.yml: wires PAYMENT_PROVIDER/STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET through
  to the api container (previously entirely absent — Phase 9 never needed it against the
  deterministic provider), empty/unset by default matching EMAIL_PROVIDER's own convention.

Verified fixed against a real Stripe TEST refund (Phase 9.1); e2e-phase9.js 20/20 and the Winter
Break commerce rehearsal 13/13 rerun clean after the fix.
2026-08-28 09:46:51 -05:00
ced4568 77fd1869db docs(stripe): document Phase 9.1 blocked status and live-mode readiness checklist
New: PHASE9_1_STRIPE_TEST_VALIDATION.md — baseline verification, the exact Stripe-credential
search performed (none found), the Stripe architecture review (dynamic price_data, unchanged),
what was proven vs. blocked per the mission's own step list, and the live-mode readiness
checklist. Updated: STRIPE_INTEGRATION_RUNBOOK.md and WINTER_BREAK_LAUNCH_CHECKLIST.md to point
at the new opt-in test and the blocked status. PHASE9_COMMERCE_AND_LAUNCH.md gets a short
addendum. No application code changed this phase — no genuine defect was exposed without real
Stripe traffic, so no regression rerun was required.
2026-08-27 10:40:53 -05:00
ced4568 f0ff14af38 test(stripe): add opt-in real Stripe TEST-mode integration check
RUN_STRIPE_INTEGRATION_TEST=true (default: no-op, zero Stripe network traffic). Refuses to run
against a live-mode secret key. Automates what's provable via pure API alone — real Checkout
Session creation verified against Stripe's own retrieve API, server-authoritative pricing proven
against Stripe's own recorded amount even under a tampered caller amount, invalid-signature
webhook rejection with no domain side effect — then polls RootNode's status endpoint for a
human/Stripe-CLI-completed real payment rather than fabricating a signed success event. Never
runs as part of normal regression/CI.
2026-08-27 10:40:42 -05:00
ced4568 80a5f6e457 docs(phase9): document commerce integration and Winter Break launch readiness
New: PHASE9_COMMERCE_AND_LAUNCH.md, WEBSITE_REGISTRATION_AND_CHECKOUT.md,
STRIPE_INTEGRATION_RUNBOOK.md, WINTER_BREAK_LAUNCH_CHECKLIST.md. Updated:
ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (commerce boundary + PHASE 9 BUILT rows),
OFFERING_AND_REGISTRATION_MODEL.md (pricing, the payment/commerce boundary, three-status
principle), WEBSITE_ENROLLMENT_INTEGRATION.md (price fields, real agreement/checkout/webhook
sections replacing Phase 8 placeholders), WINTER_BREAK_CAMP_OPERATIONS.md (real payment step
replacing the manual placeholder).
2026-08-27 09:06:04 -05:00
ced4568 281d60bc2d test(camp): add Winter Break commerce rehearsal
13/13 against a real, freshly-built stack, PAYMENT_PROVIDER=test (no live money): admin
configuration including price/payment-provider readiness, minor + adult registration through
real checkout and a real webhook call, duplicate-webhook idempotency, failed-payment seat
retention, abandoned-checkout seat release, a last-seat checkout race, a real classroom session,
and a real Lab task completed by the newly-activated minor student.
2026-08-27 09:05:54 -05:00
ced4568 8b3e56e402 test(phase9): add commerce integration acceptance suite
19/19: pricing in the public serializer, checkout gated on agreements, server-authoritative
amount, webhook signature verification and idempotency, payment success/failure/abandonment,
last-seat checkout race, reservation reaper, manual refund with no cascade, minor guardian
routing, admin/RBAC payment-data restrictions, audit/metric hygiene, the status-lookup endpoint,
and confirmation that ROOT_ V1/free-offering/classroom flows are unaffected.
2026-08-27 09:05:46 -05:00
ced4568 f0dde9b84a feat(admin): add offering pricing and payment/refund UI
- admin-offerings-routes.js: PATCH /api/admin/offerings/:id (allowlisted fields), GET
  /api/admin/registrations/:id/payments, POST /api/admin/payments/:id/refund
- frontend: Pricing card on offering detail, Payments card (transaction history + record-refund
  action) on registration detail — admin/owner-gated, reusing existing admin UI conventions
2026-08-27 09:05:36 -05:00
ced4568 16622bd9ab feat(webhook): add verified payment webhook route and website checkout/agreement contract
- webhook-routes.js: POST /api/webhooks/payment — signature-verified, idempotent, always 200
  once verified (a forged/invalid signature is a clean quiet 400)
- integration-routes.js: POST /api/integrations/registrations/:id/agreements (closes Phase 8's
  gap — agreement acceptance now on the public registration contract), POST .../:id/checkout
  (server-to-server, amount always server-side), GET .../:id/status (minimal safe status lookup
  for the website's success page — no PII, no amount, no provider reference)
- public-offerings-routes.js: price_amount/price_currency/requires_payment in the public serializer
- metrics.js: checkout/payment/webhook/conversion/seat-released counters, all low-cardinality
- notifications.js: listForRegistration now unions by the registration's own enrollment_id too,
  so the post-conversion activation notification shows up in the registration's communication
  history (previously only pre-conversion notifications were visible)
2026-08-27 09:05:29 -05:00
ced4568 b0e07bb17f feat(commerce): add payment-provider abstraction, commerce layer, and pricing schema
- migration 037: offerings.price_amount/price_currency, offering_registrations.reservation_expires_at,
  payment_transactions, payment_webhook_events (webhook_event_id as real PK — the idempotency mechanism)
- payment-provider.js: StripeProvider (real SDK, HMAC webhook verification) + TestProvider
  (deterministic, in-process, no network) behind one interface, selected via PAYMENT_PROVIDER
- commerce.js: createCheckoutSession (server-side price, never caller-supplied), processWebhookEvent
  (idempotent), handlePaymentSuccess/Failure/CheckoutAbandoned/Refund, reservation reaper support
- offerings.js: computeReservationExpiry, updateOffering (allowlisted PATCH), readiness gained
  price/payment_provider checks, convertRegistrationToEnrollment now fires the activation email
  every other enrollment path already triggers, and links the registration's guardian_id into
  student_guardians on conversion (real bug: a converted minor had no guardian link, so the new
  activation email had nowhere to resolve a recipient to)
- server.js: raw-body middleware scoped to /api/webhooks/payment, reservation-reaper poller
2026-08-27 09:05:19 -05:00
ced4568andClaude Sonnet 5 788fb197e0 docs(phase8): document enrollment operations
New: PHASE8_ENROLLMENT_OPERATIONS.md (phase summary), WINTER_BREAK_CAMP_OPERATIONS.md
(the real operational walkthrough), REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md,
OFFERING_ADMIN_GUIDE.md.

Updated: ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (BUILT NOW/PHASE 8 BUILT
table), OFFERING_AND_REGISTRATION_MODEL.md (both ordering bugs, launch readiness,
registration enforcement), STUDENT_GUARDIAN_DATA_MODEL.md (guardian communications now
wired), WEBSITE_ENROLLMENT_INTEGRATION.md (the real, live registration contract with
worked examples, replacing the old speculative future-flow section), platform/README.md
(test list).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 23:59:42 -05:00
ced4568andClaude Sonnet 5 aa63d09fb8 test(phase8): add enrollment operations coverage and Winter Break registration rehearsal
- e2e-phase8.js (16/16): offering CRUD/transitions/readiness, registration open/closed
  enforcement, capacity + concurrent final-seat race through the real registration
  endpoints, the public serializer's safety, website integration idempotency (sequential +
  concurrent), adult/minor paths, required-agreement conversion blocking with version
  snapshotting, offering->cohort membership, guardian recipient resolution, RBAC, and
  confirmation ROOT_ V1/self-paced/classroom flows are unaffected.
- test-phase8-winter-camp-registration.js (12/12): the full commercial-path story with no
  payment code — website submits a real minor registration (real guardian created/matched),
  guardian agreement acceptance, a simulated admin-side payment confirmation, conversion to
  real enrollment/cohort, real account activation, an independent adult path, two students
  genuinely competing for the final seat, and a REAL Lab task completed by the minor
  student with the instructor then seeing the real progress.
- e2e-phase7.js: fixed two now-stale test assumptions the new server-authoritative
  registration-window enforcement broke (an offering left in draft that Phase 7's own test
  never transitioned before registering against it) and widened a file-allowlist check for
  the new, legitimate public-offerings-routes.js.

Regression: e2e-phase6f.js 17/17, e2e-phase5b.js 32/32, e2e-phase5c.js 29/29, all fresh-stack.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 23:59:32 -05:00
ced4568andClaude Sonnet 5 40362ec0c9 feat(admin): add Offerings operations UI
Offerings list + create; offering detail with overview (valid-transitions-only buttons),
server-computed launch readiness, required agreements, and registrations/roster;
registration detail with lifecycle transitions, agreement status, communication history
(masked recipients), and convert-to-enrollment. Reuses the existing Admin.module.css/
adminApi conventions exactly (ProgramsView.jsx's own list+detail pattern) — no new UI
framework or pattern introduced.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 23:59:20 -05:00
ced4568andClaude Sonnet 5 24eee80354 feat(offering): launch readiness, agreement requirements, guardian communications, website registration API
- Registration is now server-authoritative: draft/cancelled/completed/full/window-closed
  offerings reject registration attempts with distinct error codes (assertRegistrationOpen).
- offering_agreement_requirements + conversion-time blocking on missing required
  agreements; accepted_by_type gained 'registrant' for an adult self-accepting before
  their student_id exists.
- computeOfferingReadiness() — a server-derived launch checklist (curriculum, instructor,
  schedule, location, capacity, registration window, agreements, curriculum package).
- Guardian communications wired into the existing Phase 5C notification system: a guardian
  email fallback in resolveRecipient(), a parallel enqueueForRegistration()/
  resolveRegistrationRecipient() pair for pre-account registration-stage notifications
  (registration_received/confirmed/needs_action, agreement_required, camp_operational_update),
  five new RootNode-branded templates. Same table, same worker, same masked admin view —
  no second notification system.
- public-offerings-routes.js: the unauthenticated, explicitly-allowlisted offering read API.
- POST /api/integrations/registrations: the website's idempotent registration submission
  contract, same shared-secret boundary as the existing enrollment integration endpoint.

Two real bugs found and fixed during this phase's own testing before any commit:
(1) the five new notification_type values were wired into the worker before the DB's own
CHECK constraint was widened to accept them — every registration-stage notification was
silently failing until a comprehensive smoke test caught it (036 fixes the constraint;
every previously-silent notification catch now at least logs).
(2) firing a notification right after a registration's insert, before its seat reservation
could still fail and roll the row back, created a real foreign-key violation on rollback —
fixed by reordering createRegistration() to validate -> reserve -> insert -> notify.

See docs/OFFERING_AND_REGISTRATION_MODEL.md and docs/PHASE8_ENROLLMENT_OPERATIONS.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 23:59:11 -05:00
ced4568andClaude Sonnet 5 df5e39f767 docs(phase7): document the business/product architecture foundation
New: ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (canonical business/platform
bridge — course families, delivery modes, BUILT NOW/PHASE 7 BUILT/FUTURE), OFFERING_AND_
REGISTRATION_MODEL.md (lifecycle diagrams, capacity mechanics), STUDENT_GUARDIAN_DATA_
MODEL.md (data minimization, RBAC, legal-review items), SCHOOL_AND_COMMUNITY_DELIVERY_
ARCHITECTURE.md (organization/location mapping, explicitly not multi-tenancy),
PHASE7_PRODUCT_ARCHITECTURE.md (phase summary).

Updated: WEBSITE_ENROLLMENT_INTEGRATION.md (documented future offering/registration flow,
current live contract unchanged), platform/README.md (test list).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:44:13 -05:00
ced4568andClaude Sonnet 5 7d870d8e2b test(phase7): add business/product architecture acceptance suite and Winter Camp rehearsal
- e2e-phase7.js (17/17): organizations/locations, guardians, curriculum packages
  (reference-only), offering capacity/transitions, registration lifecycle, agreements,
  registration-to-enrollment conversion, RBAC, age-guidance-independence, and confirmation
  that existing ROOT_ V1 self-paced/classroom flows are unaffected.
- test-phase7-winter-camp.js (18/18): the full Winter Break Camp operational path with NO
  payment code invoked — organization/location/curriculum-package/cohort/offering setup,
  guardian + adult registration, agreement acceptance, a simulated admin-side commerce
  confirmation, conversion to real enrollments and real cohort membership, real account
  activation via real one-time tokens, a real classroom session, and a REAL Lab task
  (linux-inspect-hardware) completed by the minor student inside a real disposable Lab
  container, confirmed to survive a real logout/login.

Regression: e2e-phase6f.js 17/17, e2e-phase5b.js 32/32 (including its own embedded Phase 5A
checks) on independent fresh stacks.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:44:02 -05:00
ced4568andClaude Sonnet 5 3510979196 feat(platform): business/product architecture — organizations, guardians, offerings, registrations
- organizations, locations, guardians, student_guardians (033) — additive, no existing
  table's meaning changes. Widens programs.family to accept 'bootcamp'.
- curriculum_packages/curriculum_package_items, offerings, instructor_offerings,
  offering_registrations, agreement_definitions/agreement_acceptances (034) — the full
  purchasable-delivery model. Server-defined status transitions only (no client-controlled
  offering/registration status). Capacity uses a single atomic UPDATE (reserved_seats),
  never a SELECT-count-then-INSERT race.
- offerings.js — business logic: reserveSeat/releaseSeat, transitionOffering/
  transitionRegistration (fixed transition tables), convertRegistrationToEnrollment
  (reuses enrollment.js's existing resolveOrCreateStudent/createManualEnrollment rather
  than duplicating student-matching logic).
- enrollment.js — resolveOrCreateStudent gains an optional, default-off returnToken param
  (used only by offerings.js's admin-only conversion route; the public website integration
  endpoint's contract is completely unchanged, per docs/WEBSITE_ENROLLMENT_INTEGRATION.md).
- admin-offerings-routes.js — the full admin/owner API surface. Registration/guardian
  detail is admin/owner only; offering/location listing is any instructor (access_notes
  stripped for a plain instructor).
- A real bug found and fixed during this phase's own smoke testing before any commit: the
  registration route originally reserved a seat BEFORE validating the registration's own
  fields, letting a validation failure consume a real seat and get masked behind a false
  OFFERING_FULL under a full offering. Fixed: validate (create) first, reserve second, roll
  back the registration row if reservation then genuinely fails.

See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and
docs/OFFERING_AND_REGISTRATION_MODEL.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:43:52 -05:00
56 changed files with 7614 additions and 14 deletions
+69
View File
@@ -0,0 +1,69 @@
# Offering Admin Guide
Phase 8 — how to actually run an offering from `/admin` → Offerings, end to end. Pairs with
`docs/OFFERING_AND_REGISTRATION_MODEL.md` (the underlying data model) and `docs/
WINTER_BREAK_CAMP_OPERATIONS.md` (the specific Winter Break Camp walkthrough).
## Creating an offering
**Offerings → Create offering.** Required: offering ID, URL slug, name, program, delivery mode.
Optional at creation: capacity (blank = unlimited), a location. Dates, the registration window,
organization, cohort, curriculum package, and agreement requirements are all set from the
offering's own detail page after creation — a new offering always starts `draft`, invisible to
the public offerings API and closed to registration, so there's no rush to fill in everything
before saving.
## The detail page
Opening an offering shows:
- **Overview** — status, seats, and the only status-transition buttons the offering can
actually take next (the ones the UI doesn't show are ones the server would reject anyway —
see `docs/OFFERING_AND_REGISTRATION_MODEL.md`'s transition diagram).
- **Launch readiness** — a live, server-computed checklist (curriculum, instructor, dates,
location, capacity, registration window, agreements configured). This is the same check the
server itself could use to gate opening registration — it is never just a frontend hint.
- **Required agreements** — which agreement definitions this offering requires before a
registration can convert to enrollment, with a form to add another.
- **Registrations / roster** — every registration against this offering, with a form to add one
directly (useful for phone/in-person sign-ups an instructor is entering by hand).
## Moving an offering through its lifecycle
`draft → published → enrollment_open → full/in_progress → completed`, or `→ cancelled` from
anywhere non-terminal. Only real, server-allowed transitions ever appear as buttons. `full` is
not automatic — an offering set to `enrollment_open` that happens to be at capacity will reject
new registrations with a clear error, but only moves to the visible `full` status when someone
explicitly sets it (or the very next registration attempt trips it) — check the readiness
panel/registration list to know why registration is currently unavailable.
## Working a registration
Open a registration from the offering's roster to see its own detail: registrant/guardian info,
agreement status (required vs. accepted vs. missing), lifecycle transition buttons, and its
communication history (every notification queued for it, with recipient always masked). Once a
registration is `confirmed` and every required agreement is accepted, **Convert to enrollment**
appears — this is the one action that creates the real student account (or matches an existing
one), the real academic enrollment, and real cohort membership if the offering has a cohort.
## Agreements
**Agreements** (reachable from an offering's "Required agreements" section) lets you define a
new agreement (title + a plain-language summary — never legal text, see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`) and associate it with any offering that needs it. A registration
cannot convert to enrollment while any required agreement is unaccepted.
## Guardians
A minor's registration always carries a linked guardian — create one from the registration form
when registering a minor by hand, or let the website integration create/match one automatically
from a real submission (see `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`). Guardian records are
admin/owner-only — a plain instructor's cohort access to a student's academic progress is
unchanged, but guardian contact detail is not part of that.
## What the admin UI deliberately does not do
No pricing/payment fields, no legal-document authoring, no bulk email composer — every
communication is a templated, transactional notification triggered by a real lifecycle event
(see `docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`), never a free-form message an admin
types and blasts out.
+190
View File
@@ -0,0 +1,190 @@
# Offering & Registration Model
Phase 7 Steps 3/6/12-14/18-20 (extended in Phase 8, extended again in Phase 9) — the schema and
lifecycle for offerings, capacity, registrations, agreements, and curriculum packages. See
`platform/api/migrations/034_phase7_offerings_registrations_agreements.sql`,
`035_phase8_enrollment_operations.sql`, and `037_phase9_commerce.sql` for the exact DDL and
`platform/api/src/offerings.js`/`commerce.js` for the implementation.
**Updated for Phase 8**: registration is now server-authoritative (draft/cancelled/completed/
full/window-closed offerings reject registration attempts with distinct error codes — see
`assertRegistrationOpen()`); offerings can require specific agreements before a registration may
convert to enrollment (`offering_agreement_requirements`); `accepted_by_type` gained a
`'registrant'` value for an adult self-accepting before their student_id exists; the website
registration integration (`POST /api/integrations/registrations`) is live — see `docs/
WEBSITE_ENROLLMENT_INTEGRATION.md`; and launch readiness is now server-computed
(`computeOfferingReadiness()`), exposed at `GET /api/admin/offerings/:id/readiness`.
**Updated for Phase 9**: an offering can carry a real price (`price_amount`/`price_currency`); a
paid offering's registration reserves its seat with a bounded expiry
(`reservation_expires_at`) instead of holding it forever; agreement acceptance is now reachable
from the public registration contract (closing Phase 8's one open gap); and a verified payment
webhook — never a browser redirect — is what actually confirms a registration and activates the
enrollment. See "Pricing" and "Payment and the commerce boundary" below, and `docs/
PHASE9_COMMERCE_AND_LAUNCH.md` for the full phase report.
## Offering lifecycle (server-defined transitions only)
```
draft ──► published ──► enrollment_open ──┬──► full ──► enrollment_open (reopen)
│ │ │ │
│ │ ├──► in_progress ──► completed
│ │ │
└──► cancelled ◄──────────────────────────────────────────────────────┘
```
No route accepts an arbitrary client-supplied status — `offerings.transitionOffering()` checks
a fixed transition table and rejects anything not explicitly allowed with `409
INVALID_TRANSITION`. `full` is a distinct status from `enrollment_open` (not a computed flag) so
an admin can see at a glance why enrollment stopped, and can explicitly reopen it (e.g. a seat
freed up) rather than have it silently reappear.
## Capacity — race-safe by construction
`offerings.capacity` is nullable (NULL = unlimited, the normal case for `self_paced`).
`offerings.reserved_seats` is a maintained counter, incremented by exactly one SQL statement:
```sql
UPDATE offerings SET reserved_seats = reserved_seats + 1
WHERE offering_id = $1 AND reserved_seats < capacity
RETURNING *;
```
If zero rows come back, the offering is genuinely full — `409 OFFERING_FULL`. This is not a
`SELECT count(*) ... then INSERT` pattern (the exact anti-pattern the authorization named): the
row-level lock Postgres takes for the UPDATE's own duration makes two concurrent registration
attempts for the last seat serialize correctly, with exactly one succeeding, never both.
**Two real ordering bugs found and fixed across Phase 7 and Phase 8, in opposite directions —
the current, final order is: validate fields → reserve the seat → insert the row → notify.**
Phase 7's own testing found the route originally reserved the seat *before* validating the
registration's fields (e.g. a minor missing `guardianId`), letting a plain validation error get
masked behind a false `OFFERING_FULL` under a full offering. Fixed by validating first.
Phase 8 then added a `registration_received` notification fired right after the registration row
was inserted — which briefly made the ORIGINAL Phase 7 fix's remaining shape (insert, then
reserve, then roll back the row by deleting it if the seat turned out to be unavailable) newly
unsafe: the notification row, created after insert but before the seat check, held a real
foreign key to the registration — so when reservation failed and the code tried to delete the
just-inserted registration row, that DELETE itself failed on the very foreign key the
notification had just created. Found via this phase's own regression testing, fixed by moving
seat reservation to happen BEFORE the insert (rolling the seat back, not a row, if the insert
itself then somehow fails) and only ever queuing the notification once the registration is fully,
durably committed — see `offerings.js`'s `createRegistration()` and its own header comment.
## Registration lifecycle
```
started ──► submitted ──► reviewed ──┬──► agreements_pending ──┬──► payment_pending ──► confirmed ──► enrollment_activated
│ │ │ │
│ └──────────────────► confirmed ◄────────────────┘
└──► cancelled (from any non-terminal state; releases the reserved seat)
```
Not every offering needs every stage — a simple self-paced offering can go `started` →
`confirmed` → `enrollment_activated` directly; a stage is a real state a registration CAN pass
through, not one it MUST. `enrollment_activated` and `cancelled` are terminal.
**Phase 9** — for a PRICED offering, a verified `checkout.session.completed` (or the test
provider's equivalent) webhook event is what actually walks the registration through whichever
of `submitted`/`agreements_pending`/`reviewed`/`payment_pending` it's currently sitting in,
straight to `confirmed`, using the exact same `transitionRegistration()` table every other path
already enforces — never a shortcut that skips a real state. A failed payment does NOT cancel
the registration or release its seat (the registrant can retry checkout against the same
reservation); only an abandoned/expired checkout session, or the reservation reaper timing out an
un-paid reservation, cancels the registration and releases the seat. See "Payment and the
commerce boundary" below.
A registration may exist with `student_id = NULL` (a guardian registering a not-yet-created
student). `convertRegistrationToEnrollment()` is the one place a registration becomes a real
academic `enrollment` row — it reuses `enrollment.js`'s existing `resolveOrCreateStudent`/
`createManualEnrollment` rather than duplicating student-matching or enrollment-creation logic,
and creates `cohort_enrollments` membership too if the offering has a `cohort_id`.
## Minor registrations
`offering_registrations.is_minor = true` requires a non-null `guardian_id` — enforced by a
database CHECK constraint, not just application logic, so it holds even against a direct/future
API caller that skips the usual route. The guardian is a real `guardians` row (Phase 7's new
model), never a duplicated email on the eventual student record.
## Agreements — acceptance records, not legal text
`agreement_definitions` stores a title and a plain-language `body_summary` for admin-UI display
— explicitly **not** attorney-approved legal language (see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s
legal-review list). `agreement_acceptances` snapshots the `accepted_version` at acceptance time
(not just a live foreign key to the current version), so a later edit to an agreement's text
never retroactively reinterprets what someone already agreed to — the same principle
`COURSE_CONTENT_MODEL.md` §6b already established for assessment session versioning. An
acceptance is tied to a student OR a guardian (a CHECK constraint requires at least one), and
optionally to the registration/enrollment context it was collected for.
## Curriculum packages — reference, never clone
A `curriculum_package` names a versioned subset of an existing program's courses via
`curriculum_package_items` (`course_id` + `sequence`) — a pure reference list, storing no
content of its own. Worked example, matching `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s
Option A:
```
curriculum_package_id: winter-linux-systems-v1
program_id: root-kubernetes-foundations
items: [ (root-01-computing-linux-foundations, seq 1), (root-02-linux-administration-networking, seq 2) ]
```
An offering referencing this package still points its students at the real, unmodified ROOT 01/
02 courses — nothing is cloned, no activity is duplicated, and any future content fix to ROOT 01/
02 is automatically reflected for every offering referencing the package. Historical offerings
stay stable against a **specific version** of the package (bump `curriculum_packages.version` for
a semantic change, exactly like every other versioned row in this schema) rather than silently
picking up a redefinition.
## Offering → cohort
For cohort/camp/workshop delivery, an offering's `cohort_id` (nullable) points at an existing
`cohorts` row, unchanged from Phase 2A. `convertRegistrationToEnrollment()` inserts the matching
`cohort_enrollments` row when present — a student registering into a cohort-backed offering ends
up on the real classroom roster automatically, with zero duplicated roster state. A `self_paced`
offering simply has `cohort_id = NULL`; there is no cohort to join.
## Pricing (Phase 9)
`offerings.price_amount` (integer minor units — cents, matching Stripe's own convention;
`NULL` = free/no checkout) and `offerings.price_currency` (default `usd`), both editable via
`PATCH /api/admin/offerings/:id`. A registration's checkout amount always comes from the
offering's OWN server-side price at checkout-creation time — never from anything the browser or
website backend supplies (`commerce.createCheckoutSession()` reads it directly from the
`offerings` row; there is no `amount` field anywhere in the checkout request the website sends).
`computeOfferingReadiness()` gained matching `price` and `payment_provider` checks — a priced
offering isn't launch-ready until a real price is set and the configured payment provider is
actually usable (the test provider always is; Stripe requires both `STRIPE_SECRET_KEY` and
`STRIPE_WEBHOOK_SECRET` to be set).
## Payment and the commerce boundary (Phase 9)
**Payment is a narrow, provider-agnostic layer bolted onto the existing registration model, not
a redesign of it.** `payment_transactions` (one row per checkout attempt: amount/currency
snapshotted at creation, provider, provider_reference, status) and `payment_webhook_events` (one
row per provider event id — the ENTIRE webhook idempotency mechanism, a real unique primary key,
not a "check then insert") are the only two new tables. See `docs/PHASE9_COMMERCE_AND_LAUNCH.md`
for the payment-provider abstraction, the webhook contract, and the reservation-reaper design in
full, and `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's own checkout contract.
**Three separately-tracked statuses, never auto-cascading into each other:** the registration's
own `status` (this document), the enrollment's own `status` (unchanged, `enrollment.js`), and a
payment transaction's own `status` (`payment_transactions.status`). A refund updates ONLY the
transaction's status — it never automatically cancels the registration or the enrollment; an
admin decides separately whether a refund also means "this student is no longer enrolled,"
because the two are genuinely different real-world decisions (a partial-camp refund for a missed
day is not the same decision as pulling a student's access).
## What was deliberately not built
Subscriptions, membership billing, coupons/discount codes, gift cards, installment/financing
plans, complex tax automation, and accounting-system (QuickBooks etc.) integration — all
explicitly out of scope, per Phase 9's own authorization. A waitlist was considered and
deliberately deferred (see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`'s "What was deliberately not
built" section for the reasoning). `offerings.metadata` (JSONB) still has room for future
descriptive marketing fields, but nothing writes payment data into it — payment data lives only
in `payment_transactions`.
+190
View File
@@ -0,0 +1,190 @@
# Phase 10 — Canonical State Audit
Gate 1. Performed before any Phase 10 production/launch work, per Phase 10's own explicit
requirement — Chase has been developing from two workstations, so nothing downstream was
started until canonical state was actually verified, not assumed.
## Baseline
- Starting SHA (as reported at the end of Phase 9.1): `a4c00580...` (full:
`a4c0058f8c3d9612f1d30508626fa5be00431fa7`).
- This workstation's `HEAD` and `origin/main`, confirmed via `git fetch --all --prune`, are
**both exactly `a4c0058f8c3d9612f1d30508626fa5be00431fa7`** — this workstation is fully
up to date with canonical Gitea, and canonical Gitea has not moved since Phase 9.1 ended.
## 1.1 Current workstation state
- Branch: `main`. Working tree: clean (`git status --short` — no output).
- `git remote -v`: `origin` → `gitea:ced4568/rootnode-academy.git` (fetch + push) — canonical,
no GitHub `kubekosh` remote configured in this clone at all.
- `git branch -vv`: `* main a4c0058 [origin/main]` — tracking correctly, no divergence.
- No stashes, no dangling work, no uncommitted changes.
## 1.2 Remote audit
`git ls-remote --heads --tags origin` shows exactly one branch (`main`, at `a4c0058`) and four
tags (`v0.4.1-preprod`, `v0.5.0-student-auth`, `v0.5.3-enrollment-comms`, `v0.6.0-root-v1`) — no
other branches, no unexpected experimental branches, no stale branches to reconcile.
`git log --all --oneline` and `git log --oneline` both return exactly **184** commits — every
commit reachable in this repository is reachable from `main`. **There is no orphaned or
unmerged work sitting in Gitea outside `main`.**
## 1.3 Second-PC reconciliation
This execution environment has no access to Chase's other workstation. Gitea's own state (§1.2)
proves there is no SECOND branch or unpushed-but-fetchable ref waiting anywhere on the remote —
if work exists only on the other PC, it is **local and unpushed there**, not merely unmerged
here. This is not automatically resolvable from this side.
**Exact commands for Chase to run on PC #2** to prove no local-only RootNode work is stranded:
```bash
cd ~/Dev/Gitea/ced4568/rootnode-academy # or wherever the clone lives on that machine
git status # must show "nothing to commit, working tree clean"
git branch -vv # every branch should show [origin/main] with no
# "ahead" count, or should not exist at all
git fetch origin
git log origin/main..HEAD --oneline # MUST be empty — any output here is unpushed work
git stash list # should be empty, or each entry reviewed manually
```
If `git log origin/main..HEAD --oneline` on PC #2 produces ANY output, that is real, unpushed
work — it must be reviewed and pushed (or explicitly discarded by Chase's own decision) before
being considered part of canonical state. **This audit cannot rule that out from here — it can
only confirm Gitea itself has no evidence of it.**
## 1.4 Expected milestone audit
Every phase SHA Phase 10's own authorization listed (Phase 2A through Phase 9.1 final) was
verified present in canonical history, in full agreement with the commit message labels:
| Phase | SHA (verified) | Commit subject |
|---|---|---|
| 2A | `42dfb1c` | docs(phase2a): document gateway and student launch architecture |
| 2B | `08ff531` | docs(phase2b): document admin and platform-data architecture |
| 3A | `4069292` | docs(phase3a): document production identity architecture |
| 3B | `45a147a` | docs(phase3b): document the learning and assessment engine |
| 4 | `bf1e1b5` | docs(phase4): production infrastructure documentation + runbooks |
| 4.1 | `5bc0743` | docs(phase4.1): pre-production hardening documentation |
| 5A | `896c230` | docs(phase5a): student identity and remote classroom documentation |
| 5B | `1c08a30` | docs(phase5b): self-paced enrollment documentation + website integration contract |
| 5C | `777efd1` | docs(phase5c): enrollment communications documentation + Google Workspace runbook |
| 6A | `fd38d06` | docs(phase6a): document RootNode course foundation |
| 6B | `2db1368` | docs(phase6b): document curriculum expansion |
| 6C | `bd0a10f` | docs(phase6c): pilot readiness standard, pilot curriculum manifest, ROOT 11 scope |
| 6D | `b6516a7` | docs(phase6d): document the ROOT Foundations milestone |
| 6E | `87ad125` | docs(phase6e): document the ROOT Foundations Completion milestone |
| 6F | `d066410` | docs(phase6f): document ROOT_ V1 pilot readiness (tag `v0.6.0-root-v1`) |
| 7 | `df5e39f` | docs(phase7): document the business/product architecture foundation |
| 8 | `788fb19` | docs(phase8): document enrollment operations |
| 9 | `80a5f6e` | docs(phase9): document commerce integration and Winter Break launch readiness |
| 9.1 (prep) | `77fd186` | docs(stripe): document Phase 9.1 blocked status and live-mode readiness checklist |
| 9.1 (final) | `a4c0058` | docs(stripe): document real Stripe TEST-mode validation results |
**Original Phase 9.5 commit — NOT FOUND, reconfirmed in Phase 10.1.** No commit, branch, or tag
matching "Phase 9.5" or a founder-pricing document existed in this repository's reachable history
(`git log --all` contains no such commit, searched again in Phase 10.1 with a broader content
search for "FOUNDER APPROVED"/"LOCKED FOR V1"/"Founding Cohort"/`39900` — every hit traces to
the Phase 9.1/10 work, never a separate Phase 9.5 commit). The complete founder-approved matrix
was subsequently recovered from Chase's synced RootNode Academy conversation and canonicalized
as `docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`. The earlier belief that a unique document existed
only on PC #2 was not verified and is no longer relied upon. The original desktop still requires
inspection only to determine whether it holds an earlier unpushed copy or commit. The recovered
document is now the authoritative V1 pricing record unless that inspection reveals a material
difference requiring Chase's review.
**Website pricing is stale, confirmed with Chase.** A read-only fetch of `rootnodeacademy.com`
during Phase 10.1 found founding-price RANGES (INIT_ $200–250, COMPILE_ $300–360, ROOT_
$550–650, CAMPS_ $250–350 — no specific "Winter Break Camp" named, no ROOT_ tier breakdown) that
do not match the exact fixed V1 prices Chase gave directly this phase. Chase confirmed the exact
fixed prices are the correct, current figures — **the live website has not yet been updated to
the real V1 pricing matrix.** This is the separate, not-RootNode-owned website repo; updating it
is outside this repo's scope, noted here only as a real discrepancy for Chase's own awareness.
## 1.5 File/artifact audit
Searched all tracked files for secret-shaped filenames (`.env*`, `.local`, `credential`,
`secret`, `.key`, `.pem`, `.p12`, service-account JSON). Every match is a legitimate
template/placeholder or educational-content reference, never a real runtime secret:
- Course content (`content/root/root-07-.../m02-configuration-and-secrets/`) — teaching material
about Kubernetes Secrets, no real secret data.
- `infra/ansible/roles/*/templates/*.env.j2` — Ansible Jinja2 templates referencing
`{{ vault_* }}` variables (values live in an Ansible Vault-encrypted file, never plaintext in
git).
- `infra/environments/{production,staging}/*.env.template` — checked-in templates with explicit
`__REPLACE_WITH_...__`/`__REPLACE_openssl_rand_hex_32__` placeholders, never real values.
- `platform/provisioner/.env.example`, migration `017_student_credentials.sql` (schema, not
data), `scenarios/data/*secret*.json` (fake Kubernetes lab-scenario fixtures).
`.gitignore` correctly excludes `.env` (blanket pattern) — confirmed via `git status
--porcelain --ignored`: `platform/.env` and `platform/provisioner/.env` (both containing real
local secrets) are ignored, never tracked.
## 1.6 Secret history audit
Searched the **entire** git history (`git log --all -p`, pickaxe search `-S`) for:
`sk_test_`, `sk_live_`, `whsec_`, `BEGIN PRIVATE KEY`, `BEGIN RSA PRIVATE KEY`, `"private_key"`,
and a broad regex for any `sk_test_`/`sk_live_`/`whsec_`-prefixed string of realistic secret
length (20+ trailing characters).
**Result: clean. No real secret value has ever been committed.** Every `sk_test_`/`sk_live_`/
`whsec_` hit is a code guard-clause literal (e.g. `.startsWith('sk_test_')`) or documentation
prose referencing the prefix pattern, from this session's own Phase 9.1 commits — never a full
key. The one `"private_key"` hit (`b38c46a`, Phase 5C) is a code comment showing an EXAMPLE
env-var invocation with a `"..."` placeholder, not a real value. No `BEGIN PRIVATE KEY`/`BEGIN
RSA PRIVATE KEY` header appears anywhere in history. **No credential rotation is required.**
## 1.7 Current runtime secret state (this workstation)
`platform/.env` (gitignored, never committed) holds real local-dev values for
`PROVISIONER_SHARED_SECRET`, `INTEGRATION_SHARED_SECRET`, `GOOGLE_SERVICE_ACCOUNT_JSON`,
`EMAIL_FROM_ADDRESS`, `GOOGLE_DELEGATED_USER` — all pre-existing from earlier phases. Its
`PAYMENT_PROVIDER`/`STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` lines are present but **blank**,
per this repo's own "set only for a deliberate session, unset again after" convention (matching
`EMAIL_PROVIDER`'s established pattern) — confirmed after Phase 9.1's real-Stripe validation
session ended. See `docs/SECRETS_AND_INFISICAL.md` for the full inventory and centralization
status.
## Discrepancies found
1. **The original "Phase 9.5" has no canonical commit** — see §1.4. The complete approved matrix
was recovered from the synced RootNode Academy conversation and canonicalized in
`docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`. PC #2 inspection remains useful only to detect an
earlier local copy or commit and compare it for material differences.
2. **Chase's Phase 10.1 authorization states RootNode Academy "ALREADY EXISTS" as an Infisical
project** on `infisical.cedshomelab.com`. Re-investigated in Phase 10.1 with a proper
org-scoped API enumeration (`GET /api/v2/organizations/{orgId}/workspaces`, not just the
CLI's interactive picker, which turned out to be incomplete — it showed 6 projects in Phase
10, the API shows 8 for the same identity). **Still no project named "RootNode Academy" (or
similar) is visible to this machine identity** even with the more complete method — the
full, real list is: Core Infrastructure, Kubernetes, Media Platform, Monitoring, Network
Service, Privileged Access Manager, Storage, Synthos Systems. **Root cause is now narrowed,
not fully resolved**: this machine identity's own project list only shows projects it has
been explicitly granted access to (standard Infisical per-project ACL, separate from org
membership) — so the most likely explanation is that "RootNode Academy" exists but this
identity was never added to it, not that it doesn't exist. This cannot be distinguished from
here without either org-owner-level API access or the web dashboard, neither available to
this session. **No duplicate project was created.** See `docs/SECRETS_AND_INFISICAL.md` for
the exact one-line fix (grant the `claude-code-migration` identity access to the existing
project via its Project Settings → Access page).
3. **No dedicated production server/Proxmox VM has been provisioned** — `infra/terraform/` has
no `terraform.tfvars` (only `.example`) and no state file; `infra/ansible/group_vars/
production.yml` still holds literal placeholders (`__PLATFORM_VM_IP__`,
`__LAB_HOST_VM_IP__`, `__PROXMOX_HOST_MGMT_CIDR__`). See `docs/
PHASE10_PRODUCTION_DEPLOYMENT.md`.
## Remediation performed
The complete founder-approved Phase 9.5 matrix was recovered from synced conversation history
and written to `docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`. This audit was corrected to distinguish
the missing original Phase 9.5 commit from the now-recovered pricing decision. No existing Git
history was rewritten.
## Conclusion
**Canonical Gitea history through Phase 10.1 is internally consistent, fully verified against
every claimed milestone, and free of any committed secret.** The Phase 9.5 pricing matrix has
now been recovered locally from synced conversation history and awaits review/commit. The only
remaining multi-PC item is running the PC #2 commands in §1.3 to positively rule out stranded
local work or identify an earlier Phase 9.5 copy for comparison.
+167
View File
@@ -0,0 +1,167 @@
# Phase 10 — Launch Readiness
Gates 2, 10-14, 19-20, and the final launch matrix. See `docs/PHASE10_CANONICAL_STATE_AUDIT.md`
(Gate 1), `docs/SECRETS_AND_INFISICAL.md` (Gate 3), `docs/PHASE10_PRODUCTION_DEPLOYMENT.md`
(Gates 4-9/15-16), and `docs/PHASE10_SECURITY_AUDIT.md` (Gate 18) for the rest.
## Gate 2 — Full application audit
Reviewed for architectural coherence across every phase; nothing was found inconsistent or in
need of a fix. Identity/auth, learning, Lab platform, business (offerings/registrations/
guardians/agreements), and commerce (pricing/payments/webhooks) all remain exactly as documented
in their own phase docs — re-verified by actually rerunning every suite (Gate 19 below), not by
re-reading code and assuming it still works.
## Gate 10 — Website integration
**Update (post-Phase 10.1)**: the website side is now actually built, not just documented.
`ced4568/RootNode-academy-website` (a separate GitHub repository — Chase provided access and
explicitly authorized this phase to build against it) now has a real registration/agreement/
checkout/status flow implementing `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`'s full contract:
public offering lookup, registration submission (adult and guardian+minor), agreement
acceptance, checkout creation, and a status-polling success page that never trusts the redirect
URL alone, exactly as that document specifies. Verified there: a clean production build, `tsc`,
eslint, and 9/9 tests all pass. The site's own stale pricing was also corrected to match
`docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`.
It has nothing to talk to yet — `ROOTNODE_API_URL`/`ROOTNODE_INTEGRATION_KEY` are unset on the
website side (no real Platform API exists to point them at), so every RootNode-facing route on
the website returns a clean `503`, never a `500`, until Gate 4's hardware blocker resolves and a
real Platform API origin exists. `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` and `docs/
WEBSITE_ENROLLMENT_INTEGRATION.md` remain the accurate contract both sides now implement.
## Gate 11 — Winter Break offering
Pricing is locked and real: **$399.00 launch ("Founding Cohort") / $449.00 standard reference,
39900/44900 cents, paid in full, no deposit, no payment plan** — given directly by Chase,
validated against real Stripe TEST mode at the exact $399 amount in Phase 9.1. **Dates, venue,
and capacity remain undecided** — not invented by this phase, exactly as every prior phase's own
documentation already states. The offering therefore cannot be transitioned to
`enrollment_open` for real, public registration yet — the platform mechanism is ready
(`PATCH /api/admin/offerings/:id` accepts all four fields the moment they're decided).
## Gate 12 — Agreements / legal
`agreement_definitions`/`agreement_acceptances` architecture is real, tested, and versioned
(Phase 7-9). **No attorney-approved legal text exists for any required Winter Break agreement**
— `agreement_definitions.body_summary` remains a plain-language placeholder, exactly as every
prior phase's docs have stated since Phase 7. This phase did not write legal language and did
not treat any placeholder as attorney-approved, per Phase 10's own explicit instruction.
**LAUNCH BLOCKED — APPROVED AGREEMENT TEXT REQUIRED.** Likely required categories (not
authored, just the taxonomy Phase 10 asked to identify): program participation terms, guardian
consent (minor registrants), a participation/liability waiver, acceptable-use / lab/technology
rules, a cancellation/refund acknowledgment tied to whatever policy Gate 14 resolves, and a
privacy/data-handling notice if RootNode's counsel determines one is needed beyond what's already
in `docs/STUDENT_GUARDIAN_DATA_MODEL.md`. A media release is optional — RootNode's own choice,
not decided here.
## Gate 13 — Tax
**TAX DECISION REQUIRED.** No deliberate Mississippi (or any jurisdiction's) tax treatment has
been configured anywhere in this codebase — `payment_transactions.amount` is charged exactly as
the offering's own price, with no tax line item, no tax calculation, and no tax remittance logic
anywhere (Phase 9's own explicit "no complex tax automation" scope boundary, unchanged). This is
not this phase's decision to make, and the current absence of tax handling should not be read as
an implicit "no tax owed" determination — that requires Chase (or a professional) to actually
decide, then tell the platform what to do (charge tax as a line item, treat the $399 as
tax-inclusive, or confirm no sales tax applies to this specific service in this jurisdiction).
## Gate 14 — Refund / cancellation policy
**LAUNCH BLOCKED — REFUND/CANCELLATION POLICY REQUIRED.** The platform's MECHANICAL behavior is
real, tested, and proven against actual Stripe money-movement in Phase 9.1: a refund is recorded
as its own tracked state and **never automatically** cancels the registration or revokes academic
enrollment — confirmed directly against the database after a real Stripe TEST refund. What is
still missing is the actual customer-facing POLICY text (how many days before the camp is a
refund allowed, is it full or partial, etc.) — a founder decision this phase does not invent.
## Gate 19 — Complete regression
Fresh stack, this phase, after confirming canonical baseline (Gate 1):
| Suite | Result |
|---|---|
| `e2e-phase5b.js` | 32/32 |
| `e2e-phase5c.js` | 29/29 |
| `e2e-phase6f.js` | 17/17 |
| `e2e-phase7.js` | 17/17 |
| `e2e-phase8.js` | 16/16 |
| `e2e-phase9.js` | 20/20 |
| Course-content validator | PASS, 0 errors |
| Migration idempotency | Confirmed both ways — a full `down -v`/`up` cycle applies all 37 migrations cleanly from empty, and a same-database container restart re-applies with zero errors |
| Frontend production build | Clean (only the pre-existing chunk-size advisory) |
| `tofu validate` (Terraform/OpenTofu) | Clean |
| `ansible-playbook --syntax-check` | Clean |
**131 automated checks, 0 failures.** No code changed this phase (Phase 10 is audit/launch-prep,
not feature work), so this is confirmation, not new coverage.
`platform/test/stripe-integration-test.js` (the Phase 9.1 opt-in real-Stripe check) was **not**
rerun this phase — it requires real Stripe TEST credentials, which currently have no durable home
(Gate 3's Infisical project doesn't exist yet) and were correctly unset from the local `.env`
after Phase 9.1 ended. Phase 9.1's own evidence (`docs/PHASE9_1_STRIPE_TEST_VALIDATION.md`)
stands as the real-Stripe proof; nothing in this phase's own code changes touches that path.
## Gate 20 — Pre-launch dress rehearsal
`infra/scripts/drills/test-phase9-winter-camp-commerce.js` rerun fresh this phase against
current `main`: **13/13** — website registration → guardian → agreement → checkout → webhook →
enrollment → activation → classroom → a real disposable Lab task → instructor visibility, plus
failed-payment/duplicate-webhook/abandoned-checkout/last-seat-race, all using the deterministic
test provider (no real money, as instructed). This is the closest possible complete rehearsal
without real production infrastructure or real dates/venue/capacity — it cannot be run against
the ACTUAL Winter Break offering because that offering cannot legitimately open registration yet
(Gate 11). No fake evidence was created for the steps genuinely blocked by unresolved business
decisions.
## Final launch matrix
Phase 10.1 adds a **Category** column distinguishing four kinds of non-complete gate, since
they need entirely different people to resolve them: **TECHNICAL READY** (code/infra is done,
nothing left to build), **OWNER DECISION REQUIRED** (Chase has to decide something, no code
involved), **EXTERNAL BLOCKER** (a third party — Stripe, an attorney, a landlord — has to act or
verify something), **HARDWARE BLOCKER** (specifically the missing production server, since it
gates so many other rows).
| Gate | Status | Category | Evidence | Owner action | Blocks registration? |
|---|---|---|---|---|---|
| 1. Canonical repo reconciled | ✅ COMPLETE | — | `docs/PHASE10_CANONICAL_STATE_AUDIT.md` | None | No |
| 2. Second-PC state reconciled | ⚠️ Pending verification | OWNER DECISION REQUIRED | Gitea shows no stranded work; PC #2 itself unverified from here; the real Phase 9.5 pricing doc is confirmed to live there, unpushed | Run §1.3's commands on PC #2, then push the real Phase 9.5 document | No (informational) |
| 3. Secrets centralized (Infisical) | 🔧 One access grant away | OWNER DECISION REQUIRED | `docs/SECRETS_AND_INFISICAL.md` — project exists per Chase, this identity lacks access to it | Grant `claude-code-migration` access to the existing RootNode Academy project | No (dev secrets already safe locally) |
| 4. Production server | ⛔ BLOCKED | HARDWARE BLOCKER | `docs/PHASE10_PRODUCTION_DEPLOYMENT.md` | Purchase/provision hardware | **Yes** |
| 5. Proxmox / VMs | ⛔ BLOCKED | HARDWARE BLOCKER | Same — no host to provision on | Same | **Yes** |
| 6. Firewall/isolation | ✅ COMPLETE (code) | TECHNICAL READY | `docs/PHASE10_SECURITY_AUDIT.md` | None until real host exists | No (code ready) |
| 7. Production database | ⛔ BLOCKED | HARDWARE BLOCKER | Same hardware blocker | Same | **Yes** |
| 8. DNS | ✅ PARTIAL | HARDWARE BLOCKER (API portion) | `rootnodeacademy.com` live; no Platform API subdomain yet | Decide/create the API subdomain once a server exists | **Yes**, for the API portion |
| 9. HTTPS | ✅ PARTIAL | HARDWARE BLOCKER (API portion) | Website real HTTPS; Platform API HTTPS pending a real host | Same as above | **Yes**, for the API portion |
| 10. Monitoring | ✅ COMPLETE (code) | TECHNICAL READY | `/healthz`/`/metrics` live in every test run | None until deployed | No |
| 11. Backups | ✅ COMPLETE (code), ⛔ unexecuted | HARDWARE BLOCKER (execution) | Ansible `backup` role exists, never run | Run once a real host exists | **Yes** (restore-test) |
| 12. Restore test | ⛔ BLOCKED | HARDWARE BLOCKER | Never executed — no target | Same | **Yes** |
| 13. Stripe TEST validation | ✅ COMPLETE | — | `docs/PHASE9_1_STRIPE_TEST_VALIDATION.md` | None | No |
| 14. Stripe LIVE configuration | ⛔ BLOCKED | EXTERNAL BLOCKER | Not configured, not attempted | Chase completes Stripe business/identity verification, provides LIVE credentials | **Yes** |
| 15. Live webhook configuration | ⛔ BLOCKED | EXTERNAL + HARDWARE BLOCKER | Depends on #14 and a real HTTPS endpoint (#9) | Same | **Yes** |
| 16. Google Workspace | ✅ COMPLETE (validated in Phase 5C) | TECHNICAL READY | `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` | Deploy alongside the real server | No (already proven) |
| 17. Website integration | ✅ COMPLETE, both sides | — | Contract tested end to end; website-side registration/checkout flow now built and verified (see Gate 10 above) | None | No |
| 18. Winter Break price | ✅ COMPLETE | — | $399/$449, real, Stripe-validated, confirmed current with Chase (the live website's own pricing is stale — see `docs/PHASE10_CANONICAL_STATE_AUDIT.md`) | None (website update is separate, not-RootNode-owned repo work) | No |
| 19. Winter Break dates | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED | Not decided | Chase decides | **Yes** |
| 20. Venue | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED | Not decided | Chase decides | **Yes** |
| 21. Capacity | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED | Not decided | Chase decides | **Yes** |
| 22. Agreements (legal text) | ⛔ BLOCKED | EXTERNAL BLOCKER | Placeholder only | Attorney-approved text required | **Yes** |
| 23. Refund/cancellation policy | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED | Mechanism proven, policy undecided | Chase decides | **Yes** |
| 24. Tax decision | ⚠️ DECISION REQUIRED | OWNER DECISION REQUIRED / EXTERNAL | Not configured | Chase/professional decides | **Yes** |
| 25. Production offering | ⛔ BLOCKED | OWNER DECISION REQUIRED (compound) | Cannot legitimately open — depends on #19-24 | Depends on all of the above | **Yes** |
| 26. Instructor assignment | 🔧 MANUAL OWNER ACTION | OWNER DECISION REQUIRED | Mechanism ready (`instructor_offerings`) | Chase assigns once the real offering exists | **Yes** |
| 27. Roster process | ✅ COMPLETE (mechanism) | TECHNICAL READY | Rehearsed in Gate 20 | None | No |
| 28. Student activation | ✅ COMPLETE | TECHNICAL READY | Proven real, twice (Phase 9.1 + Gate 20) | None | No |
| 29. Lab capacity | ✅ COMPLETE (code) | HARDWARE BLOCKER (real sizing) | Capacity/race logic proven; real server capacity planning needs #4 | Plan once real hardware specs are known | No (mechanism), **yes** (real capacity) |
| 30. Complete dress rehearsal | ✅ COMPLETE (deterministic) | TECHNICAL READY | Gate 20, 13/13 | Repeat against the real offering once it can legitimately open | No |
## Bottom line
## PHASE 10 PARTIAL / WINTER BREAK LAUNCH BLOCKED
Every gate that is genuinely software/audit work is complete and verified. Every gate that is a
real business, legal, tax, or hardware decision remains exactly that — a decision, not
something this phase invented on Chase's behalf. See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for
the single consolidated, categorized checklist Chase can work down.
+90
View File
@@ -0,0 +1,90 @@
# Phase 10 — Production Deployment
Gates 4-9, 15-16. Covers production infrastructure, network security, DNS/HTTPS, database
productionization, Stripe LIVE configuration, Google Workspace production email, observability,
and backups/restore.
## Hardware status — PRODUCTION DEPLOYMENT BLOCKED — SERVER REQUIRED
Verified, not assumed: `infra/terraform/` has no real `terraform.tfvars` (only
`terraform.tfvars.example`) and no `.tfstate` file anywhere in the repo or working tree —
`tofu apply` has never been run against real infrastructure. `infra/ansible/group_vars/
production.yml` still holds literal, unfilled placeholders: `platform_ip:
__PLATFORM_VM_IP__`, `lab_host_ip: __LAB_HOST_VM_IP__`, `proxmox_mgmt_cidr:
__PROXMOX_HOST_MGMT_CIDR__`. No SSH config, known-hosts entry, or inventory file on this
workstation references a real `rn-platform-01`/`rn-lab-host-01`/Proxmox host.
**The dedicated RootNode Academy production server has not been purchased or provisioned.**
Per Phase 10's own explicit instruction, this phase does not fake a production deployment
against placeholder infrastructure. Everything below is preparation, validation, and rehearsal —
genuinely useful the moment real hardware exists, but none of it is a live deployment.
## What WAS done this phase (all safe, all pre-hardware)
- **Gate 4 — IaC validated**: `tofu validate` (OpenTofu, the Terraform-compatible tool actually
installed) passes cleanly against `infra/terraform/`'s existing Proxmox provider config — no
redesign, the existing config is syntactically sound and ready for real `terraform.tfvars`.
`ansible-playbook site.yml --syntax-check -i inventory` passes cleanly against `infra/
ansible/`'s existing `site.yml` and its `firewall`/`docker-tls`/`platform`/`lab-host`/
`monitoring-agent`/`backup`/`common` roles (all present from Phase 4/4.1, unmodified).
- **Gate 5 — network security architecture reviewed, unchanged**: RFC1918 egress
denial/allowlisting present in `platform/provisioner/src/egress.js`/`lifecycle.js` (Provisioner
owns lab-container network policy); mTLS wiring present in `infra/ansible/roles/docker-tls/`
and referenced by `gateway.env.j2`; the `firewall` Ansible role exists for both host tiers. All
of this is existing Phase 4/4.1 work, re-confirmed present and un-redesigned — none of it can
be verified AGAINST a real host without one existing.
- **Gate 6 — DNS/HTTPS audited (read-only)**: `rootnodeacademy.com` **is already live** —
resolves via Cloudflare (`172.67.141.39`, `104.21.38.247`), serves a real HTTPS site (`HTTP/2
200`, Next.js application headers), and its mail is already routed to Google
(`smtp.google.com`), consistent with the existing `[email protected]` Google
Workspace setup. This is the separate, not-RootNode-owned marketing website (`docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md`'s explicit boundary) — nothing here was touched, and no
DNS record was added or changed. **No subdomain or DNS record exists yet for the Platform API
itself** (e.g. `api.rootnodeacademy.com` or similar) — that's the piece blocked on real
server hardware.
- **Gate 7 — database productionization reviewed**: migrations (37, through Phase 9) verified
idempotent again this phase (a fresh `down -v`/`up` cycle and a same-database container
restart both apply/re-apply cleanly with zero errors — see `docs/
PHASE10_LAUNCH_READINESS.md`'s regression section). The Ansible `platform` role's
`postgres.env.j2`/`.template` already model least-privilege connection strings
(`rootnode_platform` app user, not a superuser). No student records of any kind exist to
accidentally copy into a production database — this dev/test Postgres only ever holds
disposable test fixtures, all cleaned up after each rehearsal.
- **Gate 8 — Stripe LIVE**: explicitly **not configured, not attempted**. See "Stripe LIVE
status" below.
- **Gate 9 — Google Workspace**: `GOOGLE_SERVICE_ACCOUNT_JSON`/`GOOGLE_DELEGATED_USER` already
exist as real, previously-validated local values (Phase 5C's own `docs/
RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` documents that validation) — `[email protected]`,
`gmail.send` scope only, domain-wide delegation. Not re-sent this phase (no new test email was
needed to confirm what Phase 5C already proved, and Phase 10 explicitly says not to make GWS a
blocker for Stripe/launch work). Production DEPLOYMENT of this config is blocked on the same
missing server as everything else.
- **Gates 15/16 — observability and backups reviewed, unchanged**: `/healthz` (Platform API,
Gateway, Provisioner) and `/metrics` (Prometheus text exposition, low-cardinality labels only —
reconfirmed by this phase's own audit in `docs/PHASE10_SECURITY_AUDIT.md`) already exist and
were exercised repeatedly by every regression suite rerun this phase. The `monitoring-agent`
and `backup` Ansible roles exist (`infra/ansible/roles/monitoring-agent/`, `.../backup/`,
including a templated `run-backup.sh.j2`) — built in Phase 4, never yet run against a real
host. **A restore has never been tested against real infrastructure, because none exists** —
this phase cannot manufacture that proof; it is not sufficient for launch per Phase 10's own
"a backup that has never been restore-tested is not sufficient" rule, and is listed as a real
blocker in the launch checklist, not glossed over.
## Stripe LIVE status
**Not configured.** No live Stripe credential was provided to this session, and this phase did
not request one — activating Stripe live mode requires Chase's own Stripe account
business/identity verification (a real KYC-style process only Chase can complete), which is an
explicit Phase 10 STOP condition ("identity verification requires Chase"). Phase 9.1's real
TEST-mode validation (`docs/PHASE9_1_STRIPE_TEST_VALIDATION.md`) remains the full proof of the
application-layer correctness; going live only needs Chase's real credentials plugged into the
same, already-proven code path — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going live"
section, unchanged by this phase.
## Bottom line
**PRODUCTION DEPLOYMENT BLOCKED — SERVER REQUIRED.** Every gate that depends on real hardware
(4, 5's live verification, 6's platform subdomain, 7's live database, 8, 9's live deployment, 15,
16) is prepared, validated, and ready to execute the moment `rn-platform-01`/`rn-lab-host-01`
exist on real Proxmox hardware and `terraform.tfvars`/`group_vars/production.yml` are filled
with real values — none of it requires new code or a redesign.
+44
View File
@@ -0,0 +1,44 @@
# Phase 10 — Security Audit
Gate 18. A production-oriented review of RootNode Academy's security posture, drawing on every
regression suite rerun this phase (`docs/PHASE10_LAUNCH_READINESS.md`) plus direct code review.
No genuine launch-critical defect was found this phase — the one real defect Phase 10's
predecessor phases exposed (Phase 9.1's webhook-correlation bug) was already fixed and
regression-tested before this phase began.
| Area | Status | Evidence |
|---|---|---|
| Student authentication | ✅ Verified | `e2e-phase5b.js`/`e2e-phase5c.js` rerun clean this phase — activation, single-use tokens, expiration, password reset with anti-enumeration, session revocation on reset. |
| Instructor authentication | ✅ Verified | Same suites — instructor login, RBAC roles (owner/admin/instructor). |
| Sessions | ✅ Verified | Persistent, server-controlled sessions (Phase 3A); a real Platform API restart preserves sessions/enrollment state (rerun clean this phase). |
| Password reset / activation | ✅ Verified | Single-use, server-side expiration, no plaintext token ever returned by any admin API (`toAdminView` never includes it). |
| RBAC | ✅ Verified | Admin/owner-only routes (guardian detail, payment data) vs. any-instructor routes (offering/location lists) — reconfirmed across Phase 7/8/9 suites this phase. |
| Cohort scoping | ✅ Verified | An instructor sharing a cohort can manage; an unrelated instructor cannot (Phase 5B/7 suites). |
| CSRF | ✅ Present, unchanged | `platform/api/src/security.js` + per-route session/cookie checks (student/instructor auth routes) — not modified this phase, no regression. |
| Rate limiting / lockout | ✅ Present, unchanged | Login-attempt tracking in `security.js` (matches the existing sweeper pattern also used for session expiry). |
| Audit logging | ✅ Verified | Every admin/integration action recorded; audit-hygiene test (no secrets/PII in audit metadata) rerun clean in `e2e-phase9.js`. |
| Server-authoritative pricing | ✅ Verified, real Stripe | Phase 9.1 proved a tampered caller amount is ignored against **Stripe's own recorded object**, not just RootNode's database. |
| Agreement enforcement | ✅ Verified | Checkout blocked (`409`) until acceptance, in both the deterministic suite and real Stripe TEST mode. |
| Capacity enforcement | ✅ Verified | Race-safe seat reservation (`reserved_seats < capacity` in one UPDATE) — last-seat race test rerun clean this phase. |
| No card-data storage | ✅ Verified | `payment_transactions` schema has no card fields; explicit admin-view test asserts none appear; Stripe's hosted Checkout is the only thing that ever sees a real card. |
| Webhook signature verification | ✅ Verified, real Stripe | Invalid/forged signatures rejected (both deterministic and real-Stripe-signed tests); idempotent via a real DB primary key on the provider's event id, reconfirmed with a genuinely re-signed real Stripe event in Phase 9.1. |
| Logging/audit redaction | ✅ Verified | This session's own Stripe secret/webhook-secret values were grepped across every log/scratch file produced — none found; `e2e-phase9.js`'s hygiene test asserts no `sk_`/`whsec_`/card-number pattern in audit metadata or metrics text. |
| Secrets never committed | ✅ Verified | Full-history pickaxe search this phase — clean (`docs/PHASE10_CANONICAL_STATE_AUDIT.md` §1.6). |
| mTLS (Provisioner ↔ Docker, Gateway ↔ Lab Host Router) | ✅ Present, unchanged, not re-verified live | Ansible `docker-tls` role and TLS env vars exist; cannot be exercised against a real mTLS handshake without real hardware (Gate 4/5's hardware blocker). |
| Privileged lab boundary / RFC1918 egress denial | ✅ Present, unchanged | `provisioner/src/egress.js`; this phase reconfirmed the module exists and is wired, did not re-run Phase 1/2's own dedicated isolation drills (out of this phase's changed-code scope — nothing touched networking code). |
| Production cookie settings (`COOKIE_SECURE`) | ✅ Present, verified pattern | `e2e-phase5c.js`'s own test explicitly proves `COOKIE_SECURE=1` production gating via a real container recreate — reconfirmed present, not re-executed as a fresh drill this phase since nothing in that code path changed. |
| HTTPS end-to-end | ⏸ Cannot verify — no production host | `COOKIE_SECURE=1`/`TRUST_PROXY=1` require a real HTTPS-terminating reverse proxy in front of a real Gateway, which doesn't exist yet (Gate 4-6's hardware blocker). The marketing site at `rootnodeacademy.com` IS real HTTPS (Cloudflare) — the Platform API itself has no production endpoint yet. |
## Findings requiring action
**None launch-critical within existing architecture.** Everything marked ⏸ above is blocked on
the same missing production server documented in `docs/PHASE10_PRODUCTION_DEPLOYMENT.md` — not
a code defect, and building around it (e.g. faking an HTTPS check) would violate this phase's
own "do not fake production readiness" instruction.
## Scope discipline
This audit reviewed and reconfirmed existing security properties; it did not expand into new
security tooling, new scanning infrastructure, or unrelated hardening work not already part of
Phase 1-9's architecture, per Phase 10's own "do not expand into unrelated feature work"
instruction.
+151
View File
@@ -0,0 +1,151 @@
# Phase 7 — Business & Product Architecture Foundation
Phase 7's mission: formalize RootNode Academy as a business/product platform capable of a future
catalog across INIT_/COMPILE_/ROOT_/BOOTCAMP_, without rebuilding any proven system and without
authoring new curriculum.
## Baseline
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 6F's
`d0664104515e4bd389c8e7bc88293327e7eda100`, tag `v0.6.0-root-v1` present. Reviewed existing
`programs`/`enrollments`/`cohorts`/`classroom_sessions`/`students` schema before designing
anything new — found `programs.family`/`delivery_mode` and `cohorts.seat_target` already
existed, so Phase 7 widened rather than duplicated them where it could.
## What was built
- **Organizations, locations** (`033_phase7_organizations_locations_guardians.sql`) — a business/
delivery relationship and a physical/virtual venue, deliberately not SaaS multi-tenancy.
- **Guardians, student_guardians** (same migration) — the real foundation replacing the Phase
5C `contact_email` workaround, left functioning unchanged for the live website integration.
- **Offerings, curriculum packages, registrations, agreements**
(`034_phase7_offerings_registrations_agreements.sql`) — the full purchasable-delivery model,
pre-enrollment registration lifecycle, versioned agreement acceptance records, and
reference-only curriculum subsets.
- **`platform/api/src/offerings.js`** — the business logic: server-defined status transitions
(offerings and registrations both), race-safe atomic seat reservation, and
`convertRegistrationToEnrollment()` reusing `enrollment.js`'s existing student-matching and
enrollment-creation logic rather than duplicating it.
- **`platform/api/src/admin-offerings-routes.js`** — the full admin/owner API surface for all of
the above, registered in `server.js` alongside the existing route modules.
- **`programs.family` widened** to accept `bootcamp` (a pure CHECK-constraint widening; no
existing row changes).
## What was NOT built this phase
**Admin UI** (Steps 16/17 — Offerings/Registrations screens in `/admin`). This was a deliberate
scope call: given the phase's own emphasis ("BUSINESS DOMAIN MODEL, PRODUCT ARCHITECTURE,
ENROLLMENT/OPERATIONS ARCHITECTURE"), the schema, business logic, RBAC, tests, and a full
working rehearsal were prioritized as the load-bearing deliverable over a polished React
surface. Every capability Steps 16-17 describe is fully implemented and tested at the API layer
— an admin UI is now a bounded, well-specified follow-up (the routes and their exact shapes
already exist), not a design question.
Also not built, per the explicit DO-NOT-BUILD list: payment processing, subscription billing,
public checkout, INIT_/COMPILE_ curriculum, ROOT 08/11 expansion, a Parent Portal UI, school
SIS integrations, certificates.
## A real bug found and fixed during this phase's own testing
The registration-creation route originally reserved an offering's seat *before* validating the
registration's own fields. Under a full offering, this let a plain validation failure (e.g. a
minor registration missing `guardianId`) get masked behind a false `409 OFFERING_FULL`, and even
when capacity wasn't the issue, a request that would fail validation anyway had already consumed
a real seat. Found via this phase's own smoke testing before any commit; fixed by validating
(creating) the registration first, then reserving the seat, rolling back the registration row if
reservation then genuinely fails. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
## Testing
`platform/test/e2e-phase7.js` — 17/17 passing (fresh stack): course-family/age-guidance
independence from authorization, offerings across two distinct locations, curriculum-package
content-reference integrity (including rejecting a fabricated course id), atomic capacity
enforcement with correct validate-before-reserve ordering, instructor assignment, guardian
creation/linking, minor-vs-adult registration paths, registration lifecycle transitions
(including a rejected invalid jump), agreement-version-snapshotted acceptance, registration→
enrollment conversion with correct cohort/no-cohort behavior, curriculum-package version-bump
stability, registration/guardian RBAC (admin/owner only) vs. offering/location visibility (any
instructor, with `access_notes` stripped), explicit age-independence, and confirmation that an
existing ROOT_ V1 self-paced enrollment and an existing classroom/cohort flow are both
completely unaffected.
## Winter Break Camp rehearsal
`infra/scripts/drills/test-phase7-winter-camp.js` — 18/18 passing against a real, freshly-built
stack, with **no payment code invoked anywhere**: admin creates a real organization/location, a
curriculum package referencing the existing ROOT 01-02 courses (matching `docs/
WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A exactly), a real cohort, and a DRAFT offering
with dates/price/exact seat count left unset as authorized; a guardian registers a minor and an
adult registers themselves; both move through submission, agreement acceptance, and a simulated
admin-side commerce confirmation; both convert to real enrollments and real cohort membership
with real one-time activation tokens; both activate real accounts; the instructor starts a real
classroom session and Prepares Labs; the minor student joins, completes real ROOT 01 orientation
and lesson activities, and passes a REAL Lab task (`linux-inspect-hardware`) inside a real
disposable Lab container — with the pass then confirmed to survive a real logout/login; the
instructor sees real roster state; the offering moves through `in_progress` to `completed`.
## ROOT_ V1 regression
`e2e-phase6f.js` rerun fresh: 17/17 unaffected. `e2e-phase5b.js` (the enrollment-related suite
most exposed by this phase's one shared-code change — `enrollment.js`'s `resolveOrCreateStudent`
gained an optional, default-off parameter) rerun fresh: 32/32, including its own embedded
"Phase 5A student authentication still works unmodified" and "Phase 5A remote classroom still
works unmodified" checks. `e2e-phase5c.js` was not independently re-run: no code in
`notifications.js`/`students-auth.js` itself changed, only an additive, default-off parameter on
a function `notifications.js` doesn't call.
## Frontend, migrations, content validation
Frontend: `npm run build` clean, no frontend code touched this phase. Content validation:
0 errors, unchanged from Phase 6F's own numbers (this phase touched no course/module/activity
content at all). Migrations: additive-only, `CREATE TABLE IF NOT EXISTS`/`DROP CONSTRAINT IF
EXISTS` throughout, applied once via the existing `schema_migrations` tracking mechanism — no
destructive statement anywhere in `033`/`034`.
## Documentation
New: `ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md`, `OFFERING_AND_REGISTRATION_
MODEL.md`, `STUDENT_GUARDIAN_DATA_MODEL.md`, `SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md`,
this document. Updated: `WEBSITE_ENROLLMENT_INTEGRATION.md` (a documented future flow, current
contract unchanged). Reviewed, no change needed: `REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
`ENGINEERING_SOP.md`, `LAB_ARCHITECTURE.md`, `platform/README.md` (no new e2e-suite convention
beyond what its existing list format already documents — see commit history for the actual
`platform/README.md` addition covering `e2e-phase7.js`).
## Known limitations
- **No admin UI** for offerings/registrations — the API is complete and tested; the screens are
a well-specified follow-up, not a design question.
- **Guardian communications are not yet wired into `notifications.js`** —
`student_guardians.can_receive_communications`/`is_primary_contact` are recorded but nothing
currently reads them to route an actual email.
- **The website integration endpoint does not yet accept an `offering_slug`** — the future flow
is documented, not implemented; that decision (extend the existing endpoint vs. a parallel
one) was deliberately left open pending real website/checkout design.
- **`agreement_acceptances.accepted_by_type = 'student'`** is only meaningful post-conversion,
once a real `student_id` exists — an adult accepting at registration time (before their
account exists) currently must accept as `'guardian'`-style even when self-registering; a
future phase could add a `'self'` accepted-by-type distinct from `'guardian'` if that
distinction becomes operationally important.
## Items requiring legal/business review before production use with real minors
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s own list in full: the actual legal text of any
participation/media/liability agreement, jurisdiction-specific minor-consent requirements,
data-retention/deletion policy for guardian and registration records, and whether emergency-
contact fields need additional encryption at rest. None of these are resolved by this phase's
work — they are named so they are never silently assumed away.
## Bugs found/fixed
One (the seat-reservation-before-validation ordering bug above), found and fixed before any
commit via this phase's own smoke testing — not discovered by a later test run.
## Recommended Phase 8 scope
The admin UI for offerings/registrations (the one deliberately deferred piece of this phase's
own mission); wiring guardian communications into `notifications.js`; and, informed by real
business decisions, either extending `/api/integrations/enrollments` with an `offering_slug`
or designing the parallel endpoint the future commerce flow needs — plus whatever the business
decides about actually opening Winter Break Camp registration (which requires payment
processing, explicitly out of every phase's scope so far).
+189
View File
@@ -0,0 +1,189 @@
# Phase 8 — Enrollment Operations, Guardian Communications & Website Integration
Phase 8's mission: turn Phase 7's business/product backend into an OPERABLE enrollment system —
Offering → Registration → Guardian/Adult Contact → Agreements → Communications → Review →
Confirmation → Enrollment → Cohort → Account Activation → Classroom/Lab Access — without direct
database manipulation, ahead of RootNode's first commercial launch (Winter Break Camp). Not a
payment phase — everything is built around where a future payment step will plug in.
## Baseline
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 7's
`df5e39f767a6af3ba50daabde5de97fa1b1a5f3b`.
## What was built
- **Migrations 035/036** — registration-stage notifications (nullable `student_id` +
`registration_id` on `notification_events`), a `'registrant'` agreement-acceptance type,
`offering_agreement_requirements`, a real unique index on `external_reference` (website
registration idempotency), and the notification_type CHECK widening (see "bugs found" below).
- **`offerings.js` extended**: `assertRegistrationOpen()` (server-authoritative registration
windows), `addAgreementRequirement`/`getRegistrationAgreementStatus` (required vs. accepted vs.
missing), a conversion-time block on missing required agreements, `computeOfferingReadiness()`
(server-derived launch checklist), and `createRegistrationFromIntegrationEvent()` (the
website's idempotent submission path, mirroring `enrollment.js`'s established pattern exactly).
- **`notifications.js` extended**: a guardian-email fallback in `resolveRecipient()`, and a
parallel `enqueueForRegistration()`/`resolveRegistrationRecipient()` pair for the pre-account
registration stage — same table, same worker, same masked admin view, no second system.
- **Five new templates** (`registrationReceived`, `registrationConfirmed`,
`registrationNeedsAction`, `agreementRequired`, `campOperationalUpdate`) — HTML + text,
RootNode-branded, no tracking, no marketing content.
- **`public-offerings-routes.js`** (new) — the unauthenticated, explicitly-allowlisted offering
read API the website will consume.
- **`POST /api/integrations/registrations`** (new, in the existing `integration-routes.js`) —
the website's registration submission contract, same shared-secret boundary as
`/api/integrations/enrollments`, fully idempotent.
- **Admin UI**: a real `/admin` → Offerings section (`OfferingsView.jsx`) — list, create, detail
(overview with valid-transitions-only buttons, launch readiness, required agreements,
registrations/roster) and registration detail (lifecycle, agreement status, communication
history, convert-to-enrollment). Reuses the existing `Admin.module.css`/`adminApi` conventions
exactly — no new UI framework or pattern introduced.
## Two real bugs found and fixed during this phase's own testing (before any commit)
1. **`notification_type` CHECK constraint mismatch** — the five new registration-stage
notification types were wired into `notifications.js`/`notification-worker.js` before the
database's own CHECK constraint was widened to accept them. Every `enqueueForRegistration()`
call was silently failing (caught by the deliberate "a notification failure never blocks the
real action" try/catch) until a comprehensive smoke test surfaced it directly. Fixed in
`036_phase8_notification_types.sql`. Also hardened: every previously-silent
`.catch(() => {})` around a notification call now at least logs the error, so a future
regression like this surfaces immediately instead of silently vanishing.
2. **A registration-insert/seat-reservation/notification ordering bug** — adding a
`registration_received` notification right after the registration row's insert (but before
the seat reservation that could still fail and roll the row back) meant a failed reservation's
cleanup DELETE could hit a foreign-key violation against the notification row it had just
created. Found via `e2e-phase7.js`'s own regression rerun. Fixed by reordering
`createRegistration()` to validate → reserve the seat → insert → notify, strictly in that
order, and simplifying the admin route accordingly (see `docs/OFFERING_AND_REGISTRATION_
MODEL.md` for the full before/after).
## Cascading regression fixes (stale test assumptions, not new bugs)
Enforcing real registration-window checks (Step 21) broke `e2e-phase7.js`'s own `offeringB`
(created in Phase 7, never transitioned out of `draft`, registered against successfully only
because no such enforcement existed yet) and a new age-guidance offering created the same way.
Fixed by publishing/opening both before registering, matching the new, correct requirement. A
third `e2e-phase7.js` test's own file-allowlist check needed widening to permit the new, entirely
legitimate `public-offerings-routes.js` reference to age-guidance fields (display-only, never
authorization).
## Testing
`platform/test/e2e-phase8.js` — 16/16 passing (fresh stack): offering CRUD/transitions/
readiness, registration open/closed enforcement, capacity + concurrent final-seat race through
the real registration endpoints, the public serializer's safety (no internal/PII fields), website
integration idempotency (sequential replay + concurrent duplicate), adult/minor registration
paths, student-identity preservation on conversion, required-agreement conversion blocking with
version snapshotting even after the live definition's version later changes, offering→cohort
membership, a real activation token + queued notification on conversion, guardian recipient
resolution, RBAC (offering/location visible to any instructor, guardian/registration detail
admin-only), organization/location `access_notes` stripping, and confirmation ROOT_ V1 self-paced
and classroom flows are unaffected.
## Winter Break commercial-path rehearsal
`infra/scripts/drills/test-phase8-winter-camp-registration.js` — 12/12 passing against a real,
freshly-built stack, with no payment code anywhere: admin configures a real offering (location,
capacity, instructor, required agreement), the website integration submits a REAL minor
registration (a real guardian created/matched, a confirmation notification queued), guardian
acceptance is recorded through the supported application flow, a future payment confirmation is
SIMULATED via a plain admin transition, the registration converts to a real enrollment and real
cohort membership, the account activates with a real one-time token, an adult registers and
converts independently, two students genuinely compete for the final seat (both correctly
rejected once capacity was already exhausted — no overbooking), the instructor runs a real
classroom session and Prepares Labs, and the minor student completes real ROOT 01 orientation
plus a REAL Lab task (`linux-inspect-hardware`) inside a real disposable Lab container, with the
instructor then seeing the real progress.
## Regression
Fresh-stack reruns after every fix: `e2e-phase7.js` 17/17, `e2e-phase6f.js` 17/17, `e2e-phase5b.js`
32/32 (enrollment.js's `resolveOrCreateStudent` gained an optional, default-off `returnToken`
param in Phase 7, re-verified unaffected here), `e2e-phase5c.js` 29/29 (notifications.js was
directly extended this phase — every existing Phase 5C notification type/flow reconfirmed
byte-for-byte unaffected). `e2e-phase5a.js` not independently rerun: its own checks are embedded
in and reconfirmed by both 5B's and 5C's "Phase 5A ... still works unmodified" assertions, and no
code Phase 5A itself owns was touched.
## Frontend build
`npm run build` clean — the new `OfferingsView.jsx` compiles without warning; the only new
warning is the pre-existing "chunk larger than 500kB" advisory, unchanged in cause from every
prior phase.
## Real Lab result
Both rehearsals (Phase 7's from the prior phase, re-verified here, and this phase's own) drive a
real disposable Lab container through a genuine `linux-inspect-hardware` scenario setup/validate
cycle — confirmed passing for real, not simulated, in the Winter Break commercial-path rehearsal.
## Security/privacy findings
The public offerings API was built with an explicit field allowlist from the start (never
`SELECT *`) — `e2e-phase8.js` asserts directly that `access_notes`, `organization_id`,
`location_id`, `created_by`, `reserved_seats`, `capacity`, and `metadata` never appear in a
public response, and that a fully-booked offering reports `"full"` rather than a raw seat count
(avoiding a scraping/timing vector against a public, unauthenticated endpoint). No new security
weakening was introduced; the two bugs found above were both silent-failure/ordering bugs, not
authorization gaps.
## Legal-review requirements — unchanged, restated
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s list in full, still entirely unresolved by this
phase's work: the actual legal text of any participation/media/liability agreement,
jurisdiction-specific minor-consent requirements, data-retention/deletion policy, and whether
emergency-contact fields need additional encryption at rest. `agreement_definitions.body_summary`
remains a plain-language placeholder, never legal text.
## Documentation
New: `PHASE8_ENROLLMENT_OPERATIONS.md` (this document), `WINTER_BREAK_CAMP_OPERATIONS.md`,
`REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`, `OFFERING_ADMIN_GUIDE.md`. Updated:
`ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md` (BUILT NOW/PHASE 8 BUILT/FUTURE table),
`OFFERING_AND_REGISTRATION_MODEL.md` (the two ordering bugs, launch readiness, registration
enforcement), `STUDENT_GUARDIAN_DATA_MODEL.md` (guardian communications now wired),
`WEBSITE_ENROLLMENT_INTEGRATION.md` (the real, live registration contract with worked examples),
`platform/README.md` (test list). Reviewed, no change needed: `docs/ENGINEERING_SOP.md`,
`docs/LAB_ARCHITECTURE.md`, `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md`,
`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (the existing provider adapter needed no changes — the
new templates render through the exact same delivery path).
## Known limitations
- The website's own registration UI does not exist yet in this repository (rootnodeacademy.com
is built separately) — the contract is ready and tested, nobody has built the website-side
form against it yet.
- Agreement acceptance is not exposed on the website registration endpoint itself — currently a
separate admin-recorded (or future self-service) step after submission.
- `registration_needs_action` has no automatic trigger — an admin action, deliberately, since
"needs action" covers many distinct real reasons an admin should state explicitly rather than
the platform guessing one.
- No waitlist was built — a `waitlisted` registration status was considered and deliberately
deferred; the existing `cancelled`-releases-a-seat mechanism plus manual re-registration covers
the real, near-term operational need without adding a new status machine branch this phase
didn't have concrete evidence would be used.
- No payment processing — every "payment confirmed" step in both rehearsals is a plain admin
transition, exactly as authorized.
## Remaining blockers before opening Winter Break registration to the public
1. Real dates, venue, capacity, and price are still undecided (not invented by this phase).
2. The agreement's actual legal text requires professional review.
3. No payment processing exists yet.
4. The website's own registration form doesn't exist yet.
None of these are platform readiness gaps — the platform is ready to operate the flow the moment
those four external decisions/assets exist.
## Commits
See `git log` for this phase's exact commit sequence (migrations/backend, admin UI, tests +
rehearsal, docs) — grouped per `docs/ENGINEERING_SOP.md`'s existing convention.
## Recommended Phase 9
Real payment integration (Stripe or equivalent) wired into the registration-confirmation step;
the actual website-side registration form; agreement-acceptance exposed on the public
registration contract once the legal text is finalized; and, informed by real Winter Break Camp
operational experience, whether a waitlist is actually worth building.
+363
View File
@@ -0,0 +1,363 @@
# Phase 9.1 — Real Stripe TEST-Mode Integration Validation
Status: **VALIDATED.** A real Stripe TEST Checkout Session, a real Stripe TEST payment ($399.00
— the founder-approved Winter Break Founding Cohort tuition), a genuine Stripe-signed webhook,
and the existing registration→enrollment conversion path were all proven end to end against
Stripe's real TEST-mode API — no live money, no live-mode credential, nothing simulated. This
document also records the first, credential-less attempt at this phase (which correctly stopped
rather than fake anything) and a real defect the live traffic exposed and fixed.
## 1. Baseline verification
- First attempt (credential-less): branch `main`, clean tree, HEAD = `origin/main` =
`80a5f6e4576cb9b83a60c9e9b57e4ebc52b62f07` — the Phase 9 milestone.
- Resume attempt (this run): `git fetch origin` confirmed HEAD = `origin/main` =
`77fd1869dbe694a7ff323a6fac07382e1f55b225` — the prior, credential-less Phase 9.1 commit.
- No `docs/ROOTNODE_PRICING_AND_OFFERS_V1.md` or any "Phase 9.5" commit exists anywhere in this
repository's history, branches, or tags (verified via `git log --all`) — the pricing decision
below came directly from Chase in this session, not from a document that turned out not to
have reached this canonical repo yet (it was apparently done on a different machine and not
yet pushed). Used directly; no invented business decision was needed to proceed.
- Phase 9 payment code and documentation re-inspected: `payment-provider.js`, `commerce.js`,
`webhook-routes.js`, `offerings.js`'s conversion path, `admin-offerings-routes.js`'s payment
routes, `integration-routes.js`'s checkout/agreement/status routes, `platform/test/
e2e-phase9.js`, `infra/scripts/drills/test-phase9-winter-camp-commerce.js`.
## 2. Stripe TEST credential status
**First attempt**: none found anywhere reachable (process env, every `.env` file, this repo's
Infisical wiring, session memory) — correctly stopped and documented rather than faked (see git
history for that version of this document).
**This run**: Chase provided a real `sk_test_...` secret key directly. Format-verified as TEST
mode (never printed in full anywhere in this document, any commit, or any persisted log — only
an 8-character prefix `sk_test_` ever appears). Stored in `platform/.env` (gitignored, never
committed — confirmed via `git status` throughout this phase). A push to Infisical for durable
storage was attempted (per Chase's request) but the existing machine identity credential at
`~/.config/infisical/identity.env` returned a consistent `401 Invalid credentials` from
`app.infisical.com` across three attempts — a pre-existing, unrelated credential-store problem,
not something fixable from this session. The Stripe key remains safely local in the meantime.
The webhook signing secret was obtained via `stripe listen --api-key ... --forward-to
http://localhost:4200/api/webhooks/payment --print-secret` (the Stripe CLI, installed to
`~/.local/bin/stripe` this session for exactly this purpose — no Dashboard endpoint needed for
local TEST-mode validation).
## 3. Stripe adapter architecture — confirmed, unchanged
`StripeProvider.createCheckoutSession()` uses **dynamic Checkout line-item `price_data`**, never
pre-created Stripe Price/Product IDs. RootNode's `offerings` table remains the sole source of
truth for identity, price, and currency. Confirmed still true after this phase's fix (below) —
the fix only changes how a LATER webhook event is correlated back to a transaction, not how the
Checkout Session itself is created.
## 4. Disposable TEST offering used
A clearly-labeled, disposable offering — `[TEST] Stripe Integration Check <run-id>` —
`delivery_mode: cohort`, price **39900 (cents) / usd = $399.00**, the founder-approved Winter
Break "Founding Cohort" launch tuition Chase gave directly in this session (standard/reference
retail $449, paid in full, no deposit, no payment plan — none of that billing structure was
built, only the flat price was used for this validation). **Not** the real Winter Break offering
— a separate, disposable fixture, transitioned to `cancelled` at the end of the run (confirmed).
## 5. Registration result
A real **guardian + minor** registration (the more complex path, preferred per the mission spec
since the Winter Break audience may include minors) via
`POST /api/integrations/registrations` — guardian `display_name`/email/phone real-shaped test
data, registrant `is_minor: true`, no real child PII. Registration created, seat reserved,
payment status tracked separately from registration status, no enrollment existed before payment
— all as designed.
## 6. Agreement gate result
Checkout attempted before agreement acceptance → `409 AGREEMENTS_INCOMPLETE`. Agreement then
accepted via `POST /api/integrations/registrations/:id/agreements` → `201`, `accepted_version:
1` persisted. Checkout attempted again → succeeded. The real, live enforcement path, not
bypassed.
## 7. Real Stripe TEST Checkout Session result
`POST /api/integrations/registrations/:id/checkout` returned a real
`https://checkout.stripe.com/...` URL. Independently retrieved via **Stripe's own**
`checkout.sessions.retrieve` API (never trusting RootNode's own echo):
```
Stripe session cs_test_a1VBoHHATcDr3DRTnZixQX8E0E5Ulk3H1UAKwTYVEOYVboOcaKCuZEMuff:
amount_total = 39900, currency = usd, mode = payment, livemode = false
```
## 8. Server-authoritative amount verification
The checkout request was sent with a tampered body (`amount: 1, currency: "eur"` — neither field
exists on the real contract, sent anyway to prove they're inert). Stripe's own recorded session
still shows `amount_total: 39900, currency: usd` — the real, configured offering price, not the
caller-supplied value. **The caller cannot choose the price or currency.**
## 9. Real TEST payment result
Chase completed the real Stripe-hosted TEST Checkout in a browser with Stripe's official test
card (4242 4242 4242 4242). Independently confirmed via Stripe's own API (not just the
redirect):
```
GET /v1/checkout/sessions/cs_test_a1VB... → status: complete, payment_status: paid,
payment_intent: pi_3U9QuqPl0OwZKsjY0lprOPWi
```
## 10. Real signed webhook result
`stripe listen` relayed a genuine Stripe-signed `checkout.session.completed` event
(`evt_1U9QutPl0OwZKsjYSkW8Le84`) to `POST /api/webhooks/payment`, which accepted it (`200`,
`{"received":true,"result":"processed"}`). This is Stripe's own real signature over Stripe's own
real event body — not a locally-fabricated one.
## 11. Invalid-signature result
A garbage `Stripe-Signature` header against a fabricated body → `400 invalid_signature`. The
registration's status was re-read before and after and confirmed unchanged — zero domain side
effect from a rejected signature.
## 12. Payment transaction result
Exactly one `payment_transactions` row for this registration, `status: paid`,
`provider_reference` matching the real Checkout Session id. No `card_number`/`cvv`/`card_last4`/
`payment_method_details` field exists anywhere on the record (none is ever stored, by design).
## 13. Registration confirmation result
`offering_registrations.status = enrollment_activated`, server-derived entirely from the real
payment webhook — never a client action.
## 14. Enrollment conversion result
The **existing, unmodified** `convertRegistrationToEnrollment()` path ran — no second
implementation was created. Verified directly against the database: exactly one row in
`enrollments` for this student/program.
## 15. Cohort/capacity result
Exactly one row in `cohort_enrollments` for this student/cohort — the offering's `cohort_id`
correctly produced real classroom membership on conversion, unchanged from Phase 8/9's design.
## 16. Guardian preservation result
Exactly one row in `student_guardians` for this student (the Phase 9 guardian-linking fix from
the original Phase 9 phase held under real Stripe traffic too). `students.email` for the minor
remains the empty string — the guardian's email was never written into the student's own
identity record.
## 17. Activation/notification result
The real activation notification was queued and delivered through the existing dev-preview
mechanism (the same one every prior phase's testing has used — `EMAIL_PROVIDER` was left at its
dev default this run; Google Workspace live delivery was not exercised, since nothing about
Stripe validation requires it and the mission explicitly said not to make GWS delivery a Stripe
blocker). No second, Stripe-specific notification path exists — this reuses the exact
`notifyForEnrollment` flow every other enrollment trigger already uses.
## 18. Student access result
The real one-time activation token worked: `POST /api/student/activate` → `200`, and the newly
activated student's `GET /api/student/programs` includes the enrolled program. One access proof,
as instructed — no full curriculum rehearsal was repeated (Phase 8/9's own rehearsals already
cover that in depth).
## 19. Duplicate-event/idempotency result
The **real** Stripe event was fetched fresh via `stripe.events.retrieve(eventId)` and re-signed
locally with the real webhook secret (`stripe.webhooks.generateTestHeaderString`) — never a
fabricated "payment succeeded" event for an unpaid session, but a genuine replay of the exact
event Stripe itself recorded, mirroring what `stripe events resend` does server-side (not usable
here directly — an ephemeral `stripe listen` session has no persistent dashboard endpoint id to
target). Result: `200`, `{"result":"duplicate"}`. Confirmed via direct query: still exactly one
enrollment, still exactly one activation/confirmation notification — no double conversion, no
double seat consumption, no double email.
## 20. Declined-payment result
**Not exercised via an actual real declined Stripe TEST payment this session** — doing so would
require a second full browser-driven Checkout completion (Stripe Checkout Sessions have no
supported pure-API "decline" trigger; PaymentIntents-direct-confirm would mean not using
Checkout Sessions at all, which this phase was told not to redesign toward). Phase 9's
deterministic suite already proves "failed payment retains the seat" directly, and this phase's
own new regression test (§28) proves the exact real-Stripe reference-correlation shape a genuine
`payment_intent.payment_failed` event would use. Available on request if Chase wants one more
manual decline-card checkout to close this specific gap with fully real traffic.
## 21. Abandoned-checkout result
Fully automated, no browser needed: a second, separate registration's Checkout Session was
created normally, then expired via Stripe's own `sessions.expire()` API (a real, supported,
pure-API operation — no fabricated event). Stripe itself then fired a genuine
`checkout.session.expired` webhook, relayed by `stripe listen` and accepted (`200`). Confirmed:
the registration's status became `cancelled` and its seat was released exactly once, within the
polling window.
## 22. Refund result
`stripe.refunds.create({ payment_intent })` against the real PaymentIntent → `succeeded`
(`re_3U9QuqPl0OwZKsjY0IYvfOsM`, `39900 usd`). RootNode's own refund state was confirmed
(`status: refunded`). The non-cascade rule was reconfirmed directly against the database: the
registration stayed `enrollment_activated` and the enrollment stayed `active` — a refund does
**not** automatically revoke registration or academic access. Whether it *should*, for this
specific offering/policy, remains an explicit business-policy question this phase does not
decide in code (per Phase 9's own design and this phase's own instruction).
## 23. Stripe ↔ RootNode reconciliation
| | |
|---|---|
| RootNode registration | `04772e72-4a09-4afd-8032-d6ef4d132375` |
| RootNode enrollment | `10e2724a-43bb-4d35-8bea-98f600cbd8e9` |
| RootNode student | `student-001` |
| Stripe Checkout Session | `cs_test_a1VBoHHATcDr3DRTnZixQX8E0E5Ulk3H1UAKwTYVEOYVboOcaKCuZEMuff` |
| Stripe PaymentIntent | `pi_3U9QuqPl0OwZKsjY0lprOPWi` |
| Stripe webhook event | `evt_1U9QutPl0OwZKsjYSkW8Le84` |
| Stripe refund | `re_3U9QuqPl0OwZKsjY0IYvfOsM` |
| Amount / currency | `39900` / `usd` ($399.00) |
No secret key, webhook secret, card number, or CVV appears above or anywhere in this document.
## 24. Stripe Dashboard reconciliation
For a real transaction, Chase can locate it in the Stripe Dashboard under **Test mode →
Payments** (search by the PaymentIntent id `pi_...` or the Checkout Session id `cs_test_...`
above) or **Developers → Events** (search by the event id `evt_...`). The RootNode admin UI's
own Payments card (on the registration detail view) shows the matching `provider_reference`
side by side with RootNode's own status/timestamps — reconciliation is possible without either
system exposing credentials or card data to the other.
## 25. Security revalidation (with real Stripe enabled)
| Check | Result |
|---|---|
| Stripe secret key never reaches browser | Server-only env var; not referenced anywhere in `frontend/`. |
| Webhook signing secret never reaches browser | Same. |
| Caller cannot choose price | Proven directly (§8) against Stripe's own recorded amount. |
| Caller cannot choose currency | Proven directly (§8), same test. |
| Caller cannot bypass required agreements | Proven directly (§6). |
| Caller cannot bypass capacity | Unchanged architecture; existing `e2e-phase9.js` last-seat race regression rerun clean (§27). |
| Success redirect cannot forge payment | The script never trusted the redirect — every "paid" claim in this document is independently verified via Stripe's own API or the real webhook, not the URL. |
| Invalid webhook signature rejected | Proven directly (§11). |
| Duplicate webhook safe | Proven directly with a REAL re-signed event (§19). |
| No card number enters RootNode DB | Proven directly (§12) — no such field exists. |
| No card number enters RootNode logs | Confirmed — grepped this session's own logs/scratch files for the real secret values and any card-shaped pattern; none found. |
| No Stripe secret enters audit log | Existing `e2e-phase9.js` hygiene test rerun clean (§27). |
| No Stripe secret enters metrics | Same. |
| Stripe provider IDs not high-cardinality metric labels | Unchanged architecture — metrics still use only fixed-enum labels. |
| One student cannot inspect another's payment | Structural — payment routes are admin/owner-only; students have no route to any payment data at all. |
| Integration shared secret remains server-to-server only | Unchanged — never read from any browser-facing code. |
## 26. Bugs found
**One real defect**, found only because real Stripe traffic was exercised: `payment_intent.
payment_failed` and `charge.refunded` webhook events reference Stripe's PaymentIntent/Charge
object, never the Checkout Session — but `payment_transactions.provider_reference` only ever
stored the Session id. A real Stripe-issued failure or refund event would have silently failed
to match any transaction (`result: 'error', reason: 'UNKNOWN_TRANSACTION'`). Phase 9's
deterministic TestProvider never exposed this because its fabricated events always reuse
whatever reference the calling test explicitly supplies, never distinguishing real Stripe object
id shapes.
## 27. Bugs fixed
Smallest correct fix, matching Stripe's own documented pattern for this exact problem:
- `payment-provider.js`: `StripeProvider.createCheckoutSession()` now also sets
`payment_intent_data: { metadata }` at session creation, so the SAME metadata (which already
carries our own `transaction_id`) propagates onto the PaymentIntent Stripe creates — not just
the Session.
- `normalizeEvent()` (now `async`, one extra API call only on the rare refund path): for
`payment_intent.payment_failed`, reads `obj.metadata.transaction_id` directly (the event
object IS the PaymentIntent). For `charge.refunded`, retrieves the PaymentIntent
(`stripe.paymentIntents.retrieve`) since Charges do not inherit PaymentIntent metadata
automatically, and reads `transaction_id` from there.
- `commerce.js`: the transaction lookup now matches `provider_reference = $1 OR
transaction_id::text = $1` — a single additional `OR` clause, no schema/migration change.
- `webhook-routes.js`: added the now-required `await` on `verifyAndParseWebhook` (the
deterministic `TestProvider`'s synchronous return is unaffected — `await` on a non-promise
value resolves immediately).
Verified fixed: the real Stripe refund in this session (§22) correctly resolved via this new
path (the refund's `charge.refunded` webhook references the PaymentIntent, exactly the
previously-broken case), and a new permanent deterministic regression test (§28) proves the
mechanism directly.
## 28. Permanent regression coverage added
`platform/test/e2e-phase9.js` gained one new test: *"a webhook event referencing OUR OWN
transaction_id (not provider_reference) — the real Stripe payment_intent/charge shape — is
still matched"* — builds a test event whose `providerReference` is the transaction's own UUID
(exactly what the fixed `normalizeEvent()` now produces for real Stripe failure/refund events)
and confirms it's found and processed. This runs on every future deterministic `e2e-phase9.js`
pass, with no Stripe credentials required.
`platform/test/stripe-integration-test.js` (the opt-in real-Stripe check from the first Phase
9.1 attempt) was substantially extended: configurable price/currency
(`STRIPE_TEST_PRICE_CENTS`/`STRIPE_TEST_CURRENCY`, defaults stay cheap/generic), a
`STRIPE_TEST_MINOR=true` guardian+minor path, and — once a real payment lands — fully automated
deep verification (payment record, enrollment/cohort/guardian reconciliation, the real
re-signed-event duplicate proof, student access, a real refund) plus a fully automated,
browser-free abandoned/expired-checkout proof via `sessions.expire()`. Still opt-in, still a
guaranteed no-op by default, still refuses live-mode keys.
## 29. Regression results
- `platform/test/e2e-phase9.js`: **20/20** (fresh stack, `PAYMENT_PROVIDER=test`) — includes the
one new regression test from §28.
- `infra/scripts/drills/test-phase9-winter-camp-commerce.js`: **13/13** (fresh stack) — unaffected
by the fix, rerun to confirm.
- No registration/agreement/enrollment/notification-template code changed beyond the webhook
correlation fix itself, so Phase 8/5B/5C were not rerun (per the mission's own conditional
rule) — the fix is entirely inside `commerce.js`/`payment-provider.js`/`webhook-routes.js`,
already covered by the Phase 9 suite.
- No frontend code changed — no frontend build was required.
## 30. Documentation
Rewrote `PHASE9_1_STRIPE_TEST_VALIDATION.md` (this document) from BLOCKED to the real, proven
results. Updated `docs/STRIPE_INTEGRATION_RUNBOOK.md` (real-Stripe local testing is no longer
"not yet exercised") and `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` (the Stripe TEST technical
validation item is now checked; live-mode items remain unchecked, correctly — this phase never
touched live mode). `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` needed no change — nothing about
the website-facing contract itself changed this phase, only the server-side webhook correlation
internals.
## 31. Remaining Stripe TEST gaps
An actual real declined-payment checkout was not exercised (§20) — available on request, needs
one more manual browser step. Everything else the mission asked for was proven with real Stripe
traffic.
## 32. Remaining LIVE Stripe gaps
Unchanged from Phase 9 — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going live" section and
`docs/WINTER_BREAK_LAUNCH_CHECKLIST.md`: business/account verification, a live secret key, a
permanent live webhook endpoint, payout/bank configuration, a real HTTPS production URL, and
production deployment itself. None of this phase's work performed or authorized any live-mode
action.
## 33. Remaining Winter Break business decisions
Real dates, venue, and final capacity remain undecided (price is now decided — $399 launch /
$449 standard retail, per Chase directly this session; not yet reflected in a committed
`ROOTNODE_PRICING_AND_OFFERS_V1.md` in this canonical repo — see §1). Refund/cancellation
*policy* (as opposed to the mechanical non-cascade behavior proven in §22) remains an open
business decision.
## 34. Remaining legal requirements
Unchanged from every prior phase: the actual agreement/waiver legal text, jurisdiction-specific
minor-consent review, data-retention/deletion policy — see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`.
## Final status
**PHASE 9.1 COMPLETE / STRIPE TEST-MODE VALIDATED**
A real Stripe TEST Checkout Session, a real Stripe TEST payment, a genuine Stripe-signed
webhook, and the existing registration→enrollment conversion were all proven end to end, plus a
real duplicate-delivery proof, a real automated abandoned-checkout proof, and a real refund with
the non-cascade rule reconfirmed. One genuine defect (webhook-event correlation for
PaymentIntent/Charge-referencing events) was found only by this real traffic, fixed with the
smallest correct change, and covered by a new permanent deterministic regression test.
+287
View File
@@ -0,0 +1,287 @@
# Phase 9 — Commerce Integration, Website Registration & Winter Break Launch
Phase 9's mission: turn Phase 8's operable-but-unpaid enrollment system into a REAL commerce
pipeline — offering price → website registration → agreement acceptance (closing Phase 8's one
open gap) → hosted checkout → a verified, idempotent payment webhook → registration confirmation
→ enrollment activation — without ever trusting a browser for price, payment status, or
agreement acceptance, and without touching a single payment card number anywhere in this
codebase.
## Baseline
Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 8's
`788fb197e0a270423c1ff55829dc1d5dc380591b`.
## What was built
- **Migration 037** — `offerings.price_amount`/`price_currency` (nonnegative CHECK),
`offering_registrations.reservation_expires_at`, and two new tables:
`payment_transactions` (one row per checkout attempt — amount/currency snapshotted at
creation, provider, provider_reference with a unique partial index, status) and
`payment_webhook_events` (one row per provider event id, a real PK — the entire webhook
idempotency mechanism, never a "check then insert").
- **`payment-provider.js`** (new) — the payment-provider abstraction. `StripeProvider` (real
Stripe SDK, lazy-constructed so a test-provider dev stack never needs the real package
configured, real HMAC webhook verification via Stripe's own SDK helper, a narrow
`normalizeEvent()` mapping Stripe's event vocabulary to `{eventId, eventType,
providerReference, normalizedStatus}`) and `TestProvider` (deterministic, in-process, no
network, no real money — the default, matching `EMAIL_PROVIDER`'s existing "dev by default,
real provider opt-in" precedent). `commerce.js` and everything above it call the SAME methods
regardless of which is active — swapping providers is an environment variable, not a code
change.
- **`commerce.js`** (new) — the business-logic layer combining `offerings.js` and
`payment-provider.js`: `createCheckoutSession()` (validates state/agreements, snapshots the
offering's own price, never a caller-supplied amount), `processWebhookEvent()` (the idempotent
webhook entry point), `handlePaymentSuccess/Failure/CheckoutAbandoned/Refund()`,
`recordManualRefund()`, `reapExpiredReservations()`.
- **`webhook-routes.js`** (new) — `POST /api/webhooks/payment`. Reads the raw request body
(Express raw-body middleware scoped to only this one path, registered before the global JSON
parser in `server.js`), checks `Stripe-Signature`/`X-Test-Payment-Signature`, treats a bad
signature as a clean, quiet `400` (never a 500, never a hint about why), always responds `200`
once verification passes (a duplicate/ignored event is recorded, not retried forever).
- **`integration-routes.js` extended** — `POST /api/integrations/registrations/:id/agreements`
(closes Phase 8's explicit gap — agreement acceptance is now on the public registration
contract; who accepts is server-derived from the registration's own `is_minor`, never
caller-supplied), `POST /api/integrations/registrations/:id/checkout` (server-to-server only,
the amount always comes from the offering's own price), and `GET
/api/integrations/registrations/:id/status` (a deliberately minimal, non-admin status lookup
for the website's success page — no PII, no amount, no provider reference — see "Real bugs
found" below for why this exists).
- **`offerings.js` extended** — `reservation_expires_at` computed only for priced offerings
(`computeReservationExpiry()`); `OFFERING_EDITABLE_FIELDS`/`updateOffering()` (dynamic,
allowlisted `PATCH`, never touches status/slug/program_id); `computeOfferingReadiness()`
gained `price` and `payment_provider` checks; `convertRegistrationToEnrollment()` now fires the
same activation-or-confirmation email every other enrollment path already triggers (closing a
second real gap — see below).
- **Admin UI** — a Pricing card on the offering detail view (set/clear price), a Payments card on
the registration detail view (transaction history, a "Record refund" action for paid
transactions), both admin/owner-gated, both reusing the existing `Admin.module.css`/`adminApi`
conventions exactly.
- **Reservation reaper** — a `setInterval` poller (`server.js`) releasing any paid-offering
reservation whose `reservation_expires_at` has passed and which never reached a terminal state
(`enrollment_activated`/`cancelled`/`confirmed` excluded).
- **Prometheus metrics** — `rootnode_checkout_sessions_total`, `rootnode_payments_total`,
`rootnode_payment_webhooks_total`, `rootnode_payment_conversion_total`,
`rootnode_seats_released_total` — every label a small fixed enum (result/status/provider/
event_type/reason), never a registration_id, customer_id, or payment_intent_id.
## Three real bugs found and fixed during this phase's own testing (before any commit)
1. **Duplicate `registration_confirmed` notification.** `handlePaymentSuccess` originally fired
an explicit `registration_confirmed` notification AFTER `transitionRegistration('confirmed')`
already fires one internally — found via direct smoke-test output showing the notification
twice on one registration. Fixed by deleting the redundant explicit call.
2. **A converted registration never sent an activation email.** `convertRegistrationToEnrollment`
created the real student/enrollment/cohort rows but never called `notifyForEnrollment` — every
OTHER enrollment path (the admin manual-enroll route, `/api/integrations/enrollments`) already
does. Fixed by adding the call — which then surfaced bug 3 below, found immediately by the
Winter Break commerce rehearsal (not by the narrower e2e suite, which happened not to exercise
a brand-new minor-guardian conversion through this exact path):
3. **A converted minor student had no real guardian link.** `convertRegistrationToEnrollment`
creates the new student and enrollment but never promoted the registration's own `guardian_id`
into the relational `student_guardians` table — so `notifications.js`'s guardian-recipient
fallback (`resolveRecipient()`) had nothing to resolve to, and the new activation email failed
with `no_recipient_address`. Fixed by calling the existing `linkStudentGuardian()` helper
(already used elsewhere for admin-managed guardian links, never previously wired into
conversion) whenever `reg.is_minor && reg.guardian_id`. This is genuine plumbing, not a
business decision — a minor student converting through a paid registration now has the exact
same real guardian relationship a student created any other way already has.
Found via the Winter Break commerce rehearsal specifically, not the narrower e2e suite — the
rehearsal's own multi-step, real-webhook, real-notification-worker structure exercises the exact
timing and data shape a real minor registration produces, where a synthetic unit-style test can
accidentally supply a field (like `registrant_email`) the real website contract never actually
sends for a minor.
## Design decisions worth stating explicitly
- **Seat reservation timing unchanged from Phase 7/8**: a seat is still reserved AT
REGISTRATION, not at checkout. Phase 9 layers a bounded, configurable expiration
(`reservation_expires_at`, paid offerings only) on top rather than redesigning the reservation
model — a registrant who starts checkout keeps their seat for
`REGISTRATION_RESERVATION_TIMEOUT_MINUTES` (default 30) even if they never complete payment,
released either by an abandoned-checkout webhook event or the reaper.
- **A failed payment does NOT release the seat.** The registrant can retry checkout against the
same reservation. Only an abandoned/expired checkout session or reaper timeout releases it.
Proved directly in both `e2e-phase9.js` and the commerce rehearsal, as two genuinely different
code paths (`handlePaymentFailure` vs. `handleCheckoutAbandoned`/the reaper), not one path with
a flag.
- **Three separately-tracked statuses, never auto-cascading**: registration status
(`offering_registrations.status`), enrollment status (`enrollments.status`), payment status
(`payment_transactions.status`). A manual refund updates ONLY the transaction's status —
proved directly by querying both other statuses unchanged after a refund in `e2e-phase9.js`,
not just asserted by comment.
- **No waitlist was built.** Considered and deliberately deferred — the existing
`cancelled`-releases-a-seat mechanism, now paired with the reservation reaper's automatic
release of abandoned checkouts, covers the real near-term operational need (a freed seat
reopens registration automatically) without adding a new status-machine branch this phase has
no concrete evidence would be used for a single-camp launch. Revisit once real Winter Break
operational experience exists.
## Testing
`platform/test/e2e-phase9.js` — 19/19 passing (fresh stack): offering pricing in the public
serializer (no internal field leakage), checkout blocked until agreements are accepted,
registration rejected on draft/closed/full offerings, the server-authoritative amount (a
caller-supplied `amount` is silently ignored), webhook signature required/rejected-if-invalid/
idempotent-on-replay, payment success driving real conversion and activation with exactly one
`registration_confirmed` notification (no duplicate), a failed payment leaving the seat intact
and the registration retriable, a last-seat concurrent checkout race (`[201,409]`), the
reservation reaper releasing an expired unpaid reservation, a manual refund with no cascade into
registration/enrollment status, a minor payment confirmation routing to the guardian (never
writing to `students.email`), the admin payment view (amount/status/provider, never card data),
RBAC (a plain instructor denied payment data, the public API never exposing it), audit/metric
hygiene (no `sk_`/`whsec_`/fake-card patterns, no registration_id as a metric label), the new
website status-lookup endpoint's minimal safe shape, and confirmation that ROOT_ V1 self-paced
enrollment, a free (Phase-8-style) offering, and classroom/cohort flows are all unaffected.
## Winter Break commercial-path rehearsal
`infra/scripts/drills/test-phase9-winter-camp-commerce.js` — **13/13 passing** against a real,
freshly-built stack, `PAYMENT_PROVIDER=test` (no live money, no Stripe account needed): admin
configures a real priced offering (location, capacity, instructor, required agreement, price,
payment provider) and readiness honestly reports `ready_to_open_registration: false` (dates/
registration-window are deliberately unset — a real business decision this phase does not
invent) while every check it DID configure shows ready; admin opens registration; a minor
registers via the website integration, checkout is correctly blocked before the agreement is
accepted, the guardian accepts it, checkout is created; the test payment provider confirms
payment via a REAL webhook call — registration converts, the real activation token is retrieved
via the same dev-preview mechanism `e2e-phase5c.js` already established (never a raw-token
shortcut), and the minor's account activates; the exact same webhook event replayed (a real
Stripe retry scenario) changes nothing further; an adult registers, pays, and converts
independently with no guardian involved; a failed payment leaves a retriable registration without
consuming a phantom extra seat; an abandoned checkout (expired session) releases the seat; two
students genuinely compete for the final seat of a paid offering and exactly one wins; the
instructor starts a real classroom session and Prepares Labs; the minor student joins, completes
real orientation plus a REAL Lab task (`linux-inspect-hardware`) inside a real disposable Lab
container, and the instructor sees the real roster, payment, and progress state for the camp.
## Regression
Fresh-stack reruns after every fix: `e2e-phase8.js` 16/16, `e2e-phase7.js` 17/17, `e2e-phase6f.js`
17/17, `e2e-phase5c.js` 29/29, `e2e-phase5b.js` 32/32 — every existing flow this phase's changes
touch (`offerings.js`, `notifications.js`) reconfirmed byte-for-byte unaffected. `e2e-phase5a.js`
not independently rerun, per the same established convention as every prior phase's own doc — its
checks are embedded in and reconfirmed by 5B's/5C's own "Phase 5A ... still works unmodified"
assertions, and no code Phase 5A itself owns was touched. Course-content validator
(`infra/scripts/validate-course-content.js`) — clean, 0 errors, unaffected by this phase.
Migration idempotency — verified by restarting the API container against an already-migrated
database (no `down -v`) and confirming a clean startup with no migration errors, in addition to
every fresh `down -v`/`up --build` cycle across this phase's own testing applying all 37
migrations cleanly from empty every time.
## Frontend build
`npm run build` clean — the new Pricing/Payments admin UI cards compile without warning; the
only new warning is the pre-existing "chunk larger than 500kB" advisory, unchanged in cause from
every prior phase.
## Real Lab result
The Winter Break commerce rehearsal drives a real disposable Lab container through a genuine
`linux-inspect-hardware` scenario setup/validate cycle, reached this time through the full,
real commerce path (checkout → webhook → conversion → activation → classroom join) rather than a
simulated payment step — confirmed passing for real, not simulated.
## Security/privacy findings
- No payment-card data exists anywhere in this codebase — not in a request field, not in a
database column, not in a log line, not in an audit-event metadata blob. Stripe's hosted
Checkout page is the only thing that ever sees a real card number.
- The price is read server-side from the offering at checkout-creation time; a caller-supplied
`amount` field (tested directly) is silently ignored — a browser cannot choose its own price.
- The webhook route requires a verified signature on every request; an invalid/forged signature
is rejected with a generic `400`, never a 500 and never a hint about why verification failed
(matching this codebase's existing anti-enumeration precedent).
- The webhook is idempotent via a real database primary key on the provider's own event id — a
retried delivery (Stripe's own retry behavior on anything but a `200`) can never double-process.
- Admin payment views require admin/owner role; a plain instructor is denied (`403`, tested
directly); the public offerings API never exposes payment/transaction data at all.
- Metrics and audit events use only small, fixed-enum labels — no registration_id, customer_id,
payment_intent_id, or email ever appears as a metric label or in audit metadata (tested
directly via regex against both).
- Cross-student/guardian isolation holds: a minor's payment confirmation routes the activation
email to the guardian, never writing the guardian's email into the student's own identity
record — the same invariant Phase 8 already established, reconfirmed under the new payment
path.
## Legal-review requirements — unchanged, restated
See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s list in full, still entirely unresolved by this
phase's work: the actual legal text of any participation/media/liability agreement,
jurisdiction-specific minor-consent requirements, data-retention/deletion policy, and whether
emergency-contact fields need additional encryption at rest. Nothing about accepting payment
changes this list.
## What was deliberately NOT built (explicit, per this phase's own authorization)
Subscriptions, membership billing, coupons/discount codes, gift cards, installment/financing
plans, complex tax automation, accounting-system (QuickBooks etc.) integration, a Parent Portal
or guardian login, a CRM, marketing email/SMS, certificates, INIT_/COMPILE_ curriculum, ROOT
08/11, production deployment, a mobile app, and an AI tutor — none of these were touched, added,
or scaffolded "for later." A waitlist was considered and deliberately deferred (see "Design
decisions" above).
## Documentation
New: `PHASE9_COMMERCE_AND_LAUNCH.md` (this document), `WEBSITE_REGISTRATION_AND_CHECKOUT.md`,
`STRIPE_INTEGRATION_RUNBOOK.md`, `WINTER_BREAK_LAUNCH_CHECKLIST.md`. Updated:
`ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md` (commerce boundary + PHASE 9 BUILT rows),
`OFFERING_AND_REGISTRATION_MODEL.md` (pricing, the payment/commerce boundary, the three-status
principle, the updated "not built" list), `WEBSITE_ENROLLMENT_INTEGRATION.md` (price/availability
fields, the real agreement/checkout/webhook/confirmation sections replacing Phase 8's
placeholders), `WINTER_BREAK_CAMP_OPERATIONS.md` (the real payment step replacing the old manual
placeholder, updated blockers), `platform/README.md` (test list). Reviewed, no change needed:
`docs/ENGINEERING_SOP.md` (its own numbered-section convention has not been extended past Phase
6B for any prior phase either — Phase 7 and 8 made the same call; its Roadmap Status table
doesn't itemize commerce as a tracked line item to begin with).
## Known limitations
- The website's own registration/checkout UI does not exist yet in this repository
(rootnodeacademy.com is built separately, not RootNode-owned in this repo) — the contract is
ready and tested, nobody has built the website-side form against it yet.
- No real Stripe credentials exist — the entire commerce pipeline is built and tested against the
deterministic test provider, which exercises the exact same `commerce.js`/webhook-route code
path real Stripe traffic would, but has never made a real network call to Stripe's API or
received a real Stripe-signed webhook. See `docs/STRIPE_INTEGRATION_RUNBOOK.md`.
- No waitlist (deliberate, see "Design decisions" above).
- `registration_needs_action` still has no automatic trigger, unchanged from Phase 8 — still a
deliberate admin action, not something a failed payment forces (a failed payment DOES send this
notification automatically, but broader "needs action" cases remain admin-initiated).
## Remaining blockers before opening Winter Break registration to the public
See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for the full, itemized list. In summary: real dates/
venue/capacity/price (business decisions), the agreement's actual legal text (legal review), real
Stripe credentials (external dependency), and the website's own registration form (separate,
not-yet-built repo). None of these are platform readiness gaps — the platform is ready to operate
the full commerce flow the moment those four external decisions/assets exist.
## Commits
See `git log` for this phase's exact commit sequence (migrations/payment-provider/commerce,
integration routes, admin UI, tests + rehearsal, docs) — grouped per
`docs/ENGINEERING_SOP.md`'s existing convention.
## Recommended next phase
Real Stripe live-mode validation once credentials exist (`docs/STRIPE_INTEGRATION_RUNBOOK.md`'s
"Going live" section); the actual website-side registration/checkout form; the agreement's legal
text once reviewed; and, informed by real Winter Break Camp commercial operation, whether a
waitlist is actually worth building.
**Phase 9.1 addendum**: real Stripe TEST-mode validation was completed — a real Stripe TEST
Checkout, a real TEST payment (at the founder-approved $399 Founding Cohort price), a genuine
signed webhook, and the existing registration→enrollment conversion were all proven end to end,
plus real duplicate-delivery, abandoned-checkout, and refund proofs. One real defect (webhook
event correlation for PaymentIntent/Charge-referencing events, invisible to the deterministic
TestProvider) was found and fixed with a permanent regression test added. See `docs/
PHASE9_1_STRIPE_TEST_VALIDATION.md` for the full evidence. `PAYMENT_PROVIDER=stripe`,
`STRIPE_SECRET_KEY`, and `STRIPE_WEBHOOK_SECRET` are now wired through
`platform/docker-compose.dev.yml` (empty/unset by default — the deterministic TestProvider
remains the default for routine dev/test).
---
## PHASE 9 APPLICATION BUILD COMPLETE / LIVE STRIPE VALIDATION PENDING
@@ -0,0 +1,62 @@
# Registration & Guardian Communications
Phase 8 §8-§11 — how registration-stage notifications extend the existing Phase 5C notification
system. No second email subsystem was built: same `notification_events` table, same durable
worker (`notification-worker.js`), same Google Workspace-backed provider adapter
(`email/provider.js`), same masked-recipient admin API.
## What's new vs. Phase 5C
`notification_events.student_id` is now nullable, and a `registration_id` column was added (a
CHECK constraint guarantees every row still has a real subject) — because a registration-stage
notification (Registration Received, Agreement Required) may need to go out before any student
account exists at all, for a brand-new guardian-submitted registration. `notification_type`'s
CHECK constraint was widened to include five new types. See `033_phase8_...sql` and
`036_phase8_notification_types.sql`.
**A real bug found and fixed during this phase's own testing**: the new notification types were
added to `notifications.js`/`notification-worker.js` before the database CHECK constraint was
widened to accept them — every registration-stage `enqueue` call was silently failing (caught by
the deliberate "a notification failure never blocks the real action" try/catch) until this was
caught and fixed. See `docs/PHASE8_ENROLLMENT_OPERATIONS.md`.
## The five new templates
1. **Registration Received** — queued the moment a registration is created (website or admin).
2. **Registration Confirmed** — queued when a registration moves to `confirmed`.
3. **Registration Needs Action** — available for an admin/future-flow to queue with a reason
when a registration needs something before it can proceed (not automatically triggered by
any single lifecycle transition this phase — a deliberate choice, since "needs action" covers
many different real reasons an admin should state explicitly).
4. **Agreement Required** — queued once per missing required agreement when a registration
enters `agreements_pending`.
5. **Camp/Offering Operational Update** — a deliberately generic operational notice (e.g. "camp
starts Monday, here's what to bring"), NOT a marketing template — no promotional content, no
discount codes, no unrelated announcements. Available for an admin to trigger manually;
nothing queues it automatically.
All five are HTML + plain-text, RootNode-branded, no tracking pixels, no external images, no
passwords or sensitive data — same `templates.js` conventions Phase 5C established.
## Recipient resolution — extended, never overwritten
`resolveRecipient()`'s existing order (student's own email → the enrollment's
`metadata.contact_email`) now has a third, final fallback: a real, communications-enabled
primary guardian's email (`student_guardians`/`guardians`). This is purely additive — a student
who already resolves correctly today is completely unaffected, since the new fallback is only
ever reached when the first two return nothing. `enrollmentContactEmail` (Phase 5C, unchanged)
and this new guardian fallback deliberately coexist: `contact_email` remains a per-enrollment
override for the live website integration's existing contract, while the guardian relationship
is the durable, cross-enrollment identity — prefer the guardian model going forward, but the
transitional field is never broken.
For registration-stage notifications (no student yet, possibly), a separate
`resolveRegistrationRecipient()` tries: the registration's own student (if already converted) →
its linked guardian → the registrant's own supplied email.
## What was deliberately NOT built
No bulk/marketing email composer, no free-form admin-authored message sent to an arbitrary
recipient — every notification is one of the fixed templates above, triggered by a real
lifecycle event or an explicit, scoped admin action (never a raw "compose email" form). No SMS.
No third notification channel beyond email.
@@ -0,0 +1,157 @@
# RootNode Academy — Business & Product Architecture
Phase 7 Step 1 — the canonical bridge between RootNode's business strategy and its platform
architecture. This is where "what RootNode Academy sells and to whom" and "what the codebase
actually implements" are kept in one place, deliberately separated into BUILT NOW / PHASE 7
BUILT / FUTURE so nobody has to guess which parts are real.
## Course families
INIT_, COMPILE_, ROOT_, and BOOTCAMP_ are **product positioning and technical-depth families**,
not a rigid age ladder and not a mandatory sequence:
| Family | Typical audience (guidance, not a gate) | Status |
|---|---|---|
| INIT_ | ~ages 5-10 | Not yet authored (BUILT NOW: `programs.family` accepts it; PHASE 7 BUILT: nothing else; FUTURE: real curriculum) |
| COMPILE_ | ~ages 11-14 | Same as INIT_ |
| ROOT_ | ~ages 15+ through adult | **Fully authored, V1 pilot-ready** (Phase 6F) |
| BOOTCAMP_ | short/intensive seasonal or specialty offerings | BUILT NOW: `programs.family` accepts it; PHASE 7 BUILT: the offering/curriculum-package machinery a bootcamp needs; FUTURE: an actual bootcamp program row |
**INIT → COMPILE → ROOT is explicitly NOT a mandatory path.** A student's access to any given
course depends on that course's own prerequisites (already real and server-enforced within
ROOT_ — see `docs/ROOT_V1_PREREQUISITE_MAP.md`), not on which family they've touched before.
## COMPILE_ / ROOT_ overlap
These two families intentionally cover overlapping technical territory at different depths — an
adult is not blocked from an electronics/robotics/IoT course merely because it's positioned
under COMPILE_, and a COMPILE_-age student is not blocked from later revisiting the same domain
at ROOT_ depth. Example: COMPILE_ might teach "ESP32 + a sensor + introductory MQTT"; ROOT_ might
teach "Linux + networking + a real broker/service + a containerized IoT workload + observability"
— same domain, different technical floor. **BUILT NOW**: nothing prevents this architecturally —
`programs`/`offerings` carry no family-based authorization check anywhere in the codebase.
**FUTURE**: the actual COMPILE_ electronics/robotics content itself (explicitly out of Phase 7's
scope).
## Program vs. Course vs. Offering
Three previously-conflated levels, now distinct:
- **Program** (`programs` table, Phase 5B, unchanged): the educational product/path itself —
e.g. "ROOT Kubernetes Foundations." Carries `family` and a default `delivery_mode`.
- **Course** (`courses` table, Phase 6A, unchanged): a curricular unit inside a program — e.g.
"ROOT 02 Linux Administration & Networking."
- **Offering** (`offerings` table, **new this phase**): a specific purchasable/enrollable
*instance* of delivering a program (or a curriculum-package subset of one) — e.g. "Winter
Break Linux & Systems Camp — December 2026," "ROOT Foundations Cohort — Spring 2027," "ROOT
Self-Paced." A program can have many offerings across time; an offering always points at
exactly one program.
A **workshop** and a **cohort** are both just `delivery_mode` values on an offering, not separate
tables — see "Delivery modes" below. A **curriculum package** (new this phase) is an optional,
named, versioned SUBSET reference into an existing program's courses, for when an offering (like
a camp) intentionally teaches only part of a program — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
## Age guidance, not a gate
`offerings.recommended_min_age`/`recommended_max_age` and each course family's typical-audience
description are **display/marketing guidance only**. No API authorization check anywhere in this
codebase reads either value — enrollment and course access remain governed entirely by
prerequisites, entitlement (`enrollments.status`), and (new this phase) offering capacity/
registration status. Verified explicitly in `platform/test/e2e-phase7.js`.
## Delivery modes
Curriculum and delivery are separate concepts. `offerings.delivery_mode` (new this phase) is a
single column supporting: `self_paced`, `cohort`, `hybrid`, `camp`, `workshop`, `school`,
`after_school`, `homeschool`, `community`, `one_to_one`. No curriculum content is duplicated per
mode — the same `program`/`courses`/`course_modules`/`module_activities` are read regardless of
which offering enrolled the student into them, exactly the same principle
`docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md` already established for self-paced vs.
instructor-led delivery of ROOT_ V1.
## Organizations and locations
**Organization** (new this phase): a business/delivery relationship — a school, library,
homeschool group, community organization, or workforce-development partner. **Not** SaaS
multi-tenancy: RootNode Academy remains one Academy, one database, one set of students/
instructors; an organization is metadata an offering can optionally reference, not a data
partition. **Location** (new this phase): a physical or virtual delivery site, optionally tied
to an organization (a rented community-center room needs no partner org; "online" needs neither
an org nor a physical address). See `docs/SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md`.
## Guardians and minors
**Guardian** (new this phase, `guardians`/`student_guardians`): a real, separate identity from a
student's own account — not a login/auth identity yet (no password, no session), just a
contact/relationship record. Replaces the Phase 5C `enrollments.metadata.contact_email`
workaround as the proper foundation, while leaving that workaround functioning unchanged for
backward compatibility with the live website integration. See
`docs/STUDENT_GUARDIAN_DATA_MODEL.md`.
## Registration, enrollment, and agreements
**Registration** (new this phase, `offering_registrations`): pre-enrollment interest/application
information for a specific offering — separate from `enrollments` (academic entitlement,
unchanged). A registration becomes a real enrollment only once `confirmed`, via
`convertRegistrationToEnrollment()`. **Agreement** (new this phase,
`agreement_definitions`/`agreement_acceptances`): a versioned ACCEPTANCE RECORD, never legal
text authored by this codebase. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`.
## Commerce boundary — real as of Phase 9
The boundary itself is unchanged: the website owns marketing/pricing DISPLAY and the hosted
checkout experience, RootNode's platform owns the actual price (server-controlled, never
browser-supplied), payment verification, and everything after a verified commerce event. Phase 7
documented the flow; **Phase 9 built it for real**: an offering can carry a price
(`offerings.price_amount`/`price_currency`), a payment-provider abstraction (Stripe or a
deterministic in-process test provider — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md`) creates a
hosted checkout session server-side, and a verified, idempotent webhook is the ONLY thing that
ever confirms payment and drives `registration confirmed → enrollment activated` — a browser
`?paid=true` redirect proves nothing and is never trusted. See `docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's contract and `docs/
STRIPE_INTEGRATION_RUNBOOK.md` for going live with real Stripe.
## First commercial target: Winter Break Camp
Per `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A (Linux & Systems Foundations,
ROOT 01-02, ~12.4 hours), Phase 7 models this as a real, if still DRAFT, offering
(`docs/OFFERING_AND_REGISTRATION_MODEL.md`'s worked example) — proving the platform CAN
represent and operate it, without inventing dates, venue, price, or seat count that aren't
already decided.
## Future memberships/subscriptions and institutional delivery
Recurring (self-paced subscriptions, after-school programs, school/community contracts) and
institutional (schools, libraries, workforce partners) revenue channels are **FUTURE** — the
`organizations`/`locations`/`offerings` model this phase builds is deliberately general enough
to represent them later without a schema redesign, but no billing/subscription code exists or is
planned this phase.
## BUILT NOW / PHASE 7 BUILT / FUTURE — summary
| Concept | Status |
|---|---|
| Programs, courses, ROOT_ V1 curriculum | BUILT NOW (Phases 1-6F) |
| `programs.family` accepting `bootcamp` | PHASE 7 BUILT (schema widening only) |
| Organizations, locations | PHASE 7 BUILT |
| Guardians, student-guardian relationships | PHASE 7 BUILT |
| Offerings, capacity, server-defined status transitions | PHASE 7 BUILT |
| Curriculum packages (subset references) | PHASE 7 BUILT |
| Registrations, registration lifecycle | PHASE 7 BUILT |
| Agreement definitions/acceptances (record-only, no legal text) | PHASE 7 BUILT |
| Admin routes for all of the above | PHASE 7 BUILT |
| Admin UI (Offerings/Registrations screens) | **PHASE 8 BUILT** — `/admin` → Offerings, with launch readiness, roster, agreements, and per-registration detail (communications, agreement status). |
| Guardian communications (5 new notification templates, guardian recipient fallback) | PHASE 8 BUILT |
| Public offering read API + website registration submission contract | PHASE 8 BUILT (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) |
| Offering agreement requirements + conversion blocking | PHASE 8 BUILT |
| Offering pricing, payment-provider abstraction, hosted checkout, verified webhook | **PHASE 9 BUILT** (`docs/PHASE9_COMMERCE_AND_LAUNCH.md`) — Stripe test-mode + a deterministic in-process test provider; real Stripe live-mode validation is pending real credentials. |
| Agreement acceptance on the public registration contract | **PHASE 9 BUILT** — closes the gap Phase 8 left open. |
| Refund/cancellation recording (commerce status only, never auto-cascading) | PHASE 9 BUILT |
| Abandoned-reservation reaper, reservation expiry for paid offerings | PHASE 9 BUILT |
| Admin payment UI (price, payment/refund status, no card data) | PHASE 9 BUILT |
| A real website-side registration/checkout form (rootnodeacademy.com itself) | FUTURE — contract ready, form not built in this repo (separate repo, not RootNode-owned). |
| Live Stripe validation (real credentials, real card, real webhook from Stripe's servers) | FUTURE — application layer is done and Stripe-test-mode-tested; pending real Stripe credentials. |
| Subscriptions, membership billing, coupons, gift cards, installment plans, tax automation | FUTURE, explicitly out of scope |
| INIT_/COMPILE_ curriculum | FUTURE |
| School/library SIS integrations, parent portal, guardian login | FUTURE, explicitly out of scope |
+72
View File
@@ -0,0 +1,72 @@
# RootNode Academy Pricing Matrix V1
> **FOUNDER APPROVED — LOCKED FOR V1**
This document is the canonical V1 pricing decision for RootNode Academy. It was recovered from
the synced RootNode Academy conversation after the original Phase 9.5 work was found to be
absent from canonical Git history.
Prices below are in USD. A founding or early-bird price is a defined offer price, not a range.
## Seasonal camps
| Offering | Standard price | Launch price |
|---|---:|---:|
| Winter Break Camp | $449 | $399 — Founding Cohort |
| Summer Camp | $499 | $449 — Early Bird |
| Spring Break Camp | $449 | $399 — Early Bird |
## Instructor-led programs
| Offering | Standard price | Founding price |
|---|---:|---:|
| INIT_ Instructor-Led | $249 | $199 |
| COMPILE_ Instructor-Led | $399 | $349 |
| ROOT_ Foundations | $699 | $599 |
| ROOT_ Kubernetes Core | $799 | $699 |
| ROOT_ Operate & Secure | $899 | $799 |
## Self-paced
| Offering | Price | Access model |
|---|---:|---|
| ROOT_ Self-Paced | $299 | V1 one-time access |
No recurring subscription is approved or implemented for V1.
## Mentoring and workshops
| Offering | Price |
|---|---:|
| 1:1 Technical Mentoring — single hour | $100 |
| 1:1 Technical Mentoring — four-hour package | $360 |
| 1:1 Technical Mentoring — eight-hour package | $680 |
| Technical Workshop | $149–$299 framework; price set per workshop |
| School & Community Program | Custom quote |
The Technical Workshop range is an approved pricing framework, not one universal fixed price.
## Sibling policy
Each additional sibling receives 10% off tuition. This policy does not authorize or require a
promotion engine in V1.
## Winter Break Camp launch terms
- Commercial label: **Founding Cohort**
- Launch tuition: **$399 USD**
- Application representation: **39900 USD cents**
- Standard/reference tuition: **$449 USD** (`44900` cents)
- Payment: paid in full
- No deposit
- No payment plan
Pricing approval does not resolve or authorize dates, venue, capacity, tax treatment,
refund/cancellation policy, or legal agreement language. Those remain separate launch gates.
## Implementation authority
RootNode's application remains authoritative for offerings, eligibility, registration, and the
server-side amount sent to Stripe. Stripe executes and records payment; it is not the canonical
RootNode product catalog.
@@ -0,0 +1,64 @@
# School & Community Delivery Architecture
Phase 7 Step 27 — how a future school, library, homeschool organization, or community partner
maps onto the platform's new organization/location/offering model. This is architecture
documentation for an asset-light delivery strategy — RootNode favors partner/temporary venues
over building permanent classrooms everywhere, at least initially.
## The mapping
```
Organization (e.g. "Oxford Community Library")
└─ Location (e.g. "Library — Community Room B", or "Online")
└─ Offering (e.g. "After-School ROOT Foundations — Library Branch, Spring 2027")
├─ program_id → an existing program (ROOT_ V1, or a future family)
├─ curriculum_package_id → optionally, a subset (Step 20)
├─ delivery_mode → 'after_school' / 'community' / 'school' / 'homeschool'
├─ cohort_id → the real classroom/roster mechanism, unchanged
└─ instructor_offerings → who teaches it
```
A school, library, or homeschool organization is represented as ONE `organizations` row. It may
have multiple `locations` (a school with two campuses; a library system with several branches).
Each real delivery of curriculum at that organization is its own `offerings` row — this lets one
organization run several different offerings (different programs, different cohorts, different
semesters) without any of them being confused for each other, and without needing a new
mechanism for "an organization has many offerings" (it's the same one-to-many `organization_id`
foreign key `locations` and `offerings` already both carry).
## What this is NOT
**Not multi-tenancy.** No table in this schema is partitioned by `organization_id` the way a
true multi-tenant SaaS product would be — students, instructors, courses, and progress are not
scoped to an organization. An organization is a business/delivery *context* an offering can
optionally reference, nothing more. RootNode Academy remains one Academy with one shared
platform, one curriculum, and one set of students — a student who attends via a school
partnership is a normal RootNode student, indistinguishable in the data model from a self-paced
student, except for which offering(s) they registered through.
## Multi-location operation
Two offerings at two different locations coexist with no special isolation mechanism needed —
they're just two rows in `offerings` with different `location_id`s, each with its own cohort/
roster/registrations. `platform/test/e2e-phase7.js` proves this concretely: two offerings, two
locations, verified to never leak state into each other. This is operational separation (two
independent business records), not a security/tenant-isolation claim.
## What was explicitly NOT built this phase
- **No school SIS integration** (no roster import/export, no grade-passback, no LMS
interoperability standard like OneRoster or LTI).
- **No Google Classroom integration.**
- **No organization-specific portal or branding** — every organization's students see the same
RootNode Academy platform; there is no white-labeling.
- **No organization-level admin role** — an organization contact does not get their own login or
visibility into the platform; all administration remains through RootNode's own
instructor/admin accounts, same as any other offering.
## Future extension points (not built, but not blocked)
If a future phase needs an organization contact to see their own offering's roster/progress
(e.g. a school liaison checking on their students), that's a new, scoped RBAC role — the
`organizations`/`offerings` model here doesn't need to change to support it, only the
authorization layer would need a new role definition, deliberately deferred rather than guessed
at now.
+104
View File
@@ -0,0 +1,104 @@
# Secrets & Infisical
Gate 3. The full, real secret inventory — derived by grepping every `process.env.*` reference
across `platform/api/src`, `platform/gateway/src`, `platform/provisioner/src`, and
`platform/lab-router/src` (not guessed, not copied from a template list) — and the current
status of centralizing it in Infisical. **This document never contains a secret value.**
## Infisical project status — action required
Chase's self-hosted instance is `infisical.cedshomelab.com` (confirmed reachable, confirmed NOT
`app.infisical.com` — the earlier Phase 9.1 attempt failed for exactly this reason: the existing
machine identity is valid for the self-hosted instance only). Authenticated successfully this
phase using the machine identity at `~/.config/infisical/identity.env`
(`universal-auth`, org "Ceds HomeLab").
**Update, Phase 10.1**: Chase confirmed a "RootNode Academy" project already exists on this
instance. Re-investigated with a proper API call rather than trusting the CLI's interactive
picker alone — `GET /api/v2/organizations/{orgId}/workspaces` (the same authenticated identity,
a direct, complete enumeration, not a scrollable TUI) returns **8** projects visible to this
identity: Core Infrastructure, Kubernetes, Media Platform, Monitoring, Network Service,
Privileged Access Manager, Storage, Synthos Systems. (Two more than Phase 10's original 6-project
CLI capture, and with different membership — the CLI capture was evidently incomplete, not just
under-scrolled; "Hermes Fleet," which appeared in the CLI list, doesn't appear in this
authoritative API list at all.) **Still no "RootNode Academy" entry, under this identity.**
**Root cause, narrowed**: an identity's own workspace list in Infisical reflects only the
projects it has been explicitly granted access to (a per-project ACL, separate from org
membership) — an org owner's dashboard view would show every project in the org regardless of
any one identity's grants. The most likely explanation is that the project exists, created by
Chase, but the `claude-code-migration` machine identity was never added to its access list —
not that the project doesn't exist. This session cannot tell the two apart without either
org-owner API scope or the web dashboard, neither available here. **No duplicate project was
created**, per Phase 10.1's explicit instruction.
**Owner action required — the fix, precisely:** in the Infisical dashboard
(`https://infisical.cedshomelab.com`), open the RootNode Academy project → **Project Settings →
Access → Machine Identities**, and add `claude-code-migration` (identity id
`f3b42de1-11b6-4aa1-8f2e-7b85accec5fb`) with read/write access to whichever environments should
be automatable. Once granted, a future session can list/read/write its secrets directly — no
further CLI/API workaround needed. Until then, every secret continues to live only in local,
gitignored `.env` files (development) — see `docs/PHASE10_PRODUCTION_DEPLOYMENT.md` for
staging/production status.
## Secret inventory (real, from code — not from this prompt's guessed category list)
Every environment variable the application code actually reads, categorized. "Secret" = must
never appear in git, logs, or a browser; "Config" = fine to template/document in plain text
(already handled by `infra/environments/*/*.env.template`, unchanged by this phase).
| Variable | Category | Purpose | Consuming service | Environments | Rotation |
|---|---|---|---|---|---|
| `PLATFORM_DB_URL` | **Secret** (contains DB password) | Postgres connection string | Platform API | Dev/Staging/Prod (per-env DB, never shared) | On suspected compromise; routine rotation not yet scheduled |
| `PROVISIONER_SHARED_SECRET` | **Secret** | Platform API ↔ Provisioner trust boundary (`X-Provisioner-Key`) | Platform API, Provisioner | Dev/Staging/Prod (distinct per env) | On suspected compromise |
| `INTEGRATION_SHARED_SECRET` | **Secret** | Website backend ↔ Platform API trust boundary (`X-RootNode-Integration-Key`) | Platform API, website's backend (external) | Dev/Staging/Prod | On suspected compromise; the website team needs the current value out-of-band |
| `GOOGLE_SERVICE_ACCOUNT_JSON` | **Secret** | Full GWS service-account credential (Gmail API, domain-wide delegation) | Platform API (`email/gws-provider.js`) | Prod only (dev/staging use the deterministic dev provider by default) | Per Google Cloud's own service-account key rotation practice |
| `GOOGLE_DELEGATED_USER` | Config (an email address, not a secret) | Which real mailbox the service account impersonates via domain-wide delegation | Platform API | Prod | N/A |
| `EMAIL_FROM_ADDRESS` / `EMAIL_FROM_NAME` / `EMAIL_REPLY_TO` | Config | Sender identity — `[email protected]` | Platform API | All | N/A |
| `STRIPE_SECRET_KEY` | **Secret** | Stripe API authentication (checkout creation, refunds, retrieval) | Platform API (`payment-provider.js`) | Dev/Staging = TEST key; Production = LIVE key — **never mixed**, see Gate 8 status below | Per Stripe's own key-rotation guidance; rotate immediately if ever exposed |
| `STRIPE_WEBHOOK_SECRET` | **Secret** | Verifies a webhook delivery actually came from Stripe | Platform API (`webhook-routes.js`) | One per registered webhook endpoint (TEST vs. LIVE endpoints each have their own) | Regenerates automatically if the endpoint is recreated |
| `TEST_PAYMENT_WEBHOOK_SECRET` | Low-sensitivity (dev-only, never real money) | Shared secret for the deterministic `TestProvider`'s fake webhook signature | Platform API | Dev/test only | N/A |
| `LAB_ROUTER_TLS_KEY` | **Secret** | Lab Host Router's mTLS private key | Lab Host Router | Staging/Prod (dev uses a self-signed pair generated locally) | Per the mTLS cert lifecycle already documented in Phase 4.1 |
| `LAB_ROUTER_TLS_CERT` / `LAB_ROUTER_TLS_CA` | Sensitive but not strictly secret (public cert material) | mTLS certificate/CA chain | Lab Host Router, Gateway | Staging/Prod | Same lifecycle as the key above |
| `PLATFORM_DB_URL`'s host component, ports, timeouts, session TTLs, `PAYMENT_PROVIDER`, `COOKIE_SECURE`, `TRUST_PROXY`, `PUBLIC_APP_ORIGIN`, `DNS_RESOLVER`, `NO_PROXY_CIDRS`, etc. | Config | Plain operational configuration | Various | All, per-environment values | N/A |
Every "Config" row is already handled the existing way — plain values in `infra/environments/
*/*.env.template` (checked into git, safe) or `docker-compose.dev.yml`'s `${VAR:-default}`
substitutions. Only the "Secret" rows above need an Infisical home.
## Environment separation (Stripe — the one category with real launch consequences)
- **Development/Staging**: `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` must be **TEST** values
only (`sk_test_...`/a `stripe listen`-issued or TEST-mode dashboard `whsec_...`). This is what
Phase 9.1 validated end to end.
- **Production**: `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` must be **LIVE** values
(`sk_live_...`, and a permanent LIVE-mode dashboard webhook endpoint's own signing secret).
**Not configured — Chase has not provided live Stripe credentials, and this phase did not ask
for or receive them** (per Phase 10's own stop condition: "real money could move" /
"identity verification requires Chase" — live Stripe activation requires Chase's own Stripe
business/identity verification, not something this session can do on his behalf). See `docs/
PHASE10_LAUNCH_READINESS.md` Gate 8 status.
## Secret hygiene performed this phase
- Confirmed (again, freshly) that no real secret value has ever entered git history (`docs/
PHASE10_CANONICAL_STATE_AUDIT.md` §1.6).
- Confirmed `platform/.env`'s Stripe fields are correctly blank (not left set from Phase 9.1's
validation session) — matches this repo's own "unset after a deliberate session" convention.
- **Not yet performed** (blocked on the missing Infisical project): migrating any real secret
value out of local `.env` files and into Infisical. Per this phase's own instruction, a local
credential is never deleted until its Infisical replacement is verified — nothing was deleted.
## What Chase needs to do
1. In the existing "RootNode Academy" project's **Project Settings → Access → Machine
Identities**, add `claude-code-migration` (id `f3b42de1-11b6-4aa1-8f2e-7b85accec5fb`) with
read/write access to whichever environments should be automatable. **Do not create a second
project** — this one-line access grant is the fix, per Phase 10.1's investigation.
2. Confirm the project's environment names (Phase 10.1 could not see them without access) —
reuse whatever exists rather than renaming to force a Development/Staging/Production match,
per this repo's own "existing structure wins" instruction.
3. Once access is granted, a future session can populate every "Secret" row above from the
current local `platform/.env`/`platform/provisioner/.env` values for Development, and Chase
provides the real Staging/Production values (Stripe LIVE keys especially) directly when ready
for those environments — never invented or guessed by this phase.
+143
View File
@@ -0,0 +1,143 @@
# Stripe Integration Runbook
Operational reference for RootNode's payment-provider abstraction (`platform/api/src/
payment-provider.js`) — configuration, local testing against both the deterministic test
provider and real Stripe test mode, webhook setup, and what's left before a real, live-money
launch. See `docs/PHASE9_COMMERCE_AND_LAUNCH.md` for the design/architecture story and
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the website's own contract.
## The two providers
| | `PAYMENT_PROVIDER=test` (default) | `PAYMENT_PROVIDER=stripe` |
|---|---|---|
| Network calls | None — fully in-process | Real calls to Stripe's API |
| Needs credentials | No | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` |
| Checkout URL | A fake URL pointing back at your own `success_url` | A real Stripe-hosted Checkout page |
| Webhook signature | A static shared secret (`TEST_PAYMENT_WEBHOOK_SECRET`, header `X-Test-Payment-Signature`) | Real Stripe HMAC signature (header `Stripe-Signature`), verified via Stripe's own SDK |
| Real money | Never | Real in live mode; fake (test cards) in Stripe's own test mode |
| Used by | Every automated test/rehearsal in this repo | Nothing yet — pending real credentials |
`commerce.js` and every route above it call the SAME methods regardless of which provider is
active (`createCheckoutSession`, `verifyAndParseWebhook`) — switching providers is an environment
variable, never a code change.
## Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `PAYMENT_PROVIDER` | `test` | `test` or `stripe`. |
| `STRIPE_SECRET_KEY` | unset | Required for `stripe`. Server-side only — never exposed to any browser-served code. |
| `STRIPE_WEBHOOK_SECRET` | unset | Required for `stripe`. The signing secret Stripe gives you for a specific webhook endpoint (dashboard or `stripe listen`, see below) — NOT your API secret key. |
| `TEST_PAYMENT_WEBHOOK_SECRET` | `test-payment-secret-not-for-production` | Only used by the `test` provider. Change if you need to isolate parallel test runs; otherwise the default is fine. |
| `REGISTRATION_RESERVATION_TIMEOUT_MINUTES` | `30` | How long a paid offering's seat stays reserved before the reaper releases it if checkout is never completed. |
| `RESERVATION_REAPER_POLL_MS` | `60000` | How often the reservation reaper checks for expired, unpaid reservations. |
None of these belong in `frontend/` or anywhere a browser can read them — the Stripe secret key
and webhook secret are server-only, exactly like `INTEGRATION_SHARED_SECRET`/
`PROVISIONER_SHARED_SECRET` already are in this codebase.
## Local testing against the test provider (no Stripe account needed)
This is what every e2e suite and rehearsal in this repo actually runs against. Leave
`PAYMENT_PROVIDER` unset (defaults to `test`) and drive the full cycle directly:
```bash
# 1. Create a checkout session (server-to-server, same as the website would)
curl -s -X POST http://localhost:4200/api/integrations/registrations/<id>/checkout \
-H 'Content-Type: application/json' \
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
-d '{"success_url":"https://example.com/s","cancel_url":"https://example.com/c"}'
# 2. Look up the provider_reference the checkout just created
docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform \
-c "SELECT provider_reference FROM payment_transactions WHERE registration_id = '<id>' ORDER BY created_at DESC LIMIT 1;"
# 3. Build and POST a fake "payment succeeded" webhook event (Node, using the same provider
# module the real server uses — see platform/test/e2e-phase9.js for the full pattern)
node -e "
const { getPaymentProvider } = require('./platform/api/src/payment-provider');
const p = getPaymentProvider();
const event = p.buildTestEvent({ providerReference: '<provider_reference from step 2>', eventType: 'checkout.session.completed' });
console.log(JSON.stringify(event));
"
curl -s -X POST http://localhost:4200/api/webhooks/payment \
-H 'Content-Type: application/json' \
-H "X-Test-Payment-Signature: <event.signature from above>" \
-d '<event.body from above>'
```
To simulate a **failed payment** or **abandoned checkout**, use `eventType:
'payment_intent.payment_failed'` or `'checkout.session.expired'` in step 3 instead — see
`platform/test/e2e-phase9.js` for both, and for the reservation-reaper path (never send a webhook
at all; wait past `REGISTRATION_RESERVATION_TIMEOUT_MINUTES`, or lower it for a faster local
test).
## Local testing against real Stripe TEST mode
**Exercised for real in Phase 9.1** — a real Stripe TEST Checkout, a real TEST payment, a real
signed webhook, and the full registration→enrollment conversion were all proven this way; see
`docs/PHASE9_1_STRIPE_TEST_VALIDATION.md` for the full result and the one real defect it
exposed and fixed. `platform/test/stripe-integration-test.js` automates most of the steps below
— set `RUN_STRIPE_INTEGRATION_TEST=true` plus `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`/
`OWNER_EMAIL`/`OWNER_PASSWORD` and run it; it refuses to run against a live-mode key and makes
zero Stripe network calls unless explicitly enabled. Manually, or to understand what that script
does:
1. Create (or use an existing) Stripe account, switch to **Test mode** in the dashboard.
2. Copy the test-mode **Secret key** (`sk_test_...`) into `STRIPE_SECRET_KEY`.
3. Install the [Stripe CLI](https://stripe.com/docs/stripe-cli) (a prebuilt binary from
https://github.com/stripe/stripe-cli/releases works with no `apt`/sudo needed — this is how
Phase 9.1 installed it, to `~/.local/bin/stripe`) and run:
```bash
stripe listen --api-key sk_test_... --forward-to localhost:4200/api/webhooks/payment
```
No `stripe login` needed when passing `--api-key` directly (useful in a headless/agent
session with no browser to complete an OAuth flow). The CLI prints a webhook signing secret
(`whsec_...`) — set that as `STRIPE_WEBHOOK_SECRET` for the duration of the local session
(it's ephemeral per `stripe listen` run; a real deployment instead creates a permanent
webhook endpoint in the Stripe dashboard and uses ITS signing secret). Add `--print-secret`
to retrieve just the secret without leaving the listener running.
4. Set `PAYMENT_PROVIDER=stripe` and restart the API.
5. Drive the checkout flow through steps 2-4 of `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` — you
now get back a REAL `checkout_url`. Open it in a browser and pay with a Stripe test card
(`4242 4242 4242 4242`, any future expiry, any CVC, any ZIP).
6. `stripe listen` forwards Stripe's real webhook event to your local server; watch the server
log and `payment_webhook_events` table for `processed`.
7. To simulate a failure without a real failing card, use Stripe's documented test card numbers
for declines (e.g. `4000 0000 0000 0002`), or `stripe trigger payment_intent.payment_failed`.
This is the recommended path to real validation before going live — it exercises the actual
Stripe SDK, actual signature verification, and actual Stripe event delivery, with zero real
money at risk.
## Going live (real money)
1. Switch the Stripe dashboard to **Live mode**, generate a live **Secret key**
(`sk_live_...`), and create a permanent webhook endpoint pointed at the production API's
`/api/webhooks/payment` URL — copy ITS signing secret (`whsec_...`, different from test mode's).
2. Set `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) and `PAYMENT_PROVIDER=stripe` in
production configuration only — never commit these to git, never put them in a `.env` file
that gets version-controlled (matching this repo's existing secret-handling convention for
`PROVISIONER_SHARED_SECRET`/`INTEGRATION_SHARED_SECRET`).
3. Set a real price on the live offering (`PATCH /api/admin/offerings/:id`) and confirm
`GET /api/admin/offerings/:id/readiness` shows `payment_provider: ready`.
4. Run one real, small, genuinely-refundable transaction against a real card before opening
registration publicly, and confirm the full pipeline (checkout → webhook → confirmation →
enrollment → activation email) end to end, then refund it via the admin UI or the Stripe
dashboard directly.
5. Monitor `rootnode_payment_webhooks_total`/`rootnode_payments_total` (Prometheus metrics, see
`docs/PHASE9_COMMERCE_AND_LAUNCH.md`) after opening registration.
**This phase does not perform steps 1-4 above** — no real Stripe credentials were provided, and
inventing/obtaining them is a business decision reserved for Chase, not something this phase
invents. Everything up to this runbook is built and tested against the test provider; live
validation is the one explicitly pending item.
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| `503 PROVIDER_NOT_CONFIGURED` on checkout | `PAYMENT_PROVIDER=stripe` but `STRIPE_SECRET_KEY` unset, or the `stripe` npm package failed to load. |
| `400 invalid_signature` on every real Stripe webhook | `STRIPE_WEBHOOK_SECRET` doesn't match the endpoint Stripe actually delivered to (test-mode secret used against a live endpoint, or vice versa — each `stripe listen`/dashboard endpoint has its OWN secret). |
| Webhook received but nothing changes | Check `payment_webhook_events.result` for that event id — `duplicate` means it was already processed (expected on a retry); check server logs for an exception if `result` is missing entirely. |
| Seat never releases after an abandoned checkout | Confirm the reservation reaper is running (`RESERVATION_REAPER_POLL_MS` log line on server startup) and that `REGISTRATION_RESERVATION_TIMEOUT_MINUTES` hasn't been set unreasonably high. |
+81
View File
@@ -0,0 +1,81 @@
# Student & Guardian Data Model
Phase 7 Step 28 — why student and guardian identities are kept separate, what data-minimization
principles govern the new `guardians`/`student_guardians` tables, and what still needs
professional/legal review before any of this touches a real minor's data in production.
## Why separate identities
A student's own identity (`students` table — `email`/`login_identifier`, unchanged) is their
academic account. A guardian (`guardians`, new this phase) is a real, separate identity: a
parent with three enrolled children is **one** guardian row linked to three students via
`student_guardians`, not a guardian email copy-pasted into three student records. This mirrors
exactly the reasoning `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` already gave for keeping
`contact_email` off `students.email` — a contact address is not an academic identity, and now a
guardian is a real relationship, not a workaround field.
## What this phase deliberately did NOT build
- **No Parent Portal.** A guardian record has no login, no password, no session — it cannot
authenticate into anything yet. Building that is a real, separate future decision.
- **No child-activity surveillance.** Nothing in this schema or its routes lets a guardian (once
they could log in, in some future phase) see a student's Lab activity, scenario attempts, or
detailed learning analytics by default. A guardian relationship is a contact/authorization
record, not a monitoring grant.
- **No automatic broad access.** `student_guardians.can_receive_communications` and
`is_primary_contact` govern notification targeting only — wired into `notifications.js`'s
`resolveRecipient()` as of Phase 8 (a communications-enabled primary guardian is the LAST
fallback, tried only after the student's own email and any enrollment-level `contact_email` —
see `docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md`), never
authorization to student data.
## Data minimization
Only fields with a genuine, near-term operational purpose were added: `display_name`, `email`,
`phone`, and a free-text `notes` field for a guardian; `relationship`
(`parent`/`guardian`/`authorized_contact`/`emergency_contact`), `is_primary_contact`, and
`can_receive_communications` for the relationship itself. Registration-time fields
(`readiness_notes`, `accessibility_notes`, `emergency_contact_name`/`emergency_contact_phone`)
were chosen because a real camp/cohort genuinely needs them operationally — **no medical-record
functionality was built**, and `accessibility_notes` is a free-text operational note (e.g. "uses
a screen reader," "needs extra Lab time"), never a structured health/diagnosis field.
## RBAC
Guardian records and registration detail (readiness/accessibility/emergency-contact notes) are
admin/owner-only routes — a plain instructor's existing cohort-scoped access to a student's
academic progress is unchanged, but they do not automatically see guardian contact information
or registration notes through the new routes. See `admin-offerings-routes.js`'s own RBAC
comment and `platform/test/e2e-phase7.js`'s guardian-RBAC test.
## Adult vs. minor enrollment
An adult learner registers and is their own `registrant_email`/eventual student identity — no
guardian row is created or required. A minor's registration requires a `guardian_id` (enforced
by a database CHECK constraint on `offering_registrations`, not just application logic). Neither
path writes a guardian's email into `students.email` — a minor with no email of their own still
gets `login_identifier`-based identity (the same mechanism `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
already established), preserving the identity semantics Phase 5A/5C set up.
## Retention — flagged, not decided
This phase does not implement a data-retention or deletion policy for guardian/registration
records. **Requires future legal/business review**: how long registration data (especially
accessibility/emergency-contact notes for a minor) should be retained after an offering
completes or a registration is cancelled, and what a guardian's/family's data-deletion request
process should look like.
## Items explicitly requiring professional/legal review before production use with real minors
- Whether the current field set (name/email/phone/relationship) is sufficient or whether
jurisdiction-specific requirements (e.g. COPPA-adjacent consent language, state-specific camp/
minor-supervision regulations) require additional structured fields.
- The actual legal text of any participation agreement, media consent, or liability waiver —
`agreement_definitions.body_summary` is a plain-language placeholder for admin-UI display
only, never a substitute for attorney-reviewed legal language.
- Data retention/deletion policy for guardian and registration records.
- Whether `emergency_contact_name`/`emergency_contact_phone` need to be encrypted at rest beyond
Postgres's existing storage (no additional encryption was added this phase).
None of the above is resolved by this phase's work — they are named here specifically so they
are not silently assumed away.
+179 -4
View File
@@ -171,10 +171,185 @@ email — otherwise the enrollment is created but nobody is notified automatical
instructor has to relay the activation link manually. `contact_email` is stored only against 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. 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 **As of Phase 7, a real guardian/parent record now exists** (`guardians`/`student_guardians` —
student identity you provide. If you need to support "a parent manages multiple children's see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`), but this endpoint's contract is UNCHANGED and does
enrollments" as a first-class concept later, talk to the RootNode team before building around a not yet create or link one — `student.contact_email` still works exactly as described above,
workaround; the schema has room for it but it isn't implemented. stored only on the enrollment. If you need "a parent manages multiple children's enrollments" as
a first-class concept in this endpoint, talk to the RootNode team before building around a
workaround; the schema now has the real tables for it, they're just not wired into this
endpoint yet.
## Offerings and registrations (Phase 8 — live)
As of Phase 8, RootNode represents specific purchasable deliveries — camps, cohorts, self-paced
access — as first-class **offerings**, with a **registration** step before final academic
enrollment (see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). This section covers the real, live
contract; the endpoint documented above (`/api/integrations/enrollments`, direct program
enrollment) is completely unaffected and still works exactly as described.
### 1. List/view offerings (public, no auth)
```
GET /api/public/offerings
GET /api/public/offerings/:slug
```
No `X-RootNode-Integration-Key` needed — this is a public, read-only endpoint, safe to call
directly from a browser. Only `published`/`enrollment_open`/`full`/`in_progress` offerings ever
appear; `draft` and `cancelled` never do. Example response:
```json
{
"slug": "winter-linux-systems-camp-2026",
"name": "Winter Break Linux & Systems Foundations Camp",
"description": "A hands-on introduction to Linux administration and networking.",
"program_id": "root-kubernetes-foundations",
"delivery_mode": "camp",
"recommended_min_age": 13,
"recommended_max_age": null,
"starts_at": "2026-12-15T14:00:00.000Z",
"ends_at": "2026-12-19T18:00:00.000Z",
"registration_open": true,
"availability": "limited",
"location_name": "Community Room B",
"location_type": "community_center",
"price_amount": 24900,
"price_currency": "usd",
"requires_payment": true
}
```
`availability` is `"open"` / `"limited"` / `"full"` — never a raw seat count (avoids inviting
scraping/timing games against a public endpoint). No internal notes, no organization contact
info, no instructor identity — just enough to build a pricing/availability display. **As of Phase
9**: `price_amount` is an integer in minor currency units (cents — `24900` = $249.00), `null` for
a free offering; `price_currency` is a lowercase ISO code (`"usd"`); `requires_payment` is a
plain derived boolean (`price_amount !== null`) so the website never has to reimplement that
check. The actual checkout amount is always read server-side from this same offering row at
checkout-creation time — the website never sends an amount anywhere in this contract.
### 2. Select an offering, then submit a registration (server-to-server)
```
POST /api/integrations/registrations
Header: X-RootNode-Integration-Key: <the same secret /api/integrations/enrollments already uses>
```
Same trust boundary as the existing enrollment endpoint (Step 15) — server-to-server only, never
called from browser JS, the secret never leaves your backend's own configuration.
**Adult example request:**
```json
{
"event_id": "reg_9f8e7d6c5b4a",
"source": "website",
"offering_slug": "winter-linux-systems-camp-2026",
"registrant": { "display_name": "Jamie Rivera", "email": "[email protected]", "is_minor": false }
}
```
**Minor/guardian example request:**
```json
{
"event_id": "reg_1a2b3c4d5e6f",
"source": "website",
"offering_slug": "winter-linux-systems-camp-2026",
"registrant": { "display_name": "Riley Rivera", "is_minor": true },
"guardian": { "display_name": "Jamie Rivera", "email": "[email protected]", "phone": "555-0100" }
}
```
**Success response** (`201` new, `200` idempotent replay):
```json
{ "registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "offering_id": "winter-linux-systems-camp-2026", "status": "started", "created": true }
```
**Errors**: `401 unauthorized` (bad/missing key), `400` (`offering_slug`/`event_id`/registrant
fields missing, or a minor with no guardian email), `404 OFFERING_NOT_FOUND`, `409` for
`OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` /
`REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED`.
### 3. Duplicate/retry semantics (idempotent, DB-enforced)
Identical to `/api/integrations/enrollments`: send the exact same `event_id` again (a retried
webhook, a timed-out request retried by your own backend) and you get back the **same
registration**, `created: false`, never a duplicate, never a second seat reserved — enforced by
a real unique index on `event_id`, safe under concurrent/parallel retries too. Never generate a
new `event_id` for a retry of the same real-world submission.
### 4. Adult flow
Send `registrant.email`, omit `guardian` entirely, `is_minor: false` (or omit it). The adult IS
both the eventual student and the registration contact — no guardian record is created.
### 5. Minor/guardian flow
Send `registrant.is_minor: true` and a real `guardian` object (`display_name` + `email`
required; `phone` optional). RootNode matches an existing guardian by email first (a returning
family doesn't get a duplicate guardian record per sibling/per camp) or creates a new one. The
guardian's email is never written to the eventual student's own identity record.
### 6. Agreement flow (Phase 9 — live)
```
POST /api/integrations/registrations/:id/agreements
Body: { "agreement_id": "winter-camp-waiver-2026" }
```
Same shared-secret header as every other route in this section. Which party accepts is
server-derived from the registration's own `is_minor`, never a value you send: an adult accepts
as themselves (before their student account even exists yet), a minor's registration is accepted
by the guardian already on file from submission. `201 { "accepted": true, "agreement_id": ...,
"accepted_version": ... }` on success; `404 AGREEMENT_NOT_FOUND` if the id doesn't match a real
agreement configured on this offering. **Checkout is blocked (`409 AGREEMENTS_INCOMPLETE`) until
every required agreement is accepted** — call this before calling checkout below.
### 7. Checkout (Phase 9 — live)
```
POST /api/integrations/registrations/:id/checkout
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
```
Server-to-server only — the browser never calls this directly, and never supplies a price. The
amount, currency, and product description all come from the offering's own server-side
configuration. Success: `201 { "checkout_url": "...", "transaction_id": "..." }` — redirect the
customer's browser to `checkout_url` (RootNode's configured payment provider's own hosted
checkout page; today that's Stripe Checkout in test mode, or a fake URL from the deterministic
test provider in local dev — see `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`). A free offering
(`price_amount: null`) returns `409 NO_PRICE_CONFIGURED` — never call this for a free offering;
just proceed straight to confirmation. Full error table, the success-page design (never trust a
returning `?paid=true`), and the status-lookup endpoint are in `docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md`.
### 8. Payment confirmation — a verified webhook, never a browser redirect
RootNode's own webhook (not something the website calls) is the ONLY thing that confirms
payment. Once confirmed, the registration walks forward to `confirmed` and converts to a real
enrollment automatically — reusing the exact activation-email flow described below, no separate
"payment succeeded" email exists. See `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` for how to
build a safe success page against this (poll the status-lookup endpoint; never derive success
from the URL alone).
### 9. Confirmation and enrollment activation
Once `confirmed` (via the webhook above for a priced offering, or a manual admin transition for
a free one) and every required agreement is accepted, the registration converts to a real
enrollment — creating or matching the real student account and reusing the exact same
activation-email machinery `/api/integrations/enrollments` already triggers. No second
confirmation path and no second activation path exist.
### 10. What this endpoint does NOT do
No payment-card data (never sent, never has a field for it — see `docs/
WEBSITE_REGISTRATION_AND_CHECKOUT.md` for the full payment-security posture). No password
(RootNode issues its own credential/activation flow). No arbitrary registration status
(server-defined transitions only — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`). All example
data above is fake — do not
reuse these values against a real deployment.
## What the student experiences after you call this (as of Phase 5C) ## What the student experiences after you call this (as of Phase 5C)
+210
View File
@@ -0,0 +1,210 @@
# Website → RootNode Registration & Checkout
Audience: whoever builds `rootnodeacademy.com`'s registration/checkout flow for a paid offering
(Winter Break Camp and anything after it). This is the Phase 9 companion to
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` — read that document first for the shared trust
boundary, authentication header, and the base `/api/integrations/registrations` contract. This
document covers everything Phase 9 adds on top: pricing display, the agreement step, checkout,
the success page, and status lookup.
## The end-to-end flow
```
1. GET /api/public/offerings/:slug — display program, price, availability (no auth, browser-safe)
2. POST /api/integrations/registrations — submit the registration (server-to-server)
3. POST /api/integrations/registrations/:id/agreements — accept the required agreement(s)
4. POST /api/integrations/registrations/:id/checkout — create a hosted checkout session
5. redirect the customer's browser to the returned checkout_url
6. RootNode's webhook confirms payment — NOT something the website calls
7. GET /api/integrations/registrations/:id/status — poll from the success page
```
Steps 2-4 and 7 are server-to-server only, using the same `X-RootNode-Integration-Key` header as
every other integration route. Steps 1 and 5 are the only things that ever touch the customer's
browser directly.
## 1. Price and availability display
```
GET /api/public/offerings/:slug
```
No auth needed. As of Phase 9, the response includes:
```json
{
"price_amount": 24900,
"price_currency": "usd",
"requires_payment": true
}
```
`price_amount` is an integer in minor currency units (cents) — divide by 100 and format with
`price_currency` for display (`$249.00`). `price_amount: null` means a free offering —
`requires_payment` is `false` and you should skip steps 3/4/5 entirely (call `checkout` on a free
offering and you'll get `409 NO_PRICE_CONFIGURED`; there's nothing to check out).
**This price is display-only.** The actual amount charged always comes from the SAME offering
row, read server-side at checkout-creation time (step 4) — there is no field anywhere in this
contract for the website to submit an amount. A browser (or a compromised website backend)
cannot pay a different price than what's configured on the offering.
## 2. Submit the registration
Unchanged from `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` §2 — adult or minor/guardian flow, same
`event_id` idempotency. You'll get back a `registration_id`; hold onto it for every step below.
## 3. Accept the agreement(s)
```
POST /api/integrations/registrations/:id/agreements
Header: X-RootNode-Integration-Key: <secret>
Body: { "agreement_id": "winter-camp-waiver-2026" }
```
Ask RootNode for the current `agreement_id`(s) configured on the offering — there is no
"list required agreements" endpoint on this public contract yet; if your flow needs to render the
agreement text/checkbox dynamically, ask the RootNode team before building a workaround.
Which party is recorded as accepting is **server-derived from the registration's own
`is_minor`** (set at submission in step 2) — you never send who's accepting. `201` on success:
```json
{ "accepted": true, "agreement_id": "winter-camp-waiver-2026", "accepted_version": 1 }
```
`404 AGREEMENT_NOT_FOUND` if the id is wrong or not configured on this offering.
**Checkout is blocked until every required agreement is accepted** (`409 AGREEMENTS_INCOMPLETE`
from step 4). Call this before checkout, always.
## 4. Create the checkout session
```
POST /api/integrations/registrations/:id/checkout
Header: X-RootNode-Integration-Key: <secret>
Body: { "success_url": "https://rootnodeacademy.com/enroll/success", "cancel_url": "https://rootnodeacademy.com/enroll/cancelled" }
```
Both URLs are **yours** — RootNode's payment provider redirects the customer's browser back to
one of them after checkout. `success_url` and `cancel_url` are required; nothing else is. There
is no `amount` field — see "Price and availability display" above.
Success (`201`):
```json
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...", "transaction_id": "..." }
```
Redirect the customer's browser to `checkout_url` immediately (a `302`, or a client-side
`window.location`). In local/staging use with the deterministic test provider (see
`docs/STRIPE_INTEGRATION_RUNBOOK.md`), `checkout_url` is a fake URL pointing straight back at
your own `success_url` with a `?test_session=...` param appended — there is no real hosted page
to visit, which is fine for automated testing but means you can't manually click through a
"real" checkout page against the test provider.
**Errors:**
| Status | `error` | Meaning |
|---|---|---|
| `401` | `unauthorized` | Missing/wrong integration key. |
| `400` | `VALIDATION` | `success_url`/`cancel_url` missing. |
| `404` | `NOT_FOUND` / `OFFERING_NOT_FOUND` | Bad registration id, or its offering vanished. |
| `409` | `NO_PRICE_CONFIGURED` | This offering is free — don't call checkout for it. |
| `409` | `AGREEMENTS_INCOMPLETE` | Call step 3 first. |
| `409` | `INVALID_STATE` | The registration is already past the point where checkout makes sense (e.g. already confirmed). |
| `409` | `OFFERING_NOT_OPEN` / `OFFERING_CANCELLED` / `OFFERING_COMPLETED` / `OFFERING_FULL` / `REGISTRATION_NOT_YET_OPEN` / `REGISTRATION_CLOSED` | The offering/registration window state changed since step 2. |
| `503` | `PROVIDER_NOT_CONFIGURED` | RootNode's payment provider isn't configured yet — a RootNode-side problem, not yours. Show a generic "checkout is temporarily unavailable" message and alert RootNode. |
## 5. Payment confirmation — never trust the redirect
**RootNode's own webhook is the only thing that ever confirms payment.** When the payment
provider redirects the customer back to your `success_url`, that redirect proves nothing on its
own — a customer can reload, bookmark, or manually construct that URL without ever paying, and a
successful redirect can race ahead of the webhook actually being processed. **Never render "you're
enrolled" or grant anything based on the URL/query string alone.**
## 6. Building a safe success page
```
GET /api/integrations/registrations/:id/status
Header: X-RootNode-Integration-Key: <secret>
```
Call this from your OWN backend (never expose the integration key to the browser) when the
customer lands on your success page, and poll it every couple of seconds for a short window (the
webhook is normally processed within a second or two of the customer completing checkout — see
`docs/STRIPE_INTEGRATION_RUNBOOK.md` for real-world Stripe delivery latency). Response:
```json
{
"registration_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd",
"status": "confirmed",
"payment_status": "paid",
"enrollment_activated": false
}
```
Deliberately minimal — no name, no email, no amount, no provider reference. Enough to render one
of three states on your success page:
- **`payment_status: "paid"` (or `status: "enrollment_activated"`)** — "You're all set — check
your email for your RootNode Academy activation link."
- **`payment_status: "pending"` or missing, `status` still pre-confirmation** — "Processing your
payment — this page will update automatically" (keep polling, or tell the customer to check
back / check email shortly).
- **`payment_status: "failed"`** — "Your payment didn't go through. You can try again;" — for a
priced offering, a failed payment does NOT release the seat, so the SAME registration can
retry checkout (back to step 4) without losing its place.
A cancelled/expired checkout (customer closed the tab, or the provider's own session timeout)
shows up as `status: "cancelled"` once RootNode's reservation reaper or the provider's own
"expired" webhook processes it — at that point the seat has been released and the customer needs
to start a new registration if they still want in.
`401` (bad/missing key) and `404 NOT_FOUND` (bad registration id) are the only error responses.
## 7. What you must NOT do
- Never store, log, or transmit card numbers, CVVs, or any other payment-card data — this
contract has no field for it anywhere, on purpose. RootNode never touches card data either;
Stripe's hosted Checkout page collects it directly.
- Never send an `amount`/`price` in the checkout request — there is no such field, and if you
add one to your own request body it is silently ignored (the price always comes from the
offering).
- Never build your own "payment succeeded" email — RootNode's activation/confirmation email
fires automatically on webhook-confirmed conversion (same machinery `docs/
WEBSITE_ENROLLMENT_INTEGRATION.md` already documents). A second email from your side would
duplicate or conflict with it.
- Never poll the status endpoint indefinitely/aggressively — a reasonable few-second interval
for a short window (a minute or two) covers real webhook latency; beyond that, tell the
customer to check their email and stop polling.
## Accessibility and loading-state notes
- The redirect to `checkout_url` in step 4 is a full navigation, not a modal — treat it like any
other outbound link (announce the navigation to assistive tech, don't trap focus expecting a
return).
- The success page in step 6 has three real states to design for (processing/confirmed/failed),
not two — see step 6's own state list. Design a genuine "still processing" state; don't leave
the customer looking at a page that appears frozen while you poll.
- Every checkout/agreement error in the tables above has a distinct, human-meaningful cause —
map each to a real, specific message rather than one generic "something went wrong."
## Test-mode flow (what to actually test against)
Local/staging integration testing uses `PAYMENT_PROVIDER=test` (RootNode's own deterministic
provider, no Stripe account needed) — see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for how to drive a
full checkout → webhook → confirmation cycle against it, including simulating a failed payment
and an abandoned checkout. Once RootNode has real Stripe credentials, staging can switch to
`PAYMENT_PROVIDER=stripe` in Stripe's OWN test mode (fake test cards, e.g. `4242 4242 4242 4242`)
for a true end-to-end rehearsal before anything goes live with real money.
## Privacy note
The only personal data this contract ever sees is what `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`
already documents for registration (names, emails, phone) — nothing payment-specific is added.
Whatever billing/contact information Stripe's hosted Checkout collects from the customer (name,
email, card) stays with Stripe; RootNode never receives or stores it beyond the transaction
metadata `docs/PHASE9_COMMERCE_AND_LAUNCH.md` describes (amount, currency, status, timestamps,
provider reference — never card data).
+92
View File
@@ -0,0 +1,92 @@
# Winter Break Camp Operations
Phase 8 §29 (payment step updated in Phase 9) — how to actually run the Winter Break Linux &
Systems Foundations Camp (`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) using the
platform built in Phases 7-9, end to end, entirely from `/admin`. As of Phase 9, real
checkout/payment (Stripe test mode today, live mode once real credentials exist) is part of this
flow — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md` and `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md`.
## What already exists vs. what Chase still fills in
**Already representable, real, tested:**
- The curriculum package (ROOT 01-02, referencing the real, unmodified courses).
- The offering itself, in DRAFT, with delivery mode `camp`, and a real price.
- Registration → guardian/adult → agreement → real checkout → verified webhook → confirmation →
enrollment → cohort → activation → classroom → real Lab work, proven end to end in
`infra/scripts/drills/test-phase9-winter-camp-commerce.js` (13/13 passing, test payment
provider, no live money) — plus failed-payment retry, abandoned-checkout seat release, and a
last-seat checkout race, none of which existed in Phase 8's own rehearsal.
**Still genuinely unknown, deliberately left editable, never invented by this phase:**
- Exact dates, venue, real capacity, and price (the price FIELD is real and editable; the actual
dollar amount to charge is still Chase's call).
- The actual agreement text (a plain-language placeholder only — see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
- Whether registration opens before or after a specific marketing push.
- Real Stripe credentials — until they exist, the platform runs on the deterministic test
provider, functionally complete but not yet validated against Stripe's own live systems. See
`docs/STRIPE_INTEGRATION_RUNBOOK.md`.
## The operational sequence
1. **Create the location** — `/admin` → Offerings → (via the offering create form's location
dropdown) or directly if a location admin surface is added later; today, an admin/owner uses
`POST /api/admin/locations` (documented in `docs/OFFERING_ADMIN_GUIDE.md`) once the real venue
is known.
2. **Create the curriculum package** — `winter-linux-systems-v1`, referencing
`root-01-computing-linux-foundations` and `root-02-linux-administration-networking` in
sequence. This step is a one-time setup — the same package can back every future Winter Break
Camp offering.
3. **Create the cohort** and **the offering** — delivery mode `camp`, referencing the program,
the curriculum package, and (once known) the location and capacity.
4. **Assign the lead instructor** — both at the offering level (`instructor_offerings`, Phase 7)
and the cohort level (`cohort_instructors`, unchanged since Phase 2A) — the offering detail
page's own instructor step, or the two admin routes directly.
5. **Set the price** — `PATCH /api/admin/offerings/:id` with `price_amount`/`price_currency`
(or leave `price_amount` unset for a free offering). Confirm `PAYMENT_PROVIDER` is configured
(`test` for rehearsal, `stripe` with real credentials for a live launch — see `docs/
STRIPE_INTEGRATION_RUNBOOK.md`).
6. **Configure the required agreement(s)** — create the agreement definition (title + plain-
language summary) and link it to the offering. **The actual legal text still needs
professional review before this camp is opened to real families** — see below.
7. **Check launch readiness** — the offering detail page's readiness panel should show every
check green before publishing, including the new `price` and `payment_provider` checks. If
instructor/curriculum/agreements/price are configured but dates/location/capacity aren't
final yet, readiness will honestly show that.
8. **Publish, then open registration** — `draft → published → enrollment_open`. Only once
`enrollment_open` does the offering appear in the public offerings API and accept real
registrations.
9. **Registrations arrive** — via the website integration (contract ready, see
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`) or entered directly by an admin for a phone/
in-person sign-up.
10. **Guardian/adult completes the agreement, then checkout** — the agreement acceptance and
checkout-creation endpoints are both live (Phase 9); checkout is blocked until the agreement
is accepted.
11. **Payment confirmation** — a verified, idempotent webhook (never a browser redirect) is what
actually confirms payment and walks the registration to `confirmed`. A failed payment leaves
the registration retriable without releasing the seat; an abandoned checkout or an expired,
unpaid reservation releases it.
12. **Convert to enrollment** — happens automatically on payment confirmation for a priced
offering (or a manual admin transition for a free one) — creates the real student account
(or matches an existing one), the real academic enrollment, and real cohort membership.
13. **Account activation** — the student's (or, for a minor, the guardian's) real one-time
activation token, delivered the same way every other RootNode activation email already is.
14. **Classroom day** — Prepare Labs for the cohort, start the classroom session, students join
and work through real ROOT 01-02 content and Labs.
15. **Offering completion** — `in_progress → completed` once the camp concludes.
## Known blockers before this can actually open to the public
- Real dates, venue, capacity, and the actual dollar price are not yet decided (this document
does not invent them — the platform fields to hold them are real and ready).
- The agreement text requires professional legal review (see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`).
- Real Stripe credentials do not exist yet — the commerce pipeline is built and tested against
the deterministic test provider only; see `docs/STRIPE_INTEGRATION_RUNBOOK.md` for going live.
- The website's own registration/checkout UI is now built (rootnodeacademy.com, a separate
repository not owned by this one) against `docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`'s
contract — but it has nothing to talk to yet, since `ROOTNODE_API_URL`/
`ROOTNODE_INTEGRATION_KEY` point at no real Platform API (none is deployed — see `docs/
PHASE10_PRODUCTION_DEPLOYMENT.md`). Its own real pricing (`docs/
ROOTNODE_PRICING_AND_OFFERS_V1.md`) has already been published on the live site.
- See `docs/WINTER_BREAK_LAUNCH_CHECKLIST.md` for the full go/no-go list.
+111
View File
@@ -0,0 +1,111 @@
# Winter Break Camp — Launch Checklist
The single go/no-go list for opening the Winter Break Linux & Systems Foundations Camp
(`docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md` Option A) to real public registration. Every
item is tagged **COMPLETE**, **BLOCKED**, **DECISION REQUIRED**, or **MANUAL OWNER ACTION** —
see `docs/PHASE10_LAUNCH_READINESS.md` for the full evidence matrix behind each tag. Nothing on
this list is invented by the platform — every non-COMPLETE item is a real decision, asset, or
piece of hardware that has to come from Chase, a legal reviewer, or Stripe, not from more code.
- [x] **Canonical repo reconciled** — COMPLETE. `docs/PHASE10_CANONICAL_STATE_AUDIT.md`: this
workstation's HEAD, Gitea's `origin/main`, and every claimed milestone SHA all verified
consistent. No stranded branches/tags on the remote.
- [ ] **Second-PC state reconciled** — DECISION REQUIRED (verification only, not a code
blocker). Gitea shows no evidence of stranded work, but this session cannot see the other
workstation directly — run the commands in `docs/PHASE10_CANONICAL_STATE_AUDIT.md` §1.3
there. **Confirmed with Chase (Phase 10.1): the real Phase 9.5 pricing document lives on
PC #2, unpushed** — bringing that to Gitea would also close this item.
- [ ] **Secrets centralized (Infisical)** — MANUAL OWNER ACTION, one step. The "RootNode
Academy" project does exist (per Chase) — Phase 10.1's re-investigation (a proper API
enumeration, not just the CLI picker) found the `claude-code-migration` machine identity
simply isn't listed as having access to it. Grant it access via Project Settings → Access
→ Machine Identities. See `docs/SECRETS_AND_INFISICAL.md` for the exact identity id and
the full secret inventory ready to migrate in once granted.
- [ ] **Production server** — BLOCKED. Not purchased/provisioned — verified via empty
`terraform.tfvars`/no state file and placeholder `group_vars/production.yml`. See `docs/
PHASE10_PRODUCTION_DEPLOYMENT.md`.
- [ ] **Production VMs (rn-platform-01 / rn-lab-host-01)** — BLOCKED. Depends on the server above.
- [x] **Firewall/isolation (code)** — COMPLETE. `docs/PHASE10_SECURITY_AUDIT.md` — RFC1918
egress denial, mTLS wiring, firewall Ansible role all present and unchanged from Phase 4/
4.1; cannot be verified against a live host until one exists.
- [ ] **Production database** — BLOCKED. Depends on the server above. Migrations themselves
reconfirmed idempotent this phase (37/37, both a fresh apply and a same-DB restart).
- [x] **DNS (marketing site)** / [ ] **DNS (Platform API)** — PARTIAL. `rootnodeacademy.com` is
live and real (Cloudflare-fronted, confirmed this phase); no subdomain exists yet for the
Platform API itself — needs a real server first.
- [x] **HTTPS (marketing site)** / [ ] **HTTPS (Platform API)** — PARTIAL, same split as DNS.
- [x] **Monitoring (code)** — COMPLETE. `/healthz`/`/metrics` live and exercised in every
regression run this phase; deployment itself needs the production server.
- [x] **Backups (code)** / [ ] **Backups (executed)** — PARTIAL. Ansible `backup` role exists,
never run against a real host.
- [ ] **Restore test** — BLOCKED. Never executed — no target exists. A backup that has never
been restore-tested does not count as launch-ready.
- [x] **Stripe TEST validation** — COMPLETE. A real Stripe TEST Checkout Session, a real TEST
payment at the actual $399.00 price, a genuine signed webhook, the existing
registration→enrollment conversion, a real duplicate-delivery proof, a real automated
abandoned-checkout proof, and a real refund with the non-cascade rule reconfirmed — all
proven end to end in Phase 9.1 (`docs/PHASE9_1_STRIPE_TEST_VALIDATION.md`). One real
defect (webhook-event correlation) was found and fixed as a direct result.
- [ ] **Stripe LIVE configuration** — BLOCKED. Not configured, not attempted — requires Chase's
own Stripe account business/identity verification (only Chase can do this) plus real LIVE
credentials placed in Infisical (blocked on the missing project above), never invented or
substituted by this phase.
- [ ] **Live webhook configuration** — BLOCKED. Depends on LIVE credentials and a real HTTPS
Platform API endpoint, neither of which exist yet.
- [x] **Google Workspace** — COMPLETE. `[email protected]`, `gmail.send` scope,
domain-wide delegation — validated in Phase 5C (`docs/
RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`); deployment to production needs the real server.
- [x] **Website integration** — COMPLETE, both sides. Full contract tested end to end on the
platform side, including against real Stripe. The website itself (a separate repository,
`ced4568/RootNode-academy-website` on GitHub — not this repo) now has the actual
registration/agreement/checkout/status flow built against that contract, and its own
pricing has been corrected to match `docs/ROOTNODE_PRICING_AND_OFFERS_V1.md`. It has
nothing to talk to yet — `ROOTNODE_API_URL`/`ROOTNODE_INTEGRATION_KEY` are unset until a
real Platform API exists (every route fails a clean 503, never a 500, in the meantime).
- [x] **Winter Break price** — COMPLETE. **$399.00 launch ("Founding Cohort") / $449.00
standard reference, paid in full, no deposit, no payment plan** — founder-approved by
Chase directly, validated against real Stripe TEST mode at this exact amount, reconfirmed
current in Phase 10.1. Note: `rootnodeacademy.com` itself currently shows different,
stale founding-price RANGES (not this fixed figure) — Chase confirmed this repo's price is
the correct one; updating the live site is separate, not-RootNode-owned work.
- [ ] **Winter Break dates** — DECISION REQUIRED. Chase's call, not invented.
- [ ] **Venue** — DECISION REQUIRED. Chase's call (or confirm fully online/remote), not invented.
- [ ] **Capacity** — DECISION REQUIRED. Chase's call, not invented.
- [ ] **Agreements (legal text)** — BLOCKED. `agreement_definitions.body_summary` remains a
plain-language placeholder; no attorney-approved text exists for any required category
(program terms, guardian consent, participation waiver, acceptable-use/lab rules,
cancellation acknowledgment, privacy notice). See `docs/PHASE10_LAUNCH_READINESS.md` Gate
12 for the full taxonomy — none of it was authored or treated as approved by this phase.
- [ ] **Refund/cancellation policy** — DECISION REQUIRED. The platform's mechanical behavior
(record, never auto-cascade into registration/enrollment status) is real and proven
against actual Stripe money movement; the customer-facing POLICY text is Chase's decision.
- [ ] **Tax decision** — DECISION REQUIRED. No tax treatment is configured anywhere in this
codebase (Phase 9's own explicit "no complex tax automation" boundary) — this is not an
implicit "no tax owed" determination, it requires Chase or a professional to actually
decide.
- [ ] **Production offering** — BLOCKED. Cannot legitimately open registration until dates/
venue/capacity/agreements/refund policy/tax are all resolved — the mechanism itself
(`PATCH /api/admin/offerings/:id`, transition to `enrollment_open`) is ready the moment
they are.
- [ ] **Instructor assignment** — MANUAL OWNER ACTION. Mechanism ready
(`instructor_offerings`); Chase assigns the real instructor once the real offering exists.
- [x] **Roster process** — COMPLETE. Rehearsed end to end (classroom session, real Lab task,
instructor visibility) in this phase's dress rehearsal, 13/13.
- [x] **Student activation** — COMPLETE. Proven real twice — Phase 9.1's real-Stripe run and
this phase's deterministic rehearsal.
- [x] **Lab capacity (mechanism)** / [ ] **Lab capacity (real sizing)** — PARTIAL. Race-safe
capacity logic is proven; real-world capacity planning for actual concurrent students
needs the real server's actual specs, unknown until Gate "production server" resolves.
- [x] **Complete dress rehearsal** — COMPLETE (deterministic). 13/13 this phase, against
current `main`. Cannot yet be repeated against the REAL Winter Break offering because that
offering cannot legitimately open (dates/venue/capacity/agreements/policy/tax all pending).
## Final go/no-go
## PHASE 10 PARTIAL / WINTER BREAK LAUNCH BLOCKED
Registration must not open to the public until every BLOCKED and DECISION REQUIRED item above
is resolved. Every COMPLETE item has real evidence behind it, not a self-assessment — see
`docs/PHASE10_LAUNCH_READINESS.md`'s full matrix. **Even once every gate above passes, this
platform will not open registration on its own — that action requires Chase's explicit,
separate authorization, given directly, not inferred from a checklist being green.**
+3
View File
@@ -10,6 +10,7 @@ import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView' import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView' import ClassroomView from './ClassroomView'
import ProgramsView from './ProgramsView' import ProgramsView from './ProgramsView'
import OfferingsView from './OfferingsView'
import NotificationsView from './NotificationsView' import NotificationsView from './NotificationsView'
import CurriculumView from './CurriculumView' import CurriculumView from './CurriculumView'
import styles from './Admin.module.css' import styles from './Admin.module.css'
@@ -25,6 +26,7 @@ const NAV = [
{ key: 'cohorts', label: 'Cohorts' }, { key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' }, { key: 'students', label: 'Students' },
{ key: 'programs', label: 'Programs' }, { key: 'programs', label: 'Programs' },
{ key: 'offerings', label: 'Offerings' },
{ key: 'curriculum', label: 'Curriculum' }, { key: 'curriculum', label: 'Curriculum' },
{ key: 'classroom', label: 'Classroom' }, { key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' }, { key: 'learning', label: 'Learning' },
@@ -68,6 +70,7 @@ export default function AdminPortal() {
{view === 'cohorts' && <CohortsView role={instructor.role} />} {view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />} {view === 'students' && <StudentsView role={instructor.role} />}
{view === 'programs' && <ProgramsView role={instructor.role} />} {view === 'programs' && <ProgramsView role={instructor.role} />}
{view === 'offerings' && <OfferingsView role={instructor.role} />}
{view === 'curriculum' && <CurriculumView />} {view === 'curriculum' && <CurriculumView />}
{view === 'classroom' && <ClassroomView />} {view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />} {view === 'learning' && <LearningView />}
+474
View File
@@ -0,0 +1,474 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Phase 8 §1-§5 — the Offerings operations console: list/create/detail, launch readiness,
// registrations/roster, and agreement requirements. Authoring (create/transition/agreement
// requirements) is admin/owner only, matching every other authoring surface in this app
// (ProgramsView/AssessmentsView's own precedent) — canAuthor gates writes only, not viewing.
const OFFERING_TRANSITIONS = {
draft: ['published', 'cancelled'],
published: ['enrollment_open', 'cancelled'],
enrollment_open: ['full', 'in_progress', 'cancelled'],
full: ['enrollment_open', 'in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: [],
cancelled: [],
}
function statusPillClass(status) {
if (['enrollment_open', 'in_progress', 'completed'].includes(status)) return styles.pillOk
if (status === 'full') return styles.pillWarn
if (status === 'cancelled') return styles.pillErr
return styles.pillMuted
}
export default function OfferingsView({ role }) {
const [offerings, setOfferings] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canAuthor = role === 'owner' || role === 'admin'
const reload = useCallback(() => {
adminApi.offerings().then(setOfferings).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <OfferingDetail offeringId={selected} canAuthor={canAuthor} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
{canAuthor && (
<div className={styles.card}>
<h2>Create offering</h2>
<CreateOfferingForm onCreated={(id) => { reload(); setSelected(id) }} />
</div>
)}
<div className={styles.card}>
<h2>Offerings</h2>
{!offerings ? <p className={styles.muted}>Loading…</p> : offerings.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Name</th><th>Program</th><th>Delivery</th><th>Status</th><th>Seats</th><th /></tr></thead>
<tbody>
{offerings.map((o) => (
<tr key={o.offering_id}>
<td>{o.name}</td>
<td className={styles.muted}>{o.program_id}</td>
<td className={styles.muted}>{o.delivery_mode.replace('_', ' ')}</td>
<td><span className={`${styles.pill} ${statusPillClass(o.status)}`}>{o.status.replace('_', ' ')}</span></td>
<td className={styles.muted}>{o.capacity === null ? 'unlimited' : `${o.reserved_seats}/${o.capacity}`}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(o.offering_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateOfferingForm({ onCreated }) {
const [programs, setPrograms] = useState([])
const [locations, setLocations] = useState([])
const [form, setForm] = useState({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
adminApi.programs().then(setPrograms).catch(() => {})
adminApi.locations().then(setLocations).catch(() => {})
}, [])
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const offering = await adminApi.createOffering({
offeringId: form.offering_id, slug: form.slug, name: form.name, programId: form.program_id, deliveryMode: form.delivery_mode,
capacity: form.capacity === '' ? undefined : Number(form.capacity),
locationId: form.location_id || undefined,
recommendedMinAge: form.recommended_min_age === '' ? undefined : Number(form.recommended_min_age),
recommendedMaxAge: form.recommended_max_age === '' ? undefined : Number(form.recommended_max_age),
})
setForm({ offering_id: '', slug: '', name: '', program_id: '', delivery_mode: 'camp', capacity: '', location_id: '', recommended_min_age: '', recommended_max_age: '' })
onCreated(offering.offering_id)
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Offering ID (slug-like)</label>
<input className={styles.input} value={form.offering_id} onChange={(e) => setForm({ ...form, offering_id: e.target.value })} required /></div>
<div><label className={styles.label}>URL slug</label>
<input className={styles.input} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required /></div>
<div><label className={styles.label}>Name</label>
<input className={styles.input} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
</div>
<div className={styles.formRow}>
<div><label className={styles.label}>Program</label>
<select className={styles.select} value={form.program_id} onChange={(e) => setForm({ ...form, program_id: e.target.value })} required>
<option value="">Select a program…</option>
{programs.map((p) => <option key={p.program_id} value={p.program_id}>{p.name}</option>)}
</select></div>
<div><label className={styles.label}>Delivery mode</label>
<select className={styles.select} value={form.delivery_mode} onChange={(e) => setForm({ ...form, delivery_mode: e.target.value })}>
{['self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one'].map((m) => <option key={m} value={m}>{m.replace('_', ' ')}</option>)}
</select></div>
<div><label className={styles.label}>Capacity (blank = unlimited)</label>
<input className={styles.input} type="number" min="1" value={form.capacity} onChange={(e) => setForm({ ...form, capacity: e.target.value })} /></div>
<div><label className={styles.label}>Location (optional)</label>
<select className={styles.select} value={form.location_id} onChange={(e) => setForm({ ...form, location_id: e.target.value })}>
<option value="">None / online</option>
{locations.map((l) => <option key={l.location_id} value={l.location_id}>{l.name}</option>)}
</select></div>
</div>
<div className={styles.formRow}>
<div><label className={styles.label}>Recommended min age (guidance only)</label>
<input className={styles.input} type="number" value={form.recommended_min_age} onChange={(e) => setForm({ ...form, recommended_min_age: e.target.value })} /></div>
<div><label className={styles.label}>Recommended max age (guidance only)</label>
<input className={styles.input} type="number" value={form.recommended_max_age} onChange={(e) => setForm({ ...form, recommended_max_age: e.target.value })} /></div>
</div>
<p className={styles.muted}>Age fields are display guidance only — never an access gate. Dates, registration window, and organization can be set later from the offering's own detail page.</p>
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy}>{busy ? 'Creating…' : 'Create draft offering'}</button>
</form>
)
}
function OfferingDetail({ offeringId, canAuthor, onBack }) {
const [offering, setOffering] = useState(null)
const [readiness, setReadiness] = useState(null)
const [registrations, setRegistrations] = useState(null)
const [agreementReqs, setAgreementReqs] = useState(null)
const [allAgreements, setAllAgreements] = useState([])
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [selectedReg, setSelectedReg] = useState(null)
const reload = useCallback(() => {
adminApi.offering(offeringId).then(setOffering).catch((e) => setError(e.message))
adminApi.offeringReadiness(offeringId).then(setReadiness).catch(() => {})
adminApi.offeringRegistrations(offeringId).then(setRegistrations).catch(() => {})
adminApi.offeringAgreementRequirements(offeringId).then(setAgreementReqs).catch(() => {})
adminApi.agreements().then(setAllAgreements).catch(() => {})
}, [offeringId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (selectedReg) return <RegistrationDetail registrationId={selectedReg} canAuthor={canAuthor} onBack={() => { setSelectedReg(null); reload() }} />
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to offerings</button>
{!offering ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{offering.name} <span className={`${styles.pill} ${statusPillClass(offering.status)}`}>{offering.status.replace('_', ' ')}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h3>Overview</h3>
<p className={styles.muted}>{offering.description || 'No description.'}</p>
<p>Program: {offering.program_id} — Delivery: {offering.delivery_mode.replace('_', ' ')} — Seats: {offering.capacity === null ? 'unlimited' : `${offering.reserved_seats} / ${offering.capacity}`}</p>
{canAuthor && (OFFERING_TRANSITIONS[offering.status] || []).length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
{(OFFERING_TRANSITIONS[offering.status] || []).map((next) => (
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
onClick={withBusy(() => adminApi.transitionOffering(offeringId, next))}>
{next === 'cancelled' ? 'Cancel offering' : `Move to ${next.replace('_', ' ')}`}
</button>
))}
</div>
)}
</div>
<div className={styles.card}>
<h3>Pricing</h3>
<p>{offering.price_amount === null ? <span className={styles.muted}>Free — no checkout required.</span> : <>{(offering.price_amount / 100).toFixed(2)} {offering.price_currency.toUpperCase()}</>}</p>
{canAuthor && <SetPriceForm offeringId={offeringId} current={offering} onSaved={reload} />}
</div>
<div className={styles.card}>
<h3>Launch readiness</h3>
{!readiness ? <p className={styles.muted}>Loading…</p> : (
<>
<p><span className={`${styles.pill} ${readiness.ready_to_open_registration ? styles.pillOk : styles.pillWarn}`}>
{readiness.ready_to_open_registration ? 'Ready to open registration' : 'Not yet ready'}
</span></p>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{readiness.checks.map((c) => (
<li key={c.key} className={styles.muted}>{c.ready ? '✓' : '✗'} {c.label}</li>
))}
</ul>
</>
)}
</div>
<div className={styles.card}>
<h3>Required agreements</h3>
{!agreementReqs ? <p className={styles.muted}>Loading…</p> : agreementReqs.length === 0 ? <p className={styles.muted}>None configured — registrations can convert without any agreement acceptance.</p> : (
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{agreementReqs.map((a) => <li key={a.agreement_id} className={styles.muted}>{a.title} (v{a.version}) {a.requires_guardian_for_minor && <span className={styles.pill}>guardian required for minors</span>}</li>)}
</ul>
)}
{canAuthor && <AddAgreementRequirementForm offeringId={offeringId} allAgreements={allAgreements} onAdded={reload} />}
</div>
<div className={styles.card}>
<h3>Registrations / roster</h3>
{canAuthor && <CreateRegistrationForm offeringId={offeringId} onCreated={reload} />}
{!registrations ? <p className={styles.muted}>Loading…</p> : registrations.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Registrant</th><th>Type</th><th>Status</th><th /></tr></thead>
<tbody>
{registrations.map((r) => (
<tr key={r.registration_id}>
<td>{r.registrant_display_name}</td>
<td className={styles.muted}>{r.is_minor ? 'minor (guardian)' : 'adult'}</td>
<td><span className={`${styles.pill} ${r.status === 'enrollment_activated' ? styles.pillOk : r.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{r.status.replace('_', ' ')}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelectedReg(r.registration_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
function CreateRegistrationForm({ offeringId, onCreated }) {
const [form, setForm] = useState({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
let guardianId
if (form.is_minor) {
guardianId = `guardian-${Date.now()}`
await adminApi.createGuardian({ guardianId, displayName: form.guardian_display_name || form.guardian_email, email: form.guardian_email })
}
await adminApi.createRegistration(offeringId, {
registrantDisplayName: form.registrant_display_name, registrantEmail: form.registrant_email || undefined,
isMinor: form.is_minor, guardianId,
})
setForm({ registrant_display_name: '', registrant_email: '', is_minor: false, guardian_display_name: '', guardian_email: '' })
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit} style={{ marginBottom: '1rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Registrant name</label>
<input className={styles.input} value={form.registrant_display_name} onChange={(e) => setForm({ ...form, registrant_display_name: e.target.value })} required /></div>
<div><label className={styles.label}>Registrant type</label>
<select className={styles.select} value={form.is_minor ? 'minor' : 'adult'} onChange={(e) => setForm({ ...form, is_minor: e.target.value === 'minor' })}>
<option value="adult">Adult (self-registering)</option>
<option value="minor">Minor (guardian registers)</option>
</select></div>
{!form.is_minor && (
<div><label className={styles.label}>Registrant email</label>
<input className={styles.input} type="email" value={form.registrant_email} onChange={(e) => setForm({ ...form, registrant_email: e.target.value })} /></div>
)}
</div>
{form.is_minor && (
<div className={styles.formRow}>
<div><label className={styles.label}>Guardian name</label>
<input className={styles.input} value={form.guardian_display_name} onChange={(e) => setForm({ ...form, guardian_display_name: e.target.value })} required /></div>
<div><label className={styles.label}>Guardian email</label>
<input className={styles.input} type="email" value={form.guardian_email} onChange={(e) => setForm({ ...form, guardian_email: e.target.value })} required /></div>
</div>
)}
<button className={styles.btn} disabled={busy || !form.registrant_display_name.trim()}>{busy ? 'Registering…' : 'Add registration'}</button>
</form>
)
}
function SetPriceForm({ offeringId, current, onSaved }) {
const [amount, setAmount] = useState(current.price_amount === null ? '' : (current.price_amount / 100).toFixed(2))
const [currency, setCurrency] = useState(current.price_currency || 'usd')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.updateOffering(offeringId, {
priceAmount: amount === '' ? null : Math.round(Number(amount) * 100),
priceCurrency: currency,
})
onSaved()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit} style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div><label className={styles.label}>Price (blank = free, no checkout)</label>
<input className={styles.input} type="number" min="0" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} /></div>
<div><label className={styles.label}>Currency</label>
<input className={styles.input} value={currency} onChange={(e) => setCurrency(e.target.value.toLowerCase())} style={{ width: '5rem' }} /></div>
<button className={styles.btnSecondary} disabled={busy}>Save price</button>
</form>
)
}
function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) {
const [agreementId, setAgreementId] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!agreementId) return
setBusy(true); setError(null)
try { await adminApi.addOfferingAgreementRequirement(offeringId, agreementId); setAgreementId(''); onAdded() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit} style={{ marginTop: '0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div><label className={styles.label}>Require an agreement</label>
<select className={styles.select} value={agreementId} onChange={(e) => setAgreementId(e.target.value)}>
<option value="">Select…</option>
{allAgreements.map((a) => <option key={a.agreement_id} value={a.agreement_id}>{a.title}</option>)}
</select></div>
<button className={styles.btnSecondary} disabled={busy || !agreementId}>Add requirement</button>
</form>
)
}
function RegistrationDetail({ registrationId, canAuthor, onBack }) {
const [reg, setReg] = useState(null)
const [agreementStatus, setAgreementStatus] = useState(null)
const [notifs, setNotifs] = useState(null)
const [payments, setPayments] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const REG_TRANSITIONS = {
started: ['submitted', 'cancelled'], submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'], agreements_pending: ['payment_pending', 'confirmed', 'cancelled'],
payment_pending: ['confirmed', 'cancelled'], confirmed: ['enrollment_activated', 'cancelled'], enrollment_activated: [], cancelled: [],
}
const reload = useCallback(() => {
adminApi.registration(registrationId).then(setReg).catch((e) => setError(e.message))
adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {})
adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {})
adminApi.registrationPayments(registrationId).then(setPayments).catch(() => {})
}, [registrationId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to offering</button>
{!reg ? <p className={styles.muted}>Loading…</p> : (
<>
<h2 style={{ margin: '1rem 0' }}>{reg.registrant_display_name} <span className={styles.pill}>{reg.status.replace('_', ' ')}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Registration</h3>
<p>Type: {reg.is_minor ? 'Minor (guardian-registered)' : 'Adult (self-registered)'}</p>
{reg.registrant_email && <p className={styles.muted}>Contact: {reg.registrant_email}</p>}
{reg.readiness_notes && <p className={styles.muted}>Readiness notes: {reg.readiness_notes}</p>}
{canAuthor && (REG_TRANSITIONS[reg.status] || []).length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
{(REG_TRANSITIONS[reg.status] || []).map((next) => (
<button key={next} className={styles.btnSecondary} style={{ marginRight: '0.5rem' }} disabled={busy}
onClick={withBusy(() => adminApi.transitionRegistration(registrationId, next))}>
{next === 'cancelled' ? 'Cancel' : next.replace('_', ' ')}
</button>
))}
{reg.status === 'confirmed' && (
<button className={styles.btn} disabled={busy} onClick={withBusy(async () => { const r = await adminApi.convertRegistration(registrationId); setNotice(`Enrolled as ${r.student.student_id}.`) })}>
Convert to enrollment
</button>
)}
</div>
)}
</div>
<div className={styles.card}>
<h3>Agreements</h3>
{!agreementStatus ? <p className={styles.muted}>Loading…</p> : agreementStatus.required.length === 0 ? <p className={styles.muted}>No agreements required for this offering.</p> : (
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{agreementStatus.required.map((a) => (
<li key={a.agreement_id} className={styles.muted}>
{agreementStatus.missing.some((m) => m.agreement_id === a.agreement_id) ? '✗ missing' : '✓ accepted'} — {a.title}
</li>
))}
</ul>
)}
</div>
<div className={styles.card}>
<h3>Payments</h3>
{!payments ? <p className={styles.muted}>Loading…</p> : payments.length === 0 ? <p className={styles.muted}>No checkout attempted — this offering may be free, or checkout hasn't started yet.</p> : (
<table>
<thead><tr><th>Amount</th><th>Status</th><th>Provider</th><th>Paid</th><th /></tr></thead>
<tbody>
{payments.map((p) => (
<tr key={p.transaction_id}>
<td>{(p.amount / 100).toFixed(2)} {p.currency.toUpperCase()}</td>
<td><span className={`${styles.pill} ${p.status === 'paid' ? styles.pillOk : p.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{p.status.replace('_', ' ')}</span></td>
<td className={styles.muted}>{p.provider}</td>
<td className={styles.muted}>{p.paid_at ? new Date(p.paid_at).toLocaleString() : '—'}</td>
<td>
{canAuthor && p.status === 'paid' && (
<button className={styles.linkBtn} onClick={withBusy(() => adminApi.recordRefund(p.transaction_id, 'refunded'))}>Record refund</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Communications</h3>
{!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : (
<table>
<thead><tr><th>Type</th><th>To</th><th>Status</th><th>Sent</th></tr></thead>
<tbody>
{notifs.map((n) => (
<tr key={n.notification_id}>
<td className={styles.muted}>{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.sent_at ? new Date(n.sent_at).toLocaleString() : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
)
}
+36
View File
@@ -138,4 +138,40 @@ export const adminApi = {
// ── Phase 6A §7: course/module/activity curriculum view ────────────────────────────────── // ── Phase 6A §7: course/module/activity curriculum view ──────────────────────────────────
programCourses: (programId) => get(`/api/admin/programs/${programId}/courses`), programCourses: (programId) => get(`/api/admin/programs/${programId}/courses`),
studentCourses: (studentId, programId) => get(`/api/admin/students/${studentId}/courses?program_id=${encodeURIComponent(programId)}`), studentCourses: (studentId, programId) => get(`/api/admin/students/${studentId}/courses?program_id=${encodeURIComponent(programId)}`),
// ── Phase 7/8: offerings, registrations, guardians, agreements ──────────────────────────
offerings: (status) => get(`/api/admin/offerings${status ? `?status=${status}` : ''}`),
offering: (id) => get(`/api/admin/offerings/${id}`),
createOffering: (fields) => post('/api/admin/offerings', fields),
transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }),
updateOffering: (id, fields) => patch(`/api/admin/offerings/${id}`, fields),
registrationPayments: (id) => get(`/api/admin/registrations/${id}/payments`),
recordRefund: (transactionId, status) => post(`/api/admin/payments/${transactionId}/refund`, { status }),
offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`),
assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }),
offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`),
addOfferingAgreementRequirement: (id, agreement_id) => post(`/api/admin/offerings/${id}/agreement-requirements`, { agreement_id }),
organizations: () => get('/api/admin/organizations'),
createOrganization: (fields) => post('/api/admin/organizations', fields),
locations: () => get('/api/admin/locations'),
createLocation: (fields) => post('/api/admin/locations', fields),
curriculumPackage: (id) => get(`/api/admin/curriculum-packages/${id}`),
createCurriculumPackage: (fields) => post('/api/admin/curriculum-packages', fields),
addCurriculumPackageItem: (id, fields) => post(`/api/admin/curriculum-packages/${id}/items`, fields),
offeringRegistrations: (offeringId) => get(`/api/admin/offerings/${offeringId}/registrations`),
createRegistration: (offeringId, fields) => post(`/api/admin/offerings/${offeringId}/registrations`, fields),
registration: (id) => get(`/api/admin/registrations/${id}`),
transitionRegistration: (id, status) => post(`/api/admin/registrations/${id}/transition`, { status }),
convertRegistration: (id) => post(`/api/admin/registrations/${id}/convert`, {}),
registrationAgreementStatus: (id) => get(`/api/admin/registrations/${id}/agreement-status`),
registrationNotifications: (id) => get(`/api/admin/registrations/${id}/notifications`),
acceptAgreementForRegistration: (id, fields) => post(`/api/admin/registrations/${id}/agreements`, fields),
agreements: () => get('/api/admin/agreements'),
createAgreement: (fields) => post('/api/admin/agreements', fields),
createGuardian: (fields) => post('/api/admin/guardians', fields),
studentGuardians: (studentId) => get(`/api/admin/students/${studentId}/guardians`),
linkStudentGuardian: (studentId, fields) => post(`/api/admin/students/${studentId}/guardians`, fields),
} }
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Orchestrates the Phase 7 Step 31 Winter Break Camp internal rehearsal: brings up the real dev
# stack, bootstraps an owner, runs test-phase7-winter-camp.js (real offering/registration/
# guardian/agreement/enrollment/cohort/classroom/Lab flow, no payment code), 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}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
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] "P7 Winter Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
node ../infra/scripts/drills/test-phase7-winter-camp.js
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Orchestrates the Phase 8 Step 32 Winter Break commercial-path rehearsal: brings up the real
# dev stack, bootstraps an owner, runs test-phase8-winter-camp-registration.js (real website
# registration submission, guardian creation, agreement acceptance, simulated payment
# confirmation, real enrollment/cohort/classroom/Lab), 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}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
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] "P8 Winter Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \
node ../infra/scripts/drills/test-phase8-winter-camp-registration.js
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Orchestrates the Phase 9 Step 30 Winter Break commercial rehearsal: brings up the real dev
# stack, bootstraps an owner, runs test-phase9-winter-camp-commerce.js (real checkout/webhook
# processing against the deterministic test payment provider — no live Stripe, no real money),
# 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}"
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
export PAYMENT_PROVIDER="${PAYMENT_PROVIDER:-test}"
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] "P9 Owner" 'RehearsalPass!2026' )
echo "==> Running the rehearsal"
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' INTEGRATION_SHARED_SECRET="$INTEGRATION_SHARED_SECRET" \
node ../infra/scripts/drills/test-phase9-winter-camp-commerce.js
@@ -0,0 +1,233 @@
#!/usr/bin/env node
// Phase 7 Step 31 — the Winter Break Camp internal operational rehearsal, without payments.
// Proves the full path: admin creates a draft offering (Winter Break Linux & Systems Camp,
// matching docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md's Option A: ROOT 01-02) with a real
// curriculum package, location, and instructor assignment; a guardian registers a minor and an
// adult registers themselves; both registrations are walked through agreements and a simulated
// commerce confirmation (admin-side only — no payment code exists or is invoked); both convert
// to real enrollments and real cohort membership; the instructor runs a real classroom session;
// the minor's own Lab work is done for real (the same linux-inspect-hardware checkpoint the
// Phase 6D/6F rehearsals already established); progress is confirmed to survive; the offering
// is marked complete.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
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 moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
async function main() {
console.log('Phase 7 Winter Break Camp internal rehearsal — no payments, real everything else\n');
const owner = makeSession(GW);
await step('owner logs in', async () => {
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
assert.equal(r.status, 200);
});
const orgId = `winter-org-${RUN_ID}`, locId = `winter-loc-${RUN_ID}`, pkgId = `winter-linux-systems-${RUN_ID}`, cohortId = `winter-cohort-${RUN_ID}`, offeringId = `winter-camp-${RUN_ID}`;
await step('admin creates the venue (organization + location)', async () => {
const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'Oxford Community Library', type: 'library' });
assert.equal(org.status, 201);
const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'Community Room B', type: 'community_center', capacity: 15 });
assert.equal(loc.status, 201);
});
await step('admin creates the curriculum package — ROOT 01-02, matching Winter Break Camp Option A, referencing existing content', async () => {
const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'Winter Linux & Systems Foundations', programId: PROGRAM, description: 'ROOT 01-02 — see docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md Option A' });
assert.equal(pkg.status, 201);
for (const [courseId, sequence] of [['root-01-computing-linux-foundations', 1], ['root-02-linux-administration-networking', 2]]) {
const item = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId, sequence });
assert.equal(item.status, 201);
}
});
await step('admin creates the real cohort this camp will roster into', async () => {
const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Camp Cohort', tier: 'root' });
assert.equal(cohort.status, 201);
});
await step('admin creates the DRAFT offering — dates/price/exact seat count left unset, exactly as authorized', async () => {
const offering = await owner.request('POST', '/api/admin/offerings', {
offeringId, slug: `winter-linux-systems-camp-${RUN_ID}`, name: 'Winter Break Linux & Systems Foundations Camp',
programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 12,
locationId: locId, organizationId: orgId, cohortId,
recommendedMinAge: 13, recommendedMaxAge: null, // guidance only — see the age-independence test in e2e-phase7.js
});
assert.equal(offering.status, 201, JSON.stringify(offering.body));
assert.equal(offering.body.status, 'draft');
});
let instructorId;
await step('admin creates and assigns the lead instructor', async () => {
const create = await owner.request('POST', '/api/admin/instructors', { email: `winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Camp Instructor', role: 'instructor', password: 'WinterCampPass123!' });
assert.equal(create.status, 201);
instructorId = create.body.instructor_id;
const link = await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorId, role: 'teacher' });
assert.equal(link.status, 201);
// The offering-level assignment is Phase 7's own new concept; the cohort-level one (the
// real RBAC scoping mechanism a classroom session actually checks) is Phase 2A/4.1,
// unchanged — both are assigned so the instructor genuinely has classroom access below.
const cohortLink = await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorId, role_in_cohort: 'teacher' });
assert.equal(cohortLink.status, 200, JSON.stringify(cohortLink.body));
});
await step('admin publishes the offering and opens registration', async () => {
const pub = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
assert.equal(pub.status, 200);
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
assert.equal(open.status, 200);
});
const guardianId = `winter-guardian-${RUN_ID}`;
await step('a guardian registers a minor student for the camp', async () => {
const guardian = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'Winter Camp Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0100' });
assert.equal(guardian.status, 201);
global.__minorReg = (await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, {
guardianId, registrantDisplayName: 'Winter Camp Minor', isMinor: true,
emergencyContactName: 'Winter Camp Guardian', emergencyContactPhone: '555-0100',
readinessNotes: 'No prior Linux experience, comfortable with a keyboard.',
})).body;
assert.ok(global.__minorReg.registration_id);
});
await step('an adult registers themselves for the same camp', async () => {
global.__adultReg = (await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, {
registrantDisplayName: 'Winter Camp Adult', registrantEmail: `winter-adult-${RUN_ID}@example.com`,
})).body;
assert.ok(global.__adultReg.registration_id);
});
await step('both registrations move through submission and agreement acceptance', async () => {
const agreementId = `winter-participation-${RUN_ID}`;
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement', bodySummary: 'Plain-language summary — not legal text, see docs/STUDENT_GUARDIAN_DATA_MODEL.md.' });
for (const reg of [global.__minorReg, global.__adultReg]) {
await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'agreements_pending' });
}
// The minor's guardian accepts on the minor's behalf — the guardian identity already
// exists at registration time. The adult accepts as a guardian-style acceptance too here
// (accepted_by_guardian_id is really just "who accepted," not guardian-exclusive) since an
// adult registrant has no student_id yet until conversion below — a real, if slightly
// unusual, present-tense fact about this lifecycle worth noting: self-acceptance as
// accepted_by_type='student' is only possible AFTER conversion, when a real student_id
// exists (an adult could re-accept post-activation in a future UI; not required by V1).
const minorAccept = await owner.request('POST', `/api/admin/registrations/${global.__minorReg.registration_id}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId });
assert.equal(minorAccept.status, 201);
});
await step('a simulated commerce confirmation (admin-only — no payment code exists or is called)', async () => {
for (const reg of [global.__minorReg, global.__adultReg]) {
const r = await owner.request('POST', `/api/admin/registrations/${reg.registration_id}/transition`, { status: 'confirmed' });
assert.equal(r.status, 200, JSON.stringify(r.body));
}
});
let minorStudent, adultStudent, minorSession, adultSession;
await step('both registrations convert to real enrollments and real cohort membership', async () => {
const minorConvert = await owner.request('POST', `/api/admin/registrations/${global.__minorReg.registration_id}/convert`, {});
assert.equal(minorConvert.status, 201);
minorStudent = minorConvert.body.student;
assert.ok(minorStudent._activationToken, 'the instructor must have a real token to relay to a no-email minor');
const adultConvert = await owner.request('POST', `/api/admin/registrations/${global.__adultReg.registration_id}/convert`, {});
assert.equal(adultConvert.status, 201);
adultStudent = adultConvert.body.student;
assert.ok(adultStudent._activationToken, 'the adult also gets a real token, delivered to their own email in production');
const rosterCheck = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.equal(rosterCheck.status, 200);
const ids = rosterCheck.body.map((s) => s.student_id);
assert.ok(ids.includes(minorStudent.student_id) && ids.includes(adultStudent.student_id), 'both converted students must be on the real cohort roster');
});
await step('both students activate their real accounts using their real one-time tokens', async () => {
minorSession = makeSession(GW);
const minorActivate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterCampMinor123!' });
assert.equal(minorActivate.status, 200, JSON.stringify(minorActivate.body));
adultSession = makeSession(GW);
const adultActivate = await adultSession.request('POST', '/api/student/activate', { token: adultStudent._activationToken, password: 'WinterCampAdult123!' });
assert.equal(adultActivate.status, 200, JSON.stringify(adultActivate.body));
});
let instructorSession, classroomId, minorEnrollmentId;
await step('instructor starts a live classroom session and Prepares Labs for the cohort', async () => {
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
assert.equal(session.status, 201);
classroomId = session.body.classroom_session_id;
await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
assert.equal(prep.status, 200);
});
await step('the minor student joins the classroom and completes a real ROOT 01 practical', async () => {
const join = await minorSession.request('POST', `/api/classroom/${classroomId}/join`);
assert.equal(join.status, 200);
const { body: enrollBody } = await minorSession.request('GET', `/api/student/programs`);
minorEnrollmentId = enrollBody[0].enrollment_id;
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
}
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
assert.equal(launch.status, 200);
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200);
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
const containerName = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
const fs = require('fs');
const scriptPath = `/tmp/phase7-winter-hardware-${envRow.environment_id}.sh`;
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
fs.unlinkSync(scriptPath);
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true, 'expected a REAL pass, not a simulated one');
const record = await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
assert.equal(record.status, 200);
});
await step('the minor\'s progress survives logout/login (the actual academic-persistence guarantee this camp depends on)', async () => {
await minorSession.request('POST', '/api/logout');
const returning = makeSession(GW);
await returning.request('POST', '/api/student/login', { identifier: minorStudent.login_identifier, password: 'WinterCampMinor123!' });
const { body } = await returning.request('GET', `/api/student/programs/${minorEnrollmentId}/courses`);
const mod = body.courses.flatMap((c) => c.modules).find((m) => m.module_id === 'root-01-m01-how-computers-run-software');
assert.ok(mod.activities.some((a) => a.complete), 'the real Lab pass must have survived logout/login');
});
await step('instructor sees real progress and roster state for the camp', async () => {
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.equal(roster.status, 200);
assert.equal(roster.body.length, 2);
});
await step('the offering moves through in_progress to completed', async () => {
const inProgress = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' });
assert.equal(inProgress.status, 200);
const completed = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'completed' });
assert.equal(completed.status, 200);
assert.equal(completed.body.status, 'completed');
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,194 @@
#!/usr/bin/env node
// Phase 8 Step 32 — the Winter Break commercial-path rehearsal: the website submits real
// registrations (via the new /api/integrations/registrations contract), a guardian's agreement
// acceptance is recorded through the supported application flow, a future-payment confirmation
// is SIMULATED (admin-side transition only — no payment code anywhere), and the full remaining
// path (enrollment, cohort, activation, classroom, real Lab work, progress, instructor
// visibility) runs for real. Also proves the adult registration path and a genuine two-students-
// competing-for-the-final-seat race.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
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 moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function main() {
console.log('Phase 8 Winter Break commercial-path rehearsal — website submission, no real payment\n');
const owner = makeSession(GW);
await step('owner logs in', async () => {
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
assert.equal(r.status, 200);
});
const locId = `p8-winter-loc-${RUN_ID}`, cohortId = `p8-winter-cohort-${RUN_ID}`, offeringId = `p8-winter-camp-${RUN_ID}`, slug = `p8-winter-linux-systems-${RUN_ID}`, agreementId = `p8-winter-agreement-${RUN_ID}`;
await step('admin opens the Winter Break offering — location, capacity, instructor, agreement requirement configured', async () => {
await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' });
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' });
const create = await owner.request('POST', '/api/admin/offerings', {
offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp',
capacity: 2, locationId: locId, cohortId,
});
assert.equal(create.status, 201);
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-winter-instructor-${RUN_ID}@example.com`, display_name: 'Winter Instructor', role: 'instructor', password: 'WinterP8Pass123!' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' });
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
assert.ok(readiness.body.checks.find((c) => c.key === 'instructor').ready);
assert.ok(readiness.body.checks.find((c) => c.key === 'agreements').ready);
});
await step('admin publishes and opens registration', async () => {
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
assert.equal(open.status, 200);
});
let minorRegId, minorGuardianId;
await step('website integration submits a REAL minor registration (guardian created/associated), confirmation email queued', async () => {
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p8-winter-minor-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Winter Camper', is_minor: true },
guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' },
});
assert.equal(submit.status, 201, JSON.stringify(submit.body));
minorRegId = submit.body.registration_id;
const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`);
minorGuardianId = reg.body.guardian_id;
assert.ok(minorGuardianId, 'a real guardian must have been created and linked by the integration endpoint');
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
assert.ok(notifs.body.some((n) => n.notification_type === 'registration_received'), 'a registration_received confirmation must be queued');
});
await step('guardian acceptance is recorded through the supported application flow — agreement complete', async () => {
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'agreements_pending' });
const agreementNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
assert.ok(agreementNotifs.body.some((n) => n.notification_type === 'agreement_required'), 'an agreement_required notice must be queued when entering agreements_pending');
const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: minorGuardianId });
assert.equal(accept.status, 201);
const status = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreement-status`);
assert.equal(status.body.missing.length, 0);
});
await step('future payment confirmation is SIMULATED (admin-only transition) — no payment code anywhere', async () => {
const confirm = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
assert.equal(confirm.status, 200);
const confirmedNotifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
assert.ok(confirmedNotifs.body.some((n) => n.notification_type === 'registration_confirmed'));
});
let minorStudent, minorSession, minorEnrollmentId;
await step('registration converts to real enrollment + real cohort membership; account activates', async () => {
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
assert.equal(convert.status, 201, JSON.stringify(convert.body));
minorStudent = convert.body.student;
assert.ok(minorStudent._activationToken);
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.ok(roster.body.some((s) => s.student_id === minorStudent.student_id));
minorSession = makeSession(GW);
const activate = await minorSession.request('POST', '/api/student/activate', { token: minorStudent._activationToken, password: 'WinterP8Minor123!' });
assert.equal(activate.status, 200);
});
await step('adult registration path — self-registers, no guardian required, converts independently', async () => {
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p8-winter-adult-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false },
});
assert.equal(submit.status, 201);
const regId = submit.body.registration_id;
await owner.request('POST', `/api/admin/registrations/${regId}/agreements`, { agreementId, acceptedByType: 'registrant' });
await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${regId}/transition`, { status: 'confirmed' });
const convert = await owner.request('POST', `/api/admin/registrations/${regId}/convert`, {});
assert.equal(convert.status, 201);
assert.equal(convert.body.student.email, `winter-adult-${RUN_ID}@example.com`);
});
await step('two students compete for the final seat — exactly one wins, offering reports full, no overbooking', async () => {
const before = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
assert.equal(before.body.reserved_seats, before.body.capacity, 'capacity=2 should already be exhausted by the minor + adult above');
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `p8-race-a-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p8-winter-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `p8-race-b-${RUN_ID}@example.com` } }),
]);
const statuses = [a.status, b.status].sort();
assert.deepEqual(statuses, [409, 409], 'both must be rejected — the seat was already full before either request, this proves no overbooking beyond capacity');
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
assert.equal(pub.availability, 'full');
});
await step('instructor starts a live classroom session and Prepares Labs', async () => {
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
assert.equal(session.status, 201);
global.__classroomId = session.body.classroom_session_id;
await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`);
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
assert.equal(prep.status, 200);
});
await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => {
const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`);
assert.equal(join.status, 200);
const { body: enrollBody } = await minorSession.request('GET', '/api/student/programs');
minorEnrollmentId = enrollBody[0].enrollment_id;
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
}
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
assert.equal(launch.status, 200);
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200);
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
const containerName = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
const fs = require('fs');
const scriptPath = `/tmp/phase8-winter-hardware-${envRow.environment_id}.sh`;
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
fs.unlinkSync(scriptPath);
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true);
await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
});
await step('instructor sees the real roster and progress', async () => {
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.equal(roster.body.length, 2);
const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`);
const mod = moduleById(body, 'root-01-m01-how-computers-run-software');
assert.ok(mod.activities.some((a) => a.complete), 'the instructor must see the minor\'s real Lab progress');
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,249 @@
#!/usr/bin/env node
// Phase 9 Step 30 — the full Winter Break commercial rehearsal, WITH real checkout/webhook
// processing this time (Phase 8's rehearsal simulated payment confirmation via a plain admin
// transition; this one drives the actual commerce path end to end). Uses the deterministic
// 'test' payment provider — no live money, no real Stripe credentials anywhere in this script.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const { getPaymentProvider } = require('../../../platform/api/src/payment-provider');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
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 moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function webhookPost(body, signature) {
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function main() {
console.log('Phase 9 Winter Break commercial rehearsal — real checkout/webhook, test provider, no live money\n');
const provider = getPaymentProvider();
assert.equal(provider.name, 'test');
const owner = makeSession(GW);
await step('owner logs in', async () => {
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
assert.equal(r.status, 200);
});
const locId = `p9-loc-${RUN_ID}`, cohortId = `p9-cohort-${RUN_ID}`, offeringId = `p9-camp-${RUN_ID}`, slug = `p9-linux-systems-${RUN_ID}`, agreementId = `p9-agreement-${RUN_ID}`;
await step('admin configures the test offering — price, capacity, location, agreement, instructor', async () => {
await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'Community Room B' });
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Winter Break Cohort', tier: 'root' });
const create = await owner.request('POST', '/api/admin/offerings', {
offeringId, slug, name: 'Winter Break Linux & Systems Camp', programId: PROGRAM, deliveryMode: 'camp',
capacity: 2, locationId: locId, cohortId, priceAmount: 20000, priceCurrency: 'usd',
});
assert.equal(create.status, 201);
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p9-instructor-${RUN_ID}@example.com`, display_name: 'P9 Instructor', role: 'instructor', password: 'P9InstructorPass123!' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
await owner.request('POST', `/api/admin/cohorts/${cohortId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role_in_cohort: 'teacher' });
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'Winter Camp Participation Agreement' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
const readiness = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
// Dates/registration-window are deliberately NOT set — real business decisions this phase
// must not invent (Steps 4/28/32) — so overall readiness is honestly false. Every check
// this rehearsal DID configure must show ready; the ones nobody has decided yet must not.
const byKey = Object.fromEntries(readiness.body.checks.map((c) => [c.key, c.ready]));
for (const key of ['curriculum', 'instructor', 'location', 'capacity', 'agreements', 'price', 'payment_provider']) {
assert.equal(byKey[key], true, `expected ${key} to be ready: ${JSON.stringify(readiness.body.checks)}`);
}
assert.equal(readiness.body.ready_to_open_registration, false, 'must honestly stay not-fully-ready until real dates are set');
});
await step('admin opens registration', async () => {
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
assert.equal(open.status, 200);
});
// ── Minor registration -> agreement -> checkout -> real payment -> enrollment -> Lab ──────
let minorRegId, minorTransactionRef;
await step('a minor registers via the website, guardian accepts the agreement, checkout is created', async () => {
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p9-minor-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Winter Camper', is_minor: true },
guardian: { display_name: 'Winter Guardian', email: `winter-guardian-${RUN_ID}@example.com`, phone: '555-0101' },
});
assert.equal(submit.status, 201);
minorRegId = submit.body.registration_id;
const preCheckout = await integrationPost(`/api/integrations/registrations/${minorRegId}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
assert.equal(preCheckout.status, 409, 'checkout must be blocked before the agreement is accepted');
const accept = await integrationPost(`/api/integrations/registrations/${minorRegId}/agreements`, { agreement_id: agreementId });
assert.equal(accept.status, 201);
const checkout = await integrationPost(`/api/integrations/registrations/${minorRegId}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
const tx = await owner.request('GET', `/api/admin/registrations/${minorRegId}/payments`);
minorTransactionRef = tx.body[0].provider_reference;
});
let minorStudent, minorSession, minorEnrollmentId, minorPaymentEvent;
await step('the test payment provider confirms payment via a REAL webhook call — registration converts, account activates', async () => {
minorPaymentEvent = provider.buildTestEvent({ providerReference: minorTransactionRef, eventType: 'checkout.session.completed' });
const webhookResult = await webhookPost(minorPaymentEvent.body, minorPaymentEvent.signature);
assert.equal(webhookResult.status, 200);
assert.equal(webhookResult.body.result, 'processed');
const reg = await owner.request('GET', `/api/admin/registrations/${minorRegId}`);
assert.equal(reg.body.status, 'enrollment_activated');
const cohortRoster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.ok(cohortRoster.body.some((s) => s.student_id === reg.body.student_id));
minorEnrollmentId = reg.body.enrollment_id;
// The real activation token was already delivered to the (dev) email provider by the
// notification worker — retrieved here via the same dev-preview mechanism e2e-phase5c.js's
// own suite already established, never a raw-token-in-API-response shortcut (that only
// exists on the admin-authored convert route, not the webhook path — see notifications.js's
// own comment on why toAdminView() never includes it).
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}/../../../platform`, stdio: 'pipe' });
await new Promise((r) => setTimeout(r, 3000));
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
const activationNotif = notifs.body.find((n) => n.notification_type === 'activation');
assert.ok(activationNotif, `expected a real activation notification, got: ${JSON.stringify(notifs.body)}`);
const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
if (!preview.body || !preview.body.text) throw new Error(`dev-preview failed: status=${preview.status} notif=${JSON.stringify(activationNotif)} body=${JSON.stringify(preview.body)}`);
const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
minorStudent = { student_id: reg.body.student_id };
minorSession = makeSession(GW);
const activate = await minorSession.request('POST', '/api/student/activate', { token, password: 'WinterP9Minor123!' });
assert.equal(activate.status, 200, JSON.stringify(activate.body));
});
await step('the duplicate webhook (a real Stripe retry scenario) changes nothing further', async () => {
// The EXACT same event (same event id) as the prior step — a real Stripe retry redelivers
// the identical event, it never mints a new event id for "the same" delivery. Building a
// fresh test event here would defeat the point: it's calling buildTestEvent() twice that
// simulates two DIFFERENT events, not a retry of the same one.
const replay = await webhookPost(minorPaymentEvent.body, minorPaymentEvent.signature);
assert.equal(replay.body.result, 'duplicate');
});
// ── Adult registration, real payment, independent conversion ──────────────────────────────
await step('an adult registers, pays, and converts independently — no guardian involved', async () => {
const submit = await integrationPost('/api/integrations/registrations', { event_id: `p9-adult-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Winter Adult', email: `winter-adult-${RUN_ID}@example.com`, is_minor: false } });
assert.equal(submit.status, 201);
await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/agreements`, { agreement_id: agreementId });
const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
const tx = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'checkout.session.completed' });
await webhookPost(event.body, event.signature);
const reg = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
assert.equal(reg.body.status, 'enrollment_activated');
});
// ── Capacity now exhausted (2/2) — a third registration must fail cleanly ────────────────
await step('a failed payment leaves a retriable registration without consuming a phantom extra seat', async () => {
const failSlugOffering = `p9-fail-camp-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: `off-${failSlugOffering}`, slug: failSlugOffering, name: 'Fail Camp', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
await owner.request('POST', `/api/admin/offerings/off-${failSlugOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/off-${failSlugOffering}/transition`, { status: 'enrollment_open' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlugOffering, registrant: { display_name: 'Fail Registrant', email: `failreg-${RUN_ID}@example.com` } });
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
const tx = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
await webhookPost(event.body, event.signature);
const offAfter = await owner.request('GET', `/api/admin/offerings/off-${failSlugOffering}`);
assert.equal(offAfter.body.reserved_seats, 1, 'the seat stays reserved so the same registrant can retry checkout');
});
// ── Abandoned checkout releases the seat back to the pool ────────────────────────────────
await step('an abandoned checkout (expired session) releases the seat', async () => {
const abandonOffering = `off-p9-abandon-${RUN_ID}`, abandonSlug = `p9-abandon-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: abandonOffering, slug: abandonSlug, name: 'Abandon Test', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
await owner.request('POST', `/api/admin/offerings/${abandonOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${abandonOffering}/transition`, { status: 'enrollment_open' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-abandonreg-${RUN_ID}`, offering_slug: abandonSlug, registrant: { display_name: 'Abandoner', email: `abandoner-${RUN_ID}@example.com` } });
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://rootnodeacademy.com/success', cancel_url: 'https://rootnodeacademy.com/cancel' });
const tx = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: tx.body[0].provider_reference, eventType: 'checkout.session.expired' });
await webhookPost(event.body, event.signature);
const offAfter = await owner.request('GET', `/api/admin/offerings/${abandonOffering}`);
assert.equal(offAfter.body.reserved_seats, 0, 'an abandoned checkout must release its seat');
// Prove the released seat is genuinely usable again — a fresh registration succeeds.
const retry = await integrationPost('/api/integrations/registrations', { event_id: `p9-abandonretry-${RUN_ID}`, offering_slug: abandonSlug, registrant: { display_name: 'Retry Registrant', email: `retry-${RUN_ID}@example.com` } });
assert.equal(retry.status, 201);
});
// ── Last-seat race: two students competing, exactly one wins ────────────────────────────
await step('two students genuinely compete for the final seat of a paid offering — exactly one wins', async () => {
const raceOffering = `off-p9-lastseat-${RUN_ID}`, raceSlug = `p9-lastseat-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: raceOffering, slug: raceSlug, name: 'Last Seat', programId: PROGRAM, deliveryMode: 'workshop', capacity: 1, priceAmount: 5000 });
await owner.request('POST', `/api/admin/offerings/${raceOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${raceOffering}/transition`, { status: 'enrollment_open' });
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p9-lastseat-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'Last A', email: `lasta-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p9-lastseat-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'Last B', email: `lastb-${RUN_ID}@example.com` } }),
]);
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
});
// ── Real classroom + Lab work for the actually-enrolled minor student ────────────────────
await step('instructor starts a classroom session and Prepares Labs', async () => {
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'Winter Camp Day 1' });
assert.equal(session.status, 201);
global.__classroomId = session.body.classroom_session_id;
await owner.request('POST', `/api/admin/classroom-sessions/${global.__classroomId}/start`);
const prep = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
assert.equal(prep.status, 200);
});
await step('the minor student joins, completes real orientation + a REAL Lab task, progress persists', async () => {
const join = await minorSession.request('POST', `/api/classroom/${global.__classroomId}/join`);
assert.equal(join.status, 200);
for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check', 'root-01-m01-a01-lesson', 'root-01-m01-a02-guided-exercise']) {
const r = await minorSession.request('POST', `/api/student/activities/${id}/complete`);
assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`);
}
const launch = await minorSession.request('POST', `/api/student/programs/${minorEnrollmentId}/launch`);
assert.equal(launch.status, 200);
const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200);
const envRow = (await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/environment`)).body.find((e) => e.status === 'running');
const containerName = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envRow.environment_id}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
const fs = require('fs');
const scriptPath = `/tmp/phase9-winter-hardware-${envRow.environment_id}.sh`;
fs.writeFileSync(scriptPath, ['#!/bin/bash', 'set -e', 'mkdir -p /srv/hardware-lab',
'CPUS=$(nproc)', "MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt'].join('\n'));
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
fs.unlinkSync(scriptPath);
const validate = await minorSession.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true);
await minorSession.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envRow.environment_id });
});
await step('instructor sees real roster, payment, and progress state for the camp', async () => {
const roster = await owner.request('GET', `/api/admin/cohorts/${cohortId}/students`);
assert.equal(roster.body.length, 2);
const payments = await owner.request('GET', `/api/admin/registrations/${minorRegId}/payments`);
assert.equal(payments.body[0].status, 'paid');
const { body } = await owner.request('GET', `/api/admin/students/${minorStudent.student_id}/courses?program_id=${PROGRAM}`);
const mod = moduleById(body, 'root-01-m01-how-computers-run-software');
assert.ok(mod.activities.some((a) => a.complete));
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+28 -3
View File
@@ -106,11 +106,36 @@ further instructor accounts from the Instructors tab).
prerequisite graph (no cycles, no missing nodes), the strengthened Capstone gate (ROOT 09 AND prerequisite graph (no cycles, no missing nodes), the strengthened Capstone gate (ROOT 09 AND
ROOT 12 both required) in both directions, the new orientation module, and an explicit check ROOT 12 both required) in both directions, the new orientation module, and an explicit check
that no age/grade gate exists anywhere in enrollment. that no age/grade gate exists anywhere in enrollment.
- `test/e2e-phase7.js` — organizations/locations, guardians and student-guardian relationships,
curriculum packages (reference-only, never a content clone), offerings (capacity, server-
defined status transitions), the registration lifecycle through agreement acceptance and
conversion to a real enrollment, registration/guardian RBAC, explicit age-guidance-is-never-
an-authorization-check verification, and confirmation that existing ROOT_ V1/self-paced/
classroom flows are unaffected.
- `test/e2e-phase8.js` — offering launch readiness, server-authoritative registration open/
closed enforcement, capacity concurrency through the real registration endpoints, the public
offering serializer (no internal/PII leakage), the website registration integration contract
(sequential and concurrent idempotency), required-agreement conversion blocking with version
snapshotting, guardian notification recipient resolution, and confirmation that ROOT_ V1/self-
paced/classroom flows remain unaffected.
- `test/e2e-phase9.js` — offering pricing in the public serializer, checkout blocked until
agreements are accepted, the server-authoritative amount (never caller-supplied), webhook
signature verification and idempotency, payment success driving real conversion/activation, a
failed payment leaving the seat intact vs. an abandoned checkout releasing it, the reservation
reaper, a last-seat checkout race, manual refund with no cascade into registration/enrollment
status, minor payment confirmations routing to the guardian, the admin payment view (never card
data), instructor/public RBAC on payment data, audit/metric hygiene (no secrets, no
high-cardinality labels), the website status-lookup endpoint's minimal safe shape, and
confirmation that ROOT_ V1/free-offering/classroom flows remain unaffected.
`infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations, `infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations,
Phase 6F's multi-student pilot and full end-to-end V1 journey) — see each script's own header Phase 6F's multi-student pilot and full end-to-end V1 journey, Phase 7/8's Winter Break Camp
and `docs/PHASE6F_ROOT_V1_PILOT_READINESS.md` for what they prove beyond what the e2e suites rehearsals, Phase 9's Winter Break COMMERCE rehearsal —
above already cover via direct API. `test-phase9-winter-camp-commerce.js`, 13/13, real checkout/webhook against the deterministic
test payment provider, no live money) — see each script's own header and
`docs/PHASE6F_ROOT_V1_PILOT_READINESS.md`/`docs/PHASE8_ENROLLMENT_OPERATIONS.md`/`docs/
PHASE9_COMMERCE_AND_LAUNCH.md` for what they prove beyond what the e2e suites above already
cover via direct API.
All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor All e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
@@ -0,0 +1,88 @@
-- Phase 7 §7/§8/§10 — the business/product architecture's first-class organization, location,
-- and guardian concepts. Additive only: no existing table's meaning changes, no existing row's
-- data moves. See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and
-- docs/STUDENT_GUARDIAN_DATA_MODEL.md for the full reasoning.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- programs.family widened for BOOTCAMP_ (Step 15/21) — a program row can now describe a
-- seasonal/intensive offering family too. No existing row's family value changes; this is a
-- pure widening of what's allowed going forward.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE programs DROP CONSTRAINT IF EXISTS programs_family_check;
ALTER TABLE programs ADD CONSTRAINT programs_family_check CHECK (family IN ('init', 'compile', 'root', 'bootcamp'));
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- organizations — a business/delivery relationship (a school, library, homeschool group,
-- community partner, workforce-development partner), NOT a SaaS tenant. RootNode Academy
-- remains one Academy; no other table gets an organization_id foreign key added to it wholesale
-- — only offerings and locations reference one, and only optionally (Step 7's explicit
-- "do not move all RootNode data under organization_id").
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS organizations (
organization_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'other' CHECK (type IN ('school', 'library', 'homeschool_group', 'community', 'workforce_partner', 'other')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- locations — a physical or virtual delivery location. organization_id is nullable: a location
-- can be a RootNode-run venue with no partner organization at all (e.g. a rented community-
-- center room booked directly), or "online" (type='online', every address field null).
-- access_notes is deliberately never exposed on any student-facing route — internal
-- operational detail only (parking instructions, building access codes, etc).
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS locations (
location_id TEXT PRIMARY KEY,
organization_id TEXT REFERENCES organizations(organization_id),
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'online' CHECK (type IN ('school', 'library', 'community_center', 'partner_facility', 'online', 'other')),
address_line1 TEXT,
address_line2 TEXT,
city TEXT,
region TEXT,
postal_code TEXT,
country TEXT,
timezone TEXT,
capacity INTEGER,
access_notes TEXT, -- internal/operational only — never returned by a student-facing route
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- guardians / student_guardians — the proper foundation replacing the Phase 5C
-- `enrollments.metadata.contact_email` workaround (left in place, unchanged, for backward
-- compatibility with the live website integration — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md).
-- A guardian is its own identity, separate from students.email, so a parent with multiple
-- enrolled children is ONE guardian row linked to MANY students, not a duplicated email per
-- child. Explicitly NOT a login/auth identity in this phase — no password, no session, no
-- portal access — see docs/STUDENT_GUARDIAN_DATA_MODEL.md for what was deliberately deferred.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS guardians (
guardian_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT,
phone TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS student_guardians (
student_id TEXT NOT NULL REFERENCES students(student_id),
guardian_id TEXT NOT NULL REFERENCES guardians(guardian_id),
relationship TEXT NOT NULL DEFAULT 'guardian' CHECK (relationship IN ('parent', 'guardian', 'authorized_contact', 'emergency_contact')),
is_primary_contact BOOLEAN NOT NULL DEFAULT false,
can_receive_communications BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (student_id, guardian_id)
);
CREATE INDEX IF NOT EXISTS idx_student_guardians_guardian ON student_guardians(guardian_id);
@@ -0,0 +1,148 @@
-- Phase 7 §3/§12/§14/§20 — offerings (a purchasable/enrollable delivery of a program),
-- registrations (pre-enrollment application/interest, separate from academic entitlement),
-- agreements (versioned acceptance records, not legal text), and curriculum packages (a named,
-- versioned reference list into EXISTING courses — never a content clone). See
-- docs/OFFERING_AND_REGISTRATION_MODEL.md for the full reasoning and lifecycle diagrams.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- curriculum_packages / curriculum_package_items — a named, versioned SUBSET reference into
-- existing courses. curriculum_package_items stores no content of its own, only which
-- course_id(s) (whole courses, matching how V1's own course granularity already works) belong
-- to the package and in what order — exactly the same "reference, never clone" principle
-- COURSE_CONTENT_MODEL.md already established for lab activities referencing scenario_id.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS curriculum_packages (
curriculum_package_id TEXT PRIMARY KEY, -- slug, e.g. 'winter-linux-systems-v1'
name TEXT NOT NULL,
program_id TEXT NOT NULL REFERENCES programs(program_id),
description TEXT,
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS curriculum_package_items (
curriculum_package_id TEXT NOT NULL REFERENCES curriculum_packages(curriculum_package_id),
course_id TEXT NOT NULL REFERENCES courses(course_id),
sequence INTEGER NOT NULL,
PRIMARY KEY (curriculum_package_id, course_id)
);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offerings — connects a program (+ optional curriculum_package subset) to a delivery format,
-- schedule, capacity, and location/organization/cohort. Status transitions are validated
-- server-side only (offerings.js) — this table has no trigger enforcing them because the
-- application layer is the single writer (admin routes), matching every other status-machine
-- table in this schema (enrollments, classroom_sessions) which also rely on server-side
-- transition logic rather than a DB trigger.
--
-- reserved_seats is a maintained counter, not derived via COUNT(*) at read time — capacity
-- reservation (offerings.js's reserveSeat()) increments it with a single atomic
-- `UPDATE ... WHERE reserved_seats < capacity RETURNING ...`, avoiding the
-- SELECT-count-then-INSERT race explicitly flagged in this phase's authorization.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS offerings (
offering_id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
program_id TEXT NOT NULL REFERENCES programs(program_id),
curriculum_package_id TEXT REFERENCES curriculum_packages(curriculum_package_id),
delivery_mode TEXT NOT NULL CHECK (delivery_mode IN ('self_paced', 'cohort', 'hybrid', 'camp', 'workshop', 'school', 'after_school', 'homeschool', 'community', 'one_to_one')),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'enrollment_open', 'full', 'in_progress', 'completed', 'cancelled')),
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
enrollment_opens_at TIMESTAMPTZ,
enrollment_closes_at TIMESTAMPTZ,
capacity INTEGER, -- NULL = unlimited (typical for self_paced)
reserved_seats INTEGER NOT NULL DEFAULT 0,
location_id TEXT REFERENCES locations(location_id),
organization_id TEXT REFERENCES organizations(organization_id),
cohort_id TEXT REFERENCES cohorts(cohort_id),
recommended_min_age INTEGER, -- guidance only, never an authorization gate (Step 21)
recommended_max_age INTEGER,
description TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, -- room for descriptive price/marketing fields later, never payment data
version INTEGER NOT NULL DEFAULT 1,
created_by UUID REFERENCES instructors(instructor_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (capacity IS NULL OR reserved_seats <= capacity)
);
CREATE INDEX IF NOT EXISTS idx_offerings_program ON offerings(program_id);
CREATE INDEX IF NOT EXISTS idx_offerings_status ON offerings(status);
CREATE TABLE IF NOT EXISTS instructor_offerings (
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
instructor_id UUID NOT NULL REFERENCES instructors(instructor_id),
role TEXT NOT NULL DEFAULT 'teacher', -- reuses cohort_instructors' own role_in_cohort vocabulary — see admin-offerings-routes.js
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (offering_id, instructor_id)
);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offering_registrations — pre-enrollment interest/application, separate from `enrollments`
-- (academic entitlement, unchanged). A registration MAY exist with no student_id yet (a
-- guardian registering a not-yet-created student) and is converted to a real enrollment only at
-- `confirmed` -> `enrollment_activated`. No payment fields — that boundary belongs to a future
-- commerce layer entirely (Step 23).
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS offering_registrations (
registration_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
student_id TEXT REFERENCES students(student_id), -- null until matched/created
guardian_id TEXT REFERENCES guardians(guardian_id), -- who registered, if a minor
registrant_display_name TEXT NOT NULL, -- captured even before a student record exists
registrant_email TEXT,
is_minor BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'started' CHECK (status IN ('started', 'submitted', 'reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'enrollment_activated', 'cancelled')),
readiness_notes TEXT,
accessibility_notes TEXT,
emergency_contact_name TEXT,
emergency_contact_phone TEXT,
enrollment_id UUID REFERENCES enrollments(enrollment_id), -- set once converted
source TEXT NOT NULL DEFAULT 'admin',
external_reference TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (NOT is_minor OR guardian_id IS NOT NULL) -- a minor's registration must always carry a responsible guardian
);
CREATE INDEX IF NOT EXISTS idx_offering_registrations_offering ON offering_registrations(offering_id);
CREATE INDEX IF NOT EXISTS idx_offering_registrations_status ON offering_registrations(status);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- agreement_definitions / agreement_acceptances — a versioned ACCEPTANCE RECORD model only.
-- No legal text is authored here (body_summary is a plain-language description for admin UI
-- display, explicitly NOT attorney-approved legal language — see
-- docs/STUDENT_GUARDIAN_DATA_MODEL.md's "requires legal review" list). accepted_version is
-- captured on the acceptance row (not just looked up from the live definition) so a later
-- edit to an agreement's text/version never retroactively reinterprets what someone actually
-- agreed to — the same "session snapshots its own version" principle
-- COURSE_CONTENT_MODEL.md §6b already established for assessments.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS agreement_definitions (
agreement_id TEXT PRIMARY KEY, -- slug, e.g. 'participation-agreement'
title TEXT NOT NULL,
body_summary TEXT, -- plain-language description, NOT legal text
version INTEGER NOT NULL DEFAULT 1,
requires_guardian_for_minor BOOLEAN NOT NULL DEFAULT true,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS agreement_acceptances (
acceptance_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id),
accepted_version INTEGER NOT NULL,
accepted_by_type TEXT NOT NULL CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf')),
accepted_by_student_id TEXT REFERENCES students(student_id),
accepted_by_guardian_id TEXT REFERENCES guardians(guardian_id),
registration_id UUID REFERENCES offering_registrations(registration_id),
enrollment_id UUID REFERENCES enrollments(enrollment_id),
ip_address TEXT,
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_agreement_acceptances_registration ON agreement_acceptances(registration_id);
@@ -0,0 +1,53 @@
-- Phase 8 — enrollment operations foundation: registration-stage notifications, a clean
-- pre-account agreement-acceptance type, offering agreement requirements, and idempotent
-- website registration submission. All additive/widening — no existing row's meaning changes,
-- no existing constraint gets stricter. See docs/PHASE8_ENROLLMENT_OPERATIONS.md.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- notification_events — a registration may need a notification (Registration Received, Agreement
-- Required) before any student row exists at all (a brand-new guardian-submitted registration).
-- student_id is widened to nullable and a registration_id column added; a CHECK guarantees every
-- row still has SOME real subject. Every existing row already has student_id set, so this is a
-- pure widening — nothing about student-scoped notification behavior changes.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE notification_events ALTER COLUMN student_id DROP NOT NULL;
ALTER TABLE notification_events ADD COLUMN IF NOT EXISTS registration_id UUID REFERENCES offering_registrations(registration_id);
ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_subject_check;
ALTER TABLE notification_events ADD CONSTRAINT notification_events_subject_check CHECK (student_id IS NOT NULL OR registration_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_notification_events_registration ON notification_events(registration_id);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- agreement_acceptances — a 4th accepted_by_type, 'registrant', for the one real gap Phase 7's
-- own limitations list named: an adult accepting an agreement AT REGISTRATION TIME, before their
-- eventual student_id exists. Tied to registration_id only (no student/guardian id required) —
-- the CHECK is widened to accept that shape too. 'student'/'guardian'/'instructor_on_behalf'
-- (admin-recorded on someone's behalf) are unchanged and already covered Step 7's "student /
-- guardian / admin-instructor" minimum bar; 'registrant' closes the actual documented gap.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_accepted_by_type_check;
ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_accepted_by_type_check
CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf', 'registrant'));
ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_check;
ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_check
CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL OR (accepted_by_type = 'registrant' AND registration_id IS NOT NULL));
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offering_agreement_requirements — which agreements a given offering actually requires before
-- its registrations may convert to enrollment (Step 18). A plain join table, not a new status
-- machine — offerings.js checks it at conversion time.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS offering_agreement_requirements (
offering_id TEXT NOT NULL REFERENCES offerings(offering_id),
agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (offering_id, agreement_id)
);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offering_registrations — a real unique index on external_reference (nulls excluded) is what
-- actually makes website registration submission idempotent under concurrent retries, the exact
-- same pattern enrollments.external_event_id already established in Phase 5B — a DB-level
-- constraint, not a SELECT-then-INSERT check.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE UNIQUE INDEX IF NOT EXISTS idx_offering_registrations_external_reference
ON offering_registrations(external_reference) WHERE external_reference IS NOT NULL;
@@ -0,0 +1,15 @@
-- Phase 8 — self-caught correction to 035_phase8_enrollment_operations.sql, found via this
-- phase's own smoke testing before any commit: notification_events.notification_type carries a
-- CHECK constraint (Phase 5C) that never anticipated registration-stage notification types.
-- Every offerings.js call to notifications.enqueueForRegistration() was silently failing (caught
-- and swallowed by the deliberate "a notification failure must never block the real action"
-- try/catch) until this was found — no registration-stage notification had ever actually been
-- queued. Pure widening: every existing notification_type value stays valid, nothing about
-- Phase 5C's own four types changes.
ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_notification_type_check;
ALTER TABLE notification_events ADD CONSTRAINT notification_events_notification_type_check
CHECK (notification_type IN (
'activation', 'enrollment_confirmation', 'password_reset', 'password_changed',
'registration_received', 'registration_confirmed', 'registration_needs_action',
'agreement_required', 'camp_operational_update'
));
@@ -0,0 +1,71 @@
-- Phase 9 — the narrow commerce/payment-record model around Phase 8's registration system.
-- Stripe (or any future provider) is a COMMERCE provider only — this schema is what RootNode
-- itself understands, never a copy of Stripe's own object model. No card numbers, no CVV, no
-- raw payment-method details anywhere in this schema; Stripe remains the PCI boundary. See
-- docs/PHASE9_COMMERCE_AND_LAUNCH.md.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offerings — price is nullable: a NULL price means this offering never requires checkout (the
-- existing Phase 7/8 free-registration path is completely unchanged for it). amount is in minor
-- currency units (cents), matching Stripe's own convention, so no float-vs-integer rounding
-- question ever arises.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE offerings ADD COLUMN IF NOT EXISTS price_amount INTEGER; -- cents; NULL = free/no-checkout
ALTER TABLE offerings ADD COLUMN IF NOT EXISTS price_currency TEXT NOT NULL DEFAULT 'usd';
ALTER TABLE offerings DROP CONSTRAINT IF EXISTS offerings_price_amount_nonnegative;
ALTER TABLE offerings ADD CONSTRAINT offerings_price_amount_nonnegative CHECK (price_amount IS NULL OR price_amount >= 0);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- offering_registrations — reservation_expires_at is set only for a registration against a
-- PAID offering (commerce.js's own responsibility, not a DB default) — the abandoned-
-- reservation reaper (Step 27) releases the seat and cancels the registration once this passes
-- with no successful payment, exactly the "reserve at registration, expire if unpaid" model
-- Step 26 asks to prove. NULL forever for a free offering — nothing new to expire there.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
ALTER TABLE offering_registrations ADD COLUMN IF NOT EXISTS reservation_expires_at TIMESTAMPTZ;
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- payment_transactions — one row per checkout attempt (a registration can have more than one,
-- e.g. a failed attempt followed by a retry). provider_reference is the provider's own opaque
-- id (a Stripe Checkout Session id) — never a card/payment-method identifier. amount/currency
-- are SNAPSHOTTED from the offering at checkout-creation time (matching every other
-- "session snapshots its own terms" precedent in this schema — assessment sessions, agreement
-- acceptances) so a later price change never reinterprets an already-created transaction.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS payment_transactions (
transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
registration_id UUID NOT NULL REFERENCES offering_registrations(registration_id),
provider TEXT NOT NULL CHECK (provider IN ('stripe', 'test')),
provider_reference TEXT, -- e.g. Stripe Checkout Session id
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'cancelled', 'refunded', 'partially_refunded')),
amount INTEGER NOT NULL, -- cents, snapshotted from the offering at creation
currency TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
authorized_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
refunded_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_payment_transactions_registration ON payment_transactions(registration_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_transactions_provider_reference ON payment_transactions(provider_reference) WHERE provider_reference IS NOT NULL;
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- payment_webhook_events — the ENTIRE webhook idempotency mechanism: a real DB-level unique
-- primary key on the provider's own event id (Stripe already guarantees these are globally
-- unique), not a SELECT-then-process check. A duplicate delivery of the same event (Stripe
-- explicitly documents retries as expected/normal) always tries the same INSERT, always loses
-- the race gracefully via ON CONFLICT DO NOTHING, and is therefore always a safe no-op no
-- matter how many times or how concurrently it arrives.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS payment_webhook_events (
webhook_event_id TEXT PRIMARY KEY, -- the provider's own event id
provider TEXT NOT NULL CHECK (provider IN ('stripe', 'test')),
event_type TEXT NOT NULL,
transaction_id UUID REFERENCES payment_transactions(transaction_id),
result TEXT NOT NULL DEFAULT 'received' CHECK (result IN ('received', 'processed', 'ignored', 'error')),
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
+18 -1
View File
@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
"express": "^4.19.2", "express": "^4.19.2",
"pg": "^8.11.5" "pg": "^8.11.5",
"stripe": "^22.6.0"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -1178,6 +1179,22 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stripe": {
"version": "22.6.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.6.0.tgz",
"integrity": "sha512-Ezk+WYEEwX2DLxFrKfb6coznlCMZvCaCYMNPXNHFSZkBIEzF5Hgm7RLb38q/0h9Ol5jsUJGJRorvhzAvckgfhw==",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+2 -1
View File
@@ -10,8 +10,9 @@
"bootstrap-owner": "node bootstrap-owner.js" "bootstrap-owner": "node bootstrap-owner.js"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^9.4.3",
"express": "^4.19.2", "express": "^4.19.2",
"pg": "^8.11.5", "pg": "^8.11.5",
"better-sqlite3": "^9.4.3" "stripe": "^22.6.0"
} }
} }
+232
View File
@@ -0,0 +1,232 @@
// Phase 7 — admin/owner routes for organizations, locations, guardians, curriculum packages,
// offerings, registrations, and agreements. RBAC convention matches admin-programs-routes.js:
// authoring (create/transition) is admin/owner only; any instructor can list/view non-sensitive
// data (organizations, locations, offerings). Guardian and registration detail are admin/owner
// only — Step 17's explicit "plain instructors should not automatically get access to guardian/
// private registration details" — a plain instructor still sees offering rosters/progress
// through the existing cohort-scoped routes, just not guardian contact info or registration
// notes (readiness/accessibility/emergency-contact) directly.
const offerings = require('./offerings');
const notifications = require('./notifications');
const commerce = require('./commerce');
const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
function errStatus(code) {
if (code === 'NOT_FOUND' || code === 'PROGRAM_NOT_FOUND' || code === 'OFFERING_NOT_FOUND' || code === 'COURSE_NOT_FOUND' || code === 'AGREEMENT_NOT_FOUND') return 404;
if (code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE'
|| code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED'
|| code === 'REGISTRATION_NOT_YET_OPEN' || code === 'REGISTRATION_CLOSED' || code === 'DUPLICATE_REGISTRATION'
|| code === 'AGREEMENTS_INCOMPLETE' || code === 'NO_PRICE_CONFIGURED') return 409;
if (code === 'PROVIDER_NOT_CONFIGURED') return 503;
return 500;
}
module.exports = function registerAdminOfferingsRoutes(app, deps) {
const { instructorSession, rbac, getPool, recordAudit } = deps;
const { requireAnyInstructor, requireAdminOrOwner } = 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)),
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
};
function handleError(res, e) {
if (e.code) return res.status(errStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
// ── Organizations / Locations ──────────────────────────────────────────────────────────
admin.post('/api/admin/organizations', requireAdminOrOwner, async (req, res) => {
try {
const org = await offerings.createOrganization(getPool(), req.body);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'organization_create', resourceType: 'organization', resourceId: org.organization_id, result: 'success', sourceIp: req.ip });
res.status(201).json(org);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/organizations', requireAnyInstructor, async (req, res) => {
res.json(await offerings.listOrganizations(getPool()));
});
admin.post('/api/admin/locations', requireAdminOrOwner, async (req, res) => {
try {
const loc = await offerings.createLocation(getPool(), req.body);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'location_create', resourceType: 'location', resourceId: loc.location_id, result: 'success', sourceIp: req.ip });
res.status(201).json(loc);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/locations', requireAnyInstructor, async (req, res) => {
// access_notes only for admin/owner — a plain instructor sees the location, not its
// internal operational notes (Step 8).
const forInstructor = req.instructor.role === 'admin' || req.instructor.role === 'owner';
res.json(await offerings.listLocations(getPool(), { forInstructor }));
});
// ── Guardians (admin/owner only — sensitive contact data) ──────────────────────────────
admin.post('/api/admin/guardians', requireAdminOrOwner, async (req, res) => {
try {
const g = await offerings.createGuardian(getPool(), req.body);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'guardian_create', resourceType: 'guardian', resourceId: g.guardian_id, result: 'success', sourceIp: req.ip });
res.status(201).json(g);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/students/:id/guardians', requireAdminOrOwner, async (req, res) => {
try {
const link = await offerings.linkStudentGuardian(getPool(), { ...req.body, studentId: req.params.id });
res.status(201).json(link);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/students/:id/guardians', requireAdminOrOwner, async (req, res) => {
res.json(await offerings.listGuardiansForStudent(getPool(), req.params.id));
});
// ── Curriculum packages ──────────────────────────────────────────────────────────────────
admin.post('/api/admin/curriculum-packages', requireAdminOrOwner, async (req, res) => {
try {
const pkg = await offerings.createCurriculumPackage(getPool(), req.body);
res.status(201).json(pkg);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/curriculum-packages/:id/items', requireAdminOrOwner, async (req, res) => {
try {
const item = await offerings.addCurriculumPackageItem(getPool(), { ...req.body, curriculumPackageId: req.params.id });
res.status(201).json(item);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/curriculum-packages/:id', requireAnyInstructor, async (req, res) => {
const pkg = await offerings.getCurriculumPackage(getPool(), req.params.id);
if (!pkg) return res.status(404).json({ error: 'NOT_FOUND' });
res.json(pkg);
});
// ── Offerings ─────────────────────────────────────────────────────────────────────────────
admin.post('/api/admin/offerings', requireAdminOrOwner, async (req, res) => {
try {
const offering = await offerings.createOffering(getPool(), { ...req.body, createdBy: req.instructor.instructor_id });
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_create', resourceType: 'offering', resourceId: offering.offering_id, result: 'success', sourceIp: req.ip });
res.status(201).json(offering);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/offerings', requireAnyInstructor, async (req, res) => {
res.json(await offerings.listOfferings(getPool(), { status: req.query.status }));
});
admin.get('/api/admin/offerings/:id', requireAnyInstructor, async (req, res) => {
const offering = await offerings.getOffering(getPool(), req.params.id);
if (!offering) return res.status(404).json({ error: 'NOT_FOUND' });
res.json(offering);
});
// Phase 9 §28 — the one route Chase actually uses to fill in dates/venue/capacity/price once
// those real business decisions are made, on an offering that already exists as a draft.
admin.patch('/api/admin/offerings/:id', requireAdminOrOwner, async (req, res) => {
try {
const offering = await offerings.updateOffering(getPool(), req.params.id, req.body || {});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_update', resourceType: 'offering', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { fields: Object.keys(req.body || {}) } });
res.json(offering);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/offerings/:id/transition', requireAdminOrOwner, async (req, res) => {
try {
const offering = await offerings.transitionOffering(getPool(), req.params.id, req.body.status);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_transition', resourceType: 'offering', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { to: req.body.status } });
res.json(offering);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/offerings/:id/instructors', requireAdminOrOwner, async (req, res) => {
try {
const link = await offerings.assignInstructorToOffering(getPool(), req.params.id, req.body.instructor_id, req.body.role);
res.status(201).json(link);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/offerings/:id/readiness', requireAnyInstructor, async (req, res) => {
try {
res.json(await offerings.computeOfferingReadiness(getPool(), req.params.id));
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/offerings/:id/agreement-requirements', requireAdminOrOwner, async (req, res) => {
try {
const link = await offerings.addAgreementRequirement(getPool(), req.params.id, req.body.agreement_id);
res.status(201).json(link);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/offerings/:id/agreement-requirements', requireAnyInstructor, async (req, res) => {
res.json(await offerings.listAgreementRequirementsForOffering(getPool(), req.params.id));
});
// ── Registrations (admin/owner only) ────────────────────────────────────────────────────
// offerings.createRegistration() itself now does validate -> reserve -> insert -> notify, in
// that exact order (see its own header comment) — no route-level orchestration needed here
// any more.
admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
try {
const reg = await offerings.createRegistration(getPool(), { ...req.body, offeringId: req.params.id });
res.status(201).json(reg);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
res.json(await offerings.listRegistrationsForOffering(getPool(), req.params.id));
});
admin.get('/api/admin/registrations/:id', requireAdminOrOwner, async (req, res) => {
const reg = await offerings.getRegistration(getPool(), req.params.id);
if (!reg) return res.status(404).json({ error: 'NOT_FOUND' });
res.json(reg);
});
admin.get('/api/admin/registrations/:id/agreement-status', requireAdminOrOwner, async (req, res) => {
try {
res.json(await offerings.getRegistrationAgreementStatus(getPool(), req.params.id));
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/registrations/:id/notifications', requireAdminOrOwner, async (req, res) => {
const rows = await notifications.listForRegistration(getPool(), req.params.id);
res.json(rows.map(notifications.toAdminView));
});
// Phase 9 §18/§19 — payment visibility. The row itself never contains card data (only
// amount/currency/status/timestamps/the provider's own opaque reference), so it's safe to
// return whole — admin/owner only, same as every other registration-detail surface.
admin.get('/api/admin/registrations/:id/payments', requireAdminOrOwner, async (req, res) => {
res.json(await commerce.listTransactionsForRegistration(getPool(), req.params.id));
});
admin.post('/api/admin/payments/:id/refund', requireAdminOrOwner, async (req, res) => {
try {
const transaction = await commerce.recordManualRefund(getPool(), req.params.id, req.body?.status || 'refunded');
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'refund_recorded', resourceType: 'payment_transaction', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { status: transaction.status } });
res.json(transaction);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => {
try {
const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status);
res.json(reg);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/registrations/:id/convert', requireAdminOrOwner, async (req, res) => {
try {
const result = await offerings.convertRegistrationToEnrollment(getPool(), studentsAuth, req.params.id, req.body || {});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'registration_convert', resourceType: 'registration', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.status(201).json(result);
} catch (e) { handleError(res, e); }
});
// ── Agreements ────────────────────────────────────────────────────────────────────────────
admin.post('/api/admin/agreements', requireAdminOrOwner, async (req, res) => {
try {
const def = await offerings.createAgreementDefinition(getPool(), req.body);
res.status(201).json(def);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/agreements', requireAnyInstructor, async (req, res) => {
res.json(await offerings.listAgreementDefinitions(getPool()));
});
admin.post('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => {
try {
const acceptance = await offerings.recordAgreementAcceptance(getPool(), { ...req.body, registrationId: req.params.id, ipAddress: req.ip });
res.status(201).json(acceptance);
} catch (e) { handleError(res, e); }
});
admin.get('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => {
res.json(await offerings.listAcceptancesForRegistration(getPool(), req.params.id));
});
};
+229
View File
@@ -0,0 +1,229 @@
// Phase 9 — checkout/payment business logic. Sits ABOVE offerings.js (registration/agreement/
// conversion) and payment-provider.js (the Stripe/test abstraction) — this file is the only
// place either one gets combined with the other. Same error convention as offerings.js/
// enrollment.js: throw `Error` with a `.code` the route layer maps to an HTTP status.
const offerings = require('./offerings');
const notificationsLib = require('./notifications');
const metrics = require('./metrics');
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
function err(code, message) {
return Object.assign(new Error(message || code), { code });
}
// ── Checkout creation (Step 5/6) ─────────────────────────────────────────────────────────────
// Verifies, in order: registration exists and hasn't already gone past the point checkout makes
// sense; the offering actually has a price configured (a free offering never needs this at
// all); registration is still open (mirrors offerings.js's own assertRegistrationOpen, since an
// offering can close between registration and checkout); every required agreement is already
// accepted (Step 6's explicit "before checkout"). amount/currency ALWAYS come from the
// offering's own server-side price — never from the caller, closing Step 5's explicit
// "browser cannot choose price" requirement structurally, not just by convention.
async function createCheckoutSession(pool, provider, registrationId, { successUrl, cancelUrl }) {
const reg = await offerings.getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
if (['enrollment_activated', 'cancelled'].includes(reg.status)) throw err('INVALID_STATE', `A registration in status '${reg.status}' cannot start checkout.`);
const offering = await offerings.getOffering(pool, reg.offering_id);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
if (offering.price_amount === null) throw err('NO_PRICE_CONFIGURED', 'This offering has no price configured — it does not require checkout.');
offerings.assertRegistrationOpen(offering);
const agreementStatus = await offerings.getRegistrationAgreementStatus(pool, registrationId);
if (agreementStatus.missing.length) {
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
}
const { rows } = await pool.query(
`INSERT INTO payment_transactions (registration_id, provider, status, amount, currency)
VALUES ($1, $2, 'pending', $3, $4) RETURNING *`,
[registrationId, provider.name, offering.price_amount, offering.price_currency]
);
const transaction = rows[0];
let session;
try {
session = await provider.createCheckoutSession({
amountCents: offering.price_amount, currency: offering.price_currency, productName: offering.name,
successUrl, cancelUrl, metadata: { registration_id: registrationId, transaction_id: transaction.transaction_id },
});
} catch (e) {
await pool.query(`UPDATE payment_transactions SET status = 'failed', failed_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
metrics.recordCheckoutSession('error');
throw e;
}
await pool.query(`UPDATE payment_transactions SET provider_reference = $2, updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id, session.providerReference]);
metrics.recordCheckoutSession('created');
return { transactionId: transaction.transaction_id, checkoutUrl: session.checkoutUrl, providerReference: session.providerReference };
}
// ── Webhook processing (Step 8/9/10/11) — idempotent by construction ────────────────────────
// The INSERT into payment_webhook_events is the entire idempotency mechanism (a real unique
// primary key on the provider's own event id, ON CONFLICT DO NOTHING) — if this call reports
// `duplicate`, NOTHING below it ever runs, so a payment can never be double-recorded, an
// enrollment can never be double-converted, and a notification can never be double-sent no
// matter how many times (or how concurrently) the same event arrives.
async function processWebhookEvent(pool, studentsAuth, provider, normalizedEvent) {
const { eventId, eventType, providerReference, normalizedStatus } = normalizedEvent;
const { rows: inserted } = await pool.query(
`INSERT INTO payment_webhook_events (webhook_event_id, provider, event_type) VALUES ($1, $2, $3)
ON CONFLICT (webhook_event_id) DO NOTHING RETURNING *`,
[eventId, provider.name, eventType]
);
if (!inserted[0]) {
metrics.recordWebhook(eventType, 'duplicate');
return { result: 'duplicate' };
}
if (!normalizedStatus) {
await pool.query(`UPDATE payment_webhook_events SET result = 'ignored', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
metrics.recordWebhook(eventType, 'ignored');
return { result: 'ignored' };
}
// Phase 9.1 real-Stripe-TEST fix — providerReference is usually the Checkout Session id, but
// for payment_intent.payment_failed/charge.refunded StripeProvider.normalizeEvent() instead
// returns OUR OWN transaction_id (there is no other reliable Stripe-side reference for those
// event types back to the Session-keyed row) — match either column, never a schema change.
const { rows: txRows } = await pool.query(
`SELECT * FROM payment_transactions WHERE provider_reference = $1 OR transaction_id::text = $1`, [providerReference]
);
const transaction = txRows[0];
if (!transaction) {
await pool.query(`UPDATE payment_webhook_events SET result = 'error', processed_at = now() WHERE webhook_event_id = $1`, [eventId]);
metrics.recordWebhook(eventType, 'error');
return { result: 'error', reason: 'UNKNOWN_TRANSACTION' };
}
await pool.query(`UPDATE payment_webhook_events SET transaction_id = $2, result = 'processed', processed_at = now() WHERE webhook_event_id = $1`, [eventId, transaction.transaction_id]);
if (normalizedStatus === 'paid') await handlePaymentSuccess(pool, studentsAuth, transaction);
else if (normalizedStatus === 'failed') await handlePaymentFailure(pool, transaction);
else if (normalizedStatus === 'cancelled') await handleCheckoutAbandoned(pool, transaction);
else if (normalizedStatus === 'refunded' || normalizedStatus === 'partially_refunded') await handleRefund(pool, transaction, normalizedStatus);
metrics.recordWebhook(eventType, 'processed');
metrics.recordPayment(normalizedStatus, provider.name);
return { result: 'processed', normalizedStatus };
}
// paid -> transaction paid -> registration confirmed -> the EXACT existing Phase 8 conversion
// flow (never duplicated here) -> notifications. Reuses offerings.transitionRegistration/
// convertRegistrationToEnrollment exactly as an admin action already would.
async function handlePaymentSuccess(pool, studentsAuth, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'paid', paid_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (!reg || ['enrollment_activated', 'cancelled'].includes(reg.status)) { metrics.recordConversion('skipped'); return; }
// Walk the registration up to 'confirmed' through whatever real transitions are actually
// still needed — a website submission may already be 'submitted'/'agreements_pending', an
// admin-entered one might still be 'started'. Only ever moves FORWARD through the same
// server-defined transition table offerings.js already enforces, never skips a real state.
const path = { started: 'submitted', submitted: 'agreements_pending', agreements_pending: 'confirmed', reviewed: 'confirmed', payment_pending: 'confirmed' };
let current = reg.status;
while (current !== 'confirmed' && path[current]) {
await offerings.transitionRegistration(pool, transaction.registration_id, path[current]);
current = path[current];
}
if (current !== 'confirmed') { metrics.recordConversion('blocked'); return; } // e.g. AGREEMENTS_INCOMPLETE would have thrown above already, but stay defensive
try {
// No separate "registration confirmed" notification here — transitionRegistration('confirmed')
// above already fired that one (Phase 8's own logic). The "you're enrolled, here's how to
// log in" signal comes from convertRegistrationToEnrollment's own notifyForEnrollment call
// (Phase 9's fix — see that function's comment) — sending both would be a real, needless
// duplicate email to the same family for the same underlying event.
await offerings.convertRegistrationToEnrollment(pool, studentsAuth, transaction.registration_id, {});
metrics.recordConversion('success');
} catch (e) {
metrics.recordConversion('error');
console.error('[commerce] enrollment conversion after payment failed:', e.message);
}
}
async function handlePaymentFailure(pool, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'failed', failed_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (reg) {
await notificationsLib.enqueueForRegistration(pool, {
registration: reg, notificationType: 'registration_needs_action',
metadata: { display_name: reg.registrant_display_name, offering_name: (await offerings.getOffering(pool, reg.offering_id))?.name, reason: 'Your payment did not go through — please try again.' },
}).catch((e) => console.error('[commerce] payment-failure notification failed:', e.message));
}
// Deliberately does NOT release the seat or cancel the registration — a failed payment
// attempt is not the same as an abandoned one; the registrant may simply retry checkout with
// the same reservation still held. See handleCheckoutAbandoned/the reaper for actual release.
}
// A checkout that expired without ever completing (Stripe's own checkout.session.expired, or
// the reaper's timeout below) — release the seat, cancel the registration. Never touches a
// PAID registration (both callers only ever act on a still-pending transaction/registration).
async function handleCheckoutAbandoned(pool, transaction) {
await pool.query(`UPDATE payment_transactions SET status = 'cancelled', cancelled_at = now(), updated_at = now() WHERE transaction_id = $1`, [transaction.transaction_id]);
const reg = await offerings.getRegistration(pool, transaction.registration_id);
if (reg && !['enrollment_activated', 'cancelled'].includes(reg.status)) {
await offerings.transitionRegistration(pool, transaction.registration_id, 'cancelled'); // releases the seat internally
metrics.recordSeatReleased('checkout_expired');
}
}
// Refund recording only — Step 11's explicit "do not automatically erase academic history."
// Commerce status (this table) is fully separate from registration status (offering_
// registrations.status) and academic enrollment status (enrollments.status) — a refund NEVER
// cascades into cancelling either of those automatically; that remains a real, separate
// business decision an admin makes deliberately (see docs/PHASE9_COMMERCE_AND_LAUNCH.md's
// "three separate statuses" section).
async function handleRefund(pool, transaction, status) {
const column = status === 'refunded' ? 'refunded_at' : null;
await pool.query(
`UPDATE payment_transactions SET status = $2, ${column ? `refunded_at = now(),` : ''} updated_at = now() WHERE transaction_id = $1`,
[transaction.transaction_id, status]
);
}
// ── Admin-recorded refund. No automated Stripe refund call this phase — Step 11 explicitly
// scopes this to RECORDING a refund that happened, not building a refund-management product. ──
async function recordManualRefund(pool, transactionId, status) {
if (!['refunded', 'partially_refunded'].includes(status)) throw err('VALIDATION', "status must be 'refunded' or 'partially_refunded'.");
const { rows } = await pool.query(
`UPDATE payment_transactions SET status = $2, refunded_at = now(), updated_at = now() WHERE transaction_id = $1 AND status = 'paid' RETURNING *`,
[transactionId, status]
);
if (!rows[0]) throw err('INVALID_STATE', 'Only a paid transaction can be marked refunded.');
return rows[0];
}
// ── Abandoned reservation reaper (Step 27) ───────────────────────────────────────────────────
// Finds registrations whose reservation window has passed with no successful payment, and
// releases them exactly the way an expired checkout would — same function, same audit
// footprint. Never touches a registration already enrollment_activated or cancelled (the
// WHERE clause excludes both), so a paid registration can never be swept up here regardless of
// timing.
async function reapExpiredReservations(pool) {
const { rows } = await pool.query(
`SELECT * FROM offering_registrations
WHERE reservation_expires_at IS NOT NULL AND reservation_expires_at < now()
AND status NOT IN ('enrollment_activated', 'cancelled', 'confirmed')`
);
let released = 0;
for (const reg of rows) {
await offerings.transitionRegistration(pool, reg.registration_id, 'cancelled');
released++;
metrics.recordSeatReleased('reservation_expired');
}
return { checked: rows.length, released };
}
async function listTransactionsForRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM payment_transactions WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]);
return rows;
}
module.exports = {
RESERVATION_TIMEOUT_MINUTES, createCheckoutSession, processWebhookEvent, recordManualRefund,
reapExpiredReservations, listTransactionsForRegistration,
};
+73 -1
View File
@@ -131,4 +131,76 @@ If you didn't make this change, contact your instructor right away — your othe
return { subject, text, html }; return { subject, text, html };
} }
module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml }; // Phase 8 §10 — registration-stage transactional templates. Same conventions as above:
// escapeHtml on every caller-supplied value (offeringName/displayName can come from a website
// registration submission, not just admin-typed text), no tracking, no marketing content, both
// text and html written deliberately.
function registrationReceived({ displayName, offeringName }) {
const subject = `We Received Your Registration — ${offeringName}`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName);
const text = `Hi ${displayName},
We received your registration for ${offeringName} at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>We received your registration for <strong>${safeOffering}</strong> at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.</p>`);
return { subject, text, html };
}
function registrationConfirmed({ displayName, offeringName }) {
const subject = `Registration Confirmed — ${offeringName}`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName);
const text = `Hi ${displayName},
Your registration for ${offeringName} is confirmed. You'll receive a separate email with account access details shortly.
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> is confirmed. You'll receive a separate email with account access details shortly.</p>`);
return { subject, text, html };
}
function registrationNeedsAction({ displayName, offeringName, reason }) {
const subject = `Action Needed — ${offeringName} Registration`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeReason = escapeHtml(reason || 'Please contact us for details.');
const text = `Hi ${displayName},
Your registration for ${offeringName} needs one more thing before it can be confirmed: ${reason || 'please contact us for details.'}
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> needs one more thing before it can be confirmed:</p><p>${safeReason}</p>`);
return { subject, text, html };
}
function agreementRequired({ displayName, offeringName, agreementTitle }) {
const subject = `Agreement Required — ${offeringName}`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeAgreement = escapeHtml(agreementTitle);
const text = `Hi ${displayName},
Before your registration for ${offeringName} can be confirmed, we need your acceptance of: ${agreementTitle}. Your instructor/admin contact can help you complete this.
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>Before your registration for <strong>${safeOffering}</strong> can be confirmed, we need your acceptance of:</p><p><strong>${safeAgreement}</strong></p>`);
return { subject, text, html };
}
// Operational notice only (e.g. "Camp starts Monday, here's what to bring") — deliberately NOT
// a marketing template. See docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md's boundary note.
function campOperationalUpdate({ displayName, offeringName, message }) {
const subject = `${offeringName} — Update`;
const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeMessage = escapeHtml(message);
const text = `Hi ${displayName},
An update about ${offeringName}:
${message}
— ${BRAND}`;
const html = wrap(`<p>Hi ${safeName},</p><p>An update about <strong>${safeOffering}</strong>:</p><p>${safeMessage}</p>`);
return { subject, text, html };
}
module.exports = {
activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml,
registrationReceived, registrationConfirmed, registrationNeedsAction, agreementRequired, campOperationalUpdate,
};
+14 -3
View File
@@ -146,7 +146,14 @@ async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source,
// handed back so a future email/notification step — not built in this phase — could relay it). // handed back so a future email/notification step — not built in this phase — could relay it).
// A second concurrent call resolving the "same new student" case falls back to re-matching // A second concurrent call resolving the "same new student" case falls back to re-matching
// after a unique-constraint hit, the identical insert/on-conflict-reselect pattern used above. // after a unique-constraint hit, the identical insert/on-conflict-reselect pattern used above.
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }) { // `returnToken` (default false) attaches the raw activation token to the returned row as
// `_activationToken` when a NEW student is actually created. Default OFF and never passed by
// createFromIntegrationEvent — the public website integration endpoint's own contract
// (docs/WEBSITE_ENROLLMENT_INTEGRATION.md) explicitly never returns this value; only Phase 7's
// admin-only registration-conversion route (offerings.js) opts in, the same trust level an
// instructor manually creating a student already has via POST /api/admin/students (which
// already returns activation_token directly).
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }, { returnToken = false } = {}) {
const identifier = studentEmail || studentLoginIdentifier; const identifier = studentEmail || studentLoginIdentifier;
if (!identifier) throw err('STUDENT_IDENTIFIER_REQUIRED', 'student.email or student.login_identifier is required.'); if (!identifier) throw err('STUDENT_IDENTIFIER_REQUIRED', 'student.email or student.login_identifier is required.');
const existing = await studentsAuth.getStudentByIdentifier(pool, identifier); const existing = await studentsAuth.getStudentByIdentifier(pool, identifier);
@@ -160,8 +167,8 @@ async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studen
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4) RETURNING *`, `INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4) RETURNING *`,
[studentId, displayName, studentEmail ? String(studentEmail).toLowerCase().trim() : null, studentLoginIdentifier ? String(studentLoginIdentifier).trim() : null] [studentId, displayName, studentEmail ? String(studentEmail).toLowerCase().trim() : null, studentLoginIdentifier ? String(studentLoginIdentifier).trim() : null]
); );
await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
return rows[0]; return returnToken ? { ...rows[0], _activationToken: token } : rows[0];
} catch (e) { } catch (e) {
if (e.code === '23505') { if (e.code === '23505') {
const reMatched = await studentsAuth.getStudentByIdentifier(pool, identifier); const reMatched = await studentsAuth.getStudentByIdentifier(pool, identifier);
@@ -241,4 +248,8 @@ module.exports = {
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram, deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment, createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
maybeCompleteEnrollment, enrollmentContactEmail, maybeCompleteEnrollment, enrollmentContactEmail,
// Phase 7 — offerings.js's registration-to-enrollment conversion reuses this exact
// create-or-match logic rather than duplicating it (a registration's eventual student is
// resolved the same way an integration-event or manual enrollment's student already is).
resolveOrCreateStudent,
}; };
+119
View File
@@ -16,6 +16,9 @@ const enrollment = require('./enrollment');
const studentsAuth = require('./students-auth'); const studentsAuth = require('./students-auth');
const notifications = require('./notifications'); const notifications = require('./notifications');
const programsLib = require('./programs'); const programsLib = require('./programs');
const offerings = require('./offerings');
const commerce = require('./commerce');
const { getPaymentProvider } = require('./payment-provider');
const metrics = require('./metrics'); const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET; const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
@@ -36,6 +39,17 @@ function enrollmentErrorStatus(code) {
return 500; return 500;
} }
// Phase 8 §13 — website registration submission. Same auth boundary and same file as
// /api/integrations/enrollments above (Step 15's "prefer server-to-server, don't introduce a
// second scheme") — a registration is an earlier, separate step from an enrollment (see
// docs/OFFERING_AND_REGISTRATION_MODEL.md), not a replacement for the existing endpoint.
function registrationErrorStatus(code) {
if (code === 'OFFERING_NOT_FOUND') return 404;
if (code === 'EVENT_ID_REQUIRED' || code === 'OFFERING_SLUG_REQUIRED' || code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
if (['OFFERING_NOT_OPEN', 'OFFERING_CANCELLED', 'OFFERING_COMPLETED', 'OFFERING_FULL', 'REGISTRATION_NOT_YET_OPEN', 'REGISTRATION_CLOSED'].includes(code)) return 409;
return 500;
}
module.exports = function registerIntegrationRoutes(app, deps) { module.exports = function registerIntegrationRoutes(app, deps) {
const { getPool, recordAudit, wrap } = deps; const { getPool, recordAudit, wrap } = deps;
@@ -114,4 +128,109 @@ module.exports = function registerIntegrationRoutes(app, deps) {
created: result.created, created: result.created,
}); });
})); }));
// Phase 8 §13/§14/§15 — the website's registration submission endpoint. Same shared-secret
// header as above, same "server-to-server only, never browser JS" boundary. Idempotent via
// event_id -> offering_registrations.external_reference (a real unique index,
// 035_phase8_...sql), the identical pattern /api/integrations/enrollments already uses.
app.post('/api/integrations/registrations', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
return res.status(401).json({ error: 'unauthorized' });
}
const { event_id, source, offering_slug, registrant, guardian, readiness_notes } = req.body || {};
let result;
try {
result = await offerings.createRegistrationFromIntegrationEvent(getPool(), {
eventId: event_id, offeringSlug: offering_slug, source: source || 'website',
registrantDisplayName: registrant?.display_name, registrantEmail: registrant?.email, isMinor: !!registrant?.is_minor,
guardianDisplayName: guardian?.display_name, guardianEmail: guardian?.email, guardianPhone: guardian?.phone,
readinessNotes: readiness_notes,
});
} catch (e) {
if (e.code) return res.status(registrationErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
await recordAudit(getPool(), {
actorType: 'integration', actorId: source || 'website',
action: result.created ? 'registration_integration_created' : 'registration_integration_duplicate',
resourceType: 'registration', resourceId: result.registration.registration_id, result: 'success', sourceIp: req.ip,
metadata: { event_id, offering_slug },
});
res.status(result.created ? 201 : 200).json({
registration_id: result.registration.registration_id,
offering_id: result.registration.offering_id,
status: result.registration.status,
created: result.created,
});
}));
// Phase 9 §6 — closes the one gap Phase 8 explicitly left open: agreement acceptance is now
// reachable from the public registration contract. An adult accepts as 'registrant' (their
// own student_id doesn't exist yet); a minor's guardian accepts as 'guardian', using the
// guardian_id the registration already carries from submission. Same shared-secret boundary
// as every other route in this file.
app.post('/api/integrations/registrations/:id/agreements', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
const registration = await offerings.getRegistration(getPool(), req.params.id);
if (!registration) return res.status(404).json({ error: 'NOT_FOUND' });
const acceptedByType = registration.is_minor ? 'guardian' : 'registrant';
try {
const acceptance = await offerings.recordAgreementAcceptance(getPool(), {
agreementId: req.body?.agreement_id, acceptedByType,
acceptedByGuardianId: registration.is_minor ? registration.guardian_id : undefined,
registrationId: registration.registration_id, ipAddress: req.ip,
});
await recordAudit(getPool(), { actorType: 'integration', actorId: 'website', action: 'agreement_accepted', resourceType: 'registration', resourceId: registration.registration_id, result: 'success', sourceIp: req.ip, metadata: { agreement_id: req.body?.agreement_id } });
res.status(201).json({ accepted: true, agreement_id: acceptance.agreement_id, accepted_version: acceptance.accepted_version });
} catch (e) {
if (e.code) return res.status(e.code === 'AGREEMENT_NOT_FOUND' ? 404 : 400).json({ error: e.code, message: e.message });
throw e;
}
}));
// Phase 9 §5 — server-to-server checkout creation. The website's backend calls this with a
// registration_id it already has (from the registration-submission response above); the
// browser never talks to this endpoint directly, and never supplies an amount/currency —
// both always come from the offering's own server-side price (commerce.js's own
// createCheckoutSession, Step 5's explicit "browser cannot choose price" requirement).
app.post('/api/integrations/registrations/:id/checkout', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
const { success_url, cancel_url } = req.body || {};
if (!success_url || !cancel_url) return res.status(400).json({ error: 'VALIDATION', message: 'success_url and cancel_url are required.' });
try {
const provider = getPaymentProvider();
const result = await commerce.createCheckoutSession(getPool(), provider, req.params.id, { successUrl: success_url, cancelUrl: cancel_url });
await recordAudit(getPool(), { actorType: 'integration', actorId: 'website', action: 'checkout_created', resourceType: 'registration', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { transaction_id: result.transactionId } });
res.status(201).json({ checkout_url: result.checkoutUrl, transaction_id: result.transactionId });
} catch (e) {
if (e.code) {
const status = { NOT_FOUND: 404, OFFERING_NOT_FOUND: 404, NO_PRICE_CONFIGURED: 409, INVALID_STATE: 409, AGREEMENTS_INCOMPLETE: 409, OFFERING_NOT_OPEN: 409, OFFERING_CANCELLED: 409, OFFERING_COMPLETED: 409, OFFERING_FULL: 409, REGISTRATION_NOT_YET_OPEN: 409, REGISTRATION_CLOSED: 409, PROVIDER_NOT_CONFIGURED: 503 }[e.code] || 500;
return res.status(status).json({ error: e.code, message: e.message });
}
throw e;
}
}));
// Phase 9 §16/§17 — a safe status lookup for the website's post-checkout success page. NEVER
// derive "paid" from a URL param the browser controls (a returning `?paid=true` proves
// nothing) — the website's backend polls this instead, server-to-server, same shared secret,
// until it sees a terminal state. Deliberately minimal: no PII, no payment amount, no provider
// reference, nothing an admin view would show — just enough to render "processing" /
// "confirmed" / "action needed" on the success page.
app.get('/api/integrations/registrations/:id/status', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) return res.status(401).json({ error: 'unauthorized' });
const registration = await offerings.getRegistration(getPool(), req.params.id);
if (!registration) return res.status(404).json({ error: 'NOT_FOUND' });
const transactions = await commerce.listTransactionsForRegistration(getPool(), registration.registration_id);
res.json({
registration_id: registration.registration_id,
status: registration.status,
payment_status: transactions[0]?.status || null,
enrollment_activated: registration.status === 'enrollment_activated',
});
}));
}; };
+30
View File
@@ -41,6 +41,30 @@ function recordStudentActivation(result) {
studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1; studentActivationTotals[result] = (studentActivationTotals[result] || 0) + 1;
} }
// Phase 9 §22 — commerce metrics. Every label here is a small, fixed enum (result/status/
// provider/event_type) — never a registration_id, customer_id, payment_intent_id, or email, per
// the explicit "avoid high-cardinality" requirement.
const checkoutSessionTotals = {}; // result: 'created' | 'error'
function recordCheckoutSession(result) { checkoutSessionTotals[result] = (checkoutSessionTotals[result] || 0) + 1; }
const paymentTotals = {}; // key: `${status}|${provider}`
function recordPayment(status, provider) {
const key = `${status}|${provider}`;
paymentTotals[key] = (paymentTotals[key] || 0) + 1;
}
const webhookTotals = {}; // key: `${event_type}|${result}` — event_type is a small, fixed provider vocabulary, never caller-supplied free text
function recordWebhook(eventType, result) {
const key = `${eventType}|${result}`;
webhookTotals[key] = (webhookTotals[key] || 0) + 1;
}
const conversionTotals = {}; // result: 'success' | 'error' | 'blocked' | 'skipped'
function recordConversion(result) { conversionTotals[result] = (conversionTotals[result] || 0) + 1; }
const seatReleasedTotals = {}; // reason: 'checkout_expired' | 'reservation_expired'
function recordSeatReleased(reason) { seatReleasedTotals[reason] = (seatReleasedTotals[reason] || 0) + 1; }
// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a // 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 // normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
// keep in sync with reality). // keep in sync with reality).
@@ -150,10 +174,16 @@ async function renderMetrics(pool, provisioner) {
labeledCounter('rootnode_notification_delivery_attempts_total', 'Notification delivery attempts by provider and outcome.', notificationAttemptTotals, ['provider', 'result']), 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_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), line('rootnode_student_activations_invalid_total', 'Activation attempts with an invalid or expired token.', 'counter', studentActivationTotals.invalid_or_expired_token || 0),
labeledCounter('rootnode_checkout_sessions_total', 'Checkout session creation attempts by result.', checkoutSessionTotals, ['result']),
labeledCounter('rootnode_payments_total', 'Payment webhook outcomes by status and provider.', paymentTotals, ['status', 'provider']),
labeledCounter('rootnode_payment_webhooks_total', 'Payment webhook deliveries by event type and processing result.', webhookTotals, ['event_type', 'result']),
labeledCounter('rootnode_payment_conversion_total', 'Registration-to-enrollment conversions triggered by a successful payment, by result.', conversionTotals, ['result']),
labeledCounter('rootnode_seats_released_total', 'Reserved seats released by reason.', seatReleasedTotals, ['reason']),
].join('\n'); ].join('\n');
} }
module.exports = { module.exports = {
recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent, recordStudentLoginSuccess, recordStudentLoginFailure, recordEnrollmentEvent,
recordNotificationAttempt, recordStudentActivation, renderMetrics, recordNotificationAttempt, recordStudentActivation, renderMetrics,
recordCheckoutSession, recordPayment, recordWebhook, recordConversion, recordSeatReleased,
}; };
+17
View File
@@ -44,6 +44,23 @@ function renderFor(row) {
if (row.notification_type === 'password_changed') { if (row.notification_type === 'password_changed') {
return templates.passwordChanged({ displayName }); return templates.passwordChanged({ displayName });
} }
// Phase 8 — registration-stage notifications. No token/public-origin dependency, so these
// never fail on missing PUBLIC_APP_ORIGIN config the way activation/reset do.
if (row.notification_type === 'registration_received') {
return templates.registrationReceived({ displayName, offeringName: row.metadata?.offering_name || 'your offering' });
}
if (row.notification_type === 'registration_confirmed') {
return templates.registrationConfirmed({ displayName, offeringName: row.metadata?.offering_name || 'your offering' });
}
if (row.notification_type === 'registration_needs_action') {
return templates.registrationNeedsAction({ displayName, offeringName: row.metadata?.offering_name || 'your offering', reason: row.metadata?.reason });
}
if (row.notification_type === 'agreement_required') {
return templates.agreementRequired({ displayName, offeringName: row.metadata?.offering_name || 'your offering', agreementTitle: row.metadata?.agreement_title || 'a required agreement' });
}
if (row.notification_type === 'camp_operational_update') {
return templates.campOperationalUpdate({ displayName, offeringName: row.metadata?.offering_name || 'your offering', message: row.metadata?.message || '' });
}
return null; return null;
} }
+78 -1
View File
@@ -38,9 +38,57 @@ async function resolveRecipient(pool, studentId, enrollmentId) {
const contact = enrollmentContactEmail(enrollmentRows[0]); const contact = enrollmentContactEmail(enrollmentRows[0]);
if (contact) return contact; if (contact) return contact;
} }
// Phase 8 §9 — prefer the real guardian model (Phase 7) over nothing: a no-email student with
// no enrollment-level contact_email but a real, communications-enabled primary guardian still
// gets notified. Tried last, after both of the above, so an existing student.email or the
// established enrollment.metadata.contact_email always wins first — this is purely a new
// fallback, never a behavior change for any student who already resolves correctly today.
const { rows: guardianRows } = await pool.query(
`SELECT g.email FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id
WHERE sg.student_id = $1 AND sg.can_receive_communications = true AND g.email IS NOT NULL
ORDER BY sg.is_primary_contact DESC LIMIT 1`,
[studentId]
);
if (guardianRows[0]?.email) return guardianRows[0].email;
return null; return null;
} }
// Phase 8 — the registration-stage counterpart to enqueue() above, for a notification that may
// need to go out BEFORE any student row exists at all (a brand-new guardian-submitted
// registration). Recipient resolution order: the registration's own student (if already
// converted — reuses resolveRecipient/the guardian fallback above), else its linked guardian,
// else the registrant's own supplied email. Never creates a second notification pipeline —
// same notification_events table, same worker, same templates module.
async function resolveRegistrationRecipient(pool, registration) {
if (registration.student_id) {
const viaStudent = await resolveRecipient(pool, registration.student_id, registration.enrollment_id);
if (viaStudent) return viaStudent;
}
if (registration.guardian_id) {
const { rows } = await pool.query(`SELECT email FROM guardians WHERE guardian_id = $1`, [registration.guardian_id]);
if (rows[0]?.email) return rows[0].email;
}
return registration.registrant_email || null;
}
async function enqueueForRegistration(pool, { registration, notificationType, metadata }) {
const recipient = await resolveRegistrationRecipient(pool, registration);
if (!recipient) {
const { rows } = await pool.query(
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata)
VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`,
[registration.student_id || null, registration.registration_id, notificationType, JSON.stringify({})]
);
return rows[0];
}
const { rows } = await pool.query(
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, metadata)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[registration.student_id || null, registration.registration_id, notificationType, recipient, JSON.stringify(metadata || {})]
);
return rows[0];
}
// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if // 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 // 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 // state. A notification with no resolvable recipient is still recorded (never silently
@@ -135,16 +183,43 @@ async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId
}); });
} }
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation');
return enqueue(pool, { const notification = await enqueue(pool, {
studentId, enrollmentId, notificationType: 'activation', plaintextToken: token, studentId, enrollmentId, notificationType: 'activation', plaintextToken: token,
metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend }, metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend },
}); });
// Phase 9 — the raw token is attached here (never persisted beyond the notification row's own
// transient metadata, same as always) purely so a caller that itself needs to hand the
// student a working activation link right now (offerings.js's registration-conversion flow,
// matching the trust level POST /api/admin/students already has) doesn't have to re-issue a
// second token that would silently invalidate this one.
return { ...notification, _activationToken: token };
} }
async function listForStudent(pool, studentId) { async function listForStudent(pool, studentId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]); const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
return rows; return rows;
} }
// Phase 8 — every notification tied to a registration, including pre-conversion ones with no
// student_id yet at all.
// Phase 9 — a registration's full communication history spans two eras: pre-conversion
// (registration_received/confirmed/needs_action, agreement_required — keyed by registration_id,
// no student yet) and post-conversion (the activation/enrollment_confirmation email
// notifyForEnrollment sends — keyed by student_id/enrollment_id like every other enrollment,
// never by registration_id, since it isn't a registration-specific concept). Both belong in one
// admin-visible timeline for the SAME registration, so this reads the registration row itself
// to find its (possibly still-null) student_id/enrollment_id and unions both sources.
async function listForRegistration(pool, registrationId) {
const { rows: regRows } = await pool.query(`SELECT student_id, enrollment_id FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
const reg = regRows[0];
const { rows } = await pool.query(
`SELECT * FROM notification_events
WHERE registration_id = $1
OR (enrollment_id IS NOT NULL AND enrollment_id = $2)
ORDER BY created_at DESC`,
[registrationId, reg?.enrollment_id || null]
);
return rows;
}
async function getById(pool, notificationId) { async function getById(pool, notificationId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]); const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
return rows[0] || null; return rows[0] || null;
@@ -177,6 +252,7 @@ function maskEmail(email) {
function toAdminView(row) { function toAdminView(row) {
return { return {
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id, notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id,
registration_id: row.registration_id,
notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email), 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, 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, created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
@@ -186,4 +262,5 @@ function toAdminView(row) {
module.exports = { module.exports = {
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment, resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS, listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
enqueueForRegistration, listForRegistration,
}; };
+599
View File
@@ -0,0 +1,599 @@
// Phase 7 — the business/product architecture's core library: organizations, locations,
// guardians, curriculum packages, offerings, registrations, and agreement acceptances. Same
// error convention as enrollment.js/classroom.js: throw `Error` with a `.code` the route layer
// maps to an HTTP status. See docs/OFFERING_AND_REGISTRATION_MODEL.md for the full lifecycle
// diagrams this file implements, and docs/STUDENT_GUARDIAN_DATA_MODEL.md for the guardian
// model's own reasoning.
const enrollmentLib = require('./enrollment');
const notificationsLib = require('./notifications');
function err(code, message) {
return Object.assign(new Error(message || code), { code });
}
// ── Organizations / Locations ──────────────────────────────────────────────────────────────
async function createOrganization(pool, { organizationId, name, type, contactName, contactEmail, contactPhone, notes }) {
if (!organizationId || !name) throw err('VALIDATION', 'organizationId and name are required.');
const { rows } = await pool.query(
`INSERT INTO organizations (organization_id, name, type, contact_name, contact_email, contact_phone, notes)
VALUES ($1, $2, COALESCE($3, 'other'), $4, $5, $6, $7) RETURNING *`,
[organizationId, name, type || null, contactName || null, contactEmail || null, contactPhone || null, notes || null]
);
return rows[0];
}
async function listOrganizations(pool) {
const { rows } = await pool.query(`SELECT * FROM organizations ORDER BY name`);
return rows;
}
async function createLocation(pool, { locationId, organizationId, name, type, addressLine1, addressLine2, city, region, postalCode, country, timezone, capacity, accessNotes }) {
if (!locationId || !name) throw err('VALIDATION', 'locationId and name are required.');
const { rows } = await pool.query(
`INSERT INTO locations (location_id, organization_id, name, type, address_line1, address_line2, city, region, postal_code, country, timezone, capacity, access_notes)
VALUES ($1, $2, $3, COALESCE($4, 'online'), $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`,
[locationId, organizationId || null, name, type || null, addressLine1 || null, addressLine2 || null, city || null, region || null, postalCode || null, country || null, timezone || null, capacity || null, accessNotes || null]
);
return rows[0];
}
// `forInstructor` gates access_notes exactly like course-progress.js gates instructor_notes —
// internal operational detail (building access codes, parking) never leaves the instructor
// surface. See Step 8's explicit "protect private/internal operational notes."
async function listLocations(pool, { forInstructor = false } = {}) {
const { rows } = await pool.query(`SELECT * FROM locations ORDER BY name`);
if (forInstructor) return rows;
return rows.map(({ access_notes, ...rest }) => rest);
}
// ── Guardians ───────────────────────────────────────────────────────────────────────────────
async function createGuardian(pool, { guardianId, displayName, email, phone, notes }) {
if (!guardianId || !displayName) throw err('VALIDATION', 'guardianId and displayName are required.');
const { rows } = await pool.query(
`INSERT INTO guardians (guardian_id, display_name, email, phone, notes) VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[guardianId, displayName, email ? String(email).toLowerCase().trim() : null, phone || null, notes || null]
);
return rows[0];
}
async function linkStudentGuardian(pool, { studentId, guardianId, relationship, isPrimaryContact, canReceiveCommunications }) {
if (!studentId || !guardianId) throw err('VALIDATION', 'studentId and guardianId are required.');
const { rows } = await pool.query(
`INSERT INTO student_guardians (student_id, guardian_id, relationship, is_primary_contact, can_receive_communications)
VALUES ($1, $2, COALESCE($3, 'guardian'), COALESCE($4, false), COALESCE($5, true))
ON CONFLICT (student_id, guardian_id) DO UPDATE SET relationship = EXCLUDED.relationship, is_primary_contact = EXCLUDED.is_primary_contact, can_receive_communications = EXCLUDED.can_receive_communications
RETURNING *`,
[studentId, guardianId, relationship || null, isPrimaryContact ?? null, canReceiveCommunications ?? null]
);
return rows[0];
}
// A plain instructor sees which students they have real cohort access to (via the caller's own
// RBAC check on the route) — this function itself just returns the linked guardians for one
// student, RBAC is the route layer's job, same separation of concerns as everywhere else.
async function listGuardiansForStudent(pool, studentId) {
const { rows } = await pool.query(
`SELECT g.*, sg.relationship, sg.is_primary_contact, sg.can_receive_communications
FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id
WHERE sg.student_id = $1 ORDER BY sg.is_primary_contact DESC, g.display_name`,
[studentId]
);
return rows;
}
// ── Curriculum packages (a reference list into EXISTING courses, never a clone) ─────────────
async function createCurriculumPackage(pool, { curriculumPackageId, name, programId, description }) {
if (!curriculumPackageId || !name || !programId) throw err('VALIDATION', 'curriculumPackageId, name, and programId are required.');
const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]);
if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.');
const { rows } = await pool.query(
`INSERT INTO curriculum_packages (curriculum_package_id, name, program_id, description) VALUES ($1, $2, $3, $4) RETURNING *`,
[curriculumPackageId, name, programId, description || null]
);
return rows[0];
}
async function addCurriculumPackageItem(pool, { curriculumPackageId, courseId, sequence }) {
const { rows: courseRows } = await pool.query(`SELECT 1 FROM courses WHERE course_id = $1`, [courseId]);
if (!courseRows[0]) throw err('COURSE_NOT_FOUND', 'No such course — a package only ever REFERENCES an existing course, never clones one.');
const { rows } = await pool.query(
`INSERT INTO curriculum_package_items (curriculum_package_id, course_id, sequence) VALUES ($1, $2, $3)
ON CONFLICT (curriculum_package_id, course_id) DO UPDATE SET sequence = EXCLUDED.sequence RETURNING *`,
[curriculumPackageId, courseId, sequence]
);
return rows[0];
}
async function getCurriculumPackage(pool, curriculumPackageId) {
const { rows: pkgRows } = await pool.query(`SELECT * FROM curriculum_packages WHERE curriculum_package_id = $1`, [curriculumPackageId]);
if (!pkgRows[0]) return null;
const { rows: items } = await pool.query(
`SELECT cpi.course_id, cpi.sequence, c.title, c.status FROM curriculum_package_items cpi
JOIN courses c ON c.course_id = cpi.course_id WHERE cpi.curriculum_package_id = $1 ORDER BY cpi.sequence`,
[curriculumPackageId]
);
return { ...pkgRows[0], items };
}
// ── Offerings — server-defined status transitions only (Step 3's explicit requirement) ──────
const OFFERING_TRANSITIONS = {
draft: ['published', 'cancelled'],
published: ['enrollment_open', 'cancelled'],
enrollment_open: ['full', 'in_progress', 'cancelled'],
full: ['enrollment_open', 'in_progress', 'cancelled'],
in_progress: ['completed', 'cancelled'],
completed: [],
cancelled: [],
};
async function createOffering(pool, { offeringId, slug, name, programId, curriculumPackageId, deliveryMode, startsAt, endsAt, enrollmentOpensAt, enrollmentClosesAt, capacity, locationId, organizationId, cohortId, recommendedMinAge, recommendedMaxAge, description, createdBy, priceAmount, priceCurrency }) {
if (!offeringId || !slug || !name || !programId || !deliveryMode) throw err('VALIDATION', 'offeringId, slug, name, programId, and deliveryMode are required.');
const { rows: programRows } = await pool.query(`SELECT 1 FROM programs WHERE program_id = $1`, [programId]);
if (!programRows[0]) throw err('PROGRAM_NOT_FOUND', 'No such program.');
try {
const { rows } = await pool.query(
`INSERT INTO offerings (offering_id, slug, name, program_id, curriculum_package_id, delivery_mode, starts_at, ends_at, enrollment_opens_at, enrollment_closes_at, capacity, location_id, organization_id, cohort_id, recommended_min_age, recommended_max_age, description, created_by, price_amount, price_currency)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,COALESCE($20,'usd')) RETURNING *`,
[offeringId, slug, name, programId, curriculumPackageId || null, deliveryMode, startsAt || null, endsAt || null, enrollmentOpensAt || null, enrollmentClosesAt || null, capacity ?? null, locationId || null, organizationId || null, cohortId || null, recommendedMinAge ?? null, recommendedMaxAge ?? null, description || null, createdBy || null, priceAmount ?? null, priceCurrency || null]
);
return rows[0];
} catch (e) {
if (e.code === '23505') throw err('DUPLICATE_SLUG', 'An offering with this slug already exists.');
throw e;
}
}
async function getOffering(pool, offeringId) {
const { rows } = await pool.query(`SELECT * FROM offerings WHERE offering_id = $1`, [offeringId]);
return rows[0] || null;
}
// Phase 9 §28 — a draft offering is created with the bare minimum, then completed over time as
// real business decisions (dates, venue, capacity, price) land — this is the one place those
// get filled in later without recreating the row. Only ever touches columns explicitly present
// in `fields`, so calling it with just `{ priceAmount, priceCurrency }` never disturbs anything
// else. Never allows changing `status` (transitionOffering is the only path for that, with its
// own server-defined transition table) or `slug`/`program_id` (identity fields).
const OFFERING_EDITABLE_FIELDS = {
name: 'name', description: 'description', startsAt: 'starts_at', endsAt: 'ends_at',
enrollmentOpensAt: 'enrollment_opens_at', enrollmentClosesAt: 'enrollment_closes_at', capacity: 'capacity',
locationId: 'location_id', organizationId: 'organization_id', cohortId: 'cohort_id',
recommendedMinAge: 'recommended_min_age', recommendedMaxAge: 'recommended_max_age',
curriculumPackageId: 'curriculum_package_id', priceAmount: 'price_amount', priceCurrency: 'price_currency',
};
async function updateOffering(pool, offeringId, fields) {
const sets = [];
const values = [offeringId];
for (const [key, column] of Object.entries(OFFERING_EDITABLE_FIELDS)) {
if (!(key in fields)) continue;
values.push(fields[key]);
sets.push(`${column} = $${values.length}`);
}
if (!sets.length) throw err('VALIDATION', 'No editable fields provided.');
const { rows } = await pool.query(
`UPDATE offerings SET ${sets.join(', ')}, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`,
values
);
if (!rows[0]) throw err('NOT_FOUND', 'No such offering.');
return rows[0];
}
async function listOfferings(pool, { status } = {}) {
const { rows } = await pool.query(
status ? `SELECT * FROM offerings WHERE status = $1 ORDER BY created_at DESC` : `SELECT * FROM offerings ORDER BY created_at DESC`,
status ? [status] : []
);
return rows;
}
async function transitionOffering(pool, offeringId, toStatus) {
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('NOT_FOUND', 'No such offering.');
const allowed = OFFERING_TRANSITIONS[offering.status] || [];
if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move an offering from '${offering.status}' to '${toStatus}'.`);
const { rows } = await pool.query(`UPDATE offerings SET status = $2, version = version + 1, updated_at = now() WHERE offering_id = $1 RETURNING *`, [offeringId, toStatus]);
return rows[0];
}
async function assignInstructorToOffering(pool, offeringId, instructorId, role) {
const { rows } = await pool.query(
`INSERT INTO instructor_offerings (offering_id, instructor_id, role) VALUES ($1, $2, COALESCE($3, 'teacher'))
ON CONFLICT (offering_id, instructor_id) DO UPDATE SET role = EXCLUDED.role RETURNING *`,
[offeringId, instructorId, role || null]
);
return rows[0];
}
// Single atomic UPDATE, not SELECT-count-then-INSERT — see 034_phase7_....sql's own header
// comment for why this specific shape avoids the race the authorization explicitly warned
// against. A NULL capacity offering (typical for self_paced) never needs reservation at all.
async function reserveSeat(pool, offeringId) {
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('NOT_FOUND', 'No such offering.');
if (offering.capacity === null) return offering; // unlimited — nothing to reserve
const { rows } = await pool.query(
`UPDATE offerings SET reserved_seats = reserved_seats + 1, updated_at = now()
WHERE offering_id = $1 AND reserved_seats < capacity RETURNING *`,
[offeringId]
);
if (!rows[0]) throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
return rows[0];
}
async function releaseSeat(pool, offeringId) {
const { rows } = await pool.query(
`UPDATE offerings SET reserved_seats = GREATEST(reserved_seats - 1, 0), updated_at = now() WHERE offering_id = $1 RETURNING *`,
[offeringId]
);
return rows[0];
}
// ── Registrations ────────────────────────────────────────────────────────────────────────────
const REGISTRATION_TRANSITIONS = {
started: ['submitted', 'cancelled'],
submitted: ['reviewed', 'agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
reviewed: ['agreements_pending', 'payment_pending', 'confirmed', 'cancelled'],
agreements_pending: ['payment_pending', 'confirmed', 'cancelled'],
payment_pending: ['confirmed', 'cancelled'],
confirmed: ['enrollment_activated', 'cancelled'],
enrollment_activated: [],
cancelled: [],
};
// Phase 8 §21 — registration availability is server-authoritative, checked here (the one place
// a registration row is actually created), not left to a website's own hidden-button logic.
// Distinct error codes per reason so a caller (website or admin UI) can show something more
// useful than a generic failure.
function assertRegistrationOpen(offering) {
if (offering.status === 'draft') throw err('OFFERING_NOT_OPEN', 'This offering is not yet open for registration.');
if (offering.status === 'cancelled') throw err('OFFERING_CANCELLED', 'This offering has been cancelled.');
if (offering.status === 'completed') throw err('OFFERING_COMPLETED', 'This offering has already completed.');
if (offering.status === 'full') throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
if (!['published', 'enrollment_open'].includes(offering.status)) throw err('OFFERING_NOT_OPEN', 'This offering is not currently open for registration.');
const now = new Date();
if (offering.enrollment_opens_at && now < new Date(offering.enrollment_opens_at)) throw err('REGISTRATION_NOT_YET_OPEN', 'Registration for this offering has not opened yet.');
if (offering.enrollment_closes_at && now > new Date(offering.enrollment_closes_at)) throw err('REGISTRATION_CLOSED', 'Registration for this offering has closed.');
}
// Field validation -> seat reservation -> insert -> notification, strictly in that order. A
// notification is only ever enqueued for a registration that is FULLY, durably committed —
// enqueueing one earlier (e.g. before capacity is confirmed) created a real bug found during
// this phase's own regression testing: a rolled-back registration whose row got deleted after a
// failed seat reservation left behind a notification_events row still pointing at it, and the
// DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST
// (rolling it back if the insert itself somehow fails) means nothing is ever created that later
// needs to be un-created out from under a row something else already references.
// Phase 9 §26/§27 — a registration against a PAID offering carries a reservation_expires_at,
// giving the registrant a real but bounded window to complete checkout before the seat releases
// automatically (server.js's reservation reaper). A free offering never sets this — nothing new
// to expire, identical to Phase 7/8 behavior.
const RESERVATION_TIMEOUT_MINUTES = Number(process.env.REGISTRATION_RESERVATION_TIMEOUT_MINUTES) || 30;
function computeReservationExpiry(offering) {
return offering.price_amount === null ? null : new Date(Date.now() + RESERVATION_TIMEOUT_MINUTES * 60 * 1000);
}
async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) {
if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
assertRegistrationOpen(offering);
await reserveSeat(pool, offeringId); // throws OFFERING_FULL if genuinely exhausted; nothing created yet if so
let reg;
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference, reservation_expires_at)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11,$12) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null, computeReservationExpiry(offering)]
);
reg = rows[0];
} catch (e) {
await releaseSeat(pool, offeringId).catch(() => {});
if (e.code === '23505') throw err('DUPLICATE_REGISTRATION', 'A registration with this external_reference already exists for this offering.');
throw e;
}
// Best-effort — a notification failure must never fail the registration itself (matches
// enrollment.js's own precedent of never letting notification concerns block the academic
// action they're merely announcing). Safe to fire now: reg is fully, durably committed.
await notificationsLib.enqueueForRegistration(pool, {
registration: reg, notificationType: 'registration_received',
metadata: { display_name: reg.registrant_display_name, offering_name: offering.name },
}).catch((e) => console.error("[offerings] registration_received notification failed:", e.message));
return reg;
}
async function getRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
return rows[0] || null;
}
async function listRegistrationsForOffering(pool, offeringId) {
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE offering_id = $1 ORDER BY created_at`, [offeringId]);
return rows;
}
async function transitionRegistration(pool, registrationId, toStatus) {
const reg = await getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
const allowed = REGISTRATION_TRANSITIONS[reg.status] || [];
if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move a registration from '${reg.status}' to '${toStatus}'.`);
if (toStatus === 'cancelled' && !['enrollment_activated', 'cancelled'].includes(reg.status)) await releaseSeat(pool, reg.offering_id);
const { rows } = await pool.query(`UPDATE offering_registrations SET status = $2, updated_at = now() WHERE registration_id = $1 RETURNING *`, [registrationId, toStatus]);
const updated = rows[0];
// Best-effort lifecycle notifications — see createRegistration's own comment on why a
// notification failure never blocks the real state change it's merely announcing.
const offering = await getOffering(pool, reg.offering_id);
if (toStatus === 'confirmed') {
await notificationsLib.enqueueForRegistration(pool, {
registration: updated, notificationType: 'registration_confirmed',
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name },
}).catch((e) => console.error("[offerings] registration lifecycle notification failed:", e.message));
}
if (toStatus === 'agreements_pending') {
const status = await getRegistrationAgreementStatus(pool, registrationId);
for (const missing of status.missing) {
await notificationsLib.enqueueForRegistration(pool, {
registration: updated, notificationType: 'agreement_required',
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name, agreement_title: missing.title },
}).catch((e) => console.error("[offerings] agreement_required notification failed:", e.message));
}
}
return updated;
}
// ── Offering agreement requirements (Step 18) ────────────────────────────────────────────────
async function addAgreementRequirement(pool, offeringId, agreementId) {
const { rows: agreementRows } = await pool.query(`SELECT 1 FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
if (!agreementRows[0]) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
const { rows } = await pool.query(
`INSERT INTO offering_agreement_requirements (offering_id, agreement_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *`,
[offeringId, agreementId]
);
return rows[0] || { offering_id: offeringId, agreement_id: agreementId };
}
async function listAgreementRequirementsForOffering(pool, offeringId) {
const { rows } = await pool.query(
`SELECT ad.* FROM offering_agreement_requirements oar JOIN agreement_definitions ad ON ad.agreement_id = oar.agreement_id
WHERE oar.offering_id = $1 ORDER BY ad.title`,
[offeringId]
);
return rows;
}
// Required vs. accepted vs. missing, for one registration — the exact shape both the
// agreements_pending notification loop above and the admin/registration-detail UI need.
async function getRegistrationAgreementStatus(pool, registrationId) {
const reg = await getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
const required = await listAgreementRequirementsForOffering(pool, reg.offering_id);
const accepted = await listAcceptancesForRegistration(pool, registrationId);
const acceptedIds = new Set(accepted.map((a) => a.agreement_id));
const missing = required.filter((r) => !acceptedIds.has(r.agreement_id));
return { required, accepted, missing };
}
// The one place a registration becomes a real academic enrollment. Reuses
// enrollment.js's resolveOrCreateStudent/createManualEnrollment/activateEnrollment rather than
// duplicating student-matching or enrollment-creation logic — see that file's own comment on
// why resolveOrCreateStudent is now exported. Cohort membership (if the offering has a cohort)
// is created here too, using the existing cohort_enrollments table unchanged.
async function convertRegistrationToEnrollment(pool, studentsAuth, registrationId, { activate = true } = {}) {
const reg = await getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
if (reg.status !== 'confirmed') throw err('INVALID_STATE', 'A registration must be confirmed before it can become an enrollment.');
const offering = await getOffering(pool, reg.offering_id);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
// Step 18 — a required agreement with no matching acceptance blocks conversion, always (this
// offering's own configured requirements, not a global policy toggle this phase invents).
const agreementStatus = await getRegistrationAgreementStatus(pool, registrationId);
if (agreementStatus.missing.length) {
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
}
const student = reg.student_id
? (await pool.query(`SELECT * FROM students WHERE student_id = $1`, [reg.student_id])).rows[0]
: await enrollmentLib.resolveOrCreateStudent(pool, studentsAuth, {
studentEmail: reg.is_minor ? null : reg.registrant_email,
studentLoginIdentifier: reg.is_minor ? `reg-${reg.registration_id}` : null,
studentDisplayName: reg.registrant_display_name,
}); // no returnToken here — notifyForEnrollment below issues the ONE real token that
// actually gets used; pre-issuing a second one here would just have it silently
// overwritten (found while wiring Phase 9's activation-notification call — see that
// call's own comment).
const enrollment = await enrollmentLib.createManualEnrollment(pool, {
studentId: student.student_id, programId: offering.program_id,
status: activate ? 'active' : 'pending', deliveryMode: offering.delivery_mode,
externalReference: reg.external_reference,
metadata: reg.is_minor && reg.registrant_email ? { contact_email: reg.registrant_email } : null,
});
if (offering.cohort_id) {
await pool.query(
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[offering.cohort_id, student.student_id]
);
}
// Phase 9 — the registration's own guardian (set at registration time when a guardian object
// was submitted) never used to survive conversion into the relational student_guardians model,
// which is what resolveRecipient's guardian fallback (and any future guardian-facing feature)
// actually reads. Without this, a brand-new minor student converts with no guardian link at
// all, and the activation email below has no recipient to resolve to.
if (reg.is_minor && reg.guardian_id) {
await linkStudentGuardian(pool, {
studentId: student.student_id, guardianId: reg.guardian_id,
relationship: 'guardian', isPrimaryContact: true, canReceiveCommunications: true,
});
}
await pool.query(
`UPDATE offering_registrations SET student_id = $2, enrollment_id = $3, status = 'enrollment_activated', updated_at = now() WHERE registration_id = $1`,
[registrationId, student.student_id, enrollment.enrollment_id]
);
// Phase 9 §9/§20 — the same activation-or-confirmation email every OTHER enrollment path
// (the admin manual-enroll route, the website's /api/integrations/enrollments) already
// triggers automatically. A registration-converted enrollment was the one path that never
// called this — closed here so a new camp student's real path to logging in doesn't depend
// on an admin manually relaying `student._activationToken` by hand.
const programsLib = require('./programs');
const program = await programsLib.getProgram(pool, offering.program_id);
let activationToken = null;
try {
const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, {
studentId: student.student_id, enrollmentId: enrollment.enrollment_id, programName: program?.name || offering.name,
});
activationToken = notification?._activationToken || null;
} catch (e) {
console.error('[offerings] enrollment-activation notification failed:', e.message);
}
return { student: activationToken ? { ...student, _activationToken: activationToken } : student, enrollment };
}
// ── Agreements ────────────────────────────────────────────────────────────────────────────────
async function createAgreementDefinition(pool, { agreementId, title, bodySummary, requiresGuardianForMinor }) {
if (!agreementId || !title) throw err('VALIDATION', 'agreementId and title are required.');
const { rows } = await pool.query(
`INSERT INTO agreement_definitions (agreement_id, title, body_summary, requires_guardian_for_minor) VALUES ($1, $2, $3, COALESCE($4, true)) RETURNING *`,
[agreementId, title, bodySummary || null, requiresGuardianForMinor ?? null]
);
return rows[0];
}
async function listAgreementDefinitions(pool) {
const { rows } = await pool.query(`SELECT * FROM agreement_definitions ORDER BY title`);
return rows;
}
// accepted_by_type: 'student' | 'guardian' | 'instructor_on_behalf' | 'registrant'. 'registrant'
// (Step 7's fix) is the one case allowed with neither a student nor a guardian id — an adult
// self-accepting AT REGISTRATION TIME, before their eventual student_id exists, tied only to
// registrationId. Every other type still requires a real student or guardian id — enforced here
// AND by the DB CHECK constraint (035_phase8_...sql), so this can never be bypassed by a future
// caller that skips this function.
async function recordAgreementAcceptance(pool, { agreementId, acceptedByType, acceptedByStudentId, acceptedByGuardianId, registrationId, enrollmentId, ipAddress }) {
const { rows: defRows } = await pool.query(`SELECT * FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
const def = defRows[0];
if (!def) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
if (!['student', 'guardian', 'instructor_on_behalf', 'registrant'].includes(acceptedByType)) {
throw err('VALIDATION', "accepted_by_type must be one of: student, guardian, instructor_on_behalf, registrant.");
}
if (acceptedByType === 'registrant') {
if (!registrationId) throw err('VALIDATION', 'A registrant acceptance must be tied to a registrationId.');
} else if (!acceptedByStudentId && !acceptedByGuardianId) {
throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.');
}
const { rows } = await pool.query(
`INSERT INTO agreement_acceptances (agreement_id, accepted_version, accepted_by_type, accepted_by_student_id, accepted_by_guardian_id, registration_id, enrollment_id, ip_address)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[agreementId, def.version, acceptedByType, acceptedByStudentId || null, acceptedByGuardianId || null, registrationId || null, enrollmentId || null, ipAddress || null]
);
return rows[0];
}
async function listAcceptancesForRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM agreement_acceptances WHERE registration_id = $1 ORDER BY accepted_at`, [registrationId]);
return rows;
}
// ── Website integration — idempotent registration submission (Step 13/14) ──────────────────
// Mirrors enrollment.js's createFromIntegrationEvent exactly: a real DB-level unique index
// (external_reference, 035_phase8_...sql) is what makes concurrent duplicate submissions safe,
// not a SELECT-then-INSERT check. A replay of the same event_id returns the ORIGINAL
// registration, created:false, never a duplicate — same contract shape as the existing
// /api/integrations/enrollments endpoint website code already knows how to handle.
async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringSlug, registrantDisplayName, registrantEmail, isMinor, guardianDisplayName, guardianEmail, guardianPhone, readinessNotes, source }) {
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent registration events.');
if (!offeringSlug) throw err('OFFERING_SLUG_REQUIRED', 'offering_slug is required.');
if (!registrantDisplayName) throw err('VALIDATION', 'registrant display name is required.');
if (isMinor && !guardianEmail) throw err('GUARDIAN_REQUIRED', 'A minor registration requires a guardian email.');
const existing = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
if (existing.rows[0]) return { registration: existing.rows[0], created: false };
const { rows: offeringRows } = await pool.query(`SELECT * FROM offerings WHERE slug = $1`, [offeringSlug]);
const offering = offeringRows[0];
if (!offering) throw err('OFFERING_NOT_FOUND', 'Unknown offering_slug.');
assertRegistrationOpen(offering);
let guardianId = null;
if (isMinor) {
// Match an existing guardian by email first (a returning family shouldn't get a duplicate
// guardian row per sibling/per camp) — same "match before create" principle
// enrollment.js's resolveOrCreateStudent already established for students.
const existingGuardian = await pool.query(`SELECT guardian_id FROM guardians WHERE email = $1`, [String(guardianEmail).toLowerCase().trim()]);
if (existingGuardian.rows[0]) {
guardianId = existingGuardian.rows[0].guardian_id;
} else {
guardianId = `guardian-${eventId}`;
await createGuardian(pool, { guardianId, displayName: guardianDisplayName || guardianEmail, email: guardianEmail, phone: guardianPhone });
}
}
try {
await reserveSeat(pool, offering.offering_id);
} catch (e) {
if (e.code === 'OFFERING_FULL') throw e;
throw e;
}
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference, reservation_expires_at)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9)
ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING
RETURNING *`,
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId, computeReservationExpiry(offering)]
);
if (rows[0]) {
await notificationsLib.enqueueForRegistration(pool, {
registration: rows[0], notificationType: 'registration_received',
metadata: { display_name: rows[0].registrant_display_name, offering_name: offering.name },
}).catch((e) => console.error("[offerings] website registration_received notification failed:", e.message));
return { registration: rows[0], created: true };
}
// Lost the ON CONFLICT race (concurrent identical replay) — release the seat we just
// reserved (the winner already holds theirs) and return the winner's row.
await releaseSeat(pool, offering.offering_id);
const { rows: won } = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
return { registration: won[0], created: false };
} catch (e) {
await releaseSeat(pool, offering.offering_id).catch(() => {});
throw e;
}
}
// ── Launch readiness (Step 20) — server-derived, never left to frontend logic alone ─────────
async function computeOfferingReadiness(pool, offeringId) {
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('NOT_FOUND', 'No such offering.');
const checks = [];
checks.push({ key: 'curriculum', label: 'Curriculum', ready: !!offering.program_id });
const needsInstructor = offering.delivery_mode !== 'self_paced';
if (needsInstructor) {
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM instructor_offerings WHERE offering_id = $1`, [offeringId]);
checks.push({ key: 'instructor', label: 'Instructor assigned', ready: rows[0].n > 0 });
}
const needsSchedule = !['self_paced'].includes(offering.delivery_mode);
if (needsSchedule) checks.push({ key: 'schedule', label: 'Dates', ready: !!(offering.starts_at && offering.ends_at) });
const needsLocation = !['self_paced'].includes(offering.delivery_mode);
if (needsLocation) checks.push({ key: 'location', label: 'Location', ready: !!offering.location_id || offering.delivery_mode === 'community' });
checks.push({ key: 'capacity', label: 'Capacity', ready: offering.capacity !== null || offering.delivery_mode === 'self_paced' });
checks.push({ key: 'registration_window', label: 'Registration window', ready: !!(offering.enrollment_opens_at && offering.enrollment_closes_at) });
const requirements = await listAgreementRequirementsForOffering(pool, offeringId);
checks.push({ key: 'agreements', label: 'Agreements configured', ready: requirements.length > 0 });
if (offering.curriculum_package_id) {
const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id);
checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 });
}
// Phase 9 §29 — a PAID offering (price_amount set) additionally needs a real price and a
// configured payment provider before it's genuinely launch-ready; a free offering never
// needs either, unchanged from Phase 8.
if (offering.price_amount !== null) {
checks.push({ key: 'price', label: 'Price configured', ready: offering.price_amount > 0 && !!offering.price_currency });
const { getPaymentProvider } = require('./payment-provider');
let providerConfigured = false;
try { const p = getPaymentProvider(); providerConfigured = p.name === 'test' || !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_WEBHOOK_SECRET); } catch { providerConfigured = false; }
checks.push({ key: 'payment_provider', label: 'Payment provider configured', ready: providerConfigured });
}
const allReady = checks.every((c) => c.ready);
return { offering_id: offeringId, ready_to_open_registration: allReady, checks };
}
module.exports = {
createOrganization, listOrganizations,
createLocation, listLocations,
createGuardian, linkStudentGuardian, listGuardiansForStudent,
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
createOffering, getOffering, updateOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
createRegistrationFromIntegrationEvent, computeOfferingReadiness, assertRegistrationOpen,
};
+142
View File
@@ -0,0 +1,142 @@
// Phase 9 §3/§4 — the payment provider abstraction. commerce.js (registration/checkout
// business logic) only ever calls the methods below — it never touches the Stripe SDK, an env
// var, or a webhook signature format directly. Swapping providers later means writing a new
// class here, not touching commerce.js/admin-commerce-routes.js/webhook-routes.js at all.
//
// PAYMENT_PROVIDER selects the implementation: 'stripe' (real, needs STRIPE_SECRET_KEY/
// STRIPE_WEBHOOK_SECRET) or 'test' (deterministic, in-process, no network, no real money —
// the default, matching EMAIL_PROVIDER's own "dev by default, real provider opt-in" precedent).
// Every test/rehearsal in this phase runs against 'test'; nothing here has ever been exercised
// against live Stripe credentials — see docs/STRIPE_INTEGRATION_RUNBOOK.md's own "pending
// live validation" note.
const PAYMENT_PROVIDER = process.env.PAYMENT_PROVIDER || 'test';
// ── Stripe adapter ────────────────────────────────────────────────────────────────────────────
class StripeProvider {
constructor() {
this.name = 'stripe';
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
console.error('WARNING: PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is not set — checkout creation and webhook verification will fail every call until it is.');
}
this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || null;
if (!this.webhookSecret) {
console.error('WARNING: PAYMENT_PROVIDER=stripe but STRIPE_WEBHOOK_SECRET is not set — every webhook delivery will be rejected until it is.');
}
// Lazy require — the stripe package is a real dependency (package.json), but constructing
// the client eagerly at module load would crash a 'test'-provider dev stack that never
// installs/needs a real key at all.
const Stripe = require('stripe');
this.client = secretKey ? new Stripe(secretKey) : null;
}
// amountCents/currency come from the OFFERING's own server-side price, never browser input
// (Step 5's explicit requirement) — commerce.js is the only caller, and it reads them from
// the offerings table itself.
async createCheckoutSession({ amountCents, currency, productName, successUrl, cancelUrl, metadata }) {
if (!this.client) throw Object.assign(new Error('Stripe is not configured (STRIPE_SECRET_KEY missing).'), { code: 'PROVIDER_NOT_CONFIGURED' });
const session = await this.client.checkout.sessions.create({
mode: 'payment',
line_items: [{ price_data: { currency, unit_amount: amountCents, product_data: { name: productName } }, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
metadata,
// Phase 9.1 real-Stripe-TEST fix — a later payment_intent.payment_failed or
// charge.refunded event references the PaymentIntent/Charge, never the Checkout Session
// (a genuinely different Stripe object id, only discovered by testing against real
// Stripe — the deterministic TestProvider always reused one caller-supplied reference for
// every event type, which never exposed this). The Session's own `metadata` above does
// NOT propagate to its PaymentIntent automatically; `payment_intent_data.metadata` does —
// this is what lets normalizeEvent() below correlate those later events back to OUR OWN
// transaction_id, not Stripe's session id.
payment_intent_data: { metadata },
});
return { checkoutUrl: session.url, providerReference: session.id };
}
// Real signature verification via Stripe's own SDK helper — never a hand-rolled HMAC compare.
// Throws on a bad/forged signature; the webhook route treats that as a clean 400, never a 500.
async verifyAndParseWebhook(rawBody, signatureHeader) {
if (!this.client || !this.webhookSecret) throw Object.assign(new Error('Stripe webhook verification is not configured.'), { code: 'PROVIDER_NOT_CONFIGURED' });
const event = this.client.webhooks.constructEvent(rawBody, signatureHeader, this.webhookSecret);
return this.normalizeEvent(event);
}
// Stripe's own event vocabulary -> RootNode's narrow, provider-neutral shape. Only the event
// types this integration actually acts on are mapped to a real status; everything else comes
// back with normalizedStatus: null, which webhook-routes.js records (for audit completeness)
// but never acts on.
//
// providerReference is usually the Checkout Session id (matches payment_transactions.
// provider_reference, set at checkout creation) — but payment_intent.payment_failed and
// charge.refunded reference the PaymentIntent/Charge instead, which was never stored anywhere
// as a lookup key. Both are correlated back via OUR OWN transaction_id, echoed back through
// payment_intent_data.metadata at session creation (see createCheckoutSession above);
// commerce.js's transaction lookup matches on either provider_reference OR transaction_id.
async normalizeEvent(event) {
const obj = event.data.object;
let providerReference = obj.id;
let normalizedStatus = null;
if (event.type === 'checkout.session.completed') { normalizedStatus = 'paid'; providerReference = obj.id; }
else if (event.type === 'checkout.session.expired') { normalizedStatus = 'cancelled'; providerReference = obj.id; }
else if (event.type === 'payment_intent.payment_failed') {
// obj IS the PaymentIntent — its own metadata already carries our transaction_id.
normalizedStatus = 'failed'; providerReference = obj.metadata?.transaction_id || obj.id;
} else if (event.type === 'charge.refunded') {
normalizedStatus = obj.amount_refunded < obj.amount ? 'partially_refunded' : 'refunded';
// obj is a Charge — Charges do not inherit their PaymentIntent's metadata, so retrieve
// the PaymentIntent (one extra call, only on the rare refund path) to read ours back.
let piMetadataRef = null;
if (obj.payment_intent) {
try { const pi = await this.client.paymentIntents.retrieve(obj.payment_intent); piMetadataRef = pi.metadata?.transaction_id || null; }
catch { /* fall through to the raw PaymentIntent id below */ }
}
providerReference = piMetadataRef || obj.payment_intent || obj.id;
}
return { eventId: event.id, eventType: event.type, providerReference, normalizedStatus };
}
}
// ── Test/dev adapter — deterministic, in-process, no network, no real money ─────────────────
// Every "checkout" is an immediately-resolvable fake URL; a rehearsal/test drives payment
// outcomes by POSTing a fabricated event straight at the SAME webhook route real Stripe events
// arrive on, through the SAME signature-verification interface (a static shared secret compare
// here, standing in for Stripe's HMAC check) — the actual commerce.js processing code path is
// therefore identical in both providers, only how a session/event gets created differs.
class TestProvider {
constructor() {
this.name = 'test';
this.sharedSecret = process.env.TEST_PAYMENT_WEBHOOK_SECRET || 'test-payment-secret-not-for-production';
this._sessions = new Map(); // in-process only — fine, this provider only ever exists in dev/test
}
async createCheckoutSession({ amountCents, currency, productName, successUrl, cancelUrl, metadata }) {
const providerReference = `test_cs_${Math.random().toString(36).slice(2)}${Date.now()}`;
this._sessions.set(providerReference, { amountCents, currency, metadata });
return { checkoutUrl: `${successUrl}${successUrl.includes('?') ? '&' : '?'}test_session=${providerReference}`, providerReference };
}
// A rehearsal/test calls this directly (not a real HTTP round trip) to build the exact body a
// real client would POST to the webhook route.
buildTestEvent({ providerReference, eventType }) {
const body = JSON.stringify({ id: `evt_${Math.random().toString(36).slice(2)}${Date.now()}`, type: eventType, provider_reference: providerReference });
return { body, signature: this.sharedSecret };
}
verifyAndParseWebhook(rawBody, signatureHeader) {
if (signatureHeader !== this.sharedSecret) throw Object.assign(new Error('Invalid test webhook signature.'), { code: 'INVALID_SIGNATURE' });
const event = JSON.parse(rawBody);
const statusByType = { 'checkout.session.completed': 'paid', 'checkout.session.expired': 'cancelled', 'payment_intent.payment_failed': 'failed', 'charge.refunded': 'refunded' };
return { eventId: event.id, eventType: event.type, providerReference: event.provider_reference, normalizedStatus: statusByType[event.type] || null };
}
}
let cachedProvider = null;
function getPaymentProvider() {
if (cachedProvider) return cachedProvider;
cachedProvider = PAYMENT_PROVIDER === 'stripe' ? new StripeProvider() : new TestProvider();
return cachedProvider;
}
module.exports = { getPaymentProvider, StripeProvider, TestProvider, PAYMENT_PROVIDER };
@@ -0,0 +1,65 @@
// Phase 8 §12 — the PUBLIC, unauthenticated offering-read surface the website reads to build
// its own catalog/registration pages. No session of any kind (student or instructor) is
// checked or accepted here — this is meant to be reachable by anyone, the same trust level as
// the marketing site itself. An explicit allowlist serializer (never SELECT *, never returning
// a raw row) is what keeps this safe: internal notes, access_notes, organization contact info,
// instructor identity, and anything registration/student/guardian-shaped are never even
// queried by this file, let alone serialized.
function toPublicOffering(row) {
return {
slug: row.slug,
name: row.name,
description: row.description,
program_id: row.program_id,
delivery_mode: row.delivery_mode,
recommended_min_age: row.recommended_min_age,
recommended_max_age: row.recommended_max_age,
starts_at: row.starts_at,
ends_at: row.ends_at,
registration_open: row.status === 'enrollment_open',
// "availability" rather than an exact seat count — a real number invites scraping/timing
// games from a public, unauthenticated endpoint; a coarse status is enough for a website to
// build a real "Register" vs "Full" call to action.
availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open',
location_name: row.location_name || null,
location_type: row.location_type || null,
// Phase 9 §13/§17 — price is the one new public field. NULL means free/no-checkout, exactly
// like the internal price_amount column — never a Stripe id, never internal commerce state.
price_amount: row.price_amount,
price_currency: row.price_currency,
requires_payment: row.price_amount !== null,
};
}
module.exports = function registerPublicOfferingsRoutes(app, deps) {
const { getPool, wrap } = deps;
// Only offerings a family/adult should actually be able to see: published or further along,
// never draft (an admin still building it out) and never cancelled.
const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress'];
const PUBLIC_COLUMNS = `o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age,
o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, o.price_amount, o.price_currency,
l.name AS location_name, l.type AS location_type`;
app.get('/api/public/offerings', wrap(async (req, res) => {
const { rows } = await getPool().query(
`SELECT ${PUBLIC_COLUMNS}
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`,
[VISIBLE_STATUSES]
);
res.json(rows.map(toPublicOffering));
}));
app.get('/api/public/offerings/:slug', wrap(async (req, res) => {
const { rows } = await getPool().query(
`SELECT ${PUBLIC_COLUMNS}
FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id
WHERE o.slug = $1 AND o.status = ANY($2)`,
[req.params.slug, VISIBLE_STATUSES]
);
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
res.json(toPublicOffering(rows[0]));
}));
};
+32
View File
@@ -35,6 +35,16 @@ const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
const app = express(); const app = express();
if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1); if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1);
// Phase 9 §8 — the ONE route that needs the raw, unparsed request body: a payment provider's
// webhook signature (Stripe's own HMAC, or the test provider's stand-in) is computed over the
// exact bytes as sent, so express.json() must never touch this path before signature
// verification runs. Registered before the global JSON parser below so Express's normal
// path-then-registration-order middleware resolution gives this route the raw Buffer
// (`req.rawBody`) instead — every other route is completely unaffected.
app.use('/api/webhooks/payment', express.raw({ type: '*/*' }), (req, res, next) => {
req.rawBody = req.body; // a Buffer here, never parsed — webhook-routes.js reads it directly
next();
});
app.use(express.json()); app.use(express.json());
const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist'); const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist');
app.use(express.static(FRONTEND_DIST)); app.use(express.static(FRONTEND_DIST));
@@ -413,6 +423,28 @@ require('./notification-routes')(app, {
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit, instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
}); });
// Phase 7 — the business/product architecture: organizations, locations, guardians,
// curriculum packages, offerings, registrations, and agreements. Admin/owner surface only —
// no student-facing routes yet (registration is admin-entered this phase; a future public
// registration flow is a Phase 8+ decision, see docs/OFFERING_AND_REGISTRATION_MODEL.md).
require('./admin-offerings-routes')(app, {
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
});
// Phase 8 — the public, unauthenticated offering-read surface (no session of any kind), and
// the website's registration submission endpoint (server-to-server, same shared-secret
// boundary as /api/integrations/enrollments, registered alongside it in integration-routes.js).
require('./public-offerings-routes')(app, { getPool, wrap });
// Phase 9 — the payment provider webhook (Stripe or the test provider), and the reservation
// reaper for abandoned/unpaid registrations. See docs/PHASE9_COMMERCE_AND_LAUNCH.md.
require('./webhook-routes')(app, { getPool, recordAudit, wrap });
const commerceLib = require('./commerce');
const RESERVATION_REAPER_POLL_MS = Number(process.env.RESERVATION_REAPER_POLL_MS) || 60000;
setInterval(() => {
commerceLib.reapExpiredReservations(getPool()).catch((e) => console.error('[commerce] reservation reaper failed:', e.message));
}, RESERVATION_REAPER_POLL_MS);
// Fallback to the platform-shell SPA for any unmatched non-API route // Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html')); res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
+43
View File
@@ -0,0 +1,43 @@
// Phase 9 §8 — the verified payment-provider webhook endpoint. Server-to-server only (Stripe's
// own servers, or a test/rehearsal script standing in for them) — no session of any kind is
// checked or accepted here, the SAME trust boundary shape as /api/integrations/*, but the
// authentication mechanism is different on purpose: a provider webhook proves itself via a
// cryptographic signature over the exact raw body, not a static shared-secret header, which is
// why this route needs the raw (unparsed) request body — see server.js's own route-registration
// comment for how that's wired without breaking every other route's normal JSON body parsing.
const commerce = require('./commerce');
const { getPaymentProvider } = require('./payment-provider');
const studentsAuth = require('./students-auth');
module.exports = function registerWebhookRoutes(app, deps) {
const { getPool, recordAudit, wrap } = deps;
// rawBody is attached by server.js's raw-body middleware, scoped to only this path — every
// other route keeps using express.json() as before.
app.post('/api/webhooks/payment', wrap(async (req, res) => {
const provider = getPaymentProvider();
const signature = req.get('Stripe-Signature') || req.get('X-Test-Payment-Signature');
let normalizedEvent;
try {
normalizedEvent = await provider.verifyAndParseWebhook(req.rawBody, signature);
} catch (e) {
// A forged/invalid signature is a clean, quiet 400 — never a 500, never a hint about
// WHY verification failed (timing/oracle hygiene, matching this codebase's existing
// anti-enumeration precedent elsewhere).
return res.status(400).json({ error: 'invalid_signature' });
}
const result = await commerce.processWebhookEvent(getPool(), studentsAuth, provider, normalizedEvent);
await recordAudit(getPool(), {
actorType: 'integration', actorId: `payment_webhook:${provider.name}`, action: `payment_webhook_${result.result}`,
resourceType: 'payment_webhook_event', resourceId: normalizedEvent.eventId, result: 'success', sourceIp: req.ip,
metadata: { event_type: normalizedEvent.eventType, normalized_status: normalizedEvent.normalizedStatus || null },
});
// Always 200 once signature verification passes — Stripe retries on anything else, and a
// duplicate/ignored/error-but-already-recorded event is not something we want retried
// forever; the audit trail and payment_webhook_events row are the real record of what
// happened, not the HTTP status.
res.json({ received: true, result: result.result });
}));
};
+8
View File
@@ -95,6 +95,14 @@ services:
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for # 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. # real, then unsets it again — see that test's own comment.
COOKIE_SECURE: ${COOKIE_SECURE:-} COOKIE_SECURE: ${COOKIE_SECURE:-}
# Phase 9.1 — real-Stripe-TEST-mode local validation. Empty/unset by default (fail-safe:
# payment-provider.js defaults to the deterministic TestProvider when PAYMENT_PROVIDER is
# unset — the same "dev by default, real provider opt-in" pattern EMAIL_PROVIDER already
# uses above). Set all three in platform/.env only for a deliberate real-Stripe-TEST
# session; STRIPE_SECRET_KEY must be a TEST key (sk_test_...) — never a live one.
PAYMENT_PROVIDER: ${PAYMENT_PROVIDER:-}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy # TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
depends_on: depends_on:
postgres: postgres:
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env node
// Phase 7 — Business & Product Architecture acceptance test. Organizations, locations,
// guardians, curriculum packages, offerings (capacity, server-defined transitions),
// registrations (lifecycle, agreements, conversion to real enrollment), RBAC, and explicit
// proof that none of this depends on age or breaks ROOT_ V1's existing self-paced/classroom
// flows. Same conventions as e2e-phase6a-f.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=... node test/e2e-phase7.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const { completeRootFoundations01Through04 } = require('./foundations-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
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.stack || e.message}`); failed++; }
}
function psql(sql) {
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
}
async function main() {
console.log('Phase 7 Business & Product Architecture acceptance test\n');
const owner = makeSession(GW);
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
// ── 1: course families are guidance, not hard age gates ──────────────────────────────────
await test('programs.family accepts bootcamp; recommended_min/max_age is never read by any RBAC/authorization code', async () => {
psql(`INSERT INTO programs (program_id, name, family, delivery_mode, curriculum_path, status) VALUES ('p7-bootcamp-check-${RUN_ID}', 'Bootcamp Check', 'bootcamp', 'self_paced', 'rootnode-path', 'active') ON CONFLICT DO NOTHING`);
const row = psql(`SELECT family FROM programs WHERE program_id = 'p7-bootcamp-check-${RUN_ID}'`);
assert.equal(row, 'bootcamp');
const src = execSync('grep -rln "recommended_min_age\\|recommended_max_age" src/*.js', { cwd: `${__dirname}/../api`, stdio: 'pipe' }).toString();
// Only offerings.js/admin-offerings-routes.js (Phase 7) and public-offerings-routes.js
// (Phase 8's public read-only serializer — displays the fields, never authorizes on them)
// should ever mention these fields, and none of the three is an authorization/RBAC file —
// confirmed by name, not content-sniffing.
for (const file of src.trim().split('\n').filter(Boolean)) {
assert.ok(/offerings\.js|admin-offerings-routes\.js|public-offerings-routes\.js/.test(file), `unexpected file referencing age-guidance fields: ${file}`);
}
});
// ── 2/3/4: offering creation, program association, delivery-mode behavior ────────────────
const orgId = `org-p7-${RUN_ID}`;
const locId = `loc-p7-${RUN_ID}`;
let offeringA, offeringB, pkgId;
await test('organization and two distinct locations are created', async () => {
const org = await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P7 Test Org', type: 'library' });
assert.equal(org.status, 201);
const loc = await owner.request('POST', '/api/admin/locations', { locationId: locId, name: 'P7 Room', type: 'community_center', organizationId: orgId, accessNotes: 'internal only' });
assert.equal(loc.status, 201);
});
await test('a curriculum package references existing ROOT courses without duplicating content', async () => {
pkgId = `pkg-p7-${RUN_ID}`;
const pkg = await owner.request('POST', '/api/admin/curriculum-packages', { curriculumPackageId: pkgId, name: 'P7 Winter Package', programId: PROGRAM });
assert.equal(pkg.status, 201);
const item1 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-01-computing-linux-foundations', sequence: 1 });
assert.equal(item1.status, 201);
const item2 = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-02-linux-administration-networking', sequence: 2 });
assert.equal(item2.status, 201);
// 19/20: references existing content, no duplication — the module/activity counts under
// root-01/02 must be byte-identical to what course-content validation already knows about.
const activityCount = psql(`SELECT count(*) FROM module_activities ma JOIN course_modules cm ON cm.module_id = ma.module_id WHERE cm.course_id IN ('root-01-computing-linux-foundations','root-02-linux-administration-networking')`);
const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`);
assert.equal(get.status, 200);
assert.equal(get.body.items.length, 2);
assert.equal(get.body.items[0].course_id, 'root-01-computing-linux-foundations');
assert.ok(Number(activityCount) > 0, 'the referenced courses must still have their real activities, unduplicated');
});
await test('a non-existent course cannot be added to a curriculum package (reference, never fabricate)', async () => {
const r = await owner.request('POST', `/api/admin/curriculum-packages/${pkgId}/items`, { courseId: 'root-99-does-not-exist', sequence: 3 });
assert.equal(r.status, 404);
assert.equal(r.body.error, 'COURSE_NOT_FOUND');
});
await test('two offerings at two different locations coexist with no state confusion', async () => {
offeringA = `off-a-${RUN_ID}`;
offeringB = `off-b-${RUN_ID}`;
const a = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringA, slug: `p7-camp-a-${RUN_ID}`, name: 'Camp A', programId: PROGRAM, curriculumPackageId: pkgId, deliveryMode: 'camp', capacity: 2, locationId: locId, organizationId: orgId });
assert.equal(a.status, 201);
const b = await owner.request('POST', '/api/admin/offerings', { offeringId: offeringB, slug: `p7-camp-b-${RUN_ID}`, name: 'Camp B (self-paced)', programId: PROGRAM, deliveryMode: 'self_paced' });
assert.equal(b.status, 201);
assert.equal(b.body.capacity, null, 'a self-paced offering should be unlimited by default');
// Phase 8 made registration-window enforcement real (server-side, not just client hiding a
// button) — a still-draft offering now correctly rejects registration attempts. offeringB
// needs to be open before anything in this file registers against it.
await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${offeringB}/transition`, { status: 'enrollment_open' });
});
// ── 5: capacity ───────────────────────────────────────────────────────────────────────────
await test('offering capacity is enforced atomically, and validation happens BEFORE seat reservation', async () => {
for (const p of [{ status: 'published' }, { status: 'enrollment_open' }]) {
const r = await owner.request('POST', `/api/admin/offerings/${offeringA}/transition`, p);
assert.equal(r.status, 200, JSON.stringify(r.body));
}
const badMinor = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true });
assert.equal(badMinor.status, 400, 'a validation failure must not consume a seat');
const check0 = await owner.request('GET', `/api/admin/offerings/${offeringA}`);
assert.equal(check0.body.reserved_seats, 0);
const reg1 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg One', registrantEmail: `reg1-${RUN_ID}@example.com` });
assert.equal(reg1.status, 201);
const reg2 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Two', registrantEmail: `reg2-${RUN_ID}@example.com` });
assert.equal(reg2.status, 201);
const reg3 = await owner.request('POST', `/api/admin/offerings/${offeringA}/registrations`, { registrantDisplayName: 'Reg Three (overflow)', registrantEmail: `reg3-${RUN_ID}@example.com` });
assert.equal(reg3.status, 409);
assert.equal(reg3.body.error, 'OFFERING_FULL');
});
// ── 7/8/9: organization/location/instructor assignment ───────────────────────────────────
await test('a plain instructor is listed and assignable to an offering, reusing cohort_instructors\' role vocabulary', async () => {
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-stray-${RUN_ID}@example.com`, display_name: 'P7 Stray', role: 'instructor', password: 'P7StrayPass123!' });
const link = await owner.request('POST', `/api/admin/offerings/${offeringA}/instructors`, { instructor_id: strayCreate.instructor_id, role: 'teacher' });
assert.equal(link.status, 201);
});
// ── 6/9: guardians, student-guardian relationship ────────────────────────────────────────
const guardianId = `guardian-p7-${RUN_ID}`;
await test('a guardian is created and can be linked to a student', async () => {
const g = await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P7 Guardian', email: `guardian-${RUN_ID}@example.com` });
assert.equal(g.status, 201);
});
// ── 11/12: adult learner path without a guardian; minor path without corrupting identity ──
let minorRegId, adultRegId;
await test('a minor registration requires a guardian; an adult registration needs none', async () => {
const minorReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { guardianId, registrantDisplayName: 'P7 Minor', isMinor: true });
assert.equal(minorReg.status, 201);
minorRegId = minorReg.body.registration_id;
const adultReg = await owner.request('POST', `/api/admin/offerings/${offeringB}/registrations`, { registrantDisplayName: 'P7 Adult', registrantEmail: `adult-${RUN_ID}@example.com` });
assert.equal(adultReg.status, 201);
adultRegId = adultReg.body.registration_id;
});
// ── 13/14: registration lifecycle ────────────────────────────────────────────────────────
await test('a registration moves through its real lifecycle, rejecting an invalid transition', async () => {
const bad = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'enrollment_activated' });
assert.equal(bad.status, 409, 'cannot jump straight from started to enrollment_activated');
for (const status of ['submitted', 'agreements_pending']) {
const r = await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status });
assert.equal(r.status, 200, `${status}: ${JSON.stringify(r.body)}`);
}
});
// ── 15: agreement-version acceptance ─────────────────────────────────────────────────────
const agreementId = `agree-p7-${RUN_ID}`;
await test('an agreement definition is created and a guardian acceptance is recorded with a version snapshot', async () => {
const def = await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P7 Test Agreement', bodySummary: 'Plain-language summary only.' });
assert.equal(def.status, 201);
const accept = await owner.request('POST', `/api/admin/registrations/${minorRegId}/agreements`, { agreementId, acceptedByType: 'guardian', acceptedByGuardianId: guardianId });
assert.equal(accept.status, 201);
assert.equal(accept.body.accepted_version, 1);
const list = await owner.request('GET', `/api/admin/registrations/${minorRegId}/agreements`);
assert.equal(list.body.length, 1);
});
// ── 16/17/18/19: offering enrollment conversion, offering→cohort, self-paced without cohort ─
await test('a confirmed registration converts to a real enrollment (self-paced offering, no cohort)', async () => {
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
assert.equal(convert.status, 201, JSON.stringify(convert.body));
assert.equal(convert.body.enrollment.program_id, PROGRAM);
assert.equal(convert.body.enrollment.status, 'active');
const student = convert.body.student;
assert.ok(student.login_identifier && !student.email, 'a minor with no email gets a login_identifier, never a guardian email written to students.email');
const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE student_id = '${student.student_id}'`);
assert.equal(Number(cohortRow), 0, 'offeringB has no cohort_id — no cohort membership should be created');
});
// ── 21: historical package version stability ─────────────────────────────────────────────
await test('bumping a curriculum package\'s version does not change its historical item list retroactively in a confusing way', async () => {
psql(`UPDATE curriculum_packages SET version = version + 1 WHERE curriculum_package_id = '${pkgId}'`);
const get = await owner.request('GET', `/api/admin/curriculum-packages/${pkgId}`);
assert.equal(get.body.items.length, 2, 'items must survive a version bump on the package row itself');
});
// ── 22/23: registration RBAC, guardian data RBAC ─────────────────────────────────────────
await test('a plain instructor cannot view registrations or create a guardian/organization, but CAN list offerings', async () => {
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p7-rbac-${RUN_ID}@example.com`, display_name: 'P7 RBAC', role: 'instructor', password: 'P7RbacPass123!' });
const stray = makeSession(GW);
const login = await stray.request('POST', '/api/auth/instructor/login', { email: `p7-rbac-${RUN_ID}@example.com`, password: 'P7RbacPass123!' });
assert.equal(login.status, 200);
const regs = await stray.request('GET', `/api/admin/offerings/${offeringB}/registrations`);
assert.equal(regs.status, 403);
const guardianCreate = await stray.request('POST', '/api/admin/guardians', { guardianId: `g-rbac-${RUN_ID}`, displayName: 'Should Fail' });
assert.equal(guardianCreate.status, 403);
const orgCreate = await stray.request('POST', '/api/admin/organizations', { organizationId: `org-rbac-${RUN_ID}`, name: 'Should Fail' });
assert.equal(orgCreate.status, 403);
const listOfferings = await stray.request('GET', '/api/admin/offerings');
assert.equal(listOfferings.status, 200, 'listing offerings themselves is not sensitive');
const listLocations = await stray.request('GET', '/api/admin/locations');
assert.equal(listLocations.status, 200);
assert.ok(listLocations.body.every((l) => !('access_notes' in l)), 'a plain instructor must never receive access_notes');
});
// ── 27: no age-based authorization lock anywhere ─────────────────────────────────────────
await test('an offering with a recommended age range still accepts a registrant of any age (guidance only)', async () => {
const agedOffering = `off-aged-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/offerings', { offeringId: agedOffering, slug: `p7-aged-${RUN_ID}`, name: 'Aged Offering', programId: PROGRAM, deliveryMode: 'workshop', recommendedMinAge: 15, recommendedMaxAge: 18 });
assert.equal(create.status, 201);
await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${agedOffering}/transition`, { status: 'enrollment_open' });
const reg = await owner.request('POST', `/api/admin/offerings/${agedOffering}/registrations`, { registrantDisplayName: 'Any Age Registrant', registrantEmail: `anyage-${RUN_ID}@example.com` });
assert.equal(reg.status, 201, 'no age field on the registration itself, and none is checked');
});
// ── 28/29/30: existing ROOT V1 / self-paced / classroom flows still work ─────────────────
await test('an existing ROOT_ V1 self-paced enrollment (unrelated to any offering) still works end to end', async () => {
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P7-RootV1-${RUN_ID}`, login_identifier: `p7-rootv1-${RUN_ID}` });
const s = makeSession(GW);
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
assert.equal(r.status, 200);
const { body } = await s.request('GET', `/api/student/programs/${enroll.enrollment_id}/courses`);
assert.ok(body.courses.some((c) => c.course_id === 'root-01-computing-linux-foundations'));
});
await test('an existing classroom (cohort) flow still works unaffected', async () => {
const cohortId = `p7-cohort-check-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P7 Cohort Check', tier: 'root' });
assert.equal(create.status, 201);
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P7 Check Session' });
assert.equal(session.status, 201);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env node
// Phase 8 — Enrollment Operations acceptance test. Admin offering/registration operations,
// launch readiness, registration-window enforcement, capacity concurrency through the REAL
// registration endpoints, the public offering serializer, the website registration integration
// contract (idempotency, concurrent duplicates), agreements (required/missing/version snapshot,
// blocking conversion), guardian recipient resolution, RBAC, and confirmation that ROOT_ V1/
// self-paced/classroom flows are unaffected. Same conventions as e2e-phase6a-7.js.
//
// 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_SHARED_SECRET=... node test/e2e-phase8.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 OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const INTEGRATION_KEY = process.env.INTEGRATION_SHARED_SECRET || 'dev-integration-secret-not-for-production';
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.stack || e.message}`); failed++; }
}
function psql(sql) {
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
}
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function main() {
console.log('Phase 8 Enrollment Operations acceptance test\n');
const owner = makeSession(GW);
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
// ── 1/2/3: admin offering list/detail/CRUD ───────────────────────────────────────────────
let offeringId, slug;
await test('admin can create, list, and fetch an offering', async () => {
offeringId = `off-p8-${RUN_ID}`; slug = `p8-camp-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: 'Phase 8 Test Camp', programId: PROGRAM, deliveryMode: 'camp', capacity: 3 });
assert.equal(create.status, 201);
const list = await owner.request('GET', '/api/admin/offerings');
assert.ok(list.body.some((o) => o.offering_id === offeringId));
const detail = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
assert.equal(detail.status, 200);
assert.equal(detail.body.status, 'draft');
});
// ── 4/5: status transitions, readiness calculation ───────────────────────────────────────
await test('status transitions are server-defined; readiness reflects real configuration', async () => {
const badTransition = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'in_progress' });
assert.equal(badTransition.status, 409);
const r1 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
assert.equal(r1.body.ready_to_open_registration, false);
const instructorCreate = await owner.request('POST', '/api/admin/instructors', { email: `p8-lead-${RUN_ID}@example.com`, display_name: 'P8 Lead', role: 'instructor', password: 'P8LeadPass123!' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/instructors`, { instructor_id: instructorCreate.body.instructor_id, role: 'teacher' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
const r2 = await owner.request('GET', `/api/admin/offerings/${offeringId}/readiness`);
const instructorCheck = r2.body.checks.find((c) => c.key === 'instructor');
assert.equal(instructorCheck.ready, true);
});
// ── 6/7: registration open/closed enforcement ────────────────────────────────────────────
let closedOffering;
await test('registration is rejected on a draft offering, and accepted once open', async () => {
closedOffering = `off-p8-closed-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: closedOffering, slug: `p8-closed-${RUN_ID}`, name: 'Closed Offering', programId: PROGRAM, deliveryMode: 'workshop' });
const rejected = await owner.request('POST', `/api/admin/offerings/${closedOffering}/registrations`, { registrantDisplayName: 'Too Early' });
assert.equal(rejected.status, 409);
assert.equal(rejected.body.error, 'OFFERING_NOT_OPEN');
const accepted = await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult First', registrantEmail: `adult-first-${RUN_ID}@example.com` });
assert.equal(accepted.status, 201);
});
// ── 8/9: full capacity + concurrent final-seat race ──────────────────────────────────────
await test('capacity is enforced and a concurrent final-seat race yields exactly one winner', async () => {
await owner.request('POST', `/api/admin/offerings/${offeringId}/registrations`, { registrantDisplayName: 'Adult Second', registrantEmail: `adult-second-${RUN_ID}@example.com` });
// capacity=3, 2 used — one seat left. Race two integration submissions for it.
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p8-race-a-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race A', email: `racea-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p8-race-b-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Race B', email: `raceb-${RUN_ID}@example.com` } }),
]);
const statuses = [a.status, b.status].sort();
assert.deepEqual(statuses, [201, 409], `expected exactly one winner: ${JSON.stringify([a, b])}`);
const final = await owner.request('GET', `/api/admin/offerings/${offeringId}`);
assert.equal(final.body.reserved_seats, 3);
assert.ok(final.body.reserved_seats <= final.body.capacity, 'must never overbook');
});
// ── 10/11: public offering safe serializer, no internal fields leaked ───────────────────
await test('the public offerings API never leaks internal/PII fields', async () => {
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
const forbidden = ['access_notes', 'organization_id', 'location_id', 'created_by', 'reserved_seats', 'capacity', 'metadata'];
for (const key of forbidden) assert.ok(!(key in pub), `public offering response must never include ${key}`);
assert.equal(pub.availability, 'full', 'a fully-booked offering should report full availability, not a raw seat count');
});
// ── 12/13/14: website registration integration, duplicate idempotency, concurrent dup ────
let integrationOffering, integrationSlug;
await test('website registration integration is idempotent under sequential AND concurrent replay', async () => {
integrationOffering = `off-p8-web-${RUN_ID}`; integrationSlug = `p8-web-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: integrationOffering, slug: integrationSlug, name: 'Web Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${integrationOffering}/transition`, { status: 'enrollment_open' });
const eventId = `p8-web-evt-${RUN_ID}`;
const first = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } });
assert.equal(first.status, 201);
const replay = await integrationPost('/api/integrations/registrations', { event_id: eventId, offering_slug: integrationSlug, registrant: { display_name: 'Web Student', email: `webstudent-${RUN_ID}@example.com` } });
assert.equal(replay.status, 200);
assert.equal(replay.body.registration_id, first.body.registration_id);
// Concurrent duplicate — two identical submissions racing the same event_id.
const concEventId = `p8-web-conc-${RUN_ID}`;
const [c1, c2] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: concEventId, offering_slug: integrationSlug, registrant: { display_name: 'Conc Student', email: `conc-${RUN_ID}@example.com` } }),
]);
assert.equal(c1.body.registration_id, c2.body.registration_id, 'a concurrent duplicate submission must resolve to exactly one logical registration');
const count = psql(`SELECT count(*) FROM offering_registrations WHERE registration_id = '${c1.body.registration_id}'`);
assert.equal(Number(count), 1);
});
// ── 15/16/17: adult registration, minor registration, guardian relationship ──────────────
let guardianId, minorRegId;
await test('adult registration needs no guardian; minor registration creates/links a real guardian', async () => {
guardianId = `guardian-p8-${RUN_ID}`;
await owner.request('POST', '/api/admin/guardians', { guardianId, displayName: 'P8 Guardian', email: `p8guardian-${RUN_ID}@example.com` });
const minorReg = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { guardianId, registrantDisplayName: 'P8 Minor', isMinor: true });
assert.equal(minorReg.status, 201);
minorRegId = minorReg.body.registration_id;
const badMinor = await owner.request('POST', `/api/admin/offerings/${integrationOffering}/registrations`, { registrantDisplayName: 'No Guardian', isMinor: true });
assert.equal(badMinor.status, 400);
});
// ── 18: student email semantics preserved (no guardian email written to students.email) ──
await test('converting a minor registration never writes the guardian email into students.email', async () => {
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${minorRegId}/transition`, { status: 'confirmed' });
const convert = await owner.request('POST', `/api/admin/registrations/${minorRegId}/convert`, {});
assert.equal(convert.status, 201, JSON.stringify(convert.body));
assert.equal(convert.body.student.email, null);
assert.ok(convert.body.student.login_identifier.startsWith('reg-'));
});
// ── 19/20/21: required agreement behavior, version snapshot, missing blocks conversion ───
let agreementId, agreementOffering;
await test('a required agreement blocks conversion until accepted; acceptance snapshots the version', async () => {
agreementId = `agree-p8-${RUN_ID}`;
await owner.request('POST', '/api/admin/agreements', { agreementId, title: 'P8 Agreement' });
agreementOffering = `off-p8-agree-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: agreementOffering, slug: `p8-agree-${RUN_ID}`, name: 'Agreement Offering', programId: PROGRAM, deliveryMode: 'workshop' });
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/transition`, { status: 'enrollment_open' });
await owner.request('POST', `/api/admin/offerings/${agreementOffering}/agreement-requirements`, { agreement_id: agreementId });
const reg = await owner.request('POST', `/api/admin/offerings/${agreementOffering}/registrations`, { registrantDisplayName: 'Agreement Tester', registrantEmail: `agreetest-${RUN_ID}@example.com` });
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' });
const blocked = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
assert.equal(blocked.status, 409);
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
const accept = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/agreements`, { agreementId, acceptedByType: 'registrant' });
assert.equal(accept.status, 201);
assert.equal(accept.body.accepted_version, 1);
psql(`UPDATE agreement_definitions SET version = version + 1 WHERE agreement_id = '${agreementId}'`);
const accepted = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/agreement-status`);
assert.equal(accepted.body.missing.length, 0, 'the already-recorded acceptance must still count even after the definition\'s live version changed');
const nowOk = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
assert.equal(nowOk.status, 201);
});
// ── 22/23: registration→enrollment, offering→cohort ──────────────────────────────────────
await test('a cohort-backed offering creates real cohort membership on conversion', async () => {
const cohortId = `p8-cohort-${RUN_ID}`;
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort', tier: 'root' });
const cohortOffering = `off-p8-cohort-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: cohortOffering, slug: `p8-cohort-off-${RUN_ID}`, name: 'Cohort Offering', programId: PROGRAM, deliveryMode: 'cohort', cohortId });
await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${cohortOffering}/transition`, { status: 'enrollment_open' });
const reg = await owner.request('POST', `/api/admin/offerings/${cohortOffering}/registrations`, { registrantDisplayName: 'Cohort Student', registrantEmail: `cohortstudent-${RUN_ID}@example.com` });
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'submitted' });
await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/transition`, { status: 'confirmed' });
const convert = await owner.request('POST', `/api/admin/registrations/${reg.body.registration_id}/convert`, {});
const cohortRow = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${convert.body.student.student_id}'`);
assert.equal(Number(cohortRow), 1);
});
// ── 24/25: account activation, notification queued ───────────────────────────────────────
await test('a converted student has a real activation token and a queued registration notification', async () => {
const notifs = await owner.request('GET', `/api/admin/registrations/${minorRegId}/notifications`);
assert.ok(notifs.body.length > 0, 'at least a registration_received notification must have been queued');
assert.ok(notifs.body.every((n) => !n.recipient_email_masked || n.recipient_email_masked.includes('*')), 'recipient email must always be masked in the admin view');
});
// ── 26: guardian recipient resolution ────────────────────────────────────────────────────
await test('notification recipient resolution prefers a real guardian when the student has no email', async () => {
const notifications = require('../api/src/notifications');
const { Pool } = require('../api/node_modules/pg');
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
try {
const { rows: studentRows } = await pool.query(`SELECT student_id FROM offering_registrations WHERE registration_id = '${minorRegId}'`);
const studentId = studentRows[0].student_id;
await pool.query(`INSERT INTO student_guardians (student_id, guardian_id, is_primary_contact, can_receive_communications) VALUES ($1, $2, true, true) ON CONFLICT (student_id, guardian_id) DO NOTHING`, [studentId, guardianId]);
const recipient = await notifications.resolveRecipient(pool, studentId, null);
assert.equal(recipient, `p8guardian-${RUN_ID}@example.com`);
} finally { await pool.end(); }
});
// ── 28/29: instructor offering access, instructor guardian-data denial ──────────────────
await test('a plain instructor can see offerings but not guardians/registrations; RBAC holds', async () => {
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p8-stray-${RUN_ID}@example.com`, display_name: 'P8 Stray', role: 'instructor', password: 'P8StrayPass123!' });
const stray = makeSession(GW);
await stray.request('POST', '/api/auth/instructor/login', { email: `p8-stray-${RUN_ID}@example.com`, password: 'P8StrayPass123!' });
const offeringsList = await stray.request('GET', '/api/admin/offerings');
assert.equal(offeringsList.status, 200);
const guardianDenied = await stray.request('POST', '/api/admin/guardians', { guardianId: `denied-${RUN_ID}`, displayName: 'Denied' });
assert.equal(guardianDenied.status, 403);
const registrationsDenied = await stray.request('GET', `/api/admin/offerings/${offeringId}/registrations`);
assert.equal(registrationsDenied.status, 403);
});
// ── 31: organization/location behavior ───────────────────────────────────────────────────
await test('organization/location creation and access_notes RBAC stripping', async () => {
const orgId = `org-p8-${RUN_ID}`, locId = `loc-p8-${RUN_ID}`;
await owner.request('POST', '/api/admin/organizations', { organizationId: orgId, name: 'P8 Org', type: 'library' });
await owner.request('POST', '/api/admin/locations', { locationId: locId, organizationId: orgId, name: 'P8 Loc', accessNotes: 'secret internal code' });
const asOwner = await owner.request('GET', '/api/admin/locations');
assert.ok(asOwner.body.find((l) => l.location_id === locId).access_notes);
});
// ── 33/34/35: existing ROOT V1 / self-paced / classroom flows unaffected ────────────────
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 8', async () => {
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P8-RootV1-${RUN_ID}`, login_identifier: `p8-rootv1-${RUN_ID}` });
const s = makeSession(GW);
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
assert.equal(r.status, 200);
});
await test('an existing classroom (cohort) flow still works unaffected', async () => {
const cohortId = `p8-cohort-check-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P8 Cohort Check', tier: 'root' });
assert.equal(create.status, 201);
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P8 Check Session' });
assert.equal(session.status, 201);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env node
// Phase 9 — Commerce Integration acceptance test. Price configuration, the public price
// serializer, checkout eligibility (agreements/registration-window/price), server-authoritative
// amount, verified webhook processing (signature, idempotency, success/failure/refund),
// registration confirmation -> enrollment conversion -> activation, guardian recipient routing,
// capacity/seat-reservation behavior, abandoned-reservation release, admin payment visibility,
// RBAC, audit/metric hygiene, and confirmation ROOT_ V1/Phase 8 registration/classroom flows
// are all unaffected. Uses the 'test' payment provider throughout — no real Stripe credentials,
// no real money, deterministic.
//
// 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=... node test/e2e-phase9.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const { getPaymentProvider } = require('../api/src/payment-provider');
const commerce = require('../api/src/commerce');
const { Pool } = require('../api/node_modules/pg');
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_SHARED_SECRET || 'dev-integration-secret-not-for-production';
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';
const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' });
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.stack || e.message}`); failed++; }
}
function psql(sql) {
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
}
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function integrationGet(path) {
const r = await fetch(`${GW}${path}`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function webhookPost(body, signature) {
const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
async function setupPaidOffering(owner, { offeringId, slug, capacity, requireAgreement = true }) {
await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: `Test Offering ${offeringId}`, programId: PROGRAM, deliveryMode: 'camp', capacity, priceAmount: 15000, priceCurrency: 'usd' });
if (requireAgreement) {
const agreementId = `agree-${offeringId}`;
await owner.request('POST', '/api/admin/agreements', { agreementId, title: `Agreement for ${offeringId}` }).catch(() => {});
await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
}
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
}
async function main() {
console.log('Phase 9 Commerce Integration acceptance test\n');
const owner = makeSession(GW);
{ const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); }
const provider = getPaymentProvider();
assert.equal(provider.name, 'test', 'this suite must run against the deterministic test provider, never real Stripe');
// ── 1/2: price configuration, public price serializer ────────────────────────────────────
const offeringId = `off-p9-${RUN_ID}`, slug = `p9-camp-${RUN_ID}`;
await test('an offering price is configured and reflected in the public serializer, never leaking internal fields', async () => {
await setupPaidOffering(owner, { offeringId, slug, capacity: 3 });
const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json());
assert.equal(pub.price_amount, 15000);
assert.equal(pub.price_currency, 'usd');
assert.equal(pub.requires_payment, true);
for (const key of ['transaction_id', 'provider_reference', 'reserved_seats', 'organization_id']) assert.ok(!(key in pub), `must not leak ${key}`);
});
// ── 3/4: checkout eligibility, missing agreements rejected ───────────────────────────────
let registrationId;
await test('checkout is rejected until required agreements are accepted', async () => {
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reg-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Test Adult', email: `testadult-${RUN_ID}@example.com` } });
assert.equal(reg.status, 201);
registrationId = reg.body.registration_id;
const blocked = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
assert.equal(blocked.status, 409);
assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE');
});
// ── 5: closed registration rejected ───────────────────────────────────────────────────────
await test('registration is rejected on a draft/closed offering', async () => {
const draftOffering = `off-p9-draft-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: draftOffering, slug: `p9-draft-${RUN_ID}`, name: 'Draft', programId: PROGRAM, deliveryMode: 'workshop' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-draft-evt-${RUN_ID}`, offering_slug: `p9-draft-${RUN_ID}`, registrant: { display_name: 'Nope', email: `nope-${RUN_ID}@example.com` } });
assert.equal(reg.status, 409);
assert.equal(reg.body.error, 'OFFERING_NOT_OPEN');
});
// ── 6: full offering rejected ─────────────────────────────────────────────────────────────
await test('a full offering rejects further registrations', async () => {
const fullOffering = `off-p9-full-${RUN_ID}`, fullSlug = `p9-full-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: fullOffering, slug: fullSlug, capacity: 1, requireAgreement: false });
const r1 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-1-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'A', email: `fa-${RUN_ID}@example.com` } });
assert.equal(r1.status, 201);
const r2 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-2-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'B', email: `fb-${RUN_ID}@example.com` } });
assert.equal(r2.status, 409);
assert.equal(r2.body.error, 'OFFERING_FULL');
});
// ── 7/8: server-authoritative amount, checkout creation ──────────────────────────────────
let transactionId, providerReference;
await test('accepting the agreement unblocks checkout, and the amount always comes from the offering, never the caller', async () => {
const agreementId = `agree-${offeringId}`;
const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId });
assert.equal(accept.status, 201);
const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c', amount: 1 });
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
transactionId = checkout.body.transaction_id;
const tx = psql(`SELECT amount FROM payment_transactions WHERE transaction_id = '${transactionId}'`);
assert.equal(Number(tx), 15000, 'the transaction amount must come from the offering, ignoring any amount the caller sent');
const txRow = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
providerReference = txRow.body[0].provider_reference;
});
// ── 9/10/11: webhook signature, invalid rejection, idempotency ───────────────────────────
await test('webhook signature is required, invalid signatures are rejected, and valid events are idempotent', async () => {
const event = provider.buildTestEvent({ providerReference, eventType: 'checkout.session.completed' });
const bad = await webhookPost(event.body, 'wrong-signature');
assert.equal(bad.status, 400);
assert.equal(bad.body.error, 'invalid_signature');
const first = await webhookPost(event.body, event.signature);
assert.equal(first.status, 200);
assert.equal(first.body.result, 'processed');
const replay = await webhookPost(event.body, event.signature);
assert.equal(replay.status, 200);
assert.equal(replay.body.result, 'duplicate');
const count = psql(`SELECT count(*) FROM payment_transactions WHERE transaction_id = '${transactionId}' AND status = 'paid'`);
assert.equal(Number(count), 1, 'a duplicate webhook must never double-process a payment');
});
// ── 12/14/15/16: payment success, registration confirmation, conversion, activation ──────
let convertedStudentId;
await test('payment success confirms the registration, converts it to a real enrollment, and queues activation', async () => {
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
assert.equal(reg.body.status, 'enrollment_activated');
convertedStudentId = reg.body.student_id;
assert.ok(convertedStudentId);
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${reg.body.enrollment_id}'`);
assert.equal(enrollmentStatus, 'active');
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
const types = notifs.body.map((n) => n.notification_type);
assert.ok(types.includes('registration_received') && types.includes('registration_confirmed') && types.includes('activation'), `expected all three, got ${types}`);
// no duplicate registration_confirmed
assert.equal(types.filter((t) => t === 'registration_confirmed').length, 1);
});
// ── 13: payment failure ────────────────────────────────────────────────────────────────────
await test('a failed payment does not release the seat and leaves the registration retriable', async () => {
const failOffering = `off-p9-failtest-${RUN_ID}`, failSlug = `p9-failtest-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: failOffering, slug: failSlug, capacity: 2, requireAgreement: false });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlug, registrant: { display_name: 'Fail', email: `fail-${RUN_ID}@example.com` } });
const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'payment_intent.payment_failed' });
const result = await webhookPost(event.body, event.signature);
assert.equal(result.body.result, 'processed');
const offAfter = await owner.request('GET', `/api/admin/offerings/${failOffering}`);
assert.equal(offAfter.body.reserved_seats, 1, 'a failed payment must not release the seat');
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
assert.equal(regAfter.body.status, 'started', 'a failed payment leaves the registration open to retry, not cancelled');
});
// Phase 9.1 real-Stripe-TEST regression — a real Stripe payment_intent.payment_failed/
// charge.refunded event references the PaymentIntent/Charge, not the Checkout Session, so
// StripeProvider.normalizeEvent() resolves those back to OUR OWN transaction_id (never the
// stored provider_reference) via payment_intent_data.metadata. This deterministic test
// simulates exactly that shape — a webhook event whose providerReference is the transaction's
// own UUID, not its provider_reference — proving commerce.js's lookup matches on either
// column. Found and fixed only by testing against real Stripe (the deterministic
// provider previously always reused one caller-supplied reference, which never exposed this).
await test('a webhook event referencing OUR OWN transaction_id (not provider_reference) — the real Stripe payment_intent/charge shape — is still matched', async () => {
const idOffering = `off-p9-txidref-${RUN_ID}`, idSlug = `p9-txidref-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: idOffering, slug: idSlug, capacity: 2, requireAgreement: false });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-txidref-evt-${RUN_ID}`, offering_slug: idSlug, registrant: { display_name: 'TxIdRef', email: `txidref-${RUN_ID}@example.com` } });
await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`);
const transactionId = txRow.body[0].transaction_id;
assert.notEqual(transactionId, txRow.body[0].provider_reference, 'sanity check — these must be genuinely different values for this test to mean anything');
const event = provider.buildTestEvent({ providerReference: transactionId, eventType: 'payment_intent.payment_failed' });
const result = await webhookPost(event.body, event.signature);
assert.equal(result.body.result, 'processed', 'the transaction must be found via transaction_id, not just provider_reference');
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
assert.equal(regAfter.body.status, 'started', 'a failed payment (matched via transaction_id) still leaves the registration retriable, not cancelled');
});
// ── 19/21: capacity handling, last-seat race ─────────────────────────────────────────────
await test('a last-seat race between two concurrent registration attempts yields exactly one winner', async () => {
const raceOffering = `off-p9-race-${RUN_ID}`, raceSlug = `p9-race-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: raceOffering, slug: raceSlug, capacity: 1, requireAgreement: false });
const [a, b] = await Promise.all([
integrationPost('/api/integrations/registrations', { event_id: `p9-race-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceA', email: `ra-${RUN_ID}@example.com` } }),
integrationPost('/api/integrations/registrations', { event_id: `p9-race-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceB', email: `rb-${RUN_ID}@example.com` } }),
]);
const statuses = [a.status, b.status].sort();
assert.deepEqual(statuses, [201, 409]);
const off = await owner.request('GET', `/api/admin/offerings/${raceOffering}`);
assert.equal(off.body.reserved_seats, 1);
});
// ── 20/21: abandoned reservation release ─────────────────────────────────────────────────
await test('an expired, unpaid reservation is released by the reaper and its registration cancelled', async () => {
const reapOffering = `off-p9-reap-${RUN_ID}`, reapSlug = `p9-reap-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: reapOffering, slug: reapSlug, capacity: 2, requireAgreement: false });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reap-evt-${RUN_ID}`, offering_slug: reapSlug, registrant: { display_name: 'Reap', email: `reap-${RUN_ID}@example.com` } });
psql(`UPDATE offering_registrations SET reservation_expires_at = now() - interval '1 minute' WHERE registration_id = '${reg.body.registration_id}'`);
const result = await commerce.reapExpiredReservations(pool);
assert.ok(result.released >= 1);
const offAfter = await owner.request('GET', `/api/admin/offerings/${reapOffering}`);
assert.equal(offAfter.body.reserved_seats, 0);
const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`);
assert.equal(regAfter.body.status, 'cancelled');
});
// ── 22: refund recording ──────────────────────────────────────────────────────────────────
await test('a manual refund is recorded without cascading into registration/enrollment cancellation', async () => {
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
const paid = tx.body.find((t) => t.status === 'paid');
const refund = await owner.request('POST', `/api/admin/payments/${paid.transaction_id}/refund`, { status: 'refunded' });
assert.equal(refund.status, 200);
assert.equal(refund.body.status, 'refunded');
const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration');
const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${regAfter.body.enrollment_id}'`);
assert.equal(enrollmentStatus, 'active', 'a refund must never auto-cancel the academic enrollment');
});
// ── 17: guardian recipient routing ────────────────────────────────────────────────────────
await test('a minor payment confirmation routes to the guardian, never overwriting students.email', async () => {
const minorOffering = `off-p9-minor-${RUN_ID}`, minorSlug = `p9-minor-${RUN_ID}`;
await setupPaidOffering(owner, { offeringId: minorOffering, slug: minorSlug, capacity: 2, requireAgreement: false });
const submit = await integrationPost('/api/integrations/registrations', {
event_id: `p9-minor-evt-${RUN_ID}`, offering_slug: minorSlug,
registrant: { display_name: 'Minor Camper', is_minor: true },
guardian: { display_name: 'Minor Guardian', email: `minorguardian-${RUN_ID}@example.com` },
});
assert.equal(submit.status, 201);
const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
const txRow = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`);
const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'checkout.session.completed' });
await webhookPost(event.body, event.signature);
const regAfter = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`);
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${regAfter.body.student_id}'`);
assert.equal(studentEmail, '', 'a minor student must never get the guardian email written to students.email');
const notifs = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/notifications`);
assert.ok(notifs.body.some((n) => n.notification_type === 'activation'));
});
// ── 18: admin payment view ────────────────────────────────────────────────────────────────
await test('admin payment view shows amount/status/provider but never card data (none is ever stored)', async () => {
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
assert.ok(tx.body.length > 0);
for (const t of tx.body) {
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
}
});
// ── 24/25: instructor payment-data restrictions, student payment isolation ──────────────
await test('a plain instructor cannot see registration payment data; the public API never can either', async () => {
const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p9-stray-${RUN_ID}@example.com`, display_name: 'P9 Stray', role: 'instructor', password: 'P9StrayPass123!' });
const stray = makeSession(GW);
await stray.request('POST', '/api/auth/instructor/login', { email: `p9-stray-${RUN_ID}@example.com`, password: 'P9StrayPass123!' });
const denied = await stray.request('GET', `/api/admin/registrations/${registrationId}/payments`);
assert.equal(denied.status, 403);
});
// ── 26/27: audit hygiene, metric hygiene ─────────────────────────────────────────────────
await test('commerce audit events never contain secrets/card data, and metrics stay low-cardinality', async () => {
const auditRow = psql(`SELECT metadata::text FROM audit_events WHERE action = 'checkout_created' ORDER BY created_at DESC LIMIT 1`);
assert.ok(!/sk_|whsec_|4242/.test(auditRow || ''), 'no Stripe secret or fake card number should ever appear in audit metadata');
const metricsBody = await fetch(`${GW}/api/metrics`).catch(() => null);
// Metrics may be unauthenticated-admin or absent in some deployments — only assert content
// shape when reachable, never fail the whole suite on an unrelated auth requirement.
if (metricsBody && metricsBody.ok) {
const text = await metricsBody.text();
assert.ok(!new RegExp(registrationId).test(text), 'a registration_id must never appear as a metric label');
}
});
await test('the website status-lookup endpoint is safe and minimal — no PII, no amount, no provider details', async () => {
const status = await integrationGet(`/api/integrations/registrations/${registrationId}/status`);
assert.equal(status.status, 200);
assert.deepEqual(Object.keys(status.body).sort(), ['enrollment_activated', 'payment_status', 'registration_id', 'status']);
const denied = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`);
assert.equal(denied.status, 401, 'no key must be rejected, same as every other integration route');
});
// ── 28/29/30: ROOT V1, Phase 8 registration, classroom/Lab flow all remain intact ───────
await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 9', async () => {
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P9-RootV1-${RUN_ID}`, login_identifier: `p9-rootv1-${RUN_ID}` });
const s = makeSession(GW);
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' });
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`);
assert.equal(r.status, 200);
});
await test('a free (Phase 8-style) offering registration still works with no price/checkout involved', async () => {
const freeOffering = `off-p9-free-${RUN_ID}`, freeSlug = `p9-free-${RUN_ID}`;
await owner.request('POST', '/api/admin/offerings', { offeringId: freeOffering, slug: freeSlug, name: 'Free Offering', programId: PROGRAM, deliveryMode: 'self_paced' });
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'published' });
await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'enrollment_open' });
const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-free-evt-${RUN_ID}`, offering_slug: freeSlug, registrant: { display_name: 'Free Student', email: `free-${RUN_ID}@example.com` } });
assert.equal(reg.status, 201);
const checkoutAttempt = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
assert.equal(checkoutAttempt.status, 409);
assert.equal(checkoutAttempt.body.error, 'NO_PRICE_CONFIGURED');
});
await test('an existing classroom (cohort) flow still works unaffected', async () => {
const cohortId = `p9-cohort-check-${RUN_ID}`;
const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P9 Cohort Check', tier: 'root' });
assert.equal(create.status, 201);
const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P9 Check Session' });
assert.equal(session.status, 201);
});
await pool.end();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch(async (e) => { console.error(e); await pool.end().catch(() => {}); process.exit(1); });
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
// Phase 9.1 — the permanent, OPT-IN real-Stripe-TEST-mode integration check Phase 9 itself did
// not build (Phase 9 tested exclusively against the deterministic TestProvider). This script
// makes NO Stripe network call at all unless explicitly enabled, so normal regression runs and
// CI stay fully deterministic and independent of Stripe/credential availability.
//
// Enable with: RUN_STRIPE_INTEGRATION_TEST=true
// Requires (same env var names payment-provider.js already uses — never a duplicate config
// var): STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. Both must already be configured on the
// RUNNING API process too (PAYMENT_PROVIDER=stripe) — this script is an external verifier, it
// does not restart or reconfigure the server.
//
// Refuses to run against a live-mode secret key (sk_live_...) — TEST MODE ONLY, always. Creates
// one disposable, clearly-named TEST offering and TEST registration; attempts to cancel the
// offering when done (best-effort cleanup, never fatal if it fails).
//
// What this script CAN prove by itself, purely via API calls (no browser needed):
// - RootNode really creates a real Stripe TEST Checkout Session (verified by retrieving the
// session directly from Stripe's own API, not just trusting RootNode's response).
// - The amount Stripe actually recorded matches the offering's server-side price exactly,
// even when the caller's request body carries a different, tampered amount — proving
// server-authoritative pricing against Stripe's OWN object, not just RootNode's database.
// - RootNode's real webhook route rejects a garbage/invalid signature cleanly, with no domain
// side effect — this does not require an actual completed payment to test honestly.
//
// What this script deliberately does NOT fake: actually completing Stripe's hosted Checkout
// page (filling in a test card) is a real human/browser action — there is no supported pure-API
// way to "pay" a Checkout Session, and fabricating a signed "payment succeeded" event for a
// session nobody actually paid would be exactly the kind of faked validation Phase 9.1 was told
// never to produce. When enabled, this script prints the real checkout_url and polls RootNode's
// own status endpoint for STRIPE_INTEGRATION_TEST_WAIT_SECONDS (default 90) while a human
// completes it in a browser (test card 4242 4242 4242 4242) with a parallel
// `stripe listen --forward-to <gateway>/api/webhooks/payment` relaying Stripe's real event.
//
// Once that real payment lands, everything else IS fully automated via real Stripe API calls:
// payment-transaction/enrollment/cohort/guardian reconciliation, a genuine duplicate-delivery
// proof (re-signing the REAL event Stripe recorded, fetched fresh via stripe.events.retrieve —
// never a fabricated one), real student activation + program-visibility proof, a real Stripe
// TEST refund with the non-cascade rule reconfirmed, and — needing no browser at all — a real
// abandoned/expired-checkout proof via Stripe's own `sessions.expire()` API. Configurable via
// STRIPE_TEST_PRICE_CENTS/STRIPE_TEST_CURRENCY (defaults to a cheap $1 generic check) and
// STRIPE_TEST_MINOR=true (guardian+minor path instead of adult). See
// docs/STRIPE_INTEGRATION_RUNBOOK.md and docs/PHASE9_1_STRIPE_TEST_VALIDATION.md.
const assert = require('assert');
const RUN = process.env.RUN_STRIPE_INTEGRATION_TEST === 'true';
if (!RUN) {
console.log('Stripe integration test skipped (RUN_STRIPE_INTEGRATION_TEST is not "true") — no Stripe network traffic, no credentials required.');
process.exit(0);
}
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_SHARED_SECRET || 'dev-integration-secret-not-for-production';
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const WAIT_SECONDS = Number(process.env.STRIPE_INTEGRATION_TEST_WAIT_SECONDS) || 90;
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one with platform/api/bootstrap-owner.js).');
process.exit(1);
}
if (!STRIPE_SECRET_KEY) {
console.error('RUN_STRIPE_INTEGRATION_TEST=true requires STRIPE_SECRET_KEY (the same variable payment-provider.js reads) so this script can independently verify what Stripe actually recorded.');
process.exit(1);
}
if (STRIPE_SECRET_KEY.startsWith('sk_live_')) {
console.error('REFUSING TO RUN: STRIPE_SECRET_KEY looks like a LIVE-mode key (sk_live_...). This script is TEST MODE ONLY. Use an sk_test_ key.');
process.exit(1);
}
if (!STRIPE_SECRET_KEY.startsWith('sk_test_')) {
console.error('REFUSING TO RUN: STRIPE_SECRET_KEY does not look like a Stripe test key (expected sk_test_...). Refusing out of caution.');
process.exit(1);
}
const Stripe = require('../api/node_modules/stripe');
const stripe = new Stripe(STRIPE_SECRET_KEY);
const { makeSession } = require('./session-helper');
const RUN_ID = Date.now();
const PROGRAM = 'root-kubernetes-foundations';
// Phase 9.1 resume — overridable so a real business price (e.g. the $399/39900 Founding Cohort
// tuition) can be validated directly against Stripe's own recorded amount, instead of always
// testing an arbitrary $1. Defaults stay cheap/generic for a routine opt-in regression run.
const TEST_PRICE_CENTS = Number(process.env.STRIPE_TEST_PRICE_CENTS) || 100;
const TEST_CURRENCY = process.env.STRIPE_TEST_CURRENCY || 'usd';
const TEST_MINOR = process.env.STRIPE_TEST_MINOR === 'true'; // guardian+minor path vs. adult
const TAMPERED_AMOUNT_CENTS = 1; // what a malicious caller will try to substitute
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.stack || e.message}`); failed++; }
}
async function integrationPost(path, body) {
const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) });
return { status: r.status, body: await r.json().catch(() => ({})) };
}
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
function psql(sql) {
const { execSync } = require('child_process');
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
}
async function main() {
console.log('Phase 9.1 real-Stripe-TEST-mode integration check (opt-in, real Stripe API calls, TEST MODE ONLY)\n');
console.log(`Price under test: ${TEST_PRICE_CENTS} ${TEST_CURRENCY} (${(TEST_PRICE_CENTS / 100).toFixed(2)}) — ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration path.\n`);
const owner = makeSession(GW);
const offeringId = `stripe-it-${RUN_ID}`, slug = `stripe-integration-test-${RUN_ID}`;
const agreementId = `stripe-it-agreement-${RUN_ID}`;
let registrationId, sessionId, checkoutUrl, cohortId;
await test('owner logs in', async () => {
const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(r.status, 200, JSON.stringify(r.body));
});
await test('a disposable TEST offering is created with the real TEST price and a cohort', async () => {
cohortId = `stripe-it-cohort-${RUN_ID}`;
const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: `[TEST] Stripe Integration Cohort ${RUN_ID}`, tier: 'root' });
assert.equal(cohort.status, 201, JSON.stringify(cohort.body));
const create = await owner.request('POST', '/api/admin/offerings', {
offeringId, slug, name: `[TEST] Stripe Integration Check ${RUN_ID} — NOT the real Winter Break offering`,
programId: PROGRAM, deliveryMode: 'cohort', cohortId,
priceAmount: TEST_PRICE_CENTS, priceCurrency: TEST_CURRENCY,
});
assert.equal(create.status, 201, JSON.stringify(create.body));
const agreement = await owner.request('POST', '/api/admin/agreements', { agreementId, title: '[TEST] Stripe Integration Waiver', bodySummary: 'Disposable test agreement — Phase 9.1.' });
assert.equal(agreement.status, 201, JSON.stringify(agreement.body));
const link = await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId });
assert.equal(link.status, 201, JSON.stringify(link.body));
await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' });
const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' });
assert.equal(open.status, 200);
});
await test(`a real ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration is created through the public integration contract`, async () => {
const body = TEST_MINOR
? { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Stripe IT Test Camper', is_minor: true },
guardian: { display_name: 'Stripe IT Test Guardian', email: `stripe-it-guardian-${RUN_ID}@example.com`, phone: '555-0199' } }
: { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Stripe Integration Tester', email: `stripe-it-${RUN_ID}@example.com` } };
const reg = await integrationPost('/api/integrations/registrations', body);
assert.equal(reg.status, 201, JSON.stringify(reg.body));
registrationId = reg.body.registration_id;
});
await test('checkout is rejected before the required agreement is accepted', async () => {
const attempt = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
assert.equal(attempt.status, 409);
assert.equal(attempt.body.error, 'AGREEMENTS_INCOMPLETE');
});
await test('accepting the agreement is persisted with a version', async () => {
const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId });
assert.equal(accept.status, 201, JSON.stringify(accept.body));
assert.equal(accept.body.accepted_version, 1);
});
await test('RootNode creates a REAL Stripe TEST Checkout Session, and a tampered caller-supplied amount is ignored', async () => {
// amount/currency here are not real fields on this endpoint's contract (Step 5's own
// "the browser never supplies a price" design) — sending them anyway proves they're inert.
const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, {
success_url: 'https://example.com/success', cancel_url: 'https://example.com/cancel',
amount: TAMPERED_AMOUNT_CENTS, currency: 'eur',
});
assert.equal(checkout.status, 201, JSON.stringify(checkout.body));
checkoutUrl = checkout.body.checkout_url;
assert.ok(checkoutUrl.startsWith('https://checkout.stripe.com/'), `expected a real Stripe-hosted checkout URL, got: ${checkoutUrl}`);
// Independently verify against STRIPE'S OWN API — never trust RootNode's own echo of what
// it thinks it sent.
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
sessionId = tx.body[0].provider_reference;
const session = await stripe.checkout.sessions.retrieve(sessionId);
assert.equal(session.mode, 'payment');
assert.equal(session.amount_total, TEST_PRICE_CENTS, 'Stripe must have recorded the OFFERING price, never the tampered caller amount');
assert.equal(session.currency, TEST_CURRENCY, 'Stripe must have recorded the OFFERING currency, never the tampered caller currency');
assert.equal(session.livemode, false, 'this must be a TEST-mode session — refusing to proceed otherwise');
console.log(` Stripe session ${sessionId}: amount_total=${session.amount_total} ${session.currency}, livemode=${session.livemode}`);
});
await test('an invalid webhook signature is rejected with no domain side effect', async () => {
const before = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
const r = await fetch(`${GW}/api/webhooks/payment`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': 'invalid-signature-not-real' },
body: JSON.stringify({ id: 'evt_fake', type: 'checkout.session.completed', data: { object: { id: sessionId } } }),
});
assert.equal(r.status, 400);
const after = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
assert.equal(after.body.status, before.body.status, 'an invalid signature must never move the registration forward');
});
console.log(`\n${'─'.repeat(78)}`);
console.log('Everything above is fully automated and required no human/browser step.');
console.log('What remains needs a real completed Stripe Checkout — this is a genuine human or');
console.log('Stripe-CLI action, never simulated by this script (see the file header comment and');
console.log('docs/PHASE9_1_STRIPE_TEST_VALIDATION.md):\n');
console.log(` 1. Open this REAL Stripe TEST checkout page in a browser:\n ${checkoutUrl}`);
console.log(' 2. Pay with Stripe\'s official test card: 4242 4242 4242 4242, any future expiry, any CVC/ZIP.');
console.log(` 3. Make sure Stripe can reach your webhook — e.g. \`stripe listen --forward-to ${GW}/api/webhooks/payment\`.`);
console.log(`\nPolling RootNode's own status endpoint for up to ${WAIT_SECONDS}s for a real webhook-confirmed result...\n`);
const deadline = Date.now() + WAIT_SECONDS * 1000;
let finalStatus = null;
while (Date.now() < deadline) {
const r = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
const status = await r.json();
if (status.payment_status === 'paid' || status.status === 'enrollment_activated') { finalStatus = status; break; }
await sleep(3000);
}
let realEventId = null, realPaymentIntentId = null, enrollmentId = null, studentId = null;
if (finalStatus) {
console.log(` ok — a REAL Stripe TEST payment was completed and confirmed by a REAL signed webhook: ${JSON.stringify(finalStatus)}`);
passed++;
await test('exactly one payment_transactions record, paid, reconciled against Stripe — no card data anywhere', async () => {
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
assert.equal(tx.body.length, 1, `expected exactly one transaction, got ${tx.body.length}`);
const t = tx.body[0];
assert.equal(t.status, 'paid');
assert.equal(t.provider_reference, sessionId);
for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t));
const session = await stripe.checkout.sessions.retrieve(sessionId);
realPaymentIntentId = session.payment_intent;
assert.ok(realPaymentIntentId, 'a paid session must have a real PaymentIntent');
realEventId = psql(`SELECT webhook_event_id FROM payment_webhook_events WHERE provider = 'stripe' AND transaction_id = (SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}') ORDER BY received_at DESC LIMIT 1`);
assert.ok(realEventId, 'the real Stripe event id must be recorded');
});
await test('registration confirmed, enrollment converted exactly once, cohort membership created, guardian preserved', async () => {
const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
assert.equal(reg.body.status, 'enrollment_activated');
enrollmentId = reg.body.enrollment_id;
studentId = reg.body.student_id;
const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`);
assert.equal(enrollmentCount.trim(), '1', 'exactly one enrollment must exist for this student/program');
const cohortCount = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${studentId}'`);
assert.equal(cohortCount.trim(), '1', 'exactly one cohort membership must exist');
if (TEST_MINOR) {
const guardianLink = psql(`SELECT count(*) FROM student_guardians WHERE student_id = '${studentId}'`);
assert.equal(guardianLink.trim(), '1', 'the minor student must have exactly one real guardian relationship');
const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${studentId}'`);
assert.equal(studentEmail.trim(), '', 'the guardian email must never be written to the minor student\'s own identity record');
}
});
await test('a REAL, Stripe-issued duplicate delivery of the same event is idempotent — no double conversion', async () => {
// Fetches the REAL event Stripe recorded (not fabricated) and re-signs it with the REAL
// webhook secret to prove a genuine Stripe retry-delivery scenario is handled correctly —
// never inventing a "payment succeeded" event for a session nobody paid (that would be
// exactly the faked validation this phase was told never to produce). This mirrors what
// `stripe events resend` does server-side; done locally here because a `stripe listen`
// ephemeral forwarding session has no persistent dashboard endpoint id to target.
const realEvent = await stripe.events.retrieve(realEventId);
const payload = JSON.stringify(realEvent);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret: process.env.STRIPE_WEBHOOK_SECRET });
const replay = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header }, body: payload });
const replayBody = await replay.json();
assert.equal(replay.status, 200);
assert.equal(replayBody.result, 'duplicate');
const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`);
assert.equal(enrollmentCount.trim(), '1', 'a duplicate delivery must never create a second enrollment');
const notifCount = psql(`SELECT count(*) FROM notification_events WHERE enrollment_id = '${enrollmentId}' AND notification_type IN ('activation','enrollment_confirmation')`);
assert.equal(notifCount.trim(), '1', 'a duplicate delivery must never queue a second activation/confirmation email');
});
await test('student access: the real activation token works and the enrolled program is visible', async () => {
require('child_process').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' });
await sleep(3000);
const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`);
const activationNotif = notifs.body.find((n) => n.notification_type === 'activation' || n.notification_type === 'enrollment_confirmation');
assert.ok(activationNotif, `expected a real activation/confirmation notification, got: ${JSON.stringify(notifs.body)}`);
if (activationNotif.notification_type === 'activation') {
const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`);
const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1];
const s = makeSession(GW);
const activate = await s.request('POST', '/api/student/activate', { token, password: 'StripeIT!2026Test' });
assert.equal(activate.status, 200, JSON.stringify(activate.body));
const programs = await s.request('GET', '/api/student/programs');
assert.ok(programs.body.some((p) => p.program_id === PROGRAM), 'the enrolled program must be visible to the newly-activated student');
}
});
await test('a Stripe TEST refund is recorded without cascading into registration/enrollment status', async () => {
const refund = await stripe.refunds.create({ payment_intent: realPaymentIntentId });
assert.equal(refund.status, 'succeeded');
// Give the (not-yet-forwarded) refund webhook a moment if stripe listen also relays
// charge.refunded; either way, prove RootNode's OWN non-cascade rule holds regardless —
// record it via the admin route directly if the webhook hasn't landed yet.
await sleep(2000);
const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`);
const t = tx.body[0];
if (t.status !== 'refunded') {
const transactionId = psql(`SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}'`).trim();
await owner.request('POST', `/api/admin/payments/${transactionId}/refund`, {});
}
const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`);
assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration');
const enrollmentAfter = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${enrollmentId}'`);
assert.equal(enrollmentAfter.trim(), 'active', 'a refund must never auto-revoke the enrollment — this is a business-policy question, not something to decide in code');
console.log(` Stripe refund ${refund.id}: status=${refund.status}, amount=${refund.amount} ${refund.currency}`);
});
console.log(`\n${'─'.repeat(78)}`);
console.log('Stripe <-> RootNode reconciliation for this run:');
console.log(` RootNode registration: ${registrationId}`);
console.log(` RootNode enrollment: ${enrollmentId}`);
console.log(` RootNode student: ${studentId}`);
console.log(` Stripe Checkout Session: ${sessionId}`);
console.log(` Stripe PaymentIntent: ${realPaymentIntentId}`);
console.log(` Stripe event (webhook): ${realEventId}`);
console.log(` Amount / currency: ${TEST_PRICE_CENTS} ${TEST_CURRENCY}`);
} else {
console.log(' (not completed within the wait window — go complete the checkout in a browser and rerun, or increase STRIPE_INTEGRATION_TEST_WAIT_SECONDS)');
}
await test('abandoned/expired checkout (pure API, no browser needed): the seat is released exactly once', async () => {
const abReg = await integrationPost('/api/integrations/registrations', {
event_id: `stripe-it-abandon-${RUN_ID}`, offering_slug: slug,
registrant: { display_name: 'Stripe IT Abandon Tester', email: `stripe-it-abandon-${RUN_ID}@example.com` },
});
assert.equal(abReg.status, 201, JSON.stringify(abReg.body));
const abAccept = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/agreements`, { agreement_id: agreementId });
assert.equal(abAccept.status, 201);
const abCheckout = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' });
assert.equal(abCheckout.status, 201, JSON.stringify(abCheckout.body));
const abTx = await owner.request('GET', `/api/admin/registrations/${abReg.body.registration_id}/payments`);
const abSessionId = abTx.body[0].provider_reference;
// A REAL Stripe API call — no browser needed — asking Stripe to expire an open session,
// which makes Stripe itself fire a genuine checkout.session.expired webhook.
await stripe.checkout.sessions.expire(abSessionId);
const deadline2 = Date.now() + 30000;
let released = false;
while (Date.now() < deadline2) {
const r = await fetch(`${GW}/api/integrations/registrations/${abReg.body.registration_id}/status`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } });
const s = await r.json();
if (s.status === 'cancelled') { released = true; break; }
await sleep(2000);
}
assert.ok(released, 'the abandoned/expired checkout must release the seat within 30s of the real Stripe webhook');
});
// Best-effort cleanup — never fatal.
try { await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'cancelled' }); } catch { /* ponytail: best-effort cleanup only */ }
console.log(`\n${passed} passed, ${failed} failed${finalStatus ? '' : ' (real-payment step not completed this run)'}`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });