Compare commits
9
Commits
80a5f6e457
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a5902ddf | ||
|
|
bfae2aafe0 | ||
|
|
74033b4cb8 | ||
|
|
0b2303a3e2 | ||
|
|
a4c0058f8c | ||
|
|
fbf52d8334 | ||
|
|
c86b3de611 | ||
|
|
77fd1869db | ||
|
|
f0ff14af38 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# RootNode Academy Pricing Matrix V1
|
||||
|
||||
> **FOUNDER APPROVED — LOCKED FOR V1**
|
||||
|
||||
This document is the canonical V1 pricing decision for RootNode Academy. It was recovered from
|
||||
the synced RootNode Academy conversation after the original Phase 9.5 work was found to be
|
||||
absent from canonical Git history.
|
||||
|
||||
Prices below are in USD. A founding or early-bird price is a defined offer price, not a range.
|
||||
|
||||
## Seasonal camps
|
||||
|
||||
| Offering | Standard price | Launch price |
|
||||
|---|---:|---:|
|
||||
| Winter Break Camp | $449 | $399 — Founding Cohort |
|
||||
| Summer Camp | $499 | $449 — Early Bird |
|
||||
| Spring Break Camp | $449 | $399 — Early Bird |
|
||||
|
||||
## Instructor-led programs
|
||||
|
||||
| Offering | Standard price | Founding price |
|
||||
|---|---:|---:|
|
||||
| INIT_ Instructor-Led | $249 | $199 |
|
||||
| COMPILE_ Instructor-Led | $399 | $349 |
|
||||
| ROOT_ Foundations | $699 | $599 |
|
||||
| ROOT_ Kubernetes Core | $799 | $699 |
|
||||
| ROOT_ Operate & Secure | $899 | $799 |
|
||||
|
||||
## Self-paced
|
||||
|
||||
| Offering | Price | Access model |
|
||||
|---|---:|---|
|
||||
| ROOT_ Self-Paced | $299 | V1 one-time access |
|
||||
|
||||
No recurring subscription is approved or implemented for V1.
|
||||
|
||||
## Mentoring and workshops
|
||||
|
||||
| Offering | Price |
|
||||
|---|---:|
|
||||
| 1:1 Technical Mentoring — single hour | $100 |
|
||||
| 1:1 Technical Mentoring — four-hour package | $360 |
|
||||
| 1:1 Technical Mentoring — eight-hour package | $680 |
|
||||
| Technical Workshop | $149–$299 framework; price set per workshop |
|
||||
| School & Community Program | Custom quote |
|
||||
|
||||
The Technical Workshop range is an approved pricing framework, not one universal fixed price.
|
||||
|
||||
## Sibling policy
|
||||
|
||||
Each additional sibling receives 10% off tuition. This policy does not authorize or require a
|
||||
promotion engine in V1.
|
||||
|
||||
## Winter Break Camp launch terms
|
||||
|
||||
- Commercial label: **Founding Cohort**
|
||||
- Launch tuition: **$399 USD**
|
||||
- Application representation: **39900 USD cents**
|
||||
- Standard/reference tuition: **$449 USD** (`44900` cents)
|
||||
- Payment: paid in full
|
||||
- No deposit
|
||||
- No payment plan
|
||||
|
||||
Pricing approval does not resolve or authorize dates, venue, capacity, tax treatment,
|
||||
refund/cancellation policy, or legal agreement language. Those remain separate launch gates.
|
||||
|
||||
## Implementation authority
|
||||
|
||||
RootNode's application remains authoritative for offerings, eligibility, registration, and the
|
||||
server-side amount sent to Stripe. Stripe executes and records payment; it is not the canonical
|
||||
RootNode product catalog.
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user