Passa al contenuto principale

Versioning dei consensi

Come il launcher Playroll registra i consensi degli utenti, perché la regola di rotazione ha due varianti, e come la UNION cross-DB con il contributor portal resta sensata dopo che le identità vengono linkate.

Background: questo ship EC-23 (age gate) + EC-25 (persistenza DB). Launcher e contributor portal sono prodotti diversi con audience diverse (launcher consumer vs. programma contributor B2B), ma i due universi si sovrappongono almeno su un documento — la Privacy Policy. Il sistema deve mantenere una vista stabile di "questa persona ha accettato la versione corrente?" senza forzare prompt duplicati quando le due identità vengono linkate.

Pipeline

Ogni accettazione passa per consents-accept. Il renderer non persiste né back-fill mai una riga da solo.

Schema

Launcher (playroll_supa_new.playroll_user_consents)

create table public.playroll_user_consents (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
consent_type text not null check (consent_type in (
'age_confirmation', 'tos', 'privacy', 'eula', 'edc_gameplay'
)),
version text not null,
accepted_at timestamptz not null default now(),
country text, -- popolato solo per age_confirmation
metadata jsonb not null default '{}'::jsonb,
ip_address inet, -- catturato server-side dall'edge
user_agent text -- idem
);

Append-only. Nessuna policy UPDATE / DELETE — la tabella è un log legale di audit. RLS permette agli utenti di SELECT le proprie righe (utile per una futura UI "i tuoi dati"); solo il service role può INSERT (l'edge consents-accept).

Portal (playroll-contributors.consents + document_versions)

Tabella portal pre-esistente; questo lavoro ha aggiunto metadata di rotazione a document_versions:

ColonnaSignificato
current_versionLa versione su cui gli operatori vogliono gli utenti adesso.
previous_versionCosa era current subito prima dell'ultima rotazione. Usata insieme a rotation_kind per concedere il pass.
rotation_kindmaterial (modifica legalmente rilevante → tutti riaccettano) vs cosmetic (typo / formattazione → l'accettazione precedente resta valida). Default material.
consent_type_canonicalPonte verso il vocabolario launcher. Oggi privacy → privacy; null per documenti portal-only (compensation, incarico, isee_notice, package).

Vocabolario canonico dei consensi

Cinque valori consent_type, tutti forzati dal CHECK constraint del launcher:

consent_typeSource of truth per versionOverlap col portal
age_confirmationcostante launcher (playroll-age-confirmation-v1)
toscostante launcher (playroll-tos-v1)
privacyportal document_versions.current_versionsì — stessa stringa nei due DB
eulacostante launcher (playroll-eula-v1)
edc_gameplaycostante launcher (playroll-edc-gameplay-v1)

Le costanti launcher sono cablate nel sorgente delle edge function consents-accept e consents-status. Bumpare una di esse = rotazione di quel documento; rideploya l'edge e ogni client riprompta al prossimo boot.

privacy è oggi l'unico documento che overlap col contributor portal. L'edge consents-accept risolve la sua version fetchando document_versions.current_version where document_id = 'privacy' dal portal al request time (cached 60 s in una invocation warm). Così il record launcher porta la stessa stringa del record portal — rendendo la UNION cross-DB sotto ben definita.

La regola di rotazione

consents-status decide requires_action per consent_type con questa regola, applicata server-side:

se accepted_version == current_version -> ok
se rotation_kind == 'cosmetic'
e accepted_version == previous_version -> ok (typo fix conserva consenso precedente)
altrimenti -> requires_action: true

material è il default per nuove rotazioni — la scelta legalmente sicura. Gli operatori settano rotation_kind = 'cosmetic' esplicitamente nell'admin path del portal quando vogliono che gli utenti saltino il prompt.

Per i consensi launcher-managed (tos / eula / edc_gameplay / age_confirmation) non tracciamo previous_version o rotation_kind in tabella. Ogni bump di una costante launcher = rotazione material per definizione. Se mai serviranno rotazioni cosmetic launcher-side spostiamo le quattro costanti in una piccola tabella mirror.

UNION cross-DB (launcher + portal)

consents-status aggrega due log in una vista effettiva:

  1. Launcher acceptances da playroll_user_consents (questo DB).
  2. Portal acceptances da playroll-contributors.consents (DB diverso, fetchato via secret edge CONTRIBUTOR_PORTAL_URL + CONTRIBUTOR_SUPABASE_SERVICE_ROLE_KEY).

Il lookup portal avviene solo dopo che le identità sono linkate. Il ponte è contributor_app_links (tabella portal):

-- lookup contributor_id portal a partire dall'utente launcher
select contributor_id
from contributor_app_links
where app_user_id = <launcher auth.uid()>
and app_project = 'playroll'
and status = 'linked'
order by linked_at desc
limit 1;

Finché lo Steam handshake non completa il link, contributor_id risolve a null e il lato portal della UNION è vuoto — consents-status ritorna solo la vista launcher.

Dopo il link, per ogni consent_type l'edge sceglie l'acceptance più recente tra i due log, poi applica la regola di rotazione. Quindi se Marco ha accettato playroll-privacy-ack-v1 via launcher il 17 maggio e il portal aveva già playroll-privacy-ack-v2 registrata il 20 maggio, il launcher non gli chiederà di riaccettare il salto v1→v2 — la riga portal porta la versione più nuova con la stessa stringa canonica.

Superficie API

POST /functions/v1/consents-accept

// Authorization: Bearer <user JWT>
{
"acceptances": [
{ "consent_type": "age_confirmation", "accepted_at": "2026-05-17T15:09:01Z", "country": "IT" },
{ "consent_type": "tos", "accepted_at": "2026-05-17T15:09:30Z" },
{ "consent_type": "privacy", "accepted_at": "2026-05-17T15:09:30Z" },
{ "consent_type": "eula", "accepted_at": "2026-05-17T15:09:30Z" },
{ "consent_type": "edc_gameplay", "accepted_at": "2026-05-17T15:10:00Z" }
]
}

Risoluzione server-side:

  • version è settata dall'edge — il renderer non può registrare una versione obsoleta o arbitraria.
  • country per age_confirmation fallback a playroll_users.country quando il renderer la omette (l'overlay di riaccettazione lo fa). Quando nessuna sorgente ha un country, il request è rifiutato con un messaggio esplicito che indica la remediation.
  • ip_address + user_agent catturati dalla request, mai dal body.

Rate limit: 5 batch al minuto per utente.

Response: 201 { "inserted": <n>, "rows": [...] }.

GET /functions/v1/consents-status

// Authorization: Bearer <user JWT>
{
"user_id": "<auth.uid>",
"consents": [
{
"consent_type": "privacy",
"current_version": "playroll-privacy-ack-v1",
"accepted_version": "playroll-privacy-ack-v1",
"accepted_at": "2026-05-17T15:09:30Z",
"source": "launcher", // oppure "portal" oppure null
"requires_action": false
},
{ ... }
]
}

Una entry per ogni consent_type noto. Il renderer tratta qualsiasi entry con requires_action: true come bloccante — ConsentReacceptanceOverlay lista quelle pending in un modal non-dismissible.

Integrazione UI

SurfaceQuandoCosa fa
OnboardingFlowA fine onboarding (dopo Steam login)Costruisce il batch dei consensi dallo state del reducer, chiama submitOnboardingConsents fire-and-forget, poi chiude il gate dell'onboarding. I failure non sono bloccanti — il check al boot è la rete di sicurezza.
DashboardPost-auth, dopo che il gate dell'onboarding è chiusoPolla getConsentsStatus. Filtra requires_action: true e passa l'array a ConsentReacceptanceOverlay.
ConsentReacceptanceOverlayQuando requires_action.length > 0Modal non-dismissible che lista i consensi pending. L'utente deve spuntarli tutti. On submit chiama submitOnboardingConsents con un batch parziale (solo i consensi da riaccettare), poi ri-runna il check di status per chiudersi.

Leggere i dati

"Marco ha accettato la Privacy Policy corrente?"

-- lato launcher
with current as (
select current_version from portal.document_versions where document_id = 'privacy'
)
select 1
from public.playroll_user_consents c, current
where c.user_id = $marco
and c.consent_type = 'privacy'
and c.version = current.current_version
union all
-- lato portal, solo se linkato
select 1
from portal.consents c, current,
portal.contributor_app_links link
where link.app_user_id = $marco
and link.app_project = 'playroll'
and link.status = 'linked'
and c.contributor_id = link.contributor_id
and c.consent_type = 'privacy'
and c.version = current.current_version
limit 1;

(In pratica non si scrive a mano — si chiama consents-status.)

"Quanti utenti devono ancora accettarmi la v2 della Privacy Policy?"

with target as (select 'playroll-privacy-ack-v2'::text as v),
accepted as (
select user_id
from public.playroll_user_consents, target
where consent_type = 'privacy' and version = target.v
)
select count(*) as users_pending
from public.playroll_users u
where u.user_id not in (select user_id from accepted);

(Esclude il lato portal della UNION — un count più accurato sottrarrebbe anche gli utenti con acceptance portal di playroll-privacy-ack-v2 via contributor_app_links. Per ops a scala pilot l'approssimazione launcher-only basta.)

Note operative

  • Append-only non negoziabile. Mai UPDATE una riga esistente per bumpare la version. Ogni accettazione è una riga separata; la riga (user_id, consent_type) più recente vince. È così che il log legale di audit resta difendibile.
  • Rotazioni cosmetic richiedono azione operatore esplicita. Default è material. Settare rotation_kind = 'cosmetic' è una decisione deliberata "questa modifica non invalida il consenso precedente"; documenta perché nel runbook operatore quando lo fai.
  • La cache di 60s sui lookup portal (in consents-accept) significa che una rotazione di privacy sul portal può impiegare fino a ~1 minuto per riflettersi sul launcher. Accettabile per uso tipico; non è un hot path. Bumpare una version launcher-side è istantaneo dopo redeploy dell'edge.
  • Fallback country per age_confirmation legge playroll_users.country — coverage ~85% oggi. Il restante 15% riceverà il 400 esplicito col messaggio di remediation; popola la colonna o fai rifare l'AgeStep all'utente per fixare.

Riferimenti

  • Tabella launcher: playroll-cpp/supabase/migrations/20260518181200_playroll_user_consents.sql
  • Estensione portal: playroll-contributor-portal/supabase/migrations/20260518181300_document_versions_rotation_metadata.sql
  • Edge functions: playroll-cpp/supabase/functions/consents-accept/ e consents-status/
  • UI: playroll-ui/src/renderer/features/consents/ConsentReacceptanceOverlay.tsx, OnboardingFlow.completeOnce, Dashboard.onComplete
  • Linear: EC-23, EC-25
  • Correlato: Flusso del gate di onboarding