discovery-media-player 0.1.118 → 0.1.120

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
  ---
@@ -35,6 +35,9 @@ need.
35
35
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
36
36
  "separateIssuer": true,
37
37
  "internalStrict": true,
38
+ "presenceStrict": true,
39
+ "presenceJetons": true,
40
+ "presenceDurcissement": "inconnu",
38
41
  "retentionSweep": false,
39
42
  "hostShare": true,
40
43
  "hostMail": true,
@@ -63,6 +66,31 @@ different question when you want a real answer:
63
66
  GET /api/doc?contract=1&schema=1
64
67
  ```
65
68
 
69
+ The three `presence*` fields report what the host has **observed**, not what it is configured to do:
70
+
71
+ | field | meaning |
72
+ |---|---|
73
+ | `presenceJetons` | measured — the host actually signed a throwaway token, so `PLAYER_PRESENCE_SECRET` works |
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
+ | `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
+
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
+
66
94
  That parameter **is** the one part of this card that needs the database, and only when you ask for
67
95
  it. `verdict` is then one of:
68
96
 
package/docs/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Documentation
2
+
3
+ Nine documents, three readers. Start with the one that matches what you are trying to do —
4
+ none of them 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
+ Documents marked *(French)* are internal working documents; everything an integrator or an
41
+ operator needs day to day is in English. The reasoning behind that split is at the end of the
42
+ [README](../README.md#contributing).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.118",
3
+ "version": "0.1.120",
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",
@@ -6,7 +6,7 @@ const crypto = require("crypto");
6
6
  // ⚠️ Le contexte est REÇU, pas construit. Ce module ne doit pas savoir d'où il vient : c'est ce
7
7
  // qui lui permettra de partir dans le dépôt du player sans emporter le studio avec lui.
8
8
  let PLAYER = null;
9
- function init(ctx) { PLAYER = ctx; _bumpSansDurcissementJusqua = 0; _avertRpcPresence = false; _dernierSuccesDurcissement = 0; _dernierEchecDurcissement = 0; }
9
+ function init(ctx) { PLAYER = ctx; _bumpSansDurcissementJusqua = 0; _avertRpcPresence = false; _etatDurcissement = "inconnu"; }
10
10
 
11
11
  /**
12
12
  * L'état OBSERVÉ du durcissement des bootstraps — pas sa configuration.
@@ -23,14 +23,17 @@ function init(ctx) { PLAYER = ctx; _bumpSansDurcissementJusqua = 0; _avertRpcPre
23
23
  */
24
24
  function etatDurcissementBootstrap() {
25
25
  if (Date.now() < _bumpSansDurcissementJusqua) return "degrade";
26
- // ⚠️ « actif » EXIGE UN SUCCÈS PLUS RÉCENT QUE LE DERNIER ÉCHEC — pas un simple drapeau. Deux
27
- // défauts tenaient dans la version précédente (relevés par le second hôte) : (1) le drapeau était
28
- // posé AVANT l'appel, donc il enregistrait une TENTATIVE et non un succès ; (2) l'expiration du
29
- // mémo ne rétrogradait pas l'état, elle le PROMOUVAIT — un hôte sans 0018 rendait « degrade »
30
- // 60 s, puis « actif », sur la foi d'une tentative dont la seule chose prouvée était l'échec.
31
- // Comparer les deux instants traite les deux d'un coup : le repli d'une preuve périmée est
32
- // l'IGNORANCE, jamais la confiance.
33
- return _dernierSuccesDurcissement > _dernierEchecDurcissement ? "actif" : "inconnu";
26
+ // ⚠️ UN ÉTAT EXPLICITE, PAS UNE COMPARAISON D'INSTANTS. La version précédente comparait le dernier
27
+ // succès au dernier échec — correct sur le fond, mais deux réponses concurrentes qui se terminent
28
+ // dans la MÊME milliseconde rendaient les deux instants égaux, et l'égalité tombait du côté
29
+ // « inconnu » : jamais un faux « actif », mais un faux négatif possible. Le dernier mot observé
30
+ // s'écrit ; il n'a pas à se déduire d'une horloge dont la résolution n'est pas garantie.
31
+ // (Simplification proposée par l'audit externe.)
32
+ //
33
+ // ⚠️ ET L'EXPIRATION NE PROMEUT TOUJOURS PAS. Une preuve NÉGATIVE périmée retombe sur l'ignorance,
34
+ // jamais sur la confiance : c'était le défaut de 0.1.115, et le remède ne doit pas le réintroduire
35
+ // en chemin. Un « oui » n'expire pas — une fonction ne disparaît pas toute seule.
36
+ return _etatDurcissement === "degrade" ? "inconnu" : _etatDurcissement;
34
37
  }
35
38
 
36
39
 
@@ -836,12 +839,11 @@ function signatureAbsente(erreur) {
836
839
  // silence. Un « oui » (la signature existe) n'a pas besoin d'expirer : une fonction ne disparaît pas.
837
840
  let _bumpSansDurcissementJusqua = 0;
838
841
  const MEMO_SANS_DURCISSEMENT_MS = 60 * 1000;
839
- // ⚠️ DEUX INSTANTS, PAS UN DRAPEAU — parce que l'ORDRE est ce qui distingue « réparé » de « cassé ».
840
- // Un booléen « on a essayé » ne peut pas dire si le dernier mot fut un succès ou un échec, et c'est
841
- // exactement le mot qui décide. Un succès plus récent que le dernier échec ⇒ la signature existe
842
- // (une fonction ne disparaît pas toute seule) ; un échec plus récent, mémo expiré ⇒ on ne sait plus.
843
- let _dernierSuccesDurcissement = 0;
844
- let _dernierEchecDurcissement = 0;
842
+ // ⚠️ LE DERNIER MOT OBSERVÉ, ÉCRIT — pas déduit. Un booléen « on a essayé » ne pouvait pas dire si
843
+ // ce mot fut un succès ou un échec, et c'est lui qui décide. Comparer deux instants le disait, au
844
+ // prix d'une égalité possible à la milliseconde. On écrit donc l'état : « actif » (un appel durci
845
+ // est revenu), « degrade » (la signature manquait), « inconnu » (rien constaté dans ce processus).
846
+ let _etatDurcissement = "inconnu";
845
847
  // ⚠️ A-T-ON SEULEMENT ESSAYÉ ? « Pas dégradé » n'est pas « vérifié » : un processus qui vient de
846
848
  // démarrer n'a rien tenté, et rendre « actif » reviendrait à annoncer une garde active sur la foi
847
849
  // d'une absence d'observation. Trois états, donc — la même règle que le verdict du schéma, où « rien
@@ -857,7 +859,7 @@ async function appelerBump(corps, durcissementVoulu) {
857
859
  // ⚠️ ON NE MARQUE LE SUCCÈS QU'ICI — l'appel est REVENU, donc la signature à 12 arguments existe.
858
860
  // Un rendu `{ok:false, usurpe:true}` compte : seule 0018 sait répondre ça. Ce qui se mesure est
859
861
  // le RETOUR, jamais l'intention de partir.
860
- if (durcissementVoulu) _dernierSuccesDurcissement = Date.now();
862
+ if (durcissementVoulu) _etatDurcissement = "actif";
861
863
  return r;
862
864
  } catch (erreur) {
863
865
  if (!durcissementVoulu) throw erreur; // rien à retirer : l'échec est réel
@@ -866,8 +868,8 @@ async function appelerBump(corps, durcissementVoulu) {
866
868
  if (!signatureAbsente(erreur)) throw erreur;
867
869
  // La signature à 12 arguments n'existe pas (0018 non appliquée) : on retire l'argument et on
868
870
  // réessaie. Le durcissement n'est alors PAS appliqué — et ça se dit, une fois, plus bas.
869
- _dernierEchecDurcissement = Date.now();
870
- _bumpSansDurcissementJusqua = _dernierEchecDurcissement + MEMO_SANS_DURCISSEMENT_MS;
871
+ _etatDurcissement = "degrade";
872
+ _bumpSansDurcissementJusqua = Date.now() + MEMO_SANS_DURCISSEMENT_MS;
871
873
  // ⚠️ CE QUI DISPARAÎT SE DIT. Le durcissement demandé n'est pas appliqué : sous
872
874
  // PLAYER_PRESENCE_STRICT, la porte se fermerait sur les battements legacy tout en laissant un
873
875
  // bootstrap auto-déclaré s'emparer d'une présence réclamée — c'est-à-dire une fermeture qui
@@ -879,6 +881,25 @@ async function appelerBump(corps, durcissementVoulu) {
879
881
  + "de jeton — n'armez pas PLAYER_PRESENCE_STRICT avant de l'avoir appliquée.",
880
882
  ), { route: "present-attend" });
881
883
  } catch { /* jamais bloquant */ }
884
+ // ⚠️ SOUS PORTE FERMÉE, ON NE SE REPLIE PAS — ON REFUSE. Le repli retire le contrôle
885
+ // anti-usurpation et écrit quand même : acceptable pendant la TRANSITION (mieux vaut une
886
+ // présence enregistrée sans contrôle qu'une présentation cassée), inacceptable une fois
887
+ // PLAYER_PRESENCE_STRICT posé. À ce moment-là l'exploitant a déclaré que seule une identité
888
+ // prouvée entre ; laisser un bootstrap auto-déclaré s'emparer d'une ligne réclamée serait une
889
+ // fermeture qui rassure sans protéger — précisément ce que la porte prétend empêcher.
890
+ //
891
+ // On lève, et l'appelant rend 503 : « je n'ai pas pu vérifier », pas « c'est refusé » ni « c'est
892
+ // écrit ». ⚠️ Seuls les BOOTSTRAPS sont concernés (durcissementVoulu) : un battement prouvé n'a
893
+ // 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
+ }
882
903
  const { p_only_if_unclaimed: _retire, ...sansDurcissement } = corps;
883
904
  return appel(sansDurcissement);
884
905
  }
@@ -1224,4 +1245,4 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
1224
1245
  module.exports = {
1225
1246
  reacteurDepuisJeton,
1226
1247
  purgerPerimees,
1227
- 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};
1248
+ 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
@@ -315,6 +315,7 @@ async function vraimentSonderTout() {
315
315
  const etat = etatDuSchema();
316
316
  await ajouterSansRang(etat);
317
317
  await ajouterPresence(etat);
318
+ await ajouterDurcissement(etat);
318
319
  return etat;
319
320
  }
320
321
 
@@ -343,6 +344,65 @@ async function vraimentSonderTout() {
343
344
  // DÉCLARE la condition de validité, plutôt qu'un qu'on croirait inconditionnel. (relevé 2e hôte)
344
345
  const PLAFOND_SANS_RANG = 1000;
345
346
 
347
+ // ⚠️ 0018 EST UNE PROPRIÉTÉ DE LA BASE — ELLE DOIT SE DEMANDER À LA BASE.
348
+ //
349
+ // `presenceDurcissement` (carte) est un RAPPORT D'EXÉCUTION : il dit ce que CE processus a constaté
350
+ // en servant des bootstraps. Sur une instance au repos il rend « inconnu », et c'est juste — mais
351
+ // inutilisable comme contrôle avant déploiement, où la question est « la migration est-elle là ? ».
352
+ // Nous avions même écrit une consigne de pré-vol qui l'exigeait : un hôte la suivant à la lettre
353
+ // aurait lu « pas degrade, donc j'y vais » et découvert le refus à la première présentation, c'est-
354
+ // à-dire au pire moment. On avait bâti un champ qui refuse de se prononcer sans observation, puis
355
+ // placé ce champ au centre d'une procédure qui exige une réponse. (Relevé par le second hôte.)
356
+ //
357
+ // ⚠️ LA SONDE N'ÉCRIT RIEN, ET CE N'EST PAS UN ESPOIR : avec `p_anon_cap = 0`, une ligne inexistante
358
+ // part par la branche « capped » de 0018 (`if v_count >= p_anon_cap then return ... ; return; end if;`)
359
+ // et la fonction rend AVANT son `insert`. Le slug de sonde porte une espace — donc il ne peut jamais
360
+ // être un slug réel (contrat `^[A-Za-z0-9_-]{1,64}$`), et ne peut pas heurter une vraie présence.
361
+ // Un test de base (Postgres réel) vérifie qu'aucune ligne n'apparaît.
362
+ const SLUG_SONDE_DURCISSEMENT = "sonde durcissement";
363
+
364
+ async function ajouterDurcissement(etat) {
365
+ const corps = {
366
+ p_slug: SLUG_SONDE_DURCISSEMENT, p_key: "sonde", p_ip_hash: null, p_page: 1,
367
+ p_name: "", p_avatar: "", p_is_member: false, p_is_presenter: false,
368
+ p_max_gap_ms: 0, p_anon_cap: 0, p_has_token: null, p_only_if_unclaimed: true,
369
+ };
370
+ try {
371
+ await PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: corps });
372
+ etat.durcissementBase = "applique";
373
+ } catch (erreur) {
374
+ // ⚠️ TROIS ISSUES, ET LA TROISIÈME N'EST PAS UNE DES DEUX AUTRES. Seule la signature absente
375
+ // prouve que 0018 manque ; une panne réseau ou un 500 ne prouvent RIEN, et les compter comme
376
+ // « absente » serait le défaut qu'on a mis trois versions à retirer d'ailleurs.
377
+ let absente = false;
378
+ try { absente = require("./presentations.js").signatureAbsente(erreur); } catch { /* module absent */ }
379
+ etat.durcissementBase = absente ? "absente" : "indetermine";
380
+ // ⚠️ ET ON LE DIT, PAS SEULEMENT DANS LA CARTE. Une garde de ce dépôt refuse qu'une écriture soit
381
+ // rattrapée en silence, et elle a raison ici pour une raison qu'elle ne pouvait pas connaître :
382
+ // c'est ce journal qui prévient un hôte à qui 0018 manque SANS qu'aucune présentation ne tourne
383
+ // — exactement le cas où le rapport d'exécution reste muet, et où l'exploitant ne saura rien
384
+ // avant la première présentation. Une fois par heure : la carte est publique, donc appelable en
385
+ // boucle, et un journal sans frein deviendrait une arme.
386
+ try {
387
+ if (await PLAYER.limits.allow("schema:durcissement-absent", 1, 3600)) {
388
+ PLAYER.errors.capture(new Error(
389
+ absente
390
+ ? "migration 0018-bootstrap-non-usurpable.sql ABSENTE : les bootstraps de présence ne "
391
+ + "sont pas contrôlés. N'armez pas PLAYER_PRESENCE_STRICT avant de l'appliquer — il "
392
+ + "refuserait alors les bootstraps en 503."
393
+ : "sonde de durcissement (0018) sans réponse exploitable : ni confirmée, ni infirmée — "
394
+ + ((erreur && erreur.message) || erreur),
395
+ ), { route: "schema" });
396
+ }
397
+ } catch { /* jamais bloquant */ }
398
+ }
399
+ etat.durcissementBaseCouvre =
400
+ "propriété de la BASE (migration 0018), globale à toutes les instances — à ne pas confondre avec "
401
+ + "presenceDurcissement, qui est ce que CE processus a constaté en servant des bootstraps. "
402
+ + "C'est ce champ-ci qu'on lit AVANT un déploiement ; « indetermine » = la question n'a pas pu "
403
+ + "être posée, ce n'est ni un oui ni un non.";
404
+ }
405
+
346
406
  async function ajouterSansRang(etat) {
347
407
  const r = reponses.get("doc_presentation_messages.mod_seq");
348
408
  if (!r || !r.present) return;