---
title: "PPPToo — Participant Portal v2"
slug: pppto-architecture
section: Portals
group: Applicant Portal
status: stable
description: "PPPToo (Alliance-Strategies/PPPToo) is the greenfield rebuild of the Participant/Applicant Portal, replacing ParticipantPortalPrototype (reference-only, pinned at d5052d7c4)."
last-updated: 2026-07-04
---

# PPPToo — Participant Portal v2

# PPPToo — Participant Portal v2

PPPToo (Alliance-Strategies/PPPToo) is the greenfield rebuild of the Participant/Applicant Portal, replacing ParticipantPortalPrototype (reference-only, pinned at d5052d7c4). One NestJS backend, one Vite/React frontend, built around two packaged libraries: @alliance-strategies/form-engine-portable (all dynamic forms) and @alliance-strategies/globus-sdk (Globus access, server-side only). The portal is stateless — all state lives in Globus and Keycloak.

See also: Direct vs Partner Entry , HubSpot Integration and Portal Overview & Boundaries .

## System Map (dev)

| Piece | Where | Notes |
| --- | --- | --- |
| SPA ppp2-participant-client | Cloud Run, skilled-script-469819-f2 (northamerica-northeast1) | React/Vite, mobile-first responsive shell (bottom nav below 768px), form-engine-portable for all workflow steps |
| BFF ppp2-participant-server | Cloud Run, same project | NestJS, /api/v1/*, stateless — no database, no migrations; globus-sdk lives here only |
| Identity | Keycloak realm applicant-portal | Password grant via public client front-react-applicant-portal-app; tokens carry aud back-node-applicant-portal-app and the globusApplicantId claim |
| Source of truth | Globus Hub (development.globus.allianceabroad.com) | Applicants, applications, steps, programs, documents, check-ins |
| Documents | Globus portal document routes (header-auth) | Globus-backed since BOP PRs #1310/#1312/#1313; interim GCS store remains as read fallback for legacy ppp2/ ids — see Document Pipeline below |
| Automation | n8n | HubSpot-to-Globus provisioning plus Globus event fan-out (SendGrid email, HubSpot upsert) |
| CI/CD | GitHub Actions, keyless WIF (ppp2-deployer SA) | development branch deploys to dev only; images tagged dev- , never bare latest |

## Core Invariants

| Invariant | Rule |
| --- | --- |
| Stateless portal | No Postgres, no migrations, no local persistence. All state lives in Globus (applicant/application/steps/forms) and Keycloak (identity). If a feature seems to need a portal table, the data belongs in Globus — extend the Globus API/SDK instead. |
| Identity link | Globus knows applicants by portal_user_id. Keycloak accounts carry it as the globusApplicantId attribute/claim; the BFF forwards it as X-Applicant-User-Id on every applicant-scoped Globus call and Globus enforces row ownership. Portal-auth failures are 401; upstream Globus denials map to 403 (a raw 401 would log the user out). |
| Globus messages verbatim | applicant_messages, applicant_copy, and rejection envelopes render unmodified (AP-1); only machine enums (e.g. on_track) are humanized for display. |
| Steps = step-progress joined with step-library | Globus step-progress rows are snake_case with no titles/configs; the BFF joins them with the step library (name, description, fieldsConfig) and serves render-ready steps. fieldsConfig is the form-engine stepConfig contract. |
| Form engine is render-only | The host imports form-engine-portable/style.css, mounts a react-hot-toast Toaster, and passes onUploadDocument — the engine never posts file bytes itself. As of v2.16 all workflow-reachable composites (skills/hobbies, showcase, photo badges, section locks) ship as real built-ins; injection slots remain for overrides. |

## HubSpot → PPPToo Journey (as-built)

The end-to-end path from a marketing lead in HubSpot to an active applicant working in PPPToo. This is the as-built dev flow, verified end-to-end on 2026-07-03 by provisioning a live applicant through it.

> **As-built is Globus-first, not portal-first**
>
> The Direct vs Partner Entry page describes the target portal-first model (HubSpot webhook → portal /api/v1/onboard → portal emits applicant.created to Globus). The flow actually running in dev is Globus-first: n8n calls the Globus provision endpoint directly and the portal never owns the applicant record — which is precisely what makes the stateless PPPToo design possible. Treat this page as the implementation ground truth until the entry-flows contract is revisited.

### Lead Capture & Provision

Applicants first exist as HubSpot contacts (marketing pages, ads, partner referral links). HubSpot owns pre-qualification data only: contact info, program interest, eligibility pre-screen, UTM attribution. When a contact reaches the Pre-Qualified lifecycle stage, the n8n workflow [HubSpot→Globus] Flow 1 calls POST /api/integrations/onboarding/provision on the Globus Hub (x-api-key auth; the key must carry the integrations.onboarding scope; deduplication is keyed on hubspot_contact_id). That single call creates the applicant, the application (program/brand/season locked at creation), and the portal invite token, returning invite_url, invite_token and portal_user_id.

### Event Fan-Out (n8n)

Provisioning triggers the Globus Event Outbox, and n8n reacts on two paths. The [Globus] Handler: Applicant Domain workflow routes to [Globus] Delivery: SendGrid Email, which sends the branded welcome email with the magic link (typically ~10 seconds after provision). In parallel, [Globus] Delivery: HubSpot Upsert reverse-syncs globus_applicant_id and portal_invite_url back onto the HubSpot contact — the provision call's hubspot_contact_id may even be synthetic, in which case the upsert creates the real contact by email.

### Magic-Link Onboarding

The applicant clicks the magic link and lands on the portal's /onboarding page. Because PPPToo is stateless, token validate-and-burn is delegated to Globus: the page first previews the identity non-burning (POST /api/portal/session + GET /api/applicants/:id), then create-or-claims the Keycloak account — setting the chosen password, with ownership enforced by matching the globusApplicantId attribute — and signs the applicant straight in. Keycloak account existence is the stateless replay guard; there is no portal-side used-token table.

### Steady State — Login & Runtime

Every subsequent login is a Keycloak password grant on the public client front-react-applicant-portal-app; the resulting JWT (audience back-node-applicant-portal-app, carrying the globusApplicantId claim) is held in sessionStorage and sent as a Bearer token on every BFF request. The BFF proxies to Globus with its API key plus the caller's X-Applicant-User-Id for row ownership: applications, steps, documents, programs, check-ins, context resolution (GET /me/context, composed from /api/portal/applications joined with /api/portal/programs) and branding (/api/portal/branding?slug=). Ongoing status changes flow back out through the same Event Outbox → n8n → HubSpot/SendGrid path for applicant notifications.

## Frontend Surface

The client implements the PRD §6 responsive app shell (header with needs-attention badge, desktop sidebar / tablet rail / mobile bottom nav and drawer) with shell/navConfig.ts as the single source of truth for conditional navigation. The dashboard renders the program journey as the snake design carried over from v1 — an S-shaped SVG trail per phase with completed/current/attention nodes deep-linking into the application workflow. Workflow steps render through form-engine-portable with IndexedDB drafts, compressed document uploads, an offline queue with Background Sync, and a status-driven Check-ins tab (season-scoped recurring check-ins via the API-key path).

## Document Pipeline (Globus-Backed)

Swapped to Globus 2026-07-04 (PPPToo commit 24b6de6): Globus shipped header-authenticated portal document routes (BOP PRs #1310/#1312/#1313, closing AP-7), and the BFF's documents controller now proxies list, required-documents catalogue, upload, versions and download straight to Globus with ownership enforced server-side. The interim GCS store (ppp2/applications//) survives read-only as a legacy fallback: pre-swap document ids decode to ppp2/ object paths (e.g. showcase gallery refs saved in step data) and are still served, with the ownership check mirrored via Globus. It takes no new writes.

## Known Gaps / Open Dependencies

| Gap | Owner / Status |
| --- | --- |
| Header-auth document routes + required-documents catalogue (AP-7) | SHIPPED 2026-07-04 (BOP PRs #1310/#1312/#1313; PPPToo swapped in commit 24b6de6) — legacy GCS ids still served read-only |
| workflowVersionId on the programs API | Globus-side; AP-1 pinning is enforce-when-available, creates currently warn application_created_unpinned |
| Application creation via the portal requires a Globus portal session | By design — primary applications arrive via HubSpot provisioning; staff users hitting create get the Globus 401 envelope verbatim |
| QA / prod promotion | Deliberately not built yet — dev-first |

## Ops Notes

Health: GET /api/v1/health keeps v1's 7-indicator shape (database/queue report up with stateless messages) plus a real globus indicator. Secrets live in the dev project (ppp2-keycloak-admin-client-secret, ppp2-globus-api-key). The BFF's CORS_ORIGINS must list both Cloud Run URL forms of the client (deterministic project-number form and hash form) — a user on the unlisted form gets a passing preflight but a blocked real request. Test-applicant flow: mint an invite via the provision endpoint and open the token against the ppp2 client /onboarding — the invite token is portal-agnostic.
