Playroll App Identity Link
The identity link connects the authenticated Playroll app user to the contributor identity activated by the contributor portal.
playroll_supa_new.playroll_users.user_id <-> playroll_contributor_portal.contributors.id
This link is the prerequisite for showing contributor verification, payout readiness, validated hours, rewards, and payment state in the Playroll app. It is not the reward ledger and it does not compute payable amounts.
Two flows
There are two ways the link can be established. They share the same database tables and projection contract, only the bootstrap is different.
| App-first | Portal-first | |
|---|---|---|
| Trigger | App generates a signed JWT intent and opens the portal | Contributor generates an 8-char activation code on the portal and pastes it into the app |
| Bridge channel | URL parameter ?link_intent=<JWT> | One-time code stored as SHA-256 hash on the portal, sent in the redeem POST body |
| Edge function entry point | activate-contributor-app-link (portal project) | mint-activation-code (portal project) + redeem-activation-code (app project) |
| When to use | Contributor is already logged into the Playroll app and wants to link | Contributor finished onboarding on the portal first (e.g. landing page, marketing) and only later installs the app |
Both flows end in the same outcome: contributor_app_links.status = 'active' on the portal and contributor_status_projection.link_status = 'verified' on the app.
Repositories
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)
Supabase Projects
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
The portal writes the source link in the portal project and upserts a minimal read model in the app project. The app reads only the projection.
Source Of Truth
Portal source table:
playroll_contributor_portal.public.contributor_app_links
Statuses:
pending_activation
active
revoked
Rules:
- one active link per contributor
- one active link per Playroll app user
- pending rows are captured app context only
- activation requires the portal gate
- active links sync a projection row to the Playroll app Supabase project
App read model:
playroll_supa_new.public.contributor_status_projection
The app rule is deliberately small:
projection row exists for current app user -> Verified
projection row missing -> Not verified
The projection must not expose legal documents, IBAN, full payout data, or other portal PII.
Activation Gate
The app cannot activate a contributor by itself. The app proves the Playroll app identity; the portal proves contributor readiness.
The portal activates only when required document acceptances are complete:
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
and a confirmed SEPA IBAN receiving method exists:
payout_receiving_methods.method_type = sepa_iban
payout_receiving_methods.status = confirmed
payout_receiving_methods.email_confirmation_status = confirmed
manual_review is not sufficient for automatic activation in this MVP.
App-first Flow
Important behavior:
- the signed intent is short-lived and carries no portal credentials
- the portal accepts both
link_intentand the legacy-compatibleintent_idquery parameter - the portal stores pending activation if the user is logged in but has not completed the gate
- the same pending row is promoted to
activeafter the gate succeeds
Portal-first Flow
If a contributor finishes onboarding on the portal before they ever open the Playroll app, the app-first JWT bridge cannot run (no Steam-authenticated session yet). The portal-first flow uses a short-lived activation code instead.
Code spec:
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
Server-side guards on mint-activation-code:
- Same gate check as
activate-contributor-app-link: required document acceptances + confirmed SEPA IBAN. - Materialises the
contributorsrow viaensure_current_contributorif missing. Portal-first users hit mint before any app-link, so the row may not exist yet. - Rate limit: max 5 codes minted per contributor per hour.
- Any other
issuedcode for the same contributor is revoked before the new one is minted (the partial unique indexrecorder_activation_codes_one_issued_per_contributorenforces at most one active code).
Server-side guards on redeem-activation-code:
- Steam identity must exist on
playroll_usersfor the calling auth user. - Atomic SHA-256 lookup; distinct error codes for
code_not_found,code_expired,already_redeemed,code_revoked. - In-memory rate limit: max 10 redeem attempts per
app_user_idper hour. - Step 5 (write
contributor_app_linksas active) uses manual update-or-insert instead of UPSERT because the unique indexes on that table are partial (WHERE status = ...) and Postgres rejectsON CONFLICTagainst partial indexes.
The system still does not use email matching as the bridge. Both flows require an authenticated app user and a portal-side proof of contributor readiness; portal-first just decouples the order in which they happen.
Observability
Edge Function responses include request_id for correlation. During e2e tests, use it to join app logs, portal browser logs, and Supabase function logs.
App-side function logs:
Contributor link intent created
Contributor link intent rejected: missing Steam identity
Failed to create contributor link intent
Portal function logs:
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
Portal browser logs (app-first):
[ContributorLink] sync started
[ContributorLink] sync failed
[ContributorLink] sync completed
Portal-first telemetry events (telemetry_events on portal project):
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, ...)
Playroll app telemetry events:
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
E2E Acceptance
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
Negative checks (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
Negative checks (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