discovery-media-player 0.1.164 → 0.1.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/serve.js CHANGED
@@ -179,7 +179,21 @@ async function servir(req, res) {
179
179
  // Le player lit `req.query` (convention des plateformes serverless) et `req.body` déjà analysé.
180
180
  req.query = q;
181
181
  if (req.method === "POST") {
182
- req.body = await lireCorpsJson(req);
182
+ // ⚠️ TROIS ISSUES DISTINCTES, TROIS RÉPONSES — un corps invalide, trop gros ou coupé rendait
183
+ // `{}` dans les trois cas, et le gestionnaire répondait « bad-event » à un client qui n'avait
184
+ // qu'envoyé un fichier trop lourd. 413 pour le dépassement (avec `Connection: close` : on ne
185
+ // draine pas un corps sans fin), 400 pour un JSON illisible, rien pour une connexion partie.
186
+ // Relevé par un audit externe le 13/09.
187
+ const lu = await lireCorpsJson(req);
188
+ if (lu.etat === "too-large") {
189
+ res.setHeader("Connection", "close");
190
+ res.once("finish", () => { try { req.destroy(); } catch { /* déjà parti */ } });
191
+ player.refuserEnTexte(res, 413, "Corps trop volumineux (1 Mo au plus)");
192
+ return;
193
+ }
194
+ if (lu.etat === "invalid-json") { player.refuserEnTexte(res, 400, "Corps JSON illisible"); return; }
195
+ if (lu.etat === "aborted") { try { res.end(); } catch { /* flux clos */ } return; }
196
+ req.body = lu.corps;
183
197
  }
184
198
 
185
199
  try {
@@ -202,22 +216,43 @@ const serveur = http.createServer((req, res) => {
202
216
  player.refuserEnTexte(res, 500, "Erreur");
203
217
  });
204
218
  });
219
+ // ⚠️ LES DÉLAIS DE NODE (300 s par requête, 60 s pour les en-têtes) SONT CEUX D'UN SERVEUR DERRIÈRE UN
220
+ // PROXY, PAS D'UN SERVEUR EXPOSÉ. Mesurés par un audit externe le 13/09 : `requestTimeout` 300 000,
221
+ // `headersTimeout` 60 000. Une connexion qui envoie ses en-têtes au goutte-à-goutte tenait donc une
222
+ // minute, un corps lent cinq — par socket. ⚠️ Ces bornes portent sur la REQUÊTE : `requestTimeout`
223
+ // ne couvre PAS l'émission d'une réponse (cette phrase affirmait que « trente secondes couvrent un
224
+ // relais de 60 Mo » — faux, relevé par le même audit) ; un relais lent est borné par ses propres
225
+ // délais de progression et de budget (`relayStallMs`, `relayMaxMs` dans le gestionnaire). Les
226
+ // en-têtes n'ont aucune raison de prendre plus de quinze secondes ; un keep-alive court rend les
227
+ // sockets. Un proxy amont peut serrer davantage, jamais l'inverse.
228
+ serveur.requestTimeout = 30_000;
229
+ serveur.headersTimeout = 15_000;
230
+ serveur.keepAliveTimeout = 5_000;
205
231
 
206
232
  /** Corps JSON, borné. Un corps sans fin est une façon peu coûteuse de faire tomber un serveur. */
233
+ /**
234
+ * Rend `{ etat, corps }` : `ok` (corps = l'objet lu, `{}` pour un corps vide), `too-large` (la lecture
235
+ * s'arrête au premier octet au-delà de `maxOctets`, sans drainer la suite), `invalid-json`, ou
236
+ * `aborted` (la connexion est partie avant la fin). L'appelant choisit le code HTTP : ici, on ne sait
237
+ * que lire.
238
+ */
207
239
  function lireCorpsJson(req, maxOctets = 1_000_000) {
208
240
  return new Promise((resolve) => {
209
- let taille = 0;
241
+ let taille = 0, regle = false;
210
242
  const morceaux = [];
243
+ const rendre = (v) => { if (!regle) { regle = true; resolve(v); } };
211
244
  req.on("data", (c) => {
245
+ if (regle) return;
212
246
  taille += c.length;
213
- if (taille > maxOctets) { req.destroy(); resolve({}); return; }
247
+ if (taille > maxOctets) { try { req.pause(); } catch { /* déjà clos */ } rendre({ etat: "too-large", corps: null }); return; }
214
248
  morceaux.push(c);
215
249
  });
216
250
  req.on("end", () => {
217
- try { resolve(JSON.parse(Buffer.concat(morceaux).toString("utf8") || "{}")); }
218
- catch { resolve({}); }
251
+ try { rendre({ etat: "ok", corps: JSON.parse(Buffer.concat(morceaux).toString("utf8") || "{}") }); }
252
+ catch { rendre({ etat: "invalid-json", corps: null }); }
219
253
  });
220
- req.on("error", () => resolve({}));
254
+ req.on("error", () => rendre({ etat: "aborted", corps: null }));
255
+ req.on("aborted", () => rendre({ etat: "aborted", corps: null }));
221
256
  });
222
257
  }
223
258
 
@@ -286,4 +321,4 @@ if (require.main === module) {
286
321
  });
287
322
  }
288
323
 
289
- module.exports = { serveur, servir, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
324
+ module.exports = { serveur, servir, versParametres, pageAccueil, lireCorpsJson, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
@@ -840,6 +840,18 @@ function createStandaloneContext(env = process.env) {
840
840
  supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY || "",
841
841
  mapsKey: env.GOOGLE_MAPS_API_KEY || "",
842
842
  extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
843
+ // Transferts de fichiers simultanés par processus (défaut 64) : le relais refuse en 503 au-delà,
844
+ // avant tout appel amont. Lu ICI, pas dans le cœur — la configuration entre par le contexte.
845
+ // ⚠️ TRANSMIS TEL QUEL, PAS NORMALISÉ ICI. La première écriture faisait `Number(x) || 64` : une
846
+ // valeur illisible devenait le défaut EN SILENCE, et 2 147 483 648 passait jusqu'à `setTimeout`,
847
+ // qui ramène un tel délai à 1 ms (audit externe, cinquième passe). Les plages vivent dans
848
+ // `server/bornes.js`, et le cœur les applique à `init` en DISANT une fois ce qu'il a refusé —
849
+ // 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,
851
+ // Un relais sans progression pendant relayStallMs, ou plus long que relayMaxMs, est abandonné
852
+ // (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,
843
855
 
844
856
  /**
845
857
  * 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,
@@ -241,6 +242,18 @@ object rather than as separate fields you could read apart.
241
242
  not of your deployment. Aggregating is your job — and letting you believe otherwise would be worse
242
243
  than returning nothing.
243
244
 
245
+ ### `relaisRefuses` — what the relay admission refused
246
+
247
+ Same three keys, same reading rules, **another ceiling**: this counts the file relays refused with
248
+ `503` + `Retry-After: 2` because `config.maxConcurrentRelays` was reached (see *Relay `Range`* in the
249
+ three things a host implements). ⚠️ `lectureSaturee` does **not** cover it — it is the read cache
250
+ only. A host answered *"we never saturate relays"* from `lectureSaturee.total = 0` (13/09), which was
251
+ a reasonable reading of a card that had no relay counter; and `mesures.statuts.occupe503` mixes the
252
+ two refusals. This field exists so that the question *did this instance refuse a relay?* is answered
253
+ by the card, structured and dated, and never by a search through logs for `relais refusés` — the
254
+ log line stays for diagnosis, the card is the way to *notice*. Process-local like everything on this
255
+ card, and **never reset by `init`**, exactly like the counter of open relays.
256
+
244
257
  ⚠️ **Before you upgrade, do not read `presenceDurcissement` or `presenceFusion`.** They are *reports
245
258
  of execution*: on an instance where nothing is running they say `inconnu`, which means *nobody
246
259
  looked* — not *the migration is there*. A pre-flight check built on one of them silently passes on
@@ -410,6 +423,56 @@ inheritance both refuse — without either of them knowing why.
410
423
  Requires `supabase/migrations/0001-destinataire-atteste.sql`. Until it is applied the player refuses
411
424
  the attested creation and names the file; it never falls back to the other column.
412
425
 
426
+ ## The visitor wall (`plugins.visitors`): what the player counts, and what your plugin must do
427
+
428
+ A host can gate documents behind a soft wall — an e-mail code, or a Google credential — by providing
429
+ `plugins.visitors` with `requestCode(email, { title })`, `verifyCode(email, code, name)` and
430
+ `verifyGoogle(credential)`. The player exposes them as `visitor-request`, `visitor-verify` and
431
+ `visitor-google`.
432
+
433
+ ⚠️ **Until this train, only the request was rate-limited; verification called your plugin directly.**
434
+ An external audit reproduced 1 000 code attempts and 1 000 Google verifications from one address with
435
+ zero limiter calls (13/09). A short code with no counter in the plugin was brute-forceable, and a
436
+ Google verification per anonymous request was a network amplifier. The player no longer assumes your
437
+ plugin counts — the same rule as the assistant's session↔document binding: a security property must
438
+ not depend on code the player does not contain.
439
+
440
+ | action | per address | per identity (fingerprint of the normalised e-mail, never the address) |
441
+ |---|---|---|
442
+ | `visitor-request` | 20 / hour | 5 / hour |
443
+ | `visitor-verify` | 100 / hour | 10 / 15 minutes |
444
+ | `visitor-google` | 100 / hour | — |
445
+
446
+ Counters are taken **at admission**: success, failure and an exception in your plugin consume them
447
+ alike. Beyond a limit the answer is `429 { error: "rate" }` and **your plugin is not called**.
448
+
449
+ ⚠️ **Provide `rateLimitKey(email): Promise<string>` on the plugin — the identity key should be
450
+ yours.** Without it the player keys the per-identity counters on a truncated SHA-256 of the
451
+ normalised e-mail: `player_rate_limits` never carries an address in clear, but a fingerprint is a
452
+ **pseudonym, not a secret** — anyone reading that table, a backup or an admin tool can precompute the
453
+ fingerprints of likely addresses (an audit showed it on 13/09). Your implementation should be a
454
+ stable, opaque HMAC with a host-side secret and **domain separation**:
455
+ `HMAC(secret, "visitor-email\0" + emailNormalised)`. The player calls it with the e-mail already
456
+ trimmed and lower-cased, prefixes your key with `h:` (a fallback fingerprint gets `e:`, so the two
457
+ never collide), and truncates it to 64 characters. If the capability is absent, throws, or returns
458
+ anything but a non-empty string, the player **falls back to the fingerprint and reports it once per
459
+ process** through `errors.capture` (`benin: true`): refusing to limit would be worse than limiting
460
+ under a weak pseudonym, and silence would be worse than both. (An earlier version of this paragraph
461
+ said the player holds no server secret at all — too absolute: the standalone context already carries
462
+ `ipHashSecret` for another purpose. The key still belongs with you, not with that secret.)
463
+
464
+ ⚠️ **What your plugin must still guarantee — the player cannot do it for you:**
465
+
466
+ - the code is **short-lived** (minutes, not hours) and **single-use**: a code that stays valid after a
467
+ successful verification can be replayed from a shoulder-surfed screen;
468
+ - the code has enough entropy for 10 attempts per quarter-hour not to be a lottery — six digits give
469
+ one chance in 100 000 per attempt at that pace, which is acceptable; four digits are not;
470
+ - `verifyGoogle` validates the credential's audience and issuer server-side, and does not accept an
471
+ expired token.
472
+
473
+ The player's counters bound the *rate*; your plugin bounds the *code*. Both are needed, and neither
474
+ replaces the other.
475
+
413
476
  ## ⚠️ What `limits.allow` promises changed
414
477
 
415
478
  It used to promise *best effort, per process*. The standalone context now counts in a **shared
@@ -712,6 +775,24 @@ Four requirements, in order of what they cost when missed:
712
775
  anywhere. Announce the length of what you send, request `Accept-Encoding: identity`, and refuse
713
776
  a compressed `206` — range bounds refer to compressed bytes.
714
777
  2. **Relay `Range`** (`206` + `Accept-Ranges: bytes`). Progressive loading depends on it.
778
+ ⚠️ And expect the player to hold **at most `config.maxConcurrentRelays` relays open per process**
779
+ (default 64; `PLAYER_MAX_RELAYS` in the standalone context): above it the player answers **503
780
+ with `Retry-After: 2` before calling you**, with no queue. The stream bounded bytes; nothing
781
+ bounded how many streams were open — an audit opened 200 slow transfers and got 200 upstream
782
+ connections (13/09). The slot is released in a `finally`, so your errors and a client leaving
783
+ mid-stream give it back — and so does a relay that **stops progressing**: no chunk for
784
+ `config.relayStallMs` (30 s) or a total beyond `config.relayMaxMs` (15 min) aborts the pipeline,
785
+ destroying your response and the client's. A client that stops reading no longer keeps a slot
786
+ forever; a route of yours that stops sending does not either.
787
+ ⚠️ Three things about those numbers, all measured on 0.1.165 by an audit: **64 is not a safe
788
+ default everywhere** — 64 relays × 8 MiB with slow consumers took the process RSS from ~63 MiB to
789
+ 193–257 MiB; on a 256 MiB process set 16–32. The settings are **integers in a written range**
790
+ (`maxConcurrentRelays` 1–1024, the two delays 1–86 400 000 ms): out of range falls back to the
791
+ default and is reported once at `init` through `errors.capture`, because `setTimeout` clamps
792
+ anything above 2 147 483 647 ms to 1 ms and a "24-day" delay aborted a relay in 6 ms. And the
793
+ **counter of open relays is never reset by `init`**: calling `init` again re-reads the ceiling and
794
+ the delays for the relays admitted afterwards; one already in flight keeps its slot and its
795
+ bounds, so a re-initialisation cannot let a request past a full ceiling.
715
796
  3. **Accept a server-to-server call.** A tracked link is opened by someone with no session on your
716
797
  side. Authenticate the player with the shared secret in the `x-player-fetch-secret` **header** —
717
798
  header only, never a query string: logs keep URLs.
@@ -762,6 +843,23 @@ back on an inability to *reach*.** And "do not fall back" applies to what you **
762
843
 
763
844
  ## What will bite
764
845
 
846
+ ⚠️ **`node_modules` cannot be a symlink, and a repository inside a syncing folder will break under
847
+ you.** Reported by a host (13/09), not reproduced here: a checkout living in iCloud Drive had files
848
+ duplicated and emptied *while being worked on* — four breakages in six days, four different
849
+ signatures, one of which blocked a delivery. Their escape route, a symbolic link from `node_modules`
850
+ to a folder outside the sync, is a dead end: `npm` replaces the link with a real directory, whether
851
+ the target is empty or populated, on `install` as on `ci`. Keep the clone itself outside any
852
+ synchronised folder; there is no way to keep only its dependencies out.
853
+
854
+ ⚠️ **Every capability you provide must settle in bounded time — `db.request` first of all.** The
855
+ player awaits your `db.request`, `storage.fetchFile`, `mail.send` and the visitor plugin; a promise
856
+ that never settles keeps a request in flight, and the read cache admits at most 128 in-flight reads
857
+ per process before answering **503 busy** to everyone. A database call that hangs is therefore not
858
+ "slow", it is an availability incident for the whole instance — the exact mechanism an audit
859
+ reproduced inside the test suite with a never-settling promise (13/09). Time out your own calls
860
+ (the standalone context bounds its own with `AbortSignal`), and never return a promise you cannot
861
+ guarantee will settle.
862
+
765
863
  **Your document-opening doors reappear.** A host has more than one place that opens a file, and new
766
864
  ones get written. Keep the list and hunt it periodically — and note that **your search criteria
767
865
  decide what you find**: search by what the user *obtains* (a document opens), not by the technique
@@ -834,12 +932,18 @@ late, a caller reading `sent: false` retries, creating a **second child link and
834
932
  | `delivery` | what happened | what to do |
835
933
  |---|---|---|
836
934
  | `"sent"` | your mail path reported success | nothing |
837
- | `"refused"` | a decision was made — yours or ours; `sendRefused` names it | surface the reason; retrying will refuse again |
935
+ | `"refused"` | a decision was made — yours or ours; `sendRefused` names it, and when your mail hook answered `{ sent: false, reason }` (or `motif`) that word comes back as `hostReason`, trimmed to 80 characters | surface the reason; retrying will refuse again |
838
936
  | `"unknown"` | the call failed (timeout, network). **We do not know whether the mail went out** | surface it to a human. **Do not retry automatically** — a retry may duplicate the email |
839
937
  | `"not-requested"` | `send` was falsy | nothing |
840
938
 
841
939
  `sent` is unchanged for integrations already reading it.
842
940
 
941
+ ⚠️ **Your refusal reason was being thrown away.** One host answers every refusal with
942
+ `{ sent: false, motif }` — eight distinct reasons — precisely so that *refused* never reads as *down*;
943
+ the route read only `sent`. From this train on, a string `reason` or `motif` on your hook's answer travels
944
+ back to the caller as `hostReason` (a string, at most 80 characters; an object is ignored). It is
945
+ your word to your own caller, not a channel: keep it short and non-sensitive.
946
+
843
947
  ⚠️ **And you can now make the retry safe: pass a `clientKey`.** Two `reshare` calls with the same
844
948
  parent, the same recipient and the same `clientKey` return the **same child link** and send **one**
845
949
  email — the second answers `delivery: "idempotent"`, which means *"this was already done"*, not
@@ -875,6 +979,11 @@ Three things changed, and the second one is the one you may notice:
875
979
  - **The renderer refuses the same URLs again**, because one path never reaches this server: a
876
980
  participant can broadcast presence over Realtime straight to the other viewers. No server-side
877
981
  barrier can see that, so the check also lives where every path converges — at render time.
982
+ - ⚠️ **`data:` and `blob:` URLs are refused too, deliberately.** They reach no one, but a data URL
983
+ travels inside every message row and every presence broadcast, and one more "harmless" form is
984
+ one more form to reason about at the next audit. A host that stores avatars as data URLs — as an
985
+ offline fallback, say — will see initials, and nothing will say why except this line. (Asked for by
986
+ a host, 13/09: three of its members carry one.)
878
987
 
879
988
  ⚠️ **What your `storage.remove` returns now decides whether a row survives.** It returns a boolean:
880
989
  `true` means the object is gone, `false` means it is still there. Until 0.1.163 the retention sweep
@@ -893,6 +1002,14 @@ permanently. The sweep now **keeps the row** when `remove` returns `false`, and
893
1002
  - **`retenues > 0` in a retention report means your provider refused a removal**, not that the purge
894
1003
  is broken. The next pass retries. A row that lingers is recoverable; a file whose only pointer was
895
1004
  erased is not.
1005
+ - ⚠️ **If you provide no `storage.remove` at all, file-bearing rows are retained too — and the report
1006
+ says so.** There were three states, not two: `true`, `false`, and *not attempted*. Until 0.1.164 the
1007
+ third one let the row go "as before" — so a host providing `put` without `remove` manufactured
1008
+ permanently unreachable objects at every sweep, with no counter moving. A host found it by reading
1009
+ `retention.js`, not this paragraph, which assumed you provide one. Now: the row stays, `retenues`
1010
+ counts it, the report carries `sansRemove: true` (in `dryRun` too, so you can read it before arming
1011
+ the sweep), and the missing capability is reported once per process through `errors.capture` with
1012
+ `benin: true`. Provide `storage.remove` and the next pass lets them go.
896
1013
 
897
1014
  **If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
898
1015
  only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
package/docs/RETENTION.md CHANGED
@@ -366,6 +366,12 @@ what keeps them out of this tool's candidate list.
366
366
  by an external audit on 2026-09-12, reproduced before being fixed. A retained row is recoverable —
367
367
  the next pass retries it; a lost file is not. Read `retenues > 0` as *"the storage provider refused
368
368
  a removal; look at it"*, not as a purge failure.
369
+ - ⚠️ **And "not attempted" is the third state, found by a host on 0.1.164.** A host providing `put`
370
+ without `remove` had no capability to refuse with: `retirerFichier` answered `null` and the row
371
+ went "as before" — the irreversible loss above, through the other door. Now a missing
372
+ `storage.remove` retains every file-bearing row, counts it in `retenues`, sets `sansRemove: true` on
373
+ the result (also in `dryRun`), and is reported once per process (`errors.capture`, `benin: true`).
374
+ A row without a file still goes.
369
375
  - ⚠️ **The alignment `.json` never retains anything — only the audio does.** A third of fingerprints
370
376
  legitimately have no companion (see the 552/356 measurement above); gating the row on both objects
371
377
  would hold a third of the cache forever to protect files that do not exist. So the `.mp3` alone
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.164",
3
+ "version": "0.1.166",
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,59 @@
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 ».
33
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.trim()) : NaN;
34
+ const valide = Number.isSafeInteger(n) && n >= min && n <= max;
35
+ return { valeur: valide ? n : defaut, valide, posee: true };
36
+ }
37
+
38
+ /**
39
+ * Les trois réglages du relais, lus dans `config`, bornés, avec la liste de ce qui a été refusé —
40
+ * pour que l'appelant le dise une fois, avec la plage, plutôt que d'appliquer un défaut en silence.
41
+ * @returns {{ plafond: number, stallMs: number, maxMs: number, invalides: string[] }}
42
+ */
43
+ function bornesRelais(config) {
44
+ const c = config || {};
45
+ const plafond = entierBorne(c.maxConcurrentRelays, { defaut: RELAIS_SIMULTANES_DEFAUT, min: RELAIS_SIMULTANES_MIN, max: RELAIS_SIMULTANES_MAX });
46
+ const stall = entierBorne(c.relayStallMs, { defaut: RELAIS_STALL_MS_DEFAUT, min: RELAIS_MS_MIN, max: RELAIS_MS_MAX });
47
+ const total = entierBorne(c.relayMaxMs, { defaut: RELAIS_MAX_MS_DEFAUT, min: RELAIS_MS_MIN, max: RELAIS_MS_MAX });
48
+ const invalides = [];
49
+ if (!plafond.valide) invalides.push(`maxConcurrentRelays=${String(c.maxConcurrentRelays)} (entier de ${RELAIS_SIMULTANES_MIN} à ${RELAIS_SIMULTANES_MAX}, défaut ${RELAIS_SIMULTANES_DEFAUT})`);
50
+ if (!stall.valide) invalides.push(`relayStallMs=${String(c.relayStallMs)} (entier de ${RELAIS_MS_MIN} à ${RELAIS_MS_MAX} ms, défaut ${RELAIS_STALL_MS_DEFAUT})`);
51
+ if (!total.valide) invalides.push(`relayMaxMs=${String(c.relayMaxMs)} (entier de ${RELAIS_MS_MIN} à ${RELAIS_MS_MAX} ms, défaut ${RELAIS_MAX_MS_DEFAUT})`);
52
+ return { plafond: plafond.valeur, stallMs: stall.valeur, maxMs: total.valeur, invalides };
53
+ }
54
+
55
+ module.exports = {
56
+ entierBorne, bornesRelais,
57
+ RELAIS_SIMULTANES_DEFAUT, RELAIS_SIMULTANES_MIN, RELAIS_SIMULTANES_MAX,
58
+ RELAIS_STALL_MS_DEFAUT, RELAIS_MAX_MS_DEFAUT, RELAIS_MS_MIN, RELAIS_MS_MAX,
59
+ };
@@ -6,4 +6,4 @@
6
6
  // Injecté par api/doc.js dans un <script nonce=…> de la visionneuse. Le code navigateur du
7
7
  // player vit en modules TypeScript testés sous player/src/ — c'est ici qu'il atterrit.
8
8
  "use strict";
9
- module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var L=Object.defineProperty,Ke=Object.defineProperties,Ye=Object.getOwnPropertyDescriptor,Ze=Object.getOwnPropertyDescriptors,qe=Object.getOwnPropertyNames,Ee=Object.getOwnPropertySymbols;var Te=Object.prototype.hasOwnProperty,Qe=Object.prototype.propertyIsEnumerable;var _e=(e,n,t)=>n in e?L(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,Q=(e,n)=>{for(var t in n||(n={}))Te.call(n,t)&&_e(e,t,n[t]);if(Ee)for(var t of Ee(n))Qe.call(n,t)&&_e(e,t,n[t]);return e},Se=(e,n)=>Ke(e,Ze(n));var w=(e,n)=>{for(var t in n)L(e,t,{get:n[t],enumerable:!0})},Xe=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of qe(n))!Te.call(e,o)&&o!==t&&L(e,o,{get:()=>n[o],enumerable:!(r=Ye(n,o))||r.enumerable});return e};var Je=e=>Xe(L({},\"__esModule\",{value:!0}),e);var at={};w(at,{bridge:()=>H,cadence:()=>$,chat:()=>F,live:()=>V,presentation:()=>W,presentationState:()=>j,tracking:()=>D,viewer:()=>B});var H={};w(H,{onHostMessage:()=>cn,onPlayerMessage:()=>ln,parseHostMessage:()=>Ae,parsePlayerMessage:()=>Re,sendToHost:()=>an,sendToPlayer:()=>sn,toWire:()=>J});var X=\"3dd-doc-\",en=/^[A-Za-z0-9_-]{1,64}$/,nn=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],tn=[\"embed-denied\"],rn=[\"present-invite\",\"present-handover\",\"present-switch\"],on=[\"handover-done\"];function J(e){let n={type:X+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function ke(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(X))return null;let o=r.slice(X.length);if(n.includes(o))return{type:o};if(tn.includes(o)){let s=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(s||\"\"))?String(s):\"unknown\"}}if(!t.includes(o))return null;let a=e.slug;return typeof a!=\"string\"||!en.test(a)?null:{type:o,slug:a}}function Re(e){return ke(e,nn,rn)}function Ae(e){return ke(e,on,[])}function an(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(J(e),\"*\")}catch(t){}}function sn(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(J(n),\"*\")}catch(r){}}function un(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function ln(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=a=>{if(!un(t,a.source))return;let s=Re(a.data);s&&e(s)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function cn(e,n){var a;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(a=t.parent)!=null?a:null,o=s=>{if(!r||r===t||s.source!==r)return;let u=Ae(s.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var D={};w(D,{createTracker:()=>xn});var $={};w($,{PRESENTER_ACTIONS_PER_HOUR:()=>te,PRESENT_CACHE_MS:()=>fn,PRESENT_QUOTA_MARGIN:()=>Ue,PRESENT_QUOTA_PER_HOUR:()=>gn,PRESENT_READS_PER_HOUR:()=>Oe,PRESENT_READ_BURST:()=>Le,PRESENT_READ_COALESCE_MS:()=>He,PRESENT_RESYNC_MS:()=>Ce,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>pn,PRESENT_WRITE_TIMEOUT_MS:()=>re,READERS_PER_EGRESS:()=>U,RESYNC_READS_PER_HOUR:()=>Ne,SESSION_IDLE_MS:()=>ne,SESSION_INTERVAL_MS:()=>ee,SESSION_QUOTA_PER_HOUR:()=>dn,SESSION_WRITES_PER_HOUR:()=>Ie,VIEW_EVENTS_PER_READER_HOUR:()=>Pe,VIEW_QUOTA_PER_HOUR:()=>mn});var ee=3e4,Ie=Math.ceil(120),U=25,dn=Ie*U,Pe=720,mn=Pe*U,ne=18e4,Ce=25e3,Ne=Math.ceil(36e5/Ce),te=720,Oe=Ne+te,pn=te,Le=20,re=1e4,He=400,fn=He,Ue=4,gn=(Oe+Le)*U*Ue;var bn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function hn(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function yn(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;return o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):(n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0}),!0)}catch(r){return!1}}}function xn(e={}){var Me,ve;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",a=(Me=e.idleMs)!=null?Me:ne,s=(ve=e.sessionEveryMs)!=null?ve:3e4,u=e.slug||\"\",d=e.internal||null,l=e.sessionId||hn(),m=e.send||yn(o,n),p={},c=0,b=0,f=0,E=0,v=null,k=r(),R=!1,G=!1,N=[],Ge=()=>!!u||!!d,fe=i=>{if(!Ge())return!1;try{return m(i)!==!1}catch(g){return!1}},K=()=>!!t&&t.visibilityState===\"visible\"&&!R,ge=i=>{let g=r()-i,y=Math.max(0,k-i)+a;return Math.max(0,Math.min(g,y))},Y=()=>{v!=null&&c>0&&(p[c]=(p[c]||0)+ge(v)/1e3),v=K()?r():null},be=()=>{Y(),v=null},Z=()=>{v==null&&K()&&(v=r())},q=()=>{let i=0;for(let g in p)i+=p[g];return v!=null&&(i+=ge(v)/1e3),Math.round(i)},he=()=>{let i={};for(let g in p)i[g]=Math.round(p[g]);return i},A=()=>{k=r(),R&&(R=!1,Z())},ye=(i,g)=>{d||fe({slug:u,event:i,page:g||b,maxPage:f,seconds:q(),sessionId:l})},xe=\"\",O=()=>{Y();let i=q(),g=`${i}|${f}|${E}`;if(g===xe)return;let y={event:\"session\",sessionId:l,numPages:E,maxPage:f,totalSeconds:i,pagesTime:he()};d?(y.internal=!0,y.docId=d.docId,y.email=d.email,y.name=d.name,d.it&&(y.it=d.it)):y.slug=u,fe(y)&&(xe=g)},I=(i,g,y,we)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(g,y,we),N.push(()=>{i.removeEventListener(g,y,we)}))};return{sessionId:l,totalSeconds:q,maxPage:()=>f,pageTimes:he,start(){if(G)return;G=!0,ye(\"open\",1),c=1,k=r(),v=K()?r():null,I(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(be(),O()):(A(),Z())}),I(n,\"focus\",()=>{A(),Z()});for(let y of bn)I(n,y,A,{passive:!0});I(e.scrollElement,\"scroll\",A,{passive:!0}),I(n,\"beforeunload\",O);let i=n.setInterval(()=>{!R&&r()-k>a&&(R=!0,be())},5e3),g=n.setInterval(O,s);N.push(()=>{n.clearInterval(i),n.clearInterval(g)})},setPage(i){!i||i===b||(k=r(),Y(),c=i,b=i,i>f&&(f=i,ye(\"page\",i)))},setPageCount(i){E=i||0},noteActivity:A,flush:O,stop(){for(;N.length;){let i=N.pop();try{i==null||i()}catch(g){}}G=!1}}}var V={};w(V,{STORAGE_KEYS:()=>_,attendeeKey:()=>En,authorToken:()=>_n,avatarHtml:()=>oe,canModerate:()=>ue,createBudget:()=>Pn,createFileEcritures:()=>Cn,createScheduler:()=>An,escapeHtml:()=>x,fetchBorne:()=>In,flattenPresence:()=>Mn,formatMessageBody:()=>ae,initials:()=>$e,isMentioned:()=>Sn,isMine:()=>T,origineAvatarAutorisee:()=>De,presenceId:()=>Nn,reactorId:()=>se,referenceAuteur:()=>Tn,shouldNotify:()=>kn,unreadLabel:()=>Rn});var _={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function x(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function $e(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function De(e,n){let t=String(e||\"\").trim();if(!t)return!1;if(/^[a-z][a-z0-9+.-]*:/i.test(t)===!1&&!t.startsWith(\"//\"))return!0;let r=typeof location!=\"undefined\"&&location?location.origin:\"\",o;try{o=new URL(t,r||\"https://invalide.exemple\")}catch(a){return!1}return o.protocol!==\"https:\"&&o.protocol!==\"http:\"?!1:r&&o.origin===r?!0:(n||[]).some(a=>{try{return new URL(String(a)).origin===o.origin}catch(s){return!1}})}function oe(e,n,t){return De(e,t)?`<img src=\"${x(e)}\" alt=\"\">`:x($e(n))}function ae(e){let n=x(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function Mn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function vn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function wn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function En(e,n=\"anon\",t=ie){try{if(!e)return`anon-${n}`;let r=e.getItem(_.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(_.attendeeKey,o),o}catch(r){return`anon-${n}`}}function ie(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function _n(e,n=ie){let t=vn(e,_.authorToken);if(t)return t;let r=n();return wn(e,_.authorToken,r),r}async function Tn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let a=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(a),s=>s.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function se(e){return String(e&&e.ref||\"\")}function T(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function ue(e){return!!(e&&e.role===\"presenter\")}function Sn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!T(e,n):!1}function kn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function Rn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function An(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,E)=>setTimeout(f,E)),a=n.clearTimer||(f=>clearTimeout(f)),s=!1,u=!1,d=-1/0,l=null,m=!1;function p(){if(m||s){u=!0;return}let f=t-(r()-d);if(f>0){c(f);return}s=!0,u=!1,d=r();let E=!1;e(()=>{E||(E=!0,s=!1,u&&!m&&p())})}function c(f){m||l!==null||(l=o(()=>{l=null,p()},Math.max(0,f)))}return{signaler(){m||p()},maintenant(){m||(d=-1/0,p())},arreter(){m=!0,l!==null&&(a(l),l=null)}}}function In(e,n,t=re,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let a=r.setTimer||((c,b)=>setTimeout(c,b)),s=r.clearTimer||(c=>clearTimeout(c)),u=null;try{u=new AbortController}catch(c){}let d=u?Se(Q({},n||{}),{signal:u.signal}):Q({},n||{}),l=null,m=new Promise((c,b)=>{l=a(()=>{try{u==null||u.abort()}catch(f){}b(new Error(\"delai\"))},t)}),p=()=>{try{s(l)}catch(c){}};return Promise.race([o(e,d),m]).then(c=>(p(),c),c=>{throw p(),c})}function Pn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let a=n();return r=Math.min(t,r+(a-o)*e.parHeure/36e5),o=a,r<1?!1:(r-=1,!0)},restants:()=>r}}function Cn(e={}){var l;let n=(l=e.minMs)!=null?l:0,t=e.now||(()=>Date.now()),r=e.setTimer||((m,p)=>setTimeout(m,p)),o=new Map,a=!1,s=-1/0,u=!1;function d(){if(a||o.size===0)return;let m=n-(t()-s);if(m>0){if(u)return;u=!0,r(()=>{u=!1,d()},m);return}let p=o.keys().next().value,c=o.get(p);o.delete(p),a=!0,s=t(),Promise.resolve().then(c.tache).then(b=>c.regler(b),()=>c.regler(null)).then(()=>{a=!1,d()})}return{poser(m,p){return new Promise(c=>{let b=o.get(m);o.set(m,{tache:p,regler:f=>{b&&b.regler(f),c(f)}}),d()})},enAttente:()=>o.size}}function Nn(e,n=ie){try{if(e){let t=e.getItem(_.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(_.presenceId,r),r}}catch(t){}return`p-${n()}`}var F={};w(F,{messageClassName:()=>Ln,renderActions:()=>We,renderAttachment:()=>Fe,renderMessage:()=>On,renderReactions:()=>Ve});function Ve(e,n){let t=e&&e.reactions||{},r=se(n),o=\"\";for(let a in t){let s=t[a]||[];if(!s.length)continue;let u=s.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${x(a)}\">${x(a)} ${s.length}</button>`}return o}function Fe(e){if(!e||!e.url)return\"\";let n=x(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=x(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function We(e,n){let t=T(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||ue(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function On(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${x(e.reply_name||\"\")}</b> ${x(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ae(e.body)}${r}</div>`:\"\",a=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${oe(e.author_avatar,e.author_name,n.avatarOrigins)}</span><span class=b><div class=who><b>${x(e.author_name||\"Invit\\xE9\")}</b>${a}</div>`+t+o+Fe(e.attachment)+`<div class=cm-re>${Ve(e,n.me)}</div></span><span class=cm-act>${We(e,n)}</span>`}function Ln(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var W={};w(W,{DEFAULT_CENTER:()=>de,DEFAULT_ZOOM:()=>me,MAP_TYPES:()=>P,cycleMapType:()=>Hn,initialMapContent:()=>$n,mapTypeLabel:()=>Un,sanitizeContent:()=>pe});var P=[\"roadmap\",\"satellite\",\"hybrid\"],de=[46.6,2.5],me=6;function S(e){let n=Number(e);return Number.isFinite(n)?n:null}function le(e){if(!Array.isArray(e))return null;let n=S(e[0]),t=S(e[1]);return n!=null&&t!=null?[n,t]:null}var ce=(e,n,t)=>Math.max(n,Math.min(t,e));function pe(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=le(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:S(r.heading)||0,pitch:ce(S(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ce(S(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:le(n.center)||[...de],zoom:ce(Math.trunc(S(n.zoom)||me),1,21),marker:le(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Hn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function Un(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function $n(){return{kind:\"map\",center:[...de],zoom:me,marker:null,mapType:null,label:null}}var B={};w(B,{CURRENT_PAGE_MARGIN:()=>Bn,DEFAULT_ASPECT:()=>Fn,MAX_ZOOM:()=>Vn,MIN_PAGE_WIDTH:()=>Wn,MIN_ZOOM:()=>Dn,PRERENDER_MARGIN:()=>zn,ROTATIONS:()=>Xn,ancrageApresZoom:()=>Jn,arrowState:()=>Kn,aspectApresRotation:()=>je,averageColor:()=>qn,clampPage:()=>Be,clampZoom:()=>jn,fenetreVirtuelle:()=>nt,fitWidth:()=>Zn,isImageDocument:()=>Qn,pasVertical:()=>z,plafondFenetre:()=>tt,positionDe:()=>et,progressPercent:()=>Gn,rotationEffective:()=>C});var Dn=.5,Vn=3,Fn=1.35,Wn=280,zn=\"500px 0px\",Bn=\"-48% 0px -48% 0px\";function jn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Be(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Gn(e,n){return n?Math.round(Be(e,n)/n*100):0}function Kn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Yn=28;function Zn(e){var s;let n=(s=e.zoom)!=null?s:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=je(e.aspect,e.rotation),a=((e.containerHeight||600)-Yn-(e.overlap||0)-(e.reserve||0))/r;return a>120?Math.min(t,a):t}function qn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let a=0;a<e.length;a+=4)t+=e[a],r+=e[a+1],o+=e[a+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Qn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var Xn=[0,90,180,270],h=(e,n=0)=>typeof e==\"number\"&&Number.isFinite(e)?e:n;function C(e,n){return(Math.round((h(e)+h(n))/90)%4+4)%4*90}function je(e,n){let t=h(e,0)>0?e:1.35,r=C(0,n);return r===90||r===270?1/t:t}function ze(e,n,t,r,o,a,s){let u=Math.min(Math.max(0,o),Math.max(0,r)),d=u+Math.max(0,r-u)*s,l=Math.min(Math.max(0,a),Math.max(0,r)),m=r-l>0?(d-l)/(r-l):1,p=Math.max(0,(t-r)/2),c=n+e-p,b=l+Math.max(0,c-l)*m-n;return Math.min(Math.max(0,b),Math.max(0,d-t))}function Jn(e){let n=h(e.zoomAvant,0),t=h(e.zoomApres,0),r={x:h(e.defilement&&e.defilement.x),y:h(e.defilement&&e.defilement.y)};if(n<=0||t<=0)return r;let o=t/n,a=e.fixe||{largeur:0,hauteur:0},s=e.fixeDebut||{largeur:0,hauteur:0};return{x:ze(r.x,h(e.point&&e.point.x),h(e.cadre&&e.cadre.largeur),h(e.contenu&&e.contenu.largeur),h(a.largeur),h(s.largeur),o),y:ze(r.y,h(e.point&&e.point.y),h(e.cadre&&e.cadre.hauteur),h(e.contenu&&e.contenu.hauteur),h(a.hauteur),h(s.hauteur),o)}}var M=(e,n=0)=>{let t=Number(e);return Number.isFinite(t)?t:n};function z(e,n){return Math.max(1,M(e,1))+Math.max(0,M(n))}function et(e,n){return Math.max(0,M(n.decalageHaut))+(Math.max(1,Math.trunc(M(e,1)))-1)*z(n.hauteurElement,n.ecart)}function nt(e){let n=Math.max(0,Math.trunc(M(e.total)));if(n===0)return{debut:0,fin:0,avant:0,apres:0};let t=z(e.hauteurElement,e.ecart),r=Math.max(0,Math.trunc(M(e.marge))),o=Math.max(0,M(e.decalageHaut)),a=Math.max(0,M(e.debutVisible))-o,s=a+Math.max(0,M(e.hauteurVisible)),u=Math.floor(a/t)+1,d=Math.floor(s/t)+1,l=Math.min(n,Math.max(1,u-r)),m=Math.max(l,Math.min(n,d+r));return{debut:l,fin:m,avant:(l-1)*t,apres:(n-m)*t}}function tt(e){let n=z(e.hauteurElement,e.ecart);return Math.ceil(Math.max(0,M(e.hauteurVisible))/n)+2+2*Math.max(0,Math.trunc(M(e.marge)))}var j={};w(j,{presentationTransition:()=>rt,switchDocUrl:()=>ot});function rt(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=pe(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}],o=C(0,e.view_rotation);return o!==C(0,n.rotation)&&r.push({kind:\"rotate\",rotation:o}),e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function ot(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Je(at);})();", SOURCE_HASH: "ac174cbf0b8d7e02" };
9
+ module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var H=Object.defineProperty,Ye=Object.defineProperties,Ze=Object.getOwnPropertyDescriptor,qe=Object.getOwnPropertyDescriptors,Qe=Object.getOwnPropertyNames,_e=Object.getOwnPropertySymbols;var Se=Object.prototype.hasOwnProperty,Xe=Object.prototype.propertyIsEnumerable;var Te=(e,n,t)=>n in e?H(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,Q=(e,n)=>{for(var t in n||(n={}))Se.call(n,t)&&Te(e,t,n[t]);if(_e)for(var t of _e(n))Xe.call(n,t)&&Te(e,t,n[t]);return e},ke=(e,n)=>Ye(e,qe(n));var E=(e,n)=>{for(var t in n)H(e,t,{get:n[t],enumerable:!0})},Je=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Qe(n))!Se.call(e,o)&&o!==t&&H(e,o,{get:()=>n[o],enumerable:!(r=Ze(n,o))||r.enumerable});return e};var en=e=>Je(H({},\"__esModule\",{value:!0}),e);var it={};E(it,{bridge:()=>U,cadence:()=>D,chat:()=>W,live:()=>V,presentation:()=>z,presentationState:()=>j,tracking:()=>F,viewer:()=>B});var U={};E(U,{onHostMessage:()=>dn,onPlayerMessage:()=>cn,parseHostMessage:()=>Ie,parsePlayerMessage:()=>Ae,sendToHost:()=>sn,sendToPlayer:()=>un,toWire:()=>J});var X=\"3dd-doc-\",nn=/^[A-Za-z0-9_-]{1,64}$/,tn=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],rn=[\"embed-denied\"],on=[\"present-invite\",\"present-handover\",\"present-switch\"],an=[\"handover-done\"];function J(e){let n={type:X+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function Re(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(X))return null;let o=r.slice(X.length);if(n.includes(o))return{type:o};if(rn.includes(o)){let s=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(s||\"\"))?String(s):\"unknown\"}}if(!t.includes(o))return null;let a=e.slug;return typeof a!=\"string\"||!nn.test(a)?null:{type:o,slug:a}}function Ae(e){return Re(e,tn,on)}function Ie(e){return Re(e,an,[])}function sn(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(J(e),\"*\")}catch(t){}}function un(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(J(n),\"*\")}catch(r){}}function ln(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function cn(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=a=>{if(!ln(t,a.source))return;let s=Ae(a.data);s&&e(s)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function dn(e,n){var a;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(a=t.parent)!=null?a:null,o=s=>{if(!r||r===t||s.source!==r)return;let u=Ie(s.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var F={};E(F,{createTracker:()=>Mn});var D={};E(D,{PRESENTER_ACTIONS_PER_HOUR:()=>te,PRESENT_CACHE_MS:()=>gn,PRESENT_QUOTA_MARGIN:()=>$e,PRESENT_QUOTA_PER_HOUR:()=>bn,PRESENT_READS_PER_HOUR:()=>Le,PRESENT_READ_BURST:()=>He,PRESENT_READ_COALESCE_MS:()=>Ue,PRESENT_RESYNC_MS:()=>Ce,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>fn,PRESENT_WRITE_TIMEOUT_MS:()=>re,READERS_PER_EGRESS:()=>$,RESYNC_READS_PER_HOUR:()=>Oe,SESSION_IDLE_MS:()=>ne,SESSION_INTERVAL_MS:()=>ee,SESSION_QUOTA_PER_HOUR:()=>mn,SESSION_WRITES_PER_HOUR:()=>Pe,VIEW_EVENTS_PER_READER_HOUR:()=>Ne,VIEW_QUOTA_PER_HOUR:()=>pn});var ee=3e4,Pe=Math.ceil(120),$=25,mn=Pe*$,Ne=720,pn=Ne*$,ne=18e4,Ce=25e3,Oe=Math.ceil(36e5/Ce),te=720,Le=Oe+te,fn=te,He=20,re=1e4,Ue=400,gn=Ue,$e=4,bn=(Le+He)*$*$e;var hn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function yn(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function xn(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;return o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):(n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0}),!0)}catch(r){return!1}}}function Mn(e={}){var ve,Ee;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",a=(ve=e.idleMs)!=null?ve:ne,s=(Ee=e.sessionEveryMs)!=null?Ee:3e4,u=e.slug||\"\",d=e.internal||null,l=e.sessionId||yn(),m=e.send||xn(o,n),p={},c=0,b=0,f=0,w=0,v=null,k=r(),R=!1,G=!1,O=[],Ke=()=>!!u||!!d,ge=i=>{if(!Ke())return!1;try{return m(i)!==!1}catch(g){return!1}},K=()=>!!t&&t.visibilityState===\"visible\"&&!R,be=i=>{let g=r()-i,x=Math.max(0,k-i)+a;return Math.max(0,Math.min(g,x))},Y=()=>{v!=null&&c>0&&(p[c]=(p[c]||0)+be(v)/1e3),v=K()?r():null},he=()=>{Y(),v=null},Z=()=>{v==null&&K()&&(v=r())},q=()=>{let i=0;for(let g in p)i+=p[g];return v!=null&&(i+=be(v)/1e3),Math.round(i)},ye=()=>{let i={};for(let g in p)i[g]=Math.round(p[g]);return i},A=()=>{k=r(),R&&(R=!1,Z())},xe=(i,g)=>{d||ge({slug:u,event:i,page:g||b,maxPage:f,seconds:q(),sessionId:l})},Me=\"\",L=()=>{Y();let i=q(),g=`${i}|${f}|${w}`;if(g===Me)return;let x={event:\"session\",sessionId:l,numPages:w,maxPage:f,totalSeconds:i,pagesTime:ye()};d?(x.internal=!0,x.docId=d.docId,x.email=d.email,x.name=d.name,d.it&&(x.it=d.it)):x.slug=u,ge(x)&&(Me=g)},I=(i,g,x,we)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(g,x,we),O.push(()=>{i.removeEventListener(g,x,we)}))};return{sessionId:l,totalSeconds:q,maxPage:()=>f,pageTimes:ye,start(){if(G)return;G=!0,xe(\"open\",1),c=1,k=r(),v=K()?r():null,I(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(he(),L()):(A(),Z())}),I(n,\"focus\",()=>{A(),Z()});for(let x of hn)I(n,x,A,{passive:!0});I(e.scrollElement,\"scroll\",A,{passive:!0}),I(n,\"beforeunload\",L);let i=n.setInterval(()=>{!R&&r()-k>a&&(R=!0,he())},5e3),g=n.setInterval(L,s);O.push(()=>{n.clearInterval(i),n.clearInterval(g)})},setPage(i){!i||i===b||(k=r(),Y(),c=i,b=i,i>f&&(f=i,xe(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:A,flush:L,stop(){for(;O.length;){let i=O.pop();try{i==null||i()}catch(g){}}G=!1}}}var V={};E(V,{STORAGE_KEYS:()=>_,attendeeKey:()=>_n,authorToken:()=>Tn,avatarHtml:()=>oe,canModerate:()=>ue,createBudget:()=>Nn,createFileEcritures:()=>Cn,createScheduler:()=>In,escapeHtml:()=>M,fetchBorne:()=>Pn,flattenPresence:()=>vn,formatMessageBody:()=>ae,initials:()=>De,isMentioned:()=>kn,isMine:()=>T,origineAvatarAutorisee:()=>Fe,presenceId:()=>On,reactorId:()=>ie,referenceAuteur:()=>Sn,shouldNotify:()=>Rn,unreadLabel:()=>An});var _={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function M(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function De(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function Fe(e,n){let t=String(e||\"\").trim();if(!t)return!1;if(/^[a-z][a-z0-9+.-]*:/i.test(t)===!1&&!t.startsWith(\"//\"))return!0;let r=typeof location!=\"undefined\"&&location?location.origin:\"\",o;try{o=new URL(t,r||\"https://invalide.exemple\")}catch(a){return!1}return o.protocol!==\"https:\"&&o.protocol!==\"http:\"?!1:r&&o.origin===r?!0:(n||[]).some(a=>{try{return new URL(String(a)).origin===o.origin}catch(s){return!1}})}function oe(e,n,t){return Fe(e,t)?`<img src=\"${M(e)}\" alt=\"\">`:M(De(n))}function ae(e){let n=M(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function vn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function En(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function wn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function _n(e,n=\"anon\",t=se){try{if(!e)return`anon-${n}`;let r=e.getItem(_.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(_.attendeeKey,o),o}catch(r){return`anon-${n}`}}function se(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function Tn(e,n=se){let t=En(e,_.authorToken);if(t)return t;let r=n();return wn(e,_.authorToken,r),r}async function Sn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let a=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(a),s=>s.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function ie(e){return String(e&&e.ref||\"\")}function T(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function ue(e){return!!(e&&e.role===\"presenter\")}function kn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!T(e,n):!1}function Rn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||T(n,t)?!1:o}function An(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function In(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),a=n.clearTimer||(f=>clearTimeout(f)),s=!1,u=!1,d=-1/0,l=null,m=!1;function p(){if(m||s){u=!0;return}let f=t-(r()-d);if(f>0){c(f);return}s=!0,u=!1,d=r();let w=!1;e(()=>{w||(w=!0,s=!1,u&&!m&&p())})}function c(f){m||l!==null||(l=o(()=>{l=null,p()},Math.max(0,f)))}return{signaler(){m||p()},maintenant(){m||(d=-1/0,p())},arreter(){m=!0,l!==null&&(a(l),l=null)}}}function Pn(e,n,t=re,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let a=r.setTimer||((c,b)=>setTimeout(c,b)),s=r.clearTimer||(c=>clearTimeout(c)),u=null;try{u=new AbortController}catch(c){}let d=u?ke(Q({},n||{}),{signal:u.signal}):Q({},n||{}),l=null,m=new Promise((c,b)=>{l=a(()=>{try{u==null||u.abort()}catch(f){}b(new Error(\"delai\"))},t)}),p=()=>{try{s(l)}catch(c){}};return Promise.race([o(e,d),m]).then(c=>(p(),c),c=>{throw p(),c})}function Nn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let a=n();return r=Math.min(t,r+(a-o)*e.parHeure/36e5),o=a,r<1?!1:(r-=1,!0)},restants:()=>r}}function Cn(e={}){var l;let n=(l=e.minMs)!=null?l:0,t=e.now||(()=>Date.now()),r=e.setTimer||((m,p)=>setTimeout(m,p)),o=new Map,a=!1,s=-1/0,u=!1;function d(){if(a||o.size===0)return;let m=n-(t()-s);if(m>0){if(u)return;u=!0,r(()=>{u=!1,d()},m);return}let p=o.keys().next().value,c=o.get(p);o.delete(p),a=!0,s=t(),Promise.resolve().then(c.tache).then(b=>c.regler(b),()=>c.regler(null)).then(()=>{a=!1,d()})}return{poser(m,p){return new Promise(c=>{let b=o.get(m);o.set(m,{tache:p,regler:f=>{b&&b.regler(f),c(f)}}),d()})},enAttente:()=>o.size}}function On(e,n=se){try{if(e){let t=e.getItem(_.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(_.presenceId,r),r}}catch(t){}return`p-${n()}`}var W={};E(W,{messageClassName:()=>Hn,renderActions:()=>ze,renderAttachment:()=>We,renderMessage:()=>Ln,renderReactions:()=>Ve});function Ve(e,n){let t=e&&e.reactions||{},r=ie(n),o=\"\";for(let a in t){let s=t[a]||[];if(!s.length)continue;let u=s.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${M(a)}\">${M(a)} ${s.length}</button>`}return o}function We(e){if(!e||!e.url)return\"\";let n=M(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=M(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function ze(e,n){let t=T(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||ue(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function Ln(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${M(e.reply_name||\"\")}</b> ${M(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ae(e.body)}${r}</div>`:\"\",a=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${oe(e.author_avatar,e.author_name,n.avatarOrigins)}</span><span class=b><div class=who><b>${M(e.author_name||\"Invit\\xE9\")}</b>${a}</div>`+t+o+We(e.attachment)+`<div class=cm-re>${Ve(e,n.me)}</div></span><span class=cm-act>${ze(e,n)}</span>`}function Hn(e,n,t){return\"cm\"+(T(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var z={};E(z,{DEFAULT_CENTER:()=>de,DEFAULT_ZOOM:()=>me,MAP_TYPES:()=>P,cycleMapType:()=>Un,initialMapContent:()=>Dn,mapTypeLabel:()=>$n,sanitizeContent:()=>pe});var P=[\"roadmap\",\"satellite\",\"hybrid\"],de=[46.6,2.5],me=6;function S(e){let n=Number(e);return Number.isFinite(n)?n:null}function le(e){if(!Array.isArray(e))return null;let n=S(e[0]),t=S(e[1]);return n!=null&&t!=null?[n,t]:null}var ce=(e,n,t)=>Math.max(n,Math.min(t,e));function pe(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=le(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:S(r.heading)||0,pitch:ce(S(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ce(S(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:le(n.center)||[...de],zoom:ce(Math.trunc(S(n.zoom)||me),1,21),marker:le(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Un(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function $n(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Dn(){return{kind:\"map\",center:[...de],zoom:me,marker:null,mapType:null,label:null}}var B={};E(B,{CURRENT_PAGE_MARGIN:()=>jn,DEFAULT_ASPECT:()=>Wn,MAX_ZOOM:()=>Vn,MIN_PAGE_WIDTH:()=>zn,MIN_ZOOM:()=>Fn,PLAFOND_DEFILEMENT_PX:()=>fe,PRERENDER_MARGIN:()=>Bn,ROTATIONS:()=>Jn,ancrageApresZoom:()=>et,arrowState:()=>Yn,aspectApresRotation:()=>Ge,averageColor:()=>Qn,clampPage:()=>je,clampZoom:()=>Gn,fenetreVirtuelle:()=>tt,fitWidth:()=>qn,isImageDocument:()=>Xn,pagesAtteignables:()=>ot,pasVertical:()=>C,plafondFenetre:()=>rt,positionDe:()=>nt,progressPercent:()=>Kn,rotationEffective:()=>N});var Fn=.5,Vn=3,Wn=1.35,zn=280,Bn=\"500px 0px\",jn=\"-48% 0px -48% 0px\";function Gn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function je(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Kn(e,n){return n?Math.round(je(e,n)/n*100):0}function Yn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Zn=28;function qn(e){var s;let n=(s=e.zoom)!=null?s:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=Ge(e.aspect,e.rotation),a=((e.containerHeight||600)-Zn-(e.overlap||0)-(e.reserve||0))/r;return a>120?Math.min(t,a):t}function Qn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let a=0;a<e.length;a+=4)t+=e[a],r+=e[a+1],o+=e[a+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Xn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var Jn=[0,90,180,270],h=(e,n=0)=>typeof e==\"number\"&&Number.isFinite(e)?e:n;function N(e,n){return(Math.round((h(e)+h(n))/90)%4+4)%4*90}function Ge(e,n){let t=h(e,0)>0?e:1.35,r=N(0,n);return r===90||r===270?1/t:t}function Be(e,n,t,r,o,a,s){let u=Math.min(Math.max(0,o),Math.max(0,r)),d=u+Math.max(0,r-u)*s,l=Math.min(Math.max(0,a),Math.max(0,r)),m=r-l>0?(d-l)/(r-l):1,p=Math.max(0,(t-r)/2),c=n+e-p,b=l+Math.max(0,c-l)*m-n;return Math.min(Math.max(0,b),Math.max(0,d-t))}function et(e){let n=h(e.zoomAvant,0),t=h(e.zoomApres,0),r={x:h(e.defilement&&e.defilement.x),y:h(e.defilement&&e.defilement.y)};if(n<=0||t<=0)return r;let o=t/n,a=e.fixe||{largeur:0,hauteur:0},s=e.fixeDebut||{largeur:0,hauteur:0};return{x:Be(r.x,h(e.point&&e.point.x),h(e.cadre&&e.cadre.largeur),h(e.contenu&&e.contenu.largeur),h(a.largeur),h(s.largeur),o),y:Be(r.y,h(e.point&&e.point.y),h(e.cadre&&e.cadre.hauteur),h(e.contenu&&e.contenu.hauteur),h(a.hauteur),h(s.hauteur),o)}}var y=(e,n=0)=>{let t=Number(e);return Number.isFinite(t)?t:n};function C(e,n){return Math.max(1,y(e,1))+Math.max(0,y(n))}function nt(e,n){return Math.max(0,y(n.decalageHaut))+(Math.max(1,Math.trunc(y(e,1)))-1)*C(n.hauteurElement,n.ecart)}function tt(e){let n=Math.max(0,Math.trunc(y(e.total)));if(n===0)return{debut:0,fin:0,avant:0,apres:0};let t=C(e.hauteurElement,e.ecart),r=Math.max(0,Math.trunc(y(e.marge))),o=Math.max(0,y(e.decalageHaut)),a=Math.max(0,y(e.debutVisible))-o,s=a+Math.max(0,y(e.hauteurVisible)),u=Math.floor(a/t)+1,d=Math.floor(s/t)+1,l=Math.min(n,Math.max(1,u-r)),m=Math.max(l,Math.min(n,d+r));return{debut:l,fin:m,avant:(l-1)*t,apres:(n-m)*t}}function rt(e){let n=C(e.hauteurElement,e.ecart);return Math.ceil(Math.max(0,y(e.hauteurVisible))/n)+2+2*Math.max(0,Math.trunc(y(e.marge)))}var fe=33554428;function ot(e,n=fe){let t=Math.max(0,Math.trunc(y(e.total)));if(t===0)return 0;let r=C(e.hauteurElement,e.ecart),o=Math.max(1,y(e.hauteurElement,1)),a=Math.max(0,y(e.decalageHaut)),s=Math.max(0,y(n,fe))-a-o;return s<0?1:Math.max(1,Math.min(t,Math.floor(s/r)+1))}var j={};E(j,{presentationTransition:()=>at,switchDocUrl:()=>st});function at(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=pe(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}],o=N(0,e.view_rotation);return o!==N(0,n.rotation)&&r.push({kind:\"rotate\",rotation:o}),e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function st(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return en(it);})();", SOURCE_HASH: "60dc161c7dc11943" };
@@ -102,6 +102,7 @@ const BOT_CSS = `
102
102
  body.onepage #pages{height:100%;display:flex;align-items:center;justify-content:center;padding:0}
103
103
 
104
104
  body.onepage #pages .page{display:none;margin:0}
105
+ body.onepage #pages .pspace{display:none} /* un espaceur de pages absentes pousserait la page courante hors de l'écran */
105
106
  body.onepage #pages .page.cur{display:block;box-shadow:0 6px 34px rgba(0,0,0,.16)}
106
107
  /* Transition de page sobre en mode guidé/lecture (glissé directionnel ~0,28 s). Exclut le player mobile
107
108
  (rythme rapide). Activable/désactivable via .botanim (CFG.botAnim, défaut ON — param profil futur). */
@@ -595,11 +595,13 @@ var Live=(function(){
595
595
  // C'est une meilleure propriété que d'essayer de l'empêcher — elle vaut aussi le jour où le
596
596
  // transport lui-même a un défaut.
597
597
  //
598
- // ⚠️ 'map' et 'typing' restent appliqués tels quels, et c'est un choix : ce sont des signaux
599
- // ÉPHÉMÈRES (mouvements de carte, « untel écrit »), sans état serveur à confronter et à
600
- // fréquence élevée. Les revérifier coûterait un aller-retour par déplacement de souris pour
601
- // protéger… un déplacement de souris. Ce qui fait autorité — la page affichée, le document,
602
- // la fin de la présentation — passe par 'state', qui est relu.
598
+ // ⚠️ INVARIANT : AUCUNE CHARGE DE DIFFUSION N'EST APPLIQUÉE TELLE QUELLE. 'map' déclenche une
599
+ // relecture de l'état serveur SANS lire sa charge (voir plus bas, « aucune charge ne passe »).
600
+ // Seul 'typing' reste volontairement éphémère et non confronté : « untel écrit » n'a pas d'état
601
+ // serveur et ne porte aucun privilège — au pire un faux « untel écrit ». Ce qui fait autorité —
602
+ // la page affichée, le document, la fin de la présentation — passe par 'state', qui est relu.
603
+ // (Ce paragraphe affirmait que 'map' restait appliqué tel quel alors que le code l'ignorait
604
+ // déjà : réécrit le 13/09 sur relevé d'un audit externe.)
603
605
  function relire(url,applique){
604
606
  fetch('/api/doc?present='+encodeURIComponent(SLUG)+url)
605
607
  .then(function(r){return r.json();})
@@ -669,16 +671,23 @@ var Live=(function(){
669
671
  // rattrape ce cas : elle ne coûte presque rien et évite qu'une audience reste figée sur un
670
672
  // état périmé en croyant être à jour. C'est l'inverse d'une optimisation : c'est le prix de
671
673
  // la borne.
672
- _filet=setInterval(function(){_ordEtat.maintenant();_ordChat.maintenant();},25000);
674
+ // ⚠️ ET LE FILET NE PART PAS EN CHŒUR. Mille spectateurs qui rejoignent ensemble relisaient
675
+ // ensemble : une rafale de 2 000 GET toutes les 25 s, en phase, pour une audience qui ne fait
676
+ // rien. Un décalage initial tiré entre 0 et 25 s étale la rafale sans augmenter la fréquence
677
+ // maximale — chacun garde exactement une relecture de chaque point par 25 s. Le même
678
+ // identifiant porte d'abord le délai puis l'intervalle : clearInterval les retire tous deux.
679
+ function filetTick(){_ordEtat.maintenant();_ordChat.maintenant();}
680
+ _filet=setTimeout(function(){filetTick();_filet=setInterval(filetTick,25000);},Math.floor(Math.random()*25000));
673
681
 
674
- // ⚠️ LE TITRE VIENT D'ICI, PAS DE LA PRÉSENCE. La liste des participants tirait
675
- // « présentateur » de la charge de présence, que chacun compose lui-même : un
676
- // 'track({role:"presenter"})' suffisait à apparaître comme le présentateur devant toute
677
- // l'audience, avec le nom et l'avatar de son choix. Le canal ne peut pas arbitrer ça — un
678
- // participant légitime a le droit d'y écrire SA présence.
679
- //
680
- // Le serveur renvoie la CLÉ de celui qui a prouvé le control_token ; l'audience compare. Pas
681
- // de clé, pas de titre : mieux vaut aucun titre qu'un titre usurpé.
682
+ // ⚠️ INVARIANT : LE TITRE « PRÉSENTÉ PAR » VIENT EXCLUSIVEMENT DE state.presenter_name, relu
683
+ // auprès du serveur — jamais de la présence Realtime, que chacun compose lui-même. Aucune
684
+ // métadonnée de présence (role, name, avatar) ne porte un privilège : la liste des participants
685
+ // n'affiche aucun badge de rôle, et modération comme paternité sont arbitrées par le serveur
686
+ // sur ses propres jetons. Une présence hostile peut au plus falsifier SA ligne cosmétique.
687
+ // Raison : le canal ne peut pas arbitrer — un participant légitime a le droit d'y écrire SA
688
+ // présence, donc rien de ce qui y transite ne peut fonder un droit.
689
+ // (Ce commentaire affirmait que « le serveur renvoie la clé et l'audience compare » : cette clé
690
+ // n'existe plus, le serveur ne rend qu'un nom. Réécrit le 13/09 sur relevé d'un audit externe.)
682
691
  function etatDuServeur(st){
683
692
  if(typeof st.presenter_name!=='undefined'){var n2=st.presenter_name||'';
684
693
  if(n2!==PRESNAME){PRESNAME=n2;try{if(ch)renderPres(ch.presenceState());}catch(e){}}}
package/server/handler.js CHANGED
@@ -40,6 +40,22 @@ function init(ctx) {
40
40
  // `init`, et un hôte a le même droit. On n'ajoute qu'une chose, on n'en fige aucune.
41
41
  if (ctx && ctx.db) { const vu = Object.create(ctx); vu.db = mesures.observerBase(ctx.db); ctx = vu; }
42
42
  PLAYER = ctx;
43
+ // ⚠️ LA CONFIGURATION SE RELIT, L'ÉTAT VIVANT NE SE REMET PAS À ZÉRO. Cette ligne posait
44
+ // `relaisEnCours = 0` : un hôte qui rappelle `init` pendant qu'un relais est ouvert désarmait le
45
+ // plafond — la demande suivante partait vers l'amont avec l'unique place encore prise, et le
46
+ // `finally` de l'ancien relais rendait ensuite le compteur négatif (reproduit par un audit externe,
47
+ // cinquième passe, 13/09). Le compteur appartient au processus, pas au contexte : il ne se relit pas.
48
+ // Les bornes de temps, elles, sont capturées à l'ADMISSION de chaque relais (`relayerSousAdmission`) :
49
+ // un relais admis sous 30 s reste sous 30 s, quoi qu'un `init` ultérieur décide.
50
+ const bornes = bornesRelais(ctx && ctx.config);
51
+ plafondRelais = bornes.plafond;
52
+ relaisStallMs = bornes.stallMs;
53
+ relaisMaxMs = bornes.maxMs;
54
+ if (bornes.invalides.length) {
55
+ // Une fois, ici — pas à chaque relais : un réglage hors plage est une erreur de déploiement, dite
56
+ // à 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
+ }
43
59
  // Le domaine reçoit le même contexte : une seule construction pour tout le player.
44
60
  require("./shares").init(ctx);
45
61
  require("./retention").init(ctx);
@@ -55,6 +71,55 @@ function init(ctx) {
55
71
  }
56
72
  const isAllowedStorageUrl = (url) => PLAYER.storage.isAllowedUrl(url);
57
73
 
74
+ // ⚠️ LE FLUX BORNAIT LES OCTETS, RIEN NE BORNAIT LE NOMBRE DE FLUX. `fetchFile` partait sans
75
+ // admission : 200 demandes lentes simultanées → 200 connexions amont, 200 pipelines, 200 réponses
76
+ // ouvertes, dans un seul processus (reproduit par un audit externe le 13/09). Le streaming empêche
77
+ // d'allouer 60 Mo par requête ; il n'empêche pas d'ouvrir mille sockets. Une admission par processus,
78
+ // AVANT l'appel amont : refus immédiat 503 + Retry-After quand c'est plein — aucune file d'attente,
79
+ // une file non bornée est le même défaut avec un délai — et la place rendue dans un `finally`, donc
80
+ // aussi sur erreur amont et sur déconnexion cliente (le pipeline rejette alors). Le plafond vient du
81
+ // contexte (`config.maxConcurrentRelays`, défaut 64) : assez pour les requêtes Range parallèles de
82
+ // pdf.js, borné pour un processus. Sur serverless la plate-forme borne déjà la concurrence globale ;
83
+ // ceci protège le mode autonome et chaque instance chaude.
84
+ const { bornesRelais, RELAIS_SIMULTANES_DEFAUT, RELAIS_STALL_MS_DEFAUT, RELAIS_MAX_MS_DEFAUT } = require("./bornes");
85
+ let plafondRelais = RELAIS_SIMULTANES_DEFAUT, relaisEnCours = 0;
86
+ // ⚠️ COMPTÉS, PARCE QU'UN HÔTE A CRU LES LIRE AILLEURS. On demandait aux hôtes de chercher « relais
87
+ // refusés » dans leurs journaux ; l'un d'eux a répondu par `lectureSaturee.total = 0` de la carte —
88
+ // qui ne compte que le cache de lecture, pas les relais (13/09). Une question qu'un hôte peut
89
+ // trancher par la carte ne doit pas être posée comme une fouille de journaux : la carte est
90
+ // structurée, datée, et répond même pour l'hôte qui ne lit jamais ses journaux. État du processus,
91
+ // comme `relaisEnCours` : jamais remis à zéro par `init`.
92
+ let relaisRefusesTotal = 0, dernierRefusRelais = null;
93
+ // ⚠️ UNE PLACE N'EST BORNÉE QUE SI LE RELAIS QUI L'OCCUPE FINIT. Un client qui cesse de lire — ou un
94
+ // amont qui cesse d'envoyer — laissait le pipeline en attente pour toujours : `finally` jamais atteint,
95
+ // place jamais rendue, et avec un plafond de 1, plus aucun fichier ne partait (reproduit par un audit
96
+ // externe le 13/09). `requestTimeout` ne borne que la RÉCEPTION de la requête, pas l'émission de la
97
+ // réponse — le commentaire de `bin/serve.js` affirmait le contraire. Deux bornes, configurables par
98
+ // le contexte : sans progression pendant `relayStallMs` (30 s), ou au-delà de `relayMaxMs` (15 min),
99
+ // le pipeline est ABANDONNÉ par signal — source amont détruite, réponse détruite, rejet, `finally`.
100
+ // ⚠️ Les valeurs viennent de `server/bornes.js` : entiers dans une plage écrite, jamais « tout nombre
101
+ // fini » — `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms (audit, cinquième passe).
102
+ let relaisStallMs = RELAIS_STALL_MS_DEFAUT, relaisMaxMs = RELAIS_MAX_MS_DEFAUT;
103
+ async function relayerSousAdmission(res, travail) {
104
+ if (relaisEnCours >= plafondRelais) {
105
+ relaisRefusesTotal += 1; dernierRefusRelais = Date.now();
106
+ // Une fois par heure, l'exploitant l'apprend : un 503 muet ressemble à une panne d'amont.
107
+ try {
108
+ 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
+ }
111
+ } catch { /* jamais bloquant */ }
112
+ res.setHeader("Retry-After", "2");
113
+ refuserEnTexte(res, 503, "Trop de transferts en cours, réessayez dans un instant");
114
+ return;
115
+ }
116
+ // Les bornes de CE relais sont figées ici : un `init` pendant le transfert relit la configuration
117
+ // pour les suivants, jamais pour celui-ci.
118
+ const bornes = { stallMs: relaisStallMs, maxMs: relaisMaxMs };
119
+ relaisEnCours += 1;
120
+ try { await travail(bornes); } finally { relaisEnCours -= 1; }
121
+ }
122
+
58
123
  // GREFFONS de ce studio — jamais du player. `null` quand le module est absent ou coupé
59
124
  // (`PLAYER_PLUGINS_OFF`) : chaque usage doit donc être gardé, et le player continue sans eux.
60
125
  let docbot = null;
@@ -219,7 +284,7 @@ const { refuserEnTexte, repondreJson, repondreJsonTexte } = require("./reponses.
219
284
  // exemplaires d'un fait divergent, c'est la formule du dépôt.
220
285
  const POLITIQUE_PERMISSIONS = "camera=(), microphone=(), geolocation=(), payment=()";
221
286
 
222
- async function relayerFichier(res, r, disposition) {
287
+ async function relayerFichier(res, r, disposition, bornes = { stallMs: relaisStallMs, maxMs: relaisMaxMs }) {
223
288
  if (!r) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
224
289
  // 413 et 416 sont des REFUS ARGUMENTÉS de l'amont local (plafond, borne absurde) : les fondre
225
290
  // dans un 502 dirait « panne » là où l'amont a dit « demande irrecevable ».
@@ -299,21 +364,33 @@ async function relayerFichier(res, r, disposition) {
299
364
  if (!compresse && brute) res.setHeader("Content-Length", brute);
300
365
 
301
366
  const plafond = PLAFOND_RELAIS;
367
+ // Deux minuteries et un signal : « sans progression » se réarme à chaque morceau qui PASSE (si le
368
+ // client ne lit plus, la contre-pression arrête les morceaux et la minuterie tombe) ; « budget total »
369
+ // ne se réarme jamais. L'abandon passe par le signal du pipeline, qui détruit la source ET la réponse.
370
+ const abandon = new globalThis.AbortController();
371
+ let stall = null;
372
+ const rearmer = () => { clearTimeout(stall); stall = setTimeout(() => abandon.abort(new Error(`relais abandonné : aucune progression depuis ${bornes.stallMs} ms`)), bornes.stallMs); };
373
+ const budget = setTimeout(() => abandon.abort(new Error(`relais abandonné : plus de ${bornes.maxMs} ms au total`)), bornes.maxMs);
374
+ rearmer();
302
375
  try {
303
376
  await pipeline(Readable.fromWeb(r.body), async function* (source) {
304
377
  let vus = 0;
305
378
  for await (const morceau of source) {
306
379
  vus += morceau.length;
307
380
  if (vus > plafond) throw new Error(`relais interrompu : ${vus} octets reçus, plafond ${plafond}`);
381
+ rearmer();
308
382
  yield morceau;
309
383
  }
310
- }, res);
384
+ }, res, { signal: abandon.signal });
311
385
  } catch (erreur) {
312
386
  // ⚠️ ROMPRE, PAS RÉPONDRE — et le DIRE. Aucun code de retour n'est plus disponible ; ne
313
387
  // reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
314
388
  // défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
315
- try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "relais" }); } catch { /* jamais bloquant */ }
389
+ 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 */ }
316
391
  try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
392
+ } finally {
393
+ clearTimeout(stall); clearTimeout(budget);
317
394
  }
318
395
  }
319
396
 
@@ -762,6 +839,15 @@ async function handlerMesure(req, res) {
762
839
  derniereIlYaS: dernier == null ? null : Math.max(0, Math.round((Date.now() - dernier) / 1000)),
763
840
  };
764
841
  })(),
842
+ // ⚠️ MÊME FORME, AUTRE PLAFOND. `lectureSaturee` est le cache de lecture ; ceci est l'admission
843
+ // des relais de fichiers (`config.maxConcurrentRelays`). Un hôte a lu le premier pour le second
844
+ // (13/09) parce que le second n'existait pas sur la carte — et `mesures.statuts.occupe503`
845
+ // les confond. Trois clés, jamais séparées : un total sans sa fenêtre ment par omission.
846
+ relaisRefuses: {
847
+ total: relaisRefusesTotal,
848
+ fenetreS: Math.round(process.uptime()),
849
+ derniereIlYaS: dernierRefusRelais == null ? null : Math.max(0, Math.round((Date.now() - dernierRefusRelais) / 1000)),
850
+ },
765
851
  // ⚠️ CE QUE CETTE INSTANCE A VÉCU — parce que `lectureSaturee` ne répondait qu'à UNE
766
852
  // question. « La route est-elle lente ? », « lesquelles ? », « la base ou nous ? »,
767
853
  // « combien de 5xx ? », « la boucle décroche-t-elle ? » n'avaient aucune réponse
@@ -850,10 +936,16 @@ async function handlerMesure(req, res) {
850
936
  // Placée AVANT `getPresentation` — écrite après, elle aurait laissé passer très exactement la
851
937
  // requête qu'elle est censée épargner. Le quota se déduit de la cadence de l'audience
852
938
  // (`src/cadence.ts`), il n'est pas choisi à la main.
853
- const sondage = String(q.state || "") === "1" || String(q.chat || "") === "1";
854
- if (sondage) {
855
- const ipSondage = adresseAppelant(req) || "anon";
856
- if (!(await PLAYER.limits.allow(`pread:${ipSondage}`, PRESENT_QUOTA_PER_HOUR, 3600))) {
939
+ // ⚠️ DEUX POINTS, DEUX CLÉS — LE QUOTA EST DÉRIVÉ « SUR CHACUN DES DEUX POINTS » ET UNE SEULE
940
+ // CLÉ LES FAISAIT PAYER LE MÊME BUDGET. Le filet du navigateur relit l'état ET le chat toutes
941
+ // les 25 s (`gabarit-live.js`) : sous `pread:<ip>`, une sortie unique portait 306 spectateurs
942
+ // au repos, pas les 613 annoncés — et une saturation du chat coupait l'état, qui fait autorité
943
+ // sur la page affichée. Reproduit par un audit externe le 13/09 contre le vrai limiteur. Une
944
+ // requête qui demanderait les deux points paie les deux.
945
+ const points = [String(q.state || "") === "1" ? "state" : null, String(q.chat || "") === "1" ? "chat" : null].filter(Boolean);
946
+ const ipSondage = points.length ? (adresseAppelant(req) || "anon") : "";
947
+ for (const point of points) {
948
+ if (!(await PLAYER.limits.allow(`pread:${point}:${ipSondage}`, PRESENT_QUOTA_PER_HOUR, 3600))) {
857
949
  // ⚠️ UN REFUS MUET FAIT UNE AUDIENCE QUI DÉCROCHE SANS CAUSE NOMMÉE. Le 429 n'apparaît
858
950
  // que dans la console du spectateur ; l'exploitant, lui, verrait des pages qui ne tournent
859
951
  // plus chez tout un bâtiment. Une fois par heure suffit à nommer la cause sans inonder.
@@ -976,8 +1068,10 @@ async function handlerMesure(req, res) {
976
1068
  if (String(q.file || "") === "1") {
977
1069
  if (!isAllowedStorageUrl(pres.file_url)) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
978
1070
  const range = req.headers["range"];
979
- const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
980
- await relayerFichier(res, r, null);
1071
+ await relayerSousAdmission(res, async (bornes) => {
1072
+ const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
1073
+ await relayerFichier(res, r, null, bornes);
1074
+ });
981
1075
  return;
982
1076
  }
983
1077
  const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
@@ -999,8 +1093,10 @@ async function handlerMesure(req, res) {
999
1093
  if (!isAllowedStorageUrl(url)) return sendRefusal(res, "url-not-allowed", embed);
1000
1094
  if (String(q.stream || "") === "1") {
1001
1095
  const range = req.headers["range"];
1002
- const r = await PLAYER.storage.fetchFile(url, { range });
1003
- await relayerFichier(res, r, dispositionInline(q.name));
1096
+ await relayerSousAdmission(res, async (bornes) => {
1097
+ const r = await PLAYER.storage.fetchFile(url, { range });
1098
+ await relayerFichier(res, r, dispositionInline(q.name), bornes);
1099
+ });
1004
1100
  return;
1005
1101
  }
1006
1102
  const supaUrl = (PLAYER.config && PLAYER.config.supabaseUrl) || "";
@@ -1051,8 +1147,10 @@ async function handlerMesure(req, res) {
1051
1147
  // Stream depuis le Storage en RELAYANT les requêtes Range → pdf.js charge progressivement (les 1res
1052
1148
  // pages s'affichent sans télécharger tout le PDF) → affichage bien plus rapide.
1053
1149
  const range = req.headers["range"];
1054
- const r = await PLAYER.storage.fetchFile(share.file_url, { range });
1055
- await relayerFichier(res, r, dispositionInline(share.file_name));
1150
+ await relayerSousAdmission(res, async (bornes) => {
1151
+ const r = await PLAYER.storage.fetchFile(share.file_url, { range });
1152
+ await relayerFichier(res, r, dispositionInline(share.file_name), bornes);
1153
+ });
1056
1154
  return;
1057
1155
  }
1058
1156
 
@@ -1143,6 +1241,13 @@ async function handlerMesure(req, res) {
1143
1241
  // ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
1144
1242
  // regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
1145
1243
  // ⚠️ Exporté pour être ÉPROUVÉ : « le contexte reste vivant » ne se vérifie pas de l'extérieur.
1146
- module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
1244
+ module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript,
1245
+ // ⚠️ COUTURE DE BANC : le compteur de relais en vol est un état du PROCESSUS, jamais remis à zéro par
1246
+ // `init`. Un banc doit pouvoir vérifier qu'il revient à zéro et ne passe jamais sous zéro.
1247
+ __relaisEnCours: () => relaisEnCours,
1248
+ // ⚠️ COUTURE DE BANC, PAS D'API : le cache de lecture est global au module, et un banc qui laisse des
1249
+ // lectures en vol contamine le suivant (128 promesses éternelles, 503 partout — trouvé par un audit
1250
+ // externe sous mélange, graine 20260913). Un banc doit pouvoir VÉRIFIER qu'il rend le cache vide.
1251
+ __cacheLecture: cacheLecture };
1147
1252
 
1148
1253
  // redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
@@ -138,6 +138,18 @@ function viewerHtml(share, nonce, logoUrl, pitch) {
138
138
  #pages{display:flex;flex-direction:column;align-items:center;gap:16px;width:max-content;min-width:100%;margin:0 auto;padding:22px 14px}
139
139
  .page{position:relative;background:#fff;box-shadow:0 6px 22px #0006;border-radius:3px}
140
140
  .pspace{flex:0 0 auto;width:1px;visibility:hidden} /* porte la hauteur des pages ou vignettes NON materialisees */
141
+ /* Le navigateur plafonne la hauteur de défilement (Chrome : 33 554 432 px) : au-delà, des pages existent et ne se
142
+ joignent pas. L'avis le DIT, fixé au bas du cadre, et disparaît dès que le zoom rend tout le document atteignable. */
143
+ /* Mode « une seule page » — la STRUCTURE vit ici, dans la visionneuse de base : enterOnePage() est offert à
144
+ tout greffon (PlayerBot), pas seulement à l'assistant dont la feuille (gabarit-agent) porte les animations.
145
+ Sans ces règles, une page hors assistant en mode une page empilait courante et voisines, et la courante
146
+ se retrouvait sous le cadre (mesuré dans Chrome le 13/09). Un espaceur de pages absentes est caché aussi. */
147
+ body.onepage .scroll{overflow:hidden}
148
+ body.onepage #pages{height:100%;display:flex;align-items:center;justify-content:center;padding:0}
149
+ body.onepage #pages .page{display:none;margin:0}
150
+ body.onepage #pages .page.cur{display:block}
151
+ body.onepage #pages .pspace{display:none}
152
+ .plafond{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);max-width:min(92vw,560px);background:#111d;color:#fff;border:1px solid #fff3;border-radius:8px;padding:8px 12px;font-size:13px;line-height:1.4;text-align:center;z-index:40;box-shadow:0 6px 22px #0006}
141
153
  .page canvas{display:block;border-radius:3px}
142
154
  /* Couche texte pdf.js : invisible, superposée au canvas → sélection du texte possible (requiert --scale-factor). */
143
155
  .textLayer{position:absolute;inset:0;overflow:hidden;line-height:1;opacity:1;z-index:2;forced-color-adjust:none}
@@ -335,6 +347,7 @@ ${LEGAL_CSS}
335
347
  get onePage(){return onePage;},
336
348
  get reportEnAttente(){return tRestaurer!==null;},
337
349
  get fenetre(){return pagesFenetre;},get vignFenetre(){return vignFenetre;},
350
+ get atteignables(){return atteignables;},
338
351
  get soloOffered(){return soloOffered;},set soloOffered(v){soloOffered=!!v;}};
339
352
  var pdfDoc=null, zoom=1, rot=0, firstAspect=1.35, rendered={}, io=null, ioCur=null;
340
353
  // ATTENTION : LE LECTEUR GARDAIT TOUTES LES PAGES RENDUES (P1 audit externe).
@@ -353,6 +366,14 @@ ${LEGAL_CSS}
353
366
  // FENETRE VIRTUELLE : seuls les gabarits de pages (et de vignettes) proches du visible EXISTENT
354
367
  // dans le DOM ; deux espaceurs portent la hauteur des absents. Voir src/viewer.ts.
355
368
  var pagesFenetre={debut:0,fin:0}, vignFenetre={debut:0,fin:0};
369
+ // ⚠️ LA VIRTUALISATION BORNE LE DOM, PAS LA GEOMETRIE. Les espaceurs portent la hauteur de TOUTES les
370
+ // pages absentes et Chrome plafonne la hauteur de defilement a 33 554 432 px : au-dela, un scrollTop
371
+ // pose ne mene nulle part. A 200 % sur 1 440 px, la 8 615e page d un document de 10 000 est la
372
+ // derniere atteignable — reproduit par un audit externe le 13/09 dans Chrome reel. Le calcul vit
373
+ // dans src/viewer.ts (pagesAtteignables) ; ici on l applique, on le DIT au lecteur, et on ne le
374
+ // laisse pas defiler vers une page qui n arrive jamais. Le remede durable est un defilement
375
+ // segmente ; ceci est le plafond explicite, jamais silencieux, en attendant.
376
+ var atteignables=0;
356
377
  var MARGE_VIRTUELLE=3, ESPACE_PAGES=16, HAUT_PAGES=22; // = CSS #pages (gap, padding-top)
357
378
  var VIGN_MARGE_VIRTUELLE=4, ESPACE_VIGN=10, HAUT_VIGN=12, VIGN_BORDURE=4; // = CSS .vign-in / .vg
358
379
  var pagesAvant=null, pagesApres=null, vignAvant=null, vignApres=null, rafPages=0, rafVign=0;
@@ -962,13 +983,39 @@ ${LEGAL_CSS}
962
983
  function poserEspaceur(el,hauteur,ecart){ if(hauteur>0){ el.style.display=''; el.style.height=Math.max(0,hauteur-ecart)+'px'; } else { el.style.display='none'; el.style.height='0px'; } }
963
984
  function hauteurPage(){ return Math.round(Math.round(targetWidth())*aspectEffectif()); }
964
985
  function geoPages(){ return {hauteurElement:hauteurPage(),ecart:ESPACE_PAGES,decalageHaut:HAUT_PAGES}; }
986
+ // La derniere page que le navigateur peut presenter a cette geometrie, et l avis qui va avec. En mode
987
+ // une page, le defilement ne sert pas a naviguer : tout est atteignable.
988
+ function majAtteignables(){
989
+ if(!numPages){ atteignables=0; return; }
990
+ var g=geoPages();
991
+ atteignables=onePage?numPages:Player.viewer.pagesAtteignables({hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages});
992
+ var el=document.getElementById('plafondAvis');
993
+ if(!el){ el=document.createElement('div'); el.id='plafondAvis'; el.className='plafond'; el.setAttribute('role','status'); el.setAttribute('aria-live','polite'); el.style.display='none'; (document.querySelector('.lmain')||document.body).appendChild(el); }
994
+ if(atteignables<numPages){
995
+ var auMin=atteignablesAuZoom(Player.viewer.MIN_ZOOM);
996
+ el.textContent='Ce navigateur ne peut pas faire défiler au-delà de la page '+atteignables+' sur '+numPages+' à ce zoom. '
997
+ +(auMin>=numPages?'Réduisez le zoom, ou passez en mode une page pour lire la suite.':'Même au zoom minimal, seules '+auMin+' pages seraient atteignables : passez en mode une page pour lire la suite.');
998
+ el.style.display='';
999
+ }
1000
+ else { el.style.display='none'; el.textContent=''; }
1001
+ }
1002
+ function totalDefilable(){ return onePage?numPages:(atteignables||numPages); }
1003
+ // Ce que le defilement continu atteindrait a un autre zoom — pour dire au lecteur si reduire suffit.
1004
+ function atteignablesAuZoom(z){
1005
+ var w=Math.round(Player.viewer.fitWidth({containerWidth:baseWidth(),containerHeight:scrollEl.clientHeight,zoom:z,onePage:false,aspect:firstAspect,rotation:rot,overlap:botOverlap(),reserve:onePageReserve()}));
1006
+ return Player.viewer.pagesAtteignables({hauteurElement:Math.round(w*aspectEffectif()),ecart:ESPACE_PAGES,decalageHaut:HAUT_PAGES,total:numPages});
1007
+ }
965
1008
  function creerPage(i,w,h){ var d=document.createElement('div'); d.className='page ph'; d.dataset.p=i; d.style.width=w+'px'; d.style.height=h+'px'; d.textContent='Page '+i; if(io)io.observe(d); if(ioCur)ioCur.observe(d); return d; }
966
1009
  function retirerPage(el){ var n=+el.dataset.p; libererPage(n); try{ if(io)io.unobserve(el); if(ioCur)ioCur.unobserve(el); }catch(e){} if(el.parentNode) el.parentNode.removeChild(el); }
967
1010
  function reconcilierPages(force,autour){
968
1011
  if(!pagesEl||!pagesAvant||!pagesApres||!numPages)return;
969
1012
  var g=geoPages(), f;
970
- if(onePage){ var c=autour||cur||1; var pas=Player.viewer.pasVertical(g.hauteurElement,g.ecart); var d0=Math.max(1,c-1), f0=Math.min(numPages,c+1); f={debut:d0,fin:f0,avant:(d0-1)*pas,apres:(numPages-f0)*pas}; }
971
- else f=Player.viewer.fenetreVirtuelle({debutVisible:scrollEl.scrollTop||0,hauteurVisible:scrollEl.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages,marge:MARGE_VIRTUELLE});
1013
+ // ⚠️ EN MODE UNE PAGE, AUCUN ESPACEUR : la page courante est centree par flex et les autres sont
1014
+ // display:none — un espaceur portant la hauteur des milliers de pages precedentes poussait la
1015
+ // 10 000e a 8 339 242 px du haut, presente dans le DOM, courante, et INVISIBLE. Mesure par un
1016
+ // audit externe dans Chrome le 13/09 : les bancs prouvaient l existence et le numero, pas l ecran.
1017
+ if(onePage){ var c=autour||cur||1; var d0=Math.max(1,c-1), f0=Math.min(numPages,c+1); f={debut:d0,fin:f0,avant:0,apres:0}; }
1018
+ else f=Player.viewer.fenetreVirtuelle({debutVisible:scrollEl.scrollTop||0,hauteurVisible:scrollEl.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:totalDefilable(),marge:MARGE_VIRTUELLE});
972
1019
  if(!force&&f.debut===pagesFenetre.debut&&f.fin===pagesFenetre.fin)return;
973
1020
  pagesFenetre=f;
974
1021
  var w=Math.round(targetWidth()), h=g.hauteurElement, presents={};
@@ -1000,6 +1047,7 @@ ${LEGAL_CSS}
1000
1047
  pagesEl.innerHTML='';
1001
1048
  pagesAvant=espaceur('pagesAvant'); pagesApres=espaceur('pagesApres'); pagesEl.appendChild(pagesAvant); pagesEl.appendChild(pagesApres);
1002
1049
  pagesFenetre={debut:0,fin:0};
1050
+ majAtteignables();
1003
1051
  reconcilierPages(true,cur||1);
1004
1052
  var _band=capReserve(); var _pb=document.body.classList.contains('botplayer')?(document.body.classList.contains('vsplit')?Math.round(window.innerHeight*0.38)+50:(isLand()?0:240)):(botOverlap()+(_band?_band+12:(document.body.classList.contains('deskaudio')?16:0))); pagesEl.style.paddingBottom = onePage ? (_pb+'px') : ''; // centre la page dans l'espace VISIBLE (au-dessus de la sheet mobile / du bandeau desktop / sous le header en audio seul)
1005
1053
  if(onePage) showPage(cur||1);
@@ -1032,6 +1080,9 @@ ${LEGAL_CSS}
1032
1080
  function scrollToPage(p){
1033
1081
  // Toute navigation explicite PERIME le report en attente : c est ce qui ferme la course.
1034
1082
  clearTimeout(tRestaurer); tRestaurer=null;
1083
+ // Une page au-dela du plafond de defilement n arrive jamais : on s arrete a la derniere atteignable,
1084
+ // et l avis (deja visible dans ce cas) dit pourquoi.
1085
+ if(!onePage&&atteignables&&p>atteignables) p=atteignables;
1035
1086
  var el=pagesEl.querySelector('.page[data-p="'+p+'"]');
1036
1087
  // La page peut ne pas EXISTER encore : on se place a sa position calculee, on materialise, puis on aligne.
1037
1088
  if(!el){ scrollEl.scrollTop=Player.viewer.positionDe(p,geoPages()); reconcilierPages(true,p); el=pagesEl.querySelector('.page[data-p="'+p+'"]'); }
@@ -1169,7 +1220,7 @@ ${LEGAL_CSS}
1169
1220
  function reconcilierVignettes(force){
1170
1221
  if(!vignIn||!vignAvant||!vignApres||!vignOuvert||numPages<2)return;
1171
1222
  var g=geoVign();
1172
- var f=Player.viewer.fenetreVirtuelle({debutVisible:vignIn.scrollTop||0,hauteurVisible:vignIn.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages,marge:VIGN_MARGE_VIRTUELLE});
1223
+ var f=Player.viewer.fenetreVirtuelle({debutVisible:vignIn.scrollTop||0,hauteurVisible:vignIn.clientHeight||0,hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:Math.min(numPages,Player.viewer.pagesAtteignables({hauteurElement:g.hauteurElement,ecart:g.ecart,decalageHaut:g.decalageHaut,total:numPages})),marge:VIGN_MARGE_VIRTUELLE});
1173
1224
  if(!force&&f.debut===vignFenetre.debut&&f.fin===vignFenetre.fin)return;
1174
1225
  vignFenetre=f;
1175
1226
  var presents={}, els=vignIn.querySelectorAll('.vg');
@@ -17,7 +17,15 @@
17
17
  // confronte les colonnes du schéma vivant à docs/RETENTION.md.
18
18
 
19
19
  let PLAYER = null;
20
- const init = (ctx) => { PLAYER = ctx; };
20
+ // ⚠️ TROISIÈME ÉTAT : `storage.remove` ABSENT. Un hôte qui fournit `put` sans `remove` fabrique des
21
+ // objets, et le balayage effaçait leur ligne « comme avant » — le seul chemin vers l'objet, puisque
22
+ // la capacité expose `put` et `remove` mais jamais `list`. Trouvé par un hôte (STUDIO, 13/09) en
23
+ // lisant les lignes 141 et 248 de ce fichier, pas la prose du contrat, qui supposait qu'on fournit
24
+ // un `remove`. Le correctif de 0.1.164 distinguait « a échoué » de « a réussi » ; il ne voyait pas
25
+ // « n'a pas été tenté ». Désormais : ligne RETENUE, comptée, et la capacité manquante est dite une
26
+ // fois par processus — ici, pas à chaque ligne, sinon un balayage de mille lignes crie mille fois.
27
+ let sansRemove = false, sansRemoveDit = false;
28
+ const init = (ctx) => { PLAYER = ctx; sansRemove = false; sansRemoveDit = false; };
21
29
  const enc = encodeURIComponent;
22
30
 
23
31
  // Fenêtres par défaut de docs/RETENTION.md — l'hôte ajuste via `config.retention`.
@@ -137,9 +145,23 @@ async function effacerParIds(table, filtre, colId, ids, opts) {
137
145
  // retrait donnerait deux chemins de destruction, dont un seul serait gardé. C'est exactement ce que
138
146
  // `retentionUnePorte.test.js` refuse de laisser arriver.
139
147
  async function retirerFichier(bucket, chemin, opts) {
140
- if (opts.dryRun || !chemin) return null; // null = rien tenté ; true = retiré ; false = échec
148
+ if (!chemin) return null; // null = rien à retirer (ou dry-run) ; true = retiré ; false = pas retiré
141
149
  const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
142
- if (!retirer) return null;
150
+ if (!retirer) {
151
+ // Pas de capacité : l'objet ne peut PAS être retiré, donc la ligne ne doit pas partir. Même en
152
+ // dry-run on le note, pour qu'un hôte le lise AVANT d'armer le balayage.
153
+ sansRemove = true;
154
+ if (!sansRemoveDit) {
155
+ sansRemoveDit = true;
156
+ try {
157
+ PLAYER.errors.capture(new Error("rétention : `storage.remove` n'est pas fourni — les lignes porteuses de fichiers"
158
+ + " sont RETENUES (leur objet resterait sinon inatteignable, la capacité n'exposant jamais `list`)."
159
+ + " Fournissez `storage.remove` pour qu'elles partent."), { route: "retention", benin: true });
160
+ } catch { /* jamais bloquant */ }
161
+ }
162
+ return opts.dryRun ? null : false;
163
+ }
164
+ if (opts.dryRun) return null;
143
165
  try { return !!(await retirer(bucket, chemin)); } catch { return false; }
144
166
  }
145
167
 
@@ -333,6 +355,7 @@ async function purgerCacheDeVoix(opts, borneDate) {
333
355
  }
334
356
 
335
357
  async function purgerRetention(now, optsBrutes = {}) {
358
+ sansRemove = false; // le rapport dit ce qui a manqué pendant CE passage, pas pendant la vie du processus
336
359
  let f, opts;
337
360
  try { f = fenetresValidees(); opts = optionsValidees(optsBrutes); }
338
361
  catch (e) {
@@ -436,7 +459,8 @@ async function purgerRetention(now, optsBrutes = {}) {
436
459
  doc_presentation_attendees: presRapport.presences,
437
460
  pieces_jointes: presRapport.fichiers,
438
461
  };
439
- return { ok: true, dryRun: !!opts.dryRun, efface, rapport };
462
+ // `sansRemove` : la capacité manquait pendant CE passage, et `retenues` en porte la trace.
463
+ return { ok: true, dryRun: !!opts.dryRun, sansRemove, efface, rapport };
440
464
  }
441
465
 
442
466
  // Balayage opportuniste : au plus UN par fenêtre de 24 h (le verrou est le compteur de débit
@@ -12,6 +12,18 @@ const { SESSION_QUOTA_PER_HOUR, VIEW_QUOTA_PER_HOUR } = require("./shared.genera
12
12
  let PLAYER = null;
13
13
  const init = (ctx) => { PLAYER = ctx; };
14
14
 
15
+ /**
16
+ * Le motif qu'un crochet de courrier DÉCLARE avec son refus (`reason` ou `motif`), borné : une chaîne
17
+ * courte, ou rien. Jamais un objet, jamais plus de 80 caractères — c'est un mot de l'hôte à son
18
+ * appelant, pas un canal.
19
+ */
20
+ function motifDeclare(r) {
21
+ const m = r && (r.reason !== undefined ? r.reason : r.motif);
22
+ if (m === null || m === undefined || typeof m === "object") return null;
23
+ const t = String(m).trim().slice(0, 80);
24
+ return t || null;
25
+ }
26
+
15
27
  // Traite les actions de cette famille. Le MARQUEUR est le retour : les blocs répondent puis
16
28
  // sortent par leurs `return` d'origine (valeur ≠ false) ; si aucune action ne correspond, la
17
29
  // chute au bout rend `false` et le dispatch continue. Aucune liste d'actions n'est dupliquée
@@ -301,7 +313,7 @@ async function traiter(req, res, body, slug) {
301
313
  try { out = await createReshare(body.slug || slug, { email: mail, name: body.name, clientKey: body.clientKey }); } catch { /* parent introuvable */ }
302
314
  if (!out) return j(404, { ok: false });
303
315
  let sent = false;
304
- let refusEnvoi = null;
316
+ let refusEnvoi = null, motifHote = null;
305
317
  // ⚠️ UN LIEN IDEMPOTENT NE RENVOIE PAS DE COURRIER, ET C'EST L'AUTRE MOITIÉ DU CORRECTIF.
306
318
  // Rendre le même enfant tout en réexpédiant laisserait le défaut entier : le destinataire
307
319
  // reçoit deux messages, ce qui est exactement ce qu'on répare. Trouvé par le banc de
@@ -364,7 +376,15 @@ async function traiter(req, res, body, slug) {
364
376
  const origin = publique;
365
377
  const r = await sendReshareEmail({ parent, childSlug: out.slug, origin, toEmail: mail, toName: body.name });
366
378
  sent = !!(r && r.sent);
367
- if (!sent) refusEnvoi = refusEnvoi || "host-declined";
379
+ if (!sent) {
380
+ refusEnvoi = refusEnvoi || "host-declined";
381
+ // ⚠️ L'HÔTE DIT POURQUOI, ET ON LE JETAIT. Un hôte (ADV, 13/09) répond délibérément
382
+ // `{ sent: false, motif }` à chaque refus — huit motifs distincts — précisément pour que
383
+ // « refusé » ne se confonde pas avec « en panne ». On ne lisait que `sent` : la
384
+ // désambiguïsation que le contrat disait manquante, au moins un hôte l'envoyait déjà.
385
+ // Bornée et recopiée telle quelle : c'est un mot de l'hôte à son propre appelant.
386
+ motifHote = motifDeclare(r);
387
+ }
368
388
  } catch { /* best-effort : le lien existe quand même */ }
369
389
  }
370
390
  // ⚠️ `sent: false` MENTAIT QUAND LA VÉRITÉ ÉTAIT « JE NE SAIS PAS », ET C'EST CE MENSONGE QUI
@@ -393,7 +413,7 @@ async function traiter(req, res, body, slug) {
393
413
  // Le refus se DIT : « rien n'est parti » et « l'envoi n'était pas permis » ne se
394
414
  // ressemblent pas, et une interface qui les confond propose un bouton qui ne marchera
395
415
  // jamais.
396
- return j(200, { ok: true, slug: out.slug, sent, delivery, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}) });
416
+ return j(200, { ok: true, slug: out.slug, sent, delivery, ...(refusEnvoi ? { sendRefused: refusEnvoi } : {}), ...(motifHote ? { hostReason: motifHote } : {}) });
397
417
  }
398
418
  // ⚠️ CE REPLI NE COUVRE QUE LES ÉVÉNEMENTS ANALYTIQUES (P2 huitième audit). Une action POST
399
419
  // qu'aucune famille n'a reconnue tombait ici et repartait `{"ok":true}` — une faute de
@@ -8,7 +8,56 @@ const { repondreJson } = require("./reponses.js");
8
8
 
9
9
  const { getShareBySlug } = require("./shares");
10
10
  let PLAYER = null;
11
- const init = (ctx) => { PLAYER = ctx; };
11
+
12
+ // ⚠️ LA VÉRIFICATION N'AVAIT AUCUN PLAFOND — seule la DEMANDE de code en avait un (20/h par adresse).
13
+ // `visitor-verify` et `visitor-google` appelaient le greffon directement : mille tentatives depuis
14
+ // une adresse, zéro appel au limiteur (reproduit par un audit externe le 13/09). Un code court sans
15
+ // compteur dans le greffon se force ; une vérification Google par requête anonyme est une
16
+ // amplification réseau. Le player ne peut pas supposer que le greffon compte — c'est la même règle
17
+ // que la liaison session ↔ document de l'assistant : une propriété de sécurité ne dépend pas d'un
18
+ // code que le player ne contient pas.
19
+ //
20
+ // Deux dimensions pour le code, parce qu'une seule se contourne : par ADRESSE (une adresse ne
21
+ // recommence pas à zéro en changeant d'email) et par IDENTITÉ (plusieurs adresses ne forcent pas un
22
+ // même email). Les compteurs sont pris À L'ADMISSION, donc réussite, échec et exception les
23
+ // consomment pareil. L'identité n'est jamais l'email : la table des compteurs n'a pas à porter
24
+ // d'adresses en clair. La clé vient du GREFFON (un HMAC avec un secret chez l'hôte, ci-dessous) ;
25
+ // l'empreinte SHA-256 n'est que le repli, et il est dit. ⚠️ Ce paragraphe affirmait « le cœur n'a
26
+ // pas de secret de serveur, donc une empreinte, pas un HMAC » — retiré (cinquième passe de l'audit,
27
+ // 13/09) : trop absolu, et surtout la clé n'a pas besoin d'un secret du cœur, elle vient de l'hôte.
28
+ const VERIF_PAR_ADRESSE = 100, VERIF_PAR_IDENTITE = 10, VERIF_FENETRE_IDENTITE_S = 900;
29
+ const GOOGLE_PAR_ADRESSE = 100, DEMANDE_PAR_IDENTITE = 5;
30
+ // ⚠️ UN SHA-256 D'EMAIL N'EST PAS UNE ANONYMISATION : il se renverse par dictionnaire — qui lit la
31
+ // table des compteurs, une sauvegarde ou un outil d'administration précalcule les empreintes des
32
+ // adresses probables (relevé par un audit externe le 13/09 ; `alice@example.com` → une valeur connue).
33
+ // La clé vient donc du GREFFON quand il sait la produire : `visitors.rateLimitKey(email)` rend un HMAC
34
+ // stable et opaque, avec un secret que le player ne voit jamais et une séparation de domaine
35
+ // (`"visitor-email\0" + email normalisé`). Sans cette capacité, ou si elle échoue, on retombe sur
36
+ // l'empreinte — pseudonyme, pas secrète — et on le DIT une fois : refuser la limite serait pire que la
37
+ // limiter sous un pseudonyme faible, et la taire serait pire que les deux.
38
+ const normaliserEmail = (email) => String(email || "").trim().toLowerCase();
39
+ const empreinteIdentite = (email) =>
40
+ require("crypto").createHash("sha256").update(normaliserEmail(email)).digest("hex").slice(0, 32);
41
+ let repliDit = false;
42
+ async function cleIdentite(V, email) {
43
+ const norm = normaliserEmail(email);
44
+ if (V && typeof V.rateLimitKey === "function") {
45
+ try {
46
+ const k = await V.rateLimitKey(norm);
47
+ if (typeof k === "string" && k.trim()) return "h:" + k.trim().slice(0, 64);
48
+ throw new Error("rateLimitKey a rendu autre chose qu'une chaîne non vide");
49
+ } catch (e) { direLeRepli(`visitors.rateLimitKey a échoué (${e && e.message ? e.message : "cause inconnue"})`); }
50
+ } else direLeRepli("visitors.rateLimitKey n'est pas fourni");
51
+ return "e:" + empreinteIdentite(norm);
52
+ }
53
+ function direLeRepli(pourquoi) {
54
+ if (repliDit) return;
55
+ repliDit = true;
56
+ 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
+ } catch { /* jamais bloquant */ }
59
+ }
60
+ const init = (ctx) => { PLAYER = ctx; repliDit = false; };
12
61
 
13
62
  // Traite les actions de cette famille. Le MARQUEUR est le retour : les blocs répondent puis
14
63
  // sortent par leurs `return` d'origine (valeur ≠ false) ; si aucune action ne correspond, la
@@ -25,6 +74,8 @@ async function traiter(req, res, body, _slug) {
25
74
  const ip = adresseAppelant(req) || "ip";
26
75
  if (body.action === "visitor-request") {
27
76
  if (!(await PLAYER.limits.allow(`vcode:${ip}`, 20, 3600))) return jv(429, { ok: false, error: "rate" });
77
+ // Une boîte ne se fait pas inonder depuis cent adresses : cinq codes par heure et par email.
78
+ if (!(await PLAYER.limits.allow(`vcode:id:${await cleIdentite(V, body.email)}`, DEMANDE_PAR_IDENTITE, 3600))) return jv(429, { ok: false, error: "rate" });
28
79
  const sh = await getShareBySlug(String(body.slug || ""));
29
80
  return jv(200, await V.requestCode(body.email, { title: sh && sh.doc_title }));
30
81
  }
@@ -50,10 +101,13 @@ async function traiter(req, res, body, _slug) {
50
101
  }
51
102
  };
52
103
  if (body.action === "visitor-google") {
104
+ if (!(await PLAYER.limits.allow(`vgoogle:${ip}`, GOOGLE_PAR_ADRESSE, 3600))) return jv(429, { ok: false, error: "rate" });
53
105
  const r = await V.verifyGoogle(body.credential);
54
106
  if (r.ok) await recordUnlock(r.visitor, "google");
55
107
  return r.ok ? jv(200, { ok: true }, r.setCookie) : jv(400, r);
56
108
  }
109
+ if (!(await PLAYER.limits.allow(`vverif:${ip}`, VERIF_PAR_ADRESSE, 3600))) return jv(429, { ok: false, error: "rate" });
110
+ if (!(await PLAYER.limits.allow(`vverif:id:${await cleIdentite(V, body.email)}`, VERIF_PAR_IDENTITE, VERIF_FENETRE_IDENTITE_S))) return jv(429, { ok: false, error: "rate" });
57
111
  const r = await V.verifyCode(body.email, body.code, body.name);
58
112
  if (r.ok) await recordUnlock(r.visitor, "email");
59
113
  return r.ok ? jv(200, { ok: true }, r.setCookie) : jv(400, r);
@@ -128,6 +128,18 @@ 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 ; 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;
136
+ /** Un relais sans progression pendant ce délai est abandonné — source et réponse détruites, place
137
+ * rendue (défaut 30 000 ms ; entier de 1 à 86 400 000 ms, sinon le défaut, dit une fois à `init` :
138
+ * `setTimeout` ramène à 1 ms tout délai au-delà de 2 147 483 647 ms). `requestTimeout` ne borne
139
+ * pas l'émission d'une réponse. */
140
+ relayStallMs?: number;
141
+ /** Durée totale maximale d'un relais (défaut 900 000 ms ; même plage que `relayStallMs`). */
142
+ relayMaxMs?: number;
131
143
  /** Ce qui est POSÉ, à côté de ce que le code SAIT faire : la carte d'identité publie les deux,
132
144
  * parce qu'une capacité disponible mais non configurée se comporte comme une absence. */
133
145
  separateIssuer?: boolean;