Identity Link con l'App Playroll
L'identity link collega l'utente authenticated dell'app Playroll all'identità del contributor attivata dal Contributor Portal.
playroll_supa_new.playroll_users.user_id <-> playroll_contributor_portal.contributors.id
Questo collegamento è il prerequisito per mostrare nell'app Playroll la verifica del contributor, lo stato di payout-ready, le ore validate, le ricompense e lo stato dei pagamenti. Non è il ledger delle ricompense e non calcola gli importi pagabili.
Due flussi
Ci sono due modi in cui il collegamento può essere stabilito. Condividono le stesse tabelle del database e lo stesso contratto di proiezione, cambia solo il bootstrap.
| App-first | Portal-first | |
|---|---|---|
| Trigger | L'app genera un JWT intent firmato e apre il portale | Il contributor genera sul portale un codice di attivazione di 8 caratteri e lo incolla nell'app |
| Canale di bridge | Parametro URL ?link_intent=<JWT> | Codice monouso conservato come hash SHA-256 sul portale, inviato nel body della POST di redeem |
| Edge function di ingresso | activate-contributor-app-link (progetto portale) | mint-activation-code (progetto portale) + redeem-activation-code (progetto app) |
| Quando usarlo | Il contributor è già loggato nell'app Playroll e vuole collegarsi | Il contributor ha completato prima l'onboarding sul portale (es. landing page, marketing) e installa l'app solo in un secondo momento |
Entrambi i flussi terminano nello stesso esito: contributor_app_links.status = 'active' sul portale e contributor_status_projection.link_status = 'verified' sull'app.
Repository
playroll-cpp
supabase/functions/create-contributor-link-intent/index.ts (app-first: app side)
supabase/functions/redeem-activation-code/index.ts (portal-first: app side)
supabase/migrations/20260511160000_add_contributor_status_projection.sql
playroll-contributor-portal
supabase/functions/activate-contributor-app-link/index.ts (app-first: portal side)
supabase/functions/mint-activation-code/index.ts (portal-first: portal side)
supabase/migrations/20260511161000_add_contributor_app_links.sql
supabase/migrations/20260515180000_add_recorder_activation_codes.sql
src/features/portal/usePortalFlow.ts
src/features/rewards/RecorderCodeBanner.tsx
src/lib/portalApi.ts
src/lib/portalUrl.ts
playroll-ui
src/main/ipc/handlers.ts
src/renderer/pages/Dashboard.tsx
src/renderer/features/launcher/Launcher.tsx
src/renderer/features/launcher/views/HomeView.tsx
src/renderer/features/launcher/views/SettingsView.tsx (portal-first: app side UI)
Progetti Supabase
Playroll app project:
name: playroll_supa_new
ref: vtyolotvuvlbvqbgbzde
url: https://vtyolotvuvlbvqbgbzde.supabase.co
Contributor portal project:
name: playroll_contributor_portal
ref: xlsjwlmsqonllvtyplml
url: https://xlsjwlmsqonllvtyplml.supabase.co
Il portale scrive il source del link nel proprio progetto e fa upsert di un read model minimale nel progetto dell'app. L'app legge soltanto la proiezione.
Source of Truth
Tabella source del portale:
playroll_contributor_portal.public.contributor_app_links
Stati:
pending_activation
active
revoked
Regole:
- un solo link attivo per contributor
- un solo link attivo per utente dell'app Playroll
- le righe pending catturano solo il contesto dell'app
- l'attivazione richiede il gate del portale
- i link attivi sincronizzano una riga di proiezione nel progetto Supabase dell'app Playroll
App read model:
playroll_supa_new.public.contributor_status_projection
La regola dell'app è volutamente semplice:
projection row exists for current app user -> Verified
projection row missing -> Not verified
La proiezione non deve esporre documenti legali, IBAN, dati di payout completi o altre PII del portale.
Gate di Attivazione
L'app non può attivare un contributor da sola. L'app dimostra l'identità dell'utente dell'app Playroll; il portale dimostra la readiness del contributor.
Il portale attiva soltanto quando sono complete le accettazioni dei documenti richiesti:
onboarding_submissions.accepted_documents.incarico = true
onboarding_submissions.accepted_documents.privacy = true
onboarding_submissions.accepted_documents.compensation = true
onboarding_submissions.accepted_documents.isee_notice = true
ed esiste un receiving-method SEPA IBAN confermato:
payout_receiving_methods.method_type = sepa_iban
payout_receiving_methods.status = confirmed
payout_receiving_methods.email_confirmation_status = confirmed
manual_review non è sufficiente per l'attivazione automatica in questo MVP.
Flusso App-first
Comportamento importante:
- l'intent firmato è a vita breve e non porta con sé credenziali del portale
- il portale accetta sia
link_intentsia il parametro di queryintent_idlegacy-compatibile - il portale memorizza l'attivazione come pending se l'utente è loggato ma non ha ancora completato il gate
- la stessa riga pending viene promossa a
activedopo che il gate viene superato
Flusso Portal-first
Se un contributor completa l'onboarding sul portale prima di aver mai aperto l'app Playroll, il bridge JWT app-first non può partire (non c'è ancora una sessione Steam-authenticated). Il flusso portal-first usa al suo posto un codice di attivazione a vita breve.
Spec del codice:
alphabet: A-H J K M N P-Z 2-9 (excludes 0, 1, I, L, O for legibility)
length: 8 chars
entropy: ~38 bits (single-use + 15 min TTL keeps brute force impractical)
storage: SHA-256 hex of normalised code; raw is shown once and never persisted
TTL: 15 minutes
single-use: status flips 'issued' -> 'redeemed' in one atomic UPDATE
Tutele lato server su mint-activation-code:
- Stesso controllo di gate di
activate-contributor-app-link: accettazioni dei documenti richieste + IBAN SEPA confermato. - Materializza la riga
contributorstramiteensure_current_contributorse manca. Gli utenti portal-first arrivano al mint prima di qualsiasi app-link, quindi la riga potrebbe non esistere ancora. - Rate limit: massimo 5 codici emessi per contributor all'ora.
- Qualsiasi altro codice
issuedper lo stesso contributor viene revocato prima che il nuovo venga emesso (l'indice unique parzialerecorder_activation_codes_one_issued_per_contributorgarantisce al massimo un codice attivo).
Tutele lato server su redeem-activation-code:
- L'identità Steam deve esistere su
playroll_usersper l'auth user chiamante. - Lookup SHA-256 atomico; codici di errore distinti per
code_not_found,code_expired,already_redeemed,code_revoked. - Rate limit in-memory: massimo 10 tentativi di redeem per
app_user_idall'ora. - Lo step 5 (scrittura di
contributor_app_linkscome active) usa un update-or-insert manuale invece di UPSERT, perché gli indici unique su quella tabella sono parziali (WHERE status = ...) e Postgres rifiutaON CONFLICTsu indici parziali.
Il sistema continua a non usare il matching via email come bridge. Entrambi i flussi richiedono un utente app authenticated e una prova lato portale della readiness del contributor; il portal-first si limita a disaccoppiare l'ordine in cui le due cose avvengono.
Observability
Le risposte delle Edge Function includono request_id per la correlazione. Durante i test e2e, usalo per unire i log dell'app, i log del browser del portale e i log delle function Supabase.
Log delle function lato app:
Contributor link intent created
Contributor link intent rejected: missing Steam identity
Failed to create contributor link intent
Log delle function del portale:
Contributor app link intent received
Contributor app link captured pending activation
Contributor app link still pending activation
Contributor app link verified
Contributor app link conflict
Failed to check contributor activation gate
Failed to ensure contributor
Failed to activate contributor app link
Log del browser del portale (app-first):
[ContributorLink] sync started
[ContributorLink] sync failed
[ContributorLink] sync completed
Eventi di telemetria portal-first (telemetry_events sul progetto portale):
recorder_code.requested portal browser, click on "Genera codice"
recorder_code.minted mint-activation-code success, attrs: contributor_id, expires_at
recorder_code.mint_failed mint refused (gate, rate, contributor_create, ...)
recorder_code.displayed portal UI rendered the raw code to the user
recorder_code.copy_clicked portal UI copy button hit
recorder_code.expired_in_ui countdown reached zero before redeem
recorder_code.redeemed redeem-activation-code success
recorder_code.redeem_failed redeem refused (not_found, expired, already, revoked, ...)
Eventi di telemetria dell'app Playroll:
contributor_link.intent_opened
contributor_link.intent_failed
contributor_link.status_verified
contributor_link.status_missing
contributor_link.status_fetch_failed
contributor_link.redeem_success
contributor_link.redeem_failed
Accettazione E2E
Happy path:
App user is authenticated
App creates link intent
Portal receives link_intent
Portal user completes required document acceptances
Portal user confirms SEPA IBAN
Portal activates link
Portal writes contributor_status_projection
App refreshes and shows Verified
Controlli negativi (app-first):
missing app auth -> create intent returns 401
missing Steam identity -> create intent returns 409
missing document acceptance -> portal returns pending_activation or activation_required
missing confirmed SEPA IBAN -> portal returns pending_activation or activation_required
manual_review receiving method -> not sufficient for activation
same app user linked to another contributor -> conflict
same contributor linked to another app user -> conflict
Happy path (portal-first):
Contributor signs up on portal, completes documents, confirms SEPA IBAN
Contributor clicks "Genera codice" in Rewards section
Portal shows 8-char code (e.g. ZJ929AEY) with 15-min countdown
Contributor opens Playroll app, signs in with Steam
Contributor pastes the code in Settings -> Account contributor -> Riscatta
contributor_app_links flips to active, contributor_status_projection upserts to verified
App refreshes within ~1s and shows VERIFIED badge
Controlli negativi (portal-first):
mint without completed gate -> 409 activation_gate_incomplete
mint hit 6th time within an hour -> 429 rate_limited
redeem unknown code -> 404 code_not_found
redeem code older than 15 min -> 410 code_expired
redeem code already used by another device -> 409 already_redeemed (with redeemed_app_user_id)
redeem code that mint revoked -> 409 code_revoked
redeem without Steam identity on playroll_users -> 409 steam_identity_required