Portal in PR Previews
How to open the customer portal in a labeled PR preview, log in as the golden-seed customer, validate the email/PIN flow, and drive the portal Aero agent end-to-end.
What changed
The customer portal (app/ui-customer) is now routable in every labeled PR preview.
Previously, previews only served the org-admin SPA at /dashboard. The portal SPA shell (ui-customer.html) is now uploaded to S3 and a CloudFront behavior at /portal* serves it — mirroring the existing /dashboard* behavior. All /api/* requests continue to hit the per-PR Flask Lambda.
Because a preview's request.host is a Lambda Function URL (not a vendor-branded domain), normal host-based vendor resolution fails for the portal. A config flag (IS_PR_PREVIEW, derived from ENVIRONMENT_NAME=preview-<N>) engages a host-independent resolver that returns the single seeded golden tenant instead of vendor_not_found. No ProxyFix, no per-PR domain seeding.
Single-tenant by design. Every preview Neon DB is a copy-on-write clone of the golden parent seeded from devtools/db/seed_data.sql. That seed pins exactly one customer-portal tenant: customer@example.com (is_customer_user=true, portal-v2 feature enabled). There is no multi-tenant portal path in a preview.
The portal is reachable at:
https://pr-<N>.preview.onrampapps.com/portal/v2/authRequest flow
sequenceDiagram
participant B as Browser
participant CF as CloudFront
participant S3 as S3 shell bucket
participant FL as Flask Lambda
participant VR as Vendor resolver
participant AC as AgentCore runtime
B->>CF: GET /portal/v2/auth
CF->>S3: /portal* behavior, shell-router fn rewrites to pr-N/portal.html
S3-->>B: portal SPA shell (ui-customer.html)
B->>CF: GET /api/portal/vendor-data
CF->>FL: /api/* behavior, api-dispatch fn forwards to Lambda
FL->>VR: resolve_portal_vendor(request)
Note over VR: host lookup fails, IS_PR_PREVIEW=true, returns golden tenant
VR-->>FL: golden vendor + customer@example.com
FL-->>B: vendor branding + is_pr_preview: true
B->>CF: POST /api/portal/preview-login (quick path)
CF->>FL: Flask Lambda
FL-->>B: orac session cookie
B->>CF: POST /api/oauth/token
CF->>FL: mint customer JWT
FL-->>B: Bearer JWT
B->>AC: POST /runtimes/onramp_preview_N_portal/invocations (SSE)
AC-->>B: agent response streamUse case 1 -- quick login (dev iterating on portal UI)
Open the auth page in a browser:
https://pr-<N>.preview.onrampapps.com/portal/v2/authThe page renders a "Continue as preview customer" button (visible only when is_pr_preview is true). Click it. The browser calls:
POST /api/portal/preview-loginNo email, no PIN. You land on /portal/v2/app as customer@example.com.
Headless / agent-browser equivalent
# Establish the session cookie
curl -c cookies.txt -X POST \
https://pr-<N>.preview.onrampapps.com/api/portal/preview-login \
-H "Content-Type: application/json"Or in JavaScript (useful in Playwright / agent-browser scripts):
const res = await fetch(
'https://pr-<N>.preview.onrampapps.com/api/portal/preview-login',
{ method: 'POST', credentials: 'include' }
)
// Session cookie is now set; subsequent /api/* calls are authenticated./api/portal/preview-login is hard-gated. It returns 404 in every non-preview environment. The flag (IS_PR_PREVIEW) is derived purely from ENVIRONMENT_NAME=preview-<N> in config.py and is False in stage and prod. Defense-in-depth: the golden customer user only exists in seeded (preview/local) databases.
Use case 2 -- validate the email/PIN flow itself
Use this path when you need to confirm the real request-PIN and verify-PIN controllers work correctly (not just the quick bypass).
- On the auth page, enter
customer@example.comand click Send PIN. - Because previews have no email delivery, the PIN is surfaced in three places instead of being emailed:
- An on-screen banner on the Auth page:
Preview PIN: 123456 - The
request-pinresponse body includes apreview_pinfield - A server-side
log.infoentry (readable in CloudWatch -- see Logs below)
- An on-screen banner on the Auth page:
- Enter the PIN in the normal field and click Verify. The full
verify_pincode path runs -- same as prod. - You land on
/portal/v2/app.
Why the PIN is surfaced. Previews have no email infrastructure. PIN surfacing is the only way to complete the real verify flow without an inbox. It is strictly gated: IS_PR_PREVIEW is false in every non-preview environment, so PINs are never included in a response body outside of previews.
Reading the PIN from the response (automation)
PIN=$(curl -s -X POST \
https://pr-<N>.preview.onrampapps.com/api/customer/request-pin \
-H "Content-Type: application/json" \
-d '{"email":"customer@example.com"}' | jq -r '.preview_pin')
# Complete the real verify-pin flow
curl -c cookies.txt -X POST \
https://pr-<N>.preview.onrampapps.com/api/customer/verify-pin \
-H "Content-Type: application/json" \
-d "{\"email\":\"customer@example.com\",\"pin\":\"$PIN\"}"Driving the portal Aero agent
Once you are logged in (either path above):
- Open the portal chat panel in the browser (bottom-right Aero button in
/portal/v2/app). - Send any prompt. Under the hood
usePortalAgentChat.ts:- Calls
GET /api/agent/runtime-infoto fetch the portal runtime ARN from SSM (/onramp/preview-<N>/agent-runtimes/portal, written by the preview-deploy pipeline). - Calls
POST /api/oauth/tokento mint a customer JWT. - POSTs browser-direct to the AgentCore runtime with
Authorization: Bearer <jwt>. The response streams back as SSE.
- Calls
If the SSE stream returns immediately with no content, check logs (next section).
Where to look when it breaks
Portal runtime (AgentCore)
/aws/bedrock-agentcore/runtimes/onramp_preview_<N>_portal-*Open CloudWatch Logs in us-west-2. The log group suffix includes the full runtime ARN slug. Filter for ERROR or the trace ID from the browser network tab.
Flask Lambda
Each preview has its own Lambda function. Look up the function name in the AWS Console (onramp-preview-<N>) or via:
aws logs tail /aws/lambda/onramp-preview-<N> --follow --profile <your-profile>Common error: vendor_not_found in the Flask Lambda logs indicates the IS_PR_PREVIEW resolver is not engaging -- verify that ENVIRONMENT_NAME is set to preview-<N> in the Lambda environment (injected by the preview-deploy pipeline, depends on PR #9452 landing first).
Checklist
| Symptom | Likely cause | Check |
|---|---|---|
/portal/v2/auth loads a blank page | Portal shell not uploaded to S3 | preview-deploy.yml S3 sync step |
vendor_not_found on any portal API call | IS_PR_PREVIEW flag off or resolver not wired | Lambda env ENVIRONMENT_NAME, Flask Lambda logs |
preview-login returns 404 | Flag off -- wrong environment | Lambda ENVIRONMENT_NAME value |
| Agent chat sends but no SSE back | Runtime ARN missing from SSM | /onramp/preview-<N>/agent-runtimes/portal in SSM; confirm PR #9452 deploy |
| JWT mint fails (401) | No logged-in session | Complete login first (use case 1 or 2) |
Security note
The preview-login bypass and PIN surfacing are both gated by IS_PR_PREVIEW:
# config.py
IS_PR_PREVIEW = _ENV_NAME_LOWER.startswith("preview-") and _ENV_NAME_LOWER[len("preview-"):].isdigit()This value is False in every named environment except preview-<N>. Neither the bypass endpoint nor the preview_pin field in request-pin responses are reachable in stage or prod. The golden customer (customer@example.com) only exists in seeded databases (preview Neon clones and the local golden seed) -- the user record is absent in stage and prod.
Dependencies
This feature depends on PR #9452 (agent-preview-deployer-iam) being merged first. That PR:
- Injects
ENVIRONMENT_NAME=preview-<N>into the preview Lambda (the signal that enablesIS_PR_PREVIEW) - Adds an OIDC-discovery readiness gate before AgentCore runtimes are created
- Writes the portal runtime ARN to SSM (
/onramp/preview-<N>/agent-runtimes/portal)
Without #9452, IS_PR_PREVIEW will be False and the portal resolver, quick login, and PIN surfacing will all be inactive.