discovery-media-player 0.1.166 → 0.1.167

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.
@@ -18,6 +18,20 @@
18
18
  const crypto = require("node:crypto");
19
19
  const storage = require("./storage");
20
20
 
21
+ // ⚠️ « JAMAIS BLOQUANT » NE TENAIT QUE POUR UNE EXCEPTION SYNCHRONE, ICI AUSSI. Le cœur a reçu
22
+ // `server/capture.js` à la sixième passe de l'audit ; ce contexte gardait cinq appels directs à
23
+ // `journal.capture` sous un `try/catch`, et un helper en ligne dans `appelHote`. Or `ctx.errors` et le
24
+ // journal capturé par ces capacités sont LE MÊME objet : un hôte qui pose un `capture` qui rejette
25
+ // tuait le processus à `mail.send` sans secret, avant tout réseau (reproduit par l'audit, septième
26
+ // passe, 14/09). Même règle que le cœur, reprise ici sans l'importer — le contexte ne dépend pas du
27
+ // serveur — et UNE seule forme : tout appel au journal passe par ici, un banc structurel le tient.
28
+ function capturerJournalSansBloquer(journal, erreur, meta) {
29
+ try {
30
+ const resultat = journal && typeof journal.capture === "function" ? journal.capture(erreur, meta) : undefined;
31
+ if (resultat && typeof resultat.then === "function") resultat.then(undefined, () => { /* un journal ne doit jamais interrompre le traitement */ });
32
+ } catch { /* exception synchrone : même règle */ }
33
+ }
34
+
21
35
  /**
22
36
  * Retire les barres finales d'une base d'URL.
23
37
  *
@@ -228,7 +242,7 @@ async function appelHote(url, secret, corps, errors) {
228
242
  // indiscernable de « le droit est refusé », et on cherche pendant une demi-journée du côté des
229
243
  // rôles. Un hôte qui a écrit sa route sur la description du contrat plutôt que sur le code a
230
244
  // perdu exactement ce temps-là.
231
- const signaler = (quoi) => { try { errors && errors.capture(new Error(`route hôte : ${quoi}`), { url }); } catch { /* jamais bloquant */ } };
245
+ const signaler = (quoi) => capturerJournalSansBloquer(errors, new Error(`route hôte : ${quoi}`), { url });
232
246
  try {
233
247
  const r = await fetchBorne(url, {
234
248
  method: "POST",
@@ -333,7 +347,7 @@ function creerLimites(db, journal, horloge = () => Date.now()) {
333
347
  function prevenirUneFois(message) {
334
348
  if (deja === message) return;
335
349
  deja = message;
336
- try { journal.capture(new Error(message), { route: "limits" }); } catch { /* jamais bloquant */ }
350
+ try { capturerJournalSansBloquer(journal, new Error(message), { route: "limits" }); } catch { /* jamais bloquant */ }
337
351
  }
338
352
 
339
353
  async function tablePresente() {
@@ -346,7 +360,7 @@ function creerLimites(db, journal, horloge = () => Date.now()) {
346
360
  if (!signale) {
347
361
  signale = true;
348
362
  try {
349
- journal.capture(new Error(
363
+ capturerJournalSansBloquer(journal, new Error(
350
364
  "compteurs de débit non partagés : appliquez supabase/migrations/0003-limites-partagees.sql. "
351
365
  + "Sans elle, chaque instance compte pour elle seule et les limites sont plus lâches qu'annoncé.",
352
366
  ), { route: "limits" });
@@ -546,7 +560,7 @@ function createStandaloneContext(env = process.env) {
546
560
  const secret = String(env.PLAYER_HOST_MAIL_SECRET || "");
547
561
  if (!url) return null;
548
562
  if (!secret) {
549
- try { journal.capture(new Error("PLAYER_HOST_MAIL_URL est configurée sans PLAYER_HOST_MAIL_SECRET : aucun envoi ne partira"), {}); } catch { /* ignore */ }
563
+ try { capturerJournalSansBloquer(journal, new Error("PLAYER_HOST_MAIL_URL est configurée sans PLAYER_HOST_MAIL_SECRET : aucun envoi ne partira"), {}); } catch { /* ignore */ }
550
564
  return null;
551
565
  }
552
566
  const reponse = await appelHote(url, secret, message, journal);
@@ -591,7 +605,7 @@ function createStandaloneContext(env = process.env) {
591
605
  if (emetteur && !cle) {
592
606
  // Le refus silencieux est le piège de cette configuration : sans clé, chaque membre est
593
607
  // simplement « non authentifié », ce qui ressemble à un droit manquant. On le dit.
594
- try { journal.capture(new Error("PLAYER_AUTH_URL est configurée sans PLAYER_AUTH_KEY : aucun jeton ne peut être vérifié"), {}); } catch { /* ignore */ }
608
+ try { capturerJournalSansBloquer(journal, new Error("PLAYER_AUTH_URL est configurée sans PLAYER_AUTH_KEY : aucun jeton ne peut être vérifié"), {}); } catch { /* ignore */ }
595
609
  }
596
610
  if (!jeton || !url || !cle) return null;
597
611
  try {
@@ -757,7 +771,7 @@ function createStandaloneContext(env = process.env) {
757
771
  // parfaitement intentionnée — vaut refus, et le dit. C'est le cas le plus courant au
758
772
  // branchement d'un nouvel hôte.
759
773
  if (reponse && typeof reponse.allowed !== "boolean") {
760
- try { journal.capture(new Error("route d'autorisation : champ `allowed` booléen attendu"), { recu: Object.keys(reponse).join(",") }); } catch { /* ignore */ }
774
+ try { capturerJournalSansBloquer(journal, new Error("route d'autorisation : champ `allowed` booléen attendu"), { recu: Object.keys(reponse).join(",") }); } catch { /* ignore */ }
761
775
  }
762
776
  return reponse ? reponse.allowed === true : false;
763
777
  },
@@ -847,11 +861,15 @@ function createStandaloneContext(env = process.env) {
847
861
  // qui ramène un tel délai à 1 ms (audit externe, cinquième passe). Les plages vivent dans
848
862
  // `server/bornes.js`, et le cœur les applique à `init` en DISANT une fois ce qu'il a refusé —
849
863
  // si on bornait ici, il ne verrait qu'une valeur valide et l'exploitant ne saurait jamais.
850
- maxConcurrentRelays: env.PLAYER_MAX_RELAYS ? Number(env.PLAYER_MAX_RELAYS) : 64,
864
+ // ⚠️ ET « TEL QUEL » VEUT DIRE LA CHAÎNE, PAS `Number(chaîne)`. La seconde écriture convertissait
865
+ // encore : « abc » arrivait au cœur en NaN et le diagnostic disait `relayStallMs=NaN` — l'exploitant
866
+ // ne retrouvait pas ce qu'il avait saisi (audit, sixième passe). La chaîne passe intacte ; absente
867
+ // ou vide, le cœur applique le défaut sans avertir (« non posé »).
868
+ maxConcurrentRelays: env.PLAYER_MAX_RELAYS,
851
869
  // Un relais sans progression pendant relayStallMs, ou plus long que relayMaxMs, est abandonné
852
870
  // (source et réponse détruites) : sans ça, un client qui cesse de lire garde sa place pour toujours.
853
- relayStallMs: env.PLAYER_RELAY_STALL_MS ? Number(env.PLAYER_RELAY_STALL_MS) : 30_000,
854
- relayMaxMs: env.PLAYER_RELAY_MAX_MS ? Number(env.PLAYER_RELAY_MAX_MS) : 900_000,
871
+ relayStallMs: env.PLAYER_RELAY_STALL_MS,
872
+ relayMaxMs: env.PLAYER_RELAY_MAX_MS,
855
873
 
856
874
  /**
857
875
  * Clé de `localStorage` sous laquelle VOTRE application range la session de ses membres.
@@ -185,7 +185,7 @@ these numbers from their side.
185
185
  | `routes` | one entry per family of work — `document`, `presentation`, `action`, `fichier`, `carte`, `autre`. Families absent from the object were never exercised in this process |
186
186
  | `base` | the same shape, for calls through the `db` capability **you** supply — measured at the seam, so it covers every call, including ones nobody has written yet |
187
187
  | `statuts` | responses by class: `ok` (<400), `refus4xx`, `debit429`, `occupe503`, `erreur5xx` |
188
- | `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request |
188
+ | `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request. ⚠️ **Half a number**: it is only judicable against the memory ceiling of the process, which the player does not know and no platform serves the same way — on Lambda-based functions (Vercel included) read `AWS_LAMBDA_FUNCTION_MEMORY_SIZE`; in a container, the cgroup limit. A host spent half a day finding that its project API, its logs and its `vercel.json` all left it out (14/09). Display the ceiling beside the RSS, or the RSS says nothing about the relay ceiling you can afford |
189
189
  | `boucleMs` | event-loop **delay** — `moyen` and `p99` in ms, with `n` samples and the sampler's `resolutionMs` |
190
190
 
191
191
  ⚠️ **A percentile over buckets is a bound, not a value.** `p95sousMs: 250` reads *"95% of calls
@@ -238,6 +238,36 @@ saturate*; on a process that started four seconds ago it says *nobody has looked
238
238
  same trap as `inconnu` in the two rows above, and the reason the three keys are returned as one
239
239
  object rather than as separate fields you could read apart.
240
240
 
241
+ ⚠️ **Read `fenetreS` first — and on serverless, expect it to stay short.** Every counter on this
242
+ card belongs to the process, and on a serverless platform the process is the unit that dies: a host
243
+ read its two domains a few minutes apart and got windows of 15 s and 19 s, then 4 s and 7 s — the
244
+ processes had been recycled in between (14/09). There, `total: 0` over 15 seconds means *nothing in
245
+ the last quarter of a minute*, which is almost no information, and these counters can structurally
246
+ never accumulate more than a cold start's lifetime. This is not a defect of the field — on a
247
+ long-lived process it says what it should — it is a limit of applicability, and `fenetreS` is the
248
+ key that reveals it. So the reading order is: `fenetreS`, then `total`; and a fleet of short windows
249
+ is a fact about your hosting, never a reassurance. ⚠️ **And sampling from outside does not repair a
250
+ short window — it inherits it.** A host did the arithmetic (14/09): a daily cron reading a counter
251
+ whose window is ~15 s observes 15 × 365 = 5 475 seconds a year out of 31 536 000, 0.017 % of the
252
+ time; an hourly one, 0.42 %. A `total: 0` collected 365 times a year says exactly what it says once,
253
+ with the added look of a time series — the credibility of a surveillance without the surveillance.
254
+ The only form that would work on serverless is a **push at the end of the process**, because the
255
+ process is the one entity that knows its own total and it dies without saying it. The player does
256
+ not do that today, and no serverless platform guarantees a hook to do it in; this paragraph states
257
+ the limit with its way out, so that nobody builds the sampler first.
258
+ The same applies to `relaisRefuses` below and to the **counters** under `mesures` (`statuts`,
259
+ `routes`, `base`).
260
+
261
+ ⚠️ **Three natures share this card, and the rule above covers only the first.** An earlier version
262
+ of this paragraph said it applied to "everything under `mesures`" — too broad, and a host applied
263
+ the counter rule to a gauge because nothing told it the gauge was not covered (14/09).
264
+
265
+ | nature | examples | what qualifies it | what saves it |
266
+ |---|---|---|---|
267
+ | **counter** | `lectureSaturee`, `relaisRefuses`, `mesures.statuts` | accumulates over `fenetreS` | read `fenetreS` first — a long window makes a zero informative |
268
+ | **gauge** | `mesures.memoireMio` | the state **at the instant of the read** (`process.memoryUsage()`); no window enters its production | **nothing on the card**: a process idle for a week shows the same ~64 MiB as one born a minute ago, and a long window would only make it *look* trustworthy. It says nothing about what the process would weigh under load; the only way to learn that is to read it *during* a load — which an audit can provoke and a host must not on real readers |
269
+ | **sample** | `mesures.boucleMs` | `{ n: 0, moyen: null, p99: null }` until something was observed | itself — it refuses to answer rather than return an interpretable zero |
270
+
241
271
  ⚠️ **It is process-local.** Behind a load balancer this is the count of the instance that answered,
242
272
  not of your deployment. Aggregating is your job — and letting you believe otherwise would be worse
243
273
  than returning nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.166",
3
+ "version": "0.1.167",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
package/server/bornes.js CHANGED
@@ -29,7 +29,11 @@ const RELAIS_MS_MIN = 1, RELAIS_MS_MAX = 86_400_000;
29
29
  function entierBorne(v, { defaut, min, max }) {
30
30
  if (v === undefined || v === null || v === "") return { valeur: defaut, valide: true, posee: false };
31
31
  // Un nombre, ou une chaîne (l'environnement n'en connaît pas d'autre) — jamais un booléen, que
32
- // `Number(true)` transformerait en un délai de 1 ms parfaitement « valide ».
32
+ // `Number(true)` transformerait en un délai de 1 ms parfaitement « valide ». ⚠️ La famille est plus
33
+ // large que les booléens, et un hôte l'a payée : `Number(null)` et `Number("")` valent 0, et chez
34
+ // lui une absence devenait une coordonnée (0, 0) valide, en six endroits dont un qui débitait un
35
+ // quota (ADV, 14/09). L'ORDRE des opérations est le seul remède général : l'absence est écartée
36
+ // ci-dessus AVANT toute conversion, et 0 tombe ensuite sous `min`. Ce n'est pas un hasard.
33
37
  const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.trim()) : NaN;
34
38
  const valide = Number.isSafeInteger(n) && n >= min && n <= max;
35
39
  return { valeur: valide ? n : defaut, valide, posee: true };
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright © 2026 3D Discovery
3
+ // « JAMAIS BLOQUANT » NE TENAIT QUE POUR UNE EXCEPTION SYNCHRONE.
4
+ //
5
+ // ⚠️ TRENTE-CINQ APPELS À `errors.capture` PORTAIENT LE COMMENTAIRE « jamais bloquant » SOUS UN
6
+ // `try/catch` — QUI N'ATTRAPE QU'UNE EXCEPTION SYNCHRONE. Le contrat autorise `capture` à rendre une
7
+ // promesse ; une promesse REJETÉE échappait au `catch`, remontait en `unhandledRejection`, et Node
8
+ // arrête le processus : reproduit par un audit externe sur le tag v0.1.166, à `init`, avec un
9
+ // `capture` qui rejette et une configuration hors plage — sortie 1 avant le premier octet servi
10
+ // (sixième passe, 14/09). Un journal qui échoue ne doit rien arrêter, ni de façon synchrone ni de
11
+ // façon asynchrone : c'est la même règle, et elle vit ici, une fois, pour tous les appelants.
12
+ //
13
+ // ⚠️ On n'ATTEND pas la promesse — on la neutralise. Attendre ferait dépendre chaque réponse de la
14
+ // latence du journal de l'hôte ; ici on veut juste qu'un rejet ne tue personne.
15
+
16
+ /**
17
+ * Confie une erreur au journal de l'hôte sans que le journal puisse, lui, faire échouer quoi que
18
+ * ce soit — ni par une exception, ni par une promesse rejetée. `errors` peut être absent.
19
+ */
20
+ function capturerSansBloquer(errors, erreur, meta) {
21
+ try {
22
+ const r = errors && typeof errors.capture === "function" ? errors.capture(erreur, meta) : undefined;
23
+ if (r && typeof r.then === "function") r.then(undefined, () => { /* un journal qui échoue ne doit rien arrêter */ });
24
+ } catch { /* exception synchrone : même règle */ }
25
+ }
26
+
27
+ module.exports = { capturerSansBloquer };
package/server/handler.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // - GET /doc/:slug?file=1 → stream le PDF depuis le Storage (MÊME ORIGINE → pas de souci CORS pour pdf.js)
7
7
  // - POST /api/doc {slug,event…}→ journalise un événement (open / page / heartbeat) — best-effort
8
8
  const crypto = require("crypto");
9
+ const { capturerSansBloquer } = require("./capture");
9
10
  const { Readable } = require("node:stream");
10
11
  const { pipeline } = require("node:stream/promises");
11
12
  const { getShareBySlug } = require("./shares");
@@ -54,7 +55,7 @@ function init(ctx) {
54
55
  if (bornes.invalides.length) {
55
56
  // Une fois, ici — pas à chaque relais : un réglage hors plage est une erreur de déploiement, dite
56
57
  // à l'exploitant avec la plage, plutôt qu'un défaut appliqué en silence.
57
- try { PLAYER.errors.capture(new Error(`réglages de relais hors plage, défauts appliqués : ${bornes.invalides.join(" ; ")}`), { route: "relais", benin: true }); } catch { /* jamais bloquant */ }
58
+ try { capturerSansBloquer(PLAYER.errors, new Error(`réglages de relais hors plage, défauts appliqués : ${bornes.invalides.join(" ; ")}`), { route: "relais", benin: true }); } catch { /* jamais bloquant */ }
58
59
  }
59
60
  // Le domaine reçoit le même contexte : une seule construction pour tout le player.
60
61
  require("./shares").init(ctx);
@@ -106,7 +107,7 @@ async function relayerSousAdmission(res, travail) {
106
107
  // Une fois par heure, l'exploitant l'apprend : un 503 muet ressemble à une panne d'amont.
107
108
  try {
108
109
  if (await PLAYER.limits.allow("relais:sature-avert", 1, 3600)) {
109
- PLAYER.errors.capture(new Error(`relais refusés : ${plafondRelais} transferts simultanés atteints dans ce processus (config.maxConcurrentRelays)`), { route: "relais", benin: true });
110
+ capturerSansBloquer(PLAYER.errors, new Error(`relais refusés : ${plafondRelais} transferts simultanés atteints dans ce processus (config.maxConcurrentRelays)`), { route: "relais", benin: true });
110
111
  }
111
112
  } catch { /* jamais bloquant */ }
112
113
  res.setHeader("Retry-After", "2");
@@ -310,7 +311,7 @@ async function relayerFichier(res, r, disposition, bornes = { stallMs: relaisSta
310
311
  const brute = r.headers.get("content-length");
311
312
  const annoncee = Number(brute || 0);
312
313
  if (annoncee > PLAFOND_RELAIS) {
313
- try { PLAYER.errors.capture(new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
314
+ try { capturerSansBloquer(PLAYER.errors, new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
314
315
  refuserEnTexte(res, 413, "Fichier trop volumineux");
315
316
  // ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
316
317
  // pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
@@ -387,7 +388,7 @@ async function relayerFichier(res, r, disposition, bornes = { stallMs: relaisSta
387
388
  // reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
388
389
  // défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
389
390
  const cause = abandon.signal.aborted && abandon.signal.reason instanceof Error ? abandon.signal.reason : erreur;
390
- try { PLAYER.errors.capture(cause instanceof Error ? cause : new Error(String(cause)), { route: "relais" }); } catch { /* jamais bloquant */ }
391
+ try { capturerSansBloquer(PLAYER.errors, cause instanceof Error ? cause : new Error(String(cause)), { route: "relais" }); } catch { /* jamais bloquant */ }
391
392
  try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
392
393
  } finally {
393
394
  clearTimeout(stall); clearTimeout(budget);
@@ -1219,7 +1220,7 @@ async function handlerMesure(req, res) {
1219
1220
  // exactement comme sans PLAYER_HOST_AUTHZ_URL personne ne peut DIFFUSER.
1220
1221
  if (share.embed && !(PLAYER.config.extraFrameAncestors || []).length) {
1221
1222
  try {
1222
- PLAYER.errors.capture(
1223
+ capturerSansBloquer(PLAYER.errors,
1223
1224
  new Error("?embed=1 demandé mais DOC_FRAME_ANCESTORS est vide : seuls une page de même origine et *.vercel.app peuvent encadrer cette instance"),
1224
1225
  { route: "doc", indice: "le navigateur bloquera l'iframe avant le chargement — aucun embed-denied ne partira" },
1225
1226
  );
@@ -4,6 +4,7 @@
4
4
  // (cf. migration v12324) — écriture service role only. Le présentateur détient un control_token ; on en
5
5
  // stocke le HASH (sha256) → l'audience peut lire la ligne (Realtime) sans pouvoir piloter.
6
6
  const crypto = require("crypto");
7
+ const { capturerSansBloquer } = require("./capture");
7
8
  // Base de données via le contexte injecté (cf. _player-context.js) — aucune adhérence au studio.
8
9
  // ⚠️ Le contexte est REÇU, pas construit. Ce module ne doit pas savoir d'où il vient : c'est ce
9
10
  // qui lui permettra de partir dans le dépôt du player sans emporter le studio avec lui.
@@ -487,7 +488,7 @@ async function createUploadUrl(slug, name, type) {
487
488
  // serait pire : une pièce jointe qui ne part jamais, sans que personne sache que la capacité
488
489
  // manque.
489
490
  if (!PLAYER.storage || typeof PLAYER.storage.signUpload !== "function") {
490
- try { PLAYER.errors.capture(new Error("storage.signUpload absent du contexte : les pièces jointes de chat sont indisponibles"), {}); } catch { /* jamais bloquant */ }
491
+ try { capturerSansBloquer(PLAYER.errors, new Error("storage.signUpload absent du contexte : les pièces jointes de chat sont indisponibles"), {}); } catch { /* jamais bloquant */ }
491
492
  return { ok: false, status: 501 };
492
493
  }
493
494
  const signe = await PLAYER.storage.signUpload("present-attachments", path);
@@ -687,7 +688,7 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
687
688
  // attendu est journalisé une fois, en clair, parce qu'un renvoi fréquent est une information.
688
689
  const conflit = cle && estConflit(erreur);
689
690
  if (!conflit) throw erreur;
690
- try { PLAYER.errors.capture(new Error("message déjà enregistré (renvoi) : " + String(slug)), { route: "present-chat", benin: true }); } catch { /* jamais bloquant */ }
691
+ try { capturerSansBloquer(PLAYER.errors, new Error("message déjà enregistré (renvoi) : " + String(slug)), { route: "present-chat", benin: true }); } catch { /* jamais bloquant */ }
691
692
  const deja = await PLAYER.db.request(
692
693
  `doc_presentation_messages?slug=eq.${enc(String(slug))}&client_key=eq.${enc(cle)}&select=*&limit=1`);
693
694
  const ligne = Array.isArray(deja) && deja[0];
@@ -1022,7 +1023,7 @@ async function appelerBump(corps, durcissementVoulu) {
1022
1023
  // bootstrap auto-déclaré s'emparer d'une présence réclamée — c'est-à-dire une fermeture qui
1023
1024
  // rassure sans protéger. On nomme donc le fichier ET la conséquence.
1024
1025
  try {
1025
- PLAYER.errors.capture(new Error(
1026
+ capturerSansBloquer(PLAYER.errors, new Error(
1026
1027
  "bootstrap de présence NON durci : appliquez supabase/migrations/0018-bootstrap-non-usurpable.sql. "
1027
1028
  + "Sans elle, un bootstrap auto-déclaré peut écraser une présence déjà réclamée par un porteur "
1028
1029
  + "de jeton — n'armez pas PLAYER_PRESENCE_STRICT avant de l'avoir appliquée.",
@@ -1162,7 +1163,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
1162
1163
  // battements ORDINAIRES, qui continuent de se replier normalement.
1163
1164
  if (onlyIfUnclaimed) {
1164
1165
  try {
1165
- PLAYER.errors.capture(new Error(
1166
+ capturerSansBloquer(PLAYER.errors, new Error(
1166
1167
  "bootstrap de présence refusé : le contrôle anti-usurpation n'a pas pu s'exécuter — "
1167
1168
  + ((erreur && erreur.message) || erreur),
1168
1169
  ), { route: "present-attend" });
@@ -1181,7 +1182,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
1181
1182
  const fichier = transitionDispo
1182
1183
  ? "supabase/migrations/0017-jeton-presence.sql (ou 0015-presence-atomique.sql)"
1183
1184
  : "supabase/migrations/0015-presence-atomique.sql";
1184
- PLAYER.errors.capture(new Error(
1185
+ capturerSansBloquer(PLAYER.errors, new Error(
1185
1186
  "présence non atomique : appliquez " + fichier + ". "
1186
1187
  + "Sans elle, la présence est écrite par lire-modifier-réécrire (correct) mais le plafond "
1187
1188
  + "de création de faux participants anonymes n'est pas appliqué. "
@@ -1235,7 +1236,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
1235
1236
  if (!estConflit(erreur)) throw erreur;
1236
1237
  // Journalisé comme bénin : deux onglets qui arrivent ensemble sont une information, pas
1237
1238
  // une panne — et la garde des écritures muettes exige que tout rattrapage parle.
1238
- try { PLAYER.errors.capture(new Error("présence déjà ouverte (second onglet) : " + String(slug)), { route: "present-attend", benin: true }); } catch { /* jamais bloquant */ }
1239
+ try { capturerSansBloquer(PLAYER.errors, new Error("présence déjà ouverte (second onglet) : " + String(slug)), { route: "present-attend", benin: true }); } catch { /* jamais bloquant */ }
1239
1240
  continue; // le tour suivant relit : la ligne que l'autre onglet vient de créer
1240
1241
  }
1241
1242
  }
@@ -154,7 +154,7 @@ async function retirerFichier(bucket, chemin, opts) {
154
154
  if (!sansRemoveDit) {
155
155
  sansRemoveDit = true;
156
156
  try {
157
- PLAYER.errors.capture(new Error("rétention : `storage.remove` n'est pas fourni — les lignes porteuses de fichiers"
157
+ capturerSansBloquer(PLAYER.errors, new Error("rétention : `storage.remove` n'est pas fourni — les lignes porteuses de fichiers"
158
158
  + " sont RETENUES (leur objet resterait sinon inatteignable, la capacité n'exposant jamais `list`)."
159
159
  + " Fournissez `storage.remove` pour qu'elles partent."), { route: "retention", benin: true });
160
160
  } catch { /* jamais bloquant */ }
@@ -232,6 +232,7 @@ async function resteEncore(table, filtre, colId, curseur, dryRun) {
232
232
  // avec le slug de la présentation purgée. Une validation d'écriture n'est jamais la seule barrière
233
233
  // d'un delete : les lignes déjà en base d'avant le correctif peuvent porter une URL piégée.
234
234
  const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
235
+ const { capturerSansBloquer } = require("./capture");
235
236
 
236
237
  // Purge des messages d'une présentation morte, par lots bornés qui lisent id+attachment ENSEMBLE :
237
238
  // on retire les fichiers du bucket du lot (si l'hôte sait), puis on supprime le lot. Rend `tronque`
@@ -360,7 +361,7 @@ async function purgerRetention(now, optsBrutes = {}) {
360
361
  try { f = fenetresValidees(); opts = optionsValidees(optsBrutes); }
361
362
  catch (e) {
362
363
  if (!e.retentionInvalide) throw e;
363
- try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ }
364
+ try { capturerSansBloquer(PLAYER.errors, e, { route: "retention" }); } catch { /* jamais bloquant */ }
364
365
  return { ok: false, error: e.message }; // config OU option douteuse → zéro DELETE
365
366
  }
366
367
  const base = String((PLAYER.config && PLAYER.config.supabaseUrl) || "");
@@ -480,7 +481,7 @@ function tick() {
480
481
  Promise.resolve()
481
482
  .then(() => PLAYER.limits.allow("retention:sweep", 1, 86400))
482
483
  .then((permis) => { if (permis) return purgerRetention(Date.now()); })
483
- .catch((e) => { try { PLAYER.errors.capture(e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
484
+ .catch((e) => { try { capturerSansBloquer(PLAYER.errors, e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
484
485
  }
485
486
 
486
487
  /**
@@ -9,6 +9,7 @@
9
9
  // des bancs) n'ont que WebCrypto, et la route rendait alors 500 à la première synthèse. Le banc
10
10
  // agentVoix.test.js échoue sans cette ligne.
11
11
  const crypto = require("node:crypto");
12
+ const { capturerSansBloquer } = require("./capture");
12
13
  const { adresseAppelant } = require("./appelant");
13
14
  const { jsonPour, etiquetteRoute } = require("./reponses.js");
14
15
 
@@ -355,7 +356,7 @@ async function traiter(req, res, body, _slug) {
355
356
  cached: extrait.cached || undefined,
356
357
  spoken: spoken !== text ? spoken : undefined,
357
358
  });
358
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
359
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
359
360
  }
360
361
  if (body.action === "bot-start" || body.action === "bot-say" || body.action === "bot-history" || body.action === "bot-nudge" || body.action === "bot-book" || body.action === "bot-contact" || body.action === "bot-rate" || body.action === "bot-script") {
361
362
  const jp = jsonPour(res);
@@ -470,7 +471,7 @@ async function traiter(req, res, body, _slug) {
470
471
  const r = await docbot.botSay(String(body.sessionId || ""), share, text, pages, mobile, blang);
471
472
  if (r.error) return jp(400, { ok: false, error: r.error });
472
473
  return jp(200, { ok: true, ...r });
473
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
474
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
474
475
  }
475
476
  // Assistance (heartbeat) : PUBLIC (l'audience est anonyme). Journalise qui suit / combien de temps / pages vues.
476
477
  // Rate-limit généreux par IP (heartbeat ≈ 145/h/participant) : bloque le spam d'assistants factices sans
@@ -4,6 +4,7 @@
4
4
  // Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
5
5
 
6
6
  const { adresseAppelant, lcMembre, cleAnonyme, profilDuJeton } = require("./appelant");
7
+ const { capturerSansBloquer } = require("./capture");
7
8
  const { jsonPour, etiquetteRoute } = require("./reponses.js");
8
9
 
9
10
  const { createPresentation, getPresentation, setPage, endPresentation, addMessage, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
@@ -49,7 +50,7 @@ async function traiter(req, res, body, _slug) {
49
50
  // hôte, chaque « Terminer » échouait en 23502 (marqueur d'archive NOT NULL) — et ce 500
50
51
  // muet ne laissait RIEN, même pas une ligne dans le journal d'erreurs. Un journal que
51
52
  // personne ne lit vaut peu ; aucun journal ne vaut rien du tout.
52
- try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: etiquetteRoute("present-" + String(body.action || "").replace(/^present-/, "")) }); } catch { /* jamais bloquant */ }
53
+ try { capturerSansBloquer(PLAYER.errors, erreur instanceof Error ? erreur : new Error(String(erreur)), { route: etiquetteRoute("present-" + String(body.action || "").replace(/^present-/, "")) }); } catch { /* jamais bloquant */ }
53
54
  return jp(500, { ok: false });
54
55
  }
55
56
  }
@@ -174,7 +175,7 @@ async function traiter(req, res, body, _slug) {
174
175
  if (PLAYER.config && PLAYER.config.presenceStrict && !peutEmettre && !profil) {
175
176
  try {
176
177
  if (await PLAYER.limits.allow("presence:strict-inerte", 1, 3600)) {
177
- PLAYER.errors.capture(new Error(
178
+ capturerSansBloquer(PLAYER.errors, new Error(
178
179
  "PLAYER_PRESENCE_STRICT est posé mais AUCUN jeton ne peut être émis "
179
180
  + "(PLAYER_PRESENCE_SECRET absent) : la porte reste OUVERTE. Armé tel quel, elle "
180
181
  + "refuserait tous les participants anonymes.",
@@ -228,7 +229,7 @@ async function traiter(req, res, body, _slug) {
228
229
  // des présences) : une présentation close refuse toute écriture, jeton valide ou non.
229
230
  const pt = (r.ok && jetonCandidat) ? jetonCandidat : "";
230
231
  return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
231
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
232
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
232
233
  }
233
234
  // Gestion des présentations (membre AUTHENTIFIÉ requis) : liste / reprise / transfert / stats / historique doc.
234
235
  if (body.action === "present-list" || body.action === "present-reclaim" || body.action === "present-handover" || body.action === "present-owner-end" || body.action === "present-stats" || body.action === "present-doc-list" || body.action === "present-switch" || body.action === "present-content") {
@@ -281,7 +282,7 @@ async function traiter(req, res, body, _slug) {
281
282
  else if (body.action === "present-content") r = await setPresentationContent(String(body.slug || ""), (u && u.email) || "", isAdmin, body.content, String(body.control || ""));
282
283
  else r = await handoverPresentation(String(body.slug || ""), u.email, body.newOwner);
283
284
  return jp(r.ok ? 200 : (r.status || 400), r);
284
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
285
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
285
286
  }
286
287
  // Chat de présentation (historisé) : n'importe quel participant (présentateur ou audience) poste un
287
288
  // message. Écriture via service role ; anti-spam par IP (60/h). La présentation doit exister.
@@ -313,7 +314,7 @@ async function traiter(req, res, body, _slug) {
313
314
  avatar: profil ? profil.avatar : null,
314
315
  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 });
315
316
  return jp(r.ok ? 200 : (r.status || 400), r);
316
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
317
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
317
318
  }
318
319
  // Pièce jointe : URL d'upload signée (la présentation doit exister ; rate-limit).
319
320
  if (body.action === "present-upload-url") {
@@ -326,7 +327,7 @@ async function traiter(req, res, body, _slug) {
326
327
  if (!allowed) return jp(429, { ok: false, error: "rate" });
327
328
  const r = await createUploadUrl(String(body.slug || ""), body.name, body.type);
328
329
  return jp(r.ok ? 200 : (r.status || 400), r);
329
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
330
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
330
331
  }
331
332
  // Chat : éditer / supprimer un message, verrouiller le chat.
332
333
  if (body.action === "present-msg-edit" || body.action === "present-msg-delete" || body.action === "present-chatlock") {
@@ -337,7 +338,7 @@ async function traiter(req, res, body, _slug) {
337
338
  else if (body.action === "present-msg-delete") r = await deleteMessage(String(body.slug || ""), body.msgId, { authorToken: body.authorToken, control: body.control });
338
339
  else r = await setChatLock(String(body.slug || ""), String(body.control || ""), !!body.locked);
339
340
  return jp(r.ok ? 200 : (r.status || 400), r);
340
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
341
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
341
342
  }
342
343
  // Réaction emoji (toggle) sur un message du chat de présentation.
343
344
  if (body.action === "present-react") {
@@ -357,7 +358,7 @@ async function traiter(req, res, body, _slug) {
357
358
  const reacteur = require("./presentations").reacteurDepuisJeton(body.authorToken);
358
359
  const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, reacteur, body.etat);
359
360
  return jp(r.ok ? 200 : (r.status || 400), r);
360
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
361
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
361
362
  }
362
363
  return false;
363
364
  }
@@ -4,6 +4,7 @@
4
4
  // Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
5
5
 
6
6
  const { adresseAppelant } = require("./appelant");
7
+ const { capturerSansBloquer } = require("./capture");
7
8
  const { jsonPour, repondreJson, etiquetteRoute } = require("./reponses.js");
8
9
  const { estConflit } = require("./erreurs-base.js");
9
10
  const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, listSharesForDoc, listSessionsForDoc, listSessionsForRecipient, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
@@ -56,7 +57,7 @@ async function traiter(req, res, body, slug) {
56
57
  const { action: _a, ...opts } = body;
57
58
  const resultat = await require("./retention").purgerRetention(Date.now(), opts);
58
59
  return jd(resultat.ok === false ? 400 : 200, resultat);
59
- } catch (e) { try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
60
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
60
61
  }
61
62
  if (String(body.action || "").startsWith("docshare.")) {
62
63
  const jd = jsonPour(res);
@@ -159,7 +160,7 @@ async function traiter(req, res, body, slug) {
159
160
  });
160
161
  } catch (erreur) {
161
162
  if (!estConflit(erreur)) throw erreur;
162
- try { PLAYER.errors.capture(new Error("backfill hôte : la clé était déjà posée ailleurs — " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
163
+ try { capturerSansBloquer(PLAYER.errors, new Error("backfill hôte : la clé était déjà posée ailleurs — " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
163
164
  const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleHote)}&select=slug&limit=1`);
164
165
  if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
165
166
  return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
@@ -186,7 +187,7 @@ async function traiter(req, res, body, slug) {
186
187
  return jd(200, { ok: true, slug: neuf.slug, reused: false });
187
188
  } catch (erreur) {
188
189
  if (!estConflit(erreur)) throw erreur;
189
- try { PLAYER.errors.capture(new Error("lien hôte déjà créé par une demande simultanée : " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
190
+ try { capturerSansBloquer(PLAYER.errors, new Error("lien hôte déjà créé par une demande simultanée : " + docId), { route: "hostshare", benin: true }); } catch { /* jamais bloquant */ }
190
191
  const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleHote)}&select=slug&limit=1`);
191
192
  if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur; // 409 d'autre chose : on ne l'invente pas
192
193
  return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
@@ -266,7 +267,7 @@ async function traiter(req, res, body, slug) {
266
267
  await PLAYER.db.request(`commercial_doc_shares?slug=eq.${encodeURIComponent(ex[0].slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { doc_title: body.docTitle || null, file_url: String(body.fileUrl), file_name: body.fileName || null, bot_enabled: true, bot_guided: true, bot_profile_id: (body.profileId || "").trim() || null, revoked: false, ...(cleDispo ? { idem_key: cleTest } : {}) } });
267
268
  } catch (erreur) {
268
269
  if (!estConflit(erreur)) throw erreur;
269
- try { PLAYER.errors.capture(new Error("backfill répétition : la clé était déjà posée ailleurs — " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
270
+ try { capturerSansBloquer(PLAYER.errors, new Error("backfill répétition : la clé était déjà posée ailleurs — " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
270
271
  const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleTest)}&select=slug&limit=1`);
271
272
  if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
272
273
  return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
@@ -279,7 +280,7 @@ async function traiter(req, res, body, slug) {
279
280
  return jd(200, { ok: true, slug: t.slug });
280
281
  } catch (erreur) {
281
282
  if (!estConflit(erreur)) throw erreur;
282
- try { PLAYER.errors.capture(new Error("lien de répétition déjà créé par une demande simultanée : " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
283
+ try { capturerSansBloquer(PLAYER.errors, new Error("lien de répétition déjà créé par une demande simultanée : " + docId), { route: "docshare-test", benin: true }); } catch { /* jamais bloquant */ }
283
284
  const gagnant = await PLAYER.db.request(`commercial_doc_shares?idem_key=eq.${encodeURIComponent(cleTest)}&select=slug&limit=1`);
284
285
  if (!Array.isArray(gagnant) || !gagnant[0]) throw erreur;
285
286
  return jd(200, { ok: true, slug: gagnant[0].slug, reused: true });
@@ -291,7 +292,7 @@ async function traiter(req, res, body, slug) {
291
292
  }
292
293
  const { slug } = await createShare({ brandKey: body.brandKey, docId: body.docId, docTitle: body.docTitle, fileUrl: body.fileUrl, fileName: body.fileName, recipientEmail: body.recipientEmail, recipientName: body.recipientName, createdBy: u.email, bot: body.bot, botScript: body.botScript, guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload, videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark });
293
294
  return jd(200, { ok: true, slug });
294
- } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
295
+ } catch (e) { try { capturerSansBloquer(PLAYER.errors, e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
295
296
  }
296
297
 
297
298
  // Re-partage (forward depuis la visionneuse) : crée un lien enfant tracé, et envoie l'email via 3D
@@ -369,7 +370,7 @@ async function traiter(req, res, body, slug) {
369
370
  // Signalé par la seconde passe d'audit (P1-1).
370
371
  const publique = String(PLAYER.legal.publicUrl || "").trim();
371
372
  if (!publique) {
372
- try { PLAYER.errors.capture(new Error("PLAYER_PUBLIC_URL non configurée : envoi refusé (le lien de l'email serait construit depuis l'en-tête Host, que le client choisit)"), { route: "reshare" }); } catch { /* jamais bloquant */ }
373
+ try { capturerSansBloquer(PLAYER.errors, new Error("PLAYER_PUBLIC_URL non configurée : envoi refusé (le lien de l'email serait construit depuis l'en-tête Host, que le client choisit)"), { route: "reshare" }); } catch { /* jamais bloquant */ }
373
374
  refusEnvoi = "public-url-unconfigured";
374
375
  throw new Error("URL publique non configurée");
375
376
  }
@@ -468,7 +469,7 @@ async function traiter(req, res, body, slug) {
468
469
  // et le signalement passe AVANT le `return`, sans quoi il ne s'exécuterait jamais.
469
470
  try {
470
471
  if (await PLAYER.limits.allow("intsess:quota-avert", 1, 3600)) {
471
- PLAYER.errors.capture(new Error(`session interne refusée : quota horaire atteint (${SESSION_QUOTA_PER_HOUR}/h par adresse) — la mesure s'arrête tant qu'il l'est`), { route: "internal-session" });
472
+ capturerSansBloquer(PLAYER.errors, new Error(`session interne refusée : quota horaire atteint (${SESSION_QUOTA_PER_HOUR}/h par adresse) — la mesure s'arrête tant qu'il l'est`), { route: "internal-session" });
472
473
  }
473
474
  } catch { /* un journal ne doit jamais empêcher une lecture */ }
474
475
  repondreJson(res, 429, { ok: false, error: "rate" });
@@ -486,7 +487,7 @@ async function traiter(req, res, body, slug) {
486
487
  // Une fois par heure et par instance : assez pour être vu dans les journaux, pas assez
487
488
  // pour les noyer — un avertissement répété à chaque battement ne se lit plus.
488
489
  if (await PLAYER.limits.allow("intsess:avert", 1, 3600)) {
489
- try { PLAYER.errors.capture(new Error("session interne écrite sans jeton : l'identité vient du navigateur. Poser PLAYER_INTERNAL_STRICT=1 une fois l'hôte à jour"), { route: "internal-session" }); } catch { /* ignore */ }
490
+ try { capturerSansBloquer(PLAYER.errors, new Error("session interne écrite sans jeton : l'identité vient du navigateur. Poser PLAYER_INTERNAL_STRICT=1 une fois l'hôte à jour"), { route: "internal-session" }); } catch { /* ignore */ }
490
491
  }
491
492
  }
492
493
  // Le jeton fait foi quand il est là : c'est l'hôte qui se porte garant, pas l'appelant.
@@ -517,7 +518,7 @@ async function traiter(req, res, body, slug) {
517
518
  // de mesure.
518
519
  try {
519
520
  if (await PLAYER.limits.allow("intsess:echec", 1, 3600)) {
520
- PLAYER.errors.capture(new Error(`écriture de session interne refusée : ${e && e.message ? e.message : "cause inconnue"} — la mesure ne s'enregistre pas`), { route: "internal-session" });
521
+ capturerSansBloquer(PLAYER.errors, new Error(`écriture de session interne refusée : ${e && e.message ? e.message : "cause inconnue"} — la mesure ne s'enregistre pas`), { route: "internal-session" });
521
522
  }
522
523
  } catch { /* un journal ne doit jamais empêcher une lecture */ }
523
524
  }
@@ -545,7 +546,7 @@ async function traiter(req, res, body, slug) {
545
546
  // que l'exploitant peut relier à un quota, plutôt qu'une mesure qui stagne sans explication.
546
547
  try {
547
548
  const avert = estSession ? "sess:quota-avert" : "view:quota-avert";
548
- if (await PLAYER.limits.allow(avert, 1, 3600)) PLAYER.errors.capture(new Error(`télémétrie externe abandonnée (${body.event}) : quota horaire atteint (${quotaTrack}/h par adresse)`), { route: "track", abandon: true });
549
+ if (await PLAYER.limits.allow(avert, 1, 3600)) capturerSansBloquer(PLAYER.errors, new Error(`télémétrie externe abandonnée (${body.event}) : quota horaire atteint (${quotaTrack}/h par adresse)`), { route: "track", abandon: true });
549
550
  } catch { /* jamais bloquant */ }
550
551
  repondreJson(res, 200, { ok: true });
551
552
  return;
@@ -565,7 +566,7 @@ async function traiter(req, res, body, slug) {
565
566
  // n'aurait rien vu : c'est ce qui rend la classe dangereuse, pas l'instance.
566
567
  try {
567
568
  if (await PLAYER.limits.allow("sess:echec", 1, 3600)) {
568
- PLAYER.errors.capture(new Error(`écriture de mesure refusée : ${e && e.message ? e.message : "cause inconnue"} — la lecture n'est pas comptée`), { route: "track" });
569
+ capturerSansBloquer(PLAYER.errors, new Error(`écriture de mesure refusée : ${e && e.message ? e.message : "cause inconnue"} — la lecture n'est pas comptée`), { route: "track" });
569
570
  }
570
571
  } catch { /* un journal ne doit jamais empêcher une lecture */ }
571
572
  }
@@ -4,6 +4,7 @@
4
4
  // Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
5
5
 
6
6
  const { adresseAppelant } = require("./appelant");
7
+ const { capturerSansBloquer } = require("./capture");
7
8
  const { repondreJson } = require("./reponses.js");
8
9
 
9
10
  const { getShareBySlug } = require("./shares");
@@ -54,7 +55,7 @@ function direLeRepli(pourquoi) {
54
55
  if (repliDit) return;
55
56
  repliDit = true;
56
57
  try {
57
- PLAYER.errors.capture(new Error(`mur visiteur : ${pourquoi} — les compteurs par identité utilisent une EMPREINTE de l'email (SHA-256 tronqué), pseudonyme mais renversable par dictionnaire. Fournissez rateLimitKey (HMAC, secret côté hôte, séparation de domaine).`), { route: "visitor", benin: true });
58
+ capturerSansBloquer(PLAYER.errors, new Error(`mur visiteur : ${pourquoi} — les compteurs par identité utilisent une EMPREINTE de l'email (SHA-256 tronqué), pseudonyme mais renversable par dictionnaire. Fournissez rateLimitKey (HMAC, secret côté hôte, séparation de domaine).`), { route: "visitor", benin: true });
58
59
  } catch { /* jamais bloquant */ }
59
60
  }
60
61
  const init = (ctx) => { PLAYER = ctx; repliDit = false; };
@@ -95,7 +96,7 @@ async function traiter(req, res, body, _slug) {
95
96
  // noms. Une liste ne voit que ce qu'on y a mis ; une forme voit aussi le prochain.
96
97
  try {
97
98
  if (await PLAYER.limits.allow("unlock:echec", 1, 3600)) {
98
- PLAYER.errors.capture(new Error(`déverrouillage visiteur non journalisé : ${e && e.message ? e.message : "cause inconnue"}`), { route: "visitor-unlock" });
99
+ capturerSansBloquer(PLAYER.errors, new Error(`déverrouillage visiteur non journalisé : ${e && e.message ? e.message : "cause inconnue"}`), { route: "visitor-unlock" });
99
100
  }
100
101
  } catch { /* un journal ne doit jamais empêcher une lecture */ }
101
102
  }
package/server/schema.js CHANGED
@@ -52,6 +52,8 @@
52
52
  // — il balaie le dossier et refuse un nom qui déclencherait la garde, au moment où on l'écrit.
53
53
  // (La forme de la règle vient du STUDIO : une règle tenue par habitude a un taux de couverture que
54
54
  // personne ne mesure.)
55
+ const { capturerSansBloquer } = require("./capture");
56
+
55
57
  const RACINE_MIGRATIONS = "supabase/migrations/";
56
58
 
57
59
  const ATTENDUES = {
@@ -460,7 +462,7 @@ const PORTEE_FUSION =
460
462
  // plus rien.
461
463
  async function journaliser(cle, message) {
462
464
  try {
463
- if (await PLAYER.limits.allow(cle, 1, 3600)) PLAYER.errors.capture(new Error(message), { route: "schema" });
465
+ if (await PLAYER.limits.allow(cle, 1, 3600)) capturerSansBloquer(PLAYER.errors, new Error(message), { route: "schema" });
464
466
  } catch { /* jamais bloquant */ }
465
467
  }
466
468
 
@@ -529,7 +531,7 @@ async function ajouterMigrationsDePresence(etat) {
529
531
  // boucle, et un journal sans frein deviendrait une arme.
530
532
  try {
531
533
  if (await PLAYER.limits.allow("schema:durcissement-absent", 1, 3600)) {
532
- PLAYER.errors.capture(new Error(
534
+ capturerSansBloquer(PLAYER.errors, new Error(
533
535
  absente
534
536
  ? "migration 0018-bootstrap-non-usurpable.sql ABSENTE : les bootstraps de présence ne "
535
537
  + "sont pas contrôlés. N'armez pas PLAYER_PRESENCE_STRICT avant de l'appliquer — il "
package/server/shares.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // GED commerciale : liens de partage tracés (un par destinataire) + agrégation des consultations.
4
4
  // Tables service-role only (cf. migration v12321) → tout passe par le service role ici.
5
5
  const crypto = require("crypto");
6
+ const { capturerSansBloquer } = require("./capture");
6
7
  const { signatureAbsente } = require("./erreurs-base.js");
7
8
  // Tout ce qui vient de l'hôte passe par le contexte injecté — base, email, marque. C'est ce qui
8
9
  // permettra à ce fichier de partir dans le dépôt du player sans emporter le studio avec lui.
@@ -434,7 +435,7 @@ async function overviewEnBase(since) {
434
435
  lastAt: r.last_at || null,
435
436
  }])) || [];
436
437
  } catch (erreur) {
437
- try { PLAYER.errors.capture(erreur, { route: "overview", indice: "consultations internes indisponibles — la vue d'ensemble ne montrera que les ouvertures client" }); } catch { /* jamais bloquant */ }
438
+ try { capturerSansBloquer(PLAYER.errors, erreur, { route: "overview", indice: "consultations internes indisponibles — la vue d'ensemble ne montrera que les ouvertures client" }); } catch { /* jamais bloquant */ }
438
439
  }
439
440
  return { byDoc: new Map(vues), intByDoc: new Map(internes) };
440
441
  }
@@ -935,7 +936,7 @@ async function upsertInternalSession(p, { ip: _ip, ua }) {
935
936
  try {
936
937
  if (await PLAYER.limits.allow("intsess:jetee", 1, 3600)) {
937
938
  const manque = !sessionId ? "sessionId" : "docId";
938
- PLAYER.errors.capture(new Error(`session interne jetée : ${manque} absent — rien ne sera mesuré tant qu'il manque`), { route: "internal-session" });
939
+ capturerSansBloquer(PLAYER.errors, new Error(`session interne jetée : ${manque} absent — rien ne sera mesuré tant qu'il manque`), { route: "internal-session" });
939
940
  }
940
941
  } catch { /* un journal ne doit jamais empêcher une lecture */ }
941
942
  return;
@@ -129,17 +129,19 @@ export interface Reglages {
129
129
  mapsKey: string;
130
130
  extraFrameAncestors: string[];
131
131
  /** Transferts de fichiers relayés simultanément par processus (défaut 64 ; entier de 1 à 1024,
132
- * sinon le défaut, dit une fois à `init`). Au-delà, le relais répond 503 + Retry-After AVANT
133
- * l'appel amont ; la place est rendue en `finally`. 64 n'est pas sûr partout : 64 × 8 Mio avec
134
- * des clients lents font monter la RSS de 130 à 194 Mio — 16 à 32 sur un processus à 256 Mio. */
135
- maxConcurrentRelays?: number;
132
+ * sinon le défaut, dit une fois à `init`). Une chaîne — celle de l'environnement — est acceptée
133
+ * telle quelle et bornée par le cœur, qui cite ce qu'il a reçu s'il la refuse. Au-delà, le relais
134
+ * répond 503 + Retry-After AVANT l'appel amont ; la place est rendue en `finally`. 64 n'est pas sûr
135
+ * partout : 64 × 8 Mio avec des clients lents ont fait monter la RSS de 63 à 193–257 Mio (une
136
+ * croissance de 130 à 194) — 16 à 32 sur un processus à 256 Mio. */
137
+ maxConcurrentRelays?: number | string;
136
138
  /** Un relais sans progression pendant ce délai est abandonné — source et réponse détruites, place
137
139
  * rendue (défaut 30 000 ms ; entier de 1 à 86 400 000 ms, sinon le défaut, dit une fois à `init` :
138
140
  * `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms). `requestTimeout` ne borne
139
141
  * pas l'émission d'une réponse. */
140
- relayStallMs?: number;
142
+ relayStallMs?: number | string;
141
143
  /** Durée totale maximale d'un relais (défaut 900 000 ms ; même plage que `relayStallMs`). */
142
- relayMaxMs?: number;
144
+ relayMaxMs?: number | string;
143
145
  /** Ce qui est POSÉ, à côté de ce que le code SAIT faire : la carte d'identité publie les deux,
144
146
  * parce qu'une capacité disponible mais non configurée se comporte comme une absence. */
145
147
  separateIssuer?: boolean;