Files
rootnode-academy/platform/docker-compose.dev.yml
T
ced4568 c86b3de611 fix(payment): correlate PaymentIntent/Charge-referencing webhooks to the right transaction
Real Stripe TEST traffic (Phase 9.1) exposed a genuine defect the deterministic TestProvider
never could: payment_intent.payment_failed and charge.refunded events reference Stripe's
PaymentIntent/Charge object, never the Checkout Session, but payment_transactions.
provider_reference only ever stored the Session id. A real failure or refund webhook would have
silently failed to match any transaction (UNKNOWN_TRANSACTION).

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

Verified fixed against a real Stripe TEST refund (Phase 9.1); e2e-phase9.js 20/20 and the Winter
Break commerce rehearsal 13/13 rerun clean after the fix.
2026-08-28 09:46:51 -05:00

124 lines
6.2 KiB
YAML

# Phase 2B full-stack local dev/test — Postgres + egress-proxy + Provisioner + the lightweight
# Platform API + the Gateway, all on one machine (the documented Phase 1/2A/2B dev exception;
# production is the two-VM Proxmox split, see docs/LAB_ARCHITECTURE.md §1).
#
# `api` (platform/api) replaces Phase 2A's "identity" service — that was a full, --privileged
# Lab image booting an unused k3s cluster just to serve roster/curriculum/progress APIs. This
# is a plain Node process: no k3s, no --privileged, no terminal runtime. It owns
# students/cohorts/enrollments/curriculum/progress/scenario_attempts in Postgres and is the
# only service that talks to the Provisioner (student launch/reset AND instructor/admin
# actions) — see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md.
#
# gateway, provisioner, and api's egress-adjacent needs aside, `api` does NOT need
# network_mode: host — it never touches Docker networking or firewall rules itself, it only
# calls the Provisioner's HTTP API and Postgres. gateway and provisioner keep network_mode:
# host because they need to reach ephemeral per-student Lab containers by internal IP /
# manage firewall rules on the host network namespace.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rootnode_platform
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
egress-proxy:
build: ./egress-proxy
network_mode: host
provisioner:
build: ./provisioner
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PROVISIONER_PORT: "4100"
LAB_IMAGE_REF: ${LAB_IMAGE_REF:-rootnode-academy:test}
EGRESS_PROXY_PORT: "3128"
DNS_RESOLVER: "127.0.0.11"
NO_PROXY_CIDRS: "10.42.0.0/16,10.43.0.0/16"
depends_on:
postgres:
condition: service_healthy
api:
build:
context: ..
dockerfile: platform/api/Dockerfile
network_mode: host # needs to reach the Provisioner at localhost:4100 (host-networked);
# port 4300 is directly the host's port under host networking, no
# `ports:` mapping needed (and compose rejects one with this mode).
environment:
# localhost:5433, not the "postgres" compose hostname — network_mode: host means this
# container is on the host's network namespace, not the compose bridge network, so
# compose's internal DNS doesn't resolve here (same reason Provisioner uses localhost too).
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform
PROVISIONER_URL: "http://localhost:4100"
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
# Phase 5B — the website/commerce integration boundary's shared secret (see
# docs/WEBSITE_ENROLLMENT_INTEGRATION.md). Optional in dev: unset means
# POST /api/integrations/enrollments refuses every request (fails closed), which is fine
# for anyone not exercising that boundary locally.
INTEGRATION_SHARED_SECRET: ${INTEGRATION_SHARED_SECRET:-}
# Phase 5C — activation/reset email links are built from this origin (see
# docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §7). The Gateway's own dev URL, since that's
# what a browser actually hits locally. EMAIL_PROVIDER is deliberately left unset here —
# it defaults to the dev provider (email/dev-provider.js), which never makes a real
# network call no matter what recipient it's given.
PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200}
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy}
# Empty/unset by default (fail-safe: dev provider, no real email — see email/provider.js).
# Set all four in platform/.env only for a deliberate one-off live-delivery test; unset
# again afterward so routine dev/test runs never risk a real send.
EMAIL_PROVIDER: ${EMAIL_PROVIDER:-}
GOOGLE_SERVICE_ACCOUNT_JSON: ${GOOGLE_SERVICE_ACCOUNT_JSON:-}
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-}
GOOGLE_DELEGATED_USER: ${GOOGLE_DELEGATED_USER:-}
PLATFORM_API_PORT: "4300"
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
# Empty by default — unset/empty means the exact same dev behavior as before this was
# parametrized (dev_reset_token echoed, dev-preview route reachable). Phase 5C's
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for
# real, then unsets it again — see that test's own comment.
COOKIE_SECURE: ${COOKIE_SECURE:-}
# 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:
condition: service_healthy
provisioner:
condition: service_started
gateway:
build: ./gateway
network_mode: host
environment:
GATEWAY_PORT: "4200"
PROVISIONER_URL: "http://localhost:4100"
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
PLATFORM_API_URL: "http://localhost:4300"
depends_on:
- provisioner
- api