Skip to main content

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, and intent_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_hash callbacks
  • PKCE code callbacks
  • legacy implicit access_token fragments
  • 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.