discovery-media-player 0.1.163 → 0.1.164

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.
@@ -56,12 +56,43 @@ function sansBarreFinale(valeur) {
56
56
  * consultations protégées.
57
57
  *
58
58
  * ⚠️ ET UNE COURSE DE PROMESSES NE SUFFIRAIT PAS : elle rendrait la main sans ANNULER le `fetch`,
59
- * donc sans rien libérer. C'est `AbortSignal` ou rien. Un signal fourni par l'appelant a priorité —
60
- * un hôte qui borne lui-même une opération longue n'est pas écrasé.
59
+ * donc sans rien libérer. C'est `AbortSignal` ou rien. Un signal fourni par l'appelant s'AJOUTE au
60
+ * plancher — voir `composerSignaux` : le premier des deux qui parle gagne.
61
61
  */
62
+ /**
63
+ * ⚠️ ON COMPOSE LES SIGNAUX, ON NE LES REMPLACE PAS — ET LA PREMIÈRE ÉCRITURE LES REMPLAÇAIT.
64
+ *
65
+ * Elle disait `options.signal || AbortSignal.timeout(delai)` : un signal fourni par l'appelant
66
+ * SUPPRIMAIT le plancher, au lieu de s'y ajouter. Un hôte qui borne lui-même une opération longue
67
+ * croyait donc ajouter une garantie, et en retirait une. Mesuré : avec un signal qui n'expire jamais
68
+ * et `timeoutMs: 20`, la promesse est encore en attente après 150 ms.
69
+ *
70
+ * ⚠️ ET LE COMMENTAIRE BÉNISSAIT LE DÉFAUT. Il écrivait « un signal fourni par l'appelant a
71
+ * priorité — un hôte qui borne lui-même une opération longue n'est pas écrasé ». L'intention est
72
+ * juste ; « a priorité » était la mauvaise traduction. Le premier des deux qui parle gagne : c'est
73
+ * ce que « borner » veut dire. Rapporté par un audit externe le 12/09 comme défaut LATENT — aucun
74
+ * appel du produit ne transmet aujourd'hui de signal, donc personne ne l'aurait vu arriver.
75
+ */
76
+ function composerSignaux(fourni, delaiMs) {
77
+ const horloge = (typeof AbortSignal !== "undefined" && AbortSignal.timeout)
78
+ ? AbortSignal.timeout(delaiMs) : undefined;
79
+ if (!fourni) return horloge;
80
+ if (!horloge) return fourni;
81
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([fourni, horloge]);
82
+ // ⚠️ REPLI SANS `AbortSignal.any` : un contrôleur qui suit les deux. Le `aborted` se teste AVANT
83
+ // de s'abonner — un signal déjà déclenché n'émettra plus jamais son événement, et l'attendre
84
+ // serait une attente infinie posée par la précaution elle-même.
85
+ const relais = new globalThis.AbortController();
86
+ const abandonner = () => { try { relais.abort(); } catch { /* déjà abandonné */ } };
87
+ for (const s of [fourni, horloge]) {
88
+ if (s.aborted) { abandonner(); break; }
89
+ try { s.addEventListener("abort", abandonner, { once: true }); } catch { /* signal exotique */ }
90
+ }
91
+ return relais.signal;
92
+ }
93
+
62
94
  function fetchBorne(cible, options = {}, delaiMs) {
63
- const signal = options.signal
64
- || (typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(delaiMs) : undefined);
95
+ const signal = composerSignaux(options.signal, delaiMs);
65
96
  return fetch(cible, { ...options, ...(signal ? { signal } : {}) });
66
97
  }
67
98
 
@@ -243,13 +274,33 @@ async function appelHote(url, secret, corps, errors) {
243
274
  * base. Y adosser un compteur partagé ferait payer à la garde le prix qu'on venait d'épargner à ce
244
275
  * qu'elle garde. Sur ce chemin, la protection réelle est le cache, pas le compteur.
245
276
  *
246
- * ⚠️ LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE. PostgREST ne sait pas exprimer « incrémente » : c'est une
247
- * lecture puis une écriture. Deux instances peuvent donc lire la même valeur et n'en écrire qu'une —
248
- * le compteur SOUS-estime sous forte concurrence. Pour une limite de débit, sous-estimer signifie
249
- * laisser passer un peu plus, jamais refuser à tort. Le dire vaut mieux que laisser croire à une
250
- * exactitude qu'on n'a pas.
277
+ * ⚠️ CE PARAGRAPHE DISAIT QUE LE COMPTE PARTAGÉ N'EST PAS ATOMIQUE. C'EST FAUX DEPUIS 0004, et il
278
+ * a survécu à ce qu'il décrivait — écrit quand PostgREST ne savait pas exprimer « incrémente »,
279
+ * donc quand compter était une lecture puis une écriture. La migration `0004-limites-atomiques.sql`
280
+ * a remplacé les deux par UNE instruction serveur : `player_rate_limit_bump`. Corrigé plutôt que
281
+ * supprimé, parce qu'un hôte qui l'a lu a pu bâtir une compensation dont il n'a pas besoin.
282
+ *
283
+ * ⚠️ ET LA DÉGRADATION RÉELLE EST L'INVERSE DE CE QU'IL LAISSAIT CROIRE. Sans 0004, l'étage partagé
284
+ * ne compte pas moins bien : IL NE COMPTE PAS DU TOUT. Le `return true` plus bas laisse passer, et
285
+ * seul le compteur LOCAL, par processus, subsiste — une limite de 120/h en autorise 120 PAR
286
+ * EXÉCUTION. « Non atomique » nommait un mode qui n'existe pas : un comptage partagé dégradé.
287
+ *
288
+ * La matrice complète est dans `docs/HOST-CONTRACT.md` ; elle est la version qui fait foi.
289
+ *
290
+ * ⚠️ CETTE PHRASE-CI EST LE JUMEAU FRANÇAIS DE CELLE CORRIGÉE DANS LE CONTRAT LE 11/09 — À 95
291
+ * LIGNES DE L'AVERTISSEMENT CORRIGÉ LE MÊME JOUR, DANS CE MÊME FICHIER. Corriger un exemplaire
292
+ * d'une affirmation et pas l'autre est le mode de panne que ce dépôt traque partout ailleurs : deux
293
+ * copies d'une règle divergent, et personne ne les confronte. Cherchez le MÉCANISME que vous venez
294
+ * de changer, pas les mots dont vous vous souvenez.
295
+ */
296
+ /**
297
+ * @param horloge lecture du temps, injectable. ⚠️ ELLE EXISTE POUR QU'UN BANC N'AIT PAS À REMPLACER
298
+ * `Date.now` GLOBALEMENT. Une simulation d'une heure d'audience doit faire avancer le temps ; le
299
+ * seul moyen était de rustiner un global, ce qui laisse l'instrument dépendre d'un `finally` posé au
300
+ * bon endroit — et la première écriture de cette simulation l'avait posé au mauvais, mesurant en
301
+ * partie le temps RÉEL sans le dire. Une horloge passée en argument ne peut pas fuir.
251
302
  */
252
- function creerLimites(db, journal) {
303
+ function creerLimites(db, journal, horloge = () => Date.now()) {
253
304
  const seaux = new Map();
254
305
  const PREFIXES_LOCAUX = ["pread:"];
255
306
  let partageDisponible = null; // null = pas encore demandé
@@ -263,7 +314,7 @@ function creerLimites(db, journal) {
263
314
  // anti-inondation, et c'est le compromis explicitement recommandé.
264
315
  const PLAFOND_CLES = 5000;
265
316
  function localAutorise(cle, max, fenetreSecondes) {
266
- const maintenant = Date.now();
317
+ const maintenant = horloge();
267
318
  const fenetreMs = fenetreSecondes * 1000;
268
319
  let e = seaux.get(cle);
269
320
  if (!e || maintenant - e.debut >= fenetreMs) e = { debut: maintenant, compte: 0 };
@@ -390,7 +441,23 @@ function createStandaloneContext(env = process.env) {
390
441
  // ⚠️ DERNIÈRE BARRIÈRE avant un DELETE à la clé service_role (P1 huitième audit). Bucket en
391
442
  // liste blanche, et refus de toute traversée — chaque segment sur l'alphabet des chemins
392
443
  // signés. `fetch` normalise `..` : un chemin non validé sortirait du bucket visé.
393
- if (bucket !== "present-attachments") return false;
444
+ //
445
+ // ⚠️ LA LISTE NE PORTAIT QU'UN BUCKET SUR LES DEUX, ET LA PURGE DU CACHE DE VOIX N'A DONC
446
+ // JAMAIS RIEN RETIRÉ. `tts-cache` était refusé ICI, avant tout appel réseau : chaque retrait
447
+ // rendait `false`, la trace partait quand même, et l'objet restait dans un bucket PUBLIC
448
+ // sans plus aucun chemin vers lui — puisque cette capacité expose `put` et `remove`, jamais
449
+ // `list`. C'est très exactement le mal que la migration 0021 avait été écrite pour rendre
450
+ // réparable, à 100 %, en silence.
451
+ //
452
+ // ⚠️ ET CE SILENCE ÉTAIT DOCUMENTÉ. Le rapport comptait ces refus dans `fichiersErreur`, que
453
+ // `docs/RETENTION.md` explique par un fait vrai — un tiers des empreintes n'a pas de `.json`
454
+ // d'alignement (552 mp3 pour 356 json, mesuré par un hôte). Une explication JUSTE rendait
455
+ // donc un échec TOTAL indiscernable d'un fonctionnement normal. Trouvé le 12/09 en écrivant
456
+ // la documentation du correctif d'un AUTRE défaut du même chemin.
457
+ //
458
+ // La liste énumère maintenant les deux buckets que la rétention doit atteindre, et rien
459
+ // d'autre : la barrière garde son objet, elle cesse d'interdire le travail qu'on lui demande.
460
+ if (bucket !== "present-attachments" && bucket !== "tts-cache") return false;
394
461
  const segs = String(chemin).split("/");
395
462
  for (const seg of segs) {
396
463
  if (seg === "" || seg === "." || seg === ".." || !/^[A-Za-z0-9._-]+$/.test(seg)) return false;
@@ -399,7 +466,23 @@ function createStandaloneContext(env = process.env) {
399
466
  const r = await fetchBorne(`${base}/storage/v1/object/${encodeURIComponent(bucket)}/${segs.map(encodeURIComponent).join("/")}`, {
400
467
  method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
401
468
  }, DELAI_STOCKAGE_MS);
402
- return r.ok;
469
+ if (r.ok) return true;
470
+ // ⚠️ UN OBJET DÉJÀ ABSENT EST UN SUCCÈS POUR CE QU'ON DEMANDE ICI, ET CE N'EST PLUS UNE
471
+ // NUANCE DE COMPTAGE. La purge RETIENT désormais la ligne quand ce
472
+ // retrait rend `false`, parce que la ligne est le seul chemin vers l'objet (`storage`
473
+ // expose `put` et `remove`, jamais `list`). Rendre `false` sur un objet qui n'est plus là
474
+ // retiendrait donc la ligne POUR TOUJOURS, en attendant un fichier qui n'existe pas —
475
+ // exactement la sur-rétention que le correctif de la sous-rétention ne doit pas créer.
476
+ // Ce qu'on demande est « l'objet n'est plus là », et il n'y est plus.
477
+ //
478
+ // ⚠️ ON LIT LE CORPS PARCE QUE LE CODE NE SUFFIT PAS. Le Storage de Supabase répond 400
479
+ // sur un objet manquant, pas seulement 404 : se fier au seul statut raterait le cas le
480
+ // plus fréquent. NON VÉRIFIÉ CONTRE UN SUPABASE VIVANT DEPUIS CE DÉPÔT — ce qui est
481
+ // éprouvé ici est la CORRESPONDANCE (statut et corps vers verdict), pas la forme exacte
482
+ // que le fournisseur émet. Un hôte qui observerait une autre formulation doit la dire.
483
+ if (r.status === 404) return true;
484
+ const corps = await r.text().catch(() => "");
485
+ return /not[_ ]?found|no such key|does not exist/i.test(corps);
403
486
  } catch { return false; }
404
487
  },
405
488
 
@@ -327,6 +327,22 @@ function isAllowedStorageUrl(candidate, origins, hostBase, root) {
327
327
  // 3. le nombre de sauts est borné, et le protocole ne peut pas changer de nature : une
328
328
  // redirection vers `file:` transformerait un amont distant en lecture de disque local.
329
329
  const MAX_REDIRECTIONS = 5;
330
+ /**
331
+ * ⚠️ LE BUDGET EST GLOBAL À L'OPÉRATION, PAS PAR SAUT — ET IL ÉTAIT PAR SAUT.
332
+ *
333
+ * Chaque tour de boucle créait son propre `AbortSignal.timeout(60 s)`. Avec six tours (saut 0 à 5),
334
+ * une chaîne de redirections lente pouvait donc immobiliser la requête, sa socket et sa place
335
+ * d'admission pendant SIX MINUTES — alors que le commentaire juste en dessous affirmait « le délai
336
+ * est large mais il est borné ». Il bornait un saut ; personne ne bornait l'opération. Relevé par
337
+ * un audit externe le 12/09.
338
+ *
339
+ * ⚠️ CE N'EST PAS UN TROU DE SÉCURITÉ, ET LE DIRE COMPTE : chaque saut repasse la garde complète
340
+ * d'origine et recalcule le secret. Le risque est de DISPONIBILITÉ — c'est la même leçon que les
341
+ * appels non bornés du contexte autonome, au même endroit du raisonnement.
342
+ *
343
+ * Un seul signal, créé avant la boucle et partagé par tous les sauts : le total ne peut pas dépasser
344
+ * ce chiffre, quel que soit le nombre de redirections.
345
+ */
330
346
  const DELAI_MAX_MS = 60_000;
331
347
 
332
348
  async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root, secret } = {}) {
@@ -335,6 +351,10 @@ async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root,
335
351
  if (local) return readLocal(local, range);
336
352
 
337
353
  let cible = String(url);
354
+ // ⚠️ CRÉÉ ICI, PAS DANS LA BOUCLE : `AbortSignal.timeout` compte à partir de sa création, donc un
355
+ // signal fabriqué avant le premier saut EST le budget de toute l'opération.
356
+ const budget = (typeof AbortSignal !== "undefined" && AbortSignal.timeout)
357
+ ? AbortSignal.timeout(DELAI_MAX_MS) : undefined;
338
358
  for (let saut = 0; saut <= MAX_REDIRECTIONS; saut++) {
339
359
  const headers = { "accept-encoding": "identity" };
340
360
  if (range) headers.range = range;
@@ -342,8 +362,9 @@ async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root,
342
362
  if (isHostFetchUrl(cible, hostBase) && secret) headers["x-player-fetch-secret"] = secret;
343
363
 
344
364
  // Un amont qui ne répond jamais immobiliserait la requête et ses ressources indéfiniment.
345
- // Le délai est large — un gros document met du temps — mais il est borné.
346
- const r = await fetch(cible, { headers, redirect: "manual", signal: AbortSignal.timeout(DELAI_MAX_MS) });
365
+ // Le délai est large — un gros document met du temps — mais il borne l'OPÉRATION ENTIÈRE, pas
366
+ // chaque saut : le même signal sert à tous, donc le temps déjà consommé ne se reconstitue pas.
367
+ const r = await fetch(cible, { headers, redirect: "manual", ...(budget ? { signal: budget } : {}) });
347
368
  if (r.status < 300 || r.status > 399) return r;
348
369
 
349
370
  const suivante = r.headers.get("location");
@@ -825,6 +825,75 @@ was a correct `bot`, and the reader would have returned an empty string for ever
825
825
  set, so every request refused, on a perfectly correct integration. If your field is none of those
826
826
  three, tell us and we widen the list. The field name carries no security; the **role** filter does.
827
827
 
828
+ ⚠️ **`reshare` now answers with a three-state `delivery`, and the state you must handle is
829
+ `"unknown"`.** The response used to carry a single `sent` boolean, which collapsed three different
830
+ outcomes: your mail path declined, *we* declined, or **the call failed without us learning what your
831
+ side did**. Only the third is dangerous — if your host really sent the message and then answered too
832
+ late, a caller reading `sent: false` retries, creating a **second child link and a second email**.
833
+
834
+ | `delivery` | what happened | what to do |
835
+ |---|---|---|
836
+ | `"sent"` | your mail path reported success | nothing |
837
+ | `"refused"` | a decision was made — yours or ours; `sendRefused` names it | surface the reason; retrying will refuse again |
838
+ | `"unknown"` | the call failed (timeout, network). **We do not know whether the mail went out** | surface it to a human. **Do not retry automatically** — a retry may duplicate the email |
839
+ | `"not-requested"` | `send` was falsy | nothing |
840
+
841
+ `sent` is unchanged for integrations already reading it.
842
+
843
+ ⚠️ **And you can now make the retry safe: pass a `clientKey`.** Two `reshare` calls with the same
844
+ parent, the same recipient and the same `clientKey` return the **same child link** and send **one**
845
+ email — the second answers `delivery: "idempotent"`, which means *"this was already done"*, not
846
+ *"this failed"*. Generate the key before the first call and reuse it on every retry.
847
+
848
+ - The key you send is **fingerprinted on our side**, together with the parent and the recipient. It
849
+ is never stored as you wrote it, and it cannot collide with the system links the host-to-host path
850
+ creates.
851
+ - ⚠️ **This rides on migration `0011`, which you may already have.** No new column was added: the
852
+ table has carried a unique idempotency key since then, and the reshare route had simply never been
853
+ offered it. If `0011` is not applied, `clientKey` is ignored and you get the old behaviour — a
854
+ retry creates a second link. Nothing breaks; the guarantee is what degrades, and `delivery` still
855
+ tells you when you are in doubt.
856
+ - Without a `clientKey`, nothing changes: several links to the same recipient remain possible, which
857
+ is a legitimate thing to want.
858
+
859
+ ⚠️ **Avatars are only loaded from origins the page already serves content from, and everything else
860
+ degrades to initials.** An avatar URL is an `<img>` in the browser of **every other viewer**: an
861
+ arbitrary URL therefore sends each of them — their IP, user agent, the time, the page origin — to
862
+ whoever wrote it. That is not an XSS (the markup is escaped); it is a privacy leak aimed at your
863
+ audience, and an external audit reproduced it on 2026-09-12 against the real chat renderer.
864
+
865
+ Three things changed, and the second one is the one you may notice:
866
+
867
+ - **A participant who is not authenticated no longer supplies an avatar at all.** A proven identity
868
+ replaces what is asserted; an anonymous visitor proves nothing, so the field is dropped and the
869
+ audience sees initials.
870
+ - **A member's avatar must come from your Supabase origin (or be a relative URL).** It arrives from
871
+ your identity provider's metadata, and a provider that lets a user edit that field would hand us
872
+ an arbitrary URL under a proven name. ⚠️ **If your members' avatars live elsewhere — Gravatar, a
873
+ CDN, Google — they will now render as initials.** Serve them from your own storage to get the
874
+ images back. The degradation is visible and reversible; the leak was neither.
875
+ - **The renderer refuses the same URLs again**, because one path never reaches this server: a
876
+ participant can broadcast presence over Realtime straight to the other viewers. No server-side
877
+ barrier can see that, so the check also lives where every path converges — at render time.
878
+
879
+ ⚠️ **What your `storage.remove` returns now decides whether a row survives.** It returns a boolean:
880
+ `true` means the object is gone, `false` means it is still there. Until 0.1.163 the retention sweep
881
+ erased the row either way — and since this capability exposes `put` and `remove` but **never
882
+ `list`**, the row is the only path to the object: erasing it stranded the file in the bucket
883
+ permanently. The sweep now **keeps the row** when `remove` returns `false`, and reports it as
884
+ `retenues`. Two consequences for you:
885
+
886
+ - **Do not return `false` for an object that was already absent.** An already-gone object is a
887
+ success for this purpose — returning `false` makes the sweep retain a row forever, waiting for a
888
+ file that does not exist. ⚠️ **The player's own standalone context used to have exactly this bug**,
889
+ found while writing this paragraph: it returned `r.ok`, and Supabase Storage answers an error for a
890
+ missing object, so the fix for lost files would have created permanent retention instead. It now
891
+ treats 404 — and a body naming "not found" — as removed, because what is being asked is *"the
892
+ object is no longer there"*, and it is not there. If you wrap a different provider, do the same.
893
+ - **`retenues > 0` in a retention report means your provider refused a removal**, not that the purge
894
+ is broken. The next pass retries. A row that lingers is recoverable; a file whose only pointer was
895
+ erased is not.
896
+
828
897
  **If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
829
898
  only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
830
899
  row. Anything your code puts in that bucket is therefore invisible to the sweep — **permanently**,
package/docs/RETENTION.md CHANGED
@@ -287,8 +287,54 @@ numbers, say so; do not round the sentence.
287
287
  it defers nothing. A host with no PITR and eight daily snapshots has **one** deadline, not two: the
288
288
  age of its oldest snapshot. Take the later of the deadlines that *exist*.
289
289
 
290
+ ## Cleaning up the objects the broken sweep stranded
291
+
292
+ The voice-cache sweep removed nothing until it was fixed (see above), so every voice object it
293
+ "purged" is still in the bucket with its row deleted — unreachable by the product, by construction.
294
+ `tools/orphelins-tts.mjs` exists for exactly that backlog, and for nothing else.
295
+
296
+ ```
297
+ node tools/orphelins-tts.mjs --inspecter [--age-jours=N] [--limite=N]
298
+ node tools/orphelins-tts.mjs --inspecter --supprimer --confirme=<the count from the report>
299
+ ```
300
+
301
+ It reads `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` from the environment, and it deliberately
302
+ **steps outside the host contract**: it talks to the Storage API directly to do the one thing the
303
+ contract does not expose — `list`. That is why it is not a guard, runs in no workflow, and contacts
304
+ nothing at all until you pass `--inspecter`.
305
+
306
+ ⚠️ **It cannot tell our orphans from yours, and no measurement can.** An object with no row is one of
307
+ three things: stranded by the broken sweep, a relic from before migration 0021, or **a file your own
308
+ code wrote under the player's naming** — one integrating host reported 908 of those. The tool
309
+ therefore:
310
+
311
+ - **reports by default** and deletes nothing;
312
+ - treats only objects **older than the retention window** as candidates — a recent object with no row
313
+ may be a synthesis whose trace write just failed, and deleting it would erase a file the product is
314
+ about to serve;
315
+ - refuses to act unless you **type the candidate count back** from a report it produced on the
316
+ current state. If the number has changed since you looked, the bucket moved, and that is precisely
317
+ what the barrier is there to tell you;
318
+ - never touches a name that is not `<fingerprint>.mp3` / `.json`, nor an object whose date it cannot
319
+ read — no date means *we do not know*, and we do not delete what we do not know.
320
+
321
+ Read the report before passing `--supprimer`. If your own code writes to this bucket, write the trace
322
+ too (see *Voice* in `docs/HOST-CONTRACT.md`) — that is what makes your objects distinguishable, and
323
+ what keeps them out of this tool's candidate list.
324
+
290
325
  ## Limits stated rather than left unsaid
291
326
 
327
+ - ⚠️ **Until the next release the voice-cache sweep removed nothing at all, in the reference host context,
328
+ and the paragraph below is what hid it.** `storage.remove` carries an allow-list — a last barrier
329
+ before a DELETE with the service-role key — and it named only `present-attachments`. `tts-cache`
330
+ was refused **before any network call**: every removal returned `false`, the trace row was erased
331
+ anyway, and the object stayed in a public bucket with no path left to it. That is precisely the
332
+ harm migration 0021 was written to make repairable, realised at 100%.
333
+ ⚠️ **What concealed it is a true explanation.** Those refusals were counted in `fichiersErreur`,
334
+ which the next bullet attributes — correctly — to alignment files that legitimately do not exist.
335
+ A correct account of the noise is the best place to hide a signal. Found on 2026-09-12 while
336
+ writing the documentation for a *different* fix on the same path; the allow-list now names both
337
+ buckets the sweep must reach, and nothing else.
292
338
  - ⚠️ **`fichiersErreur` can be high without any removal having failed.** Each fingerprint has two
293
339
  objects, and the alignment `.json` is not always there — the provider does not always return one.
294
340
  Measured on an integrating host's bucket on 27/08: **552 `.mp3` for 356 `.json`**, so 196 audio
@@ -312,6 +358,28 @@ age of its oldest snapshot. Take the later of the deadlines that *exist*.
312
358
  - **The dryRun report is complete for presentations**: `messagesExaminees`, `presencesExaminees`
313
359
  and `fichiersCandidats` say what the REAL purge would do — same selection walk, no-op deletion,
314
360
  `efface.* = 0`.
361
+ - ⚠️ **A row is never erased above a file that resisted, and `retenues` counts the rows kept.**
362
+ Until 0.1.163 the deletion was unconditional: a failed `storage.remove` still erased the row — and
363
+ with it the only path to the object. The `storage` capability exposes `put` and `remove`, **never
364
+ `list`**, which is the very argument that justified migration 0021: with no row there is nothing
365
+ to walk, so the object stays in the bucket **permanently**, beyond the reach of any sweep. Found
366
+ by an external audit on 2026-09-12, reproduced before being fixed. A retained row is recoverable —
367
+ the next pass retries it; a lost file is not. Read `retenues > 0` as *"the storage provider refused
368
+ a removal; look at it"*, not as a purge failure.
369
+ - ⚠️ **The alignment `.json` never retains anything — only the audio does.** A third of fingerprints
370
+ legitimately have no companion (see the 552/356 measurement above); gating the row on both objects
371
+ would hold a third of the cache forever to protect files that do not exist. So the `.mp3` alone
372
+ decides, and a missing `.json` is still **counted** in `fichiersErreur` rather than masked.
373
+ - ⚠️ **A presentation is not deleted above a retained message.** The condition used to require only
374
+ that nothing was `tronque`; "retained" is a second way of not having gone, and without it the fix
375
+ above would have reopened the parent/child orphan an earlier audit had closed.
376
+ - ⚠️ **The delete carries the purge predicate, not just the identifiers.** The sweep selects by date
377
+ and used to delete by identifier alone: a heartbeat landing between the two requests refreshed a
378
+ row that was then erased anyway, judged on a date that was no longer its own. Same audit, same
379
+ day, also reproduced. PostgREST applies every predicate in the URL at delete time, so replaying
380
+ the original filter makes the row be judged on its state **at that instant**. The race window does
381
+ not disappear — it stops being destructive. `select=` returns only what actually went, so the
382
+ counts stay honest when the database spares a row at the last moment.
315
383
  - **The purge advances in BOUNDED BATCHES** (200 rows, a ceiling of 5000 per table and 500
316
384
  presentations per run): it selects a batch of identifiers, deletes them with `id=in.(…)`, and
317
385
  starts again. The report (`r.rapport`) carries, per table: `examinees`, `supprimees`, `tronque`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.163",
3
+ "version": "0.1.164",
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",
@@ -6,4 +6,4 @@
6
6
  // Injecté par api/doc.js dans un <script nonce=…> de la visionneuse. Le code navigateur du
7
7
  // player vit en modules TypeScript testés sous player/src/ — c'est ici qu'il atterrit.
8
8
  "use strict";
9
- module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var O=Object.defineProperty,Be=Object.defineProperties,je=Object.getOwnPropertyDescriptor,Ge=Object.getOwnPropertyDescriptors,Ke=Object.getOwnPropertyNames,Me=Object.getOwnPropertySymbols;var _e=Object.prototype.hasOwnProperty,Ye=Object.prototype.propertyIsEnumerable;var we=(e,n,t)=>n in e?O(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,Z=(e,n)=>{for(var t in n||(n={}))_e.call(n,t)&&we(e,t,n[t]);if(Me)for(var t of Me(n))Ye.call(n,t)&&we(e,t,n[t]);return e},Ee=(e,n)=>Be(e,Ge(n));var M=(e,n)=>{for(var t in n)O(e,t,{get:n[t],enumerable:!0})},Ze=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Ke(n))!_e.call(e,o)&&o!==t&&O(e,o,{get:()=>n[o],enumerable:!(r=je(n,o))||r.enumerable});return e};var qe=e=>Ze(O({},\"__esModule\",{value:!0}),e);var Jn={};M(Jn,{bridge:()=>L,cadence:()=>H,chat:()=>W,live:()=>D,presentation:()=>V,presentationState:()=>F,tracking:()=>U,viewer:()=>z});var L={};M(L,{onHostMessage:()=>an,onPlayerMessage:()=>sn,parseHostMessage:()=>ke,parsePlayerMessage:()=>Se,sendToHost:()=>tn,sendToPlayer:()=>rn,toWire:()=>Q});var q=\"3dd-doc-\",Qe=/^[A-Za-z0-9_-]{1,64}$/,Xe=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Je=[\"embed-denied\"],en=[\"present-invite\",\"present-handover\",\"present-switch\"],nn=[\"handover-done\"];function Q(e){let n={type:q+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function Te(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(q))return null;let o=r.slice(q.length);if(n.includes(o))return{type:o};if(Je.includes(o)){let a=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(a||\"\"))?String(a):\"unknown\"}}if(!t.includes(o))return null;let s=e.slug;return typeof s!=\"string\"||!Qe.test(s)?null:{type:o,slug:s}}function Se(e){return Te(e,Xe,en)}function ke(e){return Te(e,nn,[])}function tn(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Q(e),\"*\")}catch(t){}}function rn(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Q(n),\"*\")}catch(r){}}function on(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function sn(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!on(t,s.source))return;let a=Se(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function an(e,n){var s;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(s=t.parent)!=null?s:null,o=a=>{if(!r||r===t||a.source!==r)return;let u=ke(a.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var U={};M(U,{createTracker:()=>bn});var H={};M(H,{PRESENTER_ACTIONS_PER_HOUR:()=>ee,PRESENT_CACHE_MS:()=>dn,PRESENT_QUOTA_MARGIN:()=>Le,PRESENT_QUOTA_PER_HOUR:()=>pn,PRESENT_READS_PER_HOUR:()=>Ce,PRESENT_READ_BURST:()=>Ne,PRESENT_READ_COALESCE_MS:()=>Oe,PRESENT_RESYNC_MS:()=>Ie,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>cn,PRESENT_WRITE_TIMEOUT_MS:()=>ne,READERS_PER_EGRESS:()=>$,RESYNC_READS_PER_HOUR:()=>Pe,SESSION_IDLE_MS:()=>J,SESSION_INTERVAL_MS:()=>X,SESSION_QUOTA_PER_HOUR:()=>un,SESSION_WRITES_PER_HOUR:()=>Re,VIEW_EVENTS_PER_READER_HOUR:()=>Ae,VIEW_QUOTA_PER_HOUR:()=>ln});var X=3e4,Re=Math.ceil(120),$=25,un=Re*$,Ae=720,ln=Ae*$,J=18e4,Ie=25e3,Pe=Math.ceil(36e5/Ie),ee=720,Ce=Pe+ee,cn=ee,Ne=20,ne=1e4,Oe=400,dn=Oe,Le=4,pn=(Ce+Ne)*$*Le;var mn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function fn(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function gn(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;return o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):(n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0}),!0)}catch(r){return!1}}}function bn(e={}){var ye,xe;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",s=(ye=e.idleMs)!=null?ye:J,a=(xe=e.sessionEveryMs)!=null?xe:3e4,u=e.slug||\"\",p=e.internal||null,d=e.sessionId||fn(),m=e.send||gn(o,n),c={},l=0,b=0,f=0,w=0,v=null,S=r(),k=!1,B=!1,C=[],Fe=()=>!!u||!!p,pe=i=>{if(!Fe())return!1;try{return m(i)!==!1}catch(g){return!1}},j=()=>!!t&&t.visibilityState===\"visible\"&&!k,me=i=>{let g=r()-i,y=Math.max(0,S-i)+s;return Math.max(0,Math.min(g,y))},G=()=>{v!=null&&l>0&&(c[l]=(c[l]||0)+me(v)/1e3),v=j()?r():null},fe=()=>{G(),v=null},K=()=>{v==null&&j()&&(v=r())},Y=()=>{let i=0;for(let g in c)i+=c[g];return v!=null&&(i+=me(v)/1e3),Math.round(i)},ge=()=>{let i={};for(let g in c)i[g]=Math.round(c[g]);return i},R=()=>{S=r(),k&&(k=!1,K())},be=(i,g)=>{p||pe({slug:u,event:i,page:g||b,maxPage:f,seconds:Y(),sessionId:d})},he=\"\",N=()=>{G();let i=Y(),g=`${i}|${f}|${w}`;if(g===he)return;let y={event:\"session\",sessionId:d,numPages:w,maxPage:f,totalSeconds:i,pagesTime:ge()};p?(y.internal=!0,y.docId=p.docId,y.email=p.email,y.name=p.name,p.it&&(y.it=p.it)):y.slug=u,pe(y)&&(he=g)},A=(i,g,y,ve)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(g,y,ve),C.push(()=>{i.removeEventListener(g,y,ve)}))};return{sessionId:d,totalSeconds:Y,maxPage:()=>f,pageTimes:ge,start(){if(B)return;B=!0,be(\"open\",1),l=1,S=r(),v=j()?r():null,A(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(fe(),N()):(R(),K())}),A(n,\"focus\",()=>{R(),K()});for(let y of mn)A(n,y,R,{passive:!0});A(e.scrollElement,\"scroll\",R,{passive:!0}),A(n,\"beforeunload\",N);let i=n.setInterval(()=>{!k&&r()-S>s&&(k=!0,fe())},5e3),g=n.setInterval(N,a);C.push(()=>{n.clearInterval(i),n.clearInterval(g)})},setPage(i){!i||i===b||(S=r(),G(),l=i,b=i,i>f&&(f=i,be(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:R,flush:N,stop(){for(;C.length;){let i=C.pop();try{i==null||i()}catch(g){}}B=!1}}}var D={};M(D,{STORAGE_KEYS:()=>_,attendeeKey:()=>vn,authorToken:()=>Mn,avatarHtml:()=>te,canModerate:()=>ie,createBudget:()=>Rn,createFileEcritures:()=>An,createScheduler:()=>Sn,escapeHtml:()=>x,fetchBorne:()=>kn,flattenPresence:()=>hn,formatMessageBody:()=>re,initials:()=>$e,isMentioned:()=>_n,isMine:()=>E,presenceId:()=>In,reactorId:()=>se,referenceAuteur:()=>wn,shouldNotify:()=>En,unreadLabel:()=>Tn});var _={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function x(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function $e(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function te(e,n){return e?`<img src=\"${x(e)}\" alt=\"\">`:x($e(n))}function re(e){let n=x(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function hn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function yn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function xn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function vn(e,n=\"anon\",t=oe){try{if(!e)return`anon-${n}`;let r=e.getItem(_.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(_.attendeeKey,o),o}catch(r){return`anon-${n}`}}function oe(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function Mn(e,n=oe){let t=yn(e,_.authorToken);if(t)return t;let r=n();return xn(e,_.authorToken,r),r}async function wn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function se(e){return String(e&&e.ref||\"\")}function E(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function ie(e){return!!(e&&e.role===\"presenter\")}function _n(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!E(e,n):!1}function En(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||E(n,t)?!1:o}function Tn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function Sn(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,u=!1,p=-1/0,d=null,m=!1;function c(){if(m||a){u=!0;return}let f=t-(r()-p);if(f>0){l(f);return}a=!0,u=!1,p=r();let w=!1;e(()=>{w||(w=!0,a=!1,u&&!m&&c())})}function l(f){m||d!==null||(d=o(()=>{d=null,c()},Math.max(0,f)))}return{signaler(){m||c()},maintenant(){m||(p=-1/0,c())},arreter(){m=!0,d!==null&&(s(d),d=null)}}}function kn(e,n,t=ne,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((l,b)=>setTimeout(l,b)),a=r.clearTimer||(l=>clearTimeout(l)),u=null;try{u=new AbortController}catch(l){}let p=u?Ee(Z({},n||{}),{signal:u.signal}):Z({},n||{}),d=null,m=new Promise((l,b)=>{d=s(()=>{try{u==null||u.abort()}catch(f){}b(new Error(\"delai\"))},t)}),c=()=>{try{a(d)}catch(l){}};return Promise.race([o(e,p),m]).then(l=>(c(),l),l=>{throw c(),l})}function Rn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function An(e={}){var d;let n=(d=e.minMs)!=null?d:0,t=e.now||(()=>Date.now()),r=e.setTimer||((m,c)=>setTimeout(m,c)),o=new Map,s=!1,a=-1/0,u=!1;function p(){if(s||o.size===0)return;let m=n-(t()-a);if(m>0){if(u)return;u=!0,r(()=>{u=!1,p()},m);return}let c=o.keys().next().value,l=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(l.tache).then(b=>l.regler(b),()=>l.regler(null)).then(()=>{s=!1,p()})}return{poser(m,c){return new Promise(l=>{let b=o.get(m);o.set(m,{tache:c,regler:f=>{b&&b.regler(f),l(f)}}),p()})},enAttente:()=>o.size}}function In(e,n=oe){try{if(e){let t=e.getItem(_.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(_.presenceId,r),r}}catch(t){}return`p-${n()}`}var W={};M(W,{messageClassName:()=>Cn,renderActions:()=>De,renderAttachment:()=>Ue,renderMessage:()=>Pn,renderReactions:()=>He});function He(e,n){let t=e&&e.reactions||{},r=se(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let u=a.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${x(s)}\">${x(s)} ${a.length}</button>`}return o}function Ue(e){if(!e||!e.url)return\"\";let n=x(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=x(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function De(e,n){let t=E(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||ie(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function Pn(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${x(e.reply_name||\"\")}</b> ${x(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${re(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${te(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${x(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ue(e.attachment)+`<div class=cm-re>${He(e,n.me)}</div></span><span class=cm-act>${De(e,n)}</span>`}function Cn(e,n,t){return\"cm\"+(E(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var V={};M(V,{DEFAULT_CENTER:()=>le,DEFAULT_ZOOM:()=>ce,MAP_TYPES:()=>I,cycleMapType:()=>Nn,initialMapContent:()=>Ln,mapTypeLabel:()=>On,sanitizeContent:()=>de});var I=[\"roadmap\",\"satellite\",\"hybrid\"],le=[46.6,2.5],ce=6;function T(e){let n=Number(e);return Number.isFinite(n)?n:null}function ae(e){if(!Array.isArray(e))return null;let n=T(e[0]),t=T(e[1]);return n!=null&&t!=null?[n,t]:null}var ue=(e,n,t)=>Math.max(n,Math.min(t,e));function de(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=ae(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:T(r.heading)||0,pitch:ue(T(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ue(T(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:ae(n.center)||[...le],zoom:ue(Math.trunc(T(n.zoom)||ce),1,21),marker:ae(n.marker),mapType:I.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Nn(e){let n=I.indexOf(e);return I[(n+1)%I.length]}function On(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Ln(){return{kind:\"map\",center:[...le],zoom:ce,marker:null,mapType:null,label:null}}var z={};M(z,{CURRENT_PAGE_MARGIN:()=>Vn,DEFAULT_ASPECT:()=>Un,MAX_ZOOM:()=>Hn,MIN_PAGE_WIDTH:()=>Dn,MIN_ZOOM:()=>$n,PRERENDER_MARGIN:()=>Wn,ROTATIONS:()=>Zn,ancrageApresZoom:()=>qn,arrowState:()=>Bn,aspectApresRotation:()=>ze,averageColor:()=>Kn,clampPage:()=>Ve,clampZoom:()=>zn,fitWidth:()=>Gn,isImageDocument:()=>Yn,progressPercent:()=>Fn,rotationEffective:()=>P});var $n=.5,Hn=3,Un=1.35,Dn=280,Wn=\"500px 0px\",Vn=\"-48% 0px -48% 0px\";function zn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ve(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Fn(e,n){return n?Math.round(Ve(e,n)/n*100):0}function Bn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var jn=28;function Gn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=ze(e.aspect,e.rotation),s=((e.containerHeight||600)-jn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Kn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Yn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var Zn=[0,90,180,270],h=(e,n=0)=>typeof e==\"number\"&&Number.isFinite(e)?e:n;function P(e,n){return(Math.round((h(e)+h(n))/90)%4+4)%4*90}function ze(e,n){let t=h(e,0)>0?e:1.35,r=P(0,n);return r===90||r===270?1/t:t}function We(e,n,t,r,o,s,a){let u=Math.min(Math.max(0,o),Math.max(0,r)),p=u+Math.max(0,r-u)*a,d=Math.min(Math.max(0,s),Math.max(0,r)),m=r-d>0?(p-d)/(r-d):1,c=Math.max(0,(t-r)/2),l=n+e-c,b=d+Math.max(0,l-d)*m-n;return Math.min(Math.max(0,b),Math.max(0,p-t))}function qn(e){let n=h(e.zoomAvant,0),t=h(e.zoomApres,0),r={x:h(e.defilement&&e.defilement.x),y:h(e.defilement&&e.defilement.y)};if(n<=0||t<=0)return r;let o=t/n,s=e.fixe||{largeur:0,hauteur:0},a=e.fixeDebut||{largeur:0,hauteur:0};return{x:We(r.x,h(e.point&&e.point.x),h(e.cadre&&e.cadre.largeur),h(e.contenu&&e.contenu.largeur),h(s.largeur),h(a.largeur),o),y:We(r.y,h(e.point&&e.point.y),h(e.cadre&&e.cadre.hauteur),h(e.contenu&&e.contenu.hauteur),h(s.hauteur),h(a.hauteur),o)}}var F={};M(F,{presentationTransition:()=>Qn,switchDocUrl:()=>Xn});function Qn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=de(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}],o=P(0,e.view_rotation);return o!==P(0,n.rotation)&&r.push({kind:\"rotate\",rotation:o}),e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function Xn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return qe(Jn);})();", SOURCE_HASH: "720cf1b3ec849d63" };
9
+ module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var L=Object.defineProperty,Ke=Object.defineProperties,Ye=Object.getOwnPropertyDescriptor,Ze=Object.getOwnPropertyDescriptors,qe=Object.getOwnPropertyNames,Ee=Object.getOwnPropertySymbols;var Te=Object.prototype.hasOwnProperty,Qe=Object.prototype.propertyIsEnumerable;var _e=(e,n,t)=>n in e?L(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,Q=(e,n)=>{for(var t in n||(n={}))Te.call(n,t)&&_e(e,t,n[t]);if(Ee)for(var t of Ee(n))Qe.call(n,t)&&_e(e,t,n[t]);return e},Se=(e,n)=>Ke(e,Ze(n));var w=(e,n)=>{for(var t in n)L(e,t,{get:n[t],enumerable:!0})},Xe=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of qe(n))!Te.call(e,o)&&o!==t&&L(e,o,{get:()=>n[o],enumerable:!(r=Ye(n,o))||r.enumerable});return e};var Je=e=>Xe(L({},\"__esModule\",{value:!0}),e);var at={};w(at,{bridge:()=>H,cadence:()=>$,chat:()=>F,live:()=>V,presentation:()=>W,presentationState:()=>j,tracking:()=>D,viewer:()=>B});var H={};w(H,{onHostMessage:()=>cn,onPlayerMessage:()=>ln,parseHostMessage:()=>Ae,parsePlayerMessage:()=>Re,sendToHost:()=>an,sendToPlayer:()=>sn,toWire:()=>J});var X=\"3dd-doc-\",en=/^[A-Za-z0-9_-]{1,64}$/,nn=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],tn=[\"embed-denied\"],rn=[\"present-invite\",\"present-handover\",\"present-switch\"],on=[\"handover-done\"];function J(e){let n={type:X+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function ke(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(X))return null;let o=r.slice(X.length);if(n.includes(o))return{type:o};if(tn.includes(o)){let s=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(s||\"\"))?String(s):\"unknown\"}}if(!t.includes(o))return null;let a=e.slug;return typeof a!=\"string\"||!en.test(a)?null:{type:o,slug:a}}function Re(e){return ke(e,nn,rn)}function Ae(e){return ke(e,on,[])}function an(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(J(e),\"*\")}catch(t){}}function sn(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(J(n),\"*\")}catch(r){}}function un(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function ln(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=a=>{if(!un(t,a.source))return;let s=Re(a.data);s&&e(s)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function cn(e,n){var a;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(a=t.parent)!=null?a:null,o=s=>{if(!r||r===t||s.source!==r)return;let u=Ae(s.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var D={};w(D,{createTracker:()=>xn});var $={};w($,{PRESENTER_ACTIONS_PER_HOUR:()=>te,PRESENT_CACHE_MS:()=>fn,PRESENT_QUOTA_MARGIN:()=>Ue,PRESENT_QUOTA_PER_HOUR:()=>gn,PRESENT_READS_PER_HOUR:()=>Oe,PRESENT_READ_BURST:()=>Le,PRESENT_READ_COALESCE_MS:()=>He,PRESENT_RESYNC_MS:()=>Ce,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>pn,PRESENT_WRITE_TIMEOUT_MS:()=>re,READERS_PER_EGRESS:()=>U,RESYNC_READS_PER_HOUR:()=>Ne,SESSION_IDLE_MS:()=>ne,SESSION_INTERVAL_MS:()=>ee,SESSION_QUOTA_PER_HOUR:()=>dn,SESSION_WRITES_PER_HOUR:()=>Ie,VIEW_EVENTS_PER_READER_HOUR:()=>Pe,VIEW_QUOTA_PER_HOUR:()=>mn});var ee=3e4,Ie=Math.ceil(120),U=25,dn=Ie*U,Pe=720,mn=Pe*U,ne=18e4,Ce=25e3,Ne=Math.ceil(36e5/Ce),te=720,Oe=Ne+te,pn=te,Le=20,re=1e4,He=400,fn=He,Ue=4,gn=(Oe+Le)*U*Ue;var bn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function hn(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function yn(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;return o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):(n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0}),!0)}catch(r){return!1}}}function xn(e={}){var Me,ve;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",a=(Me=e.idleMs)!=null?Me:ne,s=(ve=e.sessionEveryMs)!=null?ve:3e4,u=e.slug||\"\",d=e.internal||null,l=e.sessionId||hn(),m=e.send||yn(o,n),p={},c=0,b=0,f=0,E=0,v=null,k=r(),R=!1,G=!1,N=[],Ge=()=>!!u||!!d,fe=i=>{if(!Ge())return!1;try{return m(i)!==!1}catch(g){return!1}},K=()=>!!t&&t.visibilityState===\"visible\"&&!R,ge=i=>{let g=r()-i,y=Math.max(0,k-i)+a;return Math.max(0,Math.min(g,y))},Y=()=>{v!=null&&c>0&&(p[c]=(p[c]||0)+ge(v)/1e3),v=K()?r():null},be=()=>{Y(),v=null},Z=()=>{v==null&&K()&&(v=r())},q=()=>{let i=0;for(let g in p)i+=p[g];return v!=null&&(i+=ge(v)/1e3),Math.round(i)},he=()=>{let i={};for(let g in p)i[g]=Math.round(p[g]);return i},A=()=>{k=r(),R&&(R=!1,Z())},ye=(i,g)=>{d||fe({slug:u,event:i,page:g||b,maxPage:f,seconds:q(),sessionId:l})},xe=\"\",O=()=>{Y();let i=q(),g=`${i}|${f}|${E}`;if(g===xe)return;let y={event:\"session\",sessionId:l,numPages:E,maxPage:f,totalSeconds:i,pagesTime:he()};d?(y.internal=!0,y.docId=d.docId,y.email=d.email,y.name=d.name,d.it&&(y.it=d.it)):y.slug=u,fe(y)&&(xe=g)},I=(i,g,y,we)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(g,y,we),N.push(()=>{i.removeEventListener(g,y,we)}))};return{sessionId:l,totalSeconds:q,maxPage:()=>f,pageTimes:he,start(){if(G)return;G=!0,ye(\"open\",1),c=1,k=r(),v=K()?r():null,I(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(be(),O()):(A(),Z())}),I(n,\"focus\",()=>{A(),Z()});for(let y of bn)I(n,y,A,{passive:!0});I(e.scrollElement,\"scroll\",A,{passive:!0}),I(n,\"beforeunload\",O);let i=n.setInterval(()=>{!R&&r()-k>a&&(R=!0,be())},5e3),g=n.setInterval(O,s);N.push(()=>{n.clearInterval(i),n.clearInterval(g)})},setPage(i){!i||i===b||(k=r(),Y(),c=i,b=i,i>f&&(f=i,ye(\"page\",i)))},setPageCount(i){E=i||0},noteActivity:A,flush:O,stop(){for(;N.length;){let i=N.pop();try{i==null||i()}catch(g){}}G=!1}}}var V={};w(V,{STORAGE_KEYS:()=>_,attendeeKey:()=>En,authorToken:()=>_n,avatarHtml:()=>oe,canModerate:()=>ue,createBudget:()=>Pn,createFileEcritures:()=>Cn,createScheduler:()=>An,escapeHtml:()=>x,fetchBorne:()=>In,flattenPresence:()=>Mn,formatMessageBody:()=>ae,initials:()=>$e,isMentioned:()=>Sn,isMine:()=>T,origineAvatarAutorisee:()=>De,presenceId:()=>Nn,reactorId:()=>se,referenceAuteur:()=>Tn,shouldNotify:()=>kn,unreadLabel:()=>Rn});var _={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function x(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function $e(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function De(e,n){let t=String(e||\"\").trim();if(!t)return!1;if(/^[a-z][a-z0-9+.-]*:/i.test(t)===!1&&!t.startsWith(\"//\"))return!0;let r=typeof location!=\"undefined\"&&location?location.origin:\"\",o;try{o=new URL(t,r||\"https://invalide.exemple\")}catch(a){return!1}return o.protocol!==\"https:\"&&o.protocol!==\"http:\"?!1:r&&o.origin===r?!0:(n||[]).some(a=>{try{return new URL(String(a)).origin===o.origin}catch(s){return!1}})}function oe(e,n,t){return De(e,t)?`<img src=\"${x(e)}\" alt=\"\">`:x($e(n))}function ae(e){let n=x(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function Mn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function vn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function wn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function En(e,n=\"anon\",t=ie){try{if(!e)return`anon-${n}`;let r=e.getItem(_.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(_.attendeeKey,o),o}catch(r){return`anon-${n}`}}function ie(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function _n(e,n=ie){let t=vn(e,_.authorToken);if(t)return t;let r=n();return wn(e,_.authorToken,r),r}async function Tn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let a=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(a),s=>s.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function se(e){return String(e&&e.ref||\"\")}function T(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function ue(e){return!!(e&&e.role===\"presenter\")}function Sn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!T(e,n):!1}function kn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function Rn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function An(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,E)=>setTimeout(f,E)),a=n.clearTimer||(f=>clearTimeout(f)),s=!1,u=!1,d=-1/0,l=null,m=!1;function p(){if(m||s){u=!0;return}let f=t-(r()-d);if(f>0){c(f);return}s=!0,u=!1,d=r();let E=!1;e(()=>{E||(E=!0,s=!1,u&&!m&&p())})}function c(f){m||l!==null||(l=o(()=>{l=null,p()},Math.max(0,f)))}return{signaler(){m||p()},maintenant(){m||(d=-1/0,p())},arreter(){m=!0,l!==null&&(a(l),l=null)}}}function In(e,n,t=re,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let a=r.setTimer||((c,b)=>setTimeout(c,b)),s=r.clearTimer||(c=>clearTimeout(c)),u=null;try{u=new AbortController}catch(c){}let d=u?Se(Q({},n||{}),{signal:u.signal}):Q({},n||{}),l=null,m=new Promise((c,b)=>{l=a(()=>{try{u==null||u.abort()}catch(f){}b(new Error(\"delai\"))},t)}),p=()=>{try{s(l)}catch(c){}};return Promise.race([o(e,d),m]).then(c=>(p(),c),c=>{throw p(),c})}function Pn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let a=n();return r=Math.min(t,r+(a-o)*e.parHeure/36e5),o=a,r<1?!1:(r-=1,!0)},restants:()=>r}}function Cn(e={}){var l;let n=(l=e.minMs)!=null?l:0,t=e.now||(()=>Date.now()),r=e.setTimer||((m,p)=>setTimeout(m,p)),o=new Map,a=!1,s=-1/0,u=!1;function d(){if(a||o.size===0)return;let m=n-(t()-s);if(m>0){if(u)return;u=!0,r(()=>{u=!1,d()},m);return}let p=o.keys().next().value,c=o.get(p);o.delete(p),a=!0,s=t(),Promise.resolve().then(c.tache).then(b=>c.regler(b),()=>c.regler(null)).then(()=>{a=!1,d()})}return{poser(m,p){return new Promise(c=>{let b=o.get(m);o.set(m,{tache:p,regler:f=>{b&&b.regler(f),c(f)}}),d()})},enAttente:()=>o.size}}function Nn(e,n=ie){try{if(e){let t=e.getItem(_.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(_.presenceId,r),r}}catch(t){}return`p-${n()}`}var F={};w(F,{messageClassName:()=>Ln,renderActions:()=>We,renderAttachment:()=>Fe,renderMessage:()=>On,renderReactions:()=>Ve});function Ve(e,n){let t=e&&e.reactions||{},r=se(n),o=\"\";for(let a in t){let s=t[a]||[];if(!s.length)continue;let u=s.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${x(a)}\">${x(a)} ${s.length}</button>`}return o}function Fe(e){if(!e||!e.url)return\"\";let n=x(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=x(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function We(e,n){let t=T(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||ue(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function On(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${x(e.reply_name||\"\")}</b> ${x(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ae(e.body)}${r}</div>`:\"\",a=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${oe(e.author_avatar,e.author_name,n.avatarOrigins)}</span><span class=b><div class=who><b>${x(e.author_name||\"Invit\\xE9\")}</b>${a}</div>`+t+o+Fe(e.attachment)+`<div class=cm-re>${Ve(e,n.me)}</div></span><span class=cm-act>${We(e,n)}</span>`}function Ln(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var W={};w(W,{DEFAULT_CENTER:()=>de,DEFAULT_ZOOM:()=>me,MAP_TYPES:()=>P,cycleMapType:()=>Hn,initialMapContent:()=>$n,mapTypeLabel:()=>Un,sanitizeContent:()=>pe});var P=[\"roadmap\",\"satellite\",\"hybrid\"],de=[46.6,2.5],me=6;function S(e){let n=Number(e);return Number.isFinite(n)?n:null}function le(e){if(!Array.isArray(e))return null;let n=S(e[0]),t=S(e[1]);return n!=null&&t!=null?[n,t]:null}var ce=(e,n,t)=>Math.max(n,Math.min(t,e));function pe(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=le(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:S(r.heading)||0,pitch:ce(S(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ce(S(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:le(n.center)||[...de],zoom:ce(Math.trunc(S(n.zoom)||me),1,21),marker:le(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Hn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function Un(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function $n(){return{kind:\"map\",center:[...de],zoom:me,marker:null,mapType:null,label:null}}var B={};w(B,{CURRENT_PAGE_MARGIN:()=>Bn,DEFAULT_ASPECT:()=>Fn,MAX_ZOOM:()=>Vn,MIN_PAGE_WIDTH:()=>Wn,MIN_ZOOM:()=>Dn,PRERENDER_MARGIN:()=>zn,ROTATIONS:()=>Xn,ancrageApresZoom:()=>Jn,arrowState:()=>Kn,aspectApresRotation:()=>je,averageColor:()=>qn,clampPage:()=>Be,clampZoom:()=>jn,fenetreVirtuelle:()=>nt,fitWidth:()=>Zn,isImageDocument:()=>Qn,pasVertical:()=>z,plafondFenetre:()=>tt,positionDe:()=>et,progressPercent:()=>Gn,rotationEffective:()=>C});var Dn=.5,Vn=3,Fn=1.35,Wn=280,zn=\"500px 0px\",Bn=\"-48% 0px -48% 0px\";function jn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Be(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Gn(e,n){return n?Math.round(Be(e,n)/n*100):0}function Kn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Yn=28;function Zn(e){var s;let n=(s=e.zoom)!=null?s:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=je(e.aspect,e.rotation),a=((e.containerHeight||600)-Yn-(e.overlap||0)-(e.reserve||0))/r;return a>120?Math.min(t,a):t}function qn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let a=0;a<e.length;a+=4)t+=e[a],r+=e[a+1],o+=e[a+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Qn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var Xn=[0,90,180,270],h=(e,n=0)=>typeof e==\"number\"&&Number.isFinite(e)?e:n;function C(e,n){return(Math.round((h(e)+h(n))/90)%4+4)%4*90}function je(e,n){let t=h(e,0)>0?e:1.35,r=C(0,n);return r===90||r===270?1/t:t}function ze(e,n,t,r,o,a,s){let u=Math.min(Math.max(0,o),Math.max(0,r)),d=u+Math.max(0,r-u)*s,l=Math.min(Math.max(0,a),Math.max(0,r)),m=r-l>0?(d-l)/(r-l):1,p=Math.max(0,(t-r)/2),c=n+e-p,b=l+Math.max(0,c-l)*m-n;return Math.min(Math.max(0,b),Math.max(0,d-t))}function Jn(e){let n=h(e.zoomAvant,0),t=h(e.zoomApres,0),r={x:h(e.defilement&&e.defilement.x),y:h(e.defilement&&e.defilement.y)};if(n<=0||t<=0)return r;let o=t/n,a=e.fixe||{largeur:0,hauteur:0},s=e.fixeDebut||{largeur:0,hauteur:0};return{x:ze(r.x,h(e.point&&e.point.x),h(e.cadre&&e.cadre.largeur),h(e.contenu&&e.contenu.largeur),h(a.largeur),h(s.largeur),o),y:ze(r.y,h(e.point&&e.point.y),h(e.cadre&&e.cadre.hauteur),h(e.contenu&&e.contenu.hauteur),h(a.hauteur),h(s.hauteur),o)}}var M=(e,n=0)=>{let t=Number(e);return Number.isFinite(t)?t:n};function z(e,n){return Math.max(1,M(e,1))+Math.max(0,M(n))}function et(e,n){return Math.max(0,M(n.decalageHaut))+(Math.max(1,Math.trunc(M(e,1)))-1)*z(n.hauteurElement,n.ecart)}function nt(e){let n=Math.max(0,Math.trunc(M(e.total)));if(n===0)return{debut:0,fin:0,avant:0,apres:0};let t=z(e.hauteurElement,e.ecart),r=Math.max(0,Math.trunc(M(e.marge))),o=Math.max(0,M(e.decalageHaut)),a=Math.max(0,M(e.debutVisible))-o,s=a+Math.max(0,M(e.hauteurVisible)),u=Math.floor(a/t)+1,d=Math.floor(s/t)+1,l=Math.min(n,Math.max(1,u-r)),m=Math.max(l,Math.min(n,d+r));return{debut:l,fin:m,avant:(l-1)*t,apres:(n-m)*t}}function tt(e){let n=z(e.hauteurElement,e.ecart);return Math.ceil(Math.max(0,M(e.hauteurVisible))/n)+2+2*Math.max(0,Math.trunc(M(e.marge)))}var j={};w(j,{presentationTransition:()=>rt,switchDocUrl:()=>ot});function rt(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=pe(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}],o=C(0,e.view_rotation);return o!==C(0,n.rotation)&&r.push({kind:\"rotate\",rotation:o}),e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function ot(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Je(at);})();", SOURCE_HASH: "ac174cbf0b8d7e02" };
@@ -303,7 +303,17 @@ var Live=(function(){
303
303
  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>';
304
304
  function esc(s){return Player.live.escapeHtml(s);}
305
305
  function ini(n){return Player.live.initials(n);}
306
- function av(u,n){return Player.live.avatarHtml(u,n);}
306
+ // ⚠️ LES ORIGINES AUTORISÉES POUR UN AVATAR, CALCULÉES UNE FOIS. Sans liste, avatarHtml ne rend
307
+ // une image que depuis la MÊME origine : une URL arbitraire retombe sur les initiales plutôt que
308
+ // de faire partir l'IP de chaque spectateur chez un inconnu. On déclare le stockage de l'hôte,
309
+ // d'où viennent les avatars légitimes ; rien d'autre. Voir origineAvatarAutorisee dans
310
+ // src/live.ts pour le pourquoi, et pour le chemin Realtime que le serveur ne voit pas passer.
311
+ //
312
+ // ⚠️ PAS D'ACCENT GRAVE DANS CE FICHIER : il est lui-même un littéral de gabarit, et un accent
313
+ // grave dans un COMMENTAIRE le referme. Le lint l'a refusé — « Unexpected token » sur une ligne
314
+ // qui n'est que du commentaire.
315
+ var ORIGINES_AV=(function(){var o=[];try{if(LIVECFG&&LIVECFG.supaUrl)o.push(String(LIVECFG.supaUrl));}catch(e){}return o;})();
316
+ function av(u,n){return Player.live.avatarHtml(u,n,ORIGINES_AV);}
307
317
  function isOverlay(){var pn=document.getElementById('chatPanel');return !!(window.matchMedia&&window.matchMedia('(max-width:720px)').matches)||!!(pn&&pn.classList.contains('float'));}
308
318
  // Aplatit l'état de présence en DÉDOUBLONNANT par identité (email, sinon nom) → un participant reconnecté
309
319
  // (nouveau MYID) ou un fantôme websocket non nettoyé n'apparaît qu'une fois. On garde la méta présentateur si dispo.
@@ -137,6 +137,7 @@ function viewerHtml(share, nonce, logoUrl, pitch) {
137
137
  .scroll{flex:1;overflow:auto;position:relative;touch-action:pan-x pan-y} /* ⚠️ pan-x pan-y, PAS auto : le navigateur garde le défilement et NOUS rendons le pincement. Sans cette ligne le geste à deux doigts est happé par le zoom de page avant qu'aucun événement de pointeur ne nous parvienne. Le zoom navigateur reste disponible SUR LE RESTE de l'interface — le retirer partout serait une régression d'accessibilité pour qui grossit le chrome, pas le document. */
138
138
  #pages{display:flex;flex-direction:column;align-items:center;gap:16px;width:max-content;min-width:100%;margin:0 auto;padding:22px 14px}
139
139
  .page{position:relative;background:#fff;box-shadow:0 6px 22px #0006;border-radius:3px}
140
+ .pspace{flex:0 0 auto;width:1px;visibility:hidden} /* porte la hauteur des pages ou vignettes NON materialisees */
140
141
  .page canvas{display:block;border-radius:3px}
141
142
  /* Couche texte pdf.js : invisible, superposée au canvas → sélection du texte possible (requiert --scale-factor). */
142
143
  .textLayer{position:absolute;inset:0;overflow:hidden;line-height:1;opacity:1;z-index:2;forced-color-adjust:none}
@@ -333,6 +334,7 @@ ${LEGAL_CSS}
333
334
  get cur(){return cur;},get numPages(){return numPages;},get pdfDoc(){return pdfDoc;},
334
335
  get onePage(){return onePage;},
335
336
  get reportEnAttente(){return tRestaurer!==null;},
337
+ get fenetre(){return pagesFenetre;},get vignFenetre(){return vignFenetre;},
336
338
  get soloOffered(){return soloOffered;},set soloOffered(v){soloOffered=!!v;}};
337
339
  var pdfDoc=null, zoom=1, rot=0, firstAspect=1.35, rendered={}, io=null, ioCur=null;
338
340
  // ATTENTION : LE LECTEUR GARDAIT TOUTES LES PAGES RENDUES (P1 audit externe).
@@ -348,6 +350,13 @@ ${LEGAL_CSS}
348
350
  var taches={}; // n -> RenderTask, pour pouvoir ANNULER
349
351
  var enCours={}; // n -> 1 tant que la page n a pas abouti
350
352
  var MARGE_PAGES=2; // on garde la page courante et deux de chaque cote
353
+ // FENETRE VIRTUELLE : seuls les gabarits de pages (et de vignettes) proches du visible EXISTENT
354
+ // dans le DOM ; deux espaceurs portent la hauteur des absents. Voir src/viewer.ts.
355
+ var pagesFenetre={debut:0,fin:0}, vignFenetre={debut:0,fin:0};
356
+ var MARGE_VIRTUELLE=3, ESPACE_PAGES=16, HAUT_PAGES=22; // = CSS #pages (gap, padding-top)
357
+ var VIGN_MARGE_VIRTUELLE=4, ESPACE_VIGN=10, HAUT_VIGN=12, VIGN_BORDURE=4; // = CSS .vign-in / .vg
358
+ var pagesAvant=null, pagesApres=null, vignAvant=null, vignApres=null, rafPages=0, rafVign=0;
359
+ var tacheDoc=null, docGen=0, tChargement=null, DELAI_CHARGEMENT_MS=120000;
351
360
  // ATTENTION : UNE GENERATION PAR PAGE. La generation globale (renderGen) ne bouge qu au build ;
352
361
  // une page liberee PENDANT que son rendu ou sa couche texte est en vol gardait donc la meme
353
362
  // generation, et le resultat tardif se posait sur une page remise en reserve. On incremente
@@ -588,7 +597,8 @@ ${LEGAL_CSS}
588
597
  var rotPres=Player.viewer.rotationEffective(0,d.rotation);
589
598
  if(rotPres!==rot){ rot=rotPres; if(pdfDoc||IS_IMG){ build(); if(vignOuvert) vignConstruire(); } }
590
599
  var target=Math.max(1,d.page||1);
591
- var tries=0; (function jump(){ var el=(document.getElementById('pages')||document).querySelector('.page[data-p="'+target+'"]'); if(el){ el.scrollIntoView({block:'start'}); } else if(tries++<40){ setTimeout(jump,150); } })();
600
+ if(typeof window.__allerPage==='function'){ try{ window.__allerPage(target); }catch(e){} }
601
+ var tries=0; (function jump(){ var el=(document.getElementById('pages')||document).querySelector('.page[data-p="'+target+'"]'); if(el){ if(el.scrollIntoView) el.scrollIntoView({block:'start'}); } else if(tries++<40){ setTimeout(jump,150); } })();
592
602
  }).catch(function(){});
593
603
  }
594
604
  // Carte live : persiste le contenu (present-content) → l'audience bascule/suit via Realtime.
@@ -674,6 +684,8 @@ ${LEGAL_CSS}
674
684
  // Un document d une seule page, ou une image : le panneau n aurait rien a montrer.
675
685
  if(IS_IMG){ var vb=document.getElementById('vignBtn'); if(vb) vb.style.display='none'; }
676
686
  brancherZoomAuGeste();
687
+ scrollEl.addEventListener('scroll',planifierPages,{passive:true});
688
+ if(vignIn) vignIn.addEventListener('scroll',planifierVignettes,{passive:true});
677
689
  render();
678
690
  }
679
691
  // ── ZOOM AU GESTE : apercu immediat, reconstruction differee ─────────────────────────────────
@@ -910,9 +922,19 @@ ${LEGAL_CSS}
910
922
  }
911
923
  function render(){
912
924
  if(IS_IMG){ renderImage(); return; }
925
+ // ATTENTION : UNE TACHE DE CHARGEMENT N ETAIT JAMAIS DETRUITE, NI BORNEE. Un document qui ne finit
926
+ // jamais d arriver gardait ouverts son transfert et son worker jusqu a la fermeture de l onglet.
927
+ // (Audit externe, 12/09.) Un delai global, et destroy() quand il expire ou qu une autre tache prend la place.
928
+ if(tacheDoc){ try{ tacheDoc.destroy(); }catch(e){} tacheDoc=null; }
929
+ clearTimeout(tChargement);
930
+ var genDoc=++docGen;
913
931
  var task=pdfjsLib.getDocument({url:CFG.fileUrl,isEvalSupported:false});
932
+ tacheDoc=task;
933
+ tChargement=setTimeout(function(){ if(genDoc!==docGen)return; try{ task.destroy(); }catch(e){} if(tacheDoc===task) tacheDoc=null; loadError("Le document met trop longtemps a charger."); },DELAI_CHARGEMENT_MS);
914
934
  task.onProgress=function(p){ if(p&&p.total){ var pct=Math.max(8,Math.min(99,Math.round(p.loaded/p.total*100))); var bar=document.getElementById('lbar'); if(bar)bar.classList.remove('idle'); var f=document.getElementById('lbarFill'); if(f)f.style.width=pct+'%'; var l=document.getElementById('lpct'); if(l)l.textContent=pct+' %'; } };
915
935
  task.promise.then(function(pdf){
936
+ if(genDoc!==docGen)return; // une tache plus recente a pris la place
937
+ clearTimeout(tChargement);
916
938
  pdfDoc=pdf; numPages=pdf.numPages; window.__n=pdf.numPages; T.setPageCount(pdf.numPages);
917
939
  // Un document d une seule page : le panneau de vignettes n aurait rien a montrer.
918
940
  if(numPages<2){ var vb1=document.getElementById('vignBtn'); if(vb1) vb1.style.display='none'; }
@@ -921,6 +943,42 @@ ${LEGAL_CSS}
921
943
  .catch(function(){ build(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} });
922
944
  }).catch(function(){ loadError("Impossible d'afficher ce document."); });
923
945
  }
946
+ // ── FENETRE VIRTUELLE ────────────────────────────────────────────────────────────────────
947
+ //
948
+ // ATTENTION : LE PRESENTATEUR CREAIT UN ELEMENT PAR PAGE ET UN BOUTON PAR VIGNETTE, POUR TOUT LE
949
+ // DOCUMENT. Le rendu des canvas etait deja paresseux et borne (fenetre glissante, budget de
950
+ // pixels) ; les GABARITS, eux, etaient tous la. Mesure par un audit externe dans un Chrome
951
+ // reel : 10 000 pages = ~70 000 noeuds, 50 000 pages = ~450 000, reconstruction au zoom 2,4 s.
952
+ // Un document hostile n a pas besoin d etre lourd : il lui suffit d etre long.
953
+ //
954
+ // Le calcul de la fenetre est PUR et vit dans src/viewer.ts ; ici on ne fait que reconcilier
955
+ // le DOM avec ce qu il decide. Les observateurs, l eviction des canvas et le rendu paresseux
956
+ // restent exactement ce qu ils etaient : ils voient simplement moins d elements.
957
+ //
958
+ // Note de forme : ce script vit dans un litteral de gabarit — pas d accent grave ici.
959
+ function espaceur(id){ var s=document.createElement('div'); s.id=id; s.className='pspace'; s.setAttribute('aria-hidden','true'); return s; }
960
+ // Un espaceur present ajoute un ECART de flex avant/apres lui : sa hauteur est reduite d autant, et
961
+ // un espaceur vide est retire du flux, sinon il decalerait tout de la valeur d un ecart.
962
+ function poserEspaceur(el,hauteur,ecart){ if(hauteur>0){ el.style.display=''; el.style.height=Math.max(0,hauteur-ecart)+'px'; } else { el.style.display='none'; el.style.height='0px'; } }
963
+ function hauteurPage(){ return Math.round(Math.round(targetWidth())*aspectEffectif()); }
964
+ function geoPages(){ return {hauteurElement:hauteurPage(),ecart:ESPACE_PAGES,decalageHaut:HAUT_PAGES}; }
965
+ function creerPage(i,w,h){ var d=document.createElement('div'); d.className='page ph'; d.dataset.p=i; d.style.width=w+'px'; d.style.height=h+'px'; d.textContent='Page '+i; if(io)io.observe(d); if(ioCur)ioCur.observe(d); return d; }
966
+ function retirerPage(el){ var n=+el.dataset.p; libererPage(n); try{ if(io)io.unobserve(el); if(ioCur)ioCur.unobserve(el); }catch(e){} if(el.parentNode) el.parentNode.removeChild(el); }
967
+ function reconcilierPages(force,autour){
968
+ if(!pagesEl||!pagesAvant||!pagesApres||!numPages)return;
969
+ var g=geoPages(), f;
970
+ if(onePage){ var c=autour||cur||1; var pas=Player.viewer.pasVertical(g.hauteurElement,g.ecart); var d0=Math.max(1,c-1), f0=Math.min(numPages,c+1); f={debut:d0,fin:f0,avant:(d0-1)*pas,apres:(numPages-f0)*pas}; }
971
+ else f=Player.viewer.fenetreVirtuelle({debutVisible:scrollEl.scrollTop||0,hauteurVisible:scrollEl.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages,marge:MARGE_VIRTUELLE});
972
+ if(!force&&f.debut===pagesFenetre.debut&&f.fin===pagesFenetre.fin)return;
973
+ pagesFenetre=f;
974
+ var w=Math.round(targetWidth()), h=g.hauteurElement, presents={};
975
+ var els=pagesEl.querySelectorAll('.page');
976
+ for(var i=0;i<els.length;i++){ var n=+els[i].dataset.p; if(n<f.debut||n>f.fin) retirerPage(els[i]); else presents[n]=els[i]; }
977
+ var suivant=pagesApres;
978
+ for(var p=f.fin;p>=f.debut;p--){ var el=presents[p]; if(!el){ el=creerPage(p,w,h); pagesEl.insertBefore(el,suivant); } else if(el.nextSibling!==suivant){ pagesEl.insertBefore(el,suivant); } suivant=el; }
979
+ poserEspaceur(pagesAvant,f.avant,ESPACE_PAGES); poserEspaceur(pagesApres,f.apres,ESPACE_PAGES);
980
+ }
981
+ function planifierPages(){ if(rafPages)return; var raf=window.requestAnimationFrame||function(fn){return setTimeout(fn,16);}; rafPages=raf(function(){ rafPages=0; reconcilierPages(false); }); }
924
982
  function build(){
925
983
  // Un apercu de zoom n a plus de sens des qu on reconstruit : le laisser poserait une
926
984
  // transformation orpheline sur des pages neuves. Cas reel : un redimensionnement de
@@ -940,14 +998,16 @@ ${LEGAL_CSS}
940
998
  // pas polluée par la marge de pré-rendu. Corrige le décalage d'une page présentateur ↔ audience.
941
999
  ioCur=new IntersectionObserver(function(es){es.forEach(function(e){ if(e.isIntersecting){ setCur(+e.target.dataset.p); } });},{root:scrollEl,rootMargin:Player.viewer.CURRENT_PAGE_MARGIN,threshold:0});
942
1000
  pagesEl.innerHTML='';
943
- var w=Math.round(targetWidth());
944
- for(var i=1;i<=numPages;i++){ var d=document.createElement('div'); d.className='page ph'; d.dataset.p=i; d.style.width=w+'px'; d.style.height=Math.round(w*aspectEffectif())+'px'; d.textContent='Page '+i; pagesEl.appendChild(d); io.observe(d); ioCur.observe(d); }
1001
+ pagesAvant=espaceur('pagesAvant'); pagesApres=espaceur('pagesApres'); pagesEl.appendChild(pagesAvant); pagesEl.appendChild(pagesApres);
1002
+ pagesFenetre={debut:0,fin:0};
1003
+ reconcilierPages(true,cur||1);
945
1004
  var _band=capReserve(); var _pb=document.body.classList.contains('botplayer')?(document.body.classList.contains('vsplit')?Math.round(window.innerHeight*0.38)+50:(isLand()?0:240)):(botOverlap()+(_band?_band+12:(document.body.classList.contains('deskaudio')?16:0))); pagesEl.style.paddingBottom = onePage ? (_pb+'px') : ''; // centre la page dans l'espace VISIBLE (au-dessus de la sheet mobile / du bandeau desktop / sous le header en audio seul)
946
1005
  if(onePage) showPage(cur||1);
947
1006
  }
948
1007
  // ── Mode « une seule page » : afficher / tourner une page à la fois, sans défilement ──────────────────
949
1008
  function syncArrows(){ var st=Player.viewer.arrowState(cur,numPages); var pv=document.getElementById('opPrev'), nx=document.getElementById('opNext'); if(pv)pv.disabled=st.prevDisabled; if(nx)nx.disabled=st.nextDisabled; }
950
1009
  function showPage(p){ p=Player.viewer.clampPage(p,numPages); try{ document.body.classList.toggle('pgback',(+p)<(cur||1)); }catch(e){} // sens du glissé (avant/arrière)
1010
+ reconcilierPages(true,p);
951
1011
  var els=pagesEl.querySelectorAll('.page'); for(var i=0;i<els.length;i++){ els[i].classList.toggle('cur',(+els[i].dataset.p)===p); } var el=pagesEl.querySelector('.page[data-p="'+p+'"]'); if(el){ renderPage(p,el); var nx=pagesEl.querySelector('.page[data-p="'+(p+1)+'"]'); if(nx)renderPage(p+1,nx); } setCur(p); syncArrows();
952
1012
  var pf=document.getElementById('pglineF'); if(pf&&numPages)pf.style.width=Player.viewer.progressPercent(p,numPages)+'%'; } // ligne de progression (mode présentation)
953
1013
  function enterOnePage(){ if(onePage)return; onePage=true; document.body.classList.add('onepage'); document.body.classList.add('botlock'); if(pdfDoc){ var c=cur||1; build(); showPage(c); } syncArrows(); }
@@ -972,7 +1032,10 @@ ${LEGAL_CSS}
972
1032
  function scrollToPage(p){
973
1033
  // Toute navigation explicite PERIME le report en attente : c est ce qui ferme la course.
974
1034
  clearTimeout(tRestaurer); tRestaurer=null;
975
- var el=pagesEl.querySelector('.page[data-p="'+p+'"]'); if(el) el.scrollIntoView({block:'start'});
1035
+ var el=pagesEl.querySelector('.page[data-p="'+p+'"]');
1036
+ // La page peut ne pas EXISTER encore : on se place a sa position calculee, on materialise, puis on aligne.
1037
+ if(!el){ scrollEl.scrollTop=Player.viewer.positionDe(p,geoPages()); reconcilierPages(true,p); el=pagesEl.querySelector('.page[data-p="'+p+'"]'); }
1038
+ if(el&&el.scrollIntoView) el.scrollIntoView({block:'start'});
976
1039
  }
977
1040
  // ⚠️ ET « NAVIGUER » NE SE RESUME PAS A CLIQUER. Le report ne se perimait qu au passage par
978
1041
  // scrollToPage — donc au clic sur une vignette, une fleche, le sommaire. Le lecteur qui ouvre le
@@ -1076,14 +1139,47 @@ ${LEGAL_CSS}
1076
1139
  }
1077
1140
  function vignMarquer(p){
1078
1141
  if(!vignOuvert||!vignIn)return;
1142
+ if(vignSuivi){
1143
+ // On deplace le defilement DU PANNEAU, jamais scrollIntoView : celui-ci remonte la chaine des
1144
+ // ancetres scrollables et emporterait le document avec lui. La position est CALCULEE : le
1145
+ // bouton peut ne pas exister encore, et c est ce deplacement qui le fera naitre.
1146
+ var g=geoVign();
1147
+ vignIn.scrollTop=Math.max(0,Player.viewer.positionDe(p,g)-(vignIn.clientHeight-g.hauteurElement)/2);
1148
+ reconcilierVignettes(true);
1149
+ }
1079
1150
  var els=vignIn.querySelectorAll('.vg');
1080
1151
  for(var i=0;i<els.length;i++) els[i].classList.toggle('on',(+els[i].dataset.p)===p);
1081
- if(!vignSuivi)return;
1082
- var el=vignIn.querySelector('.vg[data-p="'+p+'"]');
1083
- // On deplace le defilement DU PANNEAU, jamais scrollIntoView : celui-ci remonte la chaine des
1084
- // ancetres scrollables et emporterait le document avec lui.
1085
- if(el) vignIn.scrollTop=Math.max(0,el.offsetTop-(vignIn.clientHeight-el.offsetHeight)/2);
1086
1152
  }
1153
+ function geoVign(){ return {hauteurElement:Math.round(VIGN_LARGEUR*aspectEffectif())+VIGN_BORDURE,ecart:ESPACE_VIGN,decalageHaut:HAUT_VIGN}; }
1154
+ function vignCreer(i){
1155
+ var bt=document.createElement('button');
1156
+ bt.type='button'; bt.className='vg'; bt.dataset.p=i; bt.setAttribute('role','listitem');
1157
+ bt.setAttribute('aria-label','Aller à la page '+i);
1158
+ vignVider(bt);
1159
+ bt.addEventListener('click',function(){
1160
+ var n=+this.dataset.p; vignSuivi=true;
1161
+ if(onePage) showPage(n); else scrollToPage(n);
1162
+ vignMarquer(n);
1163
+ });
1164
+ if(vignIO) vignIO.observe(bt);
1165
+ return bt;
1166
+ }
1167
+ function vignRetirer(bt){ var n=+bt.dataset.p; try{ if(vignIO) vignIO.unobserve(bt); }catch(e){} delete vignFaites[n]; var j=vignOrdre.indexOf(n); if(j>=0) vignOrdre.splice(j,1); if(bt.parentNode) bt.parentNode.removeChild(bt); }
1168
+ // Meme fenetre virtuelle que les pages : seuls les boutons proches du visible existent.
1169
+ function reconcilierVignettes(force){
1170
+ if(!vignIn||!vignAvant||!vignApres||!vignOuvert||numPages<2)return;
1171
+ var g=geoVign();
1172
+ var f=Player.viewer.fenetreVirtuelle({debutVisible:vignIn.scrollTop||0,hauteurVisible:vignIn.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages,marge:VIGN_MARGE_VIRTUELLE});
1173
+ if(!force&&f.debut===vignFenetre.debut&&f.fin===vignFenetre.fin)return;
1174
+ vignFenetre=f;
1175
+ var presents={}, els=vignIn.querySelectorAll('.vg');
1176
+ for(var i=0;i<els.length;i++){ var n=+els[i].dataset.p; if(n<f.debut||n>f.fin) vignRetirer(els[i]); else presents[n]=els[i]; }
1177
+ var suivant=vignApres;
1178
+ for(var p=f.fin;p>=f.debut;p--){ var bt=presents[p]; if(!bt){ bt=vignCreer(p); vignIn.insertBefore(bt,suivant); } else if(bt.nextSibling!==suivant){ vignIn.insertBefore(bt,suivant); } suivant=bt; }
1179
+ poserEspaceur(vignAvant,f.avant,ESPACE_VIGN); poserEspaceur(vignApres,f.apres,ESPACE_VIGN);
1180
+ var c=cur||1, elc=presents[c]||vignIn.querySelector('.vg[data-p="'+c+'"]'); if(elc) elc.classList.add('on');
1181
+ }
1182
+ function planifierVignettes(){ if(rafVign)return; var raf=window.requestAnimationFrame||function(fn){return setTimeout(fn,16);}; rafVign=raf(function(){ rafVign=0; reconcilierVignettes(false); }); }
1087
1183
  function vignConstruire(){
1088
1184
  if(!vignIn)return;
1089
1185
  if(vignIO){ vignIO.disconnect(); vignIO=null; }
@@ -1092,18 +1188,9 @@ ${LEGAL_CSS}
1092
1188
  vignIO=new IntersectionObserver(function(es){
1093
1189
  es.forEach(function(e){ if(e.isIntersecting){ vignIO.unobserve(e.target); vignRendre(e.target); } });
1094
1190
  },{root:vignIn,rootMargin:'220px 0px'});
1095
- for(var i=1;i<=numPages;i++){
1096
- var bt=document.createElement('button');
1097
- bt.type='button'; bt.className='vg'; bt.dataset.p=i; bt.setAttribute('role','listitem');
1098
- bt.setAttribute('aria-label','Aller à la page '+i);
1099
- vignVider(bt);
1100
- bt.addEventListener('click',function(){
1101
- var n=+this.dataset.p; vignSuivi=true;
1102
- if(onePage) showPage(n); else scrollToPage(n);
1103
- vignMarquer(n);
1104
- });
1105
- vignIn.appendChild(bt); vignIO.observe(bt);
1106
- }
1191
+ vignAvant=espaceur('vignAvant'); vignApres=espaceur('vignApres'); vignIn.appendChild(vignAvant); vignIn.appendChild(vignApres);
1192
+ vignFenetre={debut:0,fin:0};
1193
+ reconcilierVignettes(true);
1107
1194
  vignMarquer(cur||1);
1108
1195
  }
1109
1196
  function basculerVignettes(){
@@ -1129,6 +1216,7 @@ ${LEGAL_CSS}
1129
1216
  if(onePage) showPage(c); else restaurerPage(c);
1130
1217
  }
1131
1218
  window.__refit=function(){ if(!pdfDoc)return; var c=cur||1; build(); restaurerPage(c); };
1219
+ window.__allerPage=scrollToPage; // le saut d audience cherchait l element par data-p : il n existe plus forcement
1132
1220
  var _rzT; window.addEventListener('resize',function(){ clearTimeout(_rzT); _rzT=setTimeout(function(){ window.__refit(); },160); });
1133
1221
  // ÉCRAN PARTAGÉ mobile : le fond au-dessus/en-dessous du document prolonge les couleurs de la page
1134
1222
  // (échantillon des bords haut/bas du canvas) — du header jusqu'à la vidéo, dynamique à chaque page.
@@ -15,7 +15,23 @@ const crypto = require("crypto");
15
15
  const ROTATIONS = [0, 90, 180, 270];
16
16
 
17
17
  let PLAYER = null;
18
- function init(ctx) { PLAYER = ctx; _bumpSansDurcissementJusqua = 0; _avertRpcPresence = false; _etatDurcissement = "inconnu"; }
18
+ /**
19
+ * ⚠️ `init` JETTE LES OBSERVATIONS, ET IL DOIT LES JETER TOUTES. Les mémos d'exécution disent ce que
20
+ * CE processus a constaté de CETTE base. Un contexte neuf peut être une autre base : reporter une
21
+ * observation d'avant, c'est rapporter une propriété de la base précédente sous le nom de la
22
+ * nouvelle.
23
+ *
24
+ * ⚠️ LE MÉMO DE LA FUSION MANQUAIT ICI, ET LES DEUX JUMEAUX SE DISENT IDENTIQUES TROIS FOIS DANS CE
25
+ * FICHIER — « même patron que 0018 », « même lecture que `etatDurcissementBootstrap`,
26
+ * délibérément ». Ils l'étaient sur le chemin de LECTURE, le seul que les bancs regardaient, et pas
27
+ * sur la remise à zéro. Une règle énoncée n'est pas une règle tenue, et celle-ci était énoncée.
28
+ */
29
+ function init(ctx) {
30
+ PLAYER = ctx;
31
+ _bumpSansDurcissementJusqua = 0; _etatDurcissement = "inconnu";
32
+ _bumpSansFusionJusqua = 0; _etatFusion = "inconnu";
33
+ _avertRpcPresence = false;
34
+ }
19
35
 
20
36
  /**
21
37
  * L'état OBSERVÉ du durcissement des bootstraps — pas sa configuration.
@@ -587,6 +603,33 @@ function premierPublic(reponse) {
587
603
  return messagePublic(row);
588
604
  }
589
605
 
606
+ /**
607
+ * ⚠️ UN AVATAR N'EST GARDÉ QUE S'IL VIENT D'UNE ORIGINE QU'ON SERT DÉJÀ — DEUXIÈME BARRIÈRE.
608
+ *
609
+ * La première est en amont : `routes-direct.js` n'accepte plus l'avatar d'un anonyme. Celle-ci
610
+ * couvre ce qui reste — l'avatar d'un MEMBRE, qui vient de son jeton, donc des métadonnées d'un
611
+ * fournisseur d'identité. Un fournisseur peut laisser son utilisateur écrire ce champ ; une
612
+ * identité prouvée prouve QUI parle, pas OÙ pointe son image.
613
+ *
614
+ * Ce qui passe : une URL relative, ou une URL dont l'origine est celle du stockage de l'hôte. Tout
615
+ * le reste devient `null`, et l'audience voit des initiales. ⚠️ C'est une dégradation VISIBLE et
616
+ * réversible — un hôte dont les avatars vivent ailleurs les sert depuis son stockage — là où la
617
+ * fuite, elle, était invisible et subie par les spectateurs.
618
+ *
619
+ * ⚠️ ON COMPARE DES ORIGINES, PAS DES PRÉFIXES. `https://<base>.attaquant.net` commence par ce
620
+ * qu'on croit reconnaître ; son origine, non.
621
+ */
622
+ function avatarAdmissible(avatar, base) {
623
+ const brut = String(avatar || "").trim();
624
+ if (!brut) return null;
625
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(brut) && !brut.startsWith("//")) return brut.slice(0, 600);
626
+ let u, b;
627
+ try { u = new URL(brut); } catch { return null; }
628
+ if (u.protocol !== "https:" && u.protocol !== "http:") return null;
629
+ try { b = new URL(String(base || "")); } catch { return null; }
630
+ return u.origin === b.origin ? brut.slice(0, 600) : null;
631
+ }
632
+
590
633
  async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment, clientKey }) {
591
634
  if (await estArchive(slug)) return REFUS_ARCHIVE;
592
635
  const b = String(body || "").trim().slice(0, 2000);
@@ -614,7 +657,7 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
614
657
  const row = {
615
658
  slug: String(slug), author_name: (name || "").trim().slice(0, 80) || null,
616
659
  author_email: (email || "").trim().toLowerCase().slice(0, 160) || null,
617
- author_avatar: (avatar || "").slice(0, 600) || null,
660
+ author_avatar: avatarAdmissible(avatar, (PLAYER.config && PLAYER.config.supabaseUrl) || ""),
618
661
  is_presenter: !!isPresenter, is_member: !!isMember, body: b, attachment: att,
619
662
  author_hash: authorToken ? sha(authorToken) : null,
620
663
  reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
@@ -110,9 +110,26 @@ const guill = (v) => encodeURIComponent('"' + String(v).replace(/\\/g, "\\\\").r
110
110
  // exige qu'il n'y en ait qu'une (server/__tests__/retentionUnePorte.test.js). Un troisième chemin
111
111
  // d'écriture DEVRA passer par ces portes, ou il rougira le compte. C'est « retirer la seconde
112
112
  // source de vérité » appliqué à la suppression : un seul endroit peut détruire.
113
- async function effacerParIds(table, colId, ids, opts) {
113
+ /**
114
+ * ⚠️ LE PRÉDICAT DE PURGE VOYAGE AVEC LE DELETE, ET C'EST TOUT LE SUJET. On SÉLECTIONNE par date,
115
+ * puis on supprimait par IDENTIFIANT SEUL — deux requêtes, et entre les deux une ligne peut
116
+ * redevenir active. Un battement qui rafraîchit `last_at` juste après le SELECT laissait une ligne
117
+ * VIVANTE se faire supprimer, sur la foi d'une date qui n'était plus la sienne. Reproduit le 12/09
118
+ * (audit externe) : le DELETE émis était `session_id=in.("vivante")` et rien d'autre.
119
+ *
120
+ * ⚠️ CE N'EST PAS UN VERROU, ET ÇA N'A PAS À L'ÊTRE. PostgREST applique TOUS les prédicats de l'URL
121
+ * au moment du DELETE : rejouer le filtre d'origine fait juger la ligne sur son état À CET
122
+ * INSTANT-LÀ. Une ligne redevenue récente ne satisfait plus `last_at=lt.<borne>` et survit. La
123
+ * fenêtre de course ne disparaît pas, elle cesse d'être DESTRUCTRICE.
124
+ *
125
+ * ⚠️ ET LE FILTRE EST UN PARAMÈTRE OBLIGATOIRE, PAS OPTIONNEL. Optionnel, il s'oublie : un
126
+ * quatrième périmètre de purge écrit dans six mois recréerait le défaut en silence, et tout
127
+ * resterait vert. `select=` ne rend que ce qui a RÉELLEMENT été supprimé, donc le compte reste
128
+ * honnête quand la base refuse une ligne au dernier moment.
129
+ */
130
+ async function effacerParIds(table, filtre, colId, ids, opts) {
114
131
  if (opts.dryRun || !ids || !ids.length) return 0;
115
- const del = await PLAYER.db.request(`${table}?${colId}=in.(${ids.map(guill).join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
132
+ const del = await PLAYER.db.request(`${table}?${filtre}&${colId}=in.(${ids.map(guill).join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
116
133
  return Array.isArray(del) ? del.length : 0; // lignes RENDUES, pas ids présélectionnés
117
134
  }
118
135
  // ⚠️ LE BUCKET EST UN PARAMÈTRE, PAS UNE CONSTANTE — ET LA PORTE RESTE UNIQUE. Deux périmètres
@@ -174,7 +191,7 @@ async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LO
174
191
  if (!Array.isArray(lot) || !lot.length) break;
175
192
  examinees += lot.length;
176
193
  curseur = lot[lot.length - 1][colId];
177
- supprimees += await effacerParIds(table, colId, lot.map((r) => r[colId]).filter((v) => v != null), { dryRun });
194
+ supprimees += await effacerParIds(table, filtre, colId, lot.map((r) => r[colId]).filter((v) => v != null), { dryRun });
178
195
  if (lot.length < limite) break; // dernier lot (moins que demandé → plus rien après)
179
196
  }
180
197
  return { examinees, supprimees, tronque };
@@ -199,7 +216,7 @@ const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
199
216
  // pour que l'appelant décide de garder ou non la présentation. Compte les lignes RENDUES.
200
217
  async function purgerMessagesPresentation(slug, opts, base, plafond) {
201
218
  const { dryRun, taille } = opts;
202
- let supprimees = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
219
+ let supprimees = 0, retenues = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
203
220
  for (;;) {
204
221
  const reste = plafond - examinees;
205
222
  if (reste <= 0) { tronque = await resteEncore("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", curseur, dryRun); break; }
@@ -209,17 +226,34 @@ async function purgerMessagesPresentation(slug, opts, base, plafond) {
209
226
  if (!Array.isArray(lot) || !lot.length) break;
210
227
  examinees += lot.length;
211
228
  curseur = lot[lot.length - 1].id;
229
+ // ⚠️ LA LIGNE NE PART QUE SI SON FICHIER EST PARTI — ET LE CONTRAIRE ÉTAIT UN DÉFAUT DE
230
+ // RÉTENTION, PAS UNE IMPRÉCISION DE COMPTAGE. La suppression était INCONDITIONNELLE : un retrait
231
+ // qui échoue laissait la ligne partir, donc le CHEMIN du fichier disparaissait avec elle. Or la
232
+ // capacité `storage` du contrat expose `put` et `remove`, JAMAIS `list` — c'est l'argument que
233
+ // ce fichier écrit lui-même vingt lignes plus bas pour justifier la migration 0021. Sans ligne,
234
+ // il n'y a « littéralement rien à parcourir » : l'objet devient inatteignable POUR TOUJOURS,
235
+ // dans un bucket, et aucun balayage ne peut le rattraper. Reproduit le 12/09 (audit externe).
236
+ //
237
+ // ⚠️ ET UNE LIGNE RETENUE EST RÉCUPÉRABLE, UN FICHIER PERDU NE L'EST PAS. Le passage suivant la
238
+ // reverra et réessaiera ; une perte irréversible ne se rattrape par rien. Entre un retard visible
239
+ // et une perte silencieuse, on garde le retard — et `retenues` le NOMME dans le rapport, sans
240
+ // quoi on aurait remplacé un défaut muet par un autre.
241
+ const aEffacer = [];
212
242
  for (const j of lot) {
213
243
  const url = j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
214
244
  const chemin = cheminSurSlug(url, slug, base); // hors du dossier du slug → null → jamais retiré (barrière 2)
215
245
  if (chemin) fichiersCandidats += 1; // compté même en dry-run (ce que la vraie purge tenterait)
216
246
  const issue = await retirerFichier("present-attachments", chemin, { dryRun });
217
247
  if (issue === true) fichiers += 1; else if (issue === false) fichiersErreur += 1; // false = échec compté
248
+ // `null` = rien tenté (pas de capacité `storage`, dry-run, ou aucun fichier) : la ligne part,
249
+ // comme avant, et le rapport ne prétend pas avoir retiré quoi que ce soit.
250
+ if (issue === false) { retenues += 1; continue; }
251
+ if (j.id != null) aEffacer.push(j.id);
218
252
  }
219
- supprimees += await effacerParIds("doc_presentation_messages", "id", lot.map((r) => r.id).filter((v) => v != null), { dryRun });
253
+ supprimees += await effacerParIds("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", aEffacer, { dryRun });
220
254
  if (lot.length < limite) break;
221
255
  }
222
- return { supprimees, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
256
+ return { supprimees, retenues, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
223
257
  }
224
258
 
225
259
  /**
@@ -241,7 +275,7 @@ async function purgerMessagesPresentation(slug, opts, base, plafond) {
241
275
  async function purgerCacheDeVoix(opts, borneDate) {
242
276
  const { dryRun, taille, plafond } = opts;
243
277
  const filtre = `created_at=lt.${enc(borneDate)}`;
244
- let supprimees = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
278
+ let supprimees = 0, retenues = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
245
279
  for (;;) {
246
280
  const reste = plafond - examinees;
247
281
  if (reste <= 0) { tronque = await resteEncore("doc_tts_objects", filtre, "hash", curseur, dryRun); break; }
@@ -251,6 +285,7 @@ async function purgerCacheDeVoix(opts, borneDate) {
251
285
  if (!Array.isArray(lot) || !lot.length) break;
252
286
  examinees += lot.length;
253
287
  curseur = lot[lot.length - 1].hash;
288
+ const aEffacer = [];
254
289
  for (const o of lot) {
255
290
  const h = o && o.hash;
256
291
  if (!h) continue;
@@ -269,18 +304,32 @@ async function purgerCacheDeVoix(opts, borneDate) {
269
304
  // ait échoué — un tiers des empreintes n'a légitimement pas de compagnon à retirer. Le compte
270
305
  // reste non masqué, mais sa lecture demande ce paragraphe : un exploitant qui découvrirait
271
306
  // deux cents « erreurs » à sa première purge chercherait une panne qui n'existe pas.
307
+ // ⚠️ C'EST L'AUDIO QUI COMMANDE LA LIGNE, PAS SON COMPAGNON — ET LA RAISON EST DÉJÀ ÉCRITE
308
+ // CI-DESSUS. Un tiers des empreintes n'a légitimement PAS de `.json` : faire dépendre la ligne
309
+ // des deux retiendrait un tiers du cache pour toujours, en croyant protéger des fichiers qui
310
+ // n'existent pas. Le `.mp3`, lui, existe toujours — c'est lui, et lui seul, dont la survie
311
+ // rendrait la ligne indispensable.
312
+ let audioPerdu = false;
272
313
  for (const suffixe of [".mp3", ".json"]) {
273
314
  fichiersCandidats += 1; // compté même en dry-run : ce que la vraie purge tenterait
274
315
  const issue = await retirerFichier("tts-cache", h + suffixe, { dryRun });
275
316
  if (issue === true) fichiers += 1; else if (issue === false) fichiersErreur += 1;
317
+ if (suffixe === ".mp3" && issue === false) audioPerdu = true;
276
318
  }
319
+ // ⚠️ SANS LA LIGNE, L'OBJET EST INATTEIGNABLE — c'est l'argument exact qui justifie l'existence
320
+ // de `doc_tts_objects` (migration 0021), écrit dans l'en-tête de cette fonction : la capacité
321
+ // `storage` expose `put` et `remove`, jamais `list`, donc « il n'y a littéralement rien à
322
+ // parcourir ». Effacer la trace d'un audio qui a résisté, c'est purger le seul moyen de le
323
+ // purger. Un audit externe l'a reproduit le 12/09 : deux objets restés, la ligne partie.
324
+ if (audioPerdu) { retenues += 1; continue; }
325
+ aEffacer.push(h);
277
326
  }
278
327
  // ⚠️ LA LIGNE PART APRÈS LES OBJETS, JAMAIS AVANT. Effacer la trace d'abord rendrait les deux
279
328
  // objets définitivement inatteignables — on aurait purgé le seul moyen de les purger.
280
- supprimees += await effacerParIds("doc_tts_objects", "hash", lot.map((r) => r.hash).filter((v) => v != null), { dryRun });
329
+ supprimees += await effacerParIds("doc_tts_objects", filtre, "hash", aEffacer, { dryRun });
281
330
  if (lot.length < limite) break;
282
331
  }
283
- return { supprimees, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
332
+ return { supprimees, retenues, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
284
333
  }
285
334
 
286
335
  async function purgerRetention(now, optsBrutes = {}) {
@@ -334,7 +383,7 @@ async function purgerRetention(now, optsBrutes = {}) {
334
383
  // des présentations ; la boucle s'arrête quand ils sont épuisés (tronque), sans supprimer les
335
384
  // parents restants. En dry-run, on parcourt quand même pour REMONTER ce que la vraie purge
336
385
  // ferait (examinés), sans jamais détruire.
337
- const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, messagesExaminees: 0, presencesExaminees: 0, fichiers: 0, fichiersErreur: 0, fichiersCandidats: 0, tronque: troncPres };
386
+ const presRapport = { examinees: 0, supprimees: 0, retenues: 0, messages: 0, presences: 0, messagesExaminees: 0, presencesExaminees: 0, fichiers: 0, fichiersErreur: 0, fichiersCandidats: 0, tronque: troncPres };
338
387
  let budgetMessages = opts.plafond, budgetPresences = opts.plafond;
339
388
  for (const p of (Array.isArray(mortes) ? mortes : [])) {
340
389
  const slug = p && p.slug; if (!slug) continue;
@@ -346,6 +395,7 @@ async function purgerRetention(now, optsBrutes = {}) {
346
395
  presRapport.fichiers += msgs.fichiers;
347
396
  presRapport.fichiersErreur += msgs.fichiersErreur;
348
397
  presRapport.fichiersCandidats += msgs.fichiersCandidats;
398
+ presRapport.retenues += msgs.retenues;
349
399
  budgetMessages -= opts.dryRun ? msgs.examinees : msgs.supprimees;
350
400
  // Présences : interrogées AUSSI en dry-run (pour remonter presencesExaminees), suppression
351
401
  // no-op via effacerParIds. Budget global partagé.
@@ -357,7 +407,14 @@ async function purgerRetention(now, optsBrutes = {}) {
357
407
  // (P2 onzième audit — sinon la supervision croit la purge complète alors qu'un reste subsiste).
358
408
  presRapport.tronque = presRapport.tronque || msgs.tronque || pres.tronque;
359
409
  // La présentation n'est supprimée que si TOUS ses enfants sont partis (9e audit).
360
- if (!opts.dryRun && !msgs.tronque && !pres.tronque) {
410
+ //
411
+ // ⚠️ ET « RETENU » EST UNE FAÇON DE NE PAS ÊTRE PARTI QUE CETTE CONDITION NE CONNAISSAIT PAS.
412
+ // Elle ne regardait que `tronque` — « il en reste pour le prochain passage ». Le correctif du
413
+ // 12/09 introduit une SECONDE façon : un message dont le fichier a résisté est gardé exprès.
414
+ // Sans cette ligne, le parent serait supprimé au-dessus d'un enfant retenu — exactement
415
+ // l'orphelin que le neuvième audit avait fermé, rouvert par le correctif d'un autre défaut.
416
+ // Une réparation qui recrée ailleurs ce qu'elle ferme ici ne répare rien.
417
+ if (!opts.dryRun && !msgs.tronque && !pres.tronque && !msgs.retenues) {
361
418
  presRapport.supprimees += (await purgerParLots("doc_presentations", `slug=eq.${enc(slug)}`, "slug", opts)).supprimees;
362
419
  }
363
420
  }
@@ -199,7 +199,13 @@ async function traiter(req, res, body, _slug) {
199
199
  key: profil ? lcMembre(profil.email) : cleAnon,
200
200
  name: (profil && profil.name) || body.name,
201
201
  email: profil ? profil.email : body.email,
202
- avatar: (profil && profil.avatar) || body.avatar,
202
+ // ⚠️ UN ANONYME N'A PAS D'AVATAR, ET C'EST LA RÈGLE ÉCRITE PLUS HAUT APPLIQUÉE JUSQU'AU
203
+ // BOUT : « une identité prouvée REMPLACE celle qu'on affirme ». L'avatar d'un membre
204
+ // vient de son jeton ; celui d'un anonyme n'était qu'une URL affirmée, resservie à
205
+ // TOUTE l'audience dans une `<img>`. Ce n'est pas un XSS — c'est un pixel de suivi :
206
+ // l'IP, l'agent et l'heure de chaque spectateur partent chez qui a écrit l'URL.
207
+ // Rapporté par un audit externe le 12/09. Les initiales prennent la place.
208
+ avatar: profil ? profil.avatar : null,
203
209
  // ⚠️ ON N'AFFIRME PLUS LE TITRE, ON FOURNIT LA PREUVE (`controlHash`, plus bas) : c'est
204
210
  // la base qui compare. Affirmer `false` ici n'enlève donc rien — le titre vient du jeton.
205
211
  isMember: !!profil, isPresenter: false,
@@ -298,7 +304,13 @@ async function traiter(req, res, body, _slug) {
298
304
  const r = await addMessage(String(body.slug || ""), {
299
305
  name: (profil && profil.name) || body.name,
300
306
  email: profil ? profil.email : body.email,
301
- avatar: (profil && profil.avatar) || body.avatar,
307
+ // ⚠️ UN ANONYME N'A PAS D'AVATAR, ET C'EST LA RÈGLE ÉCRITE PLUS HAUT APPLIQUÉE JUSQU'AU
308
+ // BOUT : « une identité prouvée REMPLACE celle qu'on affirme ». L'avatar d'un membre
309
+ // vient de son jeton ; celui d'un anonyme n'était qu'une URL affirmée, resservie à
310
+ // TOUTE l'audience dans une `<img>`. Ce n'est pas un XSS — c'est un pixel de suivi :
311
+ // l'IP, l'agent et l'heure de chaque spectateur partent chez qui a écrit l'URL.
312
+ // Rapporté par un audit externe le 12/09. Les initiales prennent la place.
313
+ avatar: profil ? profil.avatar : null,
302
314
  isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment , clientKey: body.clientKey });
303
315
  return jp(r.ok ? 200 : (r.status || 400), r);
304
316
  } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
@@ -298,11 +298,15 @@ async function traiter(req, res, body, slug) {
298
298
  const allowed = await PLAYER.limits.allow(`reshare:${ip}`, 8, 3600);
299
299
  if (!allowed) return j(429, { ok: false, error: "rate", message: "Trop de partages, réessayez plus tard." });
300
300
  let out = null;
301
- try { out = await createReshare(body.slug || slug, { email: mail, name: body.name }); } catch { /* parent introuvable */ }
301
+ try { out = await createReshare(body.slug || slug, { email: mail, name: body.name, clientKey: body.clientKey }); } catch { /* parent introuvable */ }
302
302
  if (!out) return j(404, { ok: false });
303
303
  let sent = false;
304
304
  let refusEnvoi = null;
305
- if (body.send) {
305
+ // ⚠️ UN LIEN IDEMPOTENT NE RENVOIE PAS DE COURRIER, ET C'EST L'AUTRE MOITIÉ DU CORRECTIF.
306
+ // Rendre le même enfant tout en réexpédiant laisserait le défaut entier : le destinataire
307
+ // reçoit deux messages, ce qui est exactement ce qu'on répare. Trouvé par le banc de
308
+ // l'idempotence, qui rougissait sur un lien pourtant correctement dédoublonné.
309
+ if (body.send && !out.idempotent) {
306
310
  try {
307
311
  const parent = await getShareBySlug(body.slug || slug);
308
312
  // ⚠️ ON N'ENVOIE DE COURRIER QUE POUR UN LIEN QUI A UN DESTINATAIRE.
@@ -360,12 +364,36 @@ async function traiter(req, res, body, slug) {
360
364
  const origin = publique;
361
365
  const r = await sendReshareEmail({ parent, childSlug: out.slug, origin, toEmail: mail, toName: body.name });
362
366
  sent = !!(r && r.sent);
367
+ if (!sent) refusEnvoi = refusEnvoi || "host-declined";
363
368
  } catch { /* best-effort : le lien existe quand même */ }
364
369
  }
370
+ // ⚠️ `sent: false` MENTAIT QUAND LA VÉRITÉ ÉTAIT « JE NE SAIS PAS », ET C'EST CE MENSONGE QUI
371
+ // DUPLIQUE LES COURRIERS.
372
+ //
373
+ // Trois issues se ressemblaient dans un seul booléen : l'hôte a refusé, nous avons refusé,
374
+ // ou l'appel a échoué SANS que nous sachions ce que l'hôte a fait. Le dernier cas est le
375
+ // seul dangereux : si l'hôte a réellement envoyé puis répondu trop tard, un client qui lit
376
+ // « false » réessaie — et crée un SECOND lien enfant en envoyant un SECOND courrier.
377
+ // Relevé par un audit externe le 12/09.
378
+ //
379
+ // ⚠️ C'EST LA DOCTRINE DE `bot-tts` RETOURNÉE. Là-bas, « je n'ai pas pu vérifier » doit se
380
+ // lire NON, parce que le doute empêche une dépense. Ici, le doute lu comme « non » PROVOQUE
381
+ // la dépense, parce que quelqu'un réessaie. La règle constante n'est pas « dans le doute,
382
+ // non » : c'est « dans le doute, DIS-LE » — et laisse l'appelant choisir, en sachant.
383
+ //
384
+ // `sent` reste, inchangé, pour les intégrations qui le lisent déjà. `delivery` porte les
385
+ // trois états. L'IDEMPOTENCE VRAIE — une clé qui ferait retomber un réessai sur le MÊME
386
+ // lien enfant — demande une colonne, donc une migration : elle n'est pas ici, et
387
+ // `docs/HOST-CONTRACT.md` dit ce que l'appelant doit faire en attendant.
388
+ const delivery = out.idempotent ? "idempotent"
389
+ : !body.send ? "not-requested"
390
+ : sent ? "sent"
391
+ : refusEnvoi ? "refused"
392
+ : "unknown";
365
393
  // Le refus se DIT : « rien n'est parti » et « l'envoi n'était pas permis » ne se
366
394
  // ressemblent pas, et une interface qui les confond propose un bouton qui ne marchera
367
395
  // jamais.
368
- return j(200, { ok: true, slug: out.slug, sent, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
396
+ return j(200, { ok: true, slug: out.slug, sent, delivery, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
369
397
  }
370
398
  // ⚠️ CE REPLI NE COUVRE QUE LES ÉVÉNEMENTS ANALYTIQUES (P2 huitième audit). Une action POST
371
399
  // qu'aucune famille n'a reconnue tombait ici et repartait `{"ok":true}` — une faute de
package/server/shares.js CHANGED
@@ -67,7 +67,7 @@ async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail,
67
67
 
68
68
  // Re-partage depuis la visionneuse publique (forward) : crée un lien ENFANT tracé pour un nouveau
69
69
  // destinataire, rattaché au lien parent (parent_slug) → chaîne de diffusion. created_by = celui qui forwarde.
70
- async function createReshare(parentSlug, { email, name }) {
70
+ async function createReshare(parentSlug, { email, name, clientKey }) {
71
71
  const parent = await getShareBySlug(parentSlug);
72
72
  if (!parent) throw Object.assign(new Error("lien introuvable"), { statusCode: 404 });
73
73
  const slug = newSlug();
@@ -103,7 +103,44 @@ async function createReshare(parentSlug, { email, name }) {
103
103
  //
104
104
  // `created_at` est retiré : la base le pose. `is_test` est hérité — un lien de répétition dont
105
105
  // un enfant compterait dans les vraies statistiques les fausserait.
106
+ // ⚠️ LA CLÉ D'IDEMPOTENCE EXISTE DÉJÀ SUR CETTE TABLE, ET AJOUTER LA MIENNE AURAIT ÉTÉ UN
107
+ // DOUBLON. `idem_key` (migration 0011) est globalement unique quand elle est renseignée, et sert
108
+ // depuis toujours au chemin serveur-à-serveur. Elle n'avait simplement jamais été offerte au
109
+ // re-partage. Une colonne neuve aurait donné deux mécanismes d'idempotence à la même table, dont
110
+ // un seul aurait été contraint par l'autre : le pire des deux mondes.
111
+ //
112
+ // ⚠️ ET LA CLÉ DE L'APPELANT EST EMPREINTÉE ICI, PAS RECOPIÉE. Le format est « genre:sha256 » et
113
+ // les genres existants — `hote`, `repetition` — désignent des liens SYSTÈME. Recopier une chaîne
114
+ // fournie laisserait un appelant écrire « hote:… » et entrer en collision avec le lien système
115
+ // d'un document : la contrainte d'unicité ferait alors retomber son re-partage sur ce lien-là.
116
+ // Le genre est donc posé par nous, et la chaîne de l'appelant n'est qu'un composant parmi
117
+ // d'autres — le parent en fait partie, sinon la même clé réutilisée ailleurs collisionnerait.
118
+ const cle = clientKey
119
+ ? cleIdempotence("repartage", [parent.slug, low(email) || "", String(clientKey).slice(0, 200)])
120
+ : null;
121
+ const cleDispo = cle ? await require("./schema.js").attendue("liensUniques") : false;
122
+
123
+ // ⚠️ ON REGARDE AVANT D'ÉCRIRE, PUIS APRÈS AVOIR ÉCHOUÉ. Le premier coup d'œil sert le cas
124
+ // courant — un réessai après un délai dépassé ; l'index unique sert le cas concurrent, où deux
125
+ // réessais partent ensemble. Une lecture seule est une course ; une contrainte seule transforme
126
+ // un réessai ordinaire en erreur. C'est le même patron que `createShare` deux fonctions plus haut.
127
+ if (cleDispo) {
128
+ const existant = await reshareParCle(cle);
129
+ if (existant) return { slug: existant.slug, docTitle: parent.doc_title, idempotent: true };
130
+ }
131
+
132
+ // ⚠️ LA CLÉ DU PARENT NE S'HÉRITE PAS — elle désigne le lien du PARENT, et l'index unique la
133
+ // refuserait sur un second porteur. C'est très exactement la décision que le commentaire
134
+ // ci-dessus réclame d'écrire plutôt que d'oublier.
135
+ //
136
+ // ⚠️ ET C'EST UN `delete`, PAS UN DÉSTRUCTURAGE, POUR UNE RAISON DE GARDE. Écrit
137
+ // `{ idem_key: _herite, ...reste }`, ce retrait ressemble à une ÉCRITURE pour
138
+ // `colonneMigreeConditionnelle.test.js`, qui cherche `idem_key\s*:` hors d'une portée
139
+ // conditionnelle — et qui a raison de ne pas tenter de distinguer les deux par expression
140
+ // régulière. On ne relâche pas la garde : on écrit l'exclusion sous une forme qui ne se confond
141
+ // avec rien.
106
142
  const { created_at: _cree, ...herite } = parent;
143
+ delete herite.idem_key;
107
144
  const row = {
108
145
  ...herite,
109
146
  slug,
@@ -112,9 +149,27 @@ async function createReshare(parentSlug, { email, name }) {
112
149
  created_by: parent.recipient_email || parent.created_by || null,
113
150
  parent_slug: parent.slug,
114
151
  revoked: false,
152
+ ...(cleDispo ? { idem_key: cle } : {}),
115
153
  };
116
- await PLAYER.db.request("commercial_doc_shares", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
117
- return { slug, docTitle: parent.doc_title };
154
+ try {
155
+ await PLAYER.db.request("commercial_doc_shares", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
156
+ } catch (erreur) {
157
+ // ⚠️ SANS CLÉ DISPONIBLE, ON NE RATTRAPE RIEN : une erreur d'écriture doit remonter. « Déjà là »
158
+ // n'est une réussite que lorsqu'une clé a été posée pour que ce soit le cas.
159
+ if (!cleDispo) throw erreur;
160
+ const existant = await reshareParCle(cle);
161
+ if (existant) return { slug: existant.slug, docTitle: parent.doc_title, idempotent: true };
162
+ throw erreur;
163
+ }
164
+ return { slug, docTitle: parent.doc_title, idempotent: false };
165
+ }
166
+
167
+ /** Le lien déjà créé pour cette clé d'idempotence, s'il existe. */
168
+ async function reshareParCle(cle) {
169
+ try {
170
+ const rows = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${enc(String(cle))}&select=slug&limit=1`);
171
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
172
+ } catch { return null; }
118
173
  }
119
174
 
120
175
  async function getShareBySlug(slug) {
@@ -16,9 +16,13 @@
16
16
  -- indéfiniment — un visiteur régulier ne verrait jamais son compteur repartir de zéro.
17
17
  --
18
18
  -- Sans lui : le player laisse passer, et le dit une fois dans son journal en nommant ce fichier.
19
- -- Les limites restent celles d'avant — comptées par instance et non atomiques — donc plusieurs
20
- -- requêtes simultanées peuvent dépasser le plafond ensemble. Rien ne casse, rien ne se ferme : un
21
- -- 429 raté coûte moins cher qu'une visionneuse morte, et une garde qui s'ouvre doit le dire.
19
+ -- ⚠️ ET CE QUI RESTE ALORS EST LE COMPTEUR LOCAL SEUL, PAS UN COMPTAGE PARTAGÉ DÉGRADÉ. Cette
20
+ -- phrase disait « comptées par instance ET NON ATOMIQUES » : la première moitié est exacte, la
21
+ -- seconde nomme un mode qui n'existe pas. Sans cette migration l'étage partagé ne compte pas moins
22
+ -- bien, il ne compte PAS — une limite de 120/h en autorise 120 par exécution. Dire « non atomique »
23
+ -- laissait croire qu'un plafond d'instance tient encore, en moins précis.
24
+ -- Rien ne casse, rien ne se ferme : un 429 raté coûte moins cher qu'une visionneuse morte, et une
25
+ -- garde qui s'ouvre doit le dire — mais elle doit dire JUSTE ce qu'elle laisse passer.
22
26
  --
23
27
  -- Applicable pendant que la version précédente tourne : tant que le code ne l'appelle pas, elle
24
28
  -- ne fait rien ; dès qu'il l'appelle, elle compte juste.