discovery-media-player 0.1.79 → 0.1.81

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.
@@ -35,6 +35,7 @@ need.
35
35
  "frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
36
36
  "separateIssuer": true,
37
37
  "internalStrict": true,
38
+ "retentionSweep": false,
38
39
  "hostShare": true,
39
40
  "hostMail": true,
40
41
  "plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false },
@@ -95,6 +96,12 @@ exactly the monitoring case where "no data" would otherwise read as "all clear".
95
96
  ⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
96
97
  on its own, even when the rest has not been checked.
97
98
 
99
+ ⚠️ **`retentionSweep`** says whether the automatic retention purge is *armed*
100
+ (`config.retention.balayage === true`). The `retention` capability only says the instance *can*
101
+ purge; this boolean says whether it *does*, on its own, once a day. Default `false` — nothing is
102
+ deleted unless an operator wrote the policy. A cockpit can read this to know if an instance is
103
+ subject to automatic deletion, instead of inferring it from a log.
104
+
98
105
  ⚠️ **`internalStrict: false` means internal identity comes from the browser.** In transitional
99
106
  mode the internal-analytics route accepts `docId`, `email` and `name` as the client declares them —
100
107
  a caller can fabricate "this colleague read this document". Strict mode (`PLAYER_INTERNAL_STRICT=1`)
package/docs/RETENTION.md CHANGED
@@ -115,6 +115,13 @@ Parcours guidé par l'agent : **purge 13 mois** après `last_at`.
115
115
  contexte hôte fournit `storage.remove` (capacité optionnelle). Sans elle, l'URL devient
116
116
  introuvable depuis le produit mais l'objet survit dans le bucket — c'est dit ici plutôt que
117
117
  simulé.
118
+ - **Le plafond des présentations est GLOBAL** : messages et présences partagent chacun un budget
119
+ `plafond` réparti sur toutes les présentations d'une exécution — pas un plafond par présentation
120
+ (sinon 500 × 5000 = 2,5 M de lignes possibles). La boucle s'arrête quand les budgets sont
121
+ épuisés, sans supprimer les présentations restantes.
122
+ - **Le rapport dryRun est complet pour les présentations** : `messagesExaminees`,
123
+ `presencesExaminees` et `fichiersCandidats` disent ce que la VRAIE purge ferait — même parcours
124
+ de sélection, suppression no-op, `efface.* = 0`.
118
125
  - **La purge avance par LOTS bornés** (200 lignes, plafond 5000 par table et 500 présentations
119
126
  par exécution) : elle sélectionne un lot d'identifiants, les supprime par `id=in.(…)`, et
120
127
  recommence. Le rapport (`r.rapport`) porte, par table : `examinees`, `supprimees`, `tronque`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
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
@@ -595,6 +595,12 @@ async function handler(req, res) {
595
595
  // MESURABLE : un cockpit peut refuser une instance non stricte, au lieu de le découvrir en
596
596
  // lisant un journal. Demandé par le second hôte (cinquième audit, P1-4).
597
597
  internalStrict: !!(PLAYER.config && PLAYER.config.internalStrict),
598
+ // ⚠️ LE BALAYAGE DE RÉTENTION EST-IL ARMÉ ? La capacité `retention` dit que l'instance PEUT
599
+ // purger ; ce booléen dit si le balayage automatique TOURNE (`config.retention.balayage`).
600
+ // Sans lui, une instance armée est indiscernable d'une instance éteinte — et une purge qui
601
+ // supprime se compose mal avec l'ignorance de savoir si l'on est concerné (signalé par le
602
+ // second hôte). Défaut false, comme la décision par défaut : rien ne s'efface tout seul.
603
+ retentionSweep: !!(PLAYER.config && PLAYER.config.retention && PLAYER.config.retention.balayage === true),
598
604
  // ⚠️ L'ÉTAT DU SCHÉMA, LÀ OÙ ON REGARDE. Une colonne absente était signalée par un
599
605
  // `console.warn`, une fois par processus : sur une fonction serverless, une ligne perdue
600
606
  // dans une sortie que personne n'ouvre tant que tout a l'air de marcher — et « tout a
@@ -88,7 +88,29 @@ function optionsValidees(opts) {
88
88
  // Une valeur pour `id=in.(…)` : double-guillemets, guillemet et antislash internes échappés —
89
89
  // PostgREST exige le guillemetage dès qu'une valeur porte un caractère réservé (`:` d'une clé de
90
90
  // débit, par exemple). Guillemeter TOUJOURS est correct et évite d'avoir à deviner.
91
- const guill = (v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
91
+ // ⚠️ DOUBLE encodage nécessaire (trouvé par le banc volumétrique, audit 10) : le guillemetage
92
+ // gère les délimiteurs de PostgREST (virgule, parenthèse) ; l'encodage d'URL gère ceux de l'URL
93
+ // (`&`, `#`, `+`, espace…) — un `&` non encodé dans une valeur coupe le filtre `in.(…)` en deux.
94
+ // PostgREST décode le percent-encoding avant de parser, donc `%22…%26…%22` redevient `"…&…"`.
95
+ const guill = (v) => encodeURIComponent('"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"');
96
+
97
+ // ⚠️ LES DEUX SEULES PORTES DE DESTRUCTION DU MODULE — dryRun court-circuité en PREMIÈRE ligne.
98
+ // « Honoré » ne se prouve pas en lisant chaque DELETE (une fenêtre de lecture décide de la réponse
99
+ // — le piège du périmètre), mais en n'ayant QU'UNE porte, gardée ici, et une garde de forme qui
100
+ // exige qu'il n'y en ait qu'une (server/__tests__/retentionUnePorte.test.js). Un troisième chemin
101
+ // d'écriture DEVRA passer par ces portes, ou il rougira le compte. C'est « retirer la seconde
102
+ // source de vérité » appliqué à la suppression : un seul endroit peut détruire.
103
+ async function effacerParIds(table, colId, ids, opts) {
104
+ if (opts.dryRun || !ids || !ids.length) return 0;
105
+ const del = await PLAYER.db.request(`${table}?${colId}=in.(${ids.map(guill).join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
106
+ return Array.isArray(del) ? del.length : 0; // lignes RENDUES, pas ids présélectionnés
107
+ }
108
+ async function retirerFichier(chemin, opts) {
109
+ if (opts.dryRun || !chemin) return null; // null = rien tenté ; true = retiré ; false = échec
110
+ const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
111
+ if (!retirer) return null;
112
+ try { return !!(await retirer("present-attachments", chemin)); } catch { return false; }
113
+ }
92
114
 
93
115
  // ⚠️ PURGE PAR LOTS BORNÉS (P2 huitième audit). On SÉLECTIONNE un lot d'identifiants (borné,
94
116
  // ordonné par la colonne de date), on les supprime par `id=in.(…)`, on recommence — jamais un
@@ -102,7 +124,8 @@ const guill = (v) => '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
102
124
  // au RESTE du plafond (`min(taille, plafond - examinees)`) pour ne jamais le dépasser. Et on
103
125
  // compte les lignes RENDUES par le DELETE (`return=representation&select=id`), pas les ids
104
126
  // présélectionnés : deux exécutions concurrentes n'annoncent pas deux fois la même suppression.
105
- async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LOT, plafond = PLAFOND } = {}) {
127
+ async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LOT, plafond = PLAFOND } = {}, plafondForce) {
128
+ if (plafondForce !== undefined) plafond = plafondForce;
106
129
  let examinees = 0, supprimees = 0, tronque = false, curseur = null;
107
130
  for (;;) {
108
131
  const reste = plafond - examinees;
@@ -113,13 +136,7 @@ async function purgerParLots(table, filtre, colId, { dryRun = false, taille = LO
113
136
  if (!Array.isArray(lot) || !lot.length) break;
114
137
  examinees += lot.length;
115
138
  curseur = lot[lot.length - 1][colId];
116
- if (!dryRun) {
117
- const ids = lot.map((r) => r[colId]).filter((v) => v != null).map(guill);
118
- if (ids.length) {
119
- const del = await PLAYER.db.request(`${table}?${colId}=in.(${ids.join(",")})&select=${colId}`, { method: "DELETE", headers: { Prefer: "return=representation" } });
120
- supprimees += Array.isArray(del) ? del.length : 0;
121
- }
122
- }
139
+ supprimees += await effacerParIds(table, colId, lot.map((r) => r[colId]).filter((v) => v != null), { dryRun });
123
140
  if (lot.length < limite) break; // dernier lot (moins que demandé → plus rien après)
124
141
  }
125
142
  return { examinees, supprimees, tronque };
@@ -142,9 +159,9 @@ const { cheminPieceJointe: cheminSurSlug } = require("./presentations");
142
159
  // Purge des messages d'une présentation morte, par lots bornés qui lisent id+attachment ENSEMBLE :
143
160
  // on retire les fichiers du bucket du lot (si l'hôte sait), puis on supprime le lot. Rend `tronque`
144
161
  // pour que l'appelant décide de garder ou non la présentation. Compte les lignes RENDUES.
145
- async function purgerMessagesPresentation(slug, opts, retirer, base) {
146
- const { dryRun, taille, plafond } = opts;
147
- let supprimees = 0, fichiers = 0, fichiersErreur = 0, examinees = 0, tronque = false, curseur = null;
162
+ async function purgerMessagesPresentation(slug, opts, base, plafond) {
163
+ const { dryRun, taille } = opts;
164
+ let supprimees = 0, fichiers = 0, fichiersErreur = 0, fichiersCandidats = 0, examinees = 0, tronque = false, curseur = null;
148
165
  for (;;) {
149
166
  const reste = plafond - examinees;
150
167
  if (reste <= 0) { tronque = await resteEncore("doc_presentation_messages", `slug=eq.${enc(slug)}`, "id", curseur, dryRun); break; }
@@ -154,25 +171,17 @@ async function purgerMessagesPresentation(slug, opts, retirer, base) {
154
171
  if (!Array.isArray(lot) || !lot.length) break;
155
172
  examinees += lot.length;
156
173
  curseur = lot[lot.length - 1].id;
157
- if (!dryRun) {
158
- if (retirer) {
159
- for (const j of lot) {
160
- const url = j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
161
- const chemin = cheminSurSlug(url, slug, base);
162
- if (!chemin) continue; // hors du dossier du slug → jamais supprimé (barrière 2)
163
- try { if (await retirer("present-attachments", chemin)) fichiers += 1; else fichiersErreur += 1; } // false = échec compté aussi
164
- catch { fichiersErreur += 1; }
165
- }
166
- }
167
- const ids = lot.map((r) => r.id).filter((v) => v != null).map(guill);
168
- if (ids.length) {
169
- const del = await PLAYER.db.request(`doc_presentation_messages?id=in.(${ids.join(",")})&select=id`, { method: "DELETE", headers: { Prefer: "return=representation" } });
170
- supprimees += Array.isArray(del) ? del.length : 0;
171
- }
174
+ for (const j of lot) {
175
+ const url = j.attachment && (typeof j.attachment === "object" ? j.attachment.url : j.attachment);
176
+ const chemin = cheminSurSlug(url, slug, base); // hors du dossier du slug → null → jamais retiré (barrière 2)
177
+ if (chemin) fichiersCandidats += 1; // compté même en dry-run (ce que la vraie purge tenterait)
178
+ const issue = await retirerFichier(chemin, { dryRun });
179
+ if (issue === true) fichiers += 1; else if (issue === false) fichiersErreur += 1; // false = échec compté
172
180
  }
181
+ supprimees += await effacerParIds("doc_presentation_messages", "id", lot.map((r) => r.id).filter((v) => v != null), { dryRun });
173
182
  if (lot.length < limite) break;
174
183
  }
175
- return { supprimees, fichiers, fichiersErreur, examinees, tronque };
184
+ return { supprimees, fichiers, fichiersErreur, fichiersCandidats, examinees, tronque };
176
185
  }
177
186
 
178
187
  async function purgerRetention(now, optsBrutes = {}) {
@@ -203,23 +212,32 @@ async function purgerRetention(now, optsBrutes = {}) {
203
212
  // ses présences — et les fichiers du bucket si storage.remove est fourni (OPTIONNELLE).
204
213
  const bPres = borne(now, f.presentationsMois);
205
214
  const mortes = await PLAYER.db.request(`doc_presentations?active=eq.false&updated_at=lt.${enc(bPres)}&select=slug&order=slug.asc&limit=${PLAFOND_PRESENTATIONS}`);
206
- const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, fichiers: 0, fichiersErreur: 0, tronque: Array.isArray(mortes) && mortes.length >= PLAFOND_PRESENTATIONS };
207
- const retirer = PLAYER.storage && typeof PLAYER.storage.remove === "function" ? PLAYER.storage.remove.bind(PLAYER.storage) : null;
215
+ // ⚠️ BUDGET GLOBAL AUX PRÉSENTATIONS (P2 dixième audit). Le plafond n'était appliqué qu'À CHAQUE
216
+ // présentation : 500 présentations × 5 000 = 2,5 M de messages en une exécution, timeout et
217
+ // contention avec le chat. Deux budgets partagés — messages et présences — décrémentés au fil
218
+ // des présentations ; la boucle s'arrête quand ils sont épuisés (tronque), sans supprimer les
219
+ // parents restants. En dry-run, on parcourt quand même pour REMONTER ce que la vraie purge
220
+ // ferait (examinés), sans jamais détruire.
221
+ const presRapport = { examinees: 0, supprimees: 0, messages: 0, presences: 0, messagesExaminees: 0, presencesExaminees: 0, fichiers: 0, fichiersErreur: 0, fichiersCandidats: 0, tronque: Array.isArray(mortes) && mortes.length >= PLAFOND_PRESENTATIONS };
222
+ let budgetMessages = opts.plafond, budgetPresences = opts.plafond;
208
223
  for (const p of (Array.isArray(mortes) ? mortes : [])) {
209
224
  const slug = p && p.slug; if (!slug) continue;
225
+ if (budgetMessages <= 0 && budgetPresences <= 0) { presRapport.tronque = true; break; } // budgets épuisés
210
226
  presRapport.examinees += 1;
211
- // Messages : lot borné qui lit id ET attachment ENSEMBLE (la lecture des pièces jointes n'est
212
- // plus une requête globale non bornée — P2), retire les fichiers du lot, puis supprime le lot.
213
- const msgs = await purgerMessagesPresentation(slug, opts, retirer, base);
227
+ const msgs = await purgerMessagesPresentation(slug, opts, base, budgetMessages);
214
228
  presRapport.messages += msgs.supprimees;
229
+ presRapport.messagesExaminees += msgs.examinees;
215
230
  presRapport.fichiers += msgs.fichiers;
216
231
  presRapport.fichiersErreur += msgs.fichiersErreur;
217
- const pres = opts.dryRun ? { supprimees: 0, tronque: false } : await purgerParLots("doc_presentation_attendees", `slug=eq.${enc(slug)}`, "attendee_key", opts);
232
+ presRapport.fichiersCandidats += msgs.fichiersCandidats;
233
+ budgetMessages -= opts.dryRun ? msgs.examinees : msgs.supprimees;
234
+ // Présences : interrogées AUSSI en dry-run (pour remonter presencesExaminees), suppression
235
+ // no-op via effacerParIds. Budget global partagé.
236
+ const pres = await purgerParLots("doc_presentation_attendees", `slug=eq.${enc(slug)}`, "attendee_key", opts, Math.max(0, budgetPresences));
218
237
  presRapport.presences += pres.supprimees;
219
- // ⚠️ LA PRÉSENTATION N'EST SUPPRIMÉE QUE SI TOUS SES ENFANTS SONT PARTIS (P2 neuvième audit) :
220
- // sinon 5 000 messages partent, le parent aussi, et 1 000 orphelins restent que le balayage
221
- // suivant ne peut plus rattacher à leur présentation. Tronqués → on garde le parent inactif
222
- // pour le passage suivant.
238
+ presRapport.presencesExaminees += pres.examinees;
239
+ budgetPresences -= opts.dryRun ? pres.examinees : pres.supprimees;
240
+ // La présentation n'est supprimée que si TOUS ses enfants sont partis (9e audit).
223
241
  if (!opts.dryRun && !msgs.tronque && !pres.tronque) {
224
242
  presRapport.supprimees += (await purgerParLots("doc_presentations", `slug=eq.${enc(slug)}`, "slug", opts)).supprimees;
225
243
  }