discovery-media-player 0.1.126 → 0.1.127

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/RETENTION.md CHANGED
@@ -1,145 +1,144 @@
1
- # Rétention des données
2
-
3
- Ce document est le **périmètre déclaré** de la rétention : chaque colonne du schéma dont la forme
4
- peut porter une donnée personnelle y a une politique — *purgée après N* ou *conservée parce que*.
5
- Une garde de forge énumère les colonnes du schéma **vivant** (`information_schema`, jamais notre
6
- mémoire du fichier) et refuse toute colonne à forme personnelle absente d'ici : une donnée sans
7
- politique écrite ne peut pas entrer dans le schéma sans rougir.
8
-
9
- Le contrat de vérification a deux moitiés, volontairement **indépendantes** (aucun code partagé —
10
- ni fonction de périmètre, ni filtre) :
11
-
12
- 1. la **purge** (`server/retention.js`) déclare ce qu'elle a effacé, compte par compte ;
13
- 2. le **recensement** (`supabase/recensement-retention.sql`, SQL nu) compte ce qui reste dans le
14
- périmètre revendiqué. Les deux nombres doivent se contredire si l'un ment.
15
-
16
- > ⚠️ **Fenêtres proposées, à valider par l'exploitant.** Les durées ci-dessous sont des défauts
17
- > raisonnés (journaux analytiques : 13 mois, comparaison année sur année ; archives de
18
- > présentation : 12 mois après la fin). Un hôte les ajuste via `config.retention` — **entiers de
19
- > mois dans [1, 120] uniquement**. Toute valeur négative, nulle, non entière, `NaN`, `Infinity`
20
- > ou chaîne fait ÉCHOUER la purge avant le premier `DELETE`, en nommant la clé fautive : une
21
- > faute de configuration ne supprime jamais rien. Les bornes sont calculées en UTC, rabattues au
22
- > dernier jour du mois cible (« 31 mars − 1 mois » = 28 février, pas le 3 mars).
1
+ # Data retention
2
+
3
+ This document is the **declared scope** of retention: every column of the schema whose *shape* can
4
+ carry personal data has a policy here — *purged after N* or *kept because*. A CI guard enumerates
5
+ the columns of the **live** schema (`information_schema`, never our memory of the file) and refuses
6
+ any personal-shaped column missing from this page: data without a written policy cannot enter the
7
+ schema without turning the build red.
8
+
9
+ The verification contract has two halves, deliberately **independent** (no shared code — no scope
10
+ function, no filter):
11
+
12
+ 1. the **purge** (`server/retention.js`) declares what it erased, count by count;
13
+ 2. the **census** (`supabase/recensement-retention.sql`, plain SQL) counts what remains inside the
14
+ claimed scope. The two numbers must contradict each other if either one lies.
15
+
16
+ > ⚠️ **Proposed windows, to be confirmed by the operator.** The durations below are reasoned
17
+ > defaults (analytics logs: 13 months, for year-on-year comparison; presentation archives: 12
18
+ > months after the end). A host adjusts them through `config.retention` — **whole months in
19
+ > [1, 120] only**. Any negative, zero, non-integer, `NaN`, `Infinity` or string value makes the
20
+ > purge FAIL before the first `DELETE`, naming the offending key: a configuration mistake never
21
+ > deletes anything. Bounds are computed in UTC and clamped to the last day of the target month
22
+ > ("31 March − 1 month" = 28 February, not 3 March).
23
23
  >
24
- > ⚠️ **Le balayage automatique est OPT-IN STRICT** : il ne tourne que si l'hôte écrit
25
- > `config.retention.balayage: true`. Un hôte qui consomme le contexte autonome tel quel hérite de
26
- > toutes ses capacités par défaut — « rien à brancher parce que rien n'a été débranché » — et une
27
- > suppression est une décision métier : elle n'agit que là où un exploitant l'a écrite. L'action
28
- > `retention.run` (hôte de confiance ou admin) reste disponible sans opt-in : l'appeler EST la
29
- > décision.
24
+ > ⚠️ **The automatic sweep is STRICTLY OPT-IN**: it runs only if the host writes
25
+ > `config.retention.balayage: true`. A host consuming the standalone context as-is inherits all of
26
+ > its capabilities by default — "nothing to wire up because nothing was unwired" — and a deletion is
27
+ > a business decision: it acts only where an operator has written it down. The `retention.run`
28
+ > action (trusted host or admin) stays available without opt-in: calling it IS the decision.
30
29
 
31
- ## Journaux de lecture (population externe)
30
+ ## Reading logs (external audience)
32
31
 
33
- Finalité : statistiques de lecture d'un document envoyé. **Purge : 13 mois** après l'événement.
32
+ Purpose: reading statistics for a document that was sent out. **Purge: 13 months** after the event.
34
33
 
35
- | colonne | contenu | sort |
34
+ | column | contents | fate |
36
35
  |---|---|---|
37
- | `commercial_doc_views.recipient_email` | à qui la lecture est attribuée | purgée avec la ligne, 13 mois après `at` |
38
- | `commercial_doc_views.session_id` | corrèle les vues d'une session | idem |
39
- | `commercial_doc_views.ua` | navigateur (User-Agent brut) | idem |
40
- | `commercial_doc_sessions.recipient_email` | attribution de la session | purgée avec la ligne, 13 mois après `last_at` |
41
- | `commercial_doc_sessions.session_id` | identifiant de session | idem |
42
- | `commercial_doc_sessions.ip` | **adresse IP en clair** | idem — c'est la donnée la plus sensible du schéma |
43
- | `commercial_doc_sessions.ua` | User-Agent brut | idem |
44
- | `commercial_doc_sessions.num_pages` / `commercial_doc_sessions.pages_time` | comportement de lecture page par page | idem |
36
+ | `commercial_doc_views.recipient_email` | who the read is attributed to | purged with the row, 13 months after `at` |
37
+ | `commercial_doc_views.session_id` | correlates the views of one session | same |
38
+ | `commercial_doc_views.ua` | browser (raw User-Agent) | same |
39
+ | `commercial_doc_sessions.recipient_email` | session attribution | purged with the row, 13 months after `last_at` |
40
+ | `commercial_doc_sessions.session_id` | session identifier | same |
41
+ | `commercial_doc_sessions.ip` | **IP address in the clear** | same — the most sensitive datum in the schema |
42
+ | `commercial_doc_sessions.ua` | raw User-Agent | same |
43
+ | `commercial_doc_sessions.num_pages` / `commercial_doc_sessions.pages_time` | page-by-page reading behaviour | same |
45
44
 
46
- ## Journaux de lecture (équipe interne)
45
+ ## Reading logs (internal team)
47
46
 
48
- Même finalité, population interne. **Purge : 13 mois** après `last_at`.
47
+ Same purpose, internal audience. **Purge: 13 months** after `last_at`.
49
48
 
50
- | colonne | sort |
49
+ | column | fate |
51
50
  |---|---|
52
- | `commercial_doc_internal_sessions.user_email` / `commercial_doc_internal_sessions.user_name` | purgées avec la ligne |
53
- | `commercial_doc_internal_sessions.session_id` | idem |
54
- | `commercial_doc_internal_sessions.num_pages` / `commercial_doc_internal_sessions.pages_time` | idem |
51
+ | `commercial_doc_internal_sessions.user_email` / `commercial_doc_internal_sessions.user_name` | purged with the row |
52
+ | `commercial_doc_internal_sessions.session_id` | same |
53
+ | `commercial_doc_internal_sessions.num_pages` / `commercial_doc_internal_sessions.pages_time` | same |
55
54
 
56
- ## Liens d'envoi (`commercial_doc_shares`)
55
+ ## Sending links (`commercial_doc_shares`)
57
56
 
58
- Un lien **vivant** est un enregistrement métier : ses champs restent tant que l'URL distribuée
59
- doit fonctionner. Un lien **révoqué** ne sert plus personne : **purge 13 mois après révocation**
60
- (alignée sur les journaux, qui référencent son slug). La révocation est **datée** par
61
- `commercial_doc_shares.revoked_at` (migration 0013) ; les révoqués d'avant la colonne ont reçu la
62
- date de la migration — leur horloge démarre là, compter large plutôt qu'inventer. Sans la
63
- colonne, cette purge-là se tait (sonde de schéma), les autres tournent.
57
+ A **live** link is a business record: its fields stay for as long as the distributed URL must keep
58
+ working. A **revoked** link serves nobody: **purged 13 months after revocation** (aligned with the
59
+ logs, which reference its slug). Revocation is **dated** by `commercial_doc_shares.revoked_at`
60
+ (migration 0013); links revoked before that column existed were given the migration's date — their
61
+ clock starts there, counting generously rather than inventing. Without the column, that particular
62
+ purge stays silent (schema probe); the others still run.
64
63
 
65
- | colonne | contenu | sort |
64
+ | column | contents | fate |
66
65
  |---|---|---|
67
- | `commercial_doc_shares.recipient_email` | qui peut expédier au repartage | conservée tant que le lien vit ; ligne purgée 13 mois après révocation |
68
- | `commercial_doc_shares.attested_recipient_email` | à qui l'hôte atteste le lien | idem |
69
- | `commercial_doc_shares.recipient_name` | nom du destinataire | idem |
70
- | `commercial_doc_shares.created_by` | email du commercial créateur | idem |
71
- | `commercial_doc_shares.file_name` | nom du fichier (peut porter un nom de personne) | métier, purgé avec la ligne |
66
+ | `commercial_doc_shares.recipient_email` | who may forward on re-share | kept while the link lives; row purged 13 months after revocation |
67
+ | `commercial_doc_shares.attested_recipient_email` | who the host attests the link to | same |
68
+ | `commercial_doc_shares.recipient_name` | recipient's name | same |
69
+ | `commercial_doc_shares.created_by` | email of the salesperson who created it | same |
70
+ | `commercial_doc_shares.file_name` | file name (may carry a person's name) | business data, purged with the row |
72
71
 
73
- ## Présentations en direct
72
+ ## Live presentations
74
73
 
75
- Une présentation **inactive** (terminée ou abandonnée) est une archive : **purge 12 mois après
76
- `updated_at`** — la présentation, ses messages, ses présences, et ses pièces jointes du bucket
77
- `present-attachments` (si l'hôte fournit `storage.remove`, sinon la limite est dite ci-dessous).
74
+ An **inactive** presentation (finished or abandoned) is an archive: **purged 12 months after
75
+ `updated_at`** — the presentation, its messages, its attendance records, and its attachments in the
76
+ `present-attachments` bucket (if the host provides `storage.remove`, otherwise the limit is stated
77
+ below).
78
78
 
79
- | colonne | contenu | sort |
79
+ | column | contents | fate |
80
80
  |---|---|---|
81
- | `doc_presentations.presenter_name` / `doc_presentations.owner_name` | identité du présentateur | purgées avec la ligne, 12 mois après la fin |
82
- | `doc_presentations.owner_email` / `doc_presentations.owner_user_id` | propriétaire | idem |
83
- | `doc_presentations.owner_avatar` | URL d'avatar | idem |
84
- | `doc_presentations.control_hash` | empreinte du jeton de contrôle (pas le jeton) | idem |
85
- | `doc_presentations.content` | contenu partagé (cartes, médias) | idem |
86
- | `doc_presentations.file_name` | nom du fichier présenté | idem |
87
- | `doc_presentation_messages.author_name` / `doc_presentation_messages.author_email` / `doc_presentation_messages.author_avatar` | identité de l'auteur | purgées avec la présentation |
88
- | `doc_presentation_messages.author_hash` | empreinte du jeton d'auteur | idem |
89
- | `doc_presentation_messages.body` | corps du message | idem |
90
- | `doc_presentation_messages.reply_name` / `doc_presentation_messages.reply_text` | citation d'un autre message | idem |
91
- | `doc_presentation_messages.attachment` | URL de pièce jointe | idem — fichier du bucket inclus quand `storage.remove` existe |
92
- | `doc_presentation_messages.client_key` | clé d'idempotence d'envoi | idem |
93
- | `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` | identité du participant | purgées avec la présentation |
94
- | `doc_presentation_attendees.attendee_key` | identifiant de présence | idem |
95
- | `doc_presentation_attendees.creator_ip_hash` | **empreinte tronquée** de l'IP qui a créé la ligne (jamais l'IP en clair) — sert au plafond de création anonyme (migration 0015). ⚠️ **Donnée PSEUDONYMISÉE, pas anonyme** : une IP hachée reste une donnée personnelle au sens du RGPD, et un SHA-256 non salé se recalcule intégralement sur l'espace IPv4. Depuis 0.1.114 l'empreinte est un HMAC salé par `PLAYER_IP_HASH_SECRET` (à défaut `PLAYER_PRESENCE_SECRET`, avec séparation de domaine) et liée au `slug`, ce qui empêche de corréler une même adresse d'une présentation à l'autre. Sans sel configuré, l'ancienne empreinte non salée subsiste. | purgée avec la présentation |
96
- | `doc_presentation_attendees.last_token_at` / `doc_presentation_attendees.last_no_token_at` | horodatage du dernier battement avec / sans jeton de présence — sert au compteur de transition (migration 0017), aucune donnée d'identité | purgés avec la présentation |
97
- | `doc_presentation_attendees.pages` | pages vues par le participant | idem |
98
-
99
- ## Sessions d'agent (`doc_bot_sessions`)
100
-
101
- Parcours guidé par l'agent : **purge 13 mois** après `last_at`.
102
-
103
- | colonne | sort |
81
+ | `doc_presentations.presenter_name` / `doc_presentations.owner_name` | presenter's identity | purged with the row, 12 months after the end |
82
+ | `doc_presentations.owner_email` / `doc_presentations.owner_user_id` | owner | same |
83
+ | `doc_presentations.owner_avatar` | avatar URL | same |
84
+ | `doc_presentations.control_hash` | fingerprint of the control token (not the token) | same |
85
+ | `doc_presentations.content` | shared content (cards, media) | same |
86
+ | `doc_presentations.file_name` | name of the presented file | same |
87
+ | `doc_presentation_messages.author_name` / `doc_presentation_messages.author_email` / `doc_presentation_messages.author_avatar` | author's identity | purged with the presentation |
88
+ | `doc_presentation_messages.author_hash` | fingerprint of the author token | same |
89
+ | `doc_presentation_messages.body` | message body | same |
90
+ | `doc_presentation_messages.reply_name` / `doc_presentation_messages.reply_text` | quotation of another message | same |
91
+ | `doc_presentation_messages.attachment` | attachment URL | same — the bucket file included when `storage.remove` exists |
92
+ | `doc_presentation_messages.client_key` | idempotency key for sending | same |
93
+ | `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` | attendee's identity | purged with the presentation |
94
+ | `doc_presentation_attendees.attendee_key` | presence identifier | same |
95
+ | `doc_presentation_attendees.creator_ip_hash` | **truncated fingerprint** of the IP that created the row (never the IP in the clear) — used for the anonymous-creation ceiling (migration 0015). ⚠️ **PSEUDONYMISED data, not anonymous**: a hashed IP remains personal data under the GDPR, and an unsalted SHA-256 can be recomputed exhaustively over the whole IPv4 space. Since 0.1.114 the fingerprint is an HMAC salted with `PLAYER_IP_HASH_SECRET` (failing that `PLAYER_PRESENCE_SECRET`, with domain separation) and bound to the `slug`, which prevents correlating one address across presentations. With no salt configured, the old unsalted fingerprint survives. | purged with the presentation |
96
+ | `doc_presentation_attendees.last_token_at` / `doc_presentation_attendees.last_no_token_at` | timestamp of the last heartbeat with / without a presence token — feeds the transition counter (migration 0017), carries no identity | purged with the presentation |
97
+ | `doc_presentation_attendees.pages` | pages the attendee viewed | same |
98
+
99
+ ## Agent sessions (`doc_bot_sessions`)
100
+
101
+ Agent-guided walkthrough: **purged 13 months** after `last_at`.
102
+
103
+ | column | fate |
104
104
  |---|---|
105
- | `doc_bot_sessions.rating` / `doc_bot_sessions.rating_comment` | avis du visiteur — purgés avec la ligne |
106
- | `doc_bot_sessions.in_tokens` / `doc_bot_sessions.out_tokens` / `doc_bot_sessions.cache_tokens` | volumétrie IA (pas personnelle, mais portée par la ligne) — purgés avec elle |
105
+ | `doc_bot_sessions.rating` / `doc_bot_sessions.rating_comment` | visitor's feedback — purged with the row |
106
+ | `doc_bot_sessions.in_tokens` / `doc_bot_sessions.out_tokens` / `doc_bot_sessions.cache_tokens` | AI volume (not personal, but carried by the row) — purged with it |
107
107
 
108
- ## Limites de débit (`player_rate_limits`)
108
+ ## Rate limits (`player_rate_limits`)
109
109
 
110
- | colonne | contenu | sort |
110
+ | column | contents | fate |
111
111
  |---|---|---|
112
- | `player_rate_limits.key` | peut contenir une **IP en clair** (`hshare:<ip>`) ou un email | ligne purgée dès `expires_at` dépassé (opportuniste, à chaque passage) |
113
-
114
- ## Limites dites plutôt que tues
115
-
116
- - **Pièces jointes orphelines** : la purge des lignes n'efface le fichier du bucket que si le
117
- contexte hôte fournit `storage.remove` (capacité optionnelle). Sans elle, l'URL devient
118
- introuvable depuis le produit mais l'objet survit dans le bucket — c'est dit ici plutôt que
119
- simulé.
120
- - **Le plafond des présentations est GLOBAL** : messages et présences partagent chacun un budget
121
- `plafond` réparti sur toutes les présentations d'une exécution — pas un plafond par présentation
122
- (sinon 500 × 5000 = 2,5 M de lignes possibles). La boucle s'arrête quand les budgets sont
123
- épuisés, sans supprimer les présentations restantes.
124
- - **Le rapport dryRun est complet pour les présentations** : `messagesExaminees`,
125
- `presencesExaminees` et `fichiersCandidats` disent ce que la VRAIE purge ferait — même parcours
126
- de sélection, suppression no-op, `efface.* = 0`.
127
- - **La purge avance par LOTS bornés** (200 lignes, plafond 5000 par table et 500 présentations
128
- par exécution) : elle sélectionne un lot d'identifiants, les supprime par `id=in.(…)`, et
129
- recommence. Le rapport (`r.rapport`) porte, par table : `examinees`, `supprimees`, `tronque`
130
- (il reste à faire au prochain passage). `retention.run` accepte `{ dryRun: true }` : elle
131
- compte sans rien effacer — à lancer avant la première vraie purge d'un gros historique.
132
- - **Index** (migration 0014) : `commercial_doc_sessions(last_at)`, `doc_bot_sessions(last_at)`,
133
- `commercial_doc_shares(revoked_at) where revoked`. Sur une installation VOLUMINEUSE déjà en
134
- production, créez-les à la main en `CREATE INDEX CONCURRENTLY` hors migration (la migration les
135
- pose en index ordinaire, ce qui verrouille brièvement l'écriture — négligeable sur une base
136
- jeune, à éviter sur une grosse table active).
137
- - **Le recensement ne tourne pas tout seul en production** : c'est un SQL qu'un exploitant lance
138
- (et que la forge exécute à chaque course sur une base réelle vieillie artificiellement).
139
- - **« Ce qui existe » a une profondeur temporelle qu'`information_schema` n'a pas** (question du
140
- second hôte, sans réponse mécanique) : une colonne supprimée du schéma sort du périmètre des
141
- deux textes, mais sa donnée peut survivre dans un dump, une sauvegarde ou une table d'archive.
142
- Ce contrat couvre la BASE VIVANTE ; les copies (sauvegardes, exports, dumps de migration) sont
143
- le périmètre de l'exploitant, nommé ici plutôt que simulé. Corollaire opératoire : supprimer
144
- une colonne à donnée personnelle est un acte de rétention — sa ligne quitte ce document dans
145
- le même commit, et les copies antérieures suivent la politique de sauvegarde de l'hôte.
112
+ | `player_rate_limits.key` | may contain an **IP in the clear** (`hshare:<ip>`) or an email | row purged as soon as `expires_at` has passed (opportunistically, on every pass) |
113
+
114
+ ## Limits stated rather than left unsaid
115
+
116
+ - **Orphaned attachments**: purging the rows erases the bucket file only if the host context
117
+ provides `storage.remove` (an optional capability). Without it, the URL becomes unreachable from
118
+ the product but the object survives in the bucket — said here rather than simulated.
119
+ - **The presentation ceiling is GLOBAL**: messages and attendance records each share one `plafond`
120
+ budget spread across every presentation of a run — not a ceiling per presentation (otherwise
121
+ 500 × 5000 = 2.5 M possible rows). The loop stops when the budgets are exhausted, without
122
+ deleting the remaining presentations.
123
+ - **The dryRun report is complete for presentations**: `messagesExaminees`, `presencesExaminees`
124
+ and `fichiersCandidats` say what the REAL purge would do — same selection walk, no-op deletion,
125
+ `efface.* = 0`.
126
+ - **The purge advances in BOUNDED BATCHES** (200 rows, a ceiling of 5000 per table and 500
127
+ presentations per run): it selects a batch of identifiers, deletes them with `id=in.(…)`, and
128
+ starts again. The report (`r.rapport`) carries, per table: `examinees`, `supprimees`, `tronque`
129
+ (there is more left for the next pass). `retention.run` accepts `{ dryRun: true }`: it counts
130
+ without erasing anything — to be run before the first real purge of a large history.
131
+ - **Indexes** (migration 0014): `commercial_doc_sessions(last_at)`, `doc_bot_sessions(last_at)`,
132
+ `commercial_doc_shares(revoked_at) where revoked`. On a LARGE installation already in production,
133
+ create them by hand with `CREATE INDEX CONCURRENTLY` outside the migration (the migration lays
134
+ them down as ordinary indexes, which briefly locks writes — negligible on a young database, to be
135
+ avoided on a large active table).
136
+ - **The census does not run by itself in production**: it is a piece of SQL an operator runs (and
137
+ that CI executes on every run against a real, artificially aged database).
138
+ - **"What exists" has a depth in time that `information_schema` does not have** (a question from the
139
+ second host, with no mechanical answer): a column dropped from the schema leaves the scope of both
140
+ texts, but its data may survive in a dump, a backup or an archive table. This contract covers the
141
+ LIVE DATABASE; copies (backups, exports, migration dumps) are the operator's scope, named here
142
+ rather than simulated. Operational corollary: dropping a column holding personal data is itself a
143
+ retention act — its row leaves this document in the same commit, and earlier copies follow the
144
+ host's backup policy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.126",
3
+ "version": "0.1.127",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
@@ -839,6 +839,12 @@ function signatureAbsente(erreur) {
839
839
  // silence. Un « oui » (la signature existe) n'a pas besoin d'expirer : une fonction ne disparaît pas.
840
840
  let _bumpSansDurcissementJusqua = 0;
841
841
  const MEMO_SANS_DURCISSEMENT_MS = 60 * 1000;
842
+ // ⚠️ MÉMO DU CONTRAT FUSIONNÉ (0019), MÊME PATRON QUE 0018. La fusion est FONCTION-SEULE : aucune
843
+ // colonne à sonder, donc on la DEMANDE et on retient l'échec, sinon un hôte non migré paierait un
844
+ // aller-retour perdu à chaque battement. Soixante secondes : assez pour ne pas insister, assez court
845
+ // pour qu'appliquer la migration se voie sans redémarrer le processus.
846
+ let _bumpSansFusionJusqua = 0;
847
+ const MEMO_SANS_FUSION_MS = 60 * 1000;
842
848
  // ⚠️ LE DERNIER MOT OBSERVÉ, ÉCRIT — pas déduit. Un booléen « on a essayé » ne pouvait pas dire si
843
849
  // ce mot fut un succès ou un échec, et c'est lui qui décide. Comparer deux instants le disait, au
844
850
  // prix d'une égalité possible à la milliseconde. On écrit donc l'état : « actif » (un appel durci
@@ -862,6 +868,29 @@ function erreurDurcissementAbsent() {
862
868
  return e;
863
869
  }
864
870
 
871
+ // ⚠️ L'APPEL FUSIONNÉ NE PASSE PAS PAR `appelerBump`, ET CE N'EST PAS UN OUBLI. `appelerBump` lit
872
+ // une signature absente comme « 0018 manque » : c'est vrai tant qu'UN SEUL fichier peut causer cet
873
+ // échec. Avec 0019, deux le peuvent — et le diagnostic se tromperait de fichier, exactement le défaut
874
+ // qu'on a retiré du message d'alerte quelques versions plus tôt (un nom FAUX est pire qu'absent :
875
+ // l'exploitant vérifie la migration nommée, la trouve appliquée, et conclut au faux positif).
876
+ //
877
+ // Ici on ne diagnostique donc RIEN : l'échec de signature arme le mémo et rend la main au chemin
878
+ // classique, qui refera le diagnostic avec un seul fichier possible. Le succès, lui, prouve que 0019
879
+ // est là — donc 0018 aussi, elle la précède — et le durcissement est bien celui qu'on a demandé.
880
+ async function appelerBumpFusionne(corps, durcissementVoulu) {
881
+ const reponse = await PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: corps });
882
+ if (durcissementVoulu) _etatDurcissement = "actif";
883
+ // ⚠️ ON PROJETTE PLUTÔT QUE DE RENDRE LA LIGNE TELLE QUELLE — une garde de ce dépôt l'exige, et
884
+ // elle a raison ici : le jour où la RPC rendra une colonne de plus, elle ne traversera pas cette
885
+ // fonction sans que quelqu'un l'ait décidé. Les sept champs sont ceux que 0019 déclare.
886
+ const l = Array.isArray(reponse) ? reponse[0] : reponse;
887
+ if (!l || typeof l.ok !== "boolean") return null; // forme inattendue : aucun verdict inventé
888
+ return {
889
+ ok: !!l.ok, created: !!l.created, capped: !!l.capped, usurpe: !!l.usurpe,
890
+ introuvable: !!l.introuvable, archivee: !!l.archivee, page: Math.max(0, Math.trunc(Number(l.page) || 0)),
891
+ };
892
+ }
893
+
865
894
  async function appelerBump(corps, durcissementVoulu) {
866
895
  const appel = (b) => PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: b });
867
896
  if (durcissementVoulu && Date.now() < _bumpSansDurcissementJusqua) {
@@ -920,19 +949,36 @@ async function appelerBump(corps, durcissementVoulu) {
920
949
  }
921
950
  }
922
951
 
923
- async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null, onlyIfUnclaimed = false } = {}) {
952
+ async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null, onlyIfUnclaimed = false, controlHash = null, sansFusion = false } = {}) {
924
953
  const { key, name, email, avatar, isMember, isPresenter } = participant || {};
925
954
  if (!slug || !key) return { ok: false, status: 400 };
926
- // La route vient DÉJÀ de charger la présentation (contrôle présentateur) : elle la fournit dans les
927
- // options plutôt que de la faire relire par battement (un battement = un aller-retour DB de moins).
928
- // Repli sur une lecture si l'appelant ne la fournit pas — c'est le cas de l'appel public à 2 args.
929
- const pres = presentation || await getPresentation(slug);
930
- if (!pres) return { ok: false, status: 404 };
931
- // ⚠️ La lecture est DÉJÀ faite ici : on s'en sert plutôt que d'en refaire une. Un battement de
932
- // présence sur une session close n'a rien à mettre à jour — et il en arrive à chaque onglet
933
- // resté ouvert, longtemps après la fin.
934
- if (pres.active === false && pres.control_hash == null) return REFUS_ARCHIVE;
935
- const page = Math.max(1, Math.trunc(Number(pres.current_page) || 1));
955
+ // ⚠️ CHEMIN FUSIONNÉ (migration 0019) : C'EST LA BASE QUI LIT LA PRÉSENTATION, PLUS NOUS. Un
956
+ // battement coûtait TROIS allers-retours — débit par IP, lecture de la présentation, écriture. La
957
+ // lecture servait à trois choses, et à trois seulement : l'existence, la page courante, et
958
+ // l'identification du présentateur par son jeton de contrôle. Les trois se font désormais DANS la
959
+ // transaction d'écriture. Mesuré à 250 participants avant d'être engagé, pas supposé.
960
+ //
961
+ // On n'y va pas si l'appelant nous a DÉJÀ fourni la présentation : il l'a lue pour autre chose, la
962
+ // refaire lire ne ferait rien gagner. `sansFusion` est le repli explicite — il ne dépend pas du
963
+ // mémo, donc la reprise ci-dessous ne peut pas boucler même si l'horloge se comporte mal.
964
+ const fusion = presentation == null && !sansFusion && Date.now() >= _bumpSansFusionJusqua;
965
+ let pres = presentation;
966
+ if (!fusion) {
967
+ pres = presentation || await getPresentation(slug);
968
+ if (!pres) return { ok: false, status: 404 };
969
+ // ⚠️ La lecture est DÉJÀ faite ici : on s'en sert plutôt que d'en refaire une. Un battement de
970
+ // présence sur une session close n'a rien à mettre à jour — et il en arrive à chaque onglet
971
+ // resté ouvert, longtemps après la fin.
972
+ if (pres.active === false && pres.control_hash == null) return REFUS_ARCHIVE;
973
+ }
974
+ // `null` en mode fusionné : la base prendra sa propre `current_page`. C'est aussi le SIGNAL qu'elle
975
+ // attend pour appliquer les deux refus à notre place (cf. 0019) — un appelant qui a lu envoie
976
+ // toujours une page, un appelant qui n'a pas lu n'en a pas.
977
+ const page = fusion ? null : Math.max(1, Math.trunc(Number(pres.current_page) || 1));
978
+ // ⚠️ LA COMPARAISON DE JETON RESTE ICI HORS FUSION, sinon le présentateur perdrait son titre en
979
+ // silence sur un hôte non migré : la décision a changé de camp, elle n'a pas disparu d'ici.
980
+ const presentateurPreuve = !!isPresenter
981
+ || (!fusion && !!pres && controlHash != null && pres.control_hash != null && controlHash === pres.control_hash);
936
982
 
937
983
  // ⚠️ CHEMIN ATOMIQUE (migration 0015) : upsert ET plafond de création anonyme en UN geste, à l'abri
938
984
  // des créations concurrentes. Absent (404, migration non appliquée) → on retombe sur la boucle
@@ -956,7 +1002,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
956
1002
  const corpsRpc = {
957
1003
  p_slug: String(slug), p_key: String(key), p_ip_hash: ipHash || null, p_page: page,
958
1004
  p_name: (name || "").slice(0, 120), p_avatar: (avatar || "").slice(0, 600),
959
- p_is_member: !!isMember, p_is_presenter: !!isPresenter,
1005
+ p_is_member: !!isMember, p_is_presenter: presentateurPreuve,
960
1006
  p_max_gap_ms: ATTEND_MAX_GAP_MS, p_anon_cap: capAnon,
961
1007
  };
962
1008
  // true → last_token_at, false → last_no_token_at, null → ni l'un ni l'autre. 0017 seulement.
@@ -969,10 +1015,40 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
969
1015
  // pour le processus : un hôte non migré ne paie pas deux allers-retours par battement, un seul.
970
1016
  const durcissementVoulu = !!onlyIfUnclaimed;
971
1017
  if (durcissementVoulu) corpsRpc.p_only_if_unclaimed = true;
1018
+ // ⚠️ EN MODE FUSIONNÉ, `p_control_hash` PART TOUJOURS — MÊME NULL, ET C'EST ESSENTIEL. PostgREST
1019
+ // résout une RPC par jeu d'arguments NOMMÉS : omettre l'argument quand le visiteur n'a pas de jeton
1020
+ // de contrôle ferait résoudre l'appel vers le contrat COURT, qui existe encore sur une base non
1021
+ // migrée et qui lit `p_page` null comme « page 1 ». On enregistrerait alors la page 1 pour tout le
1022
+ // monde, sans aucun refus, et RIEN ne le dirait. L'argument explicite est précisément ce qui fait
1023
+ // ÉCHOUER l'appel là où 0019 manque — donc ce qui déclenche le repli au lieu d'un faux succès.
1024
+ if (fusion) corpsRpc.p_control_hash = controlHash || null;
972
1025
  try {
973
- const r = await appelerBump(corpsRpc, durcissementVoulu);
1026
+ let r;
1027
+ if (fusion) {
1028
+ try {
1029
+ r = await appelerBumpFusionne(corpsRpc, durcissementVoulu);
1030
+ } catch (erreurFusion) {
1031
+ // Toute autre erreur est une vraie panne : elle suit le chemin d'erreur commun ci-dessous.
1032
+ if (!signatureAbsente(erreurFusion)) throw erreurFusion;
1033
+ // 0019 n'est pas appliquée. On arme le mémo et on recommence par le chemin classique, qui
1034
+ // lira la présentation et décidera du présentateur ici — SANS rien perdre. Rien à signaler à
1035
+ // l'exploitant : le battement reste exact, il coûte simplement l'aller-retour d'avant.
1036
+ _bumpSansFusionJusqua = Date.now() + MEMO_SANS_FUSION_MS;
1037
+ return recordAttendance(slug, participant,
1038
+ { presentation, ipHash, anonCap, hasToken, onlyIfUnclaimed, controlHash, sansFusion: true });
1039
+ }
1040
+ } else {
1041
+ r = await appelerBump(corpsRpc, durcissementVoulu);
1042
+ }
974
1043
  const ligne = Array.isArray(r) ? r[0] : r;
975
1044
  if (ligne && typeof ligne.ok === "boolean") {
1045
+ // ⚠️ LES DEUX REFUS QUE LA ROUTE OPPOSAIT AVANT D'APPELER, RENDUS PAR LA BASE (0019). Ils ne
1046
+ // sont pas décoratifs : sans eux, la fusion aurait supprimé un 404 et un refus d'archive en
1047
+ // déplaçant la décision. Ils arrivent DISTINCTS plutôt que fondus dans un `ok:false`, pour que
1048
+ // le client garde les mêmes codes qu'avant — 404 « cette présentation n'existe pas », 409
1049
+ // « elle est finie ». Un appelant qui a fourni la présentation ne les verra jamais.
1050
+ if (ligne.introuvable) return { ok: false, status: 404 };
1051
+ if (ligne.archivee) return REFUS_ARCHIVE;
976
1052
  // Plafond de création atteint : on ne crée pas ce faux participant. 429 = « trop », pas une panne.
977
1053
  if (ligne.capped) return { ok: false, status: 429 };
978
1054
  // Bootstrap sur une ligne DÉJÀ RÉCLAMÉE par un porteur de jeton : rien n'a été écrit. 409 = « ce
@@ -1024,6 +1100,20 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
1024
1100
  // est conditionnée à la valeur LUE ; zéro ligne = quelqu'un d'autre a battu entre-temps (l'autre
1025
1101
  // onglet du même participant) — on relit et on rejoue. Sans ça, deux onglets qui battent dans la
1026
1102
  // même seconde perdaient une page vue : la seconde réécriture emportait la première.
1103
+ // ⚠️ LE REPLI A BESOIN DE LA PAGE, ET EN MODE FUSIONNÉ NOUS NE L'AVONS PAS. L'économie de 0019 ne
1104
+ // vaut que sur le chemin qui RÉUSSIT : ici la RPC a échoué (ou rendu une forme inattendue), donc on
1105
+ // paie la lecture qu'on avait évitée. Sans ça on écrirait `pages: [null]` et un présentateur
1106
+ // perdrait son titre — deux dégâts silencieux, au moment précis où l'on est déjà en difficulté.
1107
+ let pageEcrite = page;
1108
+ let presentateurEcrit = presentateurPreuve;
1109
+ if (pageEcrite == null) {
1110
+ const relu = await getPresentation(slug);
1111
+ if (!relu) return { ok: false, status: 404 };
1112
+ if (relu.active === false && relu.control_hash == null) return REFUS_ARCHIVE;
1113
+ pageEcrite = Math.max(1, Math.trunc(Number(relu.current_page) || 1));
1114
+ presentateurEcrit = presentateurEcrit
1115
+ || (controlHash != null && relu.control_hash != null && controlHash === relu.control_hash);
1116
+ }
1027
1117
  let cur = null;
1028
1118
  for (let essai = 0; essai < 4; essai += 1) {
1029
1119
  if (cur === null) {
@@ -1032,7 +1122,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
1032
1122
  }
1033
1123
  if (!cur) {
1034
1124
  const now = Date.now();
1035
- const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter: !!isPresenter, first_seen: new Date(now).toISOString(), last_seen: new Date(now).toISOString(), total_ms: 0, pages: [page] };
1125
+ const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter: presentateurEcrit, first_seen: new Date(now).toISOString(), last_seen: new Date(now).toISOString(), total_ms: 0, pages: [pageEcrite] };
1036
1126
  try {
1037
1127
  await PLAYER.db.request("doc_presentation_attendees", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
1038
1128
  return { ok: true };
@@ -1059,7 +1149,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
1059
1149
  const gap = now - lu;
1060
1150
  const addMs = gap > 0 && gap <= ATTEND_MAX_GAP_MS ? gap : 0;
1061
1151
  const pages = Array.isArray(cur.pages) ? cur.pages.slice() : [];
1062
- if (!pages.includes(page)) pages.push(page);
1152
+ if (!pages.includes(pageEcrite)) pages.push(pageEcrite);
1063
1153
  const ecrit = await ecrireSiEncoreVrai(
1064
1154
  `doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&last_seen=eq.${enc(String(cur.last_seen))}`,
1065
1155
  { last_seen: new Date(now).toISOString(), total_ms: Number(cur.total_ms || 0) + addMs, pages, name: (name || cur.name || "").slice(0, 120) || null, avatar: (avatar || cur.avatar || "").slice(0, 600) || null,
@@ -1067,7 +1157,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
1067
1157
  // première ligne : un transfert de présentation change qui porte le titre, et une session qui
1068
1158
  // s'authentifie en cours de route devient un membre. Figés, ils décriraient l'instant de
1069
1159
  // l'arrivée et non la réalité — et le premier arrivé aurait raison pour toujours.
1070
- is_member: !!isMember, is_presenter: !!isPresenter },
1160
+ is_member: !!isMember, is_presenter: presentateurEcrit },
1071
1161
  );
1072
1162
  if (ecrit) return { ok: true };
1073
1163
  cur = null; // battu en vol : on relira l'état frais au tour suivant
@@ -78,9 +78,18 @@ async function traiter(req, res, body, _slug) {
78
78
  // route ne le faisait pas. Et l'appartenance se prouve par le jeton d'accès de la session :
79
79
  // cette route est un `fetch`, elle peut porter un en-tête (contrairement au suivi de
80
80
  // lecture, qui part par `sendBeacon` et signe donc dans le corps).
81
- const pres = await getPresentation(String(body.slug || ""));
82
- if (!pres) return jp(404, { ok: false });
83
- const estPresentateur = !!(body.control && require("crypto").createHash("sha256").update(String(body.control)).digest("hex") === pres.control_hash);
81
+ // ⚠️ CETTE ROUTE NE LIT PLUS LA PRÉSENTATION (0019). Elle la lisait pour trois choses —
82
+ // existe-t-elle, est-elle close, et l'appelant porte-t-il le jeton de contrôle — et les
83
+ // trois se décident maintenant dans la transaction d'écriture. Le battement passe de trois
84
+ // allers-retours à deux. Ce qui monte est l'EMPREINTE du jeton, jamais le jeton : la
85
+ // comparaison est la même qu'ici, faite sur la même donnée, un cran plus bas.
86
+ //
87
+ // ⚠️ Le 404 et le refus d'archive n'ont pas disparu, ils REMONTENT : `recordAttendance`
88
+ // rend { status: 404 } et REFUS_ARCHIVE à partir de ce que la base répond. Un déplacement
89
+ // de décision est l'endroit où une garde se perd — celles-ci sont mesurées, des deux côtés.
90
+ const controlHash = body.control
91
+ ? require("crypto").createHash("sha256").update(String(body.control)).digest("hex")
92
+ : null;
84
93
  // ⚠️ Une identité prouvée REMPLACE celle qu'on affirme — elle ne s'y ajoute pas. Sinon la
85
94
  // vérification ne servirait qu'à décorer une affirmation qu'on croit toujours.
86
95
  const profil = await profilDuJeton(req);
@@ -188,13 +197,15 @@ async function traiter(req, res, body, _slug) {
188
197
  name: (profil && profil.name) || body.name,
189
198
  email: profil ? profil.email : body.email,
190
199
  avatar: (profil && profil.avatar) || body.avatar,
191
- isMember: !!profil, isPresenter: estPresentateur,
200
+ // ⚠️ ON N'AFFIRME PLUS LE TITRE, ON FOURNIT LA PREUVE (`controlHash`, plus bas) : c'est
201
+ // la base qui compare. Affirmer `false` ici n'enlève donc rien — le titre vient du jeton.
202
+ isMember: !!profil, isPresenter: false,
192
203
  // ⚠️ UN BOOTSTRAP NE PEUT PAS S'EMPARER D'UNE PRÉSENCE DÉJÀ RÉCLAMÉE (0018). `wantToken`
193
204
  // est AUTO-DÉCLARÉ : sans ce verrou, un attaquant le déclarait, posait la clé d'un
194
205
  // participant enregistré, et écrasait sa ligne — l'usurpation même que l'étape 2 ferme
195
206
  // pour les battements ordinaires. Le second hôte l'a EXÉCUTÉE sur sa prod. Le contrôle ne
196
207
  // vaut que pour un bootstrap : un porteur de jeton est déjà prouvé, un membre aussi.
197
- }, { presentation: pres, ipHash, anonCap, hasToken, onlyIfUnclaimed: bootstrap });
208
+ }, { ipHash, anonCap, hasToken, onlyIfUnclaimed: bootstrap, controlHash });
198
209
  // On ÉMET (ou ré-émet) un jeton pour l'anonyme dont l'écriture a réussi : le client le garde
199
210
  // et le renvoie aux battements suivants. `exp` court (6 h), ré-émis à chaque battement — pas
200
211
  // de table anti-rejeu (le scellé d'archive et l'exp bornent déjà le rejeu, cf. 0007). Un
package/supabase/init.sql CHANGED
@@ -386,7 +386,11 @@ begin
386
386
  end
387
387
  $$;
388
388
 
389
- -- Présence en un seul geste + plafond de création anonyme atomique. Cf. migration 0015 (mêmes règles).
389
+ -- Présence en un seul geste + plafond de création anonyme atomique, ET lecture de la présentation
390
+ -- dans la MÊME transaction que l'écriture. Cf. migrations 0015 puis 0019 — texte identique à 0019,
391
+ -- sans quoi rejouer la migration sur une base née d'ici changerait la forme (la CI le mesure).
392
+ drop function if exists public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean);
393
+
390
394
  create or replace function public.player_attendance_bump(
391
395
  p_slug text,
392
396
  p_key text,
@@ -399,9 +403,11 @@ create or replace function public.player_attendance_bump(
399
403
  p_max_gap_ms integer,
400
404
  p_anon_cap integer,
401
405
  p_has_token boolean default null,
402
- p_only_if_unclaimed boolean default null
406
+ p_only_if_unclaimed boolean default null,
407
+ p_control_hash text default null
403
408
  )
404
- returns table (ok boolean, created boolean, capped boolean, usurpe boolean)
409
+ returns table (ok boolean, created boolean, capped boolean, usurpe boolean,
410
+ introuvable boolean, archivee boolean, page integer)
405
411
  language plpgsql
406
412
  security definer
407
413
  set search_path = public
@@ -410,8 +416,56 @@ declare
410
416
  v_exists boolean;
411
417
  v_claimed boolean;
412
418
  v_count integer;
413
- v_page integer := greatest(1, coalesce(p_page, 1));
419
+ v_pres_active boolean;
420
+ v_pres_page integer;
421
+ v_pres_hash text;
422
+ v_pres_vue boolean := false;
423
+ v_presentateur boolean;
424
+ v_page integer;
414
425
  begin
426
+ -- ⚠️ LA PRÉSENTATION EST LUE ICI, ET C'EST TOUT L'OBJET DE 0019. L'appelant la lisait avant
427
+ -- d'appeler : un battement coûtait donc DEUX allers-retours là où un seul suffit. La lecture
428
+ -- servait à trois choses — l'existence, l'identification du présentateur, la page courante — et
429
+ -- les trois se font mieux ici, dans la même transaction que l'écriture.
430
+ select active, current_page, control_hash
431
+ into v_pres_active, v_pres_page, v_pres_hash
432
+ from public.doc_presentations where slug = p_slug;
433
+ v_pres_vue := found;
434
+
435
+ -- ⚠️ LE SIGNAL EST « L'APPELANT A-T-IL LU », PAS « PORTE-T-IL UN JETON ». Première écriture de
436
+ -- cette migration : les deux refus ci-dessous étaient conditionnés à `p_control_hash is not null`.
437
+ -- Or un participant ANONYME n'envoie aucun jeton de contrôle — c'est-à-dire l'immense majorité des
438
+ -- battements — et il aurait donc perdu le 404 et le refus d'archive que la route lui oppose
439
+ -- aujourd'hui. Une garde perdue en chemin, à l'endroit exact que l'en-tête désigne comme risqué.
440
+ --
441
+ -- Ce qui décide, c'est si l'appelant a DÉJÀ lu la présentation, et ça se lit à `p_page` : un
442
+ -- appelant ANCIEN en fournit toujours une (les deux seuls appelants du dépôt envoient page >= 1,
443
+ -- et 1 pour la sonde de schéma) ; un appelant NEUF n'en a pas, puisqu'il n'a rien lu. `p_page is
444
+ -- null` dit donc exactement « c'est à toi de trancher » — pour un anonyme comme pour un
445
+ -- présentateur, alors que le jeton de contrôle ne parlait que du second.
446
+ if p_page is null and not v_pres_vue then
447
+ return query select false, false, false, false, true, false, 0; -- introuvable, rien écrit
448
+ return;
449
+ end if;
450
+
451
+ -- ⚠️ MÊME REFUS QUE LE CODE QU'ON REMPLACE : une présentation close ET archivée (control_hash
452
+ -- effacé) n'a plus rien à mettre à jour. Le dire ici évite que la fusion perde une garde en
453
+ -- chemin — c'est le risque propre à tout déplacement de décision.
454
+ if p_page is null and v_pres_active is false and v_pres_hash is null then
455
+ return query select false, false, false, false, false, true, 0; -- archivée, rien écrit
456
+ return;
457
+ end if;
458
+
459
+ -- ⚠️ LA DÉCISION QUI CHANGE DE CAMP : « qui est présentateur » se décidait en JavaScript, par
460
+ -- comparaison du sha256 du jeton de contrôle au control_hash de la ligne. Elle se décide ici
461
+ -- désormais — sur la MÊME donnée, dans la même transaction. On garde le OU avec l'argument de
462
+ -- l'appelant : un appelant ancien continue de décider lui-même, et ne perd rien.
463
+ v_presentateur := coalesce(p_is_presenter, false)
464
+ or (p_control_hash is not null and v_pres_hash is not null and p_control_hash = v_pres_hash);
465
+
466
+ -- La page vient de la BASE quand l'appelant ne la fournit pas — il n'a plus à la lire pour nous.
467
+ v_page := greatest(1, coalesce(p_page, v_pres_page, 1));
468
+
415
469
  -- On lit l'existence ET l'état « réclamée » en un seul geste : un bootstrap n'a rien à écrire sur
416
470
  -- une ligne dont un porteur de jeton s'est déjà servi.
417
471
  select true, (last_token_at is not null) into v_exists, v_claimed
@@ -419,11 +473,11 @@ begin
419
473
  where slug = p_slug and attendee_key = p_key;
420
474
 
421
475
  if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
422
- return query select false, false, false, true; -- usurpation : on n'écrit RIEN
476
+ return query select false, false, false, true, false, false, v_page; -- usurpation : on n'écrit RIEN
423
477
  return;
424
478
  end if;
425
479
 
426
- if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
480
+ if not coalesce(v_exists, false) and not p_is_member and not v_presentateur then
427
481
  perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
428
482
  -- Re-lire sous le verrou : la ligne a pu naître entre-temps — et si elle est née RÉCLAMÉE, un
429
483
  -- bootstrap concurrent ne doit pas davantage l'écraser ici qu'au premier contrôle.
@@ -431,7 +485,7 @@ begin
431
485
  from public.doc_presentation_attendees
432
486
  where slug = p_slug and attendee_key = p_key;
433
487
  if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
434
- return query select false, false, false, true;
488
+ return query select false, false, false, true, false, false, v_page;
435
489
  return;
436
490
  end if;
437
491
  if not coalesce(v_exists, false) then
@@ -441,7 +495,7 @@ begin
441
495
  and creator_ip_hash is not distinct from p_ip_hash
442
496
  and is_member = false and is_presenter = false;
443
497
  if v_count >= p_anon_cap then
444
- return query select false, false, true, false;
498
+ return query select false, false, true, false, false, false, v_page;
445
499
  return;
446
500
  end if;
447
501
  end if;
@@ -451,7 +505,7 @@ begin
451
505
  (slug, attendee_key, name, email, avatar, is_member, is_presenter,
452
506
  first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
453
507
  values
454
- (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
508
+ (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, v_presentateur,
455
509
  now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
456
510
  case when p_has_token is true then now() end,
457
511
  case when p_has_token is false then now() end)
@@ -467,21 +521,21 @@ begin
467
521
  name = coalesce(nullif(p_name, ''), l.name),
468
522
  avatar = coalesce(nullif(p_avatar, ''), l.avatar),
469
523
  is_member = p_is_member,
470
- is_presenter = p_is_presenter,
524
+ is_presenter = v_presentateur,
471
525
  last_token_at = case when p_has_token is true then now() else l.last_token_at end,
472
526
  last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
473
527
 
474
- return query select true, not coalesce(v_exists, false), false, false;
528
+ return query select true, not coalesce(v_exists, false), false, false, false, false, v_page;
475
529
  end;
476
530
  $$;
477
- revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from public;
531
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) from public;
478
532
  do $$
479
533
  declare
480
534
  r text;
481
535
  begin
482
536
  foreach r in array array['anon', 'authenticated'] loop
483
537
  if exists (select 1 from pg_roles where rolname = r) then
484
- execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from %I', r);
538
+ execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) from %I', r);
485
539
  end if;
486
540
  end loop;
487
541
  end
@@ -489,7 +543,7 @@ $$;
489
543
  do $$
490
544
  begin
491
545
  if exists (select 1 from pg_roles where rolname = 'service_role') then
492
- grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) to service_role;
546
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) to service_role;
493
547
  end if;
494
548
  end
495
549
  $$;
@@ -0,0 +1,181 @@
1
+ -- LA RPC DE PRÉSENCE LIT LA PRÉSENTATION — UN BATTEMENT PASSE DE 3 À 2 ALLERS-RETOURS.
2
+ --
3
+ -- ⚠️ Dernier poste chiffré d'un audit externe, et il exigeait d'être MESURÉ avant d'être engagé.
4
+ -- La mesure existe : à 250 participants, le battement coûte 3 allers-retours (débit par IP, lecture
5
+ -- de présentation, écriture) — soit ~30 op/s. La lecture disparaît ici : ~20 op/s.
6
+ --
7
+ -- ⚠️ CE QUI CHANGE DE CAMP, ET IL FAUT LE LIRE COMME TEL : « qui est présentateur » se décidait en
8
+ -- JavaScript (sha256 du jeton de contrôle comparé au control_hash de la ligne). Cette décision passe
9
+ -- dans le SQL, sur la MÊME donnée et dans la MÊME transaction que l'écriture. Un déplacement de
10
+ -- décision est l'endroit où une garde se perd en chemin : les deux refus que le code JS opposait —
11
+ -- présentation introuvable, présentation close ET archivée — sont donc reproduits ici, et rendus
12
+ -- explicitement (`introuvable`, `archivee`) plutôt que confondus avec un simple `ok = false`.
13
+ --
14
+ -- ⚠️ RÉTROCOMPATIBLE DANS LES DEUX SENS. `p_control_hash` a un DEFAULT : une base NEUVE sert un code
15
+ -- ANCIEN sans rien changer (il passe p_page, décide lui-même du présentateur, et ne demande pas les
16
+ -- nouveaux refus). Le sens inverse — code neuf, base ancienne — reste impossible par construction
17
+ -- (PostgREST résout par jeu d'arguments nommés) : le player réessaie donc le contrat plus court, et
18
+ -- retombe sur sa lecture préalable. Dégrader, jamais casser, jamais en silence.
19
+ --
20
+ -- ⚠️ Sans lui : rien ne casse — le player redemande le contrat court, relit la présentation comme
21
+ -- avant, et le battement garde ses 3 allers-retours. Le titre de présentateur, les deux refus et la
22
+ -- page restent exacts : c'est le CHEMIN qui change, jamais le résultat.
23
+
24
+ drop function if exists public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean);
25
+
26
+ create or replace function public.player_attendance_bump(
27
+ p_slug text,
28
+ p_key text,
29
+ p_ip_hash text,
30
+ p_page integer,
31
+ p_name text,
32
+ p_avatar text,
33
+ p_is_member boolean,
34
+ p_is_presenter boolean,
35
+ p_max_gap_ms integer,
36
+ p_anon_cap integer,
37
+ p_has_token boolean default null,
38
+ p_only_if_unclaimed boolean default null,
39
+ p_control_hash text default null
40
+ )
41
+ returns table (ok boolean, created boolean, capped boolean, usurpe boolean,
42
+ introuvable boolean, archivee boolean, page integer)
43
+ language plpgsql
44
+ security definer
45
+ set search_path = public
46
+ as $$
47
+ declare
48
+ v_exists boolean;
49
+ v_claimed boolean;
50
+ v_count integer;
51
+ v_pres_active boolean;
52
+ v_pres_page integer;
53
+ v_pres_hash text;
54
+ v_pres_vue boolean := false;
55
+ v_presentateur boolean;
56
+ v_page integer;
57
+ begin
58
+ -- ⚠️ LA PRÉSENTATION EST LUE ICI, ET C'EST TOUT L'OBJET DE 0019. L'appelant la lisait avant
59
+ -- d'appeler : un battement coûtait donc DEUX allers-retours là où un seul suffit. La lecture
60
+ -- servait à trois choses — l'existence, l'identification du présentateur, la page courante — et
61
+ -- les trois se font mieux ici, dans la même transaction que l'écriture.
62
+ select active, current_page, control_hash
63
+ into v_pres_active, v_pres_page, v_pres_hash
64
+ from public.doc_presentations where slug = p_slug;
65
+ v_pres_vue := found;
66
+
67
+ -- ⚠️ LE SIGNAL EST « L'APPELANT A-T-IL LU », PAS « PORTE-T-IL UN JETON ». Première écriture de
68
+ -- cette migration : les deux refus ci-dessous étaient conditionnés à `p_control_hash is not null`.
69
+ -- Or un participant ANONYME n'envoie aucun jeton de contrôle — c'est-à-dire l'immense majorité des
70
+ -- battements — et il aurait donc perdu le 404 et le refus d'archive que la route lui oppose
71
+ -- aujourd'hui. Une garde perdue en chemin, à l'endroit exact que l'en-tête désigne comme risqué.
72
+ --
73
+ -- Ce qui décide, c'est si l'appelant a DÉJÀ lu la présentation, et ça se lit à `p_page` : un
74
+ -- appelant ANCIEN en fournit toujours une (les deux seuls appelants du dépôt envoient page >= 1,
75
+ -- et 1 pour la sonde de schéma) ; un appelant NEUF n'en a pas, puisqu'il n'a rien lu. `p_page is
76
+ -- null` dit donc exactement « c'est à toi de trancher » — pour un anonyme comme pour un
77
+ -- présentateur, alors que le jeton de contrôle ne parlait que du second.
78
+ if p_page is null and not v_pres_vue then
79
+ return query select false, false, false, false, true, false, 0; -- introuvable, rien écrit
80
+ return;
81
+ end if;
82
+
83
+ -- ⚠️ MÊME REFUS QUE LE CODE QU'ON REMPLACE : une présentation close ET archivée (control_hash
84
+ -- effacé) n'a plus rien à mettre à jour. Le dire ici évite que la fusion perde une garde en
85
+ -- chemin — c'est le risque propre à tout déplacement de décision.
86
+ if p_page is null and v_pres_active is false and v_pres_hash is null then
87
+ return query select false, false, false, false, false, true, 0; -- archivée, rien écrit
88
+ return;
89
+ end if;
90
+
91
+ -- ⚠️ LA DÉCISION QUI CHANGE DE CAMP : « qui est présentateur » se décidait en JavaScript, par
92
+ -- comparaison du sha256 du jeton de contrôle au control_hash de la ligne. Elle se décide ici
93
+ -- désormais — sur la MÊME donnée, dans la même transaction. On garde le OU avec l'argument de
94
+ -- l'appelant : un appelant ancien continue de décider lui-même, et ne perd rien.
95
+ v_presentateur := coalesce(p_is_presenter, false)
96
+ or (p_control_hash is not null and v_pres_hash is not null and p_control_hash = v_pres_hash);
97
+
98
+ -- La page vient de la BASE quand l'appelant ne la fournit pas — il n'a plus à la lire pour nous.
99
+ v_page := greatest(1, coalesce(p_page, v_pres_page, 1));
100
+
101
+ -- On lit l'existence ET l'état « réclamée » en un seul geste : un bootstrap n'a rien à écrire sur
102
+ -- une ligne dont un porteur de jeton s'est déjà servi.
103
+ select true, (last_token_at is not null) into v_exists, v_claimed
104
+ from public.doc_presentation_attendees
105
+ where slug = p_slug and attendee_key = p_key;
106
+
107
+ if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
108
+ return query select false, false, false, true, false, false, v_page; -- usurpation : on n'écrit RIEN
109
+ return;
110
+ end if;
111
+
112
+ if not coalesce(v_exists, false) and not p_is_member and not v_presentateur then
113
+ perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
114
+ -- Re-lire sous le verrou : la ligne a pu naître entre-temps — et si elle est née RÉCLAMÉE, un
115
+ -- bootstrap concurrent ne doit pas davantage l'écraser ici qu'au premier contrôle.
116
+ select true, (last_token_at is not null) into v_exists, v_claimed
117
+ from public.doc_presentation_attendees
118
+ where slug = p_slug and attendee_key = p_key;
119
+ if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
120
+ return query select false, false, false, true, false, false, v_page;
121
+ return;
122
+ end if;
123
+ if not coalesce(v_exists, false) then
124
+ select count(*) into v_count
125
+ from public.doc_presentation_attendees
126
+ where slug = p_slug
127
+ and creator_ip_hash is not distinct from p_ip_hash
128
+ and is_member = false and is_presenter = false;
129
+ if v_count >= p_anon_cap then
130
+ return query select false, false, true, false, false, false, v_page;
131
+ return;
132
+ end if;
133
+ end if;
134
+ end if;
135
+
136
+ insert into public.doc_presentation_attendees as l
137
+ (slug, attendee_key, name, email, avatar, is_member, is_presenter,
138
+ first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
139
+ values
140
+ (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, v_presentateur,
141
+ now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
142
+ case when p_has_token is true then now() end,
143
+ case when p_has_token is false then now() end)
144
+ on conflict (slug, attendee_key) do update
145
+ set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
146
+ total_ms = l.total_ms + (
147
+ case
148
+ when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
149
+ then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
150
+ else 0
151
+ end),
152
+ pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
153
+ name = coalesce(nullif(p_name, ''), l.name),
154
+ avatar = coalesce(nullif(p_avatar, ''), l.avatar),
155
+ is_member = p_is_member,
156
+ is_presenter = v_presentateur,
157
+ last_token_at = case when p_has_token is true then now() else l.last_token_at end,
158
+ last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
159
+
160
+ return query select true, not coalesce(v_exists, false), false, false, false, false, v_page;
161
+ end;
162
+ $$;
163
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) from public;
164
+ do $$
165
+ declare
166
+ r text;
167
+ begin
168
+ foreach r in array array['anon', 'authenticated'] loop
169
+ if exists (select 1 from pg_roles where rolname = r) then
170
+ execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) from %I', r);
171
+ end if;
172
+ end loop;
173
+ end
174
+ $$;
175
+ do $$
176
+ begin
177
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
178
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean, text) to service_role;
179
+ end if;
180
+ end
181
+ $$;