Phase 2 Deploy — Stubs-Only Path¶
Document:
forms-phase2-stubs-deploy.md· v1.0 · 2026-04-24 · GPUS-IT Scope: Get the Phase 2 scaffold deployed with stub responses. No data layer wire-up. Done-when: Sign in atforms.greenpeace.us→ land on/forms→/api/mereturns your real email + roles →/api/formsreturns{"forms": []}. No browser console errors.
Why stubs first¶
Two distinct things in this scaffold: - Auth + UI plumbing — Okta PKCE, CSP, CORS, JWKS, groups claim, domain mapping, React Router, ID-token validation - Data layer integration — SQL against Phase 1 tables, KMS envelope decrypt, GCS attachment writes, ClamAV, HappyFox
Different failure modes, different debug paths. Deploying them together means a breakage at sign-in could be any of fifteen things. Deploying auth-and-UI first means a breakage is one of three or four — every problem stays small.
End-state of this deploy: real Okta auth flowing end-to-end against real backend code, with routes_phase2.py returning empty stubs. The next session wires the stubs to Phase 1 data without touching auth, CSP, or domain mapping.
Pre-flight (do these BEFORE step 1)¶
1. Verify the Okta groups claim is being issued
You set this earlier today. Confirm it's actually in tokens:
- Sign out of any portal, sign back in
- Browser devtools → Application → Local Storage → pick the portal origin
- Find
okta-token-storage, expandidToken.claims - Confirm
groupsarray is present and containsapps-us-internal_portal_users
If groups is missing or empty, stop. The backend will 403 every user except rajesh.chhetry@greenpeace.us. Re-check the Group Claims filter in the OIDC app.
2. Add the production redirect URI to the Okta app
Apps → "GPUS Internal Portals" → General → Login → Sign-in redirect URIs
Confirm both are present:
- http://localhost:5173/login/callback (already there for dev)
- https://forms.greenpeace.us/login/callback ← add this if missing
Save.
3. Confirm the existing gpus-forms-backend Cloud Run service is healthy
Expect: ok. If not, stop and fix Phase 1 first.
Step 1 — Land the scaffold in the repo¶
On your Mac:
Expected new tree:
forms-frontend/ (entire new directory)
forms-backend/auth.py (new)
forms-backend/routes_phase2.py (new)
forms-backend/requirements-phase2.txt (temporary, deleted in step 2)
Don't commit yet.
Step 2 — Merge backend deps and wire the blueprint¶
Two small edits to existing files:
forms-backend/requirements.txt — append:
Then delete forms-backend/requirements-phase2.txt.
forms-backend/app.py — register the new blueprint. Add near the top (after the existing app = Flask(__name__)):
If app.py has any existing routes that overlap with the Phase 2 blueprint (/health, /api/me, /api/config, /api/forms, /api/pulldowns, /api/submissions, /api/admin/*), comment them out. The blueprint owns the /api/* surface as of contract v1.3.
If you're unsure what's in app.py and want me to check before commit, paste it in chat — but most likely the only real overlap is /health, which is safe to keep on either side (idempotent).
Step 3 — Set backend env vars on Cloud Run¶
gcloud run services update gpus-forms-backend \
--region=us-central1 \
--project=gpus-infra \
--update-env-vars=\
OKTA_ISSUER=https://greenpeaceeu.okta.com,\
OKTA_CLIENT_ID=0oavvg1y33wTWFsmP417,\
FORMS_ADMIN_EMAILS=rajesh.chhetry@greenpeace.us,\
FORMS_SUBMITTER_GROUP=apps-us-internal_portal_users,\
MAX_UPLOAD_BYTES=26214400,\
ALLOWED_MIME_TYPES=application/pdf\,image/png\,image/jpeg\,text/csv,\
FORMS_HAPPYFOX_ENABLED=false
Note the escaped commas (\,) inside ALLOWED_MIME_TYPES — gcloud parses commas as env-var separators otherwise.
Verify:
gcloud run services describe gpus-forms-backend \
--region=us-central1 --project=gpus-infra \
--format='value(spec.template.spec.containers[0].env)'
Step 4 — Commit + push backend¶
cd ~/gpus-infra-portals
git add forms-backend/auth.py forms-backend/routes_phase2.py forms-backend/requirements.txt forms-backend/app.py
git commit -m "Phase 2: Okta JWT validation + endpoint stubs
- auth.py: ID token validation against org JWKS, group->role mapping
- routes_phase2.py: contract v1.3 endpoint stubs (data layer wire-up next)
- requirements: add python-jose + requests
- app.py: register phase2 blueprint"
git push origin main
The existing trigger c06bf9fb auto-deploys (~2.5 min).
Step 5 — Smoke-test the backend BEFORE touching the frontend¶
This is critical — if backend auth doesn't work, frontend deploy is wasted.
# 1. Unauthed call should 401
curl -i https://gpus-forms-backend-1056766133984.us-central1.run.app/api/me
# Expect: HTTP/2 401, body: {"error":"invalid_token", ...}
# 2. Grab a real ID token from your browser
# DevTools → Application → Local Storage → okta-token-storage → idToken.idToken
# Copy that string (long, three dot-separated chunks)
TOKEN="eyJraWQ..." # paste here
# 3. Authed call should 200 with your identity
curl -i -H "Authorization: Bearer $TOKEN" \
https://gpus-forms-backend-1056766133984.us-central1.run.app/api/me
# Expect: HTTP/2 200
# Body: {"email":"rajesh.chhetry@greenpeace.us","name":"...","sub":"...",
# "roles":["submitter","admin"],
# "okta_groups":["apps-us-internal_portal_users", ...]}
Triage if step 5.3 fails:
- 401 invalid_token with detail Token expired → grab a fresh token (Okta ID tokens are 1h)
- 401 invalid_token with detail Signature verification failed → JWKS URL or kid lookup is broken; check Cloud Run logs
- 401 invalid_token with detail Claim mismatch: aud → the OKTA_CLIENT_ID env var doesn't match the actual aud in the token; decode the token at jwt.io (DON'T paste it — just look at the second segment in the URL bar) and compare
- 403 forbidden with detail mentioning not assigned to apps-us-internal_portal_users → the groups claim isn't reaching the backend; re-verify the Okta groups claim filter
Do not proceed to step 6 until step 5.3 returns 200 with the expected payload.
Step 6 — Provision the frontend service account¶
gcloud iam service-accounts create gpus-forms-frontend \
--display-name="Cloud Run runtime SA for gpus-forms-frontend" \
--project=gpus-infra
No role bindings needed. The frontend is static + reverse proxy only.
Step 7 — Create the Cloud Build trigger for the frontend¶
gcloud builds triggers create cloud-source-repositories \
--repo=gpus-infra-portals \
--branch-pattern=^main$ \
--build-config=forms-frontend/cloudbuild.yaml \
--included-files=forms-frontend/** \
--name=gpus-forms-frontend-trigger \
--service-account=projects/gpus-infra/serviceAccounts/gpus-cloudbuild@gpus-infra.iam.gserviceaccount.com \
--region=us-central1 \
--project=gpus-infra
Verify in Console (the CLI quirk you've hit before — gcloud builds triggers list returns empty due to regional CLI behavior):
- GCP Console → Cloud Build → Triggers → switch region to us-central1 → confirm gpus-forms-frontend-trigger exists.
Step 8 — Commit + push the frontend¶
cd ~/gpus-infra-portals
git add forms-frontend/
git commit -m "Phase 2: forms-frontend SPA scaffold
- Vite + React + TS, Okta OIDC PKCE
- Routes: Landing, FormPicker, FormFill (3-phase submit), Submitted
- Typed contract mirroring forms-api-contract.md v1.3
- Dockerfile: multi-stage build (Vite -> nginx)
- nginx.conf: SPA fallback, /api/ reverse proxy, security headers + CSP
- cloudbuild.yaml: build, push, deploy to gpus-forms-frontend"
git push origin main
Trigger fires (~3 min). Watch the build log; first build will pull node:20-alpine and nginx:1.27-alpine from scratch (~30 s extra).
Verify the new service exists:
gcloud run services describe gpus-forms-frontend \
--region=us-central1 --project=gpus-infra \
--format='value(status.url)'
Expect a URL like https://gpus-forms-frontend-1056766133984.us-central1.run.app.
Hit it directly:
curl -I https://gpus-forms-frontend-1056766133984.us-central1.run.app/
# Expect: HTTP/2 200, content-type: text/html
Step 9 — Switch the forms.greenpeace.us domain mapping¶
Currently the domain points at gpus-forms-backend. Switch to gpus-forms-frontend (the SPA reverse-proxies /api/* to the backend internally — both stay live, the user sees one origin).
# Remove old mapping (backend)
gcloud beta run domain-mappings delete \
--domain=forms.greenpeace.us \
--region=us-central1 \
--project=gpus-infra
# Create new mapping (frontend)
gcloud beta run domain-mappings create \
--service=gpus-forms-frontend \
--domain=forms.greenpeace.us \
--region=us-central1 \
--project=gpus-infra
DNS in Hover already CNAMEs to ghs.googlehosted.com — no DNS change needed. Cert reissues in 20–40 min based on the 2026-04-21 forms.greenpeace.us cutover precedent.
While the cert reissues, the URL will return TLS errors. That's expected. Watch:
When you see HTTP/2 200 the cert is live.
Step 10 — End-to-end smoke test¶
In a fresh browser window (or incognito to bypass any cached state):
- Visit
https://forms.greenpeace.us→ see the Landing page with "Sign in with Okta" - Click sign in → Okta redirect → sign in → redirected back
- Land on
/forms→ see header with your email +[admin]pill on the right - Page body says "Pick a form · 0 available" — that's correct (stubs return empty)
- DevTools → Network → confirm:
GET /api/me→ 200 with{email, name, sub, roles: ["submitter","admin"], okta_groups: [...]}GET /api/forms→ 200 with{"forms": []}- No CORS errors
- No CSP violations in Console
- Click sign out → land back on Landing
If all six pass: Phase 2 stubs are deployed. Memory + tracker can mark "Phase 2 React + Okta SPA" as complete.
Likely issues + fixes¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Okta redirect loop after sign-in | Production redirect URI missing | Pre-flight #2 — add https://forms.greenpeace.us/login/callback |
| White screen, console: CSP blocked Okta script | CSP script-src 'self' doesn't allow Okta |
Already handled — connect-src allows https://greenpeaceeu.okta.com. If still blocked, check the actual blocked URL in console and add to nginx CSP |
/api/me returns 502 from frontend but 200 directly to backend |
nginx reverse proxy misconfigured | Check proxy_pass URL in nginx.conf matches the backend's actual Cloud Run URL |
/api/me returns 401 with valid-looking token |
Backend can't reach Okta JWKS | Frontend SA has no internet egress restrictions; backend SA might. Check Cloud Run logs for requests.exceptions.ConnectionError |
TLS error on forms.greenpeace.us after step 9 |
Cert hasn't reissued yet | Wait. Up to 40 min per the 2026-04-21 precedent |
| Forms page shows skeleton then "Could not load forms" | Backend /api/forms errored, not returning the empty stub |
Check Cloud Run logs for the backend; likely auth.py import failure or a missing env var |
Rollback¶
If anything goes wrong in steps 4–9, no user data is at risk (no DB writes from this scaffold).
- Revert backend:
git revert <commit>+ push, orgcloud run services update-traffic gpus-forms-backend --to-revisions=<previous>=100 --region=us-central1 - Re-point domain to backend:
gcloud beta run domain-mappings delete --domain=forms.greenpeace.us ...then re-create with--service=gpus-forms-backend
The Phase 1 backend continues to function as before — it doesn't depend on the new blueprint.
What's left after this deploy¶
- [ ] Wire
routes_phase2.pyto Phase 1 data layer (next session) - [ ] Create one or two real forms (YAML in repo) so the picker isn't empty
- [ ] Verify a real submission round-trips: form fill → draft row → email to dept group via Postfix on MAPLE
- [ ] SQLi tabletop + blue/red drill (gates Phase 3)
- [ ] Phase 3 HappyFox API integration
The path stays sequential. No more big-bang deploys.