9 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
17 changed files with 1598 additions and 87 deletions
+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.
+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.
+11
View File
@@ -271,6 +271,17 @@ Real Stripe live-mode validation once credentials exist (`docs/STRIPE_INTEGRATIO
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
+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.
+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.
+19 -10
View File
@@ -72,22 +72,31 @@ To simulate a **failed payment** or **abandoned checkout**, use `eventType:
at all; wait past `REGISTRATION_RESERVATION_TIMEOUT_MINUTES`, or lower it for a faster local
test).
## Local testing against real Stripe TEST mode (once credentials exist)
## Local testing against real Stripe TEST mode
Not yet exercised in this repo — no Stripe account/credentials have been provided as of this
phase. Once they exist:
**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) and run:
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 login
stripe listen --forward-to localhost:4200/api/webhooks/payment
stripe listen --api-key sk_test_... --forward-to localhost:4200/api/webhooks/payment
```
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).
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
+6 -3
View File
@@ -83,7 +83,10 @@ flow — see `docs/PHASE9_COMMERCE_AND_LAUNCH.md` and `docs/WINTER_BREAK_LAUNCH_
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 does not exist yet in this repository
(rootnodeacademy.com is built separately) — the integration contract it needs is ready
(`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`), but nobody has built the website-side form yet.
- 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.
+104 -68
View File
@@ -1,75 +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. Combines
`docs/WINTER_BREAK_CAMP_OPERATIONS.md`'s operational sequence with Phase 9's commerce
requirements into one checklist Chase can walk top to bottom. Nothing on this list is invented by
the platform — every unchecked item is a real decision or asset that has to come from Chase, the
legal reviewer, or Stripe, not from more code.
(`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.
## Platform readiness — done, verified, ready whenever the business inputs below land
- [x] Offering, capacity, registration lifecycle, agreements — built and tested (Phase 7/8).
- [x] Offering pricing field, payment-provider abstraction, hosted checkout, verified webhook,
refund recording, abandoned-reservation reaper — built and tested against the deterministic
test provider (Phase 9).
- [x] Website registration + checkout + agreement + status-lookup contract — documented and
tested (`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`).
- [x] Admin UI for offering pricing and per-registration payment/refund status.
- [x] `GET /api/admin/offerings/:id/readiness` reflects real configuration state, including the
new `price` and `payment_provider` checks.
- [x] Full Winter Break commerce rehearsal green end to end against a real, freshly-built stack
(`infra/scripts/drills/test-phase9-winter-camp-commerce.js`, 13/13): admin configuration,
minor and adult registration, checkout, payment success, webhook, enrollment activation, a
real classroom session, a real Lab task, failed payment, duplicate webhook, abandoned
checkout, and a last-seat race.
## Business decisions — Chase's call, not invented by this phase
- [ ] Real camp dates and duration.
- [ ] Real venue/location (or confirm fully online/remote).
- [ ] Real capacity (seat count).
- [ ] Real price and currency.
- [ ] Whether registration opens ahead of, or alongside, a specific marketing push.
- [ ] Refund policy in plain terms (the platform can RECORD any refund decision; it does not
decide policy — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`'s "Payment and the commerce
boundary" section for what "record, never auto-cascade" means operationally).
## Legal review — required before any real family sees the agreement
- [ ] The actual participation/media/liability agreement text, reviewed by a real attorney
(`agreement_definitions.body_summary` is currently a plain-language placeholder only — see
`docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list).
- [ ] Jurisdiction-specific minor-consent requirements confirmed.
- [ ] Data-retention/deletion policy decided and, if needed, reflected in the agreement text.
- [ ] A decision on whether emergency-contact fields need additional encryption at rest.
## Payments — pending real Stripe credentials
- [ ] Real Stripe account created/available (live mode).
- [ ] `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) provisioned into production
configuration only — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going live" section.
- [ ] At least one real, small, refundable test transaction run end to end in production before
opening registration publicly, then refunded.
- [ ] Recommended (not required — the test provider already validates the application logic):
one full rehearsal against Stripe's own TEST mode (fake cards, no real money) before
flipping to live credentials, per `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s test-mode section.
## Website (separate repo, not RootNode-owned)
- [ ] rootnodeacademy.com's own registration/checkout form built against
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md`. Not tracked or built in this repository — if
that repo becomes available to RootNode's own engineering effort, say so explicitly before
any code changes are made against it.
- [ ] The website's `INTEGRATION_SHARED_SECRET` provisioned into its OWN backend configuration
(never browser-reachable) — see `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`'s "Production
secret handling."
- [ ] The website's success page built against the status-lookup polling pattern in
`docs/WEBSITE_REGISTRATION_AND_CHECKOUT.md` §6 — never derived from the redirect URL alone.
- [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
Registration should not open to the public until every box above is checked. The platform side
(first section) has been true since this phase shipped; everything else is a business, legal, or
external-team dependency this phase deliberately did not — and should not — invent on Chase's
behalf.
## 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.**
+7 -1
View File
@@ -85,7 +85,13 @@ async function processWebhookEvent(pool, studentsAuth, provider, normalizedEvent
return { result: 'ignored' };
}
const { rows: txRows } = await pool.query(`SELECT * FROM payment_transactions WHERE provider_reference = $1`, [providerReference]);
// 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]);
+32 -4
View File
@@ -42,13 +42,22 @@ class StripeProvider {
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.
verifyAndParseWebhook(rawBody, signatureHeader) {
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);
@@ -58,14 +67,33 @@ class StripeProvider {
// 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.
normalizeEvent(event) {
//
// 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') { normalizedStatus = 'failed'; providerReference = obj.metadata?.checkout_session_id || obj.id; }
else if (event.type === 'charge.refunded') { normalizedStatus = obj.amount_refunded < obj.amount ? 'partially_refunded' : 'refunded'; providerReference = obj.payment_intent || 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 };
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ module.exports = function registerWebhookRoutes(app, deps) {
const signature = req.get('Stripe-Signature') || req.get('X-Test-Payment-Signature');
let normalizedEvent;
try {
normalizedEvent = provider.verifyAndParseWebhook(req.rawBody, signature);
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
+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
# real, then unsets it again — see that test's own comment.
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
depends_on:
postgres:
+23
View File
@@ -175,6 +175,29 @@ async function main() {
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}`;
+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); });