discovery-media-player 0.1.95 → 0.1.97

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.
@@ -511,6 +511,48 @@ function createStandaloneContext(env = process.env) {
511
511
  } catch { return null; }
512
512
  },
513
513
 
514
+ /**
515
+ * JETON DE PRÉSENCE (P1c étape 2) — lie `slug + key + exp`, signé par l'hôte.
516
+ *
517
+ * ⚠️ CE QU'IL PROUVE, ET CE QU'IL NE PROUVE PAS. Il prouve que l'hôte a ÉMIS ce couple
518
+ * (slug, key) : un battement qui le porte prouve donc que son porteur détient la clé que l'hôte
519
+ * lui a donnée — assez pour empêcher un TIERS d'écraser une présence déjà enregistrée. Il ne
520
+ * prouve PAS qu'il y a une vraie personne derrière (un visiteur peut demander plusieurs jetons) :
521
+ * le plafond de création de l'étape 1 reste nécessaire.
522
+ *
523
+ * ⚠️ SECRET PAR INSTANCE, PAS PAR MARQUE. `PLAYER_PRESENCE_SECRET` est partagé par toutes les
524
+ * marques d'une même instance : un jeton émis sur l'une valide sur l'autre. Inoffensif — la vraie
525
+ * portée est le `slug`, qui est DANS la charge signée et vérifié à l'usage. Même comparaison à
526
+ * temps constant et même `exp` obligatoire que le jeton interne.
527
+ */
528
+ signPresenceToken(slug, key, ttlSeconds) {
529
+ const secret = String(env.PLAYER_PRESENCE_SECRET || "");
530
+ if (!secret || !slug || !key) return "";
531
+ const exp = Math.floor(Date.now() / 1000) + Math.max(60, Math.trunc(Number(ttlSeconds)) || 3600);
532
+ const corps = Buffer.from(JSON.stringify({ slug: String(slug), key: String(key), exp }), "utf8").toString("base64url");
533
+ const sig = crypto.createHmac("sha256", secret).update(corps).digest("base64url");
534
+ return `${corps}.${sig}`;
535
+ },
536
+ verifyPresenceToken(jeton) {
537
+ const secret = String(env.PLAYER_PRESENCE_SECRET || "");
538
+ const brut = String(jeton || "");
539
+ if (!secret || !brut) return null;
540
+ const sep = brut.lastIndexOf(".");
541
+ if (sep <= 0) return null;
542
+ const corps = brut.slice(0, sep);
543
+ const signature = brut.slice(sep + 1);
544
+ try {
545
+ const attendue = crypto.createHmac("sha256", secret).update(corps).digest("base64url");
546
+ const a = Buffer.from(attendue, "utf8"); const b = Buffer.from(signature, "utf8");
547
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
548
+ const d = JSON.parse(Buffer.from(corps, "base64url").toString("utf8"));
549
+ if (!d || typeof d !== "object") return null;
550
+ if (!Number.isFinite(d.exp) || d.exp * 1000 < Date.now()) return null;
551
+ if (!d.slug || !d.key) return null;
552
+ return { slug: String(d.slug), key: String(d.key) };
553
+ } catch { return null; }
554
+ },
555
+
514
556
  // ⚠️ Le rôle vient d'`app_metadata`, JAMAIS d'`user_metadata` : ce dernier est modifiable par
515
557
  // l'utilisateur lui-même. S'y fier revient à laisser chacun choisir ses droits.
516
558
  roleOf: (user) => String(((user || {}).app_metadata || {}).role || "").trim().toLowerCase(),
@@ -631,6 +673,9 @@ function createStandaloneContext(env = process.env) {
631
673
  hostMail: !!String(env.PLAYER_HOST_MAIL_URL || "").trim(),
632
674
  // Porte fermée : sans jeton de l'hôte, aucune session interne n'est écrite.
633
675
  internalStrict: String(env.PLAYER_INTERNAL_STRICT || "") === "1",
676
+ // Une fois les bundles déployés des deux côtés ET `presence.sansJeton` retombé à zéro : un
677
+ // battement sans jeton de présence valide cesse d'être enregistré. Off par défaut (transition).
678
+ presenceStrict: String(env.PLAYER_PRESENCE_STRICT || "") === "1",
634
679
  },
635
680
  };
636
681
  }
package/docs/RETENTION.md CHANGED
@@ -93,6 +93,7 @@ Une présentation **inactive** (terminée ou abandonnée) est une archive : **pu
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
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 |
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 |
96
97
  | `doc_presentation_attendees.pages` | pages vues par le participant | idem |
97
98
 
98
99
  ## 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.95",
3
+ "version": "0.1.97",
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",
package/server/handler.js CHANGED
@@ -595,6 +595,10 @@ async function handler(req, res) {
595
595
  // MESURABLE : un cockpit peut refuser une instance non stricte, au lieu de le découvrir en
596
596
  // lisant un journal. Demandé par le second hôte (cinquième audit, P1-4).
597
597
  internalStrict: !!(PLAYER.config && PLAYER.config.internalStrict),
598
+ // ⚠️ La porte du jeton de présence (P1c étape 2) est-elle fermée ? Même raison
599
+ // qu'internalStrict : un cockpit doit voir l'état des DEUX hôtes sans lire un journal — c'est
600
+ // ce booléen, lu avec `presence: { avecJeton, sansJeton }` de la carte, qui dit quand fermer.
601
+ presenceStrict: !!(PLAYER.config && PLAYER.config.presenceStrict),
598
602
  // ⚠️ LE BALAYAGE DE RÉTENTION EST-IL ARMÉ ? La capacité `retention` dit que l'instance PEUT
599
603
  // purger ; ce booléen dit si le balayage automatique TOURNE (`config.retention.balayage`).
600
604
  // Sans lui, une instance armée est indiscernable d'une instance éteinte — et une purge qui
@@ -768,7 +768,7 @@ 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 } = {}) {
771
+ async function recordAttendance(slug, participant, { presentation = null, ipHash = null, anonCap = null, hasToken = null } = {}) {
772
772
  const { key, name, email, avatar, isMember, isPresenter } = participant || {};
773
773
  if (!slug || !key) return { ok: false, status: 400 };
774
774
  // La route vient DÉJÀ de charger la présentation (contrôle présentateur) : elle la fournit dans les
@@ -795,6 +795,10 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
795
795
  p_name: (name || "").slice(0, 120), p_avatar: (avatar || "").slice(0, 600),
796
796
  p_is_member: !!isMember, p_is_presenter: !!isPresenter,
797
797
  p_max_gap_ms: ATTEND_MAX_GAP_MS, p_anon_cap: capAnon,
798
+ // Transition du jeton (0017) : true → last_token_at, false → last_no_token_at, null → ni l'un ni
799
+ // l'autre. La RPC 11-args (p_has_token DEFAULT null) accepte l'appel même sans la 0017 côté code
800
+ // ancien ; sans la 0017 côté base, la 11-args n'existe pas → on tombe dans le repli ci-dessous.
801
+ p_has_token: hasToken == null ? null : !!hasToken,
798
802
  },
799
803
  });
800
804
  const ligne = Array.isArray(r) ? r[0] : r;
@@ -102,7 +102,30 @@ async function traiter(req, res, body, _slug) {
102
102
  const anonCap = (PLAYER.config && Number(PLAYER.config.presenceAnonCap) > 0)
103
103
  ? Math.trunc(Number(PLAYER.config.presenceAnonCap))
104
104
  : Math.ceil(ATTENDEES_PER_EGRESS * 1.3);
105
- const r = await recordAttendance(String(body.slug || ""), {
105
+ // ⚠️ JETON DE PRÉSENCE (P1c étape 2). Pour un ANONYME, la clé de sa ligne ne vient plus du
106
+ // corps quand il porte un jeton VALIDE : elle vient du jeton PROUVÉ (que l'hôte a émis pour
107
+ // lui), donc un tiers ne peut plus poster sa clé pour écraser sa présence. `wantToken` =
108
+ // premier battement d'un client moderne (pas encore de jeton) — le distinguer d'un client
109
+ // legacy est ce qui permet à `sansJeton` de retomber à zéro. Un MEMBRE est prouvé par son JWT.
110
+ const slug = String(body.slug || "");
111
+ const jetonEntrant = (typeof PLAYER.identity.verifyPresenceToken === "function")
112
+ ? PLAYER.identity.verifyPresenceToken(String(body.pt || "")) : null;
113
+ const jetonValide = !!(jetonEntrant && jetonEntrant.slug === slug && jetonEntrant.key);
114
+ const bootstrap = !profil && !jetonValide && String(body.wantToken || "") === "1";
115
+ const cleAnon = jetonValide ? String(jetonEntrant.key) : cleAnonyme(body.key);
116
+ // Le compteur de transition : true = moderne/prouvé (membre, jeton, ou bootstrap moderne) ;
117
+ // false = legacy (anonyme, ni jeton ni wantToken). Deux champs distincts en base (0017).
118
+ const hasToken = !!(profil || jetonValide || bootstrap);
119
+ // ⚠️ PORTE STRICTE : une fois la transition finie (sansJeton===0) et PLAYER_PRESENCE_STRICT
120
+ // posé, un battement LEGACY (anonyme, sans jeton ni bootstrap) n'est plus enregistré. Off par
121
+ // défaut → aucun effet pendant la transition. ⚠️ RÉSIDU CONNU (à durcir en suivant) : un
122
+ // bootstrap `wantToken` portant la clé d'un anonyme EXISTANT pourrait l'écraser sous strict —
123
+ // exploitabilité faible (l'attaquant doit connaître un uid anonyme aléatoire, jamais exposé) ;
124
+ // le fermer proprement demande à la RPC de refuser un bootstrap sur une clé déjà existante.
125
+ if (PLAYER.config && PLAYER.config.presenceStrict && !profil && !jetonValide && !bootstrap) {
126
+ return jp(403, { ok: false, error: "presence-token" });
127
+ }
128
+ const r = await recordAttendance(slug, {
106
129
  // ⚠️ MINUSCULÉE, ET CE DÉTAIL EST UNE LIGNE DE PRÉSENCE. La clé cliente que 0.1.42 a
107
130
  // remplacée l'était (`me.email.toLowerCase()`) ; la clé dérivée ne l'était pas, et la
108
131
  // ligne se retrouve par `attendee_key=eq.` — une correspondance EXACTE. Un hôte dont
@@ -113,13 +136,22 @@ async function traiter(req, res, body, _slug) {
113
136
  // Sans effet là où les adresses sont déjà normalisées — c'est le cas des deux hôtes
114
137
  // actuels, et c'est pour ça que rien ne l'aurait signalé. Un contrat ouvert ne se
115
138
  // repose pas sur ce que ses deux premiers hôtes font.
116
- key: profil ? lcMembre(profil.email) : cleAnonyme(body.key),
139
+ key: profil ? lcMembre(profil.email) : cleAnon,
117
140
  name: (profil && profil.name) || body.name,
118
141
  email: profil ? profil.email : body.email,
119
142
  avatar: (profil && profil.avatar) || body.avatar,
120
143
  isMember: !!profil, isPresenter: estPresentateur,
121
- }, { presentation: pres, ipHash, anonCap });
122
- return jp(r.ok ? 200 : (r.status || 400), r);
144
+ }, { presentation: pres, ipHash, anonCap, hasToken });
145
+ // On ÉMET (ou ré-émet) un jeton pour l'anonyme dont l'écriture a réussi : le client le garde
146
+ // et le renvoie aux battements suivants. `exp` court (6 h), ré-émis à chaque battement — pas
147
+ // de table anti-rejeu (le scellé d'archive et l'exp bornent déjà le rejeu, cf. 0007). Un
148
+ // membre n'en a pas besoin (son JWT le prouve). Sans PLAYER_PRESENCE_SECRET, `signPresenceToken`
149
+ // rend "" → aucun champ `pt`, le client reste en mode legacy. Rien ne casse.
150
+ let pt = "";
151
+ if (!profil && r.ok && typeof PLAYER.identity.signPresenceToken === "function") {
152
+ pt = PLAYER.identity.signPresenceToken(slug, cleAnon, 6 * 3600);
153
+ }
154
+ return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
123
155
  } catch { return jp(500, { ok: false }); }
124
156
  }
125
157
  // Gestion des présentations (membre AUTHENTIFIÉ requis) : liste / reprise / transfert / stats / historique doc.
package/server/schema.js CHANGED
@@ -77,6 +77,15 @@ const ATTENDUES = {
77
77
  migration: "supabase/migrations/0016-chat-differentiel.sql",
78
78
  fonction: "relire le chat en différentiel (mod_seq > curseur) au lieu des 300 derniers à chaque signal",
79
79
  },
80
+ // Jeton de présence (P1c étape 2). `last_token_at` est sondée AVANT d'écrire les colonnes de
81
+ // transition et de servir le compteur `presence`. `last_no_token_at` voyage avec elle dans 0017 (elle
82
+ // est donc exemptée de sonde séparée, cf. NON_SONDEES du test carteSchemaMigrations). Absentes → on
83
+ // n'écrit pas les colonnes et le compteur n'apparaît pas ; la présence continue de fonctionner.
84
+ jetonPresence: {
85
+ table: "doc_presentation_attendees", colonne: "last_token_at",
86
+ migration: "supabase/migrations/0017-jeton-presence.sql",
87
+ fonction: "mesurer la transition du jeton de présence (avecJeton / sansJeton) pour savoir quand fermer",
88
+ },
80
89
  };
81
90
 
82
91
  /**
@@ -305,6 +314,7 @@ async function vraimentSonderTout() {
305
314
  for (const nom of Object.keys(ATTENDUES)) await attendue(nom);
306
315
  const etat = etatDuSchema();
307
316
  await ajouterSansRang(etat);
317
+ await ajouterPresence(etat);
308
318
  return etat;
309
319
  }
310
320
 
@@ -359,6 +369,35 @@ async function ajouterSansRang(etat) {
359
369
  } catch { /* best-effort : un compteur absent vaut mieux qu'une carte en échec */ }
360
370
  }
361
371
 
372
+ /**
373
+ * ⚠️ COMPTEUR DE TRANSITION DU JETON DE PRÉSENCE (P1c étape 2). `presence: { avecJeton, sansJeton }`
374
+ * sur 24 h : `sansJeton === 0` ⇒ plus aucun client legacy ne bat → on peut poser PLAYER_PRESENCE_STRICT.
375
+ * Sans ce compteur, poser le drapeau reviendrait à fermer la porte sans regarder qui est derrière.
376
+ *
377
+ * ⚠️ LES DEUX ENSEMBLES SE RECOUVRENT VOLONTAIREMENT. Un participant qui a envoyé les DEUX sortes de
378
+ * battements dans la fenêtre (deux onglets, l'un en cache legacy, l'autre à jour) compte dans les DEUX
379
+ * — c'est le cœur du correctif à deux champs. Donc `avecJeton + sansJeton ≠ nombre de participants` :
380
+ * quelqu'un qui « corrigerait » l'apparente incohérence en rendant les deux exclusifs ferait revenir le
381
+ * défaut d'origine (un seul battement moderne effacerait la trace legacy). Le critère est en ANY :
382
+ * `sansJeton` = « a battu sans jeton dans les 24 h », vrai quoi qu'aient fait ses autres battements.
383
+ *
384
+ * Même patron borné-ordonné-parlant que `sansRang`, et pour les mêmes raisons — `doc_presentation_attendees`
385
+ * est la plus grosse table et la seule écrite pendant qu'on la lit.
386
+ */
387
+ async function ajouterPresence(etat) {
388
+ const r = reponses.get("doc_presentation_attendees.last_token_at");
389
+ if (!r || !r.present) return;
390
+ try {
391
+ const depuis = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
392
+ const bornee = `&order=slug.asc&limit=${PLAFOND_SANS_RANG}`;
393
+ const avec = await PLAYER.db.request(`doc_presentation_attendees?last_token_at=gt.${encodeURIComponent(depuis)}&select=slug${bornee}`);
394
+ const sans = await PLAYER.db.request(`doc_presentation_attendees?last_no_token_at=gt.${encodeURIComponent(depuis)}&select=slug${bornee}`);
395
+ const nAvec = Array.isArray(avec) ? avec.length : 0;
396
+ const nSans = Array.isArray(sans) ? sans.length : 0;
397
+ etat.presence = { avecJeton: nAvec, sansJeton: nSans, tronque: nAvec >= PLAFOND_SANS_RANG || nSans >= PLAFOND_SANS_RANG };
398
+ } catch { /* best-effort */ }
399
+ }
400
+
362
401
  /** La réponse, retenue pour qui la demandera — sans repasser par la base. */
363
402
  function noter(cle, present, migration) {
364
403
  const a = Object.values(ATTENDUES).find((x) => `${x.table}.${x.colonne}` === cle);
package/supabase/init.sql CHANGED
@@ -267,6 +267,10 @@ create table if not exists public.doc_presentation_attendees (
267
267
  -- Empreinte (jamais l'IP en clair) de l'appelant qui a CRÉÉ la ligne — plafond de création anonyme
268
268
  -- par (slug, empreinte). Cf. migration 0015. Nullable : les lignes d'avant ne la portent pas.
269
269
  creator_ip_hash text,
270
+ -- Transition du jeton de présence (migration 0017) : dernier battement AVEC / SANS jeton valide.
271
+ -- Deux champs, pas un — cf. 0017 : la mesure est en max, la décision en any.
272
+ last_token_at timestamptz,
273
+ last_no_token_at timestamptz,
270
274
  primary key (slug, attendee_key)
271
275
  );
272
276
  create index if not exists doc_presentation_attendees_slug_idx on public.doc_presentation_attendees (slug);
@@ -275,6 +279,10 @@ create index if not exists doc_presentation_attendees_slug_idx on public.doc_pre
275
279
  -- `creator_ip_hash` n'existerait qu'au rattrapage de fin de fichier, trop tard pour cet index.
276
280
  alter table public.doc_presentation_attendees
277
281
  add column if not exists creator_ip_hash text;
282
+ alter table public.doc_presentation_attendees
283
+ add column if not exists last_token_at timestamptz;
284
+ alter table public.doc_presentation_attendees
285
+ add column if not exists last_no_token_at timestamptz;
278
286
  -- Compter les créations anonymes par (slug, empreinte) sans scanner toute la table. Cf. migration 0015.
279
287
  create index if not exists idx_attendees_slug_creator
280
288
  on public.doc_presentation_attendees (slug, creator_ip_hash)
@@ -389,7 +397,8 @@ create or replace function public.player_attendance_bump(
389
397
  p_is_member boolean,
390
398
  p_is_presenter boolean,
391
399
  p_max_gap_ms integer,
392
- p_anon_cap integer
400
+ p_anon_cap integer,
401
+ p_has_token boolean default null
393
402
  )
394
403
  returns table (ok boolean, created boolean, capped boolean)
395
404
  language plpgsql
@@ -401,18 +410,12 @@ declare
401
410
  v_count integer;
402
411
  v_page integer := greatest(1, coalesce(p_page, 1));
403
412
  begin
404
- -- Chemin rapide : la ligne existe déjà → c'est une ACTUALISATION, jamais soumise au plafond.
405
413
  select true into v_exists
406
414
  from public.doc_presentation_attendees
407
415
  where slug = p_slug and attendee_key = p_key;
408
416
 
409
- -- Création d'une ligne ANONYME : on sérialise par (slug, ip) et on compte sous le verrou, pour que
410
- -- deux créations concurrentes ne franchissent pas le plafond ensemble. Membres et présentateur en
411
- -- sont exemptés (leur identité est prouvée, ils ne gonflent pas de faux participants).
412
417
  if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
413
418
  perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
414
- -- Re-lire sous le verrou : la ligne a pu naître entre la première lecture et ici (même clé,
415
- -- deux onglets). Si elle existe désormais, ce n'est plus une création → pas de plafond.
416
419
  select true into v_exists
417
420
  from public.doc_presentation_attendees
418
421
  where slug = p_slug and attendee_key = p_key;
@@ -423,21 +426,20 @@ begin
423
426
  and creator_ip_hash is not distinct from p_ip_hash
424
427
  and is_member = false and is_presenter = false;
425
428
  if v_count >= p_anon_cap then
426
- return query select false, false, true; -- plafond atteint : on ne crée pas
429
+ return query select false, false, true;
427
430
  return;
428
431
  end if;
429
432
  end if;
430
433
  end if;
431
434
 
432
- -- L'écriture, en UN geste, pour la création comme pour l'actualisation. `last_seen` strictement
433
- -- croissant ; le temps ajouté est le trou depuis le dernier battement, ignoré au-delà de MAX_GAP
434
- -- (un onglet resté ouvert ne gonfle pas la présence) ; la page vue rejoint l'ensemble sans doublon.
435
435
  insert into public.doc_presentation_attendees as l
436
436
  (slug, attendee_key, name, email, avatar, is_member, is_presenter,
437
- first_seen, last_seen, total_ms, pages, creator_ip_hash)
437
+ first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
438
438
  values
439
439
  (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
440
- now(), now(), 0, jsonb_build_array(v_page), p_ip_hash)
440
+ now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
441
+ case when p_has_token is true then now() end,
442
+ case when p_has_token is false then now() end)
441
443
  on conflict (slug, attendee_key) do update
442
444
  set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
443
445
  total_ms = l.total_ms + (
@@ -450,19 +452,21 @@ begin
450
452
  name = coalesce(nullif(p_name, ''), l.name),
451
453
  avatar = coalesce(nullif(p_avatar, ''), l.avatar),
452
454
  is_member = p_is_member,
453
- is_presenter = p_is_presenter;
455
+ is_presenter = p_is_presenter,
456
+ last_token_at = case when p_has_token is true then now() else l.last_token_at end,
457
+ last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
454
458
 
455
459
  return query select true, not coalesce(v_exists, false), false;
456
460
  end;
457
461
  $$;
458
- revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from public;
462
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from public;
459
463
  do $$
460
464
  declare
461
465
  r text;
462
466
  begin
463
467
  foreach r in array array['anon', 'authenticated'] loop
464
468
  if exists (select 1 from pg_roles where rolname = r) then
465
- execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) from %I', r);
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);
466
470
  end if;
467
471
  end loop;
468
472
  end
@@ -470,7 +474,7 @@ $$;
470
474
  do $$
471
475
  begin
472
476
  if exists (select 1 from pg_roles where rolname = 'service_role') then
473
- grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer) to service_role;
477
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) to service_role;
474
478
  end if;
475
479
  end
476
480
  $$;
@@ -0,0 +1,122 @@
1
+ -- LA PRÉSENCE GARDE LA TRACE DE CE QUI EST PROUVÉ — POUR SAVOIR QUAND FERMER LA PORTE.
2
+ --
3
+ -- ⚠️ P1c étape 2 (jeton participant signé), incrément 2. Deux colonnes de transition sur la ligne de
4
+ -- présence : `last_token_at` (dernier battement AVEC un jeton valide) et `last_no_token_at` (dernier
5
+ -- battement SANS). Le compteur de la carte lira `presence: { avecJeton, sansJeton }` sur 24 h : quand
6
+ -- `sansJeton` retombe à zéro, plus aucun client legacy ne bat, et on peut poser PLAYER_PRESENCE_STRICT.
7
+ --
8
+ -- ⚠️ DEUX CHAMPS, PAS UN — c'est le cœur du correctif (relevé 2e hôte). `last_token_at` seul est un
9
+ -- champ de MAXIMUM : un seul battement moderne l'écrase et efface la trace des battements legacy du
10
+ -- MÊME participant → `sansJeton` tomberait à zéro pendant que le legacy passe encore. La MESURE est en
11
+ -- max, la DÉCISION est en ANY : il faut le second champ pour que « a-t-il battu sans jeton dans la
12
+ -- fenêtre ? » reste vrai quoi qu'aient fait ses autres battements. Les deux ensembles se recouvrent
13
+ -- volontairement (un participant qui a fait les deux compte dans les deux).
14
+ --
15
+ -- ⚠️ LA RPC CHANGE DE SIGNATURE SANS CASSER LE CONTRAT DE 0015. On DROP l'ancienne (10 args) et on crée
16
+ -- la nouvelle avec un 11e paramètre `p_has_token boolean DEFAULT null` : un appel à 10 arguments de
17
+ -- l'ANCIEN code se résout alors sur la nouvelle (le défaut remplit le 11e), sans surcharge ambiguë ni
18
+ -- corps dupliqué. `p_has_token` : true → écrit `last_token_at` ; false → `last_no_token_at` ; null
19
+ -- (ancien code) → ni l'un ni l'autre. Le reste du corps est IDENTIQUE à 0015.
20
+ --
21
+ -- ⚠️ Sans lui : le player sonde `last_token_at` (server/schema.js) ; absente, il n'écrit pas les
22
+ -- colonnes de transition et le compteur `presence` n'apparaît pas — la présence continue de
23
+ -- fonctionner. Additive ; le DROP+CREATE de la fonction est atomique dans la migration.
24
+
25
+ alter table public.doc_presentation_attendees
26
+ add column if not exists last_token_at timestamptz;
27
+ alter table public.doc_presentation_attendees
28
+ add column if not exists last_no_token_at timestamptz;
29
+
30
+ drop function if exists public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer);
31
+
32
+ create or replace function public.player_attendance_bump(
33
+ p_slug text,
34
+ p_key text,
35
+ p_ip_hash text,
36
+ p_page integer,
37
+ p_name text,
38
+ p_avatar text,
39
+ p_is_member boolean,
40
+ p_is_presenter boolean,
41
+ p_max_gap_ms integer,
42
+ p_anon_cap integer,
43
+ p_has_token boolean default null
44
+ )
45
+ returns table (ok boolean, created boolean, capped boolean)
46
+ language plpgsql
47
+ security definer
48
+ set search_path = public
49
+ as $$
50
+ declare
51
+ v_exists boolean;
52
+ v_count integer;
53
+ v_page integer := greatest(1, coalesce(p_page, 1));
54
+ begin
55
+ select true into v_exists
56
+ from public.doc_presentation_attendees
57
+ where slug = p_slug and attendee_key = p_key;
58
+
59
+ if not coalesce(v_exists, false) and not p_is_member and not p_is_presenter then
60
+ perform pg_advisory_xact_lock(hashtextextended(p_slug || '|' || coalesce(p_ip_hash, ''), 0));
61
+ select true into v_exists
62
+ from public.doc_presentation_attendees
63
+ where slug = p_slug and attendee_key = p_key;
64
+ if not coalesce(v_exists, false) then
65
+ select count(*) into v_count
66
+ from public.doc_presentation_attendees
67
+ where slug = p_slug
68
+ and creator_ip_hash is not distinct from p_ip_hash
69
+ and is_member = false and is_presenter = false;
70
+ if v_count >= p_anon_cap then
71
+ return query select false, false, true;
72
+ return;
73
+ end if;
74
+ end if;
75
+ end if;
76
+
77
+ insert into public.doc_presentation_attendees as l
78
+ (slug, attendee_key, name, email, avatar, is_member, is_presenter,
79
+ first_seen, last_seen, total_ms, pages, creator_ip_hash, last_token_at, last_no_token_at)
80
+ values
81
+ (p_slug, p_key, nullif(p_name, ''), null, nullif(p_avatar, ''), p_is_member, p_is_presenter,
82
+ now(), now(), 0, jsonb_build_array(v_page), p_ip_hash,
83
+ case when p_has_token is true then now() end,
84
+ case when p_has_token is false then now() end)
85
+ on conflict (slug, attendee_key) do update
86
+ set last_seen = greatest(now(), l.last_seen + interval '1 millisecond'),
87
+ total_ms = l.total_ms + (
88
+ case
89
+ when (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000) <= p_max_gap_ms
90
+ then (extract(epoch from (greatest(now(), l.last_seen + interval '1 millisecond') - l.last_seen)) * 1000)::bigint
91
+ else 0
92
+ end),
93
+ pages = case when l.pages @> to_jsonb(v_page) then l.pages else l.pages || to_jsonb(v_page) end,
94
+ name = coalesce(nullif(p_name, ''), l.name),
95
+ avatar = coalesce(nullif(p_avatar, ''), l.avatar),
96
+ is_member = p_is_member,
97
+ is_presenter = p_is_presenter,
98
+ last_token_at = case when p_has_token is true then now() else l.last_token_at end,
99
+ last_no_token_at = case when p_has_token is false then now() else l.last_no_token_at end;
100
+
101
+ return query select true, not coalesce(v_exists, false), false;
102
+ end;
103
+ $$;
104
+ revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from public;
105
+ do $$
106
+ declare
107
+ r text;
108
+ begin
109
+ foreach r in array array['anon', 'authenticated'] loop
110
+ if exists (select 1 from pg_roles where rolname = r) then
111
+ execute format('revoke all on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) from %I', r);
112
+ end if;
113
+ end loop;
114
+ end
115
+ $$;
116
+ do $$
117
+ begin
118
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
119
+ grant execute on function public.player_attendance_bump(text, text, text, integer, text, text, boolean, boolean, integer, integer, boolean) to service_role;
120
+ end if;
121
+ end
122
+ $$;