discovery-media-player 0.1.140 → 0.1.142

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.
@@ -54,6 +54,21 @@ need.
54
54
  never by order. `plugins` lets you refuse to start when you depend on an optional module this
55
55
  instance does not have.
56
56
 
57
+ ⚠️ **`ctx.has(name)` is that same question in code — and it is documented here because it was, until
58
+ 27/08, an accident.** The injected context carries it: `has(name)` answers whether the named plugin
59
+ is present, the same truth the `plugins` field above reports over HTTP. Supply it as
60
+ `has: (name) => !!plugins[name]`; the standalone context returns `false` for everything, having no
61
+ plugins at all.
62
+
63
+ **As measured on 27/08, nothing in `server/` called it** — that is a reading, not an omission of
64
+ this paragraph, and it is why implementing it buys you nothing immediately and skipping it costs
65
+ you nothing. It is written down for the opposite reason: on that day one host was found to
66
+ implement it *correctly and without knowing*, because the type declared it, while the shape lived
67
+ in 57 test fixtures and, **until this paragraph existed**, in no document at all. A seam that
68
+ exists, works, and is written nowhere is one rename away from being deleted as dead — and it would
69
+ not have been dead. If a future feature needs to ask *"does this host have that plugin?"*, this is
70
+ the spelling, and there should not be a second one.
71
+
57
72
  ⚠️ **`runtime` is the only way to see what the player is actually running on.** `nodeRequired` is
58
73
  the floor the package declares, `node` is what the process reports — two numbers, no verdict:
59
74
  compare them with your own semver rather than trusting a field we compute for you.
@@ -217,10 +232,20 @@ it. `verdict` is then one of:
217
232
 
218
233
  | verdict | meaning |
219
234
  |---|---|
220
- | `non-sonde` | nothing asked yet — **not** *nothing missing* |
221
- | `partiel` | some expectations checked, none of them missing |
235
+ | `non-sonde` | nothing asked yet — **not** *nothing missing*, and **process-local**: this process has looked at nothing; another instance may have looked at everything |
236
+ | `partiel` | some checked, none of them missing — the probe is **lazy**: a column is inspected only once something touches it, so this is *how much has been exercised*, never *how much exists* |
222
237
  | `complet` | all checked, all present |
223
238
  | `incomplet` | at least one is missing — `manquant` names the file and the sleeping feature |
239
+ | `indetermine` | the database did not answer; this measurement did not happen |
240
+
241
+ ⚠️ **The first two rows are one trap, and it fires on every deploy.** Because the probe is lazy and
242
+ lives in the process, a fresh instance answers `non-sonde`, then `partiel`, then `complet` as
243
+ traffic exercises columns — on a database that never changed. Measured at an integrating host on
244
+ 27/08, on one unchanged base: `sondees=9 complet` on the old instance, `sondees=0 non-sonde` right
245
+ after the deploy, `sondees=1 partiel` sixty-seven minutes later. They looked three times before
246
+ concluding, because a neighbouring field had already taught them to distrust that zero — and they
247
+ were about to report a regression that did not exist. Read these two verdicts as *what this
248
+ process has asked so far*, never as a statement about the schema.
224
249
 
225
250
  Each `manquant` entry has **exactly this shape** — pin your parser to it, not to what a schema
226
251
  probe "should" return:
@@ -240,7 +265,6 @@ If you consume this card, test your parser against the JSON above, not against a
240
265
  ⚠️ **A card without a `schema` field is an alert, not a success**: it signals an instance older
241
266
  than 0.1.58 — a version that cannot answer the question. (Rule contributed by the second host, for
242
267
  exactly the monitoring case where "no data" would otherwise read as "all clear".)
243
- | `indetermine` | the database did not answer; this measurement did not happen |
244
268
 
245
269
  ⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
246
270
  on its own, even when the rest has not been checked.
@@ -540,13 +564,39 @@ This is not hypothetical: an integrating host reported 908 objects it had writte
540
564
  the parity was deliberate, so that one clip serves both surfaces. Nothing about the name distinguishes
541
565
  its objects from the player's; only the missing row does.
542
566
 
543
- So `doc_tts_objects` is a **host write point**, not an internal table. Write it with the same
544
- fingerprint the player computes, and nothing else:
567
+ So `doc_tts_objects` is a **host write point**, not an internal table. What the sweep needs from
568
+ you is **one property, and only this one**:
569
+
570
+ > the `hash` you write in the row **is** the object's base name — the file is `<hash>.mp3`, its
571
+ > alignment is `<hash>.json`, and nothing else has to be true.
572
+
573
+ ⚠️ **How you compute that digest is yours, and this page used to say otherwise.** It read *"write
574
+ it with the same fingerprint the player computes, and nothing else"*, which made a perfectly safe
575
+ host non-compliant on paper — and the obvious fix, realigning the formula, is the one thing that
576
+ would break: the objects already in the bucket carry the **old** digest in their names, so a row
577
+ written with a new one points at nothing, and the real name loses its only row. Reported on 27/08
578
+ by a host whose third writer uses `preview-fr-v2` where the player uses `v2`. Their five write
579
+ sites are correct as they stand.
580
+
581
+ The sweep never recomputes anything: it reads `hash` from the row and removes `hash + ".mp3"` and
582
+ `hash + ".json"` (`server/retention.js`). A bench holds that property rather than a comment —
583
+ `retentionCacheDeVoix.test.js` builds its rows with `hash: "aaa"`, which is the sha256 of nothing,
584
+ and requires `tts-cache/aaa.mp3` to be the file removed.
585
+
586
+ **The player's own formula matters for one thing only, and it is not retention** — sharing. Its
587
+ route recomputes this digest to find a clip it already paid for, so match it *if* you want one clip
588
+ to serve both surfaces (as one host deliberately does, for 908 objects). If you don't, the player
589
+ simply synthesises its own, and nothing else changes:
545
590
 
546
591
  ```
547
592
  hash = sha256(voiceId + "|" + modelId + "|v2|" + spokenText) -- hex, lowercase
548
593
  ```
549
594
 
595
+ | requirement | mandatory? | if you don't |
596
+ |---|---|---|
597
+ | the row's `hash` is the object's base name | **yes, always** | the object is invisible to the sweep, permanently |
598
+ | the digest matches the player's formula | no — only to share a clip | the player synthesises its own, and pays for it |
599
+
550
600
  ```sql
551
601
  insert into public.doc_tts_objects (hash) values ($1)
552
602
  on conflict (hash) do nothing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.140",
3
+ "version": "0.1.142",
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",
@@ -81,4 +81,35 @@ function repondreJson(res, statut, valeur, entetes) {
81
81
  */
82
82
  const jsonPour = (res) => (statut, valeur, entetes) => repondreJson(res, statut, valeur, entetes);
83
83
 
84
- module.exports = { refuserEnTexte, repondreJson, repondreJsonTexte, jsonPour };
84
+ /**
85
+ * L'ÉTIQUETTE DE ROUTE D'UNE ERREUR CAPTURÉE — bornée, parce que l'appelant la choisit.
86
+ *
87
+ * ⚠️ NEUF SITES LA POSAIENT À L'IDENTIQUE, ET AUCUN NE LA BORNAIT :
88
+ * `route: String(body.action || "(sans action)")`. `body.action` vient du corps de la requête, donc
89
+ * de n'importe qui : un mégaoctet de texte, des retours à la ligne, des guillemets, des octets de
90
+ * contrôle. Ça part ensuite dans le puits d'erreurs de l'HÔTE — Sentry, un journal, un fichier —
91
+ * c'est-à-dire dans un système qu'on ne contrôle pas et dont on ne connaît pas les échappements.
92
+ *
93
+ * ⚠️ CE QU'ON EMPÊCHE N'EST PAS « UNE GROSSE CHAÎNE », C'EST LA FORGERIE DE STRUCTURE. Un saut de
94
+ * ligne dans un journal ligne-par-ligne ouvre une entrée qui n'a jamais eu lieu ; un guillemet dans
95
+ * un puits qui concatène du JSON en ouvre un champ. On ne garde donc que les caractères dont une
96
+ * action est faite — lettres, chiffres, `.`, `_`, `:`, `-` — et rien d'autre ne traverse.
97
+ *
98
+ * ⚠️ ET ON DIT LA TRONCATURE PLUTÔT QUE DE LA TAIRE. Une étiquette coupée en silence se lit comme
99
+ * une action qui s'appelle vraiment comme ça. Le `…` final est la différence entre « voici le nom »
100
+ * et « voici ce qu'il en restait ».
101
+ *
102
+ * Rendre `(action illisible)` plutôt que du vide quand tout a été retiré : « aucune action » et
103
+ * « une action dont il ne reste rien de lisible » sont deux constats différents, et un exploitant
104
+ * qui voit le second sait qu'on lui a envoyé quelque chose d'anormal.
105
+ */
106
+ const ETIQUETTE_MAX = 64;
107
+ function etiquetteRoute(action) {
108
+ const brut = String(action == null ? "" : action).trim();
109
+ if (!brut) return "(sans action)";
110
+ const propre = brut.replace(/[^A-Za-z0-9._:-]/g, "");
111
+ if (!propre) return "(action illisible)";
112
+ return propre.length > ETIQUETTE_MAX ? propre.slice(0, ETIQUETTE_MAX) + "…" : propre;
113
+ }
114
+
115
+ module.exports = { refuserEnTexte, repondreJson, repondreJsonTexte, jsonPour, etiquetteRoute };
@@ -10,7 +10,7 @@
10
10
  // agentVoix.test.js échoue sans cette ligne.
11
11
  const crypto = require("node:crypto");
12
12
  const { adresseAppelant } = require("./appelant");
13
- const { jsonPour } = require("./reponses.js");
13
+ const { jsonPour, etiquetteRoute } = require("./reponses.js");
14
14
 
15
15
  const { getShareBySlug } = require("./shares");
16
16
  const { creerCache, CODE_SATURATION } = require("./cache.js");
@@ -355,7 +355,7 @@ async function traiter(req, res, body, _slug) {
355
355
  cached: extrait.cached || undefined,
356
356
  spoken: spoken !== text ? spoken : undefined,
357
357
  });
358
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
358
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
359
359
  }
360
360
  if (body.action === "bot-start" || body.action === "bot-say" || body.action === "bot-history" || body.action === "bot-nudge" || body.action === "bot-book" || body.action === "bot-contact" || body.action === "bot-rate" || body.action === "bot-script") {
361
361
  const jp = jsonPour(res);
@@ -470,7 +470,7 @@ async function traiter(req, res, body, _slug) {
470
470
  const r = await docbot.botSay(String(body.sessionId || ""), share, text, pages, mobile, blang);
471
471
  if (r.error) return jp(400, { ok: false, error: r.error });
472
472
  return jp(200, { ok: true, ...r });
473
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
473
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
474
474
  }
475
475
  // Assistance (heartbeat) : PUBLIC (l'audience est anonyme). Journalise qui suit / combien de temps / pages vues.
476
476
  // Rate-limit généreux par IP (heartbeat ≈ 145/h/participant) : bloque le spam d'assistants factices sans
@@ -4,7 +4,7 @@
4
4
  // Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
5
5
 
6
6
  const { adresseAppelant, lcMembre, cleAnonyme, profilDuJeton } = require("./appelant");
7
- const { jsonPour } = require("./reponses.js");
7
+ const { jsonPour, etiquetteRoute } = require("./reponses.js");
8
8
 
9
9
  const { createPresentation, getPresentation, setPage, endPresentation, addMessage, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent } = require("./presentations");
10
10
  // Cadence de présence et cible de mutualisation par IP (P1 performance). L'intervalle DOIT
@@ -49,7 +49,7 @@ async function traiter(req, res, body, _slug) {
49
49
  // hôte, chaque « Terminer » échouait en 23502 (marqueur d'archive NOT NULL) — et ce 500
50
50
  // muet ne laissait RIEN, même pas une ligne dans le journal d'erreurs. Un journal que
51
51
  // personne ne lit vaut peu ; aucun journal ne vaut rien du tout.
52
- try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "present-" + String(body.action || "").replace(/^present-/, "") }); } catch { /* jamais bloquant */ }
52
+ try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: etiquetteRoute("present-" + String(body.action || "").replace(/^present-/, "")) }); } catch { /* jamais bloquant */ }
53
53
  return jp(500, { ok: false });
54
54
  }
55
55
  }
@@ -222,7 +222,7 @@ async function traiter(req, res, body, _slug) {
222
222
  // des présences) : une présentation close refuse toute écriture, jeton valide ou non.
223
223
  const pt = (r.ok && jetonCandidat) ? jetonCandidat : "";
224
224
  return jp(r.ok ? 200 : (r.status || 400), pt ? { ...r, pt } : r);
225
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
225
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
226
226
  }
227
227
  // Gestion des présentations (membre AUTHENTIFIÉ requis) : liste / reprise / transfert / stats / historique doc.
228
228
  if (body.action === "present-list" || body.action === "present-reclaim" || body.action === "present-handover" || body.action === "present-owner-end" || body.action === "present-stats" || body.action === "present-doc-list" || body.action === "present-switch" || body.action === "present-content") {
@@ -275,7 +275,7 @@ async function traiter(req, res, body, _slug) {
275
275
  else if (body.action === "present-content") r = await setPresentationContent(String(body.slug || ""), (u && u.email) || "", isAdmin, body.content, String(body.control || ""));
276
276
  else r = await handoverPresentation(String(body.slug || ""), u.email, body.newOwner);
277
277
  return jp(r.ok ? 200 : (r.status || 400), r);
278
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
278
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
279
279
  }
280
280
  // Chat de présentation (historisé) : n'importe quel participant (présentateur ou audience) poste un
281
281
  // message. Écriture via service role ; anti-spam par IP (60/h). La présentation doit exister.
@@ -301,7 +301,7 @@ async function traiter(req, res, body, _slug) {
301
301
  avatar: (profil && profil.avatar) || body.avatar,
302
302
  isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment , clientKey: body.clientKey });
303
303
  return jp(r.ok ? 200 : (r.status || 400), r);
304
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
304
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
305
305
  }
306
306
  // Pièce jointe : URL d'upload signée (la présentation doit exister ; rate-limit).
307
307
  if (body.action === "present-upload-url") {
@@ -314,7 +314,7 @@ async function traiter(req, res, body, _slug) {
314
314
  if (!allowed) return jp(429, { ok: false, error: "rate" });
315
315
  const r = await createUploadUrl(String(body.slug || ""), body.name, body.type);
316
316
  return jp(r.ok ? 200 : (r.status || 400), r);
317
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
317
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
318
318
  }
319
319
  // Chat : éditer / supprimer un message, verrouiller le chat.
320
320
  if (body.action === "present-msg-edit" || body.action === "present-msg-delete" || body.action === "present-chatlock") {
@@ -325,7 +325,7 @@ async function traiter(req, res, body, _slug) {
325
325
  else if (body.action === "present-msg-delete") r = await deleteMessage(String(body.slug || ""), body.msgId, { authorToken: body.authorToken, control: body.control });
326
326
  else r = await setChatLock(String(body.slug || ""), String(body.control || ""), !!body.locked);
327
327
  return jp(r.ok ? 200 : (r.status || 400), r);
328
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
328
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
329
329
  }
330
330
  // Réaction emoji (toggle) sur un message du chat de présentation.
331
331
  if (body.action === "present-react") {
@@ -345,7 +345,7 @@ async function traiter(req, res, body, _slug) {
345
345
  const reacteur = require("./presentations").reacteurDepuisJeton(body.authorToken);
346
346
  const r = await toggleReaction(String(body.slug || ""), body.msgId, body.emoji, reacteur, body.etat);
347
347
  return jp(r.ok ? 200 : (r.status || 400), r);
348
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
348
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jp(500, { ok: false }); }
349
349
  }
350
350
  return false;
351
351
  }
@@ -4,7 +4,7 @@
4
4
  // Reste à PLAT dans server/ (les gardes de forge ciblent server/*.js).
5
5
 
6
6
  const { adresseAppelant } = require("./appelant");
7
- const { jsonPour, repondreJson } = require("./reponses.js");
7
+ const { jsonPour, repondreJson, etiquetteRoute } = require("./reponses.js");
8
8
  const { estConflit } = require("./erreurs-base.js");
9
9
  const { createShare, createReshare, sendReshareEmail, revokeShare, setShareAuth, listSharesForDoc, listSessionsForDoc, internalStatsForDoc, cleIdempotence, getShareBySlug, logView, upsertSession, upsertInternalSession, overview: docOverview } = require("./shares");
10
10
  const { SESSION_QUOTA_PER_HOUR, VIEW_QUOTA_PER_HOUR } = require("./shared.generated.js");
@@ -249,7 +249,7 @@ async function traiter(req, res, body, slug) {
249
249
  }
250
250
  const { slug } = await createShare({ brandKey: body.brandKey, docId: body.docId, docTitle: body.docTitle, fileUrl: body.fileUrl, fileName: body.fileName, recipientEmail: body.recipientEmail, recipientName: body.recipientName, createdBy: u.email, bot: body.bot, botScript: body.botScript, guided: body.guided, profileId: body.profileId, allowDownload: body.allowDownload, videoLayout: body.videoLayout, logo: body.logo, logoDark: body.logoDark });
251
251
  return jd(200, { ok: true, slug });
252
- } catch (e) { try { PLAYER.errors.capture(e, { route: String(body.action || "(sans action)") }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
252
+ } catch (e) { try { PLAYER.errors.capture(e, { route: etiquetteRoute(body.action) }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
253
253
  }
254
254
 
255
255
  // Re-partage (forward depuis la visionneuse) : crée un lien enfant tracé, et envoie l'email via 3D