discovery-media-player 0.1.119 → 0.1.121

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/README.md CHANGED
@@ -14,6 +14,8 @@ and live presentation — for teams who would rather not hand their commercial d
14
14
  to a third-party SaaS.
15
15
 
16
16
  [![CI](https://github.com/Juli1artha/discovery-media-player/actions/workflows/ci.yml/badge.svg)](https://github.com/Juli1artha/discovery-media-player/actions/workflows/ci.yml)
17
+ [![CodeQL](https://github.com/Juli1artha/discovery-media-player/actions/workflows/codeql.yml/badge.svg)](https://github.com/Juli1artha/discovery-media-player/actions/workflows/codeql.yml)
18
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Juli1artha/discovery-media-player/badge)](https://scorecard.dev/viewer/?uri=github.com/Juli1artha/discovery-media-player)
17
19
  [![npm](https://img.shields.io/npm/v/discovery-media-player?logo=npm&color=cb3837)](https://www.npmjs.com/package/discovery-media-player)
18
20
  [![Container](https://img.shields.io/badge/ghcr.io-discovery--media--player-2496ed?logo=docker&logoColor=white)](https://github.com/Juli1artha/discovery-media-player/pkgs/container/discovery-media-player)
19
21
  [![Node](https://img.shields.io/node/v/discovery-media-player?logo=node.js&color=5fa04e)](package.json)
@@ -161,6 +163,11 @@ on an allow-listed storage origin, one explicitly configured route of your own a
161
163
  or a file under a configured local root. No credentials in URLs, no redirect following into
162
164
  your private network.
163
165
 
166
+ The project has been through repeated external audits. The reports are published in the
167
+ repository, unedited, with their follow-up ledgers — findings, fixes, and what was rejected
168
+ with its reason: see [`docs/`](docs/README.md). Fixes are traced version by version in the
169
+ [CHANGELOG](CHANGELOG.md).
170
+
164
171
  Found a hole? [`SECURITY.md`](SECURITY.md) — please do not open a public issue.
165
172
 
166
173
  ---
@@ -74,6 +74,23 @@ The three `presence*` fields report what the host has **observed**, not what it
74
74
  | `presenceStrict` | **effective** — `PLAYER_PRESENCE_STRICT` is set *and* tokens can be issued. A closed door announced over an open one would be the worse failure |
75
75
  | `presenceDurcissement` | `actif` (a hardened call came back), `degrade` (migration 0018 is missing), `inconnu` (nothing attempted in this process — **not** a green light, and process-local: another instance may have seen otherwise) |
76
76
 
77
+ ⚠️ **Before you upgrade, do not read `presenceDurcissement`.** It is a *report of execution*: on an
78
+ instance where nothing is running it says `inconnu`, which means *nobody looked* — not *the migration
79
+ is there*. A pre-flight check built on it silently passes on every idle host, and the missing
80
+ migration is then discovered at the first presentation, i.e. at the worst moment. Ask
81
+ `GET /api/doc?contract=1&schema=1` and read **`schema.durcissementBase`** instead: it asks the
82
+ database, so it answers a global fact.
83
+
84
+ | `durcissementBase` | meaning |
85
+ |---|---|
86
+ | `applique` | migration 0018 is in the database — safe to run with the strict door closed |
87
+ | `absente` | 0018 is missing: apply it **before** setting `PLAYER_PRESENCE_STRICT`, or bootstraps will be refused with `503` |
88
+ | `indetermine` | the question could not be asked — neither a yes nor a no |
89
+
90
+ The probe writes nothing: it calls the function with `p_anon_cap = 0`, so a non-existent row exits
91
+ through the *capped* branch and returns before the insert. A real-Postgres test asserts that no row
92
+ appears. A host missing 0018 is also logged once an hour, so an idle instance still finds out.
93
+
77
94
  That parameter **is** the one part of this card that needs the database, and only when you ask for
78
95
  it. `verdict` is then one of:
79
96
 
package/docs/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Documentation
2
+
3
+ Three readers, one section each. Start with the one that matches what you are trying to do —
4
+ no document assumes you have read the others.
5
+
6
+ ## You are integrating the player into an application
7
+
8
+ | Document | What it gives you |
9
+ |---|---|
10
+ | [`ARCHITECTURE.md`](ARCHITECTURE.md) | The one idea that holds the design: the core knows nothing about its host. Read this first. |
11
+ | [`API.md`](API.md) | What a host can call, and what it must implement. |
12
+ | [`HOST-CONTRACT.md`](HOST-CONTRACT.md) | The binding contract, with the dated journal of every boundary change. It ships **inside the package**: `require.resolve("discovery-media-player/contrat")`. |
13
+
14
+ ## You are running an instance
15
+
16
+ | Document | What it gives you |
17
+ |---|---|
18
+ | [`CONFIGURATION.md`](CONFIGURATION.md) | Every environment variable. An instance is described entirely by its environment — there is no configuration file, on purpose. |
19
+ | [`MIGRATIONS.md`](MIGRATIONS.md) | What happens to a database **already in service** when the player expects a newer schema. (French.) |
20
+ | [`RETENTION.md`](RETENTION.md) | The declared perimeter of data retention: every personal-data column has a written policy, and CI enforces that the list is complete. Also an export of the package: `require.resolve("discovery-media-player/retention")`. (French.) |
21
+
22
+ ## You are evaluating the project
23
+
24
+ The external audit trail is public, unedited, and kept in the state it was received —
25
+ an audit rewritten after the fact is no longer a trace. Findings and their fixes are
26
+ tracked version by version in the [CHANGELOG](../CHANGELOG.md).
27
+
28
+ | Document | What it is |
29
+ |---|---|
30
+ | [`AUDIT-2026-08-14-RAPPORT.md`](AUDIT-2026-08-14-RAPPORT.md) | First external audit, on `0.1.17`. Historical. (French.) |
31
+ | [`AUDIT-2026-08-14-SUIVI.md`](AUDIT-2026-08-14-SUIVI.md) | The follow-up ledger: done, decided-but-not-done, and rejected-with-reason. Historical. (French.) |
32
+ | [`AUDIT-2026-08-15-SECONDE-PASSE.md`](AUDIT-2026-08-15-SECONDE-PASSE.md) | Second pass, on `0.1.26` — including what the first follow-up had marked too optimistically. Historical. (French.) |
33
+
34
+ ## Work in progress
35
+
36
+ | Document | What it is |
37
+ |---|---|
38
+ | [`SPEC-MEMBRE-INJECTE.md`](SPEC-MEMBRE-INJECTE.md) | A specification sent to a host for agreement **before** the contract moves. Nothing in it is implemented. (French.) |
39
+
40
+ Public entry points are in English. Documents that remain in French — audit traces, and the
41
+ operational documents marked *(French)* above — are labelled explicitly, so nobody discovers
42
+ the language after clicking. The reasoning behind the split is at the end of the
43
+ [README](../README.md#contributing).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.119",
3
+ "version": "0.1.121",
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",
@@ -848,9 +848,32 @@ let _etatDurcissement = "inconnu";
848
848
  // démarrer n'a rien tenté, et rendre « actif » reviendrait à annoncer une garde active sur la foi
849
849
  // d'une absence d'observation. Trois états, donc — la même règle que le verdict du schéma, où « rien
850
850
  // de manquant » se lisait « tout va bien » tant qu'on ne distinguait pas « rien demandé ».
851
+ // ⚠️ UN SEUL CONSTRUCTEUR DE REFUS, PARCE QU'IL Y A DEUX CHEMINS. Le mode strict doit refuser à
852
+ // l'endroit où la RPC échoue ET à l'endroit où le mémo évite de la rappeler. Deux messages écrits
853
+ // séparément divergeraient, et c'est la divergence qui a produit le défaut : le premier chemin était
854
+ // gardé, le second non. (P1 audit externe, v0.1.120.)
855
+ function erreurDurcissementAbsent() {
856
+ const e = new Error(
857
+ "bootstrap de présence NON durci alors que PLAYER_PRESENCE_STRICT est posé : appliquez "
858
+ + "supabase/migrations/0018-bootstrap-non-usurpable.sql, ou retirez le mode strict le temps "
859
+ + "de la migration. Aucune écriture non protégée n'a été faite.",
860
+ );
861
+ e.code = "durcissement-absent";
862
+ return e;
863
+ }
864
+
851
865
  async function appelerBump(corps, durcissementVoulu) {
852
866
  const appel = (b) => PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: b });
853
867
  if (durcissementVoulu && Date.now() < _bumpSansDurcissementJusqua) {
868
+ // ⚠️ LE SECOND CHEMIN VERS LA MÊME ÉCRITURE — et c'est par là que la porte fermée se rouvrait.
869
+ // Le refus de 0.1.119 vivait dans le `catch`, donc sur le chemin où la RPC vient d'échouer. Une
870
+ // fois le mémo armé, on sort par ICI sans jamais rappeler la RPC : le premier bootstrap était
871
+ // refusé en 503, le deuxième écrivait sans contrôle pendant les 60 s suivantes.
872
+ //
873
+ // Corriger un chemin d'une opération qui en a deux ne corrige rien : ça déplace le défaut vers
874
+ // celui qu'on n'a pas regardé, et le banc reste vert parce qu'il n'éprouvait que le premier
875
+ // passage. Le régime se teste sur la RÉPÉTITION, pas sur l'appel initial.
876
+ if (PLAYER && PLAYER.config && PLAYER.config.presenceStrict) throw erreurDurcissementAbsent();
854
877
  const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
855
878
  return appel(sansDurcissement);
856
879
  }
@@ -891,15 +914,7 @@ async function appelerBump(corps, durcissementVoulu) {
891
914
  // On lève, et l'appelant rend 503 : « je n'ai pas pu vérifier », pas « c'est refusé » ni « c'est
892
915
  // écrit ». ⚠️ Seuls les BOOTSTRAPS sont concernés (durcissementVoulu) : un battement prouvé n'a
893
916
  // jamais emprunté ce chemin, donc une présentation en cours ne s'arrête pas. (Audit externe.)
894
- if (PLAYER && PLAYER.config && PLAYER.config.presenceStrict) {
895
- const refus = new Error(
896
- "bootstrap de présence NON durci alors que PLAYER_PRESENCE_STRICT est posé : appliquez "
897
- + "supabase/migrations/0018-bootstrap-non-usurpable.sql, ou retirez le mode strict le temps "
898
- + "de la migration. Aucune écriture non protégée n'a été faite.",
899
- );
900
- refus.code = "durcissement-absent";
901
- throw refus;
902
- }
917
+ if (PLAYER && PLAYER.config && PLAYER.config.presenceStrict) throw erreurDurcissementAbsent();
903
918
  const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
904
919
  return appel(sansDurcissement);
905
920
  }
@@ -1245,4 +1260,4 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
1245
1260
  module.exports = {
1246
1261
  reacteurDepuisJeton,
1247
1262
  purgerPerimees,
1248
- messagePublic, CHAMPS_PUBLICS, etatDurcissementBootstrap, cheminPieceJointe, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent , STALE_MS};
1263
+ messagePublic, CHAMPS_PUBLICS, etatDurcissementBootstrap, signatureAbsente, cheminPieceJointe, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent , STALE_MS};
package/server/schema.js CHANGED
@@ -304,7 +304,16 @@ async function vraimentSonderTout() {
304
304
  } catch {
305
305
  // On rend ce qu'on savait déjà — un manque constaté plus tôt reste un fait — mais le verdict
306
306
  // dit que cette mesure-ci n'a pas eu lieu. Taire l'un ou l'autre serait mentir d'un côté.
307
- return { ...etatDuSchema(), verdict: "indetermine" };
307
+ // ⚠️ LE CHAMP DOIT ÊTRE LÀ MÊME ICI. Le contrat annonce que `durcissementBase` vaut toujours
308
+ // l'une des trois valeurs ; ce retour anticipé le faisait DISPARAÎTRE quand la base ne répond
309
+ // pas du tout. Un hôte qui teste `durcissementBase !== "applique"` avant de déployer lisait
310
+ // alors `undefined !== "applique"` — vrai par accident, donc juste par accident. Un champ
311
+ // absent est plus dangereux qu'un champ prudent : il ne se distingue pas d'un contrat plus
312
+ // ancien. (P2 audit externe.)
313
+ return {
314
+ ...etatDuSchema(), verdict: "indetermine",
315
+ durcissementBase: "indetermine", durcissementBaseCouvre: PORTEE_DURCISSEMENT,
316
+ };
308
317
  }
309
318
  // ⚠️ LE TÉMOIN VIENT DE RÉPONDRE : tout « non » encore en cache est SUSPECT — il peut dater
310
319
  // d'une panne guérie. On le jette et on repose la question, sinon ce diagnostic rendrait la
@@ -315,6 +324,7 @@ async function vraimentSonderTout() {
315
324
  const etat = etatDuSchema();
316
325
  await ajouterSansRang(etat);
317
326
  await ajouterPresence(etat);
327
+ await ajouterDurcissement(etat);
318
328
  return etat;
319
329
  }
320
330
 
@@ -343,6 +353,69 @@ async function vraimentSonderTout() {
343
353
  // DÉCLARE la condition de validité, plutôt qu'un qu'on croirait inconditionnel. (relevé 2e hôte)
344
354
  const PLAFOND_SANS_RANG = 1000;
345
355
 
356
+ // ⚠️ 0018 EST UNE PROPRIÉTÉ DE LA BASE — ELLE DOIT SE DEMANDER À LA BASE.
357
+ //
358
+ // `presenceDurcissement` (carte) est un RAPPORT D'EXÉCUTION : il dit ce que CE processus a constaté
359
+ // en servant des bootstraps. Sur une instance au repos il rend « inconnu », et c'est juste — mais
360
+ // inutilisable comme contrôle avant déploiement, où la question est « la migration est-elle là ? ».
361
+ // Nous avions même écrit une consigne de pré-vol qui l'exigeait : un hôte la suivant à la lettre
362
+ // aurait lu « pas degrade, donc j'y vais » et découvert le refus à la première présentation, c'est-
363
+ // à-dire au pire moment. On avait bâti un champ qui refuse de se prononcer sans observation, puis
364
+ // placé ce champ au centre d'une procédure qui exige une réponse. (Relevé par le second hôte.)
365
+ //
366
+ // ⚠️ LA SONDE N'ÉCRIT RIEN, ET CE N'EST PAS UN ESPOIR : avec `p_anon_cap = 0`, une ligne inexistante
367
+ // part par la branche « capped » de 0018 (`if v_count >= p_anon_cap then return ... ; return; end if;`)
368
+ // et la fonction rend AVANT son `insert`. Le slug de sonde porte une espace — donc il ne peut jamais
369
+ // être un slug réel (contrat `^[A-Za-z0-9_-]{1,64}$`), et ne peut pas heurter une vraie présence.
370
+ // Un test de base (Postgres réel) vérifie qu'aucune ligne n'apparaît.
371
+ const SLUG_SONDE_DURCISSEMENT = "sonde durcissement";
372
+ // ⚠️ UNE SEULE DESCRIPTION, PARCE QU'IL Y A DEUX SORTIES. Le champ se pose aussi sur le retour
373
+ // ANTICIPÉ (base muette) ; deux textes écrits séparément divergeraient, et c'est exactement ce qui
374
+ // vient de se produire un cran plus haut avec le refus du mode strict.
375
+ const PORTEE_DURCISSEMENT =
376
+ "propriété de la BASE (migration 0018), globale à toutes les instances — à ne pas confondre avec "
377
+ + "presenceDurcissement, qui est ce que CE processus a constaté en servant des bootstraps. "
378
+ + "C'est ce champ-ci qu'on lit AVANT un déploiement ; « indetermine » = la question n'a pas pu "
379
+ + "être posée, ce n'est ni un oui ni un non.";
380
+
381
+ async function ajouterDurcissement(etat) {
382
+ const corps = {
383
+ p_slug: SLUG_SONDE_DURCISSEMENT, p_key: "sonde", p_ip_hash: null, p_page: 1,
384
+ p_name: "", p_avatar: "", p_is_member: false, p_is_presenter: false,
385
+ p_max_gap_ms: 0, p_anon_cap: 0, p_has_token: null, p_only_if_unclaimed: true,
386
+ };
387
+ try {
388
+ await PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: corps });
389
+ etat.durcissementBase = "applique";
390
+ } catch (erreur) {
391
+ // ⚠️ TROIS ISSUES, ET LA TROISIÈME N'EST PAS UNE DES DEUX AUTRES. Seule la signature absente
392
+ // prouve que 0018 manque ; une panne réseau ou un 500 ne prouvent RIEN, et les compter comme
393
+ // « absente » serait le défaut qu'on a mis trois versions à retirer d'ailleurs.
394
+ let absente = false;
395
+ try { absente = require("./presentations.js").signatureAbsente(erreur); } catch { /* module absent */ }
396
+ etat.durcissementBase = absente ? "absente" : "indetermine";
397
+ // ⚠️ ET ON LE DIT, PAS SEULEMENT DANS LA CARTE. Une garde de ce dépôt refuse qu'une écriture soit
398
+ // rattrapée en silence, et elle a raison ici pour une raison qu'elle ne pouvait pas connaître :
399
+ // c'est ce journal qui prévient un hôte à qui 0018 manque SANS qu'aucune présentation ne tourne
400
+ // — exactement le cas où le rapport d'exécution reste muet, et où l'exploitant ne saura rien
401
+ // avant la première présentation. Une fois par heure : la carte est publique, donc appelable en
402
+ // boucle, et un journal sans frein deviendrait une arme.
403
+ try {
404
+ if (await PLAYER.limits.allow("schema:durcissement-absent", 1, 3600)) {
405
+ PLAYER.errors.capture(new Error(
406
+ absente
407
+ ? "migration 0018-bootstrap-non-usurpable.sql ABSENTE : les bootstraps de présence ne "
408
+ + "sont pas contrôlés. N'armez pas PLAYER_PRESENCE_STRICT avant de l'appliquer — il "
409
+ + "refuserait alors les bootstraps en 503."
410
+ : "sonde de durcissement (0018) sans réponse exploitable : ni confirmée, ni infirmée — "
411
+ + ((erreur && erreur.message) || erreur),
412
+ ), { route: "schema" });
413
+ }
414
+ } catch { /* jamais bloquant */ }
415
+ }
416
+ etat.durcissementBaseCouvre = PORTEE_DURCISSEMENT;
417
+ }
418
+
346
419
  async function ajouterSansRang(etat) {
347
420
  const r = reponses.get("doc_presentation_messages.mod_seq");
348
421
  if (!r || !r.present) return;