Skip to main content

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-firstPortal-first
TriggerApp generates a signed JWT intent and opens the portalContributor generates an 8-char activation code on the portal and pastes it into the app
Bridge channelURL parameter ?link_intent=<JWT>One-time code stored as SHA-256 hash on the portal, sent in the redeem POST body
Edge function entry pointactivate-contributor-app-link (portal project)mint-activation-code (portal project) + redeem-activation-code (app project)
When to useContributor is already logged into the Playroll app and wants to linkContributor 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_intent and the legacy-compatible intent_id query 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 active after 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 contributors row via ensure_current_contributor if 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 issued code for the same contributor is revoked before the new one is minted (the partial unique index recorder_activation_codes_one_issued_per_contributor enforces at most one active code).

Server-side guards on redeem-activation-code:

  • Steam identity must exist on playroll_users for 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_id per hour.
  • Step 5 (write contributor_app_links as active) uses manual update-or-insert instead of UPSERT because the unique indexes on that table are partial (WHERE status = ...) and Postgres rejects ON CONFLICT against 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