Architecture
The portal is a static browser app with a narrow Supabase Edge Function boundary for service-role identity-link activation. Contributor-facing CRUD still goes through the Supabase JavaScript client under row-level security policies.
React UI
-> usePortalFlow
-> portalApi
-> Supabase Auth
-> Supabase Postgres with RLS
-> activate-contributor-app-link Edge Function
Stack
React 19
TypeScript 6
Vite 8
Supabase JS 2
lucide-react
Cloudflare Pages
Build and deploy commands:
npm run build
npm run deploy:pages
Entry Points
src/App.tsx is deliberately thin. It reads the portal state from usePortalFlow and renders the correct workspace.
src/features/application/PublicApplicationPage.tsx
src/features/onboarding/OnboardingWorkspace.tsx
src/features/rewards/RewardsWorkspace.tsx
src/features/status/StatusView.tsx
src/features/system/EmailSentView.tsx
src/features/system/LoadingView.tsx
src/features/portal/usePortalFlow.ts owns:
- URL parameter parsing for
invite,ref, callback codes, token hashes, legacy fragments, payout confirmation flags,link_intent, andintent_id - Supabase session boot and auth state subscription
- latest application, onboarding, and receiving-method loading
- mode selection
- public application submission
- onboarding draft save and submit
- document read-only review routing
- payout receiving-method save and email confirmation
- app identity-link sync after portal authentication
src/lib/portalApi.ts is the browser persistence boundary. It only uses the publishable Supabase client and must not import server-only keys.
Authentication
The Supabase client is configured in src/lib/supabase.ts:
flowType = pkce
detectSessionInUrl = false
persistSession = true
autoRefreshToken = true
The app manually handles:
token_hashcallbacks- PKCE
codecallbacks - legacy implicit
access_tokenfragments - Supabase callback errors
After a successful callback, auth parameters are removed from the URL and verified=1 is written to the query string.
URL Parameters
Contributor entry and callback parameters:
invite=CODE
ref=CODE
link_intent=<signed-app-intent>
intent_id=<legacy-compatible signed-app-intent>
code=<pkce-code>
token_hash=<otp-token>
type=signup|magiclink|email
payout_confirm_token=<64-char hex single-use token>
payout_confirm_token is a single-use token bound to a specific payout_receiving_methods row (auth_user_id + status='pending_email_confirmation'). The portal consumes it via the confirm_payout_receiving_method_with_token SECURITY DEFINER RPC. The bare payout_confirm=1 flag is no longer accepted (audit 2026-05-16, EC-231 M1).
link_intent is created by the Playroll app Edge Function and consumed by the contributor portal. intent_id is accepted as an optional compatibility alias. The portal removes the link intent from the browser URL only after verification succeeds.
Local Catalogs
The portal uses local data files for Italian normalization:
public/data/italian-comuni.json
public/data/italian-universities.json
src/lib/italianCities.ts
src/lib/italianUniversities.ts
City normalization is mandatory. University normalization is best-effort, because contributors may enter a group or community instead of a formal institution.
Generated Documents
src/lib/generatedDocuments.ts owns the text documents and evidence generation.
Important constants:
DOCUMENT_PACKAGE_VERSION = playroll-pre-recorder-v1
DOCUMENT_VERSIONS.incarico = playroll-lettera-incarico-v1.1
DOCUMENT_VERSIONS.privacy = playroll-privacy-ack-v1
DOCUMENT_VERSIONS.tax = playroll-dichiarazione-fiscale-v1.1
DOCUMENT_VERSIONS.compensation = playroll-data-minimization-ack-v1
The generated evidence uses browser crypto.subtle.digest to hash the exact text displayed to the contributor.
Deployment Shape
The app is a Vite static app deployed to Cloudflare Pages:
Project name: playroll-contributor-portal
Production branch: main
Build command: npm run build
Build output directory: dist
Production host: https://playroll.extrinsiccognition.com
Required browser environment variables:
VITE_SUPABASE_URL
VITE_SUPABASE_PUBLISHABLE_KEY
Server-only values such as SUPABASE_SECRET_KEY, SUPABASE_SERVICE_ROLE_KEY, CONTRIBUTOR_LINK_INTENT_SECRET, and PLAYROLL_APP_SUPABASE_SERVICE_ROLE_KEY must stay in ignored local/server environments and must not be exposed through VITE_ variables.
Edge Function Boundary
The portal owns one service-role Edge Function today:
supabase/functions/activate-contributor-app-link/index.ts
It may:
- verify a signed app link intent
- check the portal activation gate
- create or promote
contributor_app_links - call
ensure_current_contributor() - upsert
playroll_supa_new.public.contributor_status_projection
It must not:
- execute payments
- bypass required document or receiving-method gates
- expose service-role keys to browser code
- copy legal/payment PII into the Playroll app Supabase project
- become a general admin backend
Anything that requires immutable storage, payment provider calls, audit signing, or admin review still belongs outside the public browser path.