Skip to content

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.

Owner: PlatformUpdated: 2026-06-17

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/auth

Request flow

mermaid
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 stream

Use case 1 -- quick login (dev iterating on portal UI)

Open the auth page in a browser:

https://pr-<N>.preview.onrampapps.com/portal/v2/auth

The 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-login

No email, no PIN. You land on /portal/v2/app as customer@example.com.

Headless / agent-browser equivalent

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

js
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).

  1. On the auth page, enter customer@example.com and click Send PIN.
  2. 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-pin response body includes a preview_pin field
    • A server-side log.info entry (readable in CloudWatch -- see Logs below)
  3. Enter the PIN in the normal field and click Verify. The full verify_pin code path runs -- same as prod.
  4. 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)

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

  1. Open the portal chat panel in the browser (bottom-right Aero button in /portal/v2/app).
  2. Send any prompt. Under the hood usePortalAgentChat.ts:
    • Calls GET /api/agent/runtime-info to fetch the portal runtime ARN from SSM (/onramp/preview-<N>/agent-runtimes/portal, written by the preview-deploy pipeline).
    • Calls POST /api/oauth/token to mint a customer JWT.
    • POSTs browser-direct to the AgentCore runtime with Authorization: Bearer <jwt>. The response streams back as SSE.

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:

bash
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

SymptomLikely causeCheck
/portal/v2/auth loads a blank pagePortal shell not uploaded to S3preview-deploy.yml S3 sync step
vendor_not_found on any portal API callIS_PR_PREVIEW flag off or resolver not wiredLambda env ENVIRONMENT_NAME, Flask Lambda logs
preview-login returns 404Flag off -- wrong environmentLambda ENVIRONMENT_NAME value
Agent chat sends but no SSE backRuntime ARN missing from SSM/onramp/preview-<N>/agent-runtimes/portal in SSM; confirm PR #9452 deploy
JWT mint fails (401)No logged-in sessionComplete login first (use case 1 or 2)

Security note

The preview-login bypass and PIN surfacing are both gated by IS_PR_PREVIEW:

python
# 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 enables IS_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.

Internal documentation — gated behind Cloudflare Access.