discovery-media-player 0.1.7 → 0.1.9
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 +5 -0
- package/context/standalone.js +66 -5
- package/docs/HOST-CONTRACT.md +17 -2
- package/package.json +5 -5
- package/server/handler.js +12 -2
package/README.md
CHANGED
|
@@ -189,6 +189,11 @@ the player, not the people plugging into it.
|
|
|
189
189
|
[`CONTRIBUTING.md`](CONTRIBUTING.md) — how to run the tests, what the review looks for, and the
|
|
190
190
|
one rule that matters: a behaviour worth keeping is worth a test that fails without it.
|
|
191
191
|
|
|
192
|
+
Your first pull request asks you to sign the [CLA](CLA.md) — one reply, once, for good. You keep
|
|
193
|
+
the copyright in your work; you grant a licence that may be sublicensed, so that the core can stay
|
|
194
|
+
AGPL while a commercial licence remains possible for organisations that cannot live with the
|
|
195
|
+
network clause. Better said before you write the patch than after.
|
|
196
|
+
|
|
192
197
|
The code comments are in French. The project was built in a French company and the reasoning
|
|
193
198
|
behind each decision is written where the decision is; translating it would have meant either
|
|
194
199
|
losing it or maintaining two versions. Everything an integrator needs is in English.
|
package/context/standalone.js
CHANGED
|
@@ -11,13 +11,35 @@
|
|
|
11
11
|
// déléguées à des routes de l'hôte, ou refusées si elles ne sont pas configurées.
|
|
12
12
|
//
|
|
13
13
|
// ⚠️ Il ne remplace pas un câblage : il ne sait rien de vos rôles. Ce qu'il ne sait pas, il le
|
|
14
|
-
// REFUSE — jamais il n'accorde par défaut. Cf.
|
|
14
|
+
// REFUSE — jamais il n'accorde par défaut. Cf. docs/CONFIGURATION.md, « Decisions that are yours ».
|
|
15
15
|
|
|
16
16
|
const storage = require("./storage");
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Retire les barres finales d'une base d'URL.
|
|
20
|
+
*
|
|
21
|
+
* Sans expression régulière. L'analyse statique a signalé `.replace(/\/+$/, "")` dès son premier
|
|
22
|
+
* passage, sur les deux lignes que je venais d'écrire.
|
|
23
|
+
*
|
|
24
|
+
* Mesuré avant de corriger : V8 traite ce motif en temps linéaire (200 000 barres, moins d'une
|
|
25
|
+
* milliseconde), et l'entrée vient de toute façon d'une variable d'environnement — donc de
|
|
26
|
+
* l'exploitant, pas d'un visiteur. Ce n'était donc PAS une lenteur réelle ici.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ On change quand même, pour une raison qui n'est pas celle de l'alerte : cette forme se
|
|
29
|
+
* recopie. Elle est déjà à cinq endroits du dépôt, et la prochaine copie tombera peut-être sur
|
|
30
|
+
* une entrée venue du dehors, dans un moteur moins clément. Une boucle qui ne peut pas revenir en
|
|
31
|
+
* arrière retire la classe, pas l'occurrence — et évite d'apprendre à ignorer l'alerte.
|
|
32
|
+
*/
|
|
33
|
+
function sansBarreFinale(valeur) {
|
|
34
|
+
const s = String(valeur || "");
|
|
35
|
+
let fin = s.length;
|
|
36
|
+
while (fin > 0 && s.charCodeAt(fin - 1) === 47) fin--;
|
|
37
|
+
return s.slice(0, fin);
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
/** Client REST minimal (PostgREST). Absent de configuration ⇒ chaque appel échoue franchement. */
|
|
19
41
|
function creerDb(env) {
|
|
20
|
-
const url =
|
|
42
|
+
const url = sansBarreFinale(env.SUPABASE_URL);
|
|
21
43
|
const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
|
|
22
44
|
|
|
23
45
|
async function request(chemin, options = {}) {
|
|
@@ -151,11 +173,44 @@ function createStandaloneContext(env = process.env) {
|
|
|
151
173
|
mail: { async send() { return null; } },
|
|
152
174
|
|
|
153
175
|
identity: {
|
|
154
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Vérifie un jeton auprès de Supabase Auth. Sans émetteur : personne n'est authentifié.
|
|
178
|
+
*
|
|
179
|
+
* ⚠️ LA BASE DU PLAYER ET L'ÉMETTEUR DES JETONS SONT DEUX CHOSES DIFFÉRENTES.
|
|
180
|
+
*
|
|
181
|
+
* `SUPABASE_URL` servait les deux rôles. Vrai tant que le player et son application
|
|
182
|
+
* partagent un déploiement — et faux par construction dès qu'une instance est séparée : la
|
|
183
|
+
* base appartient au player, l'identité appartient à l'hôte. Les membres de l'hôte
|
|
184
|
+
* recevaient donc des jetons émis par un projet, vérifiés contre un autre : toute la moitié
|
|
185
|
+
* « membre » de la surface (diffusion, statistiques, présentations authentifiées) était
|
|
186
|
+
* hors d'atteinte. Signalé par le second hôte, c'est la troisième hypothèse de cette forme
|
|
187
|
+
* en deux jours — elles ne se voient qu'en exerçant la séparation.
|
|
188
|
+
*
|
|
189
|
+
* `PLAYER_AUTH_URL` désigne donc l'émetteur, et `SUPABASE_URL` reste la base. Absente, on
|
|
190
|
+
* retombe sur `SUPABASE_URL` : une instance où les deux coïncident ne change pas d'un
|
|
191
|
+
* caractère.
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ ET LA CLÉ NE RETOMBE PAS, ELLE. Le repli historique allait jusqu'à
|
|
194
|
+
* `SUPABASE_SERVICE_ROLE_KEY` — la clé maîtresse de NOTRE base. Tant que l'émetteur était
|
|
195
|
+
* notre propre projet, c'était sans conséquence ; vers un émetteur tiers, ce serait
|
|
196
|
+
* l'envoyer à un serveur qui n'a rien à en faire. Un émetteur distinct exige donc sa propre
|
|
197
|
+
* clé publiable, et son absence se dit au lieu de se replier.
|
|
198
|
+
*/
|
|
155
199
|
async verifyToken(authorization) {
|
|
156
200
|
const jeton = String(authorization || "").replace(/^Bearer\s+/i, "").trim();
|
|
157
|
-
const
|
|
158
|
-
const
|
|
201
|
+
const emetteur = sansBarreFinale(env.PLAYER_AUTH_URL);
|
|
202
|
+
const base = sansBarreFinale(env.SUPABASE_URL);
|
|
203
|
+
|
|
204
|
+
const url = emetteur || base;
|
|
205
|
+
const cle = emetteur
|
|
206
|
+
? String(env.PLAYER_AUTH_KEY || "")
|
|
207
|
+
: String(env.SUPABASE_PUBLISHABLE_KEY || env.SUPABASE_SERVICE_ROLE_KEY || "");
|
|
208
|
+
|
|
209
|
+
if (emetteur && !cle) {
|
|
210
|
+
// Le refus silencieux est le piège de cette configuration : sans clé, chaque membre est
|
|
211
|
+
// simplement « non authentifié », ce qui ressemble à un droit manquant. On le dit.
|
|
212
|
+
try { journal.capture(new Error("PLAYER_AUTH_URL est configurée sans PLAYER_AUTH_KEY : aucun jeton ne peut être vérifié"), {}); } catch { /* ignore */ }
|
|
213
|
+
}
|
|
159
214
|
if (!jeton || !url || !cle) return null;
|
|
160
215
|
try {
|
|
161
216
|
const r = await fetch(`${url}/auth/v1/user`, {
|
|
@@ -234,6 +289,12 @@ function createStandaloneContext(env = process.env) {
|
|
|
234
289
|
supabasePublishableKey: env.SUPABASE_PUBLISHABLE_KEY || "",
|
|
235
290
|
mapsKey: env.GOOGLE_MAPS_API_KEY || "",
|
|
236
291
|
extraFrameAncestors: String(env.DOC_FRAME_ANCESTORS || "").split(/\s+/).filter(Boolean),
|
|
292
|
+
// ⚠️ Un BOOLÉEN, jamais l'URL. La carte d'identité doit pouvoir dire « un émetteur distinct
|
|
293
|
+
// est configuré » sans dire lequel : c'est le dernier endroit où un hôte devait deviner.
|
|
294
|
+
// `host-auth` dit que le code SAIT faire la séparation ; ceci dit qu'elle est POSÉE. Sans
|
|
295
|
+
// les deux, une variable oubliée redonne exactement le symptôme que 0.1.8 a retiré — des
|
|
296
|
+
// membres « non authentifiés », ce qui ressemble à un droit manquant.
|
|
297
|
+
separateIssuer: !!sansBarreFinale(env.PLAYER_AUTH_URL),
|
|
237
298
|
},
|
|
238
299
|
};
|
|
239
300
|
}
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -25,9 +25,10 @@ need.
|
|
|
25
25
|
{
|
|
26
26
|
"product": "discovery-media-player",
|
|
27
27
|
"contract": 1,
|
|
28
|
-
"version": "0.1.
|
|
29
|
-
"capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference"],
|
|
28
|
+
"version": "0.1.9",
|
|
29
|
+
"capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth"],
|
|
30
30
|
"frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
|
|
31
|
+
"separateIssuer": true,
|
|
31
32
|
"plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false }
|
|
32
33
|
}
|
|
33
34
|
```
|
|
@@ -62,6 +63,20 @@ POST → { "email": "…", "role": "…", "action": "create|list|list.all|revo
|
|
|
62
63
|
- The token is already verified before the call: your route does not receive it and must not
|
|
63
64
|
re-verify it.
|
|
64
65
|
|
|
66
|
+
⚠️ **Verified against whom?** The player's database and your identity provider are two different
|
|
67
|
+
things. They are the same project while the player and your application share a deployment — and
|
|
68
|
+
different by construction once the instance is separate. Point `PLAYER_AUTH_URL` at the project
|
|
69
|
+
that issues your members' tokens (with its own publishable key in `PLAYER_AUTH_KEY`), or every
|
|
70
|
+
member action is refused in a way that reads like a missing permission. Unset, it falls back to
|
|
71
|
+
the player's own project, so a shared deployment changes by not one character.
|
|
72
|
+
|
|
73
|
+
Two signals, and you need both: **`host-auth` in `capabilities`** says this instance *can* target
|
|
74
|
+
a separate issuer; **`separateIssuer: true`** says one *is configured*. A version that supports
|
|
75
|
+
the split with the variable left unset fails exactly like the version before it — members come
|
|
76
|
+
back unauthenticated, which reads like a missing permission — and you would conclude the upgrade
|
|
77
|
+
changed nothing. The card answers with a boolean and never the issuer itself: you already know
|
|
78
|
+
which one is yours.
|
|
79
|
+
|
|
65
80
|
### 2. What a client's brand is
|
|
66
81
|
|
|
67
82
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -66,12 +66,12 @@
|
|
|
66
66
|
"node": ">=22"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@eslint/js": "^
|
|
70
|
-
"esbuild": "^0.
|
|
71
|
-
"eslint": "^
|
|
69
|
+
"@eslint/js": "^10.0.1",
|
|
70
|
+
"esbuild": "^0.28.2",
|
|
71
|
+
"eslint": "^10.8.1",
|
|
72
72
|
"jsdom": "^25.0.1",
|
|
73
73
|
"typescript": "^5.5.4",
|
|
74
74
|
"typescript-eslint": "^8.46.4",
|
|
75
|
-
"vitest": "^
|
|
75
|
+
"vitest": "^4.1.10"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/server/handler.js
CHANGED
|
@@ -1172,7 +1172,7 @@ const LEGAL_CSS = `
|
|
|
1172
1172
|
function sendHtml(res, status, html, scriptSrc, imgExtra, frameAncestors) {
|
|
1173
1173
|
res.statusCode = status;
|
|
1174
1174
|
// Origine Supabase Storage (voix ElevenLabs mise en cache dans le bucket public tts-cache) → autorisée en media-src.
|
|
1175
|
-
let supaOrigin
|
|
1175
|
+
let supaOrigin; try { supaOrigin = new URL(process.env.SUPABASE_URL || "").origin; } catch { supaOrigin = ""; }
|
|
1176
1176
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1177
1177
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
1178
1178
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
@@ -2581,8 +2581,13 @@ async function handler(req, res) {
|
|
|
2581
2581
|
contract: 1,
|
|
2582
2582
|
version: PLAYER_VERSION,
|
|
2583
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.
|
|
2584
2589
|
capabilities: [
|
|
2585
|
-
"docshare", "presentations", "embed-denied", "host-fetch", "brand-reference",
|
|
2590
|
+
"docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth",
|
|
2586
2591
|
],
|
|
2587
2592
|
// ⚠️ POUR QUELLES ORIGINES cette instance accepte d'être encadrée. Un booléen ne
|
|
2588
2593
|
// suffisait pas : un hôte a besoin de voir que SON domaine manque, pas seulement que
|
|
@@ -2590,6 +2595,11 @@ async function handler(req, res) {
|
|
|
2590
2595
|
// autrement — le navigateur bloque avant tout script, et rien ne peut lui être émis.
|
|
2591
2596
|
// Ce n'est pas un secret : ces mêmes valeurs partent dans chaque en-tête CSP servi.
|
|
2592
2597
|
frameAncestors: ["'self'", "https://*.vercel.app"].concat(PLAYER.config.extraFrameAncestors || []),
|
|
2598
|
+
// Même besoin que `frameAncestors`, l'inverse de la réponse : là on nomme les origines
|
|
2599
|
+
// parce qu'un hôte doit voir que LA SIENNE manque ; ici un booléen suffit, parce que
|
|
2600
|
+
// l'hôte connaît déjà son émetteur — il veut seulement savoir si l'instance le regarde.
|
|
2601
|
+
// Dire lequel n'aiderait personne et renseignerait qui sonde.
|
|
2602
|
+
separateIssuer: !!(PLAYER.config && PLAYER.config.separateIssuer),
|
|
2593
2603
|
// Greffons de l'hôte : présents ou coupés (PLAYER_PLUGINS_OFF). Booléens uniquement.
|
|
2594
2604
|
plugins: {
|
|
2595
2605
|
bot: !!p.bot, visitors: !!p.visitors, brandIntro: !!p.brandIntro,
|