discovery-media-player 0.1.70 → 0.1.71

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.
@@ -269,6 +269,18 @@ function createStandaloneContext(env = process.env) {
269
269
  }),
270
270
  // Écriture de fichier : hors périmètre d'une instance autonome (elle sert, elle ne range pas).
271
271
  async put() { throw new Error("storage.put n'est pas disponible sans câblage d'hôte"); },
272
+ // Optionnelle (rétention) : efface un objet du bucket — faux si l'API Storage est absente.
273
+ async remove(bucket, chemin) {
274
+ const base = sansBarreFinale(env.SUPABASE_URL);
275
+ const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
276
+ if (!base || !cle || !bucket || !chemin) return false;
277
+ try {
278
+ const r = await fetch(`${base}/storage/v1/object/${bucket}/${chemin}`, {
279
+ method: "DELETE", headers: { apikey: cle, Authorization: `Bearer ${cle}` },
280
+ });
281
+ return r.ok;
282
+ } catch { return false; }
283
+ },
272
284
 
273
285
  /**
274
286
  * URL d'envoi signée, pour une pièce jointe de chat. **Capacité de l'HÔTE**, pas du cœur.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
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
@@ -29,6 +29,7 @@ function init(ctx) {
29
29
  PLAYER = ctx;
30
30
  // Le domaine reçoit le même contexte : une seule construction pour tout le player.
31
31
  require("./shares").init(ctx);
32
+ require("./retention").init(ctx);
32
33
  require("./presentations").init(ctx);
33
34
  require("./brands").init(ctx);
34
35
  // ⚠️ Réinitialisé avec le contexte : les réponses de la sonde valent pour UNE base. Un hôte qui
@@ -3535,8 +3536,23 @@ async function handler(req, res) {
3535
3536
  // ⚠️ QUI a le droit de diffuser un document est en revanche une règle de l'HÔTE, pas du
3536
3537
  // player : elle passe par le contexte (`identity.canManageShares`). Le player se contente de
3537
3538
  // vérifier le jeton. Sans réponse de l'hôte : refus.
3539
+ // ── RÉTENTION (docs/RETENTION.md) ────────────────────────────────────────────────────────
3540
+ // Déclenchement explicite : hôte de confiance ou admin. Renvoie les comptes DÉCLARÉS —
3541
+ // le recensement indépendant (SQL nu) est l'autre moitié du contrat, pas cette route.
3542
+ if (body.action === "retention.run") {
3543
+ const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
3544
+ try {
3545
+ const hote = !!(PLAYER.identity.isTrustedHostCall && PLAYER.identity.isTrustedHostCall(req.headers));
3546
+ let admin = false;
3547
+ if (!hote) { const u = await PLAYER.identity.verifyToken(req.headers.authorization); admin = !!(u && PLAYER.identity.isAdmin(u)); }
3548
+ if (!hote && !admin) return jd(403, { ok: false, error: "retention.run : hôte de confiance ou admin requis" });
3549
+ return jd(200, await require("./retention").purgerRetention(Date.now()));
3550
+ } catch (e) { try { PLAYER.errors.capture(e, { route: "retention" }); } catch { /* jamais bloquant */ } return jd(500, { ok: false }); }
3551
+ }
3538
3552
  if (String(body.action || "").startsWith("docshare.")) {
3539
3553
  const jd = (status, obj) => { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(obj)); };
3554
+ // Balayage de rétention opportuniste — au plus un par 24 h, jamais bloquant.
3555
+ try { require("./retention").tick(); } catch { /* jamais bloquant */ }
3540
3556
  try {
3541
3557
  // ── L'HÔTE PARLE EN SON NOM PROPRE ────────────────────────────────────────────────
3542
3558
  //
@@ -0,0 +1,92 @@
1
+ // LA PURGE DÉCLARE, LE RECENSEMENT LA CONTREDIT.
2
+ //
3
+ // Ce module est UNE des deux moitiés du contrat de rétention (docs/RETENTION.md) : il efface ce
4
+ // qui a dépassé sa fenêtre et DÉCLARE ses comptes, table par table. L'autre moitié —
5
+ // `supabase/recensement-retention.sql` — recompte en SQL nu ce qui reste dans le périmètre
6
+ // revendiqué, sans partager une ligne, une fonction ni un filtre avec ce fichier : deux textes
7
+ // qui ne peuvent pas être faux de la même manière. La forge les confronte sur une base réelle.
8
+ //
9
+ // ⚠️ Le compte déclaré vient des LIGNES RENDUES par le DELETE (`Prefer: return=representation`,
10
+ // select réduit à une colonne), jamais d'un comptage préalable : ce qui est déclaré est ce qui a
11
+ // été fait, pas ce qui était prévu.
12
+ //
13
+ // ⚠️ L'absence ne se prouve pas ici : ce module peut seulement dire ce qu'il a effacé. La
14
+ // question « son périmètre couvre-t-il ce qui existe ? » appartient à la garde de forge qui
15
+ // confronte les colonnes du schéma vivant à docs/RETENTION.md.
16
+
17
+ let PLAYER = null;
18
+ const init = (ctx) => { PLAYER = ctx; };
19
+ const enc = encodeURIComponent;
20
+
21
+ // Fenêtres par défaut de docs/RETENTION.md — l'hôte ajuste via `config.retention`.
22
+ const FENETRES = { journauxMois: 13, presentationsMois: 12, liensRevoquesMois: 13 };
23
+ const fenetres = () => ({ ...FENETRES, ...((PLAYER.config && PLAYER.config.retention) || {}) });
24
+
25
+ // Borne calendaire : N mois avant `now`, en ISO — les fenêtres de RETENTION.md parlent en mois.
26
+ function borne(now, mois) { const d = new Date(now); d.setMonth(d.getMonth() - mois); return d.toISOString(); }
27
+
28
+ async function effacer(chemin) {
29
+ const lignes = await PLAYER.db.request(chemin, { method: "DELETE", headers: { Prefer: "return=representation" } });
30
+ return Array.isArray(lignes) ? lignes.length : 0;
31
+ }
32
+
33
+ // Chemin du bucket depuis l'URL publique d'une pièce jointe — nul si l'URL vient d'ailleurs.
34
+ function cheminPieceJointe(url) {
35
+ const m = /\/present-attachments\/(.+)$/.exec(String(url || ""));
36
+ return m ? m[1].split("?")[0] : null;
37
+ }
38
+
39
+ async function purgerRetention(now) {
40
+ const f = fenetres();
41
+ const efface = {};
42
+ const bJournaux = borne(now, f.journauxMois);
43
+
44
+ efface.commercial_doc_views = await effacer(`commercial_doc_views?at=lt.${enc(bJournaux)}&select=id`);
45
+ efface.commercial_doc_sessions = await effacer(`commercial_doc_sessions?last_at=lt.${enc(bJournaux)}&select=session_id`);
46
+ efface.commercial_doc_internal_sessions = await effacer(`commercial_doc_internal_sessions?last_at=lt.${enc(bJournaux)}&select=session_id`);
47
+ efface.doc_bot_sessions = await effacer(`doc_bot_sessions?last_at=lt.${enc(bJournaux)}&select=id`);
48
+ efface.player_rate_limits = await effacer(`player_rate_limits?expires_at=lt.${enc(new Date(now).toISOString())}&select=key`);
49
+
50
+ // Liens révoqués : seulement là où la révocation est DATÉE (0013) — sans la colonne, la purge
51
+ // resterait muette plutôt que d'inventer une borne depuis l'âge du lien.
52
+ const dateDispo = await require("./schema").attendue("revocationDatee");
53
+ efface.commercial_doc_shares = dateDispo
54
+ ? await effacer(`commercial_doc_shares?revoked=eq.true&revoked_at=lt.${enc(borne(now, f.liensRevoquesMois))}&select=slug`)
55
+ : 0;
56
+
57
+ // Présentations mortes : la ligne, ses messages, ses présences — et les fichiers du bucket si
58
+ // l'hôte fournit storage.remove (capacité OPTIONNELLE : sans elle, la limite est écrite dans
59
+ // RETENTION.md plutôt que simulée ici).
60
+ const bPres = borne(now, f.presentationsMois);
61
+ const mortes = await PLAYER.db.request(`doc_presentations?active=eq.false&updated_at=lt.${enc(bPres)}&select=slug`);
62
+ efface.doc_presentations = 0; efface.doc_presentation_messages = 0;
63
+ efface.doc_presentation_attendees = 0; efface.pieces_jointes = 0;
64
+ const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
65
+ for (const p of (Array.isArray(mortes) ? mortes : [])) {
66
+ const slug = p && p.slug; if (!slug) continue;
67
+ if (retirer) {
68
+ const jointes = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&attachment=not.is.null&select=attachment`);
69
+ for (const j of (Array.isArray(jointes) ? jointes : [])) {
70
+ const chemin = cheminPieceJointe(j && j.attachment);
71
+ if (!chemin) continue;
72
+ try { if (await retirer("present-attachments", chemin)) efface.pieces_jointes += 1; } catch { /* le fichier survit, la ligne part quand même — limite dite */ }
73
+ }
74
+ }
75
+ efface.doc_presentation_messages += await effacer(`doc_presentation_messages?slug=eq.${enc(slug)}&select=id`);
76
+ efface.doc_presentation_attendees += await effacer(`doc_presentation_attendees?slug=eq.${enc(slug)}&select=attendee_key`);
77
+ efface.doc_presentations += await effacer(`doc_presentations?slug=eq.${enc(slug)}&select=slug`);
78
+ }
79
+ return { ok: true, efface };
80
+ }
81
+
82
+ // Balayage opportuniste : au plus UN par fenêtre de 24 h (le verrou est le compteur de débit
83
+ // partagé — même mécanique que les limites, donc multi-processus sans coordination). Jamais
84
+ // bloquant : la route qui l'héberge répond sans l'attendre, l'échec se capture.
85
+ function tick() {
86
+ Promise.resolve()
87
+ .then(() => PLAYER.limits.allow("retention:sweep", 1, 86400))
88
+ .then((permis) => { if (permis) return purgerRetention(Date.now()); })
89
+ .catch((e) => { try { PLAYER.errors.capture(e, { route: "retention", benin: true }); } catch { /* jamais bloquant */ } });
90
+ }
91
+
92
+ module.exports = { init, purgerRetention, tick, cheminPieceJointe };
package/server/schema.js CHANGED
@@ -47,6 +47,11 @@ const ATTENDUES = {
47
47
  migration: "supabase/migrations/0011-liens-uniques.sql",
48
48
  fonction: "empêcher deux demandes simultanées de créer deux liens système pour le même usage",
49
49
  },
50
+ revocationDatee: {
51
+ table: "commercial_doc_shares", colonne: "revoked_at",
52
+ migration: "supabase/migrations/0013-revocation-datee.sql",
53
+ fonction: "dater la révocation pour borner la rétention des liens révoqués",
54
+ },
50
55
  reactionsOrdonnees: {
51
56
  table: "doc_presentation_messages", colonne: "reactions_seq",
52
57
  migration: "supabase/migrations/0006-reactions-ordonnees.sql",
package/server/shares.js CHANGED
@@ -345,7 +345,9 @@ async function sendReshareEmail({ parent, childSlug, origin, toEmail, toName })
345
345
  }
346
346
 
347
347
  async function revokeShare(slug) {
348
- await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { revoked: true } });
348
+ // La date borne la rétention (RETENTION.md) écrite seulement la colonne existe (0013).
349
+ const dateDispo = await require("./schema").attendue("revocationDatee");
350
+ await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { revoked: true, ...(dateDispo ? { revoked_at: new Date().toISOString() } : {}) } });
349
351
  }
350
352
 
351
353
  // Soft wall : (dé)verrouille l'accès d'un lien /doc — require_auth=true → connexion visiteur exigée.
package/supabase/init.sql CHANGED
@@ -39,6 +39,7 @@ create table if not exists public.commercial_doc_shares (
39
39
  created_by text,
40
40
  created_at timestamptz not null default now(),
41
41
  revoked boolean not null default false,
42
+ revoked_at timestamptz, -- date de l'acte : borne la rétention (RETENTION.md)
42
43
  parent_slug text, -- re-partage : le lien d'où celui-ci est issu
43
44
  bot_enabled boolean not null default false,
44
45
  bot_script text,
@@ -423,6 +424,9 @@ alter table public.doc_presentation_messages
423
424
  add column if not exists client_key text;
424
425
  alter table public.commercial_doc_shares
425
426
  add column if not exists idem_key text;
427
+ alter table public.commercial_doc_shares
428
+ add column if not exists revoked_at timestamptz;
429
+ update public.commercial_doc_shares set revoked_at = now() where revoked = true and revoked_at is null;
426
430
  -- Une base née d'un init.sql d'avant la 0008 porte un NOT NULL qui rend la clôture impossible.
427
431
  alter table public.doc_presentations
428
432
  alter column control_hash drop not null;
@@ -0,0 +1,21 @@
1
+ -- LA RÉVOCATION N'AVAIT PAS DE DATE — « 13 MOIS APRÈS RÉVOCATION » ÉTAIT INCALCULABLE.
2
+ --
3
+ -- docs/RETENTION.md promet la purge des liens révoqués 13 mois après leur révocation ; or le
4
+ -- schéma ne portait que `revoked boolean` et `created_at`. Sans date, la politique serait restée
5
+ -- une phrase — ou aurait pris l'âge du lien comme borne, purgeant un lien révoqué d'hier parce
6
+ -- qu'il est né il y a deux ans. La colonne date l'acte ; le backfill démarre l'horloge des
7
+ -- révoqués existants À MAINTENANT (leur date réelle est perdue — compter large plutôt qu'inventer).
8
+ --
9
+ -- Sans lui : rien ne casse — la purge des liens révoqués reste simplement inactive (sonde de
10
+ -- schéma négative), les autres purges tournent.
11
+ alter table public.commercial_doc_shares
12
+ add column if not exists revoked_at timestamptz;
13
+
14
+ comment on column public.commercial_doc_shares.revoked_at is
15
+ 'Date de révocation du lien — borne la rétention (purge N mois après, cf. docs/RETENTION.md). '
16
+ 'Nulle tant que le lien vit. Les révoqués d''avant cette colonne ont reçu la date de la '
17
+ 'migration : leur horloge démarre là.';
18
+
19
+ update public.commercial_doc_shares
20
+ set revoked_at = now()
21
+ where revoked = true and revoked_at is null;
@@ -0,0 +1,54 @@
1
+ -- LE RECENSEMENT INDÉPENDANT — L'AUTRE MOITIÉ DU CONTRAT DE RÉTENTION.
2
+ --
3
+ -- Ce fichier recompte, en SQL nu, ce qui RESTE dans le périmètre que docs/RETENTION.md revendique.
4
+ -- Il ne partage RIEN avec `server/retention.js` : ni fonction, ni filtre, ni transport — deux
5
+ -- textes écrits séparément, qui ne peuvent pas être faux de la même manière (l'erreur partagée
6
+ -- rend un compte juste ; l'indépendance est la propriété, pas un style). Les fenêtres (13/12/13
7
+ -- mois) sont le CONTRAT commun : elles viennent du document, pas du code.
8
+ --
9
+ -- Usage : psql -f supabase/recensement-retention.sql — chaque ligne rend « table|restantes ».
10
+ -- Après une purge, tout doit être à zéro ; un reste est un mensonge de la purge OU un trou de
11
+ -- son périmètre. La forge l'exécute à chaque course, après le banc base.
12
+ select 'commercial_doc_views' as perimetre, count(*) as restantes
13
+ from public.commercial_doc_views
14
+ where at < now() - interval '13 months'
15
+ union all
16
+ select 'commercial_doc_sessions', count(*)
17
+ from public.commercial_doc_sessions
18
+ where last_at < now() - interval '13 months'
19
+ union all
20
+ select 'commercial_doc_internal_sessions', count(*)
21
+ from public.commercial_doc_internal_sessions
22
+ where last_at < now() - interval '13 months'
23
+ union all
24
+ select 'doc_bot_sessions', count(*)
25
+ from public.doc_bot_sessions
26
+ where last_at < now() - interval '13 months'
27
+ union all
28
+ select 'player_rate_limits', count(*)
29
+ from public.player_rate_limits
30
+ where expires_at < now()
31
+ union all
32
+ select 'commercial_doc_shares_revoques', count(*)
33
+ from public.commercial_doc_shares
34
+ where revoked and revoked_at < now() - interval '13 months'
35
+ union all
36
+ select 'doc_presentations_mortes', count(*)
37
+ from public.doc_presentations
38
+ where not active and updated_at < now() - interval '12 months'
39
+ union all
40
+ -- Messages et présences : orphelins d'une présentation morte-ancienne OU d'aucune présentation —
41
+ -- formulé par NOT EXISTS, pas par la liste de slugs que la purge a construite.
42
+ select 'doc_presentation_messages_orphelins', count(*)
43
+ from public.doc_presentation_messages m
44
+ where not exists (
45
+ select 1 from public.doc_presentations p
46
+ where p.slug = m.slug
47
+ and (p.active or p.updated_at >= now() - interval '12 months'))
48
+ union all
49
+ select 'doc_presentation_attendees_orphelins', count(*)
50
+ from public.doc_presentation_attendees a
51
+ where not exists (
52
+ select 1 from public.doc_presentations p
53
+ where p.slug = a.slug
54
+ and (p.active or p.updated_at >= now() - interval '12 months'));