discovery-media-player 0.1.0 → 0.1.2

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
@@ -271,6 +271,47 @@ d'environnement), et ce qui reste est du branchement. Un câblage qu'on ne pourr
271
271
  un câblage qui contient quelque chose qui n'a rien à y faire. *(Ce n'est pas un avis juridique :
272
272
  c'est la posture retenue, et elle est sûre dans les deux cas.)*
273
273
 
274
+ ### Les deux routes que le player appelle chez l'hôte
275
+
276
+ ⚠️ **Ces formats sont le contrat, pas une suggestion.** Ils manquaient à ce document : un hôte a
277
+ écrit ses deux routes sur la *description* de `canManageShares`, ce qui donnait une intention juste
278
+ et une forme fausse. Et le pire : **deux des trois écarts étaient silencieux** — une réponse d'une
279
+ autre forme est lue comme un refus. Personne n'aurait pu diffuser un document, sans erreur nulle
280
+ part, indiscernable d'un droit correctement refusé. On aurait cherché dans les rôles.
281
+
282
+ Les deux routes sont appelées en **POST JSON, serveur à serveur**, avec le secret partagé dans
283
+ l'en-tête `x-player-fetch-secret` (jamais en query). Délai : **4 secondes** — une décision qui tarde
284
+ est une décision absente.
285
+
286
+ **`PLAYER_HOST_AUTHZ_URL` — qui a le droit de diffuser**
287
+
288
+ ```
289
+ → { "email": "…", "role": "…", "action": "create|list|list.all|revoke|setauth|overview|sessions|test" }
290
+ ← { "allowed": true } // booléen STRICT ; tout le reste vaut refus
291
+ ```
292
+
293
+ - **`email` est l'identité qui fait foi.** `role` est celui lu dans le jeton de session
294
+ (`app_metadata.role`) : un hôte dont les rôles vivent en base l'ignore et interroge la sienne à
295
+ partir de l'email. C'est un cas prévu, pas une entorse.
296
+ - **Le jeton est déjà vérifié** par le player avant l'appel. La route n'a pas à le revalider — elle
297
+ ne le reçoit d'ailleurs pas.
298
+ - **Seul `allowed` est lu**, et il doit être un booléen. `{ "canManageShares": true }` ou
299
+ `{ "allowed": "yes" }` valent refus. Le player le signale désormais dans son journal.
300
+
301
+ **`PLAYER_HOST_BRAND_URL` — la marque d'un client**
302
+
303
+ ```
304
+ → { "key": "…" } // la référence portée par le lien
305
+ ← { "logo": "https://…", "name": "…", "dark": false } ou {} / null si inconnue
306
+ ```
307
+
308
+ Une clé inconnue n'est pas une erreur : le lien retombe sur la marque de l'instance.
309
+
310
+ **Une panne n'est plus muette.** Hôte injoignable, délai dépassé, réponse non-JSON, `allowed`
311
+ d'un autre type : le player reste **fail-closed** (personne ne diffuse) mais l'écrit dans son
312
+ journal, avec la cause. Sans ça, « ma route répond mal » et « le droit est refusé » se
313
+ ressemblaient exactement.
314
+
274
315
  ### Le câblage d'une instance — à qui il appartient
275
316
 
276
317
  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
@@ -467,6 +508,10 @@ Toute évolution de la frontière se note ici, datée, avec sa nature.
467
508
  | 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
509
  | 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
510
  | 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. |
511
+ | 2026-08-13 | précision | **Le format d'appel des deux routes de l'hôte** (`PLAYER_HOST_AUTHZ_URL`, `PLAYER_HOST_BRAND_URL`) est enfin écrit. Il manquait : un hôte a écrit ses routes sur la description de `canManageShares` — intention juste, forme fausse, et **deux écarts sur trois étaient silencieux** (réponse lue comme un refus). |
512
+ | 2026-08-13 | additif | **Une panne de route d'hôte se distingue d'un refus** : injoignable, délai dépassé, réponse non-JSON ou `allowed` d'un autre type sont journalisés avec leur cause. Le player reste fail-closed. |
513
+ | 2026-08-13 | correctif | **Objet littéral utilisé comme table de correspondance** : `ATT_KINDS["constructor"]` rendait une fonction, donc une valeur vraie — la liste blanche des types de pièce jointe était contournable depuis une action publique. Même forme cinq fois dans le greffon de langue. Tout passe par `Object.hasOwn`, un test statique interdit le retour en arrière. Trouvé chez nous après signalement d'un hôte qui l'avait chez lui. |
514
+ | 2026-08-13 | correctif | **Le chat passe en diffusion** : ses messages arrivaient par lecture de table en temps réel, ce qui exposait les conversations de TOUTES les présentations à qui détient la clé publiable. Dernier usage qui l'exigeait — `v12420` devient applicable. Les mutations renvoient la ligne écrite, **projetée sur une liste blanche** : `author_hash` (le jeton qui autorise à éditer et supprimer) ne sort jamais. |
470
515
  | 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
516
  | 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
517
  | 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. |
@@ -494,7 +539,7 @@ Un hôte qui a besoin d'une évolution de la frontière l'écrit ici. Il ne la c
494
539
  | 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
540
  | 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
541
  | 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`) |
542
+ | 2026-08-13 | ADV | **Confidentialité Realtime** : la lecture anon large interdit d'y mettre des documents sensibles | 🟡 **code complet** — l'état ET le chat passent en diffusion ; migration `v12420` en attente d'une vérification en production |
498
543
  | 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
544
  | 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
545
  | 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 |
@@ -513,3 +558,5 @@ Un hôte qui a besoin d'une évolution de la frontière l'écrit ici. Il ne la c
513
558
  | 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
559
  | 2026-08-13 | ADV | **Cible de `PLAYER_SOURCE_URL`** avant le premier lecteur | ✅ **tranché** — le dépôt public lui-même |
515
560
  | 2026-08-13 | ADV | **`?contract=1` n'existait pas** alors que la règle 4 repose dessus | ✅ **livré** — forme documentée, 3 tests |
561
+ | 2026-08-13 | ADV | **Format des routes de l'hôte absent du contrat** — route d'autorisation réécrite après lecture du paquet publié | ✅ **livré** — les deux formats sont au contrat et dans `docs/CONFIGURATION.md` |
562
+ | 2026-08-13 | ADV | **Un objet littéral comme table de correspondance répond à `constructor`** (trois occurrences chez eux) | ✅ **vérifié chez nous : deux sites, dont un atteignable depuis une action publique** — corrigés, plus un test statique |
@@ -82,3 +82,49 @@ describe("le player répond sans plateforme", () => {
82
82
  expect(await ctx.identity.canManageShares(null, "create")).toBe(false);
83
83
  });
84
84
  });
85
+
86
+ // LA PREMIÈRE MINUTE DE QUELQU'UN QUI DÉCOUVRE LE PROJET.
87
+ //
88
+ // Écrit après avoir regardé un premier utilisateur suivre le README : dossier vide, nom d'exemple
89
+ // tapé tel quel, et un « Impossible d'afficher ce document » rouge — le même message qu'un fichier
90
+ // absent, qu'un dossier vide et qu'un format non géré. Trois causes, une phrase, aucune piste.
91
+ // La racine du serveur montre désormais ce qu'il y a à lire.
92
+ describe("page d'accueil du mode dossier", () => {
93
+ const fs = require("node:fs");
94
+ const os = require("node:os");
95
+ const p = require("node:path");
96
+
97
+ it("liste ce qui est affichable, et rien d'autre", async () => {
98
+ const dir = fs.realpathSync(fs.mkdtempSync(p.join(os.tmpdir(), "player-home-")));
99
+ fs.writeFileSync(p.join(dir, "rapport.pdf"), "%PDF-1.4");
100
+ fs.writeFileSync(p.join(dir, "plan.png"), "x");
101
+ fs.writeFileSync(p.join(dir, "notes.docx"), "x"); // format non géré : ne pas le proposer
102
+ fs.writeFileSync(p.join(dir, ".cache"), "x"); // fichier caché
103
+ fs.mkdirSync(p.join(dir, "archives")); // dossier
104
+
105
+ const { pageAccueil } = require("../serve.js");
106
+ const html = await pageAccueil(dir);
107
+ expect(html).toContain('href="/preview/rapport.pdf"');
108
+ expect(html).toContain('href="/preview/plan.png"');
109
+ expect(html).not.toContain("notes.docx");
110
+ expect(html).not.toContain(".cache");
111
+ expect(html).not.toContain("archives");
112
+ });
113
+
114
+ it("dit qu'il est vide plutôt que de laisser deviner", async () => {
115
+ const dir = fs.realpathSync(fs.mkdtempSync(p.join(os.tmpdir(), "player-vide-")));
116
+ const { pageAccueil } = require("../serve.js");
117
+ expect(await pageAccueil(dir)).toContain("Aucun document affichable");
118
+ });
119
+
120
+ // Un nom de fichier est écrit par quelqu'un d'autre — ici l'exploitant, mais la règle ne se
121
+ // relâche pas selon la confiance qu'on a dans la source.
122
+ it("échappe les noms de fichiers", async () => {
123
+ const dir = fs.realpathSync(fs.mkdtempSync(p.join(os.tmpdir(), "player-xss-")));
124
+ fs.writeFileSync(p.join(dir, '<img src=x onerror=alert(1)>.pdf'), "%PDF");
125
+ const { pageAccueil } = require("../serve.js");
126
+ const html = await pageAccueil(dir);
127
+ expect(html).not.toContain("<img src=x");
128
+ expect(html).toContain("&lt;img");
129
+ });
130
+ });
package/bin/serve.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  const http = require("node:http");
16
16
  const path = require("node:path");
17
+ const fs = require("node:fs/promises");
17
18
  const { pathToFileURL } = require("node:url");
18
19
  const player = require("../server/handler");
19
20
  const { createStandaloneContext } = require("../context/standalone");
@@ -49,6 +50,58 @@ function versParametres(url) {
49
50
  return q;
50
51
  }
51
52
 
53
+ const AFFICHABLES = new Set([".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
54
+
55
+ /**
56
+ * Page d'accueil du mode dossier : ce qu'il y a à lire, et où le prendre.
57
+ *
58
+ * ⚠️ Écrite après avoir regardé quelqu'un essayer le projet pour la première fois. Il a suivi le
59
+ * README, ouvert `/preview/<nom>` avec le nom d'exemple, et reçu « Impossible d'afficher ce
60
+ * document » — le même message qu'un dossier vide, qu'un fichier absent et qu'un format non géré.
61
+ * Trois causes, une phrase, aucune piste. Le premier contact avec un projet ne se rejoue pas.
62
+ *
63
+ * N'existe qu'en mode dossier : c'est la racine que l'exploitant a lui-même désignée, il n'y a
64
+ * rien à divulguer qu'il ne connaisse déjà.
65
+ */
66
+ async function pageAccueil(racine) {
67
+ let fichiers = [];
68
+ try {
69
+ fichiers = (await fs.readdir(racine, { withFileTypes: true }))
70
+ .filter((e) => e.isFile() && !e.name.startsWith(".") && AFFICHABLES.has(path.extname(e.name).toLowerCase()))
71
+ .map((e) => e.name).sort();
72
+ } catch { /* racine illisible : traitée comme vide, le message le dira */ }
73
+
74
+ const echapper = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
75
+ const liste = fichiers.length
76
+ ? `<ul>${fichiers.map((n) => `<li><a href="/preview/${encodeURIComponent(n)}">${echapper(n)}</a></li>`).join("")}</ul>`
77
+ : `<p class=vide>Aucun document affichable dans <code>${echapper(racine)}</code>.<br>
78
+ Déposez-y un PDF ou une image, puis rechargez cette page.</p>`;
79
+
80
+ return `<!doctype html><html lang=fr><head><meta charset=utf-8>
81
+ <meta name=viewport content="width=device-width,initial-scale=1"><title>Discovery Media Player</title>
82
+ <style>
83
+ body{font:15px/1.6 -apple-system,system-ui,Segoe UI,Roboto,sans-serif;color:#1c1a17;background:#f5f3ef;
84
+ margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}
85
+ .c{width:100%;max-width:560px;background:#fff;border-radius:16px;padding:32px;box-shadow:0 18px 50px rgba(30,22,12,.10)}
86
+ h1{font-size:19px;margin:0 0 4px;letter-spacing:-.01em}
87
+ .s{color:#7c7266;font-size:13.5px;margin:0 0 22px}
88
+ ul{list-style:none;margin:0;padding:0}
89
+ li+li{border-top:1px solid #eee9e0}
90
+ a{display:block;padding:11px 12px;color:#1c1a17;text-decoration:none;border-radius:9px}
91
+ a:hover{background:#f5f2ec}
92
+ .vide{color:#7c7266;font-size:14px;background:#faf8f4;border:1px dashed #ddd4c6;border-radius:12px;padding:18px;margin:0}
93
+ code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:#f0ece4;padding:1px 5px;border-radius:5px}
94
+ .f{margin:22px 0 0;padding-top:16px;border-top:1px solid #eee9e0;color:#9a9184;font-size:12.5px}
95
+ .f a{display:inline;padding:0;color:#9a9184;text-decoration:underline}
96
+ </style></head><body><div class=c>
97
+ <h1>Discovery Media Player</h1>
98
+ <p class=s>Mode dossier — les documents servis viennent de <code>${echapper(racine)}</code>.</p>
99
+ ${liste}
100
+ <p class=f>Liens tracés, statistiques et présentation en direct demandent une base :
101
+ voir <a href="https://github.com/Juli1artha/discovery-media-player#going-further">la documentation</a>.</p>
102
+ </div></body></html>`;
103
+ }
104
+
52
105
  const serveur = http.createServer(async (req, res) => {
53
106
  const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
54
107
 
@@ -60,6 +113,13 @@ const serveur = http.createServer(async (req, res) => {
60
113
  return;
61
114
  }
62
115
 
116
+ // Mode dossier : la racine du serveur montre ce qu'il y a à lire.
117
+ if ((url.pathname === "/" || url.pathname === "/preview" || url.pathname === "/preview/") && process.env.PLAYER_LOCAL_ROOT) {
118
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
119
+ res.end(await pageAccueil(path.resolve(process.env.PLAYER_LOCAL_ROOT)));
120
+ return;
121
+ }
122
+
63
123
  const q = versParametres(url);
64
124
  const routeConnue =
65
125
  url.pathname === "/api/doc" || url.pathname === "/doc" || url.pathname === "/present" ||
@@ -112,4 +172,4 @@ if (require.main === module) serveur.listen(PORT, HOST, () => {
112
172
  console.log(` état : http://localhost:${PORT}/api/doc?contract=1`);
113
173
  });
114
174
 
115
- module.exports = { serveur, versParametres };
175
+ module.exports = { serveur, versParametres, pageAccueil };
@@ -63,8 +63,14 @@ function creerDb(env) {
63
63
  * Appel d'une route de l'HÔTE (autorisation, marque). Authentifié par le même secret partagé que
64
64
  * la route de fichiers, et **jamais en query** : les journaux gardent les URL.
65
65
  */
66
- async function appelHote(url, secret, corps) {
66
+ async function appelHote(url, secret, corps, errors) {
67
67
  if (!url) return null;
68
+ // ⚠️ UN REFUS ET UNE PANNE NE DOIVENT PAS SE RESSEMBLER. Le player reste fail-closed — hôte
69
+ // injoignable ⇒ personne ne diffuse — mais sans trace, « ma route répond mal » est
70
+ // indiscernable de « le droit est refusé », et on cherche pendant une demi-journée du côté des
71
+ // rôles. Un hôte qui a écrit sa route sur la description du contrat plutôt que sur le code a
72
+ // perdu exactement ce temps-là.
73
+ const signaler = (quoi) => { try { errors && errors.capture(new Error(`route hôte : ${quoi}`), { url }); } catch { /* jamais bloquant */ } };
68
74
  try {
69
75
  const r = await fetch(url, {
70
76
  method: "POST",
@@ -75,8 +81,12 @@ async function appelHote(url, secret, corps) {
75
81
  body: JSON.stringify(corps),
76
82
  signal: AbortSignal.timeout(4000), // une décision qui tarde est une décision absente
77
83
  });
78
- return r.ok ? await r.json() : null;
79
- } catch {
84
+ if (!r.ok) { signaler(`réponse ${r.status}`); return null; }
85
+ const d = await r.json().catch(() => null);
86
+ if (!d || typeof d !== "object") { signaler("réponse illisible (JSON attendu)"); return null; }
87
+ return d;
88
+ } catch (e) {
89
+ signaler(e && e.name === "TimeoutError" ? "délai dépassé" : "injoignable");
80
90
  return null;
81
91
  }
82
92
  }
@@ -107,6 +117,11 @@ function creerLimites() {
107
117
  /** Construit le contexte d'une instance autonome à partir de l'environnement. */
108
118
  function createStandaloneContext(env = process.env) {
109
119
  const db = creerDb(env);
120
+ const journal = {
121
+ async capture(error, meta) {
122
+ console.error("[player]", (meta && meta.route) || "", (error && error.message) || error, meta && meta.url ? `→ ${meta.url}` : "");
123
+ },
124
+ };
110
125
  const secret = String(env.PLAYER_HOST_FETCH_SECRET || "");
111
126
  const origins = () => storage.storageOrigins(env);
112
127
  const hostBase = () => storage.hostFetchBase(env);
@@ -163,8 +178,14 @@ function createStandaloneContext(env = process.env) {
163
178
  if (!user || !user.email) return false;
164
179
  const reponse = await appelHote(env.PLAYER_HOST_AUTHZ_URL, secret, {
165
180
  email: user.email, role: ((user.app_metadata || {}).role) || "", action: String(action || ""),
166
- });
167
- return !!(reponse && reponse.allowed);
181
+ }, journal);
182
+ // ⚠️ `allowed` doit être présent ET booléen. Une réponse d'une autre forme — même
183
+ // parfaitement intentionnée — vaut refus, et le dit. C'est le cas le plus courant au
184
+ // branchement d'un nouvel hôte.
185
+ if (reponse && typeof reponse.allowed !== "boolean") {
186
+ try { journal.capture(new Error("route d'autorisation : champ `allowed` booléen attendu"), { recu: Object.keys(reponse).join(",") }); } catch { /* ignore */ }
187
+ }
188
+ return reponse ? reponse.allowed === true : false;
168
189
  },
169
190
  },
170
191
 
@@ -185,7 +206,7 @@ function createStandaloneContext(env = process.env) {
185
206
  */
186
207
  async forKey(key) {
187
208
  if (!key) return null;
188
- const b = await appelHote(env.PLAYER_HOST_BRAND_URL, secret, { key: String(key) });
209
+ const b = await appelHote(env.PLAYER_HOST_BRAND_URL, secret, { key: String(key) }, journal);
189
210
  return b && b.logo ? { logo: String(b.logo), name: String(b.name || ""), dark: !!b.dark } : null;
190
211
  },
191
212
 
@@ -196,11 +217,7 @@ function createStandaloneContext(env = process.env) {
196
217
  },
197
218
  },
198
219
 
199
- errors: {
200
- async capture(error, meta) {
201
- console.error("[player]", (meta && meta.route) || "", (error && error.stack) || error);
202
- },
203
- },
220
+ errors: journal,
204
221
 
205
222
  legal: {
206
223
  get sourceUrl() { return String(env.PLAYER_SOURCE_URL || "https://github.com/Juli1artha/discovery-media-player").trim(); },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -62,11 +62,11 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@eslint/js": "^9.39.4",
65
- "esbuild": "0.21.5",
65
+ "esbuild": "^0.25.12",
66
66
  "eslint": "^9.39.4",
67
67
  "jsdom": "^25.0.1",
68
68
  "typescript": "^5.5.4",
69
69
  "typescript-eslint": "^8.46.4",
70
- "vitest": "^2.1.9"
70
+ "vitest": "^3.2.7"
71
71
  }
72
72
  }
package/server/handler.js CHANGED
@@ -230,6 +230,17 @@ var Live=(function(){
230
230
  // État de la présentation diffusé par le présentateur — même canal que la carte. Sert à se
231
231
  // passer de la lecture anonyme des tables : l'audience n'a plus besoin de lire la ligne.
232
232
  function sendState(p){try{if(ch)ch.send({type:'broadcast',event:'state',payload:p});}catch(e){}}
233
+ // CHAT EN DIFFUSION. Les messages arrivaient jusqu'ici par la lecture de TABLE en temps réel,
234
+ // qui exige que cette table soit lisible publiquement — donc, avec la clé publiable, les
235
+ // conversations de TOUTES les présentations, pas seulement la sienne. C'était le dernier
236
+ // obstacle avant de pouvoir fermer cette lecture.
237
+ // L'émetteur diffuse la ligne que le serveur vient d'écrire ; les autres l'ajoutent chez eux.
238
+ // ⚠️ La diffusion ne revient pas à son émetteur : il ajoute donc SA copie lui-même.
239
+ function sendMsg(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg',payload:m});}catch(e){}}
240
+ function sendMsgUpd(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg-upd',payload:m});}catch(e){}}
241
+ // Édition, suppression, réaction : le serveur renvoie la ligne à jour, on l'applique chez soi
242
+ // puis on la diffuse. Même chemin pour les trois — une seule façon de se tromper.
243
+ function majDiffusee(r){return r.json().then(function(d){if(d&&d.ok&&d.message){updateMsg(d.message);sendMsgUpd(d.message);}}).catch(function(){});}
233
244
  function onState(fn){_onState=fn;}
234
245
  // Badge « non lus » sur le bouton chat/FAB (panneau fermé) + pulse du FAB. Aperçu (ticker) au nouveau message.
235
246
  function chatHidden(){var pn=document.getElementById('chatPanel');return !pn||pn.classList.contains('hidden');}
@@ -276,17 +287,23 @@ var Live=(function(){
276
287
  function cmClass(m){return Player.chat.messageClassName(m,ME,isMentioned(m));}
277
288
  function hydratePdf(d,m){if(m&&m.attachment&&m.attachment.kind==='pdf'&&!m.deleted){var ph=d.querySelector('.cm-att-ph');if(ph)pdfThumb(m.attachment.url,ph);}}
278
289
  function pdfThumb(url,ph){if(!ph)return;if(pdfCache[url]){ph.innerHTML='<img src="'+pdfCache[url]+'" alt="">';return;}if(!window.pdfjsLib)return;try{pdfjsLib.getDocument(url).promise.then(function(pdf){return pdf.getPage(1);}).then(function(pg){var v0=pg.getViewport({scale:1}),sc=Math.min(1.6,208/v0.width),vp=pg.getViewport({scale:sc}),cv=document.createElement('canvas');cv.width=Math.ceil(vp.width);cv.height=Math.ceil(vp.height);return pg.render({canvasContext:cv.getContext('2d'),viewport:vp}).promise.then(function(){var u=cv.toDataURL('image/jpeg',0.8);pdfCache[url]=u;ph.innerHTML='<img src="'+u+'" alt="">';});}).catch(function(){});}catch(e){}}
279
- function addMsg(m){if(m.id&&seen[m.id])return;if(m.id)seen[m.id]=1;var box=document.getElementById('chatMsgs');if(!box)return;var em=box.querySelector('.chat-empty');if(em)em.remove();
290
+ // Renvoie true seulement si le message a réellement été AJOUTÉ. Pendant la transition, il
291
+ // arrive par deux voies (diffusion et lecture de table) : sans cette réponse, l'affichage était
292
+ // bien dédoublonné mais le compteur de non-lus comptait deux fois — une pastille à 2 pour un
293
+ // seul message. Le dédoublonnage doit valoir pour tout ce qui suit l'arrivée, pas seulement
294
+ // pour le rendu.
295
+ function addMsg(m){if(m.id&&seen[m.id])return false;if(m.id)seen[m.id]=1;var box=document.getElementById('chatMsgs');if(!box)return;var em=box.querySelector('.chat-empty');if(em)em.remove();
280
296
  if(m.id)msgData[m.id]=m;
281
297
  var d=document.createElement('div');d.className=cmClass(m);if(m.id)d.setAttribute('data-id',m.id);
282
298
  d.innerHTML=renderMsgInner(m);
283
- if(m.id)msgEls[m.id]=d;box.appendChild(d);box.scrollTop=box.scrollHeight;hydratePdf(d,m);}
299
+ if(m.id)msgEls[m.id]=d;box.appendChild(d);box.scrollTop=box.scrollHeight;hydratePdf(d,m);
300
+ return true;}
284
301
  function updateMsg(m){if(!m.id)return;msgData[m.id]=m;var d=msgEls[m.id];if(!d)return;d.className=cmClass(m);d.innerHTML=renderMsgInner(m);hydratePdf(d,m);}
285
302
  function startEdit(id){var d=msgEls[id],m=msgData[id];if(!d||!m||m.deleted)return;var txt=d.querySelector('.txt');if(!txt)return;var inp=document.createElement('input');inp.className='cm-edit-in';inp.value=m.body||'';txt.replaceWith(inp);inp.focus();
286
- function fin(save){var v=(inp.value||'').trim();if(save&&v&&v!==m.body){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-edit',slug:SLUG,msgId:+id,authorToken:authToken(),body:v})}).catch(function(){});}d.innerHTML=renderMsgInner(m);}
303
+ function fin(save){var v=(inp.value||'').trim();if(save&&v&&v!==m.body){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-edit',slug:SLUG,msgId:+id,authorToken:authToken(),body:v})}).then(majDiffusee).catch(function(){});}d.innerHTML=renderMsgInner(m);}
287
304
  inp.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();fin(true);}else if(e.key==='Escape'){fin(false);}});
288
305
  inp.addEventListener('blur',function(){fin(false);});}
289
- function delMsg(id){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-delete',slug:SLUG,msgId:+id,authorToken:authToken(),control:CONTROL})}).catch(function(){});}
306
+ function delMsg(id){fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-msg-delete',slug:SLUG,msgId:+id,authorToken:authToken(),control:CONTROL})}).then(majDiffusee).catch(function(){});}
290
307
  // Modale de confirmation maison (l'iframe de présentation ne peut pas utiliser le useConfirm React). Repli window.confirm si absente.
291
308
  function confirmDialog(opts,onOk){opts=opts||{};var m=document.getElementById('lModal');if(!m){if(!onOk)return;if(window.confirm(opts.title||'Confirmer ?'))onOk();return;}
292
309
  var t=document.getElementById('lModalT'),d=document.getElementById('lModalD'),y=document.getElementById('lModalYes'),n=document.getElementById('lModalNo');
@@ -297,14 +314,17 @@ var Live=(function(){
297
314
  if(y)y.onclick=function(){close();if(onOk)onOk();};if(n)n.onclick=close;m.onclick=function(e){if(e.target===m)close();};
298
315
  document.addEventListener('keydown',key);m.classList.add('open');if(y)try{y.focus();}catch(e){}}
299
316
  function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
300
- function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:reactorId()})}).catch(function(){});}
317
+ function react(id,e){if(!ME||!id||!e)return;fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-react',slug:SLUG,msgId:+id,emoji:e,reactor:reactorId()})}).then(majDiffusee).catch(function(){});}
301
318
  function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
302
319
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
303
320
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
304
321
  var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,isPresenter:ME.role==='presenter',isMember:!!ME.member,body:t,authorToken:authToken()};
305
322
  if(CONTROL)o.control=CONTROL;
306
323
  if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
307
- fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)}).catch(function(){});}
324
+ fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)})
325
+ .then(function(r){return r.json();})
326
+ .then(function(d){if(d&&d.ok&&d.message){addMsg(d.message);sendMsg(d.message);}})
327
+ .catch(function(){});}
308
328
  function uploadFile(file){if(!file||!ME||!sb)return;if(LOCKED&&!canMod())return;
309
329
  if(file.size>10*1024*1024){alert('Fichier trop volumineux (max 10 Mo).');return;}
310
330
  var s=document.getElementById('chatSend');if(s){s.disabled=true;s.textContent='…';}
@@ -377,8 +397,10 @@ var Live=(function(){
377
397
  try{sb=window.supabase.createClient(LIVECFG.supaUrl,LIVECFG.supaKey,{realtime:{params:{eventsPerSecond:10}}});
378
398
  ch=sb.channel('plive-'+slug,{config:{presence:{key:(me.email||me.name||'x')+':'+MYID}}});
379
399
  ch.on('presence',{event:'sync'},function(){renderPres(ch.presenceState());});
380
- ch.on('postgres_changes',{event:'INSERT',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new){addMsg(p.new);notifyMsg(p.new);}});
400
+ ch.on('postgres_changes',{event:'INSERT',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new&&addMsg(p.new))notifyMsg(p.new);});
381
401
  ch.on('postgres_changes',{event:'UPDATE',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new)updateMsg(p.new);});
402
+ ch.on('broadcast',{event:'msg'},function(p){if(p&&p.payload&&addMsg(p.payload))notifyMsg(p.payload);});
403
+ ch.on('broadcast',{event:'msg-upd'},function(p){if(p&&p.payload)updateMsg(p.payload);});
382
404
  ch.on('broadcast',{event:'typing'},function(p){onTyping(p&&p.payload);});
383
405
  ch.on('broadcast',{event:'lock'},function(p){if(p&&p.payload)applyLock(p.payload.locked);});
384
406
  ch.on('broadcast',{event:'map'},function(p){if(_onMap&&p&&p.payload)_onMap(p.payload);});
@@ -2243,7 +2265,11 @@ async function handler(req, res) {
2243
2265
  return jp(200, { ok: true });
2244
2266
  }
2245
2267
  if (body.action === "bot-history") return jp(200, { ok: true, messages: await docbot.listMessages(String(body.sessionId || "")) });
2246
- const blang = docbot.I18N_LANGS[String(body.lang || "").toLowerCase()] ? String(body.lang).toLowerCase() : null; // fr/inconnu null (langue source)
2268
+ // ⚠️ Même piège : un objet littéral répond à `constructor`. Sans `Object.hasOwn`, une
2269
+ // langue « constructor » passait la garde et finissait interpolée dans le prompt du
2270
+ // modèle sous la forme « function Object() { [native code] } ».
2271
+ const langueDemandee = String(body.lang || "").toLowerCase();
2272
+ const blang = Object.hasOwn(docbot.I18N_LANGS, langueDemandee) ? langueDemandee : null; // fr/inconnu → null (langue source)
2247
2273
  if (body.action === "bot-start") return jp(200, { ok: true, ...(await docbot.botStart(share, pages, mobile, String(body.intent || ""), blang)) });
2248
2274
  // Bascule de langue EN COURS de présentation : renvoie le script (traduit ou FR) — le client
2249
2275
  // remplace sa liste d'étapes et rejoue le message courant dans la nouvelle langue.
@@ -131,7 +131,13 @@ async function endPresentation(slug, control) {
131
131
  const ATT_KINDS = { "image/png": "image", "image/jpeg": "image", "image/webp": "image", "image/gif": "image", "application/pdf": "pdf" };
132
132
  async function createUploadUrl(slug, name, type) {
133
133
  const base = (process.env.SUPABASE_URL || "").replace(/\/+$/, ""); const KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
134
- const kind = ATT_KINDS[String(type || "").toLowerCase()];
134
+ // ⚠️ `Object.hasOwn` et pas une simple lecture : un objet littéral hérite de `constructor`,
135
+ // `toString`, `valueOf`, `__proto__`… `ATT_KINDS["constructor"]` rend une FONCTION, donc une
136
+ // valeur vraie — la garde juste en dessous laissait alors passer un type qui n'a jamais été
137
+ // autorisé, et signait une URL d'envoi pour lui. Le type est fourni par l'appelant, sur une
138
+ // action publique. Signalé par un hôte tiers qui venait de trouver la même forme chez lui.
139
+ const demande = String(type || "").toLowerCase();
140
+ const kind = Object.hasOwn(ATT_KINDS, demande) ? ATT_KINDS[demande] : null;
135
141
  if (!kind || !slug || !base || !KEY) return { ok: false, status: 400 };
136
142
  const safe = (String(name || "fichier").replace(/[^a-zA-Z0-9._-]/g, "_").slice(-60)) || "fichier";
137
143
  const path = `${String(slug).replace(/[^a-zA-Z0-9._-]/g, "")}/${Date.now()}-${crypto.randomBytes(4).toString("hex")}-${safe}`;
@@ -142,6 +148,33 @@ async function createUploadUrl(slug, name, type) {
142
148
  return { ok: true, path, token, kind, publicUrl: `${base}/storage/v1/object/public/present-attachments/${path}` };
143
149
  }
144
150
 
151
+ /**
152
+ * Les champs d'un message qui ont le droit de sortir du serveur.
153
+ *
154
+ * ⚠️ LISTE BLANCHE, jamais une liste noire. `author_hash` est l'empreinte du jeton qui autorise
155
+ * à éditer et supprimer ce message : le diffuser à toute l'audience donnerait à chacun le droit
156
+ * de réécrire les messages des autres. Une ligne de table renvoyée telle quelle emporterait ce
157
+ * champ sans que personne ne le remarque — d'où la projection explicite, ici et nulle part
158
+ * ailleurs. C'est le même jeu de champs que celui servi à l'historique.
159
+ */
160
+ const CHAMPS_PUBLICS = [
161
+ "id", "author_name", "author_email", "author_avatar", "is_presenter", "is_member",
162
+ "body", "attachment", "reactions", "reply_to", "reply_name", "reply_text",
163
+ "deleted", "edited", "created_at",
164
+ ];
165
+ function messagePublic(row) {
166
+ if (!row || typeof row !== "object") return null;
167
+ const out = {};
168
+ for (const c of CHAMPS_PUBLICS) if (c in row) out[c] = row[c];
169
+ return out;
170
+ }
171
+
172
+ /** Renvoie la ligne telle qu'elle est après écriture — sans jamais renvoyer plus que le public. */
173
+ function premierPublic(reponse) {
174
+ const row = Array.isArray(reponse) ? reponse[0] : reponse;
175
+ return messagePublic(row);
176
+ }
177
+
145
178
  async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
146
179
  const b = String(body || "").trim().slice(0, 2000);
147
180
  const base = (process.env.SUPABASE_URL || "").replace(/\/+$/, "");
@@ -159,8 +192,10 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
159
192
  author_hash: authorToken ? sha(authorToken) : null,
160
193
  reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
161
194
  };
162
- await PLAYER.db.request("doc_presentation_messages", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
163
- return { ok: true };
195
+ // `return=representation` : c'est la ligne écrite qui part ensuite en diffusion vers l'audience.
196
+ // Sans elle, l'émetteur devrait deviner l'`id` et la date attribués par la base.
197
+ const cree = await PLAYER.db.request("doc_presentation_messages?select=*", { method: "POST", headers: { Prefer: "return=representation" }, body: [row] });
198
+ return { ok: true, message: premierPublic(cree) };
164
199
  }
165
200
 
166
201
  async function listMessages(slug) {
@@ -175,8 +210,8 @@ async function editMessage(slug, msgId, authorToken, body) {
175
210
  const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=author_hash,deleted&limit=1`);
176
211
  const m = Array.isArray(rows) && rows[0]; if (!m || m.deleted) return { ok: false, status: 404 };
177
212
  if (!m.author_hash || m.author_hash !== sha(authorToken)) return { ok: false, status: 403 };
178
- await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { body: b, edited: true } });
179
- return { ok: true };
213
+ const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { body: b, edited: true } });
214
+ return { ok: true, message: premierPublic(maj) };
180
215
  }
181
216
 
182
217
  // Supprimer (soft) : l'auteur (jeton) OU le présentateur (control_token de la présentation).
@@ -189,8 +224,8 @@ async function deleteMessage(slug, msgId, { authorToken, control }) {
189
224
  let byPresenter = false;
190
225
  if (!byAuthor && control) { const pres = await getPresentation(slug); byPresenter = !!(pres && tokenMatches(control, pres.control_hash)); }
191
226
  if (!byAuthor && !byPresenter) return { ok: false, status: 403 };
192
- await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { deleted: true, body: "", reactions: {}, attachment: null } });
193
- return { ok: true };
227
+ const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { deleted: true, body: "", reactions: {}, attachment: null } });
228
+ return { ok: true, message: premierPublic(maj) };
194
229
  }
195
230
 
196
231
  // Verrouiller / déverrouiller le chat (présentateur uniquement).
@@ -212,8 +247,8 @@ async function toggleReaction(slug, msgId, emoji, reactor) {
212
247
  const i = arr.indexOf(who);
213
248
  if (i >= 0) arr.splice(i, 1); else arr.push(who);
214
249
  if (arr.length) cur[e] = arr; else delete cur[e];
215
- await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { reactions: cur } });
216
- return { ok: true };
250
+ const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur } });
251
+ return { ok: true, message: premierPublic(maj) };
217
252
  }
218
253
 
219
254
  // ── Statistiques de présentation (assistance) ────────────────────────────────────────────────────────────
@@ -316,4 +351,5 @@ async function listPresentationsForDoc(docId) {
316
351
  return list.map((p) => ({ slug: p.slug, presenterName: p.presenter_name, ownerName: p.owner_name, currentPage: p.current_page || 1, active: !!p.active, createdAt: p.created_at, endedAt: p.active ? null : p.updated_at, attendees: counts[p.slug] || 0 }));
317
352
  }
318
353
 
319
- module.exports = { init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
354
+ module.exports = {
355
+ messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };
package/supabase/init.sql CHANGED
@@ -223,10 +223,22 @@ alter table public.doc_presentation_attendees enable row level security;
223
223
  alter table public.doc_bot_sessions enable row level security;
224
224
 
225
225
  -- ── Temps réel ─────────────────────────────────────────────────────────────────────────────────
226
- -- Le chat en direct reste en `postgres_changes` : ses messages sont écrits POUR être vus de
227
- -- l'audience. L'état de la présentation, lui, voyage en broadcast d'où l'absence de
228
- -- `doc_presentations` ici, et l'absence de politique de lecture au-dessus.
229
- -- ⚠️ Ne mettez jamais de donnée confidentielle dans un message de chat ni dans un nom d'auteur.
226
+ -- L'état de la présentation (page courante, carte, verrou) voyage en BROADCAST : l'audience suit
227
+ -- le présentateur sans qu'aucune table ne soit lisible publiquement. C'est ce qui permet de ne
228
+ -- créer aucune politique de lecture au-dessus.
229
+ --
230
+ -- ⚠️ LE CHAT N'EST PAS ENCORE SUR CE CHEMIN. Ses messages sont livrés par `postgres_changes`, qui
231
+ -- exige un SELECT au niveau de la TABLE — donc ouvert à quiconque détient la clé publiable, et
232
+ -- pour TOUTES les présentations, pas seulement la sienne. Ce fichier refuse ce compromis : la
233
+ -- table est publiée, mais AUCUNE politique de lecture n'est créée.
234
+ --
235
+ -- Conséquence assumée sur une installation neuve : un participant voit l'historique du chat en
236
+ -- arrivant, et les messages suivants à l'actualisation — pas en direct. Le chat n'est pas cassé,
237
+ -- il est différé. Le portage du chat en broadcast lèvera cette limite ; d'ici là, mieux vaut une
238
+ -- fonctionnalité partiellement en retard qu'une base où chacun lit les conversations des autres.
239
+ --
240
+ -- ⚠️ Ne mettez de toute façon jamais de donnée confidentielle dans un message de chat ni dans un
241
+ -- nom d'auteur.
230
242
  do $$
231
243
  begin
232
244
  if exists (select 1 from pg_publication where pubname = 'supabase_realtime') then