discovery-media-player 0.1.0

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 ADDED
@@ -0,0 +1,515 @@
1
+ # Contrat du player — v1
2
+
3
+ > **Le point de passage entre les projets.** Ce fichier est la seule source de vérité sur ce qu'un
4
+ > projet hôte peut appeler. Toute session (studio ou ADV) le lit avant de toucher à l'intégration.
5
+ >
6
+ > **Une seule SOURCE, deux formes de consommation** (décidé le 13/08/2026) :
7
+ > - **3D Discovery** installe le player comme **dépendance**. Son URL et sa base ne bougent pas —
8
+ > des liens `/doc/:slug` sont déjà chez des prospects, on ne les casse pas.
9
+ > - **Le second hôte** en fait tourner un **déploiement autonome**, sur son domaine et sa base.
10
+ > Aucune donnée ADV ne transite ni ne se stocke chez 3D Discovery. Données sensibles.
11
+ >
12
+ > Une amélioration du cœur profite aux deux au déploiement suivant, sans rien reprendre. Ce fichier
13
+ > ne décrit donc **que la frontière**, pas l'intérieur.
14
+
15
+ ## Les cinq règles
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.
21
+ 2. **Additif par défaut.** Ajouter une action, un paramètre ou un champ ne casse aucun hôte : c'est
22
+ libre, ça se note au journal. **Retirer ou renommer est une rupture** → nouvelle version, les
23
+ deux servies pendant la migration.
24
+ 3. **⚠️ Ordre de déploiement : le player part AVANT les hôtes.** L'inverse fait disparaître la
25
+ fonctionnalité partout, d'un coup, sans erreur visible.
26
+ 4. **L'hôte épingle la version qu'il vise** et un test le vérifie. Une dérive doit être bruyante,
27
+ pas silencieuse. Le player expose sa carte d'identité sur **`GET /api/doc?contract=1`** — sans
28
+ session, sans base, et sans cache : c'est un point de diagnostic, il doit répondre même quand le
29
+ reste ne va pas.
30
+
31
+ ```json
32
+ { "product": "discovery-media-player", "contract": 1, "version": "0.1.0",
33
+ "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference"],
34
+ "plugins": { "bot": true, "visitors": true, "brandIntro": true, "botBrowser": true, "providerQuotas": true } }
35
+ ```
36
+
37
+ **`contract` est LE champ à épingler** : il ne bouge que sur une rupture (règle 2) — ajouter une
38
+ action, un paramètre ou un motif de refus ne le change pas. `capabilities` se teste par
39
+ PRÉSENCE, jamais par ordre. `plugins` permet à un hôte de refuser de démarrer si le mur d'accès
40
+ manque alors qu'il compte dessus. Aucune URL, aucun secret, aucun nom d'hôte n'y figure — un
41
+ point de diagnostic qui divulgue sa configuration est un cadeau à qui le sonde.
42
+
43
+ **Qui prévient qui.** Une PR d'hôte qui exige une version plus récente l'écrit **dans son titre**
44
+ (« requiert player ≥ v2 »). Elle ne peut pas être mergée avant que l'instance correspondante soit
45
+ 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.
47
+
48
+ ---
49
+
50
+ ## Surface v1
51
+
52
+ ### Pages servies
53
+
54
+ | URL | Public | Rôle |
55
+ |---|---|---|
56
+ | `/doc/:slug` | prospect anonyme | lien tracé par destinataire (suivi complet) |
57
+ | `/present/:slug` | audience anonyme | page spectateur d'une présentation en direct |
58
+ | `/api/doc?preview=1&url=…` | membre de l'hôte | aperçu interne (pas de lien tracé, suivi interne séparé) |
59
+
60
+ **Paramètres de l'aperçu interne** : `url` (obligatoire, doit passer l'allowlist de Storage),
61
+ `name`, `title`, `docId`, `by` (nom du présentateur), `av` (avatar), `uemail` (membre — c'est lui
62
+ qui déclenche le suivi interne), `autopresent=1`, `resume=<slug>`, `embed=1`.
63
+
64
+ ### Pont `postMessage` (iframe ↔ hôte)
65
+
66
+ Décrit une seule fois dans `player/src/bridge.ts`, importé des deux côtés. Validation **par type**,
67
+ pas par origine (un contrôle d'origine strict bloquait la réception en production).
68
+
69
+ - **player → hôte** : `close`, `share`, `embed-ready`, `embed-denied {reason}`, `present-left`,
70
+ `present-denied`, `present-invite {slug}`, `present-handover {slug}`, `present-switch {slug}`
71
+ - **hôte → player** : `handover-done`
72
+
73
+ Le `slug` traversant la frontière est borné (`[A-Za-z0-9_-]{1,64}`), le `reason` aussi
74
+ (`[a-z-]{1,40}`, ramené à `unknown` sinon).
75
+
76
+ **`bridge.ts` est importable, et c'est le but** — il est publié sous MIT précisément pour qu'un hôte
77
+ n'ait pas à recopier des constantes. Un `3dd-doc-embed-ready` recopié à la main est un contrat qui
78
+ existe en deux exemplaires : le jour où le préfixe change, un seul des deux le sait. Un hôte qui ne
79
+ peut pas l'importer (autre langage, autre chaîne de build) copie le fichier *tel quel* et note d'où
80
+ il vient, plutôt que d'en extraire trois chaînes de caractères.
81
+
82
+ ### Actions `POST /api/doc`
83
+
84
+ - **Suivi** (public, sans authentification) : événements `open` / `page` / `heartbeat` / `session`.
85
+ Les sessions **internes** portent `internal:true` — deux populations, jamais fusionnées.
86
+ - **Présentation** : `present-start|page|end|touch` (démarrage public, pilotage par jeton),
87
+ `present-attend` (présence), `present-chat`, `present-react`, `present-msg-edit|delete`,
88
+ `present-chatlock`, `present-upload-url`.
89
+ - **Présentation, membre authentifié (JWT)** : `present-list|reclaim|handover|owner-end|stats|
90
+ doc-list|switch|content`.
91
+ - **Re-partage** : `reshare`.
92
+ - **Liens tracés, membre authentifié (JWT)** : `docshare.create|list|revoke|setauth|overview|sessions|test`.
93
+ ⚠️ **QUI a le droit est une règle de l'HÔTE**, pas du player : elle passe par le contexte,
94
+ `identity.canManageShares(user, action)`. Le player vérifie le jeton ; il ne connaît pas les
95
+ rôles métier d'une application qu'il ne connaît pas. Sans réponse de l'hôte, ou en cas de panne
96
+ de sa règle : **refus**. Un droit qu'on ne sait pas accorder ne s'accorde pas.
97
+
98
+ ⚠️ **L'ACTION est transmise** (`create`, `list`, `list.all`, `revoke`, `setauth`, `overview`,
99
+ `sessions`, `test`). Un droit unique pour les sept confondrait deux choses : **envoyer un
100
+ document à SON prospect** est un acte commercial ordinaire ; **révoquer le lien d'un autre** ou
101
+ **lire l'aperçu global** est un acte d'administration. Avec un seul droit, ou bien les commerciaux
102
+ ne peuvent rien envoyer, ou bien chacun révoque les liens de tout le monde. Un hôte sans cette
103
+ distinction ignore simplement le paramètre.
104
+
105
+ `list.all` est une question SUPPLÉMENTAIRE posée lors d'une liste : répondre non restreint la
106
+ réponse aux liens créés par le demandeur. Sans elle, un commercial verrait à qui d'autre le
107
+ document a été envoyé — les prospects de ses collègues.
108
+
109
+ **Limite connue — deux portées, pas trois.** Le player ne connaît que « tous » et « les miens ».
110
+ Un hôte dont le modèle a une portée intermédiaire (l'équipe, l'agence, le périmètre) ne peut pas
111
+ l'exprimer : soit il accorde tout, soit il restreint au demandeur. Signalé par ADV, dont les
112
+ chefs d'équipe ont une portée `all` maison — la mapper telle quelle leur donnerait les
113
+ destinataires de leurs collègues, c'est-à-dire la fuite que `list.all` vient de fermer. Ils s'en
114
+ tiennent donc à la règle du player.
115
+
116
+ **Le jour où ça deviendra nécessaire**, la forme est déjà claire : `canManageShares` répondrait
117
+ `true` (tout), `false` (rien), ou **une liste d'emails** dont le demandeur a le droit de voir les
118
+ liens. Le player filtrerait sur `created_by`, sans jamais rien savoir de l'organisation de
119
+ l'hôte. Non fait aujourd'hui : aucun hôte n'a la population pour l'utiliser, et un chemin de code
120
+ sans usage réel est un chemin non éprouvé.
121
+
122
+ ⚠️ **La règle de l'hôte doit être évaluée EN DIRECT, jamais recopiée dans le jeton.** Un rôle
123
+ miroité dans `app_metadata` à la connexion ne peut, par construction, pas refléter une
124
+ **désactivation** : il reste périmé jusqu'à expiration du jeton, c'est-à-dire précisément dans le
125
+ seul cas qui compte. Quelqu'un ayant quitté l'entreprise continuerait à créer des liens vers des
126
+ prospects.
127
+ - **Mur d'accès visiteur** (greffon) : `visitor-request|verify|google`.
128
+
129
+ ### D'où le player tire un fichier — « l'hôte sert le fichier »
130
+
131
+ Le player **ne stocke jamais** un document : il le relaie, et seulement depuis une **origine
132
+ déclarée** (garde anti-SSRF). Un hôte dont les documents ne vivent pas dans un Storage joignable
133
+ directement — cas d'ADV, dont **99 % des documents sont sur le serveur de fichiers de l'appli
134
+ un serveur de fichiers tiers**, derrière une clé d'API — expose **une route à lui** et n'autorise qu'elle.
135
+
136
+ ```
137
+ ┌─▶ Serveur tiers
138
+ prospect ──▶ player /doc/:slug?file=1 ──▶ route de l'hôte ─┼─▶ Partage
139
+ authentifie ├─▶ Storage de l'hôte
140
+ └─▶ Dropbox, Drive…
141
+ ```
142
+
143
+ **Le player ne voit qu'UNE porte, quel que soit le nombre de sources derrière.** C'est la
144
+ propriété qui compte : un hôte peut brancher un nouveau service de fichiers sans que le player
145
+ change d'une ligne, et sans qu'aucun lien déjà envoyé cesse de fonctionner. Les connecteurs sont
146
+ l'affaire de l'hôte — le player n'a ni à les connaître, ni à porter leurs identifiants.
147
+
148
+ ⚠️ **Conséquence pour l'hôte : prévoir la multi-source DÈS la première.** La référence signée que
149
+ la route reçoit doit dire de QUELLE source vient le fichier (`{source, ref}`), même s'il n'y en a
150
+ qu'une au début. La coder à la forme d'un seul fournisseur oblige à reprendre la route et tous ses
151
+ appelants au deuxième. Et le garde-fou anti-SSRF de l'hôte devient une allowlist par source, pas
152
+ une origine unique.
153
+
154
+ ⚠️ **Le player ne porte JAMAIS les identifiants d'un tiers.** Ils appartiennent à l'hôte, comme la
155
+ relation avec ce tiers. Autoriser directement l'origine du tiers donnerait au player le droit d'y
156
+ lire n'importe quoi, avec des identifiants : c'est précisément ce que la garde interdit.
157
+
158
+ **Trois exigences sur la route de l'hôte, faute de quoi l'expérience se dégrade sans erreur :**
159
+
160
+ 1. **Elle doit relayer les requêtes `Range`** (répondre `206` + `Accept-Ranges: bytes`). C'est de
161
+ là que vient le chargement progressif : les premières pages s'affichent sans télécharger tout le
162
+ PDF. Sans Range, un document lourd reste blanc plusieurs secondes. Si la source ne sait pas
163
+ faire de Range, c'est à la route de l'hôte de le simuler.
164
+ 2. **Elle doit accepter un appel SERVEUR À SERVEUR.** Un lien tracé `/doc/:slug` est ouvert par un
165
+ prospect **sans session chez l'hôte** : c'est l'instance du player qui va chercher le fichier,
166
+ pas le navigateur. La route reconnaît donc l'instance par un **secret partagé**
167
+ (`PLAYER_HOST_FETCH_SECRET`, en-tête serveur), et le player ne l'appelle que pour un partage
168
+ existant et non révoqué.
169
+ 3. **Elle ne doit JAMAIS relayer le `Content-Length` reçu de sa source.** La plus chère des trois,
170
+ et la seule qui ne se voit pas. `fetch()` **décompresse le corps pour vous et garde les en-têtes
171
+ reçus** : si la source (CDN, S3, proxy) a répondu en `gzip`, renvoyer fidèlement son
172
+ `Content-Length` annonce la taille du COMPRESSÉ alors que vous servez du décompressé. Le lecteur
173
+ coupe à l'octet annoncé → **PDF tronqué**, sans erreur nulle part. Ce n'est plus « le chargement
174
+ progressif ne marche pas », c'est « le document est faux ».
175
+
176
+ La règle tient en une phrase : **annoncez la taille des octets que vous envoyez, jamais celle que
177
+ vous avez reçue.** Demandez `Accept-Encoding: identity` en amont, et **refusez un `206` porteur
178
+ d'un `Content-Encoding`** : les bornes d'un fragment portent sur les octets compressés, et un
179
+ morceau de gzip ne se décompresse pas seul — mieux vaut un `502` bruyant qu'un document faux.
180
+
181
+ *(Le player applique lui-même cette règle depuis le 13/08 — `relayerFichier()`, un seul chemin
182
+ pour ses trois routes de streaming. Le piège nous concernait aussi.)*
183
+
184
+ ### Un refus se dit — `embed-denied`
185
+
186
+ Un hôte qui intègre la visionneuse (`?embed=1`) attend `embed-ready`. Il est tentant d'en faire un
187
+ délai d'attente : « rien reçu en 5 s ⇒ le player est absent ⇒ j'ouvre le document avec le lecteur du
188
+ navigateur ». **C'est un trou de sécurité**, parce que le silence recouvre deux cas opposés :
189
+
190
+ | Ce que l'hôte observe | Ce que ça peut vouloir dire | Ce que le repli produit |
191
+ |---|---|---|
192
+ | pas d'`embed-ready` | instance absente, en panne, en déploiement | ✅ repli légitime |
193
+ | pas d'`embed-ready` | le player **refuse** : lien révoqué, mur d'accès, greffon manquant | ❌ **ouvre le document que le player venait de fermer** |
194
+
195
+ Le player émet donc `embed-denied` avec un `reason`. **Tous** ses chemins de refus l'émettent —
196
+ lien tracé, aperçu interne, page d'audience — et leurs pages restent volontairement encadrables en
197
+ mode intégré : sinon le navigateur bloque le rendu et le message ne partirait pas.
198
+
199
+ | `reason` | Ce qui s'est passé | **Conduite de l'hôte** | Où ça se soigne |
200
+ |---|---|---|---|
201
+ | `revoked` | lien inconnu ou révoqué | **ne pas ouvrir** | chez l'hôte : le lien n'a plus lieu d'être |
202
+ | `auth-required` | document réservé, visiteur non connecté | **ne pas ouvrir** | nulle part : le mur reste affiché, on peut s'y connecter |
203
+ | `auth-unavailable` | document réservé, mur d'accès absent de l'instance | **ne pas ouvrir** | configuration du player (greffon `visitors` coupé) |
204
+ | `ended` | présentation terminée ou inconnue | ne pas ouvrir | nulle part : elle est finie |
205
+ | `url-not-allowed` | l'URL du fichier n'est pas couverte par la garde | **OUVRIR** — et signaler la configuration | configuration du player (`PLAYER_STORAGE_ORIGINS`, `PLAYER_HOST_FETCH_BASE`) |
206
+
207
+ ⚠️ **`url-not-allowed` est la seule exception à « on ne replie pas », et il faut la lire avec soin.**
208
+ Les deux motifs de *configuration* demandent des conduites **opposées**, ce que la seule colonne
209
+ « où ça se soigne » ne disait pas :
210
+
211
+ - avec `url-not-allowed`, **aucune décision d'accès n'a été prise** — le player n'a pas pu atteindre
212
+ le fichier. Le traiter comme un refus affiche « Document indisponible » à un membre qui a
213
+ parfaitement le droit de lire, et l'envoie chercher un document disparu là où c'est une variable
214
+ d'environnement qui est en cause ;
215
+ - avec `auth-unavailable` au contraire, le document **était** censé être protégé et c'est le mur qui
216
+ manque : ouvrir contournerait la protection.
217
+
218
+ Et ce qui rend l'exception sûre plutôt que commode : **`url-not-allowed` n'est émis que par l'aperçu
219
+ interne**, jamais sur un lien tracé public. Le lecteur qui le reçoit est un membre de l'hôte, déjà
220
+ authentifié chez lui, qui a le droit de lire ce document — l'hôte n'ouvre donc rien qu'il n'aurait
221
+ pas ouvert de toute façon. Si un jour ce motif apparaissait sur un chemin public, cette ligne du
222
+ tableau devrait changer AVANT.
223
+
224
+ La règle sous-jacente, plus sûre que la liste : **on ne replie jamais sur un refus d'ACCÈS ; on peut
225
+ replier sur une incapacité à ATTEINDRE.** *(Distinction relevée par ADV en rangeant les motifs — la
226
+ version précédente de ce tableau laissait un hôte se tromper dans un sens ou dans l'autre.)*
227
+
228
+ Ces deux motifs ressemblent pourtant tous les deux à une instance injoignable, et un hôte qui
229
+ branche sa première visionneuse (`?preview=1&embed=1`) les rencontrera avant tout le reste. Le
230
+ diagnostic tient en trois mots ; sans le motif, il coûte une demi-journée.
231
+
232
+ **Un hôte ne replie jamais après un refus d'accès** (les quatre premiers motifs) — la décision est
233
+ prise, il ne reste qu'à afficher son propre message. **Et « ne pas replier » vaut pour ce qu'il
234
+ PROPOSE, pas seulement pour ce qu'il fait automatiquement** : un lien « Ouvrir ↗ » laissé dans l'en-tête ferait du refus une
235
+ gêne contournable d'un clic. Ne pas replier tout seul mais offrir le bouton revient au même une
236
+ seconde plus tard. *(Généralisation proposée par ADV, retenue.)*
237
+
238
+ *(Le `reason` est indicatif et borné — il ne porte aucune donnée du document.)*
239
+
240
+ **L'aperçu interne porte toujours la marque de l'INSTANCE**, jamais celle d'un client : il n'accepte
241
+ pas de `brandKey`. C'est délibéré — l'aperçu est la surface des équipes, et un membre qui ouvre un
242
+ document depuis sa bibliothèque doit voir son propre outil. La marque du client sert à un lecteur
243
+ EXTERNE, qui ne doit pas voir le nom de l'outil qui la lui sert. Conséquence assumée : un document
244
+ d'un client A consulté en interne s'ouvre sous la marque de l'hôte. Ce n'est pas un défaut de
245
+ résolution.
246
+
247
+ ### Installer une instance
248
+
249
+ **Le schéma vit avec le player** : `player/supabase/init.sql` amène une base vierge à l'état
250
+ attendu, en un fichier rejouable, sans rien à lire ailleurs. Ce n'est pas la suite des migrations
251
+ de l'hôte historique — elles sont entrelacées avec les siennes, et trier 145 fichiers est une
252
+ question qui n'a de bonne réponse qu'une seule fois.
253
+
254
+ ⚠️ **Une base neuve s'installe DÉJÀ DURCIE.** L'avertissement sur `v12420` (ne pas retirer la
255
+ lecture anonyme avant d'avoir vérifié le broadcast, sous peine de figer les audiences en cours) ne
256
+ concerne QUE l'instance historique, qui doit sortir d'un état existant. `init.sql` ne crée aucune
257
+ politique de lecture publique : une instance neuve ne passe jamais par cet état, même
258
+ transitoirement. **Le point qui interdisait d'y mettre un dossier sensible n'y est donc jamais
259
+ vrai.** *(Relevé par ADV — confirmé.)*
260
+
261
+ **La dépendance** : le player est publié en **dépôt public sous AGPL-3.0**. Rien à
262
+ configurer chez l'hôte : ni jeton de lecture, ni clé de déploiement dans ses variables Vercel.
263
+
264
+ **`PLAYER_SOURCE_URL` pointe sur ce dépôt.** C'est ce qui rend l'obligation tenable : un lecteur
265
+ qui suit le lien « code source » depuis une page servie trouve exactement le code qui la sert.
266
+
267
+ Sur le **câblage** de l'hôte : il *appelle* le player, il ne le modifie pas — mais il vit dans le
268
+ même processus, et la position prudente est de le considérer couvert. C'est sans conséquence si on
269
+ le conçoit pour : **aucun secret ne doit s'y trouver en clair** (ils sont dans les variables
270
+ d'environnement), et ce qui reste est du branchement. Un câblage qu'on ne pourrait pas publier est
271
+ un câblage qui contient quelque chose qui n'a rien à y faire. *(Ce n'est pas un avis juridique :
272
+ c'est la posture retenue, et elle est sûre dans les deux cas.)*
273
+
274
+ ### Le câblage d'une instance — à qui il appartient
275
+
276
+ Une instance autonome, c'est **un petit dépôt qui appartient à l'hôte**, pas un dépôt généré par le
277
+ player. Il contient quatre choses et rien d'autre :
278
+
279
+ | | Ce que c'est |
280
+ |---|---|
281
+ | `package.json` | dépend du player |
282
+ | la route | une ligne : `module.exports = require("discovery-media-player").handler` |
283
+ | **le câblage de contexte** | l'unique fichier à écrire : `storage`, `db`, `identity`, `branding`, `limits`, `mail`, `errors` |
284
+ | `vercel.json` + variables | domaine, `/doc/:slug`, secrets |
285
+
286
+ **Il appartient à l'hôte parce qu'il ne contient que des décisions de l'hôte** : ses secrets, sa
287
+ base, qui a le droit de diffuser, quelle clé désigne quel client. Un dépôt généré par le player
288
+ devrait les deviner, et l'hôte n'aurait plus d'endroit où les changer. Le player fournit le moteur
289
+ et ce contrat ; le câblage est le seul code que l'hôte écrit — quelques centaines de lignes, une
290
+ fois.
291
+
292
+ `api/_player-context.js` du studio en est l'exemple de référence, et il est lisible comme tel.
293
+
294
+ ### Les portes se rouvrent toutes seules
295
+
296
+ Une application a plus d'un endroit qui ouvre un document, et il en réapparaît. ADV a trouvé sur sa
297
+ carte publique une visionneuse de 395 lignes que personne n'avait recensée : elle affichait des
298
+ documents **sans passer par le player**, donc sans être comptée. Ce n'est pas un oubli ponctuel,
299
+ c'est la pente naturelle d'un produit vivant — un `<iframe src="....pdf">` s'écrit en dix secondes.
300
+
301
+ **Chaque hôte doit tenir la liste de ses portes et la rechasser périodiquement.** Une recherche
302
+ suffit : `.pdf`, `window.open`, `<embed`, `<iframe` sur un fichier, `application/pdf`. Le tableau
303
+ des portes recensées vit chez chaque hôte, pas ici. La règle, elle, est commune : **une porte non
304
+ recensée est une lecture non comptée**, et l'écart ne se voit dans aucune statistique — il se voit
305
+ seulement quand quelqu'un le cherche.
306
+
307
+ ### Configuration côté player
308
+
309
+ | Variable | Rôle |
310
+ |---|---|
311
+ | `PLAYER_STORAGE_ORIGINS` | origines de Storage autorisées **en plus** de celle de la base du player. ⚠️ **Reste nécessaire même avec des instances séparées** : les fichiers d'un hôte vivent dans le Storage de SON appli, pas dans la base du player. |
312
+ | `PLAYER_PLUGINS_OFF` | greffons coupés (`bot`, `botBrowser`, `avatarClips`, `brandIntro`, `visitors`, `providerQuotas`) |
313
+ | `DOC_FRAME_ANCESTORS` | domaines autorisés à encadrer la visionneuse |
314
+ | `GOOGLE_MAPS_API_KEY` | carte et Street View de la présentation (restreinte par référent) |
315
+ | `PLAYER_HOST_FETCH_BASE` | **préfixe d'URL complet** de la route de fichiers de l'hôte (ex. `https://app.exemple.fr/api/documents/`). ⚠️ Un préfixe, pas une origine : autoriser un domaine entier rendrait le player capable d'appeler n'importe quelle route de l'hôte. |
316
+ | `PLAYER_HOST_FETCH_SECRET` | secret partagé, envoyé en en-tête **`x-player-fetch-secret`**. ⚠️ **En-tête uniquement, jamais en query** (les journaux gardent l'URL, il fuiterait en clair des deux côtés). ⚠️ **Uniquement vers la route de l'hôte** — jamais vers un Storage public, où il n'a rien à faire. Absent côté hôte ⇒ personne ne passe ; comparaison à temps constant, ≥ 32 caractères. |
317
+
318
+ ### Mentions affichées aux lecteurs
319
+
320
+ | Variable | Effet |
321
+ |---|---|
322
+ | `PLAYER_SOURCE_URL` | lien « Code source » — **obligation AGPL** |
323
+ | `PLAYER_LEGAL_URL` | lien « Mentions légales » de l'hôte |
324
+ | `PLAYER_PRIVACY_URL` | lien « Confidentialité » de l'hôte |
325
+ | `PLAYER_TRACKING_NOTICE` | remplace le texte de la mention de mesure |
326
+
327
+ ⚠️ **L'AGPL crée une obligation que la plupart des licences n'ont pas** : quiconque **utilise** le
328
+ logiciel à travers un réseau doit pouvoir en obtenir le source — pas seulement celui qui le
329
+ distribue, celui qui l'**expose**. Un lecteur de `/doc/:slug` est un utilisateur à ce titre. Chaque
330
+ instance doit donc offrir cet accès, et une instance **modifiée** doit offrir **sa** version.
331
+
332
+ ⚠️ **La mention de mesure s'affiche PAR DÉFAUT** sur les pages qui tracent — lien tracé et page
333
+ audience — et seulement sur elles. Un lien tracé enregistre qui a ouvert, quelles pages, combien de
334
+ temps et depuis quel appareil : c'est un traitement de données personnelles, la personne doit
335
+ pouvoir le savoir. **L'absence des trois liens est un choix de l'hôte ; l'absence de celle-ci est
336
+ un risque.** L'aperçu interne ne l'affiche pas : personne d'extérieur ne le lit.
337
+
338
+ > Le texte par défaut et la base légale retenue méritent une relecture juridique. Le player fournit
339
+ > l'emplacement et un texte factuel, pas un avis.
340
+
341
+ ### Formats affichés
342
+
343
+ Le player n'est **pas** limité au PDF. Une **image** (`png`, `jpg`, `jpeg`, `webp`, `gif`, `avif`,
344
+ reconnue par le NOM du fichier — le type MIME n'est pas toujours renvoyé par la source) s'affiche
345
+ comme une page unique, avec tout le chrome générique : loader, zoom, plein écran, partager,
346
+ télécharger, **et le suivi de consultation**. Une image ouverte dans le player est donc tracée
347
+ exactement comme un PDF.
348
+
349
+ ⚠️ **Le format se déduit du NOM du fichier, pas du type MIME** (une source de stockage ne le
350
+ renvoie pas toujours). Concrètement :
351
+
352
+ - pour un **lien tracé**, le nom vient de `fileName` fourni à `docshare.create` — **toujours le
353
+ renseigner** ;
354
+ - pour l'**aperçu interne**, du paramètre `&name=` ;
355
+ - à défaut, le player retombe sur l'URL, et l'extension doit y être **en fin de chaîne ou juste
356
+ avant le `?`**. Une route d'hôte à URL opaque (`/raw?d=…`) ne dit rien du format : une image y
357
+ serait envoyée à pdf.js et n'afficherait **rien** — écran vide, pas d'erreur. Faire porter le nom
358
+ par le chemin (`/raw/plan.png?d=…`) est le repli sûr.
359
+
360
+ **Autres formats** (`.docx`, `.xlsx`, …) : le player **ne les affiche pas** et n'a pas de repli —
361
+ il affiche « Impossible d'afficher ce document ». Ce n'est donc ni le player ni un choix de
362
+ l'hôte : **ces formats ne doivent pas lui être envoyés**, l'hôte les sert en téléchargement.
363
+
364
+ ### Marque
365
+
366
+ Par document : `brand_logo` (URL) et `brand_dark` (loader sur fond sombre) sur le lien de partage.
367
+ Le loader et le mur d'accès s'y conforment déjà.
368
+
369
+ **Marque de l'ÉDITEUR** (l'entreprise qui exploite l'instance — à distinguer du logo du client,
370
+ qui est par document) :
371
+
372
+ | Variable | Effet |
373
+ |---|---|
374
+ | `PLAYER_BRAND_NAME` | nom affiché : suffixe de titre d'onglet, mot-marque du loader, nom par défaut de l'assistant, objet du courriel de re-partage |
375
+ | `PLAYER_BRAND_POWERED_BY` | mention « Propulsé par … » en pied de page et « Powered by … » sous le logo d'un client |
376
+ | `PLAYER_LOADER_NAME` | marque affichée **par le loader** — repli sur `PLAYER_BRAND_NAME` |
377
+
378
+ ⚠️ **TROIS identités se croisent sur une page de document, et les confondre se voit.**
379
+
380
+ | | Ce que c'est | Où |
381
+ |---|---|---|
382
+ | **Le produit** | le logiciel qui sert la page | titre d'onglet, « Propulsé par » |
383
+ | **L'exploitant** | l'entreprise qui fait tourner l'instance | **le loader** — la première chose que voit le lecteur |
384
+ | **Le client** | celui dont on montre le document (`brand_logo` sur le lien) | le loader, **à la place** de l'exploitant |
385
+
386
+ L'erreur a été commise une fois : le nom du produit s'est retrouvé sur le loader, à la place de
387
+ la marque attendue. D'où `PLAYER_LOADER_NAME`, distinct.
388
+
389
+ **Marque PAR CLIENT — résolue par l'hôte.** Une instance sert plusieurs clients : le loader doit
390
+ porter la marque de celui dont on montre le document. Le lien porte une **référence** (`brandKey`,
391
+ posée à la création), pas une copie du logo — et le player ne sait pas ce qu'est un client. Il
392
+ appelle l'hôte :
393
+
394
+ ```
395
+ ctx.branding.forKey(brandKey) → { logo, name, dark } | null
396
+ ```
397
+
398
+ | Champ | Rôle | ⚠️ |
399
+ |---|---|---|
400
+ | `logo` | URL affichée par le loader et le mur d'accès | doit être joignable depuis le navigateur du lecteur |
401
+ | `name` | **le repli quand le logo ne charge pas** — et il ne charge pas toujours | **le plus oublié.** Sans lui, un logo cassé laisse un vide au lieu d'un nom |
402
+ | `dark` | loader sur fond sombre | `false` par défaut |
403
+
404
+ `null` (clé inconnue, client supprimé) ⇒ le lien retombe sur la marque de l'instance. Ce n'est pas
405
+ une erreur : c'est le comportement attendu.
406
+
407
+ ⚠️ **`name` n'est pas décoratif.** C'est précisément la valeur qui sert quand le reste échoue, donc
408
+ celle qu'on découvre manquante le jour où on en a besoin. Le premier hôte à câbler `forKey` l'a
409
+ omise en lisant ce contrat — d'où ce tableau.
410
+
411
+ **Où vit `forKey` quand l'instance est séparée.** Une instance autonome ne peut pas importer le
412
+ code de l'application hôte — deux projets, deux déploiements. `forKey` **appelle donc une route de
413
+ l'hôte**, comme l'autorisation le fait déjà. Recopier la correspondance clé → logo dans le câblage
414
+ serait une copie de plus : bénigne au début (les URL sont absolues, c'est le fichier servi qui
415
+ change quand la charte bouge), mais elle réclame une modification et un déploiement du câblage
416
+ chaque fois qu'un client apparaît — et surtout **elle a l'air officielle**. Une source unique qui
417
+ coûte un aller-retour au premier affichage vaut mieux qu'une copie qui ne coûte rien jusqu'au jour
418
+ où elle ment.
419
+
420
+ ⚠️ **Cet appel ne doit jamais empêcher de lire.** Hôte injoignable, clé inconnue, délai dépassé ⇒
421
+ `null` ⇒ marque de l'instance. Le loader dégrade ; le document s'ouvre. C'est déjà le comportement
422
+ du player (`brandForShare` ne lève jamais), mais la route de l'hôte doit tenir la même promesse :
423
+ répondre vite, ou répondre rien.
424
+
425
+ Il n'y a **aucun registre de marques dans le player**, et ce n'est pas un manque : l'hôte a déjà ses
426
+ clients quelque part (une fiche CRM, une organisation). Un second registre à tenir à jour serait une
427
+ copie de plus à faire diverger. *(Un registre `brand.list|upsert|delete` a existé une demi-journée
428
+ avant d'être retiré pour cette raison.)*
429
+
430
+ ⚠️ **Pourquoi une référence et pas une copie.** Un lien tracé vit des semaines dans une boîte mail.
431
+ Un logo recopié dans le lien ne bouge plus : rectifier la charte d'un client ne changerait rien à
432
+ ce qui est déjà parti. Résolue à l'affichage, la référence se propage à tout l'existant.
433
+
434
+ **Ordre de résolution** : référence du lien → logo recopié (flux historiques, toujours accepté) →
435
+ marque de l'instance. Le player ne sait pas ce qu'est un client : il ne connaît que des clés, et
436
+ c'est l'hôte qui décide quelle clé pour quel document.
437
+
438
+ ⚠️ **NEUTRE PAR DÉFAUT, et c'est voulu.** Sans configuration, le player n'affiche la marque de
439
+ personne : ni titre suffixé, ni mention de pied, ni mot-marque. Envoyer à un client un document
440
+ portant la marque d'une autre société est une faute, pas un détail — le défaut le rend impossible.
441
+ Les deux variables sont indépendantes : un hôte peut vouloir son nom sans mention de pied.
442
+
443
+ ⚠️ **Le studio doit poser ces deux variables avant de déployer**, faute de quoi ses pages perdent
444
+ « Propulsé par 3D Discovery » et le suffixe de leur titre. Rien ne casse — c'est visible, pas
445
+ fonctionnel — mais ça se voit tout de suite.
446
+
447
+ ---
448
+
449
+ ## Journal du contrat
450
+
451
+ Toute évolution de la frontière se note ici, datée, avec sa nature.
452
+
453
+ | Date | Nature | Changement |
454
+ |---|---|---|
455
+ | 2026-08-13 | — | Ouverture du contrat en v1, sur l'existant. Aucun hôte tiers branché à ce jour. |
456
+ | 2026-08-13 | décision | **Isolation** : ADV aura son propre déploiement, sa propre base, son propre domaine. Le studio ne bouge pas. Une seule source de code. |
457
+ | 2026-08-13 | précision | En-tête du secret figé : **`x-player-fetch-secret`** (nom proposé par la session ADV, retenu pour son explicité). |
458
+ | 2026-08-13 | précision | Le player affiche **aussi les images**, tracées comme un PDF. Ce n'était écrit nulle part. |
459
+ | 2026-08-13 | additif | **Mentions aux lecteurs** : lien code source (AGPL), mentions légales, confidentialité, et une mention de mesure affichée PAR DÉFAUT sur les pages qui tracent. |
460
+ | 2026-08-13 | additif | **Marque du client résolue par l'hôte** (`brandKey` sur le lien → `branding.forKey`) : un logo rectifié se propage aux liens déjà envoyés, sans registre à synchroniser. Migration `v12421`. |
461
+ | 2026-08-13 | retiré | ~~Registre des marques~~ (`brand.list|upsert|delete`, `brandKey` à la création d'un lien) : le loader porte la marque du client, par référence — un logo rectifié se propage aux liens déjà envoyés. Migration `v12421`. Rétrocompatible. |
462
+ | 2026-08-13 | additif | **Portée de la liste** : `list.all` décide si `docshare.list` renvoie tous les liens d'un document ou seulement ceux du demandeur. La réponse porte `scope: "all"|"mine"`. |
463
+ | 2026-08-13 | additif | **`canManageShares` reçoit l'ACTION** — demandé par ADV, dont le modèle sépare l'envoi (tout conseiller) de l'administration des liens. Aucune rupture : un hôte qui ne distingue pas ignore le paramètre. |
464
+ | 2026-08-13 | additif | **Liens tracés dans le player** : `docshare.*` quitte la route de synchro du studio pour `/api/doc`. Nouveau point de contexte `identity.canManageShares` — l'hôte décide qui a le droit de diffuser. |
465
+ | 2026-08-13 | additif | **Marque de l'éditeur configurable**, neutre par défaut. Aucune mention en dur ne subsiste dans les pages servies (3 tests de non-régression, un par page). ⚠️ Le studio doit poser ses variables avant de déployer. |
466
+ | 2026-08-13 | additif | **Suivi par DIFFUSION** : l'audience suit l'état par broadcast sur le canal `plive-<slug>` + relecture `?present=<slug>&state=1`, en parallèle de la lecture de table. Une fois vérifié en production, la migration `v12420` retire la lecture anonyme — **et l'énumération de toutes les présentations avec la clé publiable disparaît**. ⚠️ Ordre obligatoire : déployer, vérifier, PUIS migrer. |
467
+ | 2026-08-13 | additif | **« L'hôte sert le fichier »** : la garde de streaming accepte une route de l'hôte comme origine, appelée serveur à serveur via `PLAYER_HOST_FETCH_SECRET`. Rendu nécessaire par ADV, dont les documents vivent sur un serveur de fichiers tiers derrière une clé d'API. Le studio n'est pas concerné (Storage direct) — **additif, aucune rupture**. |
468
+ | 2026-08-13 | additif | **`embed-denied`** ajouté au pont : un refus d'afficher se dit, au lieu de se confondre avec une panne. Les pages de refus deviennent encadrables en mode intégré (sinon le message ne partirait pas). **Additif** — un hôte qui l'ignore garde le comportement d'avant. |
469
+ | 2026-08-13 | correctif | **Taille annoncée** : les trois routes de streaming passent par `relayerFichier()`, qui envoie la taille des octets servis et jamais celle reçue de l'amont, demande `Accept-Encoding: identity` et refuse un `206` compressé. Corrige un PDF tronqué silencieux. |
470
+ | 2026-08-13 | correctif | **Tous les chemins de refus** émettent `embed-denied`, pas seulement celui du lien tracé : l'aperçu interne (`url-not-allowed`) et la page d'audience (`ended`) se taisaient — or l'aperçu intégré est le premier mode qu'un nouvel hôte exerce. Deux motifs ajoutés, une garde de test empêche le prochain refus muet. |
471
+ | 2026-08-13 | précision | **`branding.forKey` renvoie `{ logo, name, dark }`** — la forme de retour manquait au contrat, et `name` (le repli quand le logo ne charge pas) a été omis par le premier hôte qui l'a lu. Le registre `brand.*`, retiré, disparaît aussi de la section Marque. |
472
+ | 2026-08-13 | précision | **« On ne replie pas » vaut pour ce qu'un hôte PROPOSE**, pas seulement pour ce qu'il fait tout seul : un bouton « Ouvrir ↗ » laissé après un refus revient à replier une seconde plus tard. Généralisation proposée par ADV. |
473
+ | 2026-08-13 | précision | **L'aperçu interne porte la marque de l'INSTANCE**, jamais celle d'un client (pas de `brandKey`) : c'est la surface des équipes. Tranché, pas subi. |
474
+ | 2026-08-13 | correctif | **Barre finale de `PLAYER_HOST_FETCH_BASE` normalisée** : la comparaison est un préfixe de chaîne, donc `…/api/documents` saisi sans barre ouvrait aussi `/api/documents-prives/`. Élargissement silencieux de la garde, par une variable tapée à la main. |
475
+ | 2026-08-13 | précision | **Colonne « conduite de l'hôte »** sur les motifs de refus : les deux motifs de *configuration* demandaient des conduites OPPOSÉES. Règle sous-jacente — on ne replie jamais sur un refus d'ACCÈS, on peut replier sur une incapacité à ATTEINDRE. |
476
+ | 2026-08-13 | correctif | **`name` traverse enfin** : promis par le contrat, jeté par la garde du contexte, absent des pages. Il devient le texte de remplacement de l'image du loader et du mur d'accès — le seul repli utilisable sous une CSP à nonce. Le premier hôte qui a câblé `forKey` l'avait omis, et rien ne le contredisait. |
477
+ | 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. |
478
+ | 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. |
479
+ | 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 ». |
480
+ | 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. |
481
+ | 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. |
482
+
483
+ ---
484
+
485
+ ## Demandes des hôtes
486
+
487
+ Un hôte qui a besoin d'une évolution de la frontière l'écrit ici. Il ne la code pas chez lui.
488
+
489
+ | Date | Hôte | Demande | État |
490
+ |---|---|---|---|
491
+ | 2026-08-13 | ADV | ~~Multi-locataire : colonne `tenant`~~ | **annulée** — instances séparées, chaque base n'a qu'un hôte |
492
+ | 2026-08-13 | ADV | ~~JWT multi-émetteurs~~ | **annulée** — chaque instance ne connaît qu'un émetteur |
493
+ | 2026-08-13 | ADV | **Extraire le player en dépôt déployable** (le studio le consomme en dépendance, `api/doc.js` devient un adaptateur mince) | à faire — **prérequis de tout le reste** |
494
+ | 2026-08-13 | ADV | **Gestion des liens tracés accessible hors studio** : `docshare.create/list/revoke` vit dans la route de synchro derrière le modèle de droits du studio (`clients_registry`) — inappelable par un autre hôte. **Point d'intégration le plus sous-estimé.** | à faire |
495
+ | 2026-08-13 | ADV | **Marque de l'hôte** : retirer les mentions « 3D Discovery » en dur | ✅ **livré** — `PLAYER_BRAND_NAME` / `PLAYER_BRAND_POWERED_BY`, neutre par défaut |
496
+ | 2026-08-13 | ADV | **Mur d'accès Microsoft** en plus d'email+code et Google One-Tap (ADV est en SSO M365) | à faire |
497
+ | 2026-08-13 | ADV | **Confidentialité Realtime** : la lecture anon large interdit d'y mettre des documents sensibles | 🟡 **code prêt, migration en attente de vérification en production** (`v12420`) |
498
+ | 2026-08-13 | ADV | **Streaming via une route de l'hôte** (Range relayé + secret serveur à serveur) — cf. section dédiée | ✅ **livré côté player** — reste à ADV d'exposer sa route |
499
+ | 2026-08-13 | ADV | **Le piège de la compression** : `fetch()` décompresse et garde les en-têtes → un `Content-Length` relayé sert un PDF tronqué, sans erreur | ✅ **livré** — 3e exigence de la section, et corrigé dans le player lui-même (le piège nous concernait) |
500
+ | 2026-08-13 | ADV | **Distinguer « player absent » de « player refuse »** : le silence pousse l'hôte à replier sur son lecteur, ce qui ouvrirait un document refusé | ✅ **livré** — `embed-denied {reason}` + pages de refus encadrables |
501
+ | 2026-08-13 | ADV | **Les portes se rouvrent** : visionneuse de 395 lignes trouvée sur la carte publique, hors player donc hors comptage | ✅ **inscrit au contrat** — chaque hôte tient et rechasse la liste de ses portes |
502
+ | 2026-08-13 | ADV | **L'aperçu interne refusait en silence** (`?preview=1&embed=1`, le premier mode qu'un hôte exerce) | ✅ **livré** — `url-not-allowed`, plus `ended` pour l'audience ; garde de test contre le prochain refus muet |
503
+ | 2026-08-13 | ADV | **Forme de retour de `branding.forKey` absente du contrat** — `name` omis, or c'est le repli quand le logo ne charge pas | ✅ **livré** — section Marque réécrite (et le registre `brand.*` retiré y traînait encore) |
504
+ | 2026-08-13 | ADV | **Généraliser « on ne replie pas » à ce qu'on PROPOSE** (retirer aussi le bouton « Ouvrir ↗ ») | ✅ **retenu au contrat** |
505
+ | 2026-08-13 | ADV | **La marque de l'aperçu interne** : instance ou client ? | ✅ **tranché** — toujours l'instance, l'aperçu est la surface des équipes |
506
+ | 2026-08-13 | ADV | **`PLAYER_HOST_FETCH_BASE` sans barre finale élargit la garde en silence** | ✅ **livré** — normalisée dans `hostFetchBase()`, 2 tests (dont la route sœur) |
507
+ | 2026-08-13 | ADV | **« Où ça se soigne » ne dit pas quelle conduite tenir** — les deux motifs de configuration s'opposent | ✅ **livré** — colonne « conduite », et la règle qui la sous-tend |
508
+ | 2026-08-13 | ADV | **Copie du contrat en retard côté ADV** | ✅ **resynchronisée** — `docs/player-contrat-v1.md`, cartouche local conservé, déposée non commitée |
509
+ | 2026-08-13 | ADV | **Où vit `forKey` pour une instance séparée** — copie dans le câblage, ou route de l'hôte ? | ✅ **tranché** — route de l'hôte (b). Une copie a l'air officielle, et réclame un déploiement par client |
510
+ | 2026-08-13 | ADV | **`name` promis mais jamais transporté** — la garde du contexte le supprimait | ✅ **corrigé** — il atteint la page comme texte de remplacement de l'image |
511
+ | 2026-08-13 | ADV | **Le dépôt extrait doit emporter son schéma** (145 migrations entrelacées, base ADV vierge) | ✅ **livré** — `player/supabase/init.sql` |
512
+ | 2026-08-13 | ADV | **Une base neuve doit s'installer déjà durcie**, sans passer par l'état « lecture anonyme » | ✅ **confirmé** — `init.sql` ne crée aucune politique publique ; l'avertissement `v12420` ne vise que l'instance historique |
513
+ | 2026-08-13 | ADV | **Comment on installe** : npm public, npm privé ou git ? | ✅ **tranché** — dépôt public + npm public, rien à configurer chez l'hôte |
514
+ | 2026-08-13 | ADV | **Cible de `PLAYER_SOURCE_URL`** avant le premier lecteur | ✅ **tranché** — le dépôt public lui-même |
515
+ | 2026-08-13 | ADV | **`?contract=1` n'existait pas** alors que la règle 4 repose dessus | ✅ **livré** — forme documentée, 3 tests |