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 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
  [![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
- [![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE)
18
- [![Node](https://img.shields.io/badge/node-%E2%89%A518-brightgreen.svg)](package.json)
19
- [![Docker](https://img.shields.io/badge/docker-ghcr.io-informational.svg)](#docker)
17
+ [![npm](https://img.shields.io/npm/v/discovery-media-player?logo=npm&color=cb3837)](https://www.npmjs.com/package/discovery-media-player)
18
+ [![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
+ [![Node](https://img.shields.io/node/v/discovery-media-player?logo=node.js&color=5fa04e)](package.json)
20
+ [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](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
@@ -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 = String(env.SUPABASE_URL || "").replace(/\/+$/, "");
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
- /** Vérifie un jeton auprès de Supabase Auth. Sans base : personne n'est authentifié. */
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 url = String(env.SUPABASE_URL || "").replace(/\/+$/, "");
158
- const cle = String(env.SUPABASE_PUBLISHABLE_KEY || env.SUPABASE_SERVICE_ROLE_KEY || "");
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`, {
@@ -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", ".svg": "image/svg+xml", ".avif": "image/avif",
136
+ ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
126
137
  };
127
138
 
128
139
  /**
@@ -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,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -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.6",
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": "./src/bridge.ts",
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": ">=18"
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
- res.setHeader("Content-Type", r.headers.get("content-type") || "application/pdf");
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