Skip to content

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 at forms.greenpeace.us → land on /forms/api/me returns your real email + roles → /api/forms returns {"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, expand idToken.claims
  • Confirm groups array is present and contains apps-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/callbackadd this if missing

Save.

3. Confirm the existing gpus-forms-backend Cloud Run service is healthy

curl -s https://gpus-forms-backend-1056766133984.us-central1.run.app/health

Expect: ok. If not, stop and fix Phase 1 first.


Step 1 — Land the scaffold in the repo

On your Mac:

cd ~/gpus-infra-portals
tar xzf ~/Downloads/forms-phase2-scaffold.tar.gz
git status

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:

python-jose[cryptography]==3.3.0
requests==2.32.3

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__)):

from routes_phase2 import bp as phase2_bp
app.register_blueprint(phase2_bp)

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:

watch -n 30 'curl -sI https://forms.greenpeace.us 2>&1 | head -3'

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):

  1. Visit https://forms.greenpeace.us → see the Landing page with "Sign in with Okta"
  2. Click sign in → Okta redirect → sign in → redirected back
  3. Land on /forms → see header with your email + [admin] pill on the right
  4. Page body says "Pick a form · 0 available" — that's correct (stubs return empty)
  5. DevTools → Network → confirm:
  6. GET /api/me → 200 with {email, name, sub, roles: ["submitter","admin"], okta_groups: [...]}
  7. GET /api/forms → 200 with {"forms": []}
  8. No CORS errors
  9. No CSP violations in Console
  10. 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, or gcloud 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.py to 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.