discovery-media-player 0.1.165 → 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
  },
@@ -842,11 +856,20 @@ function createStandaloneContext(env = process.env) {
842
856
  extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
843
857
  // Transferts de fichiers simultanés par processus (défaut 64) : le relais refuse en 503 au-delà,
844
858
  // avant tout appel amont. Lu ICI, pas dans le cœur — la configuration entre par le contexte.
845
- maxConcurrentRelays: Number(env.PLAYER_MAX_RELAYS || 0) || 64,
859
+ // ⚠️ TRANSMIS TEL QUEL, PAS NORMALISÉ ICI. La première écriture faisait `Number(x) || 64` : une
860
+ // valeur illisible devenait le défaut EN SILENCE, et 2 147 483 648 passait jusqu'à `setTimeout`,
861
+ // qui ramène un tel délai à 1 ms (audit externe, cinquième passe). Les plages vivent dans
862
+ // `server/bornes.js`, et le cœur les applique à `init` en DISANT une fois ce qu'il a refusé —
863
+ // si on bornait ici, il ne verrait qu'une valeur valide et l'exploitant ne saurait jamais.
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,
846
869
  // Un relais sans progression pendant relayStallMs, ou plus long que relayMaxMs, est abandonné
847
870
  // (source et réponse détruites) : sans ça, un client qui cesse de lire garde sa place pour toujours.
848
- relayStallMs: Number(env.PLAYER_RELAY_STALL_MS || 0) || 30_000,
849
- relayMaxMs: Number(env.PLAYER_RELAY_MAX_MS || 0) || 900_000,
871
+ relayStallMs: env.PLAYER_RELAY_STALL_MS,
872
+ relayMaxMs: env.PLAYER_RELAY_MAX_MS,
850
873
 
851
874
  /**
852
875
  * Clé de `localStorage` sous laquelle VOTRE application range la session de ses membres.
@@ -50,6 +50,7 @@ need.
50
50
  "presenceDurcissement": "inconnu",
51
51
  "presenceFusion": "inconnu",
52
52
  "lectureSaturee": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
53
+ "relaisRefuses": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
53
54
  "mesures": { "fenetreS": 0, "seauxMs": [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000], "familles": ["document", "presentation", "action", "fichier", "carte", "autre"], "routes": {}, "base": { "n": 0 }, "statuts": { "ok": 0, "refus4xx": 0, "debit429": 0, "occupe503": 0, "erreur5xx": 0 }, "memoireMio": { "rss": 0, "heap": 0, "tampons": 0 }, "boucleMs": { "n": 0, "moyen": null, "p99": null, "resolutionMs": 20 } },
54
55
  "retentionSweep": false,
55
56
  "hostShare": true,
@@ -184,7 +185,7 @@ these numbers from their side.
184
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 |
185
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 |
186
187
  | `statuts` | responses by class: `ok` (<400), `refus4xx`, `debit429`, `occupe503`, `erreur5xx` |
187
- | `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 |
188
189
  | `boucleMs` | event-loop **delay** — `moyen` and `p99` in ms, with `n` samples and the sampler's `resolutionMs` |
189
190
 
190
191
  ⚠️ **A percentile over buckets is a bound, not a value.** `p95sousMs: 250` reads *"95% of calls
@@ -237,10 +238,52 @@ saturate*; on a process that started four seconds ago it says *nobody has looked
237
238
  same trap as `inconnu` in the two rows above, and the reason the three keys are returned as one
238
239
  object rather than as separate fields you could read apart.
239
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
+
240
271
  ⚠️ **It is process-local.** Behind a load balancer this is the count of the instance that answered,
241
272
  not of your deployment. Aggregating is your job — and letting you believe otherwise would be worse
242
273
  than returning nothing.
243
274
 
275
+ ### `relaisRefuses` — what the relay admission refused
276
+
277
+ Same three keys, same reading rules, **another ceiling**: this counts the file relays refused with
278
+ `503` + `Retry-After: 2` because `config.maxConcurrentRelays` was reached (see *Relay `Range`* in the
279
+ three things a host implements). ⚠️ `lectureSaturee` does **not** cover it — it is the read cache
280
+ only. A host answered *"we never saturate relays"* from `lectureSaturee.total = 0` (13/09), which was
281
+ a reasonable reading of a card that had no relay counter; and `mesures.statuts.occupe503` mixes the
282
+ two refusals. This field exists so that the question *did this instance refuse a relay?* is answered
283
+ by the card, structured and dated, and never by a search through logs for `relais refusés` — the
284
+ log line stays for diagnosis, the card is the way to *notice*. Process-local like everything on this
285
+ card, and **never reset by `init`**, exactly like the counter of open relays.
286
+
244
287
  ⚠️ **Before you upgrade, do not read `presenceDurcissement` or `presenceFusion`.** They are *reports
245
288
  of execution*: on an instance where nothing is running they say `inconnu`, which means *nobody
246
289
  looked* — not *the migration is there*. A pre-flight check built on one of them silently passes on
@@ -771,6 +814,15 @@ Four requirements, in order of what they cost when missed:
771
814
  `config.relayStallMs` (30 s) or a total beyond `config.relayMaxMs` (15 min) aborts the pipeline,
772
815
  destroying your response and the client's. A client that stops reading no longer keeps a slot
773
816
  forever; a route of yours that stops sending does not either.
817
+ ⚠️ Three things about those numbers, all measured on 0.1.165 by an audit: **64 is not a safe
818
+ default everywhere** — 64 relays × 8 MiB with slow consumers took the process RSS from ~63 MiB to
819
+ 193–257 MiB; on a 256 MiB process set 16–32. The settings are **integers in a written range**
820
+ (`maxConcurrentRelays` 1–1024, the two delays 1–86 400 000 ms): out of range falls back to the
821
+ default and is reported once at `init` through `errors.capture`, because `setTimeout` clamps
822
+ anything above 2 147 483 647 ms to 1 ms and a "24-day" delay aborted a relay in 6 ms. And the
823
+ **counter of open relays is never reset by `init`**: calling `init` again re-reads the ceiling and
824
+ the delays for the relays admitted afterwards; one already in flight keeps its slot and its
825
+ bounds, so a re-initialisation cannot let a request past a full ceiling.
774
826
  3. **Accept a server-to-server call.** A tracked link is opened by someone with no session on your
775
827
  side. Authenticate the player with the shared secret in the `x-player-fetch-secret` **header** —
776
828
  header only, never a query string: logs keep URLs.
@@ -821,6 +873,14 @@ back on an inability to *reach*.** And "do not fall back" applies to what you **
821
873
 
822
874
  ## What will bite
823
875
 
876
+ ⚠️ **`node_modules` cannot be a symlink, and a repository inside a syncing folder will break under
877
+ you.** Reported by a host (13/09), not reproduced here: a checkout living in iCloud Drive had files
878
+ duplicated and emptied *while being worked on* — four breakages in six days, four different
879
+ signatures, one of which blocked a delivery. Their escape route, a symbolic link from `node_modules`
880
+ to a folder outside the sync, is a dead end: `npm` replaces the link with a real directory, whether
881
+ the target is empty or populated, on `install` as on `ci`. Keep the clone itself outside any
882
+ synchronised folder; there is no way to keep only its dependencies out.
883
+
824
884
  ⚠️ **Every capability you provide must settle in bounded time — `db.request` first of all.** The
825
885
  player awaits your `db.request`, `storage.fetchFile`, `mail.send` and the visitor plugin; a promise
826
886
  that never settles keeps a request in flight, and the read cache admits at most 128 in-flight reads
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.165",
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",
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright © 2026 3D Discovery
3
+ // LES RÉGLAGES DU RELAIS, BORNÉS À UN SEUL ENDROIT — parce qu'un nombre fini n'est pas un délai.
4
+ //
5
+ // ⚠️ `setTimeout` PLAFONNE À 2 147 483 647 ms ET RAMÈNE TOUT DÉPASSEMENT À 1 ms. Un délai de
6
+ // progression configuré à 2 147 483 648 ms — « environ 24,8 jours » — abandonnait le transfert en
7
+ // 6 ms, avec 65 `TimeoutOverflowWarning` pour le dire (reproduit par un audit externe, cinquième
8
+ // passe, 13/09). La première borne acceptait « tout nombre fini positif » : une décimale, l'infini
9
+ // sous la forme d'un très grand entier, tout passait jusqu'à Node. On n'accepte donc qu'un ENTIER
10
+ // SÛR dans une plage écrite, bien sous la limite native, et une valeur hors plage retombe sur le
11
+ // défaut EN LE DISANT — une fois, à l'initialisation, pas à chaque relais.
12
+ //
13
+ // Le même module sert le cœur (`handler.init`) et le contexte autonome : une seule décision.
14
+
15
+ /** Relais simultanés par processus. 1 024 est une borne de configuration, pas une recommandation :
16
+ * 64 relais × 8 Mio avec des clients lents ont fait monter la RSS de 63 à 193–257 Mio (mesuré par
17
+ * un audit externe) — sur un processus à 256 Mio, 16 à 32 est le bon ordre de grandeur. */
18
+ const RELAIS_SIMULTANES_DEFAUT = 64, RELAIS_SIMULTANES_MIN = 1, RELAIS_SIMULTANES_MAX = 1024;
19
+ /** Délais du relais, en millisecondes entières : de 1 ms à 24 h. Vingt-quatre heures est une borne
20
+ * applicative, très en dessous des 2 147 483 647 ms que `setTimeout` sait tenir. */
21
+ const RELAIS_STALL_MS_DEFAUT = 30_000, RELAIS_MAX_MS_DEFAUT = 900_000;
22
+ const RELAIS_MS_MIN = 1, RELAIS_MS_MAX = 86_400_000;
23
+
24
+ /**
25
+ * Un entier sûr dans [min, max], sinon le défaut. `undefined` et `null` sont « non posé » : le
26
+ * défaut s'applique sans être un défaut de configuration ; tout le reste hors plage l'est.
27
+ * @returns {{ valeur: number, valide: boolean, posee: boolean }}
28
+ */
29
+ function entierBorne(v, { defaut, min, max }) {
30
+ if (v === undefined || v === null || v === "") return { valeur: defaut, valide: true, posee: false };
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 ». ⚠️ 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.
37
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.trim()) : NaN;
38
+ const valide = Number.isSafeInteger(n) && n >= min && n <= max;
39
+ return { valeur: valide ? n : defaut, valide, posee: true };
40
+ }
41
+
42
+ /**
43
+ * Les trois réglages du relais, lus dans `config`, bornés, avec la liste de ce qui a été refusé —
44
+ * pour que l'appelant le dise une fois, avec la plage, plutôt que d'appliquer un défaut en silence.
45
+ * @returns {{ plafond: number, stallMs: number, maxMs: number, invalides: string[] }}
46
+ */
47
+ function bornesRelais(config) {
48
+ const c = config || {};
49
+ const plafond = entierBorne(c.maxConcurrentRelays, { defaut: RELAIS_SIMULTANES_DEFAUT, min: RELAIS_SIMULTANES_MIN, max: RELAIS_SIMULTANES_MAX });
50
+ const stall = entierBorne(c.relayStallMs, { defaut: RELAIS_STALL_MS_DEFAUT, min: RELAIS_MS_MIN, max: RELAIS_MS_MAX });
51
+ const total = entierBorne(c.relayMaxMs, { defaut: RELAIS_MAX_MS_DEFAUT, min: RELAIS_MS_MIN, max: RELAIS_MS_MAX });
52
+ const invalides = [];
53
+ if (!plafond.valide) invalides.push(`maxConcurrentRelays=${String(c.maxConcurrentRelays)} (entier de ${RELAIS_SIMULTANES_MIN} à ${RELAIS_SIMULTANES_MAX}, défaut ${RELAIS_SIMULTANES_DEFAUT})`);
54
+ if (!stall.valide) invalides.push(`relayStallMs=${String(c.relayStallMs)} (entier de ${RELAIS_MS_MIN} à ${RELAIS_MS_MAX} ms, défaut ${RELAIS_STALL_MS_DEFAUT})`);
55
+ if (!total.valide) invalides.push(`relayMaxMs=${String(c.relayMaxMs)} (entier de ${RELAIS_MS_MIN} à ${RELAIS_MS_MAX} ms, défaut ${RELAIS_MAX_MS_DEFAUT})`);
56
+ return { plafond: plafond.valeur, stallMs: stall.valeur, maxMs: total.valeur, invalides };
57
+ }
58
+
59
+ module.exports = {
60
+ entierBorne, bornesRelais,
61
+ RELAIS_SIMULTANES_DEFAUT, RELAIS_SIMULTANES_MIN, RELAIS_SIMULTANES_MAX,
62
+ RELAIS_STALL_MS_DEFAUT, RELAIS_MAX_MS_DEFAUT, RELAIS_MS_MIN, RELAIS_MS_MAX,
63
+ };
@@ -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");
@@ -40,10 +41,22 @@ function init(ctx) {
40
41
  // `init`, et un hôte a le même droit. On n'ajoute qu'une chose, on n'en fige aucune.
41
42
  if (ctx && ctx.db) { const vu = Object.create(ctx); vu.db = mesures.observerBase(ctx.db); ctx = vu; }
42
43
  PLAYER = ctx;
43
- plafondRelais = borneRelais(ctx && ctx.config && ctx.config.maxConcurrentRelays);
44
- relaisStallMs = borneMs(ctx && ctx.config && ctx.config.relayStallMs, RELAIS_STALL_MS_DEFAUT);
45
- relaisMaxMs = borneMs(ctx && ctx.config && ctx.config.relayMaxMs, RELAIS_MAX_MS_DEFAUT);
46
- relaisEnCours = 0;
44
+ // ⚠️ LA CONFIGURATION SE RELIT, L'ÉTAT VIVANT NE SE REMET PAS À ZÉRO. Cette ligne posait
45
+ // `relaisEnCours = 0` : un hôte qui rappelle `init` pendant qu'un relais est ouvert désarmait le
46
+ // plafond — la demande suivante partait vers l'amont avec l'unique place encore prise, et le
47
+ // `finally` de l'ancien relais rendait ensuite le compteur négatif (reproduit par un audit externe,
48
+ // cinquième passe, 13/09). Le compteur appartient au processus, pas au contexte : il ne se relit pas.
49
+ // Les bornes de temps, elles, sont capturées à l'ADMISSION de chaque relais (`relayerSousAdmission`) :
50
+ // un relais admis sous 30 s reste sous 30 s, quoi qu'un `init` ultérieur décide.
51
+ const bornes = bornesRelais(ctx && ctx.config);
52
+ plafondRelais = bornes.plafond;
53
+ relaisStallMs = bornes.stallMs;
54
+ relaisMaxMs = bornes.maxMs;
55
+ if (bornes.invalides.length) {
56
+ // Une fois, ici — pas à chaque relais : un réglage hors plage est une erreur de déploiement, dite
57
+ // à l'exploitant avec la plage, plutôt qu'un défaut appliqué en silence.
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 */ }
59
+ }
47
60
  // Le domaine reçoit le même contexte : une seule construction pour tout le player.
48
61
  require("./shares").init(ctx);
49
62
  require("./retention").init(ctx);
@@ -69,8 +82,15 @@ const isAllowedStorageUrl = (url) => PLAYER.storage.isAllowedUrl(url);
69
82
  // contexte (`config.maxConcurrentRelays`, défaut 64) : assez pour les requêtes Range parallèles de
70
83
  // pdf.js, borné pour un processus. Sur serverless la plate-forme borne déjà la concurrence globale ;
71
84
  // ceci protège le mode autonome et chaque instance chaude.
72
- const RELAIS_SIMULTANES_DEFAUT = 64;
85
+ const { bornesRelais, RELAIS_SIMULTANES_DEFAUT, RELAIS_STALL_MS_DEFAUT, RELAIS_MAX_MS_DEFAUT } = require("./bornes");
73
86
  let plafondRelais = RELAIS_SIMULTANES_DEFAUT, relaisEnCours = 0;
87
+ // ⚠️ COMPTÉS, PARCE QU'UN HÔTE A CRU LES LIRE AILLEURS. On demandait aux hôtes de chercher « relais
88
+ // refusés » dans leurs journaux ; l'un d'eux a répondu par `lectureSaturee.total = 0` de la carte —
89
+ // qui ne compte que le cache de lecture, pas les relais (13/09). Une question qu'un hôte peut
90
+ // trancher par la carte ne doit pas être posée comme une fouille de journaux : la carte est
91
+ // structurée, datée, et répond même pour l'hôte qui ne lit jamais ses journaux. État du processus,
92
+ // comme `relaisEnCours` : jamais remis à zéro par `init`.
93
+ let relaisRefusesTotal = 0, dernierRefusRelais = null;
74
94
  // ⚠️ UNE PLACE N'EST BORNÉE QUE SI LE RELAIS QUI L'OCCUPE FINIT. Un client qui cesse de lire — ou un
75
95
  // amont qui cesse d'envoyer — laissait le pipeline en attente pour toujours : `finally` jamais atteint,
76
96
  // place jamais rendue, et avec un plafond de 1, plus aucun fichier ne partait (reproduit par un audit
@@ -78,24 +98,27 @@ let plafondRelais = RELAIS_SIMULTANES_DEFAUT, relaisEnCours = 0;
78
98
  // réponse — le commentaire de `bin/serve.js` affirmait le contraire. Deux bornes, configurables par
79
99
  // le contexte : sans progression pendant `relayStallMs` (30 s), ou au-delà de `relayMaxMs` (15 min),
80
100
  // le pipeline est ABANDONNÉ par signal — source amont détruite, réponse détruite, rejet, `finally`.
81
- const RELAIS_STALL_MS_DEFAUT = 30_000, RELAIS_MAX_MS_DEFAUT = 900_000;
101
+ // ⚠️ Les valeurs viennent de `server/bornes.js` : entiers dans une plage écrite, jamais « tout nombre
102
+ // fini » — `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms (audit, cinquième passe).
82
103
  let relaisStallMs = RELAIS_STALL_MS_DEFAUT, relaisMaxMs = RELAIS_MAX_MS_DEFAUT;
83
- const borneMs = (v, defaut) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : defaut; };
84
- function borneRelais(v) { const n = Math.trunc(Number(v)); return Number.isFinite(n) && n >= 1 ? n : RELAIS_SIMULTANES_DEFAUT; }
85
104
  async function relayerSousAdmission(res, travail) {
86
105
  if (relaisEnCours >= plafondRelais) {
106
+ relaisRefusesTotal += 1; dernierRefusRelais = Date.now();
87
107
  // Une fois par heure, l'exploitant l'apprend : un 503 muet ressemble à une panne d'amont.
88
108
  try {
89
109
  if (await PLAYER.limits.allow("relais:sature-avert", 1, 3600)) {
90
- 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 });
91
111
  }
92
112
  } catch { /* jamais bloquant */ }
93
113
  res.setHeader("Retry-After", "2");
94
114
  refuserEnTexte(res, 503, "Trop de transferts en cours, réessayez dans un instant");
95
115
  return;
96
116
  }
117
+ // Les bornes de CE relais sont figées ici : un `init` pendant le transfert relit la configuration
118
+ // pour les suivants, jamais pour celui-ci.
119
+ const bornes = { stallMs: relaisStallMs, maxMs: relaisMaxMs };
97
120
  relaisEnCours += 1;
98
- try { await travail(); } finally { relaisEnCours -= 1; }
121
+ try { await travail(bornes); } finally { relaisEnCours -= 1; }
99
122
  }
100
123
 
101
124
  // GREFFONS de ce studio — jamais du player. `null` quand le module est absent ou coupé
@@ -262,7 +285,7 @@ const { refuserEnTexte, repondreJson, repondreJsonTexte } = require("./reponses.
262
285
  // exemplaires d'un fait divergent, c'est la formule du dépôt.
263
286
  const POLITIQUE_PERMISSIONS = "camera=(), microphone=(), geolocation=(), payment=()";
264
287
 
265
- async function relayerFichier(res, r, disposition) {
288
+ async function relayerFichier(res, r, disposition, bornes = { stallMs: relaisStallMs, maxMs: relaisMaxMs }) {
266
289
  if (!r) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
267
290
  // 413 et 416 sont des REFUS ARGUMENTÉS de l'amont local (plafond, borne absurde) : les fondre
268
291
  // dans un 502 dirait « panne » là où l'amont a dit « demande irrecevable ».
@@ -288,7 +311,7 @@ async function relayerFichier(res, r, disposition) {
288
311
  const brute = r.headers.get("content-length");
289
312
  const annoncee = Number(brute || 0);
290
313
  if (annoncee > PLAFOND_RELAIS) {
291
- 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 */ }
292
315
  refuserEnTexte(res, 413, "Fichier trop volumineux");
293
316
  // ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
294
317
  // pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
@@ -347,8 +370,8 @@ async function relayerFichier(res, r, disposition) {
347
370
  // ne se réarme jamais. L'abandon passe par le signal du pipeline, qui détruit la source ET la réponse.
348
371
  const abandon = new globalThis.AbortController();
349
372
  let stall = null;
350
- const rearmer = () => { clearTimeout(stall); stall = setTimeout(() => abandon.abort(new Error(`relais abandonné : aucune progression depuis ${relaisStallMs} ms`)), relaisStallMs); };
351
- const budget = setTimeout(() => abandon.abort(new Error(`relais abandonné : plus de ${relaisMaxMs} ms au total`)), relaisMaxMs);
373
+ const rearmer = () => { clearTimeout(stall); stall = setTimeout(() => abandon.abort(new Error(`relais abandonné : aucune progression depuis ${bornes.stallMs} ms`)), bornes.stallMs); };
374
+ const budget = setTimeout(() => abandon.abort(new Error(`relais abandonné : plus de ${bornes.maxMs} ms au total`)), bornes.maxMs);
352
375
  rearmer();
353
376
  try {
354
377
  await pipeline(Readable.fromWeb(r.body), async function* (source) {
@@ -365,7 +388,7 @@ async function relayerFichier(res, r, disposition) {
365
388
  // reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
366
389
  // défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
367
390
  const cause = abandon.signal.aborted && abandon.signal.reason instanceof Error ? abandon.signal.reason : erreur;
368
- 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 */ }
369
392
  try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
370
393
  } finally {
371
394
  clearTimeout(stall); clearTimeout(budget);
@@ -817,6 +840,15 @@ async function handlerMesure(req, res) {
817
840
  derniereIlYaS: dernier == null ? null : Math.max(0, Math.round((Date.now() - dernier) / 1000)),
818
841
  };
819
842
  })(),
843
+ // ⚠️ MÊME FORME, AUTRE PLAFOND. `lectureSaturee` est le cache de lecture ; ceci est l'admission
844
+ // des relais de fichiers (`config.maxConcurrentRelays`). Un hôte a lu le premier pour le second
845
+ // (13/09) parce que le second n'existait pas sur la carte — et `mesures.statuts.occupe503`
846
+ // les confond. Trois clés, jamais séparées : un total sans sa fenêtre ment par omission.
847
+ relaisRefuses: {
848
+ total: relaisRefusesTotal,
849
+ fenetreS: Math.round(process.uptime()),
850
+ derniereIlYaS: dernierRefusRelais == null ? null : Math.max(0, Math.round((Date.now() - dernierRefusRelais) / 1000)),
851
+ },
820
852
  // ⚠️ CE QUE CETTE INSTANCE A VÉCU — parce que `lectureSaturee` ne répondait qu'à UNE
821
853
  // question. « La route est-elle lente ? », « lesquelles ? », « la base ou nous ? »,
822
854
  // « combien de 5xx ? », « la boucle décroche-t-elle ? » n'avaient aucune réponse
@@ -1037,9 +1069,9 @@ async function handlerMesure(req, res) {
1037
1069
  if (String(q.file || "") === "1") {
1038
1070
  if (!isAllowedStorageUrl(pres.file_url)) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
1039
1071
  const range = req.headers["range"];
1040
- await relayerSousAdmission(res, async () => {
1072
+ await relayerSousAdmission(res, async (bornes) => {
1041
1073
  const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
1042
- await relayerFichier(res, r, null);
1074
+ await relayerFichier(res, r, null, bornes);
1043
1075
  });
1044
1076
  return;
1045
1077
  }
@@ -1062,9 +1094,9 @@ async function handlerMesure(req, res) {
1062
1094
  if (!isAllowedStorageUrl(url)) return sendRefusal(res, "url-not-allowed", embed);
1063
1095
  if (String(q.stream || "") === "1") {
1064
1096
  const range = req.headers["range"];
1065
- await relayerSousAdmission(res, async () => {
1097
+ await relayerSousAdmission(res, async (bornes) => {
1066
1098
  const r = await PLAYER.storage.fetchFile(url, { range });
1067
- await relayerFichier(res, r, dispositionInline(q.name));
1099
+ await relayerFichier(res, r, dispositionInline(q.name), bornes);
1068
1100
  });
1069
1101
  return;
1070
1102
  }
@@ -1116,9 +1148,9 @@ async function handlerMesure(req, res) {
1116
1148
  // Stream depuis le Storage en RELAYANT les requêtes Range → pdf.js charge progressivement (les 1res
1117
1149
  // pages s'affichent sans télécharger tout le PDF) → affichage bien plus rapide.
1118
1150
  const range = req.headers["range"];
1119
- await relayerSousAdmission(res, async () => {
1151
+ await relayerSousAdmission(res, async (bornes) => {
1120
1152
  const r = await PLAYER.storage.fetchFile(share.file_url, { range });
1121
- await relayerFichier(res, r, dispositionInline(share.file_name));
1153
+ await relayerFichier(res, r, dispositionInline(share.file_name), bornes);
1122
1154
  });
1123
1155
  return;
1124
1156
  }
@@ -1188,7 +1220,7 @@ async function handlerMesure(req, res) {
1188
1220
  // exactement comme sans PLAYER_HOST_AUTHZ_URL personne ne peut DIFFUSER.
1189
1221
  if (share.embed && !(PLAYER.config.extraFrameAncestors || []).length) {
1190
1222
  try {
1191
- PLAYER.errors.capture(
1223
+ capturerSansBloquer(PLAYER.errors,
1192
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"),
1193
1225
  { route: "doc", indice: "le navigateur bloquera l'iframe avant le chargement — aucun embed-denied ne partira" },
1194
1226
  );
@@ -1211,6 +1243,9 @@ async function handlerMesure(req, res) {
1211
1243
  // regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
1212
1244
  // ⚠️ Exporté pour être ÉPROUVÉ : « le contexte reste vivant » ne se vérifie pas de l'extérieur.
1213
1245
  module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript,
1246
+ // ⚠️ COUTURE DE BANC : le compteur de relais en vol est un état du PROCESSUS, jamais remis à zéro par
1247
+ // `init`. Un banc doit pouvoir vérifier qu'il revient à zéro et ne passe jamais sous zéro.
1248
+ __relaisEnCours: () => relaisEnCours,
1214
1249
  // ⚠️ COUTURE DE BANC, PAS D'API : le cache de lecture est global au module, et un banc qui laisse des
1215
1250
  // lectures en vol contamine le suivant (128 promesses éternelles, 503 partout — trouvé par un audit
1216
1251
  // externe sous mélange, graine 20260913). Un banc doit pouvoir VÉRIFIER qu'il rend le cache vide.
@@ -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");
@@ -20,9 +21,11 @@ let PLAYER = null;
20
21
  // Deux dimensions pour le code, parce qu'une seule se contourne : par ADRESSE (une adresse ne
21
22
  // recommence pas à zéro en changeant d'email) et par IDENTITÉ (plusieurs adresses ne forcent pas un
22
23
  // même email). Les compteurs sont pris À L'ADMISSION, donc réussite, échec et exception les
23
- // consomment pareil. L'identité est une EMPREINTE de l'email normalisé, jamais l'email : la table des
24
- // compteurs n'a pas à porter d'adresses en clair. (Le cœur n'a pas de secret de serveur, par
25
- // conception — voir le contexte autonome — donc une empreinte, pas un HMAC.)
24
+ // consomment pareil. L'identité n'est jamais l'email : la table des compteurs n'a pas à porter
25
+ // d'adresses en clair. La clé vient du GREFFON (un HMAC avec un secret chez l'hôte, ci-dessous) ;
26
+ // l'empreinte SHA-256 n'est que le repli, et il est dit. ⚠️ Ce paragraphe affirmait « le cœur n'a
27
+ // pas de secret de serveur, donc une empreinte, pas un HMAC » — retiré (cinquième passe de l'audit,
28
+ // 13/09) : trop absolu, et surtout la clé n'a pas besoin d'un secret du cœur, elle vient de l'hôte.
26
29
  const VERIF_PAR_ADRESSE = 100, VERIF_PAR_IDENTITE = 10, VERIF_FENETRE_IDENTITE_S = 900;
27
30
  const GOOGLE_PAR_ADRESSE = 100, DEMANDE_PAR_IDENTITE = 5;
28
31
  // ⚠️ UN SHA-256 D'EMAIL N'EST PAS UNE ANONYMISATION : il se renverse par dictionnaire — qui lit la
@@ -52,7 +55,7 @@ function direLeRepli(pourquoi) {
52
55
  if (repliDit) return;
53
56
  repliDit = true;
54
57
  try {
55
- 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 });
56
59
  } catch { /* jamais bloquant */ }
57
60
  }
58
61
  const init = (ctx) => { PLAYER = ctx; repliDit = false; };
@@ -93,7 +96,7 @@ async function traiter(req, res, body, _slug) {
93
96
  // noms. Une liste ne voit que ce qu'on y a mis ; une forme voit aussi le prochain.
94
97
  try {
95
98
  if (await PLAYER.limits.allow("unlock:echec", 1, 3600)) {
96
- 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" });
97
100
  }
98
101
  } catch { /* un journal ne doit jamais empêcher une lecture */ }
99
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;
@@ -128,14 +128,20 @@ export interface Reglages {
128
128
  supabasePublishableKey: string;
129
129
  mapsKey: string;
130
130
  extraFrameAncestors: string[];
131
- /** Transferts de fichiers relayés simultanément par processus (défaut 64). Au-delà, le relais
132
- * répond 503 + Retry-After AVANT l'appel amont ; la place est rendue en `finally`. */
133
- maxConcurrentRelays?: number;
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`). 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;
134
138
  /** Un relais sans progression pendant ce délai est abandonné — source et réponse détruites, place
135
- * rendue (défaut 30 000 ms). `requestTimeout` ne borne pas l'émission d'une réponse. */
136
- relayStallMs?: number;
137
- /** Durée totale maximale d'un relais (défaut 900 000 ms). */
138
- relayMaxMs?: number;
139
+ * rendue (défaut 30 000 ms ; entier de 1 à 86 400 000 ms, sinon le défaut, dit une fois à `init` :
140
+ * `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms). `requestTimeout` ne borne
141
+ * pas l'émission d'une réponse. */
142
+ relayStallMs?: number | string;
143
+ /** Durée totale maximale d'un relais (défaut 900 000 ms ; même plage que `relayStallMs`). */
144
+ relayMaxMs?: number | string;
139
145
  /** Ce qui est POSÉ, à côté de ce que le code SAIT faire : la carte d'identité publie les deux,
140
146
  * parce qu'une capacité disponible mais non configurée se comporte comme une absence. */
141
147
  separateIssuer?: boolean;