discovery-media-player 0.1.4 → 0.1.6

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/CONTRAT.md CHANGED
@@ -14,10 +14,11 @@
14
14
 
15
15
  ## Les cinq règles
16
16
 
17
- 1. **Un seul dépôt de vérité.** Le player se corrige dans le studio (`player/`, `api/doc.js`,
18
- `api/_player-context.js`). **Jamais dans un hôte.** Un correctif écrit côté hôte est une copie
19
- qui divergera le précédent est documenté : un runtime copié dans 4 dépôts, 3 sur 4 servaient
20
- une version périmée sans que personne ne le voie.
17
+ 1. **Un seul dépôt de vérité.** Le player se corrige **dans le dépôt du player**, jamais dans un
18
+ hôte pas même dans celui qui l'a écrit à l'origine, qui l'installe aujourd'hui depuis npm
19
+ comme tous les autres. Un correctif écrit côté hôte est une copie qui divergera : le précédent
20
+ est documenté — un runtime copié dans 4 dépôts, 3 sur 4 servaient une version périmée sans que
21
+ personne ne le voie.
21
22
  2. **Additif par défaut.** Ajouter une action, un paramètre ou un champ ne casse aucun hôte : c'est
22
23
  libre, ça se note au journal. **Retirer ou renommer est une rupture** → nouvelle version, les
23
24
  deux servies pendant la migration.
@@ -29,11 +30,18 @@
29
30
  reste ne va pas.
30
31
 
31
32
  ```json
32
- { "product": "discovery-media-player", "contract": 1, "version": "0.1.0",
33
+ { "product": "discovery-media-player", "contract": 1, "version": "0.1.6",
33
34
  "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference"],
35
+ "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.exemple.fr"],
34
36
  "plugins": { "bot": true, "visitors": true, "brandIntro": true, "botBrowser": true, "providerQuotas": true } }
35
37
  ```
36
38
 
39
+ ⚠️ **`frameAncestors` dit POUR QUELLES ORIGINES l'instance accepte d'être encadrée.** Un hôte
40
+ qui ne s'y trouve pas ne verra jamais la visionneuse : le navigateur bloque l'iframe **avant
41
+ tout script**, donc aucun `embed-denied` ne peut partir, et l'hôte voit un silence
42
+ indiscernable d'une instance injoignable. C'est la seule panne qu'un hôte ne peut pas
43
+ diagnostiquer autrement — **vérifiez que votre domaine y figure avant d'ouvrir un document.**
44
+
37
45
  **`contract` est LE champ à épingler** : il ne bouge que sur une rupture (règle 2) — ajouter une
38
46
  action, un paramètre ou un motif de refus ne le change pas. `capabilities` se teste par
39
47
  PRÉSENCE, jamais par ordre. `plugins` permet à un hôte de refuser de démarrer si le mur d'accès
@@ -43,7 +51,33 @@
43
51
  **Qui prévient qui.** Une PR d'hôte qui exige une version plus récente l'écrit **dans son titre**
44
52
  (« requiert player ≥ v2 »). Elle ne peut pas être mergée avant que l'instance correspondante soit
45
53
  déployée. C'est la seule règle nécessaire : il n'y a qu'une personne qui déploie les deux.
46
- 5. **Un besoin d'hôte se demande, il ne se code pas sur place.** Section « Demandes » en bas.
54
+ 5. **Qui corrige le module générique.** Cette règle disait « un besoin d'hôte se demande, il ne
55
+ se code pas sur place ». Elle datait d'avant la publication, quand le player vivait dans un
56
+ hôte et qu'aucun autre ne pouvait y toucher. Maintenant qu'il a son dépôt, elle est trop
57
+ étroite : un hôte **peut** coder — dans le bon dépôt.
58
+
59
+ **N'importe quel hôte propose. Le mainteneur arbitre et publie.**
60
+
61
+ | | |
62
+ |---|---|
63
+ | Un hôte trouve un défaut du player | il ouvre une **issue ou une PR sur le dépôt du player** — il a le contexte, souvent déjà le code |
64
+ | Le mainteneur | tranche, fusionne, **publie la version** |
65
+ | Les hôtes | épinglent la nouvelle version quand ils décident de la prendre |
66
+
67
+ ⚠️ **La publication reste au mainteneur, et ce n'est pas une question de hiérarchie.** Publier
68
+ une version décide de l'ordre de déploiement (règle 3). Si chacun publie, plus personne ne sait
69
+ quelle instance tourne sur quoi.
70
+
71
+ ⚠️ **Et l'arbitrage n'est pas une formalité : un hôte optimise pour son cas, c'est normal.**
72
+ Exemple vécu — un hôte a corrigé chez lui, en trois lignes, le fait que le gestionnaire lise
73
+ `req.query` sur une plateforme qui ne le remplit pas. Son correctif était juste. Le bon
74
+ correctif était **dans le cœur**, parce que le défaut touchait tous les hôtes, présents et à
75
+ venir. Seul quelqu'un qui tient les contraintes des deux côtés voit ça.
76
+
77
+ **Le contournement local est autorisé quand il débloque**, à deux conditions : le signalement
78
+ est ouvert **le jour même**, et le contournement est **retiré quand la version arrive**. Sinon
79
+ il devient permanent, et on a deux implémentations qui divergent — c'est-à-dire le problème que
80
+ ce contrat existe pour empêcher.
47
81
 
48
82
  ---
49
83
 
@@ -181,6 +215,47 @@ lire n'importe quoi, avec des identifiants : c'est précisément ce que la garde
181
215
  *(Le player applique lui-même cette règle depuis le 13/08 — `relayerFichier()`, un seul chemin
182
216
  pour ses trois routes de streaming. Le piège nous concernait aussi.)*
183
217
 
218
+ ⚠️ **ET UNE QUATRIÈME, D'UNE AUTRE NATURE — celle-ci n'abîme pas l'expérience, elle ouvre les
219
+ données.**
220
+
221
+ Les trois précédentes portent sur le TRANSPORT. Celle-ci porte sur ce qu'on transporte :
222
+
223
+ > **Ce que votre route accepte de signer est ce que n'importe quel appelant peut lire.
224
+ > Ne signez jamais un chemin fourni par le client.**
225
+
226
+ Le raisonnement tient en trois phrases. Le player va chercher le fichier **serveur à serveur** —
227
+ il n'a, par construction, aucune session à faire valoir : c'est tout l'objet du secret partagé.
228
+ Votre route le sert donc avec **ses propres droits**, souvent une clé de service qui contourne
229
+ vos politiques de ligne. Une action qui signe un chemin reçu du navigateur devient alors un
230
+ oracle : un utilisateur fait signer un chemin que ses droits lui refusent, ouvre l'aperçu, et le
231
+ player le lui lit avec les vôtres.
232
+
233
+ **La garde anti-SSRF ne voit rien** — l'origine est parfaitement légitime, c'est la vôtre.
234
+
235
+ La forme qui tient : **l'appelant fournit une SOURCE d'un ensemble fermé et un IDENTIFIANT de
236
+ ligne, jamais un chemin.** Le chemin est relu en base avec la session de l'appelant, et vos
237
+ politiques tranchent comme partout ailleurs. C'est la même règle que `brandKey` (une référence,
238
+ pas une copie) et que `PLAYER_HOST_FETCH_BASE` (un préfixe, pas une origine) : **on transmet de
239
+ quoi retrouver, jamais de quoi désigner.**
240
+
241
+ ⚠️ **Le piège est qu'elle est souvent théorique le jour où on l'écrit.** Si vos politiques
242
+ laissent aujourd'hui tout membre connecté lire, l'élévation n'existe pas encore — elle apparaîtra
243
+ au premier resserrement, des mois plus tard, et personne ne fera le lien entre « on a restreint un
244
+ accès » et « une route signe encore n'importe quoi ».
245
+
246
+ **Corollaire, rencontré par le même hôte une semaine plus tard : quand ce que la référence
247
+ TRANSPORTE est elle-même une capacité, signer ne suffit pas — il faut chiffrer.** Une référence
248
+ signée reste lisible : le base64 se décode. *Signé* veut dire « personne ne peut le forger » ; ça
249
+ n'a jamais voulu dire « personne ne peut le lire ». Si votre référence contient une URL qui sert le
250
+ fichier sans authentification et que rien n'expire, la publier en clair revient à publier le
251
+ fichier.
252
+
253
+ *(Signalée par le second hôte après l'avoir rencontrée en basculant ses premières surfaces.
254
+ Vérifiée chez l'hôte historique le jour même : une route y signait un chemin reçu du client,
255
+ derrière une liste NOIRE de rôles — un compte d'espace client passait, et tout rôle créé plus tard
256
+ serait passé aussi.)*
257
+
258
+
184
259
  ### Un refus se dit — `embed-denied`
185
260
 
186
261
  Un hôte qui intègre la visionneuse (`?embed=1`) attend `embed-ready`. Il est tentant d'en faire un
@@ -340,7 +415,13 @@ documents **sans passer par le player**, donc sans être comptée. Ce n'est pas
340
415
  c'est la pente naturelle d'un produit vivant — un `<iframe src="....pdf">` s'écrit en dix secondes.
341
416
 
342
417
  **Chaque hôte doit tenir la liste de ses portes et la rechasser périodiquement.** Une recherche
343
- suffit : `.pdf`, `window.open`, `<embed`, `<iframe` sur un fichier, `application/pdf`. Le tableau
418
+ suffit : `.pdf`, `window.open`, `<embed`, `<iframe` sur un fichier, `application/pdf`.
419
+
420
+ ⚠️ **Et le critère de recherche décide de ce qu'on trouve.** Un hôte a inventorié ses portes en
421
+ cherchant les appels de son moteur de stockage — et a manqué son plus gros gisement de documents,
422
+ parce que ces fichiers-là ne sont pas dans son stockage. Aucune recherche de cette forme ne pouvait
423
+ les voir. Cherchez par ce que l'utilisateur OBTIENT (un document qui s'ouvre), pas par la
424
+ technique que vous vous attendez à trouver. Le tableau
344
425
  des portes recensées vit chez chaque hôte, pas ici. La règle, elle, est commune : **une porte non
345
426
  recensée est une lecture non comptée**, et l'écart ne se voit dans aucune statistique — il se voit
346
427
  seulement quand quelqu'un le cherche.
@@ -522,6 +603,8 @@ Toute évolution de la frontière se note ici, datée, avec sa nature.
522
603
  | 2026-08-13 | précision | **Le câblage d'une instance appartient à l'HÔTE** (4 fichiers, un seul à écrire) : il ne contient que des décisions de l'hôte. `forKey` d'une instance séparée **appelle une route de l'hôte** plutôt que de recopier la correspondance clé → logo. |
523
604
  | 2026-08-13 | additif | **`GET /api/doc?contract=1`** existe enfin : la règle 4 reposait sur un point qui n'avait jamais été écrit. Carte d'identité sans session, sans base, sans cache, sans URL ni secret. |
524
605
  | 2026-08-13 | additif | **Le schéma part avec le player** (`player/supabase/init.sql`) : un fichier rejouable qui amène une base vierge à l'état attendu, **déjà durci** — une instance neuve ne connaît jamais l'état « lecture anonyme ouverte ». |
606
+ | 2026-08-13 | précision | **Règle 5 réécrite : qui corrige le module générique.** Elle interdisait à un hôte de coder — c'était vrai quand le player vivait dans un hôte. N'importe quel hôte **propose** désormais (issue ou PR sur le dépôt du player) ; **le mainteneur arbitre et publie**, parce que publier décide de l'ordre de déploiement. Contournement local autorisé s'il débloque, à condition d'ouvrir le signalement le jour même et de le retirer à l'arrivée de la version. |
607
+ | 2026-08-13 | précision | **Règle 1 recadrée** : « le player se corrige dans le studio » devient « dans le dépôt du player » — l'hôte historique l'installe depuis npm comme les autres. |
525
608
  | 2026-08-13 | décision | **Dépôt public dès la création, AGPL-3.0** : `PLAYER_SOURCE_URL` pointe dessus, aucun jeton ni clé de déploiement chez les hôtes. Le câblage d'un hôte est considéré couvert — il ne doit donc contenir aucun secret en clair, ce qui est de toute façon la bonne façon de l'écrire. |
526
609
  | 2026-08-13 | décision | **Aucun nom de tiers dans le dépôt publié** : les hôtes sont désignés par leur RÔLE (« l'hôte historique », « le second hôte »), jamais par leur raison sociale, et les exemples d'URL sont fictifs. Le rôle porte toute l'information technique ; le nom ne dit qu'une chose — quelles entreprises travaillent ensemble et où leurs documents vivent. |
527
610
 
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  <div align="center">
2
2
 
3
- # Discovery Media Player
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Juli1artha/discovery-media-player/main/assets/logo-dark.svg">
5
+ <img alt="Discovery Media Player" src="https://raw.githubusercontent.com/Juli1artha/discovery-media-player/main/assets/logo.svg" width="520">
6
+ </picture>
7
+
8
+ <br><br>
4
9
 
5
10
  **Send a document. Know if it was read.**
6
11
 
@@ -13,19 +18,32 @@ to a third-party SaaS.
13
18
  [![Node](https://img.shields.io/badge/node-%E2%89%A518-brightgreen.svg)](package.json)
14
19
  [![Docker](https://img.shields.io/badge/docker-ghcr.io-informational.svg)](#docker)
15
20
 
21
+ <br>
22
+
23
+ <img src="https://raw.githubusercontent.com/Juli1artha/discovery-media-player/main/assets/captures/viewer.png" alt="The viewer: a document, a toolbar, and the tracked-reading timer running" width="900">
24
+
25
+ <br>
26
+
16
27
  </div>
17
28
 
18
29
  ---
19
30
 
20
31
  ## Try it in two minutes
21
32
 
33
+ **[▶ Open the live demo](https://discovery-media-player-demo.vercel.app)** — a document, in the
34
+ real viewer, nothing to install.
35
+
36
+ Or on your own machine, over your own files:
37
+
22
38
  ```bash
23
39
  docker run --rm -p 3000:3000 -v "$PWD/documents:/data" ghcr.io/juli1artha/discovery-media-player
24
40
  ```
25
41
 
26
- Drop a PDF in `./documents`, open `http://localhost:3000/preview/your-file.pdf`. No database,
27
- no account, no configuration — the viewer, progressive page loading, and the reading timer all
28
- work from a folder on disk.
42
+ Drop a PDF in `./documents` and open `http://localhost:3000`. No database, no account, no
43
+ configuration — the viewer, progressive page loading and the reading timer all work from a folder
44
+ on disk.
45
+
46
+ <img src="https://raw.githubusercontent.com/Juli1artha/discovery-media-player/main/assets/captures/folder.png" alt="Folder mode: the server lists what it can display" width="760">
29
47
 
30
48
  From source, the same thing:
31
49
 
@@ -138,6 +156,11 @@ if you run a modified version and people read documents through it over a networ
138
156
  able to obtain your source. Set `PLAYER_SOURCE_URL` to where yours lives — the pages served
139
157
  link to it.
140
158
 
159
+ **The name and the logo are not covered by it.** `assets/` and the words *Discovery Media
160
+ Player* are trademarks of 3D Discovery: fork the code freely, but call your fork something else.
161
+ This is the usual arrangement in open source, and it protects you as much as us — nobody should
162
+ be able to publish something under this name that we did not write.
163
+
141
164
  One exception, on purpose: **[`src/bridge.ts`](src/bridge.ts) is MIT**
142
165
  ([`LICENSE-MIT`](LICENSE-MIT)). It is the message contract a host application imports to talk to
143
166
  the player. Putting it under the core licence would make integration itself a toll. We protect
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
@@ -0,0 +1,111 @@
1
+ // TOUTE PAGE QU'UN HÔTE PEUT INTÉGRER DOIT ÊTRE ENCADRABLE PAR CET HÔTE.
2
+ //
3
+ // ⚠️ C'est la seule panne qu'un hôte ne peut PAS diagnostiquer : le navigateur bloque l'iframe
4
+ // avant tout script, donc rien ne peut lui être émis — ni `embed-denied`, ni erreur. Il voit un
5
+ // silence, indiscernable d'une instance injoignable, et son repli ouvre le document ailleurs.
6
+ //
7
+ // Trouvé deux fois en deux jours, sur deux pages différentes. La première fois par l'absence de
8
+ // DOC_FRAME_ANCESTORS, la seconde par un `'self'` écrit EN DUR dans la branche de l'aperçu — vrai
9
+ // tant que l'application et le player partagent un déploiement, faux dès qu'une instance est
10
+ // séparée, et rien ne le signalait. Conséquence absurde relevée par l'hôte : la page de REFUS
11
+ // était encadrable chez lui, pas la page de SUCCÈS. Le chemin d'erreur était plus portable que le
12
+ // chemin nominal.
13
+
14
+ const PRESENTATION = {
15
+ slug: "Ab3-_xYz9012", doc_title: "Démo", file_name: "demo.pdf",
16
+ file_url: "https://exemple.supabase.co/storage/v1/object/public/resources/demo.pdf",
17
+ current_page: 1, active: true, updated_at: "2026-08-13T00:00:00.000Z",
18
+ };
19
+ const ID = require.resolve("../presentations.js");
20
+ const vraies = require("../presentations.js");
21
+ require.cache[ID] = { id: ID, filename: ID, loaded: true,
22
+ exports: { ...vraies, getPresentation: async () => ({ ...PRESENTATION }), listMessages: async () => [] } };
23
+
24
+ // ⚠️ APRÈS le double, jamais avant : le gestionnaire déstructure ses dépendances au chargement,
25
+ // une substitution plus tardive n'aurait aucun effet.
26
+ const player = require("../handler.js");
27
+
28
+ const HOTE = "https://app.exemple.fr";
29
+
30
+ function contexte(ancetres) {
31
+ return {
32
+ plugins: {}, has: () => false,
33
+ storage: { isAllowedUrl: (u) => String(u || "").startsWith("https://exemple.supabase.co/"), async fetchFile() { return null; }, async put() {} },
34
+ db: { async request() { return []; }, async selectAll() { return []; } },
35
+ mail: { async send() {} },
36
+ identity: { async verifyToken() { return null; }, roleOf: () => "", isAdmin: () => false, async canManageShares() { return false; } },
37
+ limits: { async allow() { return true; } },
38
+ branding: { async logo() { return ""; }, name: "", poweredBy: "", loaderName: "", async forKey() { return null; }, title: (b) => b },
39
+ errors: { async capture() {} },
40
+ legal: { sourceUrl: "", legalUrl: "", privacyUrl: "", trackingNotice: "" },
41
+ config: { supabaseUrl: "https://exemple.supabase.co", supabasePublishableKey: "k", mapsKey: "", extraFrameAncestors: ancetres },
42
+ };
43
+ }
44
+
45
+ async function csp(query, ancetres = [HOTE]) {
46
+ process.env.DOC_FRAME_ANCESTORS = ancetres.join(" ");
47
+ player.init(contexte(ancetres));
48
+ const res = { statusCode: 0, headers: {}, body: "", setHeader(k, v) { this.headers[k.toLowerCase()] = v; }, end(b) { this.body = String(b == null ? "" : b); } };
49
+ await player.handler({ method: "GET", headers: {}, socket: {}, query }, res);
50
+ const h = res.headers["content-security-policy"] || "";
51
+ return { statut: res.statusCode, ancetres: (h.match(/frame-ancestors ([^;]*)/) || [])[1] || "", corps: res.body };
52
+ }
53
+
54
+ const APERCU = { preview: "1", url: "https://exemple.supabase.co/storage/v1/object/public/resources/demo.pdf", name: "demo.pdf" };
55
+
56
+ describe("aperçu interne", () => {
57
+ it("accepte l'hôte configuré quand il est intégré", async () => {
58
+ const r = await csp({ ...APERCU, embed: "1" });
59
+ expect(r.statut).toBe(200);
60
+ expect(r.ancetres, "l'hôte configuré doit pouvoir encadrer l'aperçu").toContain(HOTE);
61
+ });
62
+
63
+ // Hors intégration, rien ne change : un aperçu autonome n'a aucune raison d'être encadré.
64
+ it("reste en même origine quand il ne l'est pas", async () => {
65
+ const r = await csp(APERCU);
66
+ expect(r.ancetres.trim()).toBe("'self'");
67
+ });
68
+ });
69
+
70
+ describe("page d'audience", () => {
71
+ // Elle ne passait AUCUN paramètre : `frame-ancestors 'none'`, encadrable par personne.
72
+ it("accepte l'hôte configuré quand elle est intégrée", async () => {
73
+ const r = await csp({ present: PRESENTATION.slug, embed: "1" });
74
+ expect(r.statut).toBe(200);
75
+ expect(r.ancetres).toContain(HOTE);
76
+ });
77
+
78
+ it("reste inencadrable sinon — c'est une page publique", async () => {
79
+ const r = await csp({ present: PRESENTATION.slug });
80
+ expect(r.ancetres.trim()).toBe("'none'");
81
+ });
82
+ });
83
+
84
+ describe("le chemin nominal est au moins aussi portable que le chemin d'erreur", () => {
85
+ // ⚠️ LE TEST QUI AURAIT ÉVITÉ TOUT ÇA. Un refus encadrable devant une réussite qui ne l'est pas
86
+ // est un signe : on a corrigé l'exception sans corriger la règle.
87
+ it("succès et refus acceptent les mêmes hôtes", async () => {
88
+ const succes = await csp({ ...APERCU, embed: "1" });
89
+ const refus = await csp({ preview: "1", url: "https://ailleurs.example/x.pdf", name: "x.pdf", embed: "1" });
90
+ expect(refus.statut).toBe(404);
91
+ for (const origine of [HOTE, "'self'"]) {
92
+ expect(succes.ancetres, `succès : ${origine}`).toContain(origine);
93
+ expect(refus.ancetres, `refus : ${origine}`).toContain(origine);
94
+ }
95
+ });
96
+ });
97
+
98
+ describe("carte d'identité", () => {
99
+ // Un booléen ne suffisait pas : un hôte doit voir que SON domaine manque, sans ouvrir un document.
100
+ it("dit POUR QUELLES origines l'instance accepte d'être encadrée", async () => {
101
+ const r = await csp({ contract: "1" });
102
+ const carte = JSON.parse(r.corps);
103
+ expect(carte.frameAncestors).toContain(HOTE);
104
+ expect(carte.frameAncestors).toContain("'self'");
105
+ });
106
+
107
+ it("le dit aussi quand rien n'est configuré — c'est justement le cas qui pose problème", async () => {
108
+ const carte = JSON.parse((await csp({ contract: "1" }, [])).corps);
109
+ expect(carte.frameAncestors).toEqual(["'self'", "https://*.vercel.app"]);
110
+ });
111
+ });
package/server/handler.js CHANGED
@@ -2557,6 +2557,12 @@ async function handler(req, res) {
2557
2557
  capabilities: [
2558
2558
  "docshare", "presentations", "embed-denied", "host-fetch", "brand-reference",
2559
2559
  ],
2560
+ // ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
2561
+ // suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
2562
+ // l'intégration est possible. C'est la seule panne qu'il ne peut pas diagnostiquer
2563
+ // autrement — le navigateur bloque avant tout script, et rien ne peut lui être émis.
2564
+ // Ce n'est pas un secret : ces mêmes valeurs partent dans chaque en-tête CSP servi.
2565
+ frameAncestors: ["'self'", "https://*.vercel.app"].concat(PLAYER.config.extraFrameAncestors || []),
2560
2566
  // Greffons de l'hôte : présents ou coupés (PLAYER_PLUGINS_OFF). Booléens uniquement.
2561
2567
  plugins: {
2562
2568
  bot: !!p.bot, visitors: !!p.visitors, brandIntro: !!p.brandIntro,
@@ -2612,7 +2618,12 @@ async function handler(req, res) {
2612
2618
  const supaKey = process.env.SUPABASE_PUBLISHABLE_KEY || "";
2613
2619
  let alogo = ""; try { alogo = await PLAYER.branding.logo(); } catch { /* sans logo */ }
2614
2620
  const anonce = crypto.randomBytes(16).toString("base64");
2615
- return sendPresentHtml(res, presentHtml(pres, anonce, alogo, supaUrl, supaKey), anonce, supaUrl, originOf(alogo));
2621
+ // Même sujet, trouvé en vérifiant le précédent : cette page ne passait AUCUN paramètre,
2622
+ // donc `frame-ancestors 'none'` — encadrable par personne, pas même par sa propre origine.
2623
+ // `'none'` reste le défaut hors intégration (anti-clickjacking) ; en `?embed=1`, un hôte
2624
+ // qui affiche l'audience dans son application doit pouvoir le faire.
2625
+ return sendPresentHtml(res, presentHtml(pres, anonce, alogo, supaUrl, supaKey), anonce, supaUrl,
2626
+ originOf(alogo), embed ? embedFrameAncestors() : "'none'");
2616
2627
  }
2617
2628
 
2618
2629
  // Aperçu interne (depuis la bibliothèque) : même visionneuse pdf.js, SANS lien tracé ni suivi.
@@ -2633,7 +2644,18 @@ async function handler(req, res) {
2633
2644
  const pnonce = crypto.randomBytes(16).toString("base64");
2634
2645
  // Aperçu interne : CSP relâchée (supabase-js jsdelivr + Realtime wss) pour la présence + le chat live,
2635
2646
  // framing MÊME ORIGINE (iframe DocViewer). La visionneuse PUBLIQUE /doc/:slug garde sa CSP stricte.
2636
- return sendPresentHtml(res, viewerHtml(pseudo, pnonce, plogo), pnonce, supaUrl, originOf(plogo), "'self'");
2647
+ // ⚠️ `'self'` ÉTAIT ÉCRIT EN DUR ICI, et l'hypothèse était juste jusqu'au jour où elle a
2648
+ // cessé de l'être. Chez l'hôte d'origine, l'application et le player sont le MÊME
2649
+ // déploiement : même origine, `'self'` suffit, et c'est même le bon réglage. Pour une
2650
+ // instance séparée — c'est toute la raison d'être d'une seconde instance — l'aperçu est sur
2651
+ // un domaine et l'application sur un autre. Le navigateur bloquait alors l'iframe avant
2652
+ // tout script : aucun `embed-denied` ne pouvait partir, et l'hôte voyait un silence.
2653
+ //
2654
+ // Conséquence absurde relevée par cet hôte : la page de REFUS, corrigée la veille, était
2655
+ // encadrable chez lui — pas la page de SUCCÈS. Le chemin d'erreur était plus portable que
2656
+ // le chemin nominal.
2657
+ return sendPresentHtml(res, viewerHtml(pseudo, pnonce, plogo), pnonce, supaUrl, originOf(plogo),
2658
+ embed ? embedFrameAncestors() : "'self'");
2637
2659
  }
2638
2660
 
2639
2661
  const share = slug ? await getShareBySlug(slug) : null;
@@ -2714,10 +2736,22 @@ async function handler(req, res) {
2714
2736
  // (+ extras via DOC_FRAME_ANCESTORS, séparés par des espaces — futurs domaines
2715
2737
  // custom d'XP). La CSP frame-ancestors PRIME sur le X-Frame-Options SAMEORIGIN
2716
2738
  // global du vercel.json (spec : XFO ignoré quand frame-ancestors est présent).
2739
+ // ⚠️ EMBARQUEMENT DEMANDÉ SANS HÔTE AUTORISÉ : le seul cas où le player ne peut pas se
2740
+ // défendre lui-même. C'est le NAVIGATEUR qui bloque, avant que la page ne soit chargée —
2741
+ // donc aucun `embed-denied` ne peut partir, et l'hôte voit un silence indiscernable d'une
2742
+ // instance injoignable. Le signaler ici est la seule occasion : c'est le moment exact où l'on
2743
+ // sait qu'on est destiné à être encadré. Sans DOC_FRAME_ANCESTORS, personne ne peut AFFICHER,
2744
+ // exactement comme sans PLAYER_HOST_AUTHZ_URL personne ne peut DIFFUSER.
2745
+ if (share.embed && !(PLAYER.config.extraFrameAncestors || []).length) {
2746
+ try {
2747
+ PLAYER.errors.capture(
2748
+ new Error("?embed=1 demandé mais DOC_FRAME_ANCESTORS est vide : seuls une page de même origine et *.vercel.app peuvent encadrer cette instance"),
2749
+ { route: "doc", indice: "le navigateur bloquera l'iframe avant le chargement — aucun embed-denied ne partira" },
2750
+ );
2751
+ } catch { /* jamais bloquant */ }
2752
+ }
2717
2753
  const frameAncestors = share.embed
2718
- ? ["'self'", "https://*.vercel.app"]
2719
- .concat(String(process.env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean))
2720
- .join(" ")
2754
+ ? embedFrameAncestors()
2721
2755
  : "'self'";
2722
2756
  return sendHtml(res, 200, viewerHtml(share, nonce, logoUrl, pitch), `'nonce-${nonce}'`, [originOf(logoUrl), originOf(share.bot_avatar), originOf(share.brand_logo)].filter(Boolean).join(" "), frameAncestors);
2723
2757
  } catch (error) {