discovery-media-player 0.1.63 → 0.1.64

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.
@@ -29,6 +29,7 @@ need.
29
29
  "capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share", "host-mail"],
30
30
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
31
31
  "separateIssuer": true,
32
+ "internalStrict": true,
32
33
  "hostShare": true,
33
34
  "hostMail": true,
34
35
  "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false },
@@ -89,6 +90,13 @@ exactly the monitoring case where "no data" would otherwise read as "all clear".
89
90
  ⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
90
91
  on its own, even when the rest has not been checked.
91
92
 
93
+ ⚠️ **`internalStrict: false` means internal identity comes from the browser.** In transitional
94
+ mode the internal-analytics route accepts `docId`, `email` and `name` as the client declares them —
95
+ a caller can fabricate "this colleague read this document". Strict mode (`PLAYER_INTERNAL_STRICT=1`)
96
+ only accepts host-signed tokens. The flag exists so monitoring can **refuse** a non-strict instance
97
+ instead of discovering it in a log; treat `false` as an alert on any instance whose host already
98
+ issues internal tokens. The default will flip to strict at the next announced breaking change.
99
+
92
100
  ⚠️ **`couvre` states the scope, because `complet` without a scope overpromises.** The card checks
93
101
  the **conditional columns** — the ones a feature probes before writing. The rate-limit migrations
94
102
  (`0003`, `0004`) are deliberately **not** in it: a host may provide its own `limits` capability, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.63",
3
+ "version": "0.1.64",
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",
package/server/handler.js CHANGED
@@ -336,9 +336,10 @@ var Live=(function(){
336
336
  // qui exige que cette table soit lisible publiquement — donc, avec la clé publiable, les
337
337
  // conversations de TOUTES les présentations, pas seulement la sienne. C'était le dernier
338
338
  // obstacle avant de pouvoir fermer cette lecture.
339
- // L'émetteur diffuse la ligne que le serveur vient d'écrire ; les autres l'ajoutent chez eux.
339
+ // L'émetteur SIGNALE qu'un message existe ; chacun émetteur compris tient son affichage de
340
+ // la RÉPONSE du serveur ou de la relecture HTTP, jamais du canal.
340
341
  // ⚠️ La diffusion ne revient pas à son émetteur : il ajoute donc SA copie lui-même — depuis la
341
- // RÉPONSE du serveur, jamais depuis le canal.
342
+ // RÉPONSE du serveur.
342
343
  //
343
344
  // ⚠️ LA CHARGE EST VIDE, ET C'EST CE QUI REND LA PROPRIÉTÉ STRUCTURELLE. Elle transportait la
344
345
  // ligne projetée — que le récepteur IGNORAIT (il relit par HTTP, cf. le commentaire du
@@ -347,13 +348,18 @@ var Live=(function(){
347
348
  // était plus forte que le code, et tenait lieu de garde sans en être une — le jour où un
348
349
  // récepteur nouveau aurait lu la charge « puisqu'elle est là », la projection serait devenue
349
350
  // optionnelle sans que rien ne le dise. Le second hôte a levé l'écart ; sendState et
350
- // sendMap avaient déjà la bonne forme. Un commentaire ici même affirmait l'ancien monde
351
- // (« les autres l'ajoutent chez eux ») : il a fait dériver sa lecture, pas la nôtre — un
352
- // commentaire n'est pas du code, et il vieillit sans essai pour le contredire.
351
+ // sendMap avaient déjà la bonne forme. Le commentaire d'en tête de ce bloc a affirmé l'ancien
352
+ // monde (« les autres l'ajoutent chez eux ») pendant des versions : il a fait dériver la
353
+ // lecture du second hôte puis a SURVÉCU à sa propre citation : le correctif qui le désignait
354
+ // comme menteur l'a cité au lieu de le réécrire, et documenter un défaut l'avait rendu
355
+ // intouchable. Réécrit à la relecture suivante, par le même hôte. Un commentaire n'est pas du
356
+ // code : il vieillit sans essai pour le contredire — et un essai fige désormais la charge vide.
353
357
  function sendMsg(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg',payload:{}});}catch(e){}}
354
358
  function sendMsgUpd(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg-upd',payload:{}});}catch(e){}}
355
359
  // Édition, suppression, réaction : le serveur renvoie la ligne à jour, on l'applique chez soi
356
- // puis on la diffuse. Même chemin pour les trois — une seule façon de se tromper.
360
+ // puis on SIGNALE — la charge part vide, les autres relisent. Même chemin pour les trois — une
361
+ // seule façon de se tromper. (« Puis on la diffuse » a survécu ici une version de trop : second
362
+ // exemplaire du commentaire d'en tête, trouvé par le second hôte dans le tarball publié.)
357
363
  function majDiffusee(r){return r.json().then(function(d){if(d&&d.ok&&d.message){updateMsg(d.message);sendMsgUpd(d.message);}}).catch(function(){});}
358
364
  function onState(fn){_onState=fn;}
359
365
  // Badge « non lus » sur le bouton chat/FAB (panneau fermé) + pulse du FAB. Aperçu (ticker) au nouveau message.
@@ -3908,6 +3914,12 @@ async function handler(req, res) {
3908
3914
  // l'hôte connaît déjà son émetteur — il veut seulement savoir si l'instance le regarde.
3909
3915
  // Dire lequel n'aiderait personne et renseignerait qui sonde.
3910
3916
  separateIssuer: !!(PLAYER.config && PLAYER.config.separateIssuer),
3917
+ // ⚠️ « L'identité interne vient-elle d'un JETON, ou du navigateur ? » En mode transitoire
3918
+ // (false), la route interne accepte docId/email/name tels que le client les déclare — un
3919
+ // appelant peut fabriquer « tel collègue a consulté tel document ». Le booléen rend l'état
3920
+ // MESURABLE : un cockpit peut refuser une instance non stricte, au lieu de le découvrir en
3921
+ // lisant un journal. Demandé par le second hôte (cinquième audit, P1-4).
3922
+ internalStrict: !!(PLAYER.config && PLAYER.config.internalStrict),
3911
3923
  // ⚠️ L'ÉTAT DU SCHÉMA, LÀ OÙ ON REGARDE. Une colonne absente était signalée par un
3912
3924
  // `console.warn`, une fois par processus : sur une fonction serverless, une ligne perdue
3913
3925
  // dans une sortie que personne n'ouvre tant que tout a l'air de marcher — et « tout a
@@ -446,6 +446,11 @@ function messagePublic(row) {
446
446
  // relit sans avoir à vérifier d'où vient chaque clé.
447
447
  const out = Object.create(null);
448
448
  for (const c of CHAMPS_PUBLICS) if (c in row) out[c] = row[c];
449
+ // ⚠️ LA CEINTURE : une ligne supprimée sort VIDE, quoi que porte encore la base. Les courses
450
+ // d'avant ce correctif — et les lignes historiques qu'elles ont laissées — peuvent avoir gardé
451
+ // un corps, une pièce jointe ou une citation sous deleted=true : la projection est le dernier
452
+ // endroit qui peut refuser de les resservir, et le seul qui couvre AUSSI le passé.
453
+ if (out.deleted) { out.body = ""; out.attachment = null; out.reactions = {}; out.reply_text = null; out.reply_name = null; }
449
454
  if ("reactions" in out) out.reactions = reactionsPubliques(out.reactions);
450
455
  // Dérivé, jamais recopié : `author_hash` est lu pour ça et ne sort jamais tel quel.
451
456
  if ("author_hash" in row) out.author_ref = refAuteur(row.author_hash);
@@ -551,7 +556,13 @@ async function editMessage(slug, msgId, authorToken, body) {
551
556
  const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=author_hash,deleted&limit=1`);
552
557
  const m = Array.isArray(rows) && rows[0]; if (!m || m.deleted) return { ok: false, status: 404 };
553
558
  if (!m.author_hash || m.author_hash !== sha(authorToken)) return { ok: false, status: 403 };
554
- const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { body: b, edited: true } });
559
+ // ⚠️ CE QUI A ÉTÉ VÉRIFIÉ VOYAGE DANS LA CONDITION. Une suppression entre la lecture et
560
+ // l'écriture vidait le message — et l'édition retardée RÉÉCRIVAIT le texte dans une ligne
561
+ // marquée supprimée : effacé à l'écran, ressuscité dans le JSON. Cinquième audit, P1-3.
562
+ // `deleted=eq.false` fait de « pas supprimé » une propriété de l'ÉCRITURE ; `author_hash` et
563
+ // `slug` par cohérence — l'écriture ne repose plus sur rien qu'elle n'exige elle-même.
564
+ const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&author_hash=eq.${sha(authorToken)}&deleted=eq.false&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { body: b, edited: true } });
565
+ if (!Array.isArray(maj) || !maj.length) return { ok: false, status: 409 };
555
566
  return { ok: true, message: premierPublic(maj) };
556
567
  }
557
568
 
@@ -566,7 +577,16 @@ async function deleteMessage(slug, msgId, { authorToken, control }) {
566
577
  let byPresenter = false;
567
578
  if (!byAuthor && control) { const pres = await getPresentation(slug); byPresenter = !!(pres && tokenMatches(control, pres.control_hash)); }
568
579
  if (!byAuthor && !byPresenter) return { ok: false, status: 403 };
569
- 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 } });
580
+ // ⚠️ Par l'AUTEUR : son hash dans la condition l'écriture n'exige que ce qu'elle a vérifié.
581
+ // Par le PRÉSENTATEUR : le jeton vit dans une AUTRE table, aucun filtre PostgREST ne peut le
582
+ // porter — la fenêtre résiduelle (un ex-présentateur modère dans la seconde où on lui reprend
583
+ // la main) est assumée : elle ne rend qu'un droit qu'il avait légitimement l'instant d'avant.
584
+ // `deleted=eq.false` partout : supprimer deux fois est IDEMPOTENT — zéro ligne veut dire
585
+ // « déjà supprimé », et c'est l'état que l'appelant voulait. `reply_text`/`reply_name` partent
586
+ // aussi : un message supprimé ne doit pas continuer de citer ce qu'il citait.
587
+ const cond = byAuthor ? `&author_hash=eq.${sha(authorToken)}` : "";
588
+ const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}${cond}&deleted=eq.false&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { deleted: true, body: "", reactions: {}, attachment: null, reply_text: null, reply_name: null } });
589
+ if (!Array.isArray(maj) || !maj.length) return { ok: true, deja: true };
570
590
  return { ok: true, message: premierPublic(maj) };
571
591
  }
572
592
 
@@ -612,7 +632,7 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
612
632
  // ⚠️ LE RANG N'EST DEMANDÉ QUE LÀ OÙ IL EXISTE — sélectionner une colonne absente fait échouer
613
633
  // la LECTURE entière chez un hôte non migré, et ce serait les réactions qu'on casserait.
614
634
  const ordonne = await require("./schema").attendue("reactionsOrdonnees");
615
- const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=${ordonne ? "reactions,reactions_seq" : "reactions"}&limit=1`);
635
+ const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=${ordonne ? "reactions,reactions_seq,deleted" : "reactions,deleted"}&limit=1`);
616
636
  // ⚠️ LE NOM DE PROPRIÉTÉ ÉCRIT ICI VIENT DU CLIENT — et la garde posée en 0.1.2 ne couvrait que
617
637
  // les LECTURES. `Object.hasOwn` empêchait de LIRE `constructor` ; rien n'empêchait de l'ÉCRIRE.
618
638
  //
@@ -635,6 +655,10 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
635
655
  // nombre de concurrents réels, pas par la malchance.
636
656
  let lu = rows;
637
657
  for (let essai = 0; essai < 4; essai += 1) {
658
+ // ⚠️ Un message supprimé PENDANT la boucle : on s'arrête — réagir à un message effacé le
659
+ // ferait revivre dans le tableau des réactions. Et `deleted=eq.false` dans la condition
660
+ // d'écriture ferme la course entre cette lecture et le PATCH.
661
+ if (Array.isArray(lu) && lu[0] && lu[0].deleted) return { ok: false, status: 404 };
638
662
  const brut = (Array.isArray(lu) && lu[0] && lu[0].reactions && typeof lu[0].reactions === "object" && !Array.isArray(lu[0].reactions)) ? lu[0].reactions : {};
639
663
  const rang = ordonne ? Math.trunc(Number((Array.isArray(lu) && lu[0] && lu[0].reactions_seq) || 0)) : 0;
640
664
  // Objet NU déclaré tel quel : la garde statique des écritures indexées reconnaît la
@@ -647,11 +671,14 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
647
671
  if (veut && i < 0) arr.push(who);
648
672
  if (!veut && i >= 0) arr.splice(i, 1);
649
673
  if (arr.length) cur[e] = arr; else delete cur[e];
650
- const condition = ordonne ? `&reactions_seq=eq.${rang}` : "";
674
+ const condition = (ordonne ? `&reactions_seq=eq.${rang}` : "") + "&deleted=eq.false";
651
675
  const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}${condition}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur, ...(ordonne ? { reactions_seq: rang + 1 } : {}) } });
652
- if (!ordonne || (Array.isArray(maj) && maj.length)) return { ok: true, message: premierPublic(maj) };
676
+ if (Array.isArray(maj) && maj.length) return { ok: true, message: premierPublic(maj) };
677
+ // Hôte non migré (pas de rang) : zéro ligne ne peut venir que de `deleted` — le message est
678
+ // parti pendant qu'on écrivait. Pas de rejeu qui le ferait revivre.
679
+ if (!ordonne) return { ok: false, status: 404 };
653
680
  // Rang dépassé : quelqu'un a écrit entre notre lecture et notre écriture. On relit, on rejoue.
654
- lu = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions,reactions_seq&limit=1`);
681
+ lu = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions,reactions_seq,deleted&limit=1`);
655
682
  }
656
683
  // Quatre tours perdus : plus de concurrents que de raisons d'attendre. Un refus net vaut mieux
657
684
  // qu'une écriture qui écraserait ce que les gagnants viennent de poser.
@@ -794,7 +821,12 @@ async function switchPresentationDoc(slug, email, isAdmin, { fileUrl, fileName,
794
821
  if (!row) return { ok: false, status: 404 };
795
822
  if (estClose(row)) return { ok: false, status: 409, error: "ended" };
796
823
  if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
797
- const ecrit = await ecrireSiEncoreVrai(`doc_presentations?slug=eq.${enc(slug)}&active=eq.true`, {
824
+ // ⚠️ LE PROPRIÉTAIRE VÉRIFIÉ VOYAGE DANS LA CONDITION — un transfert entre la lecture et
825
+ // l'écriture donnait la présentation à Bob, et la requête retardée d'Alice changeait ENCORE le
826
+ // document de Bob. Même classe que reprise/clôture/transfert (#130), deux survivants trouvés par
827
+ // le cinquième audit. L'admin, lui, écrit sur la présentation active telle quelle : modération.
828
+ const ecrit = await ecrireSiEncoreVrai(
829
+ `doc_presentations?slug=eq.${enc(slug)}&active=eq.true${isAdmin ? "" : `&owner_email=eq.${enc(row.owner_email)}`}`, {
798
830
  file_url: String(fileUrl), file_name: fileName || null, doc_title: docTitle || null, doc_id: docId ? String(docId) : null,
799
831
  content: null, current_page: 1, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(),
800
832
  });
@@ -859,10 +891,12 @@ async function setPresentationContent(slug, email, isAdmin, content, control) {
859
891
  // droit en fermant une porte. Le PILOTE écrit tant que son jeton vaut : une carte affichée
860
892
  // pendant que le portable dormait doit encore partir, comme une page. Le PROPRIÉTAIRE, lui,
861
893
  // n'écrit que sur une présentation vivante : c'est déjà ce que le contrôle au-dessus exige.
894
+ // ⚠️ Et le PROPRIÉTAIRE (non-admin) voyage dans la condition, comme partout : sans ça, sa
895
+ // requête retardée écrivait encore sur la présentation qu'un transfert venait de donner.
862
896
  const ecrit = await ecrireSiEncoreVrai(
863
897
  pilote
864
898
  ? `doc_presentations?slug=eq.${enc(slug)}&control_hash=eq.${sha(control)}`
865
- : `doc_presentations?slug=eq.${enc(slug)}&active=eq.true`,
899
+ : `doc_presentations?slug=eq.${enc(slug)}&active=eq.true${isAdmin ? "" : `&owner_email=eq.${enc(row.owner_email)}`}`,
866
900
  { content: sanitizeContent(content), active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() },
867
901
  );
868
902
  if (!ecrit) return { ok: false, status: 409, error: "ended" };
package/supabase/init.sql CHANGED
@@ -269,6 +269,9 @@ begin
269
269
  then now() + make_interval(secs => p_window_seconds)
270
270
  else l.expires_at end
271
271
  returning l.count into v_count;
272
+
273
+ -- Le refus est décidé ICI, sur la valeur qu'on vient d'écrire : le client n'a rien à recalculer,
274
+ -- donc rien à recalculer sur une valeur déjà périmée.
272
275
  return query select (v_count <= p_max), v_count;
273
276
  end;
274
277
  $$;
@@ -344,8 +347,11 @@ alter table public.doc_bot_sessions enable row level security;
344
347
  -- ── Le scellé de l'archive ─────────────────────────────────────────────────────────────────────
345
348
  -- Sept chemins d'écriture vérifient l'archive PUIS écrivent — dans une AUTRE table que celle qui
346
349
  -- porte l'état. Aucun filtre PostgREST ne peut fermer cette fenêtre : l'arbitre est la base.
347
- -- `FOR KEY SHARE` bloque une clôture concurrente jusqu'au commit de l'écriture c'est lui qui
348
- -- rend le refus atomique, pas le simple test.
350
+ -- ⚠️ `FOR SHARE`, PAS `FOR KEY SHARE` la clôture modifie des colonnes NON-CLÉS, son UPDATE prend
351
+ -- FOR NO KEY UPDATE, que KEY SHARE ne bloque pas. La 0007 a porté ce verrou trop faible pendant
352
+ -- une version, avec un commentaire qui affirmait l'atomicité (corrigé par la 0010, constat du
353
+ -- cinquième audit — VU au banc à deux transactions de la forge : le message entrait dans
354
+ -- l'archive). C'est FOR SHARE qui rend le refus atomique.
349
355
  create or replace function public.player_archive_scellee()
350
356
  returns trigger
351
357
  language plpgsql
@@ -357,7 +363,7 @@ begin
357
363
  select p.active, p.control_hash into v_active, v_control
358
364
  from public.doc_presentations p
359
365
  where p.slug = new.slug
360
- for key share;
366
+ for share;
361
367
  -- Pas de présentation : rien à sceller (chat d'un lien sans session live, données historiques).
362
368
  if not found then return new; end if;
363
369
  if v_active = false and v_control is null then
@@ -9,10 +9,11 @@
9
9
  -- doc_presentation_attendees, la condition vit dans doc_presentations — une AUTRE table. Sans
10
10
  -- transaction ni RPC côté player, l'arbitre ne peut être que la base elle-même. D'où ce trigger.
11
11
  --
12
- -- ⚠️ `FOR KEY SHARE` EST CE QUI REND LE REFUS ATOMIQUE, pas le simple test. Sans lui, une clôture
13
- -- peut se COMMITTER entre la vérification du trigger et le commit de l'écriture la fenêtre
14
- -- rétrécit mais survit. Le verrou de ligne bloque la clôture concurrente jusqu'au commit de
15
- -- l'écriture : l'ordre devient réel, plus probable.
12
+ -- ⚠️ LE VERROU CI-DESSOUS EST TROP FAIBLE, ET LA 0010 LE REMPLACE. Cette migration affirmait que
13
+ -- FOR KEY SHARE rendait le refus atomique : faux la clôture modifie des colonnes non-clés, son
14
+ -- UPDATE prend FOR NO KEY UPDATE, compatible avec KEY SHARE. La fenêtre prétendue fermée restait
15
+ -- ouverte (cinquième audit, VU au banc à deux transactions). Le fichier reste tel qu'appliqué par
16
+ -- les bases historiques ; la correction est une migration de plus, comme toujours.
16
17
  --
17
18
  -- Sans lui : rien ne casse — les sept chemins gardent leur vérification préalable, et la fenêtre
18
19
  -- de course reste ce qu'elle était avant cette migration. Le trigger est le scellé, pas la porte.
@@ -0,0 +1,38 @@
1
+ -- LE SCELLÉ DE L'ARCHIVE PRÉTENDAIT UNE ATOMICITÉ QUE SON VERROU NE FOURNISSAIT PAS.
2
+ --
3
+ -- La 0007 verrouillait la présentation en FOR KEY SHARE — et son commentaire affirmait que c'était
4
+ -- « ce qui rend le refus atomique ». Faux : la clôture modifie `active` et `control_hash`, des
5
+ -- colonnes NON-CLÉS, donc son UPDATE prend FOR NO KEY UPDATE — que KEY SHARE ne bloque PAS
6
+ -- (matrice des verrous PostgreSQL : KEY SHARE ne conflit qu'avec FOR UPDATE). Le trigger vérifiait
7
+ -- « ouverte », la clôture se committait sous lui, le message entrait dans l'archive : la fenêtre
8
+ -- que 0007 prétendait fermer était ouverte. Constat P1-1 du cinquième audit — VU au banc de la
9
+ -- forge : deux transactions psql, l'écriture n'attendait pas la clôture.
10
+ --
11
+ -- FOR SHARE, lui, conflit avec FOR NO KEY UPDATE : une clôture non committée fait ATTENDRE
12
+ -- l'écriture (qui voit ensuite l'archive et est refusée) ; une écriture en cours fait attendre la
13
+ -- clôture. Le coût est nul en pratique : plusieurs écritures partagent le verrou entre elles,
14
+ -- seule la clôture entre en conflit — précisément l'objectif.
15
+ --
16
+ -- Sans lui : le scellé garde sa valeur de refus (une écriture APRÈS la clôture committée est
17
+ -- toujours refusée) — seule la fenêtre concurrente reste ouverte, comme entre 0007 et 0010.
18
+ create or replace function public.player_archive_scellee()
19
+ returns trigger
20
+ language plpgsql
21
+ as $$
22
+ declare
23
+ v_active boolean;
24
+ v_control text;
25
+ begin
26
+ select p.active, p.control_hash into v_active, v_control
27
+ from public.doc_presentations p
28
+ where p.slug = new.slug
29
+ for share;
30
+ -- Pas de présentation : rien à sceller (chat d'un lien sans session live, données historiques).
31
+ if not found then return new; end if;
32
+ if v_active = false and v_control is null then
33
+ raise exception 'presentation archivee : ecriture refusee'
34
+ using errcode = 'P0001', hint = 'l''archive est en lecture seule';
35
+ end if;
36
+ return new;
37
+ end
38
+ $$;