discovery-media-player 0.1.56 → 0.1.58

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.
@@ -176,7 +176,9 @@ async function lireDepuis(fh, stat, cible, range) {
176
176
  const buf = Buffer.alloc(fin - debut + 1);
177
177
  const { bytesRead } = await fh.read(buf, 0, buf.length, debut);
178
178
  const corps = bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
179
- const entetes = { "content-type": type };
179
+ // La taille est connue d'un `stat` sur le descripteur ouvert : l'annoncer permet au relais de
180
+ // la refuser avant d'allouer, et à la visionneuse d'afficher une progression.
181
+ const entetes = { "content-type": type, "content-length": String(corps.length) };
180
182
  if (statut === 206) entetes["content-range"] = `bytes ${debut}-${fin}/${total}`;
181
183
  return reponse(statut, entetes, corps);
182
184
  }
@@ -31,7 +31,8 @@ need.
31
31
  "separateIssuer": true,
32
32
  "hostShare": true,
33
33
  "hostMail": true,
34
- "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false }
34
+ "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false },
35
+ "schema": { "attendues": 3, "sondees": 1, "manquant": [] }
35
36
  }
36
37
  ```
37
38
 
@@ -39,6 +40,18 @@ need.
39
40
  never by order. `plugins` lets you refuse to start when you depend on an optional module this
40
41
  instance does not have.
41
42
 
43
+ ⚠️ **`schema` tells you which migrations this instance is still waiting for.** The player never
44
+ applies migrations — it cannot, it only speaks PostgREST — so it *detects* instead, and a missing
45
+ column makes the feature that needs it **degrade silently**, by design, so as not to break a host
46
+ mid-migration. That silence is the point: an operator whose write ordering and message idempotency
47
+ are both switched off sees an instance that looks perfectly healthy. Each entry names the file to
48
+ apply and the feature that is asleep.
49
+
50
+ ⚠️ **`sondees` is not decoration.** The card **reports** what this process has already asked; it
51
+ never probes, because a diagnostic must answer when the database does not. A freshly started
52
+ process therefore knows nothing, and `manquant: []` on `sondees: 0` means *no question asked yet*,
53
+ not *nothing missing*. Compare `sondees` with `attendues` before you conclude.
54
+
42
55
  ⚠️ **`frameAncestors` matters more than it looks.** A host that is not listed will never see the
43
56
  viewer: the browser blocks the iframe **before any script runs**, so no message can be emitted and
44
57
  the host sees a silence indistinguishable from an unreachable instance. Check that your domain is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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,7 +62,8 @@
62
62
  "lint:fix": "eslint bin context server src build --fix",
63
63
  "typecheck": "tsc --noEmit",
64
64
  "prepublishOnly": "npm run build && npm test",
65
- "test:e2e": "vitest run --config vitest.e2e.config.mjs"
65
+ "test:e2e": "vitest run --config vitest.e2e.config.mjs",
66
+ "test:base": "vitest run --config vitest.base.config.mjs"
66
67
  },
67
68
  "engines": {
68
69
  "node": ">=22"
package/server/handler.js CHANGED
@@ -4,6 +4,8 @@
4
4
  // - GET /doc/:slug?file=1 → stream le PDF depuis le Storage (MÊME ORIGINE → pas de souci CORS pour pdf.js)
5
5
  // - POST /api/doc {slug,event…}→ journalise un événement (open / page / heartbeat) — best-effort
6
6
  const crypto = require("crypto");
7
+ const { Readable } = require("node:stream");
8
+ const { pipeline } = require("node:stream/promises");
7
9
  const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
8
10
  createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
9
11
  const { SESSION_QUOTA_PER_HOUR, PRESENT_QUOTA_PER_HOUR, PRESENT_CACHE_MS } = require("./shared.generated.js");
@@ -436,7 +438,12 @@ var Live=(function(){
436
438
  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();}
437
439
  function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
438
440
  function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
439
- var o={action:'present-chat',slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
441
+
442
+ // ⚠️ LA CLÉ EST FABRIQUÉE ICI, UNE FOIS, AVANT LE PREMIER ENVOI. Une clé tirée à chaque
443
+ // tentative ne servirait à rien : deux envois porteraient deux clés et passeraient tous les
444
+ // deux. C'est sa RÉUTILISATION au renvoi qui rend l'opération idempotente.
445
+ var _cle=(function(){ try{ var r=crypto.getRandomValues(new Uint8Array(12)); return Array.from(r,function(x){return x.toString(16).padStart(2,'0');}).join(''); }catch(e){ return ''; } })();
446
+ var o={action:'present-chat',clientKey:_cle,slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
440
447
  if(CONTROL)o.control=CONTROL;
441
448
  if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
442
449
  var h1={'Content-Type':'application/json'};var j1=accessToken();if(j1)h1.Authorization='Bearer '+j1;
@@ -1558,24 +1565,28 @@ async function relayerFichier(res, r, disposition) {
1558
1565
  const compresse = !!r.headers.get("content-encoding");
1559
1566
  if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
1560
1567
 
1561
- // ⚠️ UN PLAFOND, ET IL REFUSE AVANT D'ALLOUER. Ce relais chargeait le fichier ENTIER en mémoire
1562
- // sans borne : un PDF de 80 Mo, trois requêtes Range simultanées, et une fonction serverless
1563
- // tombe — pas pour un document en particulier, pour la somme. Le défaut n'est pas la taille d'un
1564
- // fichier, c'est l'absence de toute limite haute.
1568
+ // ⚠️ DEUX BORNES, ET LA SECONDE EST LA SEULE QUI TIENNE DEVANT UN AMONT QUI MENT.
1565
1569
  //
1566
- // ⚠️ ON REGARDE `Content-Length` AVANT DE LIRE LE CORPS : refuser après l'allocation ne protège
1567
- // de rien, c'est l'allocation qui coûte. Un amont qui n'annonce pas sa taille passe quand même —
1568
- // on ne peut pas refuser ce qu'on ne sait pas mesurer, et fermer par défaut couperait des
1569
- // stockages parfaitement légitimes. La borne est donc une garde contre le GROS, pas contre
1570
- // l'inconnu ; c'est ce que le streaming, lui, fermera vraiment.
1571
- const annoncee = Number(r.headers.get("content-length") || 0);
1570
+ // La première regarde `Content-Length` et renonce AVANT d'ouvrir le corps. C'est la seule qui
1571
+ // puisse encore répondre 413, puisque rien n'est parti — mais elle croit l'amont sur parole : un
1572
+ // stockage qui n'annonce rien, ou qui annonce 1 Ko et en envoie 500, passait sans être inquiété.
1573
+ //
1574
+ // La seconde COMPTE LES OCTETS QUI PASSENT et rompt au dépassement. Elle ne peut plus répondre
1575
+ // 413 : les en-têtes sont partis avec le premier octet, et on ne dédit pas un en-tête déjà
1576
+ // envoyé. Elle coupe. Le client voit un transfert interrompu — désagréable et honnête, là où
1577
+ // l'épuisement de la mémoire emportait la fonction ENTIÈRE, donc aussi les requêtes des autres.
1578
+ const brute = r.headers.get("content-length");
1579
+ const annoncee = Number(brute || 0);
1572
1580
  if (annoncee > PLAFOND_RELAIS) {
1573
1581
  try { PLAYER.errors.capture(new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
1574
1582
  res.statusCode = 413;
1575
1583
  res.end("Fichier trop volumineux");
1584
+ // ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
1585
+ // pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
1586
+ try { if (r.body) r.body.cancel(); } catch { /* déjà refermé */ }
1576
1587
  return;
1577
1588
  }
1578
- const buf = Buffer.from(await r.arrayBuffer());
1589
+
1579
1590
  res.statusCode = r.status;
1580
1591
  const typeAmont = r.headers.get("content-type") || "application/pdf";
1581
1592
  const executable = TYPES_EXECUTABLES.test(typeAmont);
@@ -1591,10 +1602,53 @@ async function relayerFichier(res, r, disposition) {
1591
1602
  // Les bornes d'un `Content-Range` ne valent que si l'amont n'a pas compressé.
1592
1603
  const cr = !compresse && r.headers.get("content-range");
1593
1604
  if (cr) res.setHeader("Content-Range", cr);
1594
- res.setHeader("Content-Length", String(buf.length)); // ce qu'on envoie, jamais ce qu'on a reçu
1605
+ // ⚠️ `fetch` DÉCOMPRESSE DE LUI-MÊME, et c'est le piège du flux. Sur un amont gzip,
1606
+ // `Content-Length` compte les octets COMPRIMÉS alors que nous relayons les octets déployés :
1607
+ // le recopier ferait attendre au client des octets qui ne viendront jamais, ou lui ferait couper
1608
+ // le document au milieu. La bufferisation nous rendait ce service sans qu'on le demande —
1609
+ // `buf.length` était toujours juste. En flux, il faut savoir se taire ; voir plus bas.
1595
1610
  if (disposition) res.setHeader("Content-Disposition", disposition);
1596
1611
  res.setHeader("Cache-Control", "private, max-age=600");
1597
- res.end(buf);
1612
+
1613
+ // ⚠️ UN AMONT SANS CORPS LISIBLE N'EST PAS UNE ANOMALIE, C'EST LE CONTRAT. `storage.fetchFile`
1614
+ // est une capacité de l'HÔTE : il rend ce qu'il veut, du moment qu'il sait dire `arrayBuffer()`.
1615
+ // Le chemin fichier local du mode autonome, lui, ne rend rien d'autre. Traiter cette absence
1616
+ // comme « rien à envoyer » servait des fichiers VIDES, sans une erreur pour le dire — un défaut
1617
+ // pire que celui qu'on ferme ici, et que seuls deux essais existants ont vu tomber.
1618
+ //
1619
+ // Ici la borne du flux ne peut rien : `arrayBuffer()` a déjà tout alloué quand on pourrait
1620
+ // compter. Seule la taille annoncée protège — ce qui suffit, parce qu'un hôte qui rend un corps
1621
+ // en un bloc l'a lu depuis quelque chose dont il connaît la taille.
1622
+ if (!r.body) {
1623
+ const buf = Buffer.from(await r.arrayBuffer());
1624
+ res.setHeader("Content-Length", String(buf.length)); // connue, donc annoncée
1625
+ res.end(buf);
1626
+ return;
1627
+ }
1628
+
1629
+ // ⚠️ ON N'ANNONCE UNE LONGUEUR QUE QUAND ON SAIT QU'ELLE DÉCRIT CE QU'ON ENVOIE — et en flux,
1630
+ // la seule qu'on ait est celle de l'amont. Sans encodage elle est exacte : on la garde, elle
1631
+ // vaut une barre de progression. Avec encodage on se tait (cf. plus haut), et la fin du corps
1632
+ // fait foi. Sans annonce amont, on se tait aussi : c'est le prix du flux, payé en connaissance.
1633
+ if (!compresse && brute) res.setHeader("Content-Length", brute);
1634
+
1635
+ const plafond = PLAFOND_RELAIS;
1636
+ try {
1637
+ await pipeline(Readable.fromWeb(r.body), async function* (source) {
1638
+ let vus = 0;
1639
+ for await (const morceau of source) {
1640
+ vus += morceau.length;
1641
+ if (vus > plafond) throw new Error(`relais interrompu : ${vus} octets reçus, plafond ${plafond}`);
1642
+ yield morceau;
1643
+ }
1644
+ }, res);
1645
+ } catch (erreur) {
1646
+ // ⚠️ ROMPRE, PAS RÉPONDRE — et le DIRE. Aucun code de retour n'est plus disponible ; ne
1647
+ // reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
1648
+ // défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
1649
+ try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "relais" }); } catch { /* jamais bloquant */ }
1650
+ try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
1651
+ }
1598
1652
  }
1599
1653
 
1600
1654
  // ⚠️ UNE MENTION DONT L'OBJET EST D'ÊTRE EXACTE NE DOIT PAS INVENTER UN EXPÉDITEUR.
@@ -3309,7 +3363,7 @@ async function handler(req, res) {
3309
3363
  name: (profil && profil.name) || body.name,
3310
3364
  email: profil ? profil.email : body.email,
3311
3365
  avatar: (profil && profil.avatar) || body.avatar,
3312
- isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
3366
+ isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment , clientKey: body.clientKey });
3313
3367
  return jp(r.ok ? 200 : (r.status || 400), r);
3314
3368
  } catch { return jp(500, { ok: false }); }
3315
3369
  }
@@ -3422,10 +3476,7 @@ async function handler(req, res) {
3422
3476
  // capable d'expédier en son nom : le repli silencieux ouvrirait exactement la porte que
3423
3477
  // la séparation ferme. On nomme le fichier à appliquer et on s'arrête.
3424
3478
  if (atteste) {
3425
- const pret = await require("./schema").aLaColonne(
3426
- "commercial_doc_shares", "attested_recipient_email",
3427
- "supabase/migrations/0001-destinataire-atteste.sql",
3428
- );
3479
+ const pret = await require("./schema").attendue("destinataireAtteste");
3429
3480
  if (!pret) {
3430
3481
  return jd(409, { ok: false, error: "migration", message: "Destinataire attesté indisponible : appliquez supabase/migrations/0001-destinataire-atteste.sql." });
3431
3482
  }
@@ -3770,6 +3821,14 @@ async function handler(req, res) {
3770
3821
  // l'hôte connaît déjà son émetteur — il veut seulement savoir si l'instance le regarde.
3771
3822
  // Dire lequel n'aiderait personne et renseignerait qui sonde.
3772
3823
  separateIssuer: !!(PLAYER.config && PLAYER.config.separateIssuer),
3824
+ // ⚠️ L'ÉTAT DU SCHÉMA, LÀ OÙ ON REGARDE. Une colonne absente était signalée par un
3825
+ // `console.warn`, une fois par processus : sur une fonction serverless, une ligne perdue
3826
+ // dans une sortie que personne n'ouvre tant que tout a l'air de marcher — et « tout a
3827
+ // l'air de marcher » est exactement l'état d'un hôte dont trois protections dorment. Cette
3828
+ // carte est ce qu'un hôte interroge déjà pour épingler sa version ; c'est donc ici.
3829
+ // Elle RAPPORTE ce qui est connu, elle ne sonde pas : un diagnostic ne doit pas tomber en
3830
+ // même temps que ce qu'il diagnostique.
3831
+ schema: require("./schema").etatDuSchema(),
3773
3832
  // « L'hôte peut-il créer un lien en son nom propre ? » — configuré, pas seulement
3774
3833
  // possible. Un hôte qui oublie le secret reçoit un 401 qui ressemble à un droit
3775
3834
  // manquant ; ce booléen le lui dit sans qu'il ait à essayer.
@@ -54,9 +54,7 @@ async function reclaimPresentation(slug, email) {
54
54
  // ⚠️ Et j'ai écrit ce champ sans condition en premier jet, ce qui est exactement le piège que
55
55
  // docs/MIGRATIONS.md décrit : PostgREST rejette le PATCH ENTIER si la colonne manque. La reprise
56
56
  // aurait cessé de fonctionner chez tout hôte non migré — pas la nouvelle garantie, la reprise.
57
- const rangDispo = await require("./schema").aLaColonne(
58
- "doc_presentations", "write_seq", "supabase/migrations/0002-ordre-des-ecritures.sql",
59
- );
57
+ const rangDispo = await require("./schema").attendue("rangEcriture");
60
58
  await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { control_hash: sha(control), active: true, ...(rangDispo ? { write_seq: 0 } : {}), last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
61
59
  return { ok: true, slug, control, page: row.current_page || 1, fileUrl: row.file_url, fileName: row.file_name, docTitle: row.doc_title, docId: row.doc_id };
62
60
  }
@@ -200,9 +198,7 @@ async function getPresentation(slug) {
200
198
  async function rangAccepte(row, seq) {
201
199
  const rang = Number(seq);
202
200
  if (!Number.isFinite(rang) || rang <= 0) return { controle: false }; // client plus ancien : pas de rang
203
- const dispo = await require("./schema").aLaColonne(
204
- "doc_presentations", "write_seq", "supabase/migrations/0002-ordre-des-ecritures.sql",
205
- );
201
+ const dispo = await require("./schema").attendue("rangEcriture");
206
202
  if (!dispo) return { controle: false };
207
203
  if (rang <= Number(row.write_seq || 0)) return { controle: true, perime: true };
208
204
  // ⚠️ ON REND LE RANG, PAS UN OBJET TOUT FAIT. La première version rendait « { write_seq: rang } »,
@@ -403,7 +399,7 @@ function premierPublic(reponse) {
403
399
  return messagePublic(row);
404
400
  }
405
401
 
406
- async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
402
+ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment, clientKey }) {
407
403
  if (await estArchive(slug)) return REFUS_ARCHIVE;
408
404
  const b = String(body || "").trim().slice(0, 2000);
409
405
  // ⚠️ Pas de normalisation ici : `config.supabaseUrl` arrive SANS barre finale, c'est le
@@ -424,10 +420,40 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
424
420
  author_hash: authorToken ? sha(authorToken) : null,
425
421
  reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
426
422
  };
423
+ // ⚠️ LA CLÉ N'EST ÉCRITE QUE SI LA COLONNE EXISTE. PostgREST rejette le POST ENTIER sur une
424
+ // colonne inconnue : chez un hôte non migré, ce n'est pas l'idempotence qu'on perdrait, c'est
425
+ // l'envoi de messages. Même piège que le rang d'écriture, même sonde.
426
+ const cle = String(clientKey || "").slice(0, 80);
427
+ if (cle && await require("./schema").attendue("envoiUnique")) {
428
+ row.client_key = cle;
429
+ }
430
+
427
431
  // `return=representation` : c'est la ligne écrite qui part ensuite en diffusion vers l'audience.
428
432
  // Sans elle, l'émetteur devrait deviner l'`id` et la date attribués par la base.
429
- const cree = await PLAYER.db.request("doc_presentation_messages?select=*", { method: "POST", headers: { Prefer: "return=representation" }, body: [row] });
430
- return { ok: true, message: premierPublic(cree) };
433
+ // ⚠️ UN REFUS D'UNICITÉ N'EST PAS UNE ERREUR, C'EST UNE CONFIRMATION. La contrainte dit « ce
434
+ // message est déjà là » : le remonter au participant remplacerait « deux messages » par « une
435
+ // erreur », ce qui n'est pas mieux. On relit donc la ligne déjà écrite et on la rend comme si
436
+ // l'envoi venait de réussir — ce qu'il a fait, la première fois.
437
+ try {
438
+ const cree = await PLAYER.db.request("doc_presentation_messages?select=*", { method: "POST", headers: { Prefer: "return=representation" }, body: [row] });
439
+ return { ok: true, message: premierPublic(cree) };
440
+ } catch (erreur) {
441
+ // ⚠️ LA GARDE DES ÉCRITURES MUETTES A REFUSÉ CE BLOC, et elle avait raison de le faire : un
442
+ // `try` autour d'une écriture doit dire quelque chose. Ici le silence était volontaire — un 409
443
+ // attendu n'est pas un incident — mais rien ne distinguait « je sais ce que je rattrape » de
444
+ // « j'avale tout ». On le dit donc : ce qui n'est pas le conflit attendu remonte, et le conflit
445
+ // attendu est journalisé une fois, en clair, parce qu'un renvoi fréquent est une information.
446
+ const conflit = cle && String((erreur && erreur.message) || "").includes("409");
447
+ if (!conflit) throw erreur;
448
+ try { PLAYER.errors.capture(new Error("message déjà enregistré (renvoi) : " + String(slug)), { route: "present-chat", benin: true }); } catch { /* jamais bloquant */ }
449
+ const deja = await PLAYER.db.request(
450
+ `doc_presentation_messages?slug=eq.${enc(String(slug))}&client_key=eq.${enc(cle)}&select=*&limit=1`);
451
+ const ligne = Array.isArray(deja) && deja[0];
452
+ // ⚠️ Si la relecture ne trouve rien, on ne prétend pas : le 409 venait d'autre chose, et le
453
+ // taire ferait croire à un envoi réussi qui n'a pas eu lieu.
454
+ if (!ligne) throw erreur;
455
+ return { ok: true, message: messagePublic(ligne), deja: true };
456
+ }
431
457
  }
432
458
 
433
459
  /**
package/server/schema.js CHANGED
@@ -16,6 +16,34 @@
16
16
  // chantiers ont été repoussés pour cette seule raison. Avec cette sonde, l'ordre de déploiement
17
17
  // cesse d'être un piège.
18
18
 
19
+ /**
20
+ * CE QUE CE CODE ATTEND DE LA BASE — DÉCLARÉ UNE FOIS, ET C'EST LA SOURCE.
21
+ *
22
+ * ⚠️ UN INVENTAIRE QUI N'EST PAS LA SOURCE DÉRIVE. Ces couples vivaient recopiés sur quatre
23
+ * appels ; en tirer une simple liste « pour l'affichage » aurait refait, en plus petit, le défaut
24
+ * qui a vidé supabase/init.sql de ses cinq migrations : deux exemplaires du même fait, personne
25
+ * pour les confronter. Les appelants passent donc par `attendue(nom)` et ne nomment plus de
26
+ * colonne — il n'existe plus qu'un endroit où se tromper. Une étape de la forge vérifie en outre
27
+ * que chaque fichier nommé ici existe, et qu'aucun appel ne contourne cette table.
28
+ */
29
+ const ATTENDUES = {
30
+ destinataireAtteste: {
31
+ table: "commercial_doc_shares", colonne: "attested_recipient_email",
32
+ migration: "supabase/migrations/0001-destinataire-atteste.sql",
33
+ fonction: "attribuer une lecture au destinataire attesté par l'hôte",
34
+ },
35
+ rangEcriture: {
36
+ table: "doc_presentations", colonne: "write_seq",
37
+ migration: "supabase/migrations/0002-ordre-des-ecritures.sql",
38
+ fonction: "refuser une écriture de pilotage doublée en vol",
39
+ },
40
+ envoiUnique: {
41
+ table: "doc_presentation_messages", colonne: "client_key",
42
+ migration: "supabase/migrations/0005-envoi-unique.sql",
43
+ fonction: "empêcher qu'un renvoi crée un second message",
44
+ },
45
+ };
46
+
19
47
  let PLAYER = null;
20
48
  /**
21
49
  * Une question posée une fois, retenue pour le processus.
@@ -28,9 +56,59 @@ let PLAYER = null;
28
56
  */
29
57
  const connues = new Map();
30
58
 
59
+ /**
60
+ * Les réponses DÉJÀ OBTENUES, pour qui veut les lire — la carte d'identité, essentiellement.
61
+ *
62
+ * ⚠️ CE N'EST PAS UN DOUBLON DE `connues`. Celle-ci porte des promesses, dont on ne peut rien dire
63
+ * sans les attendre ; celle-là porte des réponses. La distinction compte parce que la carte
64
+ * d'identité ne doit RIEN demander à la base — elle doit répondre quand la base ne répond plus.
65
+ */
66
+ const reponses = new Map();
67
+
31
68
  function init(ctx) {
32
69
  PLAYER = ctx;
33
70
  connues.clear();
71
+ reponses.clear();
72
+ }
73
+
74
+ /** La sonde d'une attente déclarée. C'est la seule forme d'appel que les appelants utilisent. */
75
+ function attendue(nom) {
76
+ const a = ATTENDUES[nom];
77
+ // Un nom inconnu est une faute de frappe, pas une dégradation : la taire ferait passer la
78
+ // fonction pour « en attente de migration » alors qu'elle est simplement mal câblée.
79
+ if (!a) throw new Error(`attente de schéma inconnue : ${nom}`);
80
+ return aLaColonne(a.table, a.colonne, a.migration);
81
+ }
82
+
83
+ /**
84
+ * ⚠️ CE QUE LE JOURNAL NE DIRA JAMAIS À PERSONNE.
85
+ *
86
+ * La sonde signale une colonne absente par un `console.warn`, une fois par processus. Sur une
87
+ * fonction serverless, c'est une ligne perdue dans une sortie que personne n'ouvre quand tout a
88
+ * l'air de marcher — et « tout a l'air de marcher » est précisément l'état d'un hôte dont trois
89
+ * protections dorment. Remarque du second hôte, et elle est juste : la trace existait à l'endroit
90
+ * exact où on ne regarde pas.
91
+ *
92
+ * ⚠️ ON NE SONDE PAS ICI, ON RAPPORTE. La carte d'identité doit répondre quand la base ne répond
93
+ * plus ; sonder depuis elle en ferait un diagnostic qui tombe en même temps que ce qu'il diagnostique.
94
+ *
95
+ * ⚠️ D'OÙ TROIS ÉTATS, ET PAS DEUX. Un processus qui n'a encore rien demandé ne sait rien — et
96
+ * « rien de manquant » se lirait « tout va bien ». Une absence de résultat ressemble à un
97
+ * résultat ; `sondees` est là pour qu'on ne puisse pas les confondre.
98
+ */
99
+ function etatDuSchema() {
100
+ const manquant = [];
101
+ for (const [, r] of reponses) if (!r.present) manquant.push({ migration: r.migration, fonction: r.fonction });
102
+ return {
103
+ attendues: Object.keys(ATTENDUES).length,
104
+ sondees: reponses.size,
105
+ // ⚠️ ON NOMME LE FICHIER, alors que cette route est publique. Même raison que `frameAncestors`
106
+ // juste au-dessus d'elle : l'exploitant n'a AUCUN autre moyen d'apprendre laquelle manque, et
107
+ // un compte nu le laisserait deviner. Ce qu'on révèle en échange — qu'une fonction de
108
+ // fiabilité est en attente, dans un dépôt dont les migrations sont publiques — n'ouvre aucun
109
+ // accès : il faut déjà détenir un jeton de pilotage pour tirer parti d'un rang absent.
110
+ manquant,
111
+ };
34
112
  }
35
113
 
36
114
  /**
@@ -67,6 +145,7 @@ async function sonder(table, colonne, migration, cle) {
67
145
  // portage a un fichier à réécrire, pas une habitude à retrouver partout.
68
146
  const champ = encodeURIComponent(colonne);
69
147
  await PLAYER.db.request(`${table}?select=${champ}&limit=0`);
148
+ noter(cle, true, migration);
70
149
  return true;
71
150
  } catch {
72
151
  // ⚠️ ON NE DISTINGUE PAS « COLONNE ABSENTE » DE « BASE INJOIGNABLE », ET C'EST VOULU. Les deux
@@ -74,11 +153,18 @@ async function sonder(table, colonne, migration, cle) {
74
153
  // message d'erreur, c'est-à-dire de dépendre du texte d'un service tiers. Ce qui change entre
75
154
  // les deux, c'est la durée : une base injoignable le redevient, et le processus suivant reposera
76
155
  // la question.
156
+ noter(cle, false, migration);
77
157
  signaler(cle, migration);
78
158
  return false;
79
159
  }
80
160
  }
81
161
 
162
+ /** La réponse, retenue pour qui la demandera — sans repasser par la base. */
163
+ function noter(cle, present, migration) {
164
+ const a = Object.values(ATTENDUES).find((x) => `${x.table}.${x.colonne}` === cle);
165
+ reponses.set(cle, { present, migration, fonction: a ? a.fonction : "" });
166
+ }
167
+
82
168
  /**
83
169
  * ⚠️ ON NOMME LE FICHIER, PAS L'ERREUR. « column does not exist » envoie l'exploitant lire du
84
170
  * PostgREST ; « appliquez supabase/migrations/0001-…sql » lui dit quoi faire. La différence entre
@@ -92,6 +178,6 @@ function signaler(cle, migration) {
92
178
  }
93
179
 
94
180
  /** Pour les tests et l'exploitation : reposer la question. */
95
- function oublier() { connues.clear(); }
181
+ function oublier() { connues.clear(); reponses.clear(); }
96
182
 
97
- module.exports = { init, aLaColonne, oublier };
183
+ module.exports = { init, aLaColonne, attendue, etatDuSchema, oublier, ATTENDUES };
package/supabase/init.sql CHANGED
@@ -50,7 +50,10 @@ create table if not exists public.commercial_doc_shares (
50
50
  brand_logo text, -- logo RECOPIÉ (flux historiques, toujours accepté)
51
51
  brand_dark boolean not null default false,
52
52
  require_auth boolean not null default false,
53
- brand_key text -- ⚠️ RÉFÉRENCE résolue à l'affichage (branding.forKey)
53
+ brand_key text, -- ⚠️ RÉFÉRENCE résolue à l'affichage (branding.forKey)
54
+ -- Destinataire attesté par l'hôte : sert à ATTRIBUER une lecture, jamais à expédier en son nom.
55
+ -- `recipient_email`, elle, dit qui peut expédier — vide quand personne ne le peut.
56
+ attested_recipient_email text
54
57
  );
55
58
  create index if not exists cds_doc_id_idx on public.commercial_doc_shares (doc_id);
56
59
  create index if not exists cds_parent_idx on public.commercial_doc_shares (parent_slug);
@@ -139,7 +142,11 @@ create table if not exists public.doc_presentations (
139
142
  owner_avatar text,
140
143
  owner_email text,
141
144
  last_seen timestamptz not null default now(),
142
- content jsonb -- carte / Street View (revalidé à la réception)
145
+ content jsonb, -- carte / Street View (revalidé à la réception)
146
+ -- Rang de la dernière écriture de pilotage acceptée. Une écriture de rang inférieur ou égal est
147
+ -- refusée : elle a été doublée en vol. Remis à zéro par toute émission d'un jeton de contrôle
148
+ -- (démarrage, reprise), qui ouvre un nouveau domaine d'ordre.
149
+ write_seq bigint not null default 0
143
150
  );
144
151
  create index if not exists doc_presentations_active_idx on public.doc_presentations (active, updated_at);
145
152
  create index if not exists doc_presentations_last_seen_idx on public.doc_presentations (active, last_seen);
@@ -163,9 +170,17 @@ create table if not exists public.doc_presentation_messages (
163
170
  reply_text text,
164
171
  deleted boolean not null default false,
165
172
  edited boolean not null default false,
166
- attachment jsonb
173
+ attachment jsonb,
174
+ -- Clé d'idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi :
175
+ -- un renvoi réseau ne crée pas un second message. Nulle si le client ne la fournit pas.
176
+ client_key text
167
177
  );
168
178
  create index if not exists dpm_slug_idx on public.doc_presentation_messages (slug, created_at);
179
+ -- ⚠️ Portée sur (slug, client_key), pas sur la clé seule : deux présentations n'ont aucune raison
180
+ -- de partager un espace de clés. Partiel, pour que les lignes sans clé ne se gênent pas entre elles.
181
+ create unique index if not exists dpm_client_key_uniq
182
+ on public.doc_presentation_messages (slug, client_key)
183
+ where client_key is not null;
169
184
 
170
185
  create table if not exists public.doc_presentation_attendees (
171
186
  slug text not null,
@@ -209,6 +224,74 @@ create table if not exists public.doc_bot_sessions (
209
224
  );
210
225
  create index if not exists doc_bot_sessions_share_idx on public.doc_bot_sessions (share_slug);
211
226
 
227
+ -- ── Limites de débit partagées ─────────────────────────────────────────────────────────────────
228
+ -- ⚠️ EN MÉMOIRE, UNE LIMITE NE LIMITE RIEN. Chaque instance serverless a la sienne : N instances
229
+ -- accordent N fois le quota, et l'hôte croit être protégé. Le compteur vit donc en base.
230
+ create table if not exists public.player_rate_limits (
231
+ key text primary key,
232
+ count integer not null default 0,
233
+ expires_at timestamptz not null
234
+ );
235
+ create index if not exists player_rate_limits_expires_idx
236
+ on public.player_rate_limits (expires_at);
237
+ alter table public.player_rate_limits enable row level security;
238
+ comment on table public.player_rate_limits is
239
+ 'Compteurs de débit partagés entre instances. Une ligne par clé et par fenêtre ; les lignes '
240
+ 'périmées sont écrasées à la première demande suivante, il n''y a rien à purger.';
241
+
242
+ -- ⚠️ ET LIRE PUIS ÉCRIRE N'EST PAS ATOMIQUE. Deux appels simultanés lisent le même compte et
243
+ -- écrivent la même valeur : la limite laisse passer le double. L'incrément se fait donc en UNE
244
+ -- instruction, côté serveur.
245
+ create or replace function public.player_rate_limit_bump(
246
+ p_key text,
247
+ p_max integer,
248
+ p_window_seconds integer
249
+ )
250
+ returns table (autorise boolean, compte integer)
251
+ language plpgsql
252
+ security definer
253
+ set search_path = public
254
+ as $$
255
+ declare
256
+ v_count integer;
257
+ begin
258
+ insert into public.player_rate_limits as l (key, count, expires_at)
259
+ values (p_key, 1, now() + make_interval(secs => p_window_seconds))
260
+ on conflict (key) do update
261
+ set count = case when l.expires_at <= now() then 1 else l.count + 1 end,
262
+ expires_at = case when l.expires_at <= now()
263
+ then now() + make_interval(secs => p_window_seconds)
264
+ else l.expires_at end
265
+ returning l.count into v_count;
266
+ return query select (v_count <= p_max), v_count;
267
+ end;
268
+ $$;
269
+ -- ⚠️ `anon` ET `authenticated` SONT DES RÔLES SUPABASE, PAS DES RÔLES POSTGRES. Les nommer en dur
270
+ -- faisait ÉCHOUER ce fichier sur un Postgres nu — donc chez tout hôte auto-hébergé, c'est-à-dire le
271
+ -- public que ce dépôt vise en s'ouvrant. Le `grant` ci-dessous était déjà gardé par un `if exists` ;
272
+ -- ce `revoke` ne l'était pas : la prudence s'arrêtait à mi-chemin. Trouvé par la garde de schéma.
273
+ --
274
+ -- `public` n'est pas un rôle mais un mot-clé : celui-là passe partout, et c'est le seul qui compte.
275
+ revoke all on function public.player_rate_limit_bump(text, integer, integer) from public;
276
+ do $$
277
+ declare
278
+ r text;
279
+ begin
280
+ foreach r in array array['anon', 'authenticated'] loop
281
+ if exists (select 1 from pg_roles where rolname = r) then
282
+ execute format('revoke all on function public.player_rate_limit_bump(text, integer, integer) from %I', r);
283
+ end if;
284
+ end loop;
285
+ end
286
+ $$;
287
+ do $$
288
+ begin
289
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
290
+ grant execute on function public.player_rate_limit_bump(text, integer, integer) to service_role;
291
+ end if;
292
+ end
293
+ $$;
294
+
212
295
  -- ── Accès ──────────────────────────────────────────────────────────────────────────────────────
213
296
  -- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
214
297
  -- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
@@ -252,3 +335,23 @@ begin
252
335
  end if;
253
336
  end $$;
254
337
  alter table public.doc_presentation_messages replica identity full;
338
+
339
+ -- ── Rattrapage : bases installées depuis un init.sql plus ancien ───────────────────────────────
340
+ --
341
+ -- ⚠️ CE FICHIER A ÉTÉ INCOMPLET, ET RIEN NE LE DISAIT. Il annonçait « un seul fichier, sans rien à
342
+ -- lire ailleurs » alors qu'aucune des cinq migrations de `supabase/migrations/` n'y figurait : un
343
+ -- hôte neuf installait une base sans rang d'écriture, sans limites partagées et sans clé
344
+ -- d'idempotence. Les sondes de schéma dégradent en silence — par conception, pour ne pas casser un
345
+ -- hôte en cours de migration — donc cet hôte-là ne l'apprenait JAMAIS. Un état anormal que rien ne
346
+ -- dit devient l'état normal.
347
+ --
348
+ -- Les colonnes sont désormais dans le corps des tables ci-dessus, ce qui règle le cas d'une base
349
+ -- VIERGE. Mais `create table if not exists` ne touche pas une table déjà là : une base créée
350
+ -- depuis l'ancien init.sql resterait incomplète en rejouant celui-ci. D'où ce rattrapage, qui ne
351
+ -- coûte rien sur une base neuve et rend le fichier vrai dans les deux cas.
352
+ alter table public.commercial_doc_shares
353
+ add column if not exists attested_recipient_email text;
354
+ alter table public.doc_presentations
355
+ add column if not exists write_seq bigint not null default 0;
356
+ alter table public.doc_presentation_messages
357
+ add column if not exists client_key text;
@@ -52,7 +52,24 @@ end;
52
52
  $$;
53
53
 
54
54
  -- Le player parle à la base avec la clé de service ; personne d'autre n'a à appeler ceci.
55
- revoke all on function public.player_rate_limit_bump(text, integer, integer) from public, anon, authenticated;
55
+ -- ⚠️ `anon` ET `authenticated` SONT DES RÔLES SUPABASE, PAS DES RÔLES POSTGRES. Les nommer en dur
56
+ -- faisait ÉCHOUER ce fichier sur un Postgres nu — donc chez tout hôte auto-hébergé, c'est-à-dire le
57
+ -- public que ce dépôt vise en s'ouvrant. Le `grant` ci-dessous était déjà gardé par un `if exists` ;
58
+ -- ce `revoke` ne l'était pas : la prudence s'arrêtait à mi-chemin. Trouvé par la garde de schéma.
59
+ --
60
+ -- `public` n'est pas un rôle mais un mot-clé : celui-là passe partout, et c'est le seul qui compte.
61
+ revoke all on function public.player_rate_limit_bump(text, integer, integer) from public;
62
+ do $$
63
+ declare
64
+ r text;
65
+ begin
66
+ foreach r in array array['anon', 'authenticated'] loop
67
+ if exists (select 1 from pg_roles where rolname = r) then
68
+ execute format('revoke all on function public.player_rate_limit_bump(text, integer, integer) from %I', r);
69
+ end if;
70
+ end loop;
71
+ end
72
+ $$;
56
73
 
57
74
  -- ⚠️ LE `revoke` CI-DESSUS NE SUFFIT PAS À RENDRE CE FICHIER VRAI TOUT SEUL — signalé par le
58
75
  -- second hôte, qui a interrogé `has_function_privilege` au lieu de relire la migration. Chez
@@ -0,0 +1,44 @@
1
+ -- DEUX ENVOIS DU MÊME MESSAGE NE DOIVENT PAS FAIRE DEUX MESSAGES.
2
+ --
3
+ -- Un renvoi réseau, un double-clic, une reprise après délai : la requête part deux fois et la base
4
+ -- enregistre deux lignes. Le participant voit son message en double, et rien ne le lui explique —
5
+ -- il n'y a eu aucune erreur, seulement un succès de trop.
6
+ --
7
+ -- ⚠️ LA CLÉ EST FABRIQUÉE PAR LE CLIENT, UNE FOIS, AVANT LE PREMIER ENVOI — et c'est toute la
8
+ -- subtilité. Une clé tirée à chaque tentative ne servirait à rien : les deux envois porteraient des
9
+ -- clés différentes et passeraient tous les deux. C'est la RÉUTILISATION de la clé au renvoi qui
10
+ -- rend l'opération idempotente, pas la clé elle-même.
11
+ --
12
+ -- ⚠️ INDEX PARTIEL, et il faut comprendre pourquoi. Les lignes déjà en base n'ont pas de clé ; un
13
+ -- index unique ordinaire les traiterait comme des doublons entre elles (plusieurs NULL sont
14
+ -- distincts en SQL, donc en réalité ça passerait — mais la condition rend l'intention explicite et
15
+ -- protège d'un futur `not null default ''`, où toutes les anciennes lignes deviendraient soudain
16
+ -- identiques). L'index ne contraint que ce que le nouveau client écrit.
17
+ --
18
+ -- ⚠️ CE QUE LA BASE FAIT, ET CE QUE LE CODE DOIT FAIRE. La contrainte REFUSE le second envoi ;
19
+ -- PostgREST répond alors 409. Ce refus n'est pas une erreur à remonter au participant : c'est la
20
+ -- preuve que son message est déjà là. Le serveur doit donc relire la ligne existante et la rendre,
21
+ -- comme si l'envoi avait réussi — sinon on remplace « deux messages » par « une erreur », ce qui
22
+ -- n'est pas mieux. Cette partie-là est du code, elle n'est pas dans ce fichier.
23
+ --
24
+ -- Sans lui : rien ne change, deux envois du même message créent toujours deux lignes. Le client
25
+ -- peut envoyer la clé, la colonne l'ignore poliment — aucune écriture ne casse, et le player ne
26
+ -- signale rien puisque l'ancien comportement EST le comportement d'aujourd'hui.
27
+ --
28
+ -- Applicable pendant que la version précédente tourne : tant que personne n'écrit la colonne, elle
29
+ -- reste vide et l'index ne contraint rien.
30
+
31
+ alter table public.doc_presentation_messages
32
+ add column if not exists client_key text;
33
+
34
+ comment on column public.doc_presentation_messages.client_key is
35
+ 'Clé d''idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi. '
36
+ 'Unique par présentation (index partiel dpm_client_key_uniq). Nulle sur les lignes antérieures '
37
+ 'à la migration 0005, et sur celles écrites par un client qui ne la fournit pas.';
38
+
39
+ -- ⚠️ Portée sur (slug, client_key) et pas sur la clé seule : deux présentations différentes n'ont
40
+ -- aucune raison de partager un espace de clés, et un client qui réutiliserait par accident la même
41
+ -- valeur d'une session à l'autre verrait son message refusé sans comprendre.
42
+ create unique index if not exists dpm_client_key_uniq
43
+ on public.doc_presentation_messages (slug, client_key)
44
+ where client_key is not null;