---
title: "PPPToo — Participant Portal v2"
slug: pppto-architecture
section: Portals
group: Applicant Portal
status: stable
description: "This page documents **`Alliance-Strategies/PPPToo`** (`development` branch) — the live Participant Portal v2."
last-updated: 2026-10-07
---

# PPPToo — Participant Portal v2

This page documents **`Alliance-Strategies/PPPToo`** (`development` branch) — the live Participant Portal v2. PPPToo is a greenfield rebuild around `form-engine-portable` + `globus-sdk` and **replaces `ParticipantPortalPrototype`**, which is now an **archived, read-only repository**. Any Nexus text, link, or claim sourced from `ParticipantPortalPrototype` describes a retired codebase and must not be used to reason about the current portal. [1] [2]

> **Authority boundary**
>
> The Participant Portal provides the applicant experience. It presents program journeys, collects submissions, manages its user session, and adapts data for the participant. Final eligibility, compliance, workflow-governance, and sponsor-operational decisions remain the responsibility of the BackOffice Portal / Globus Hub. [3]

## Code Baseline and System Map

PPPToo is an npm-workspaces monorepo with three workspaces — `client`, `server` and `e2e`. Node is pinned to `>=20 <21`. [1]

| Component | Current code-backed description | Documentation implication |
| --- | --- | --- |
| Client (`client/`) | React 18 + Vite 6 SPA. Routing via `react-router-dom` 6. TanStack Query 5 for server state. All forms render through `@alliance-strategies/form-engine-portable` (pinned exactly, currently `2.47.1`). [4] | Describe a participant-facing SPA whose form rendering is owned by the shared engine package, not by portal-local form code. |
| Server (`server/`) | **Stateless NestJS 11 BFF.** No database, no ORM, no migrations, no local persistence — every read and write is proxied to the Globus Hub through `@alliance-strategies/globus-sdk`. [5] [6] | Do **not** attribute a database, Drizzle tooling, seeding, or scheduled tasks to PPPToo. That was `ParticipantPortalPrototype`. |
| API prefix | Global prefix `api/v1`, set in `server/src/main.ts`. Three paths are deliberately **excluded** from the prefix — `POST /api/national-window/get`, `POST /api/national-window/list`, and `POST /api/eligibility/calculate` — because the compiled `useEligibilityValidation` hook inside `form-engine-portable` calls those bare paths and the bundle is shared with BackOfficePrototype (which has no global prefix). [6] | Treat those three routes as a fixed compatibility contract with the form engine; they cannot be renamed portal-side. |
| Hub integration | `GlobusService` calls the Hub via the SDK. `portalTokenContextMiddleware` captures the applicant's Bearer request-scoped and forwards it to the Hub as `X-Portal-Access-Token` for identity binding. [6] | Portal-to-Hub identity is a forwarded applicant token, not a shared portal API key. |
| Object storage | `@google-cloud/storage` is a server dependency; documents stream through the BFF. [5] | The presence of the dependency does not fix a particular signed-URL or direct-object-access model — read the owning controller. |
| Secrets | `loadSecrets()` runs before bootstrap via `@google-cloud/secret-manager`. [6] | Configuration is Secret-Manager-backed at boot, not baked into the image. |
| Hub base URL | `GLOBUS_API_BASE_URL` is the origin of the Globus Hub. The SDK client reads it lazily and throws `GLOBUS_API_BASE_URL unset` on the first Hub call if it is missing; it also makes branding logo URLs absolute. In a step submit, a profile-picture data URL (jpeg, png or webp, up to 5 MB, else 413) is uploaded to the Globus account photo slot of the applicant before the step is saved and replaced by `BASE/api/applicants/SUB/photo` in the stored step data; an unreachable photo store returns 502. | Required in every environment; the profile picture never travels to the Hub as base64 step data. |
| Dependency scanning | Dependabot opens weekly (Monday) update PRs (Mondays for npm and GitHub Actions) for the npm workspaces, GitHub Actions and the `tools` Go module (slopgate tracking), configured in `.github/dependabot.yml`. The `dependency-audit` job in `.github/workflows/hygiene.yml` runs `npm audit --audit-level=high` as advisory only (`continue-on-error`). | A high or critical advisory shows in CI output without failing the build; enforcement is through the Dependabot PRs. |

## Participant Experience Surface

The client mounts the routes below in `client/src/App.tsx`. A mounted route indicates the client exposes the experience; it does not by itself make an administrative or policy decision authoritative. [7]

| Journey group | Mounted routes | Current purpose |
| --- | --- | --- |
| Public entry | `/sign-in`, `/forgot-password`, `/onboarding` | Password sign-in, password-reset request, and magic-link onboarding completion. [7] |
| Referee experience | `/reference/respond` | Public tokenized referee form (`…/reference/respond?token=…`). Third parties — no portal account. [7] |
| Authenticated shell | `/dashboard`, `/application`, `/documents`, `/check-ins`, `/profile`, `/settings`, `/travel`, `/notifications`, `/arrival-information` | Wrapped in `RequireAuth` → `PortalContextProvider` → `TermsGateModal` → `AppLayout`. [7] |
| Legal shell | `/privacy-policy`, `/disclaimer`, `/contact` | Brand-scoped legal content pages. [7] |
| Fallback | `*` | Redirects to `/dashboard`. [7] |

The authenticated shell is a **single flat set of nine pages**. There are no `/programs/*`, `/j2-*`, `/my-payments`, `/my-evaluations`, `/travel-requests/:id`, `/program-renewal`, `/program-extension`, `/program-completion`, `/apply/:programId`, or `/register/:programId` routes in PPPToo — those belonged to the archived `ParticipantPortalPrototype` route registry.

## Authentication and Session Boundary

PPPToo uses **Bearer tokens in `sessionStorage` — no cookies at all**. `client/src/lib/api.ts` is the single API client and the only holder of the token; it exposes `auth.getToken()` / `setToken()` / `clear()`, plus `auth.isExpired()` and `auth.isAuthenticated()`. The client also checks expiry proactively: `isExpired()` decodes the JWT payload (unverified, UX only) and treats a token whose `exp` is in the past — or one that is malformed or has no `exp` claim — as expired, and `isAuthenticated()` is true only when a token exists and is not expired. This is a client-side convenience, not a trust boundary; the server validates every request. A `401` from any path other than `/auth/login` is still treated as an expired session. [8]

Route protection is a small `RequireAuth` component in `client/src/App.tsx`: if `auth.isAuthenticated()` is false it clears any lingering expired token from `sessionStorage` and renders `Navigate to="/sign-in"`. The legal shell uses the same check to choose between the authenticated app layout and the public shell. There is **no** `SimpleAuthProvider`, no `/auth/start` or `/auth/callback` route, no return-path sanitisation, and no query-string token handoff. [7] [8]

Credentials are exchanged server-side against Keycloak by `AuthController` — `POST /api/v1/auth/login`, `POST /api/v1/auth/logout`, `POST /api/v1/auth/forgot-password`. Request JWTs are validated by a `passport-jwt` strategy against the `applicant-portal` realm. [9]

| Boundary | Current implementation evidence | Do not infer |
| --- | --- | --- |
| Browser-to-portal session | `sessionStorage` Bearer + `RequireAuth` redirect (an expired or malformed token is treated as signed out); CORS runs with `credentials: false` because Chrome blocks cross-site cookies. [6] [7] [8] | That a cookie session, refresh-token rotation, or an OIDC redirect flow exists in the client. |
| Portal-to-Hub | `portalTokenContextMiddleware` forwards the applicant Bearer as `X-Portal-Access-Token`; `globus-sdk` performs the call. [6] The forward is on by default. Setting `FORWARD_PORTAL_TOKEN=false` (exact string; the variable is optional) stops it: Hub calls then carry no `X-Portal-Access-Token`, and the passport calls guarded by `requirePassportPortalToken` answer 403 with a message starting `PORTAL_TOKEN_REQUIRED`. | That browser code holds a Hub API key or decides applicant authorization. |
| Realm integrity | `assertRealmInvariants()` runs at boot and **fails closed** if the live Keycloak realm has drifted from the Terraform-provisioned security baseline (token lifespan, brute-force settings). [6] | That realm configuration drift is detected at request time — it is a boot-time gate. |

## BFF Surface and Bootstrap

`server/src/main.ts` bootstraps with `bodyParser: false` and registers parsing manually, because a step submit can legitimately carry the Profile Picture as a base64 data URL (a 5&nbsp;MB PRD-limit photo is roughly 6.7&nbsp;MB of base64, and production submissions including the photo as base64 JSON have reached about 14.3&nbsp;MB) which the 100&nbsp;kb default rejected with a `413` before any controller ran. Only `/api/v1/applications` gets the headroom — `20mb`; every other route keeps the tight `100kb` default. [6]

Other bootstrap facts, all in `main.ts`: `compression()` is registered before body parsing so it wraps every response including streamed downloads; `helmet()` runs with `crossOriginResourcePolicy: same-site` so the separate client Cloud Run origin can consume responses; `trust proxy` is set to `1` so the rate limiter keys on the real client IP behind Cloud Run rather than the load balancer; CORS origins come from `CORS_ORIGINS`; the listener defaults to port `3001`. [6]

**Health endpoint.** `GET /api/v1/health` is exempt from rate limiting and answers 503 with the report body when any indicator is down, otherwise 200. The `memory-heap` and `memory-rss` indicators report down once `heapUsed` or `rss` reaches the limit in megabytes set by `HEALTH_HEAP_LIMIT_MB` (default 512) and `HEALTH_RSS_LIMIT_MB` (default 1024); both variables are optional. `oidc` fetches the realm's `.well-known/openid-configuration` from `KEYCLOAK_ISSUER` (down if unset or unreachable), `globus` calls the Hub health check with a 5-second timeout, and `disk`, `database`, `queue` and `gcp-storage` always report up because PPPToo is stateless.

Controllers registered under the `api/v1` prefix cover: `auth`, `me`, `applications`, `applications/:id/documents`, `applications/:id/skill-certifications`, `applications/:id/terms`, `applications/:id/swt`, `applications/:id/criminal-background`, `applications/:applicationId/reference-templates`, `programs`, `documents`, `document-packages`, `document-packages/artifacts`, `references`, `checkins`, `notifications`, `onboarding`, `agreements`, `brands`, `legal`, `analytics`, `ai/text`, `ai/documents`, `arrival-information`, `embassies`, `internal/applicants`, and `health`. [5]

## Forms, Documents, and Participant Data

All form rendering is delegated to `form-engine-portable`, pinned to an exact version rather than a range so the portal and BackOfficePrototype cannot silently diverge. Documents stream through the BFF rather than being served from portal-local storage. [4] [5]

This page intentionally does **not** promise that every document flow uses a specific signed-URL pattern, direct object-store access model, file-size limit, or upstream error transformation beyond the `/api/v1/applications` body-limit exception documented above. Those fine-grained assertions are valid only when traced to the exact current controller.

## Relationship to Other Nexus Pages

| Page | Relationship to this page |
| --- | --- |
| [Portal Overview](/docs/portal-boundaries) | Cross-portal ownership map and the BackOffice/Host/Participant implementation summary. |
| [Globus Portal API Reference](/docs/globus-portal-api) | Hub-side portal/session/integration/partner API authority; use for endpoint and credential contract questions. |
| [Form Engine](/docs/form-engine-portable) | Shared form-engine package guidance. |
| [References Flow](/docs/references-flow) | Reference-specific flow and endpoint detail. |
| [Endpoint Reference](/docs/applicant-portal-endpoints) | Current PPPToo endpoint and route-boundary summary. |

## References

[1]: https://github.com/Alliance-Strategies/PPPToo/blob/development/package.json "PPPToo root workspace manifest"
[2]: https://github.com/Alliance-Strategies/PPPToo "Alliance-Strategies/PPPToo — Participant Portal v2 (replaces ParticipantPortalPrototype)"
[3]: /docs/globus-portal-api "Globus Portal API Reference"
[4]: https://github.com/Alliance-Strategies/PPPToo/blob/development/client/package.json "PPPToo client manifest"
[5]: https://github.com/Alliance-Strategies/PPPToo/blob/development/server/package.json "PPPToo BFF manifest"
[6]: https://github.com/Alliance-Strategies/PPPToo/blob/development/server/src/main.ts "PPPToo BFF bootstrap"
[7]: https://github.com/Alliance-Strategies/PPPToo/blob/development/client/src/App.tsx "PPPToo client route registry and RequireAuth guard"
[8]: https://github.com/Alliance-Strategies/PPPToo/blob/development/client/src/lib/api.ts "PPPToo API client and token store"
[9]: https://github.com/Alliance-Strategies/PPPToo/blob/development/server/src/auth/auth.controller.ts "PPPToo auth controller"
