discovery-media-player 0.1.6 → 0.1.8
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 +19 -3
- package/context/standalone.js +59 -4
- package/context/storage.js +12 -1
- package/dist/bridge.d.ts +48 -0
- package/dist/bridge.js +116 -0
- package/dist/package.json +3 -0
- package/docs/HOST-CONTRACT.md +158 -0
- package/package.json +12 -7
- package/server/handler.js +34 -2
- package/CONTRAT.md +0 -645
- package/bin/__tests__/serve.test.js +0 -130
- package/context/__tests__/storage.test.js +0 -99
- package/server/__tests__/audienceEtat.test.js +0 -90
- package/server/__tests__/encadrement.test.js +0 -111
- package/server/__tests__/plateforme.test.js +0 -82
- package/src/__tests__/bridge.test.ts +0 -108
- package/src/__tests__/chat.test.ts +0 -138
- package/src/__tests__/live.test.ts +0 -211
- package/src/__tests__/presentation-content.test.ts +0 -132
- package/src/__tests__/presentation-state.test.ts +0 -81
- package/src/__tests__/tracking.test.ts +0 -217
- package/src/__tests__/viewer.test.ts +0 -133
- package/src/bridge.ts +0 -141
- package/src/chat.ts +0 -103
- package/src/index.ts +0 -14
- package/src/live.ts +0 -225
- package/src/presentation-content.ts +0 -109
- package/src/presentation-state.ts +0 -93
- package/src/tracking.ts +0 -250
- package/src/viewer.ts +0 -109
package/README.md
CHANGED
|
@@ -14,9 +14,10 @@ and live presentation — for teams who would rather not hand their commercial d
|
|
|
14
14
|
to a third-party SaaS.
|
|
15
15
|
|
|
16
16
|
[](https://github.com/Juli1artha/discovery-media-player/actions/workflows/ci.yml)
|
|
17
|
-
[](https://www.npmjs.com/package/discovery-media-player)
|
|
18
|
+
[](https://github.com/Juli1artha/discovery-media-player/pkgs/container/discovery-media-player)
|
|
19
|
+
[](package.json)
|
|
20
|
+
[](LICENSE)
|
|
20
21
|
|
|
21
22
|
<br>
|
|
22
23
|
|
|
@@ -79,6 +80,21 @@ Two populations are never merged: a prospect reading your proposal and a colleag
|
|
|
79
80
|
it in-house produce different records. Mixing them makes "this prospect read for 12 minutes"
|
|
80
81
|
a lie, which is worse than having no number at all.
|
|
81
82
|
|
|
83
|
+
### What it displays
|
|
84
|
+
|
|
85
|
+
| Format | Status | What that means |
|
|
86
|
+
|---|:---:|---|
|
|
87
|
+
| **PDF** | ✅ | The format the player is built around. Rendered by pdf.js, progressive — the first page shows before the file has finished arriving. Per-page reading time, furthest page reached, page-level presenter sync. |
|
|
88
|
+
| **Images** — `.png` `.jpg` `.jpeg` `.webp` `.gif` `.avif` | ✅ | Displayed, zoomable, tracked as a single page. Total reading time is real; there is no per-page breakdown because there are no pages. |
|
|
89
|
+
| **Video** | ❌ | Not displayed. A document can *carry* a presenter video during a live presentation, but a video file is not something you can open as a document. |
|
|
90
|
+
| **HTML** | ❌ | Deliberately. Displaying arbitrary HTML means executing someone's script in your instance's origin, next to sessions and analytics. The same reason `.svg` is refused. |
|
|
91
|
+
| **Office** (`.docx`, `.pptx`, …) | ❌ | Convert to PDF before sending. Nothing in the player renders them, and pretending otherwise would show an empty page. |
|
|
92
|
+
|
|
93
|
+
An unsupported file is never a security question either. A relayed file opens on the *player's*
|
|
94
|
+
origin — the domain holding sessions and analytics — so anything a browser would **render** rather
|
|
95
|
+
than download (SVG, HTML, XML) is served inert: generic type, forced download, `nosniff`. It stays
|
|
96
|
+
retrievable; it cannot execute.
|
|
97
|
+
|
|
82
98
|
---
|
|
83
99
|
|
|
84
100
|
## How it fits your application
|
package/context/standalone.js
CHANGED
|
@@ -15,9 +15,31 @@
|
|
|
15
15
|
|
|
16
16
|
const storage = require("./storage");
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Retire les barres finales d'une base d'URL.
|
|
20
|
+
*
|
|
21
|
+
* Sans expression régulière. L'analyse statique a signalé `.replace(/\/+$/, "")` dès son premier
|
|
22
|
+
* passage, sur les deux lignes que je venais d'écrire.
|
|
23
|
+
*
|
|
24
|
+
* Mesuré avant de corriger : V8 traite ce motif en temps linéaire (200 000 barres, moins d'une
|
|
25
|
+
* milliseconde), et l'entrée vient de toute façon d'une variable d'environnement — donc de
|
|
26
|
+
* l'exploitant, pas d'un visiteur. Ce n'était donc PAS une lenteur réelle ici.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ On change quand même, pour une raison qui n'est pas celle de l'alerte : cette forme se
|
|
29
|
+
* recopie. Elle est déjà à cinq endroits du dépôt, et la prochaine copie tombera peut-être sur
|
|
30
|
+
* une entrée venue du dehors, dans un moteur moins clément. Une boucle qui ne peut pas revenir en
|
|
31
|
+
* arrière retire la classe, pas l'occurrence — et évite d'apprendre à ignorer l'alerte.
|
|
32
|
+
*/
|
|
33
|
+
function sansBarreFinale(valeur) {
|
|
34
|
+
const s = String(valeur || "");
|
|
35
|
+
let fin = s.length;
|
|
36
|
+
while (fin > 0 && s.charCodeAt(fin - 1) === 47) fin--;
|
|
37
|
+
return s.slice(0, fin);
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
/** Client REST minimal (PostgREST). Absent de configuration ⇒ chaque appel échoue franchement. */
|
|
19
41
|
function creerDb(env) {
|
|
20
|
-
const url =
|
|
42
|
+
const url = sansBarreFinale(env.SUPABASE_URL);
|
|
21
43
|
const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
|
|
22
44
|
|
|
23
45
|
async function request(chemin, options = {}) {
|
|
@@ -151,11 +173,44 @@ function createStandaloneContext(env = process.env) {
|
|
|
151
173
|
mail: { async send() { return null; } },
|
|
152
174
|
|
|
153
175
|
identity: {
|
|
154
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Vérifie un jeton auprès de Supabase Auth. Sans émetteur : personne n'est authentifié.
|
|
178
|
+
*
|
|
179
|
+
* ⚠️ LA BASE DU PLAYER ET L'ÉMETTEUR DES JETONS SONT DEUX CHOSES DIFFÉRENTES.
|
|
180
|
+
*
|
|
181
|
+
* `SUPABASE_URL` servait les deux rôles. Vrai tant que le player et son application
|
|
182
|
+
* partagent un déploiement — et faux par construction dès qu'une instance est séparée : la
|
|
183
|
+
* base appartient au player, l'identité appartient à l'hôte. Les membres de l'hôte
|
|
184
|
+
* recevaient donc des jetons émis par un projet, vérifiés contre un autre : toute la moitié
|
|
185
|
+
* « membre » de la surface (diffusion, statistiques, présentations authentifiées) était
|
|
186
|
+
* hors d'atteinte. Signalé par le second hôte, c'est la troisième hypothèse de cette forme
|
|
187
|
+
* en deux jours — elles ne se voient qu'en exerçant la séparation.
|
|
188
|
+
*
|
|
189
|
+
* `PLAYER_AUTH_URL` désigne donc l'émetteur, et `SUPABASE_URL` reste la base. Absente, on
|
|
190
|
+
* retombe sur `SUPABASE_URL` : une instance où les deux coïncident ne change pas d'un
|
|
191
|
+
* caractère.
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ ET LA CLÉ NE RETOMBE PAS, ELLE. Le repli historique allait jusqu'à
|
|
194
|
+
* `SUPABASE_SERVICE_ROLE_KEY` — la clé maîtresse de NOTRE base. Tant que l'émetteur était
|
|
195
|
+
* notre propre projet, c'était sans conséquence ; vers un émetteur tiers, ce serait
|
|
196
|
+
* l'envoyer à un serveur qui n'a rien à en faire. Un émetteur distinct exige donc sa propre
|
|
197
|
+
* clé publiable, et son absence se dit au lieu de se replier.
|
|
198
|
+
*/
|
|
155
199
|
async verifyToken(authorization) {
|
|
156
200
|
const jeton = String(authorization || "").replace(/^Bearer\s+/i, "").trim();
|
|
157
|
-
const
|
|
158
|
-
const
|
|
201
|
+
const emetteur = sansBarreFinale(env.PLAYER_AUTH_URL);
|
|
202
|
+
const base = sansBarreFinale(env.SUPABASE_URL);
|
|
203
|
+
|
|
204
|
+
const url = emetteur || base;
|
|
205
|
+
const cle = emetteur
|
|
206
|
+
? String(env.PLAYER_AUTH_KEY || "")
|
|
207
|
+
: String(env.SUPABASE_PUBLISHABLE_KEY || env.SUPABASE_SERVICE_ROLE_KEY || "");
|
|
208
|
+
|
|
209
|
+
if (emetteur && !cle) {
|
|
210
|
+
// Le refus silencieux est le piège de cette configuration : sans clé, chaque membre est
|
|
211
|
+
// simplement « non authentifié », ce qui ressemble à un droit manquant. On le dit.
|
|
212
|
+
try { journal.capture(new Error("PLAYER_AUTH_URL est configurée sans PLAYER_AUTH_KEY : aucun jeton ne peut être vérifié"), {}); } catch { /* ignore */ }
|
|
213
|
+
}
|
|
159
214
|
if (!jeton || !url || !cle) return null;
|
|
160
215
|
try {
|
|
161
216
|
const r = await fetch(`${url}/auth/v1/user`, {
|
package/context/storage.js
CHANGED
|
@@ -120,9 +120,20 @@ function resolveLocal(candidate, root) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// ⚠️ PAS DE `.svg`, ET C'EST DÉLIBÉRÉ — deux raisons qui pointent dans le même sens.
|
|
124
|
+
//
|
|
125
|
+
// 1. SÉCURITÉ. Un SVG est un document exécutable : servi `image/svg+xml` en ligne, il s'ouvre sur
|
|
126
|
+
// L'ORIGINE DU PLAYER et son script s'exécute avec elle. La réponse de streaming ne porte pas
|
|
127
|
+
// de CSP — c'est un fichier, pas une page. Quiconque pourrait déposer un SVG dans une source
|
|
128
|
+
// autorisée obtiendrait donc du script sur le domaine qui sert les documents.
|
|
129
|
+
// 2. COHÉRENCE. `isImageDocument()` ne reconnaît pas `.svg` : la visionneuse l'envoyait à pdf.js,
|
|
130
|
+
// qui rendait un écran blanc. Le format était donc servi sans être affichable — le pire des
|
|
131
|
+
// deux mondes.
|
|
132
|
+
//
|
|
133
|
+
// Les formats affichables sont ceux de la matrice du README : PDF et images bitmap.
|
|
123
134
|
const TYPES = {
|
|
124
135
|
".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
125
|
-
".gif": "image/gif", ".webp": "image/webp", ".
|
|
136
|
+
".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
|
|
126
137
|
};
|
|
127
138
|
|
|
128
139
|
/**
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Messages émis par le player vers l'application hôte. */
|
|
2
|
+
export type PlayerMessage = {
|
|
3
|
+
type: "close";
|
|
4
|
+
} | {
|
|
5
|
+
type: "share";
|
|
6
|
+
} | {
|
|
7
|
+
type: "embed-ready";
|
|
8
|
+
} | {
|
|
9
|
+
type: "embed-denied";
|
|
10
|
+
reason: string;
|
|
11
|
+
} | {
|
|
12
|
+
type: "present-left";
|
|
13
|
+
} | {
|
|
14
|
+
type: "present-denied";
|
|
15
|
+
} | {
|
|
16
|
+
type: "present-invite";
|
|
17
|
+
slug: string;
|
|
18
|
+
} | {
|
|
19
|
+
type: "present-handover";
|
|
20
|
+
slug: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: "present-switch";
|
|
23
|
+
slug: string;
|
|
24
|
+
};
|
|
25
|
+
/** Messages émis par l'application hôte vers le player. */
|
|
26
|
+
export type HostMessage = {
|
|
27
|
+
type: "handover-done";
|
|
28
|
+
};
|
|
29
|
+
type WireMessage = {
|
|
30
|
+
type: string;
|
|
31
|
+
slug?: string;
|
|
32
|
+
reason?: string;
|
|
33
|
+
};
|
|
34
|
+
/** Forme transportée sur le fil (préfixée), pour un message typé. */
|
|
35
|
+
export declare function toWire(msg: PlayerMessage | HostMessage): WireMessage;
|
|
36
|
+
/** Côté HÔTE : lit un message reçu du player. `null` si ce n'en est pas un. */
|
|
37
|
+
export declare function parsePlayerMessage(data: unknown): PlayerMessage | null;
|
|
38
|
+
/** Côté PLAYER : lit un message reçu de l'hôte. `null` si ce n'en est pas un. */
|
|
39
|
+
export declare function parseHostMessage(data: unknown): HostMessage | null;
|
|
40
|
+
/** Côté PLAYER : envoie un message à l'application hôte. */
|
|
41
|
+
export declare function sendToHost(msg: PlayerMessage, target?: Window | null): void;
|
|
42
|
+
/** Côté HÔTE : envoie un message au player. */
|
|
43
|
+
export declare function sendToPlayer(frame: HTMLIFrameElement | null | undefined, msg: HostMessage): void;
|
|
44
|
+
/** Côté HÔTE : écoute les messages du player. Renvoie la fonction de désabonnement. */
|
|
45
|
+
export declare function onPlayerMessage(cb: (msg: PlayerMessage) => void, win?: Window): () => void;
|
|
46
|
+
/** Côté PLAYER : écoute les messages de l'hôte. Renvoie la fonction de désabonnement. */
|
|
47
|
+
export declare function onHostMessage(cb: (msg: HostMessage) => void, win?: Window): () => void;
|
|
48
|
+
export {};
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ CE FICHIER EST SOUS MIT, pas AGPL — délibérément, et c'est le seul.
|
|
4
|
+
// C'est le contrat que l'application hôte doit importer pour parler au player. Le placer sous la
|
|
5
|
+
// licence du cœur obligerait un hôte à ouvrir son propre code pour avoir le droit d'échanger dix
|
|
6
|
+
// messages avec une iframe : la licence deviendrait un péage à l'intégration, ce qui n'est pas ce
|
|
7
|
+
// qu'on protège. On protège le player, pas ceux qui s'y branchent.
|
|
8
|
+
// Contrat postMessage entre le PLAYER (iframe) et l'APPLICATION HÔTE qui l'affiche.
|
|
9
|
+
//
|
|
10
|
+
// Le player vit dans une iframe : il possède le document, la barre d'outils et la présentation,
|
|
11
|
+
// mais tout ce qui exige le jeton de session de l'hôte (inviter l'équipe, passer la main, ouvrir
|
|
12
|
+
// la modale de partage) doit remonter à l'application. Ce fichier est le SEUL endroit où le
|
|
13
|
+
// format de ces messages est décrit — les deux côtés l'importent.
|
|
14
|
+
//
|
|
15
|
+
// Il était auparavant écrit à la main des deux côtés : dix types de messages dupliqués, non
|
|
16
|
+
// typés, validés par des `String(d.slug || "")` sans borne. Deux bugs en sont venus (validation
|
|
17
|
+
// d'origine trop stricte qui bloquait la réception en production ; fermeture du viewer avant le
|
|
18
|
+
// retrait de présence, qui laissait un participant fantôme).
|
|
19
|
+
//
|
|
20
|
+
// VALIDATION PAR TYPE, PAS PAR ORIGINE — délibéré. Un contrôle strict de `e.origin` bloquait la
|
|
21
|
+
// réception en production (l'iframe et l'app ne partagent pas toujours la même origine perçue).
|
|
22
|
+
// Les actions transportées sont inoffensives : elles ouvrent une modale ou ferment une vue, et
|
|
23
|
+
// tout ce qui touche aux données est revérifié côté serveur avec le JWT. Le `slug` est en
|
|
24
|
+
// revanche borné ici (charset + longueur) : c'est la seule donnée qui traverse la frontière.
|
|
25
|
+
/** Préfixe historique des messages sur le fil. Neutralisable d'un seul endroit le jour de l'ouverture. */
|
|
26
|
+
const WIRE_PREFIX = "3dd-doc-";
|
|
27
|
+
/** Slugs de partage et de présentation : `randomBytes(9).toString("base64url")` → 12 caractères. */
|
|
28
|
+
const SLUG_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
29
|
+
const PLAYER_PLAIN = ["close", "share", "embed-ready", "present-left", "present-denied"];
|
|
30
|
+
const PLAYER_WITH_REASON = ["embed-denied"];
|
|
31
|
+
const PLAYER_WITH_SLUG = ["present-invite", "present-handover", "present-switch"];
|
|
32
|
+
const HOST_PLAIN = ["handover-done"];
|
|
33
|
+
/** Forme transportée sur le fil (préfixée), pour un message typé. */
|
|
34
|
+
export function toWire(msg) {
|
|
35
|
+
const wire = { type: WIRE_PREFIX + msg.type };
|
|
36
|
+
if ("slug" in msg && msg.slug)
|
|
37
|
+
wire.slug = msg.slug;
|
|
38
|
+
if ("reason" in msg && msg.reason)
|
|
39
|
+
wire.reason = String(msg.reason).slice(0, 40);
|
|
40
|
+
return wire;
|
|
41
|
+
}
|
|
42
|
+
function parse(data, plain, withSlug) {
|
|
43
|
+
if (!data || typeof data !== "object")
|
|
44
|
+
return null;
|
|
45
|
+
const raw = data.type;
|
|
46
|
+
if (typeof raw !== "string" || !raw.startsWith(WIRE_PREFIX))
|
|
47
|
+
return null;
|
|
48
|
+
const type = raw.slice(WIRE_PREFIX.length);
|
|
49
|
+
if (plain.includes(type))
|
|
50
|
+
return { type };
|
|
51
|
+
if (PLAYER_WITH_REASON.includes(type)) {
|
|
52
|
+
const reason = data.reason;
|
|
53
|
+
// Motif borné et sans surprise : il traverse une frontière et finit parfois à l'écran.
|
|
54
|
+
return { type, reason: /^[a-z-]{1,40}$/.test(String(reason || "")) ? String(reason) : "unknown" };
|
|
55
|
+
}
|
|
56
|
+
if (!withSlug.includes(type))
|
|
57
|
+
return null;
|
|
58
|
+
const slug = data.slug;
|
|
59
|
+
// Un message à slug SANS slug valide est rejeté : mieux vaut ne rien ouvrir qu'ouvrir sur du vide.
|
|
60
|
+
if (typeof slug !== "string" || !SLUG_RE.test(slug))
|
|
61
|
+
return null;
|
|
62
|
+
return { type, slug };
|
|
63
|
+
}
|
|
64
|
+
/** Côté HÔTE : lit un message reçu du player. `null` si ce n'en est pas un. */
|
|
65
|
+
export function parsePlayerMessage(data) {
|
|
66
|
+
return parse(data, PLAYER_PLAIN, PLAYER_WITH_SLUG);
|
|
67
|
+
}
|
|
68
|
+
/** Côté PLAYER : lit un message reçu de l'hôte. `null` si ce n'en est pas un. */
|
|
69
|
+
export function parseHostMessage(data) {
|
|
70
|
+
return parse(data, HOST_PLAIN, []);
|
|
71
|
+
}
|
|
72
|
+
// ── Runtime ────────────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// Tout est en `try` : un player qui ne parvient pas à parler à son hôte doit continuer à afficher
|
|
74
|
+
// le document. Perdre le bouton « Partager » est ennuyeux, perdre le document est inacceptable.
|
|
75
|
+
/** Côté PLAYER : envoie un message à l'application hôte. */
|
|
76
|
+
export function sendToHost(msg, target) {
|
|
77
|
+
try {
|
|
78
|
+
const win = target || (typeof window !== "undefined" ? window.parent : null);
|
|
79
|
+
if (win)
|
|
80
|
+
win.postMessage(toWire(msg), "*");
|
|
81
|
+
}
|
|
82
|
+
catch { /* l'hôte est peut-être absent (page publique) : sans effet */ }
|
|
83
|
+
}
|
|
84
|
+
/** Côté HÔTE : envoie un message au player. */
|
|
85
|
+
export function sendToPlayer(frame, msg) {
|
|
86
|
+
try {
|
|
87
|
+
frame?.contentWindow?.postMessage(toWire(msg), "*");
|
|
88
|
+
}
|
|
89
|
+
catch { /* iframe déjà détruite */ }
|
|
90
|
+
}
|
|
91
|
+
/** Côté HÔTE : écoute les messages du player. Renvoie la fonction de désabonnement. */
|
|
92
|
+
export function onPlayerMessage(cb, win) {
|
|
93
|
+
const target = win || (typeof window !== "undefined" ? window : null);
|
|
94
|
+
if (!target)
|
|
95
|
+
return () => { };
|
|
96
|
+
const handler = (e) => {
|
|
97
|
+
const msg = parsePlayerMessage(e.data);
|
|
98
|
+
if (msg)
|
|
99
|
+
cb(msg);
|
|
100
|
+
};
|
|
101
|
+
target.addEventListener("message", handler);
|
|
102
|
+
return () => target.removeEventListener("message", handler);
|
|
103
|
+
}
|
|
104
|
+
/** Côté PLAYER : écoute les messages de l'hôte. Renvoie la fonction de désabonnement. */
|
|
105
|
+
export function onHostMessage(cb, win) {
|
|
106
|
+
const target = win || (typeof window !== "undefined" ? window : null);
|
|
107
|
+
if (!target)
|
|
108
|
+
return () => { };
|
|
109
|
+
const handler = (e) => {
|
|
110
|
+
const msg = parseHostMessage(e.data);
|
|
111
|
+
if (msg)
|
|
112
|
+
cb(msg);
|
|
113
|
+
};
|
|
114
|
+
target.addEventListener("message", handler);
|
|
115
|
+
return () => target.removeEventListener("message", handler);
|
|
116
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Host contract
|
|
2
|
+
|
|
3
|
+
What a host application may call, what it must implement, and what will not change without a
|
|
4
|
+
version bump. If you are integrating the player, this page and [`API.md`](API.md) are the two you
|
|
5
|
+
need.
|
|
6
|
+
|
|
7
|
+
## Five rules
|
|
8
|
+
|
|
9
|
+
1. **One source of truth.** Fix the player in the player's repository, never in a host — not even
|
|
10
|
+
in a host that once contained it. A fix written host-side is a copy, and copies drift.
|
|
11
|
+
2. **Additive by default.** Adding an action, a parameter or a field breaks nobody. **Removing or
|
|
12
|
+
renaming is a break** → a new contract number, both served during the migration.
|
|
13
|
+
3. **Deploy order: the player ships before its hosts.** The reverse makes a feature disappear
|
|
14
|
+
everywhere at once, with no error anywhere.
|
|
15
|
+
4. **Pin the version you target, and test it.** `GET /api/doc?contract=1` answers without a
|
|
16
|
+
session, without a database and without cache — it must answer when nothing else does.
|
|
17
|
+
5. **Anyone may propose, the maintainer releases.** Open an issue or a pull request here; you have
|
|
18
|
+
the context and often the fix. Releasing stays with the maintainer because publishing a version
|
|
19
|
+
decides deploy order. A local workaround is fine when you are blocked, on two conditions: report
|
|
20
|
+
it the same day, and remove it when the release lands.
|
|
21
|
+
|
|
22
|
+
## Identity card
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"product": "discovery-media-player",
|
|
27
|
+
"contract": 1,
|
|
28
|
+
"version": "0.1.8",
|
|
29
|
+
"capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth"],
|
|
30
|
+
"frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
|
|
31
|
+
"plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false }
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Pin `contract`**, not `version`: it moves only on a break. Test `capabilities` by **presence**,
|
|
36
|
+
never by order. `plugins` lets you refuse to start when you depend on an optional module this
|
|
37
|
+
instance does not have.
|
|
38
|
+
|
|
39
|
+
⚠️ **`frameAncestors` matters more than it looks.** A host that is not listed will never see the
|
|
40
|
+
viewer: the browser blocks the iframe **before any script runs**, so no message can be emitted and
|
|
41
|
+
the host sees a silence indistinguishable from an unreachable instance. Check that your domain is
|
|
42
|
+
there before you open a document.
|
|
43
|
+
|
|
44
|
+
## The three things a host implements
|
|
45
|
+
|
|
46
|
+
Everything the player borrows arrives through one injected object. Two of its entries carry
|
|
47
|
+
decisions the player deliberately refuses to make, and both can be answered over HTTP — most hosts
|
|
48
|
+
therefore write **no wiring code at all**, only environment variables.
|
|
49
|
+
|
|
50
|
+
### 1. Who may manage tracked links
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
POST → { "email": "…", "role": "…", "action": "create|list|list.all|revoke|setauth|overview|sessions|test" }
|
|
54
|
+
← { "allowed": true }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **The player verifies the token; you decide the rights.** No answer, or a failing rule, means
|
|
58
|
+
refusal. A right that cannot be granted is not granted.
|
|
59
|
+
- **`email` is the authoritative identity.** `role` is what the session token carried; a host whose
|
|
60
|
+
roles live in its own database ignores it and looks them up. That is expected.
|
|
61
|
+
- **Only `allowed` is read, and it must be a boolean.** Any other shape means refused.
|
|
62
|
+
- The token is already verified before the call: your route does not receive it and must not
|
|
63
|
+
re-verify it.
|
|
64
|
+
|
|
65
|
+
⚠️ **Verified against whom?** The player's database and your identity provider are two different
|
|
66
|
+
things. They are the same project while the player and your application share a deployment — and
|
|
67
|
+
different by construction once the instance is separate. Point `PLAYER_AUTH_URL` at the project
|
|
68
|
+
that issues your members' tokens (with its own publishable key in `PLAYER_AUTH_KEY`), or every
|
|
69
|
+
member action is refused in a way that reads like a missing permission. Unset, it falls back to
|
|
70
|
+
the player's own project, so a shared deployment changes by not one character. The `host-auth`
|
|
71
|
+
capability tells you an instance supports the split.
|
|
72
|
+
|
|
73
|
+
### 2. What a client's brand is
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
POST → { "key": "…" }
|
|
77
|
+
← { "logo": "https://…", "name": "…", "dark": false } or {} / null when unknown
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The link carries a **reference**, never a copy of the logo: a tracked link lives for weeks in an
|
|
81
|
+
inbox, and a logo frozen at send time would not follow a corrected brand. `name` is not decorative
|
|
82
|
+
— it is what shows when the logo fails to load.
|
|
83
|
+
|
|
84
|
+
### 3. Serving a file the player cannot reach
|
|
85
|
+
|
|
86
|
+
If your documents sit behind an API key, the player must **never** hold it. Expose one route
|
|
87
|
+
(`PLAYER_HOST_FETCH_BASE`), fetch the file yourself, and the player is allowed to call only that.
|
|
88
|
+
Four requirements, in order of what they cost when missed:
|
|
89
|
+
|
|
90
|
+
1. **Never relay the upstream `Content-Length`.** `fetch()` decompresses the body and keeps the
|
|
91
|
+
upstream headers; relaying the announced size serves a **truncated PDF**, with no error
|
|
92
|
+
anywhere. Announce the length of what you send, request `Accept-Encoding: identity`, and refuse
|
|
93
|
+
a compressed `206` — range bounds refer to compressed bytes.
|
|
94
|
+
2. **Relay `Range`** (`206` + `Accept-Ranges: bytes`). Progressive loading depends on it.
|
|
95
|
+
3. **Accept a server-to-server call.** A tracked link is opened by someone with no session on your
|
|
96
|
+
side. Authenticate the player with the shared secret in the `x-player-fetch-secret` **header** —
|
|
97
|
+
header only, never a query string: logs keep URLs.
|
|
98
|
+
4. ⚠️ **Never sign a path supplied by the client.** The first three are about transport; this one
|
|
99
|
+
is about what you transport, and it is the only one whose omission does not degrade the
|
|
100
|
+
experience but **opens your data**.
|
|
101
|
+
|
|
102
|
+
Your route serves with *your* credentials — the player has no session to present, by design.
|
|
103
|
+
An action that signs a client-supplied path becomes an oracle: a user signs a path their own
|
|
104
|
+
rights would refuse, and the player reads it back with yours. The anti-SSRF guard sees nothing:
|
|
105
|
+
the origin is legitimate, it is yours.
|
|
106
|
+
|
|
107
|
+
The shape that holds: the caller supplies a **source from a closed set and a row identifier,
|
|
108
|
+
never a path**. You re-read the path with the caller's session and your own row-level rules
|
|
109
|
+
decide. **Corollary:** when the reference itself carries a capability, signing is not enough —
|
|
110
|
+
it must be encrypted. *Signed* means nobody can forge it; it has never meant nobody can read it.
|
|
111
|
+
|
|
112
|
+
## The postMessage bridge
|
|
113
|
+
|
|
114
|
+
Described once in [`src/bridge.ts`](../src/bridge.ts) and published as `discovery-media-player/bridge`
|
|
115
|
+
— **compiled JavaScript with type declarations, under MIT** rather than the core's AGPL, so that
|
|
116
|
+
importing it is not a toll. Import it rather than copying constants: a message name retyped by hand
|
|
117
|
+
is a contract in two copies, and the day it changes only one of them knows.
|
|
118
|
+
|
|
119
|
+
**player → host:** `close` · `share` · `embed-ready` · `embed-denied {reason}` · `present-left` ·
|
|
120
|
+
`present-denied` · `present-invite {slug}` · `present-handover {slug}` · `present-switch {slug}`
|
|
121
|
+
|
|
122
|
+
**host → player:** `handover-done`
|
|
123
|
+
|
|
124
|
+
### Refusals
|
|
125
|
+
|
|
126
|
+
A host waiting for `embed-ready` is tempted to treat silence as a timeout and fall back to the
|
|
127
|
+
browser's own viewer. **That is a security hole**: silence covers two opposite cases — the player is
|
|
128
|
+
absent, or the player *refuses*. Falling back in the second case opens the document the player just
|
|
129
|
+
closed.
|
|
130
|
+
|
|
131
|
+
| `reason` | What happened | Host behaviour |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `revoked` | unknown or revoked link | do not open |
|
|
134
|
+
| `auth-required` | restricted document, visitor not signed in | do not open — the wall stays up |
|
|
135
|
+
| `auth-unavailable` | restricted document, access wall missing from this instance | do not open |
|
|
136
|
+
| `ended` | presentation over or unknown | do not open |
|
|
137
|
+
| `url-not-allowed` | the file URL is not covered by the guard | **open**, and report the configuration |
|
|
138
|
+
|
|
139
|
+
The rule underneath, safer than the list: **never fall back on a refusal of *access*; you may fall
|
|
140
|
+
back on an inability to *reach*.** And "do not fall back" applies to what you **offer** — an
|
|
141
|
+
"Open ↗" button left in place is falling back one second later.
|
|
142
|
+
|
|
143
|
+
## Two things that will bite
|
|
144
|
+
|
|
145
|
+
**Your document-opening doors reappear.** A host has more than one place that opens a file, and new
|
|
146
|
+
ones get written. Keep the list and hunt it periodically — and note that **your search criteria
|
|
147
|
+
decide what you find**: search by what the user *obtains* (a document opens), not by the technique
|
|
148
|
+
you expect to see.
|
|
149
|
+
|
|
150
|
+
**Configured is not served.** When a diagnosis is disputed, the useful question is not who is right
|
|
151
|
+
but *did you measure exactly what fails*. Two true statements about the same instance can describe
|
|
152
|
+
different responses.
|
|
153
|
+
|
|
154
|
+
## Versioning
|
|
155
|
+
|
|
156
|
+
Semantic versioning on the package, independent of the `contract` number. Pin an **exact** version:
|
|
157
|
+
the player and its hosts deploy separately, and a range brings in a version nobody decided to
|
|
158
|
+
deploy, on a day someone ran `npm install` for another reason.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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",
|
|
@@ -29,13 +29,14 @@
|
|
|
29
29
|
"files": [
|
|
30
30
|
"bin",
|
|
31
31
|
"context",
|
|
32
|
+
"dist",
|
|
32
33
|
"server",
|
|
33
|
-
"src",
|
|
34
34
|
"supabase",
|
|
35
35
|
"README.md",
|
|
36
|
-
"CONTRAT.md",
|
|
37
36
|
"LICENSE",
|
|
38
|
-
"LICENSE-MIT"
|
|
37
|
+
"LICENSE-MIT",
|
|
38
|
+
"docs/HOST-CONTRACT.md",
|
|
39
|
+
"!**/__tests__"
|
|
39
40
|
],
|
|
40
41
|
"exports": {
|
|
41
42
|
".": "./server/handler.js",
|
|
@@ -44,12 +45,16 @@
|
|
|
44
45
|
"./brands": "./server/brands.js",
|
|
45
46
|
"./context/standalone": "./context/standalone.js",
|
|
46
47
|
"./context/storage": "./context/storage.js",
|
|
47
|
-
"./bridge":
|
|
48
|
+
"./bridge": {
|
|
49
|
+
"types": "./dist/bridge.d.ts",
|
|
50
|
+
"import": "./dist/bridge.js",
|
|
51
|
+
"default": "./dist/bridge.js"
|
|
52
|
+
},
|
|
48
53
|
"./package.json": "./package.json"
|
|
49
54
|
},
|
|
50
55
|
"scripts": {
|
|
51
56
|
"start": "node bin/serve.js",
|
|
52
|
-
"build": "node build/bundle.mjs",
|
|
57
|
+
"build": "node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
|
|
53
58
|
"test": "vitest run",
|
|
54
59
|
"test:watch": "vitest",
|
|
55
60
|
"lint": "eslint bin context server src build",
|
|
@@ -58,7 +63,7 @@
|
|
|
58
63
|
"prepublishOnly": "npm run build && npm test"
|
|
59
64
|
},
|
|
60
65
|
"engines": {
|
|
61
|
-
"node": ">=
|
|
66
|
+
"node": ">=22"
|
|
62
67
|
},
|
|
63
68
|
"devDependencies": {
|
|
64
69
|
"@eslint/js": "^9.39.4",
|
package/server/handler.js
CHANGED
|
@@ -1098,6 +1098,24 @@ function botMarkup(share, pitch) {
|
|
|
1098
1098
|
* ⚠️ Et un 206 compressé est irrécupérable : les bornes portent sur les octets compressés, un
|
|
1099
1099
|
* fragment gzip ne se décompresse pas seul. On refuse bruyamment plutôt que de servir du faux.
|
|
1100
1100
|
*/
|
|
1101
|
+
|
|
1102
|
+
// ⚠️ CE QU'ON RELAIE S'OUVRE SUR NOTRE ORIGINE.
|
|
1103
|
+
//
|
|
1104
|
+
// Un fichier relayé sort du domaine qui sert les documents — donc avec ses cookies, son
|
|
1105
|
+
// `localStorage` et ses jetons de présentation. Relayer un `Content-Type` exécutable revient à
|
|
1106
|
+
// héberger le script de quelqu'un d'autre chez soi : un SVG déposé dans une source autorisée
|
|
1107
|
+
// (bucket public, route de l'hôte) s'ouvre `image/svg+xml`, et son `<script>` s'exécute avec
|
|
1108
|
+
// notre origine. La réponse de streaming ne porte aucune CSP — c'est un fichier, pas une page.
|
|
1109
|
+
//
|
|
1110
|
+
// Retirer `.svg` de la table des types locaux (côté `context/storage.js`) ne réglait que la
|
|
1111
|
+
// moitié du sujet : l'amont distant annonce le type qu'il veut, et on le recopiait.
|
|
1112
|
+
//
|
|
1113
|
+
// On ne REFUSE pas pour autant : le fichier existe, quelqu'un a le droit de le récupérer, et un
|
|
1114
|
+
// 502 sur une pièce jointe légitime serait une panne. On le rend simplement **inerte** — type
|
|
1115
|
+
// générique et téléchargement forcé. Il n'était de toute façon pas affichable (la matrice des
|
|
1116
|
+
// formats du README ne connaît que le PDF et les images bitmap).
|
|
1117
|
+
const TYPES_EXECUTABLES = /^(image\/svg|text\/html|application\/xhtml|application\/xml|text\/xml)/i;
|
|
1118
|
+
|
|
1101
1119
|
async function relayerFichier(res, r, disposition) {
|
|
1102
1120
|
if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
|
|
1103
1121
|
if (!r.ok && r.status !== 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
@@ -1107,7 +1125,16 @@ async function relayerFichier(res, r, disposition) {
|
|
|
1107
1125
|
|
|
1108
1126
|
const buf = Buffer.from(await r.arrayBuffer());
|
|
1109
1127
|
res.statusCode = r.status;
|
|
1110
|
-
|
|
1128
|
+
const typeAmont = r.headers.get("content-type") || "application/pdf";
|
|
1129
|
+
const executable = TYPES_EXECUTABLES.test(typeAmont);
|
|
1130
|
+
res.setHeader("Content-Type", executable ? "application/octet-stream" : typeAmont);
|
|
1131
|
+
// ⚠️ On ÉCRASE la disposition, on ne la complète pas : les chemins de streaming passent
|
|
1132
|
+
// `inline; filename=…` pour que la visionneuse affiche le document. Sur un type exécutable,
|
|
1133
|
+
// `inline` est précisément ce qu'il ne faut pas — et un `|| "attachment"` n'aurait jamais servi.
|
|
1134
|
+
if (executable) disposition = "attachment";
|
|
1135
|
+
// `nosniff` : sans lui, un `text/plain` contenant du HTML peut être requalifié par le
|
|
1136
|
+
// navigateur — la garde ci-dessus porterait alors sur un type qui n'est pas celui qui s'ouvre.
|
|
1137
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1111
1138
|
res.setHeader("Accept-Ranges", "bytes");
|
|
1112
1139
|
// Les bornes d'un `Content-Range` ne valent que si l'amont n'a pas compressé.
|
|
1113
1140
|
const cr = !compresse && r.headers.get("content-range");
|
|
@@ -2554,8 +2581,13 @@ async function handler(req, res) {
|
|
|
2554
2581
|
contract: 1,
|
|
2555
2582
|
version: PLAYER_VERSION,
|
|
2556
2583
|
// Ce que cette instance sait faire. Un hôte teste la présence, jamais l'ordre.
|
|
2584
|
+
// `host-auth` : cette instance sait vérifier les jetons auprès d'un émetteur DISTINCT de
|
|
2585
|
+
// sa base (PLAYER_AUTH_URL). Un hôte tiers en a besoin pour savoir si ses membres
|
|
2586
|
+
// peuvent seulement s'authentifier — sans ça, sa seule voie était d'essayer et de lire
|
|
2587
|
+
// un refus qui ressemble à un droit manquant. Le nom, jamais l'émetteur : la carte reste
|
|
2588
|
+
// muette sur les URL.
|
|
2557
2589
|
capabilities: [
|
|
2558
|
-
"docshare", "presentations", "embed-denied", "host-fetch", "brand-reference",
|
|
2590
|
+
"docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth",
|
|
2559
2591
|
],
|
|
2560
2592
|
// ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
|
|
2561
2593
|
// suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
|