discovery-media-player 0.1.99 → 0.1.101

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.99",
3
+ "version": "0.1.101",
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",
@@ -261,12 +261,27 @@ var Live=(function(){
261
261
  // hôte sans PLAYER_PRESENCE_SECRET n'en renvoie pas, et tout continue comme avant (legacy).
262
262
  // ⚠️ AUCUN BACKTICK DANS CE COMMENTAIRE : ce fichier ÉMET du JS via des template literals — un
263
263
  // backtick ici ferme la chaîne et casse tout le module (vu à l'instant, 33 essais rouges d'un coup).
264
+ // ⚠️ LE JETON SE PERSISTE, ET C'EST CE QUI REND LE DURCISSEMENT POSSIBLE (0018). Gardé en mémoire
265
+ // seule, il était perdu à CHAQUE rechargement de page : le client repartait alors en BOOTSTRAP avec
266
+ // sa clé habituelle (elle, persistée), ce qui rendait « bootstrap sur clé existante » banal — donc
267
+ // impossible à refuser sans casser tout rechargement. Rangé à côté de la clé de participant, il a la
268
+ // même durée de vie qu'elle : un rechargement repart avec son jeton, et un bootstrap sur une ligne
269
+ // déjà réclamée redevient franchement anormal. Stockage bridé : on retombe sur la mémoire seule, et
270
+ // le bootstrap reste possible tant que STRICT est absent.
271
+ var CLE_PT='3dd-present-pt';
272
+ function ptLu(){ if(_ptJeton)return _ptJeton; try{var s=_store();var v=s&&s.getItem(CLE_PT+':'+SLUG);if(v)_ptJeton=v;}catch(e){} return _ptJeton; }
273
+ function ptRange(v){ _ptJeton=v||''; try{var s=_store();if(s&&v)s.setItem(CLE_PT+':'+SLUG,v);}catch(e){} }
274
+ // Refus d'usurpation : cette clé appartient à quelqu'un qui détient son jeton. On ne perd PAS la
275
+ // présence en silence — on fait TOURNER notre clé pour repartir sur une ligne neuve au battement
276
+ // suivant. Une mesure qui s'arrête sans le dire est le pire des deux côtés de cette porte.
277
+ function rotationCle(){ try{var s=_store();if(s){s.removeItem('3dd-present-attkey');s.removeItem(CLE_PT+':'+SLUG);}}catch(e){} _ptJeton=''; }
264
278
  function sendAttend(){ if(!SLUG||!ME)return; try{ var h={'Content-Type':'application/json'};var jw=accessToken();if(jw)h.Authorization='Bearer '+jw;
265
279
  var corps={action:'present-attend',slug:SLUG,control:CONTROL,key:attKey(),name:ME.name||'',email:ME.email||'',avatar:ME.avatar||''};
266
- if(_ptJeton)corps.pt=_ptJeton;else corps.wantToken='1';
280
+ var pt=ptLu();
281
+ if(pt)corps.pt=pt;else corps.wantToken='1';
267
282
  fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify(corps)})
268
283
  .then(function(r){return r.json();})
269
- .then(function(d){if(d&&d.pt)_ptJeton=d.pt;})
284
+ .then(function(d){ if(d&&d.pt)ptRange(d.pt); else if(d&&d.usurpe)rotationCle(); })
270
285
  .catch(function(){}); }catch(e){} }
271
286
  var EMOJIS=['👍','❤️','😂','😮','👏','🎉'];
272
287
  var RSVG='<svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round><circle cx=12 cy=12 r=9 /><path d="M8.5 14.5s1.4 1.7 3.5 1.7 3.5-1.7 3.5-1.7"/><line x1=9 y1=9.2 x2=9.01 y2=9.2 /><line x1=15 y1=9.2 x2=15.01 y2=9.2 /></svg>';
@@ -768,7 +768,48 @@ let _avertRpcPresence = false;
768
768
  // D'OPTIONS, pas dans un slot positionnel : la ranger en 2e position (ce que faisait 0.1.84) déplaçait
769
769
  // le vrai paramètre et faisait jeter `Cannot destructure property 'key'` sur un appel à deux arguments
770
770
  // (P1a audit CODEX 5.6). `participant || {}` : un appel sans participant tombe en 400, jamais en throw.
771
- async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null } = {}) {
771
+ /**
772
+ * Appelle la RPC de présence en RÉESSAYANT le contrat plus ancien si la signature n'existe pas.
773
+ *
774
+ * ⚠️ POURQUOI UN RÉESSAI PLUTÔT QU'UNE SONDE. Une sonde de colonne est un indice INDIRECT de ce qu'on
775
+ * veut savoir (la signature de la fonction) ; ici on interroge exactement ce dont on dépend. C'est
776
+ * indispensable pour 0018, qui n'ajoute AUCUNE colonne — il n'y aurait rien à sonder.
777
+ *
778
+ * ⚠️ ET LE VERDICT SE MÉMORISE. Sans mémo, chaque battement d'un hôte non migré paierait un
779
+ * aller-retour perdu ; le chemin de la présence est chaud (un battement toutes les 25 s par
780
+ * participant). On retient donc l'ensemble d'arguments qui a marché, pour le processus.
781
+ */
782
+ let _bumpSansDurcissement = false; // 0018 absente : ne plus demander p_only_if_unclaimed
783
+ async function appelerBump(corps, durcissementVoulu) {
784
+ const appel = (b) => PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: b });
785
+ if (durcissementVoulu && _bumpSansDurcissement) {
786
+ const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
787
+ return appel(sansDurcissement);
788
+ }
789
+ try {
790
+ return await appel(corps);
791
+ } catch (erreur) {
792
+ if (!durcissementVoulu) throw erreur; // rien à retirer : l'échec est réel
793
+ // La signature à 12 arguments n'existe pas (0018 non appliquée) : on retire l'argument et on
794
+ // réessaie. Le durcissement n'est alors PAS appliqué — et ça se dit, une fois, plus bas.
795
+ _bumpSansDurcissement = true;
796
+ // ⚠️ CE QUI DISPARAÎT SE DIT. Le durcissement demandé n'est pas appliqué : sous
797
+ // PLAYER_PRESENCE_STRICT, la porte se fermerait sur les battements legacy tout en laissant un
798
+ // bootstrap auto-déclaré s'emparer d'une présence réclamée — c'est-à-dire une fermeture qui
799
+ // rassure sans protéger. On nomme donc le fichier ET la conséquence.
800
+ try {
801
+ PLAYER.errors.capture(new Error(
802
+ "bootstrap de présence NON durci : appliquez supabase/migrations/0018-bootstrap-non-usurpable.sql. "
803
+ + "Sans elle, un bootstrap auto-déclaré peut écraser une présence déjà réclamée par un porteur "
804
+ + "de jeton — n'armez pas PLAYER_PRESENCE_STRICT avant de l'avoir appliquée.",
805
+ ), { route: "present-attend" });
806
+ } catch { /* jamais bloquant */ }
807
+ const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
808
+ return appel(sansDurcissement);
809
+ }
810
+ }
811
+
812
+ async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null, onlyIfUnclaimed = false } = {}) {
772
813
  const { key, name, email, avatar, isMember, isPresenter } = participant || {};
773
814
  if (!slug || !key) return { ok: false, status: 400 };
774
815
  // La route vient DÉJÀ de charger la présentation (contrôle présentateur) : elle la fournit dans les
@@ -809,12 +850,23 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
809
850
  };
810
851
  // true → last_token_at, false → last_no_token_at, null → ni l'un ni l'autre. 0017 seulement.
811
852
  if (transitionDispo) corpsRpc.p_has_token = hasToken == null ? null : !!hasToken;
853
+ // ⚠️ LE DURCISSEMENT DU BOOTSTRAP (0018) N'A PAS DE COLONNE À SONDER — il est fonction-seule. On ne
854
+ // peut donc pas le déduire d'un indice : on le DEMANDE et on RÉESSAIE le contrat plus ancien si la
855
+ // signature n'existe pas. C'est la forme que le second hôte préférait (« elle ne peut pas se
856
+ // tromper, parce qu'elle interroge exactement ce dont elle dépend ») — l'indice indirect de 0017,
857
+ // lui, resterait vrai si colonne et fonction se désynchronisaient. Le contrat retenu est mémorisé
858
+ // pour le processus : un hôte non migré ne paie pas deux allers-retours par battement, un seul.
859
+ const durcissementVoulu = !!onlyIfUnclaimed;
860
+ if (durcissementVoulu) corpsRpc.p_only_if_unclaimed = true;
812
861
  try {
813
- const r = await PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: corpsRpc });
862
+ const r = await appelerBump(corpsRpc, durcissementVoulu);
814
863
  const ligne = Array.isArray(r) ? r[0] : r;
815
864
  if (ligne && typeof ligne.ok === "boolean") {
816
865
  // Plafond de création atteint : on ne crée pas ce faux participant. 429 = « trop », pas une panne.
817
866
  if (ligne.capped) return { ok: false, status: 429 };
867
+ // Bootstrap sur une ligne DÉJÀ RÉCLAMÉE par un porteur de jeton : rien n'a été écrit. 409 = « ce
868
+ // n'est pas à toi » — le client fera tourner sa clé plutôt que de perdre sa présence en silence.
869
+ if (ligne.usurpe) return { ok: false, status: 409, usurpe: true };
818
870
  return { ok: true };
819
871
  }
820
872
  // Forme inattendue : on n'invente pas de verdict, on laisse la boucle de repli faire l'écriture.
@@ -141,15 +141,26 @@ async function traiter(req, res, body, _slug) {
141
141
  email: profil ? profil.email : body.email,
142
142
  avatar: (profil && profil.avatar) || body.avatar,
143
143
  isMember: !!profil, isPresenter: estPresentateur,
144
- }, { presentation: pres, ipHash, anonCap, hasToken });
144
+ // ⚠️ UN BOOTSTRAP NE PEUT PAS S'EMPARER D'UNE PRÉSENCE DÉJÀ RÉCLAMÉE (0018). `wantToken`
145
+ // est AUTO-DÉCLARÉ : sans ce verrou, un attaquant le déclarait, posait la clé d'un
146
+ // participant enregistré, et écrasait sa ligne — l'usurpation même que l'étape 2 ferme
147
+ // pour les battements ordinaires. Le second hôte l'a EXÉCUTÉE sur sa prod. Le contrôle ne
148
+ // vaut que pour un bootstrap : un porteur de jeton est déjà prouvé, un membre aussi.
149
+ }, { presentation: pres, ipHash, anonCap, hasToken, onlyIfUnclaimed: bootstrap });
145
150
  // On ÉMET (ou ré-émet) un jeton pour l'anonyme dont l'écriture a réussi : le client le garde
146
151
  // et le renvoie aux battements suivants. `exp` court (6 h), ré-émis à chaque battement — pas
147
152
  // de table anti-rejeu (le scellé d'archive et l'exp bornent déjà le rejeu, cf. 0007). Un
148
153
  // membre n'en a pas besoin (son JWT le prouve). Sans PLAYER_PRESENCE_SECRET, `signPresenceToken`
149
154
  // rend "" → aucun champ `pt`, le client reste en mode legacy. Rien ne casse.
155
+ // ⚠️ SEPT JOURS, ET C'EST LA PERSISTANCE QUI FIXE LE CHIFFRE. Le jeton vit maintenant dans le
156
+ // stockage du visiteur, à côté de sa clé de participant : sa durée de vie doit couvrir un
157
+ // RETOUR, sinon l'expiration relance un bootstrap sur une ligne réclamée, qui sera refusé, et
158
+ // le visiteur repart sur une ligne neuve — une présence coupée en deux dans les statistiques
159
+ // pour rien. Le rejeu reste borné par le scellé d'archive (0007, il porte aussi sur la table
160
+ // des présences) : une présentation close refuse toute écriture, jeton valide ou non.
150
161
  let pt = "";
151
162
  if (!profil && r.ok && typeof PLAYER.identity.signPresenceToken === "function") {
152
- pt = PLAYER.identity.signPresenceToken(slug, cleAnon, 6 * 3600);
163
+ pt = PLAYER.identity.signPresenceToken(slug, cleAnon, 7 * 24 * 3600);
153
164
  }
154
165
  return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
155
166
  } catch { return jp(500, { ok: false }); }
package/supabase/init.sql CHANGED
@@ -398,27 +398,42 @@ create or replace function public.player_attendance_bump(
398
398
  p_is_presenter boolean,
399
399
  p_max_gap_ms integer,
400
400
  p_anon_cap integer,
401
- p_has_token boolean default null
401
+ p_has_token boolean default null,
402
+ p_only_if_unclaimed boolean default null
402
403
  )
403
- returns table (ok boolean, created boolean, capped boolean)
404
+ returns table (ok boolean, created boolean, capped boolean, usurpe boolean)
404
405
  language plpgsql
405
406
  security definer
406
407
  set search_path = public
407
408
  as $$
408
409
  declare
409
410
  v_exists boolean;
411
+ v_claimed boolean;
410
412
  v_count integer;
411
413
  v_page integer := greatest(1, coalesce(p_page, 1));
412
414
  begin
413
- select true into v_exists
415
+ -- On lit l'existence ET l'état « réclamée » en un seul geste : un bootstrap n'a rien à écrire sur
416
+ -- une ligne dont un porteur de jeton s'est déjà servi.
417
+ select true, (last_token_at is not null) into v_exists, v_claimed
414
418
  from public.doc_presentation_attendees
415
419
  where slug = p_slug and attendee_key = p_key;
416
420
 
421
+ 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
423
+ return;
424
+ end if;
425
+
417
426
  if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
418
427
  perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
419
- select true into v_exists
428
+ -- Re-lire sous le verrou : la ligne a pu naître entre-temps — et si elle est née RÉCLAMÉE, un
429
+ -- bootstrap concurrent ne doit pas davantage l'écraser ici qu'au premier contrôle.
430
+ select true, (last_token_at is not null) into v_exists, v_claimed
420
431
  from public.doc_presentation_attendees
421
432
  where slug = p_slug and attendee_key = p_key;
433
+ 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;
435
+ return;
436
+ end if;
422
437
  if not coalesce(v_exists, false) then
423
438
  select count(*) into v_count
424
439
  from public.doc_presentation_attendees
@@ -426,7 +441,7 @@ begin
426
441
  and creator_ip_hash is not distinct from p_ip_hash
427
442
  and is_member = false and is_presenter = false;
428
443
  if v_count >= p_anon_cap then
429
- return query select false, false, true;
444
+ return query select false, false, true, false;
430
445
  return;
431
446
  end if;
432
447
  end if;
@@ -456,17 +471,17 @@ begin
456
471
  last_token_at = case when p_has_token is true then now() else l.last_token_at end,
457
472
  last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
458
473
 
459
- return query select true, not coalesce(v_exists, false), false;
474
+ return query select true, not coalesce(v_exists, false), false, false;
460
475
  end;
461
476
  $$;
462
- revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from public;
477
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from public;
463
478
  do $$
464
479
  declare
465
480
  r text;
466
481
  begin
467
482
  foreach r in array array['anon', 'authenticated'] loop
468
483
  if exists (select 1 from pg_roles where rolname = r) then
469
- execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from %I', r);
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);
470
485
  end if;
471
486
  end loop;
472
487
  end
@@ -474,7 +489,7 @@ $$;
474
489
  do $$
475
490
  begin
476
491
  if exists (select 1 from pg_roles where rolname = 'service_role') then
477
- grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) to service_role;
492
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) to service_role;
478
493
  end if;
479
494
  end
480
495
  $$;
@@ -0,0 +1,133 @@
1
+ -- UN BOOTSTRAP NE PEUT PAS S'EMPARER D'UNE PRÉSENCE DÉJÀ RÉCLAMÉE PAR UN PORTEUR DE JETON.
2
+ --
3
+ -- ⚠️ Le résidu de l'étape 2, et le dernier verrou avant PLAYER_PRESENCE_STRICT. Un client moderne qui
4
+ -- n'a pas encore de jeton s'annonce par `wantToken` : c'est ce marqueur qui le distingue d'un client
5
+ -- legacy, et il est AUTO-DÉCLARÉ. Sous STRICT, un attaquant pouvait donc le déclarer à son tour, poser
6
+ -- la clé d'un participant déjà enregistré, et écraser sa ligne — exactement l'usurpation que l'étape 2
7
+ -- ferme pour les battements ordinaires. Le second hôte l'a EXÉCUTÉE sur sa prod (nom du titulaire
8
+ -- remplacé) : ce n'est pas une hypothèse.
9
+ --
10
+ -- La règle : une ligne qui a DÉJÀ battu avec un jeton valide (`last_token_at` renseigné) est RÉCLAMÉE.
11
+ -- Un bootstrap — par définition sans jeton — n'a rien à y écrire. Il reste libre de créer une ligne
12
+ -- neuve, et d'adopter une ligne legacy jamais réclamée (c'est le chemin de montée normal).
13
+ --
14
+ -- ⚠️ POURQUOI CE N'EST PAS UN REFUS DE RECHARGEMENT. Le client PERSISTE désormais son jeton à côté de
15
+ -- sa clé de participant (même durée de vie, même stockage) : un rechargement de page repart donc avec
16
+ -- son jeton, pas en bootstrap. Un bootstrap sur une ligne réclamée devient alors franchement anormal —
17
+ -- et le client, refusé, fait TOURNER sa clé pour repartir sur une ligne neuve plutôt que de perdre sa
18
+ -- présence en silence.
19
+ --
20
+ -- ⚠️ Sans lui : le player appelle la signature à 11 arguments (0017) ou 10 (0015) et le bootstrap n'est
21
+ -- pas contrôlé — c'est l'état d'aujourd'hui, tenable tant que PLAYER_PRESENCE_STRICT est absent.
22
+ -- N'ARMEZ PAS STRICT sans cette migration : elle est ce qui rend la fermeture utile.
23
+ -- Le player réessaie automatiquement le contrat plus ancien, donc appliquer dans n'importe quel
24
+ -- ordre est inoffensif.
25
+
26
+ drop function if exists public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean);
27
+
28
+ create or replace function public.player_attendance_bump(
29
+ p_slug text,
30
+ p_key text,
31
+ p_ip_hash text,
32
+ p_page integer,
33
+ p_name text,
34
+ p_avatar text,
35
+ p_is_member boolean,
36
+ p_is_presenter boolean,
37
+ p_max_gap_ms integer,
38
+ p_anon_cap integer,
39
+ p_has_token boolean default null,
40
+ p_only_if_unclaimed boolean default null
41
+ )
42
+ returns table (ok boolean, created boolean, capped boolean, usurpe boolean)
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_page integer := greatest(1, coalesce(p_page, 1));
52
+ begin
53
+ -- On lit l'existence ET l'état « réclamée » en un seul geste : un bootstrap n'a rien à écrire sur
54
+ -- une ligne dont un porteur de jeton s'est déjà servi.
55
+ select true, (last_token_at is not null) into v_exists, v_claimed
56
+ from public.doc_presentation_attendees
57
+ where slug = p_slug and attendee_key = p_key;
58
+
59
+ if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
60
+ return query select false, false, false, true; -- usurpation : on n'écrit RIEN
61
+ return;
62
+ end if;
63
+
64
+ if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
65
+ perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
66
+ -- Re-lire sous le verrou : la ligne a pu naître entre-temps — et si elle est née RÉCLAMÉE, un
67
+ -- bootstrap concurrent ne doit pas davantage l'écraser ici qu'au premier contrôle.
68
+ select true, (last_token_at is not null) into v_exists, v_claimed
69
+ from public.doc_presentation_attendees
70
+ where slug = p_slug and attendee_key = p_key;
71
+ if p_only_if_unclaimed is true and coalesce(v_exists, false) and coalesce(v_claimed, false) then
72
+ return query select false, false, false, true;
73
+ return;
74
+ end if;
75
+ if not coalesce(v_exists, false) then
76
+ select count(*) into v_count
77
+ from public.doc_presentation_attendees
78
+ where slug = p_slug
79
+ and creator_ip_hash is not distinct from p_ip_hash
80
+ and is_member = false and is_presenter = false;
81
+ if v_count >= p_anon_cap then
82
+ return query select false, false, true, false;
83
+ return;
84
+ end if;
85
+ end if;
86
+ end if;
87
+
88
+ insert into public.doc_presentation_attendees as l
89
+ (slug, attendee_key, name, email, avatar, is_member, is_presenter,
90
+ first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
91
+ values
92
+ (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
93
+ now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
94
+ case when p_has_token is true then now() end,
95
+ case when p_has_token is false then now() end)
96
+ on conflict (slug, attendee_key) do update
97
+ set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
98
+ total_ms = l.total_ms + (
99
+ case
100
+ when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
101
+ then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
102
+ else 0
103
+ end),
104
+ pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
105
+ name = coalesce(nullif(p_name, ''), l.name),
106
+ avatar = coalesce(nullif(p_avatar, ''), l.avatar),
107
+ is_member = p_is_member,
108
+ is_presenter = p_is_presenter,
109
+ last_token_at = case when p_has_token is true then now() else l.last_token_at end,
110
+ last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
111
+
112
+ return query select true, not coalesce(v_exists, false), false, false;
113
+ end;
114
+ $$;
115
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) from public;
116
+ do $$
117
+ declare
118
+ r text;
119
+ begin
120
+ foreach r in array array['anon', 'authenticated'] loop
121
+ if exists (select 1 from pg_roles where rolname = r) then
122
+ 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);
123
+ end if;
124
+ end loop;
125
+ end
126
+ $$;
127
+ do $$
128
+ begin
129
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
130
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean, boolean) to service_role;
131
+ end if;
132
+ end
133
+ $$;