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.
- package/docs/HOST-CONTRACT.md +55 -5
- package/package.json +1 -1
- package/server/reponses.js +32 -1
- package/server/routes-agent.js +3 -3
- package/server/routes-direct.js +8 -8
- package/server/routes-liens.js +2 -2
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -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
|
|
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.
|
|
544
|
-
|
|
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.
|
|
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",
|
package/server/reponses.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/server/routes-agent.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
package/server/routes-direct.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|
package/server/routes-liens.js
CHANGED
|
@@ -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:
|
|
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
|