discovery-media-player 0.1.87 → 0.1.89

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
@@ -92,6 +92,7 @@ Une présentation **inactive** (terminée ou abandonnée) est une archive : **pu
92
92
  | `doc_presentation_messages.client_key` | clé d'idempotence d'envoi | idem |
93
93
  | `doc_presentation_attendees.name` / `doc_presentation_attendees.email` / `doc_presentation_attendees.avatar` | identité du participant | purgées avec la présentation |
94
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) | purgée avec la présentation |
95
96
  | `doc_presentation_attendees.pages` | pages vues par le participant | idem |
96
97
 
97
98
  ## Sessions d'agent (`doc_bot_sessions`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.87",
3
+ "version": "0.1.89",
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",
@@ -731,13 +731,21 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
731
731
  // Heartbeat d'un participant : upsert de sa ligne d'assistance. On accumule le temps de présence (intervalles
732
732
  // < 60 s → un aller-retour ne gonfle pas total_ms) et l'ensemble des pages vues (page courante de la présentation).
733
733
  const ATTEND_MAX_GAP_MS = 60 * 1000;
734
+ // Plafond de création de participants ANONYMES par (présentation, IP). Défaut = ATTENDEES_PER_EGRESS
735
+ // (250, la cible de mutualisation par sortie internet) × 1,3 = 325 ; l'hôte peut le passer autrement.
736
+ // Ne borne QUE les nouvelles clés anon — jamais l'actualisation d'une clé déjà enregistrée, ni les
737
+ // membres/le présentateur (identité prouvée). Appliqué atomiquement par le RPC `player_attendance_bump`.
738
+ const PLAFOND_CREATION_ANON_DEFAUT = 325;
739
+ // L'avertissement « RPC de présence absente » ne se dit qu'UNE fois par processus : répété à chaque
740
+ // battement, il noierait le journal (un battement toutes les 25 s par participant).
741
+ let _avertRpcPresence = false;
734
742
  // ⚠️ SIGNATURE : `(slug, participant, { presentation })`. Le participant reste en 2e position — c'est
735
743
  // le contrat d'export PUBLIC (`./presentations`), un hôte tiers appelle `recordAttendance(slug,
736
744
  // participant)` à DEUX arguments. L'optimisation « présentation déjà chargée » vit dans un SAC
737
745
  // D'OPTIONS, pas dans un slot positionnel : la ranger en 2e position (ce que faisait 0.1.84) déplaçait
738
746
  // le vrai paramètre et faisait jeter `Cannot destructure property 'key'` sur un appel à deux arguments
739
747
  // (P1a audit CODEX 5.6). `participant || {}` : un appel sans participant tombe en 400, jamais en throw.
740
- async function recordAttendance(slug, participant, { presentation = null } = {}) {
748
+ async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null } = {}) {
741
749
  const { key, name, email, avatar, isMember, isPresenter } = participant || {};
742
750
  if (!slug || !key) return { ok: false, status: 400 };
743
751
  // La route vient DÉJÀ de charger la présentation (contrôle présentateur) : elle la fournit dans les
@@ -750,6 +758,43 @@ async function recordAttendance(slug, participant, { presentation = null } = {})
750
758
  // resté ouvert, longtemps après la fin.
751
759
  if (pres.active === false && pres.control_hash == null) return REFUS_ARCHIVE;
752
760
  const page = Math.max(1, Math.trunc(Number(pres.current_page) || 1));
761
+
762
+ // ⚠️ CHEMIN ATOMIQUE (migration 0015) : upsert ET plafond de création anonyme en UN geste, à l'abri
763
+ // des créations concurrentes. Absent (404, migration non appliquée) → on retombe sur la boucle
764
+ // lire-modifier-réécrire ci-dessous — toujours correcte, mais SANS le plafond — et on le dit une
765
+ // fois. Même patron que player_rate_limit_bump : dégrader, jamais casser, jamais en silence.
766
+ const capAnon = Number(anonCap) > 0 ? Math.trunc(Number(anonCap)) : PLAFOND_CREATION_ANON_DEFAUT;
767
+ try {
768
+ const r = await PLAYER.db.request("rpc/player_attendance_bump", {
769
+ method: "POST",
770
+ body: {
771
+ p_slug: String(slug), p_key: String(key), p_ip_hash: ipHash || null, p_page: page,
772
+ p_name: (name || "").slice(0, 120), p_avatar: (avatar || "").slice(0, 600),
773
+ p_is_member: !!isMember, p_is_presenter: !!isPresenter,
774
+ p_max_gap_ms: ATTEND_MAX_GAP_MS, p_anon_cap: capAnon,
775
+ },
776
+ });
777
+ const ligne = Array.isArray(r) ? r[0] : r;
778
+ if (ligne && typeof ligne.ok === "boolean") {
779
+ // Plafond de création atteint : on ne crée pas ce faux participant. 429 = « trop », pas une panne.
780
+ if (ligne.capped) return { ok: false, status: 429 };
781
+ return { ok: true };
782
+ }
783
+ // Forme inattendue : on n'invente pas de verdict, on laisse la boucle de repli faire l'écriture.
784
+ } catch (erreur) {
785
+ if (!_avertRpcPresence) {
786
+ _avertRpcPresence = true;
787
+ try {
788
+ PLAYER.errors.capture(new Error(
789
+ "présence non atomique : appliquez supabase/migrations/0015-presence-atomique.sql. "
790
+ + "Sans elle, la présence est écrite par lire-modifier-réécrire (correct) mais le plafond "
791
+ + "de création de faux participants anonymes n'est pas appliqué. "
792
+ + "(" + ((erreur && erreur.message) || erreur) + ")",
793
+ ), { route: "present-attend" });
794
+ } catch { /* jamais bloquant */ }
795
+ }
796
+ // On continue vers la boucle de repli.
797
+ }
753
798
  // ⚠️ LE DERNIER LIRE-MODIFIER-RÉÉCRIRE DU DÉPÔT, fermé comme les autres — mais SANS migration :
754
799
  // `last_seen` change à chaque battement accepté, c'est un verrou optimiste gratuit. L'écriture
755
800
  // est conditionnée à la valeur LUE ; zéro ligne = quelqu'un d'autre a battu entre-temps (l'autre
@@ -94,6 +94,14 @@ async function traiter(req, res, body, _slug) {
94
94
  // On dérive donc la clé de ce qui est prouvé, et jamais de ce qui est affirmé. Un anonyme,
95
95
  // lui, ne peut rien prouver : sa clé reste la sienne, mais enfermée dans un espace de noms
96
96
  // dont elle ne peut pas sortir — elle ne pourra jamais ressembler à l'e-mail d'un membre.
97
+ // ⚠️ L'IP NE PART PAS EN CLAIR VERS LA BASE : le plafond de création anonyme compte par
98
+ // (slug, empreinte d'IP), et une empreinte tronquée suffit à mesurer la pression d'un même
99
+ // appelant sans conserver de donnée personnelle. Le plafond lui-même est configurable par
100
+ // l'hôte, défaut ATTENDEES_PER_EGRESS × 1,3 (325) — la même cible que le quota de battement.
101
+ const ipHash = require("crypto").createHash("sha256").update("att:" + ip).digest("hex").slice(0, 32);
102
+ const anonCap = (PLAYER.config && Number(PLAYER.config.presenceAnonCap) > 0)
103
+ ? Math.trunc(Number(PLAYER.config.presenceAnonCap))
104
+ : Math.ceil(ATTENDEES_PER_EGRESS * 1.3);
97
105
  const r = await recordAttendance(String(body.slug || ""), {
98
106
  // ⚠️ MINUSCULÉE, ET CE DÉTAIL EST UNE LIGNE DE PRÉSENCE. La clé cliente que 0.1.42 a
99
107
  // remplacée l'était (`me.email.toLowerCase()`) ; la clé dérivée ne l'était pas, et la
@@ -110,7 +118,7 @@ async function traiter(req, res, body, _slug) {
110
118
  email: profil ? profil.email : body.email,
111
119
  avatar: (profil && profil.avatar) || body.avatar,
112
120
  isMember: !!profil, isPresenter: estPresentateur,
113
- }, { presentation: pres });
121
+ }, { presentation: pres, ipHash, anonCap });
114
122
  return jp(r.ok ? 200 : (r.status || 400), r);
115
123
  } catch { return jp(500, { ok: false }); }
116
124
  }
package/server/schema.js CHANGED
@@ -57,6 +57,18 @@ const ATTENDUES = {
57
57
  migration: "supabase/migrations/0006-reactions-ordonnees.sql",
58
58
  fonction: "empêcher deux réactions simultanées de s'écraser",
59
59
  },
60
+ // ⚠️ ENTRÉE DE REPORT, PAS DE GATE D'ÉCRITURE. Les six ci-dessus sont sondées par les chemins
61
+ // d'écriture (`attendue()`) pour décider s'ils écrivent la colonne. Celle-ci ne l'est que par la
62
+ // carte (`sonderTout`, `?schema=1`) : la présence dégrade sur l'ABSENCE DE LA RPC
63
+ // `player_attendance_bump` (repli sur la boucle, cf. presentations.js), pas sur cette colonne. Mais
64
+ // colonne et fonction voyagent ENSEMBLE dans la 0015 : la colonne est donc le témoin PROBEABLE que
65
+ // la 0015 est appliquée — une RPC, elle, ne se sonde pas proprement en REST sans l'appeler. Sans
66
+ // cette entrée, la carte annonçait « complet » à un hôte sans la 0015, présence dégradée (relevé ADV).
67
+ presenceAtomique: {
68
+ table: "doc_presentation_attendees", colonne: "creator_ip_hash",
69
+ migration: "supabase/migrations/0015-presence-atomique.sql",
70
+ fonction: "écrire la présence atomiquement et plafonner la création de faux participants anonymes",
71
+ },
60
72
  };
61
73
 
62
74
  /**
package/supabase/init.sql CHANGED
@@ -230,9 +230,21 @@ create table if not exists public.doc_presentation_attendees (
230
230
  last_seen timestamptz not null default now(),
231
231
  total_ms bigint not null default 0,
232
232
  pages jsonb not null default '[]'::jsonb,
233
+ -- Empreinte (jamais l'IP en clair) de l'appelant qui a CRÉÉ la ligne — plafond de création anonyme
234
+ -- par (slug, empreinte). Cf. migration 0015. Nullable : les lignes d'avant ne la portent pas.
235
+ creator_ip_hash text,
233
236
  primary key (slug, attendee_key)
234
237
  );
235
238
  create index if not exists doc_presentation_attendees_slug_idx on public.doc_presentation_attendees (slug);
239
+ -- ⚠️ RÈGLE (sixième audit) : un index sur une colonne apparue APRÈS un init publié est précédé de son
240
+ -- ALTER, ICI MÊME — sur une base d'hier, `create table if not exists` saute le corps de table et
241
+ -- `creator_ip_hash` n'existerait qu'au rattrapage de fin de fichier, trop tard pour cet index.
242
+ alter table public.doc_presentation_attendees
243
+ add column if not exists creator_ip_hash text;
244
+ -- Compter les créations anonymes par (slug, empreinte) sans scanner toute la table. Cf. migration 0015.
245
+ create index if not exists idx_attendees_slug_creator
246
+ on public.doc_presentation_attendees (slug, creator_ip_hash)
247
+ where is_member = false and is_presenter = false;
236
248
 
237
249
  -- ── Assistant IA (greffon `bot`) ───────────────────────────────────────────────────────────────
238
250
  -- Facultatif : le player fonctionne sans (PLAYER_PLUGINS_OFF=bot). La table peut rester vide.
@@ -332,6 +344,103 @@ begin
332
344
  end
333
345
  $$;
334
346
 
347
+ -- Présence en un seul geste + plafond de création anonyme atomique. Cf. migration 0015 (mêmes règles).
348
+ create or replace function public.player_attendance_bump(
349
+ p_slug text,
350
+ p_key text,
351
+ p_ip_hash text,
352
+ p_page integer,
353
+ p_name text,
354
+ p_avatar text,
355
+ p_is_member boolean,
356
+ p_is_presenter boolean,
357
+ p_max_gap_ms integer,
358
+ p_anon_cap integer
359
+ )
360
+ returns table (ok boolean, created boolean, capped boolean)
361
+ language plpgsql
362
+ security definer
363
+ set search_path = public
364
+ as $$
365
+ declare
366
+ v_exists boolean;
367
+ v_count integer;
368
+ v_page integer := greatest(1, coalesce(p_page, 1));
369
+ begin
370
+ -- Chemin rapide : la ligne existe déjà → c'est une ACTUALISATION, jamais soumise au plafond.
371
+ select true into v_exists
372
+ from public.doc_presentation_attendees
373
+ where slug = p_slug and attendee_key = p_key;
374
+
375
+ -- Création d'une ligne ANONYME : on sérialise par (slug, ip) et on compte sous le verrou, pour que
376
+ -- deux créations concurrentes ne franchissent pas le plafond ensemble. Membres et présentateur en
377
+ -- sont exemptés (leur identité est prouvée, ils ne gonflent pas de faux participants).
378
+ if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
379
+ perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
380
+ -- Re-lire sous le verrou : la ligne a pu naître entre la première lecture et ici (même clé,
381
+ -- deux onglets). Si elle existe désormais, ce n'est plus une création → pas de plafond.
382
+ select true into v_exists
383
+ from public.doc_presentation_attendees
384
+ where slug = p_slug and attendee_key = p_key;
385
+ if not coalesce(v_exists, false) then
386
+ select count(*) into v_count
387
+ from public.doc_presentation_attendees
388
+ where slug = p_slug
389
+ and creator_ip_hash is not distinct from p_ip_hash
390
+ and is_member = false and is_presenter = false;
391
+ if v_count >= p_anon_cap then
392
+ return query select false, false, true; -- plafond atteint : on ne crée pas
393
+ return;
394
+ end if;
395
+ end if;
396
+ end if;
397
+
398
+ -- L'écriture, en UN geste, pour la création comme pour l'actualisation. `last_seen` strictement
399
+ -- croissant ; le temps ajouté est le trou depuis le dernier battement, ignoré au-delà de MAX_GAP
400
+ -- (un onglet resté ouvert ne gonfle pas la présence) ; la page vue rejoint l'ensemble sans doublon.
401
+ insert into public.doc_presentation_attendees as l
402
+ (slug, attendee_key, name, email, avatar, is_member, is_presenter,
403
+ first_seen, last_seen, total_ms, pages, creator_ip_hash)
404
+ values
405
+ (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
406
+ now(), now(), 0, jsonb_build_array(v_page), p_ip_hash)
407
+ on conflict (slug, attendee_key) do update
408
+ set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
409
+ total_ms = l.total_ms + (
410
+ case
411
+ when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
412
+ then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
413
+ else 0
414
+ end),
415
+ pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
416
+ name = coalesce(nullif(p_name, ''), l.name),
417
+ avatar = coalesce(nullif(p_avatar, ''), l.avatar),
418
+ is_member = p_is_member,
419
+ is_presenter = p_is_presenter;
420
+
421
+ return query select true, not coalesce(v_exists, false), false;
422
+ end;
423
+ $$;
424
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from public;
425
+ do $$
426
+ declare
427
+ r text;
428
+ begin
429
+ foreach r in array array['anon', 'authenticated'] loop
430
+ if exists (select 1 from pg_roles where rolname = r) then
431
+ execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from %I', r);
432
+ end if;
433
+ end loop;
434
+ end
435
+ $$;
436
+ do $$
437
+ begin
438
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
439
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) to service_role;
440
+ end if;
441
+ end
442
+ $$;
443
+
335
444
  -- ── Accès ──────────────────────────────────────────────────────────────────────────────────────
336
445
  -- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
337
446
  -- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
@@ -0,0 +1,141 @@
1
+ -- LA PRÉSENCE EN UN SEUL GESTE, ET UN PLAFOND DE CRÉATION QUI TIENT SOUS LA CONCURRENCE.
2
+ --
3
+ -- Deux choses, une fonction (P1c audit CODEX 5.6).
4
+ --
5
+ -- 1. UPSERT ATOMIQUE. `recordAttendance` faisait lire-modifier-réécrire avec un verrou optimiste sur
6
+ -- `last_seen` et quatre tours de reprise. Correct, mais jusqu'à quatre allers-retours par
7
+ -- battement. Un `insert … on conflict do update` fait le même travail en UN geste : cumul du temps
8
+ -- plafonné au trou (ATTEND_MAX_GAP), union de la page vue, `last_seen` strictement croissant (la
9
+ -- même défense que la boucle JS, portée en SQL), rafraîchissement des drapeaux à chaque battement.
10
+ --
11
+ -- 2. PLAFOND DE CRÉATION ANONYME, ATOMIQUE. La clé `anon-*` est choisie par le navigateur : un même
12
+ -- visiteur peut fabriquer des milliers de clés distinctes et gonfler la liste des participants.
13
+ -- On borne le nombre de NOUVELLES lignes anonymes par (présentation, IP fiable) — jamais les
14
+ -- battements d'une clé DÉJÀ enregistrée, qui doivent toujours pouvoir s'actualiser. Les membres
15
+ -- authentifiés et le présentateur ne consomment pas ce quota. Le contrôle et l'insertion sont
16
+ -- sérialisés par un verrou d'avis par (slug, ip) : deux créations concurrentes ne peuvent pas
17
+ -- franchir le plafond ensemble.
18
+ --
19
+ -- ⚠️ POURQUOI UNE FONCTION SQL, alors que le reste du player tient en PostgREST simple : ni l'upsert
20
+ -- conditionnel (`count = count + 1` nomme la colonne des deux côtés) ni le « compter-puis-insérer
21
+ -- atomique » ne sont exprimables en REST. C'est le même motif que `player_rate_limit_bump` (0004),
22
+ -- et la garde de portabilité reste vraie : un `rpc/` n'ajoute ni jointure ni arbre booléen au REST.
23
+ --
24
+ -- ⚠️ Sans lui : le player retombe sur la boucle lire-modifier-réécrire (toujours correcte, sans le
25
+ -- plafond de création) et le dit une fois en nommant ce fichier. Rien ne casse, rien ne se ferme.
26
+ -- Applicable pendant que la version précédente tourne : tant que le code ne l'appelle pas, elle ne
27
+ -- fait rien ; dès qu'il l'appelle, elle compte juste.
28
+ --
29
+ -- ⚠️ L'IP N'EST JAMAIS STOCKÉE EN CLAIR. La colonne porte un HASH tronqué (le player le calcule), qui
30
+ -- suffit à compter la pression d'un même appelant sans conserver de donnée personnelle.
31
+
32
+ alter table public.doc_presentation_attendees
33
+ add column if not exists creator_ip_hash text;
34
+
35
+ -- Compter les créations anonymes par (slug, ip) reste rapide même sur une grande table.
36
+ create index if not exists idx_attendees_slug_creator
37
+ on public.doc_presentation_attendees (slug, creator_ip_hash)
38
+ where is_member = false and is_presenter = false;
39
+
40
+ create or replace function public.player_attendance_bump(
41
+ p_slug text,
42
+ p_key text,
43
+ p_ip_hash text,
44
+ p_page integer,
45
+ p_name text,
46
+ p_avatar text,
47
+ p_is_member boolean,
48
+ p_is_presenter boolean,
49
+ p_max_gap_ms integer,
50
+ p_anon_cap integer
51
+ )
52
+ returns table (ok boolean, created boolean, capped boolean)
53
+ language plpgsql
54
+ security definer
55
+ set search_path = public
56
+ as $$
57
+ declare
58
+ v_exists boolean;
59
+ v_count integer;
60
+ v_page integer := greatest(1, coalesce(p_page, 1));
61
+ begin
62
+ -- Chemin rapide : la ligne existe déjà → c'est une ACTUALISATION, jamais soumise au plafond.
63
+ select true into v_exists
64
+ from public.doc_presentation_attendees
65
+ where slug = p_slug and attendee_key = p_key;
66
+
67
+ -- Création d'une ligne ANONYME : on sérialise par (slug, ip) et on compte sous le verrou, pour que
68
+ -- deux créations concurrentes ne franchissent pas le plafond ensemble. Membres et présentateur en
69
+ -- sont exemptés (leur identité est prouvée, ils ne gonflent pas de faux participants).
70
+ if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
71
+ perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
72
+ -- Re-lire sous le verrou : la ligne a pu naître entre la première lecture et ici (même clé,
73
+ -- deux onglets). Si elle existe désormais, ce n'est plus une création → pas de plafond.
74
+ select true into v_exists
75
+ from public.doc_presentation_attendees
76
+ where slug = p_slug and attendee_key = p_key;
77
+ if not coalesce(v_exists, false) then
78
+ select count(*) into v_count
79
+ from public.doc_presentation_attendees
80
+ where slug = p_slug
81
+ and creator_ip_hash is not distinct from p_ip_hash
82
+ and is_member = false and is_presenter = false;
83
+ if v_count >= p_anon_cap then
84
+ return query select false, false, true; -- plafond atteint : on ne crée pas
85
+ return;
86
+ end if;
87
+ end if;
88
+ end if;
89
+
90
+ -- L'écriture, en UN geste, pour la création comme pour l'actualisation. `last_seen` strictement
91
+ -- croissant ; le temps ajouté est le trou depuis le dernier battement, ignoré au-delà de MAX_GAP
92
+ -- (un onglet resté ouvert ne gonfle pas la présence) ; la page vue rejoint l'ensemble sans doublon.
93
+ insert into public.doc_presentation_attendees as l
94
+ (slug, attendee_key, name, email, avatar, is_member, is_presenter,
95
+ first_seen, last_seen, total_ms, pages, creator_ip_hash)
96
+ values
97
+ (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
98
+ now(), now(), 0, jsonb_build_array(v_page), p_ip_hash)
99
+ on conflict (slug, attendee_key) do update
100
+ set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
101
+ total_ms = l.total_ms + (
102
+ case
103
+ when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
104
+ then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
105
+ else 0
106
+ end),
107
+ pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
108
+ name = coalesce(nullif(p_name, ''), l.name),
109
+ avatar = coalesce(nullif(p_avatar, ''), l.avatar),
110
+ is_member = p_is_member,
111
+ is_presenter = p_is_presenter;
112
+
113
+ return query select true, not coalesce(v_exists, false), false;
114
+ end;
115
+ $$;
116
+
117
+ -- Même posture d'accès que player_rate_limit_bump : le player parle avec la clé de service ; personne
118
+ -- d'autre n'a à appeler ceci. `anon`/`authenticated` sont des rôles Supabase, pas Postgres — on ne les
119
+ -- révoque que s'ils existent, pour que le fichier passe aussi sur un Postgres nu (public auto-hébergé).
120
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from public;
121
+ do $$
122
+ declare
123
+ r text;
124
+ begin
125
+ foreach r in array array['anon', 'authenticated'] loop
126
+ if exists (select 1 from pg_roles where rolname = r) then
127
+ execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from %I', r);
128
+ end if;
129
+ end loop;
130
+ end
131
+ $$;
132
+
133
+ -- Sur un Postgres nu, `revoke … from public` emporterait aussi le compte de service : on accorde donc
134
+ -- explicitement, et seulement si le rôle existe (sinon l'exploitant accorde à son propre rôle).
135
+ do $$
136
+ begin
137
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
138
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) to service_role;
139
+ end if;
140
+ end
141
+ $$;