Releases: Juli1artha/discovery-media-player
Release list
v0.1.148
Added
?contract=1&schema=1now says what is still stored, not only what may be purged. Apurge
block counts the rows that still carry a reader IP or a raw User-Agent, per table, with a
three-statevide:true(nothing of that legacy is left on this instance's live rows — the
condition under which those columns can eventually be dropped),false(rows remain), and
nullwhen a probe did not answer — a failed probe must never read as a zero, because zero
is the answer that authorises a deletion. Why it exists: our tables live in the host's
database, and a host's audit enumerates its own tables — a dependency's schema occupies a zone
nobody's inventory visits. Two integrating hosts found 2361 rows still carrying these columns,
and found them because a third party asked a question about its own database, not because
anything told them.retentionSweepsaid "I can purge"; nothing said what had piled up. Counts
are bounded and read one small column, so the bound reads as at least, never as exactly; and
the cost runs opposite to intuition — cheap while much remains, a full scan once nothing does —
which is stated in the code rather than hidden, the expensive case being the terminal one where
the counter has finished its work. A column an operator has since dropped counts zero, not
null: that is a known state, not a failure, and reading it as unknown would blind the counter
at the exact moment its subject is settled. Only PostgreSQL's42703is read that way; any other
error staysnull, and so does a host whosedbcapability does not return a parsed body.
Changed
docs/RETENTION.mdno longer promises to drop the three purged columns in a later release —
a statement that was misleading in a way worth naming: we cannot know which player version runs
against a host's database, and the host can. Shipping thatDROPinsupabase/migrations/,
which every host replays, would hand an irreversible gesture to installations we have never seen;
on0.1.145and earlier it would reject every session and view write, with an error naming a
column rather than a version. The section now states the condition, hands over the three
statements, and says plainly what they buy: tidiness, not erasure. The erasure already
happened —0026and0027removed the values, and routine autovacuum removed them from the
pages, measured on a real host at four seconds after the second migration, with no lock and
nothing triggered by hand. It also warns what is lost with the column: thecol_description()
comment is what proves the purge was applied, and a count of zero does not, since it cannot
tell "purged" from "never written".
What changed in the package, by zone — 0.1.147 → 0.1.148
Measured on the two tarballs, by the release workflow. Not written by hand.
| Zone | What it is | Added | Removed | Changed |
|---|---|---|---|---|
documents |
what a human reads | 0 | 0 | 2 |
manifest |
package.json — version, exports, dependencies | 0 | 0 | 1 |
server |
the code the host executes | 0 | 0 | 2 |
context |
the injected-context implementations | 0 | 0 | 0 |
browser-types |
the declarations the host's tsc reads for « ./bridge » — breaks a build, never a page | 0 | 0 | 0 |
browser |
what the visitors' page executes | 0 | 0 | 0 |
cli |
the command-line entry point | 0 | 0 | 0 |
types |
declarations for the server and context entry points — breaks a build, never runtime | 0 | 0 | 0 |
database |
the schema and the migrations the host applies itself | 0 | 0 | 0 |
The files themselves
~ docs/HOST-CONTRACT.md
~ docs/RETENTION.md
~ package.json
~ server/handler.js
~ server/retention.js
v0.1.147
⚠️ 0.1.146n'existera jamais, et voici pourquoi — pour que personne n'ait à le deviner en
voyant un trou dans la suite. Le tagv0.1.146a été posé sur le commit qui PRÉCÈDE la coupe,
lequel déclarait encore0.1.145et ne portait aucune section[0.1.146].verifierrefuse un
tag qui ne s'accorde pas avecpackage.json: rien n'a été publié — ni npm, ni Release, ni
attestation — et le registre est resté sur0.1.145. Le préflight avait refusé lui aussi, une
ligne plus haut, en imprimant « Préflight de publication — v0.1.145 » et
« REFUSÉ : 2 contrôle(s) en échec ». C'est très exactement le mode de panne que
docs/RELEASING.mddécrit depuis la0.1.141, et son remède est écrit : un tag ne se reprend
pas — la protection interdit sa suppression —, donc on coupe le numéro suivant. Avec
v0.1.147posé, le tag mort cesse d'être le plus haut etimage-reconcileredevient sain sans
qu'aucune garde soit désarmée. Le numéro sauté est le prix.
Removed
-
The raw User-Agent is erased too, on both tables. We had argued for keeping it — the only
source from whichdevice,osandbrowsercould be recomputed on rows already written — and
the argument that settled it was ours turned around: those three are derived at write time and
are what a reading record carries, so the raw string had no reader, and "we might re-parse it one
day" does not justify thirteen months of a fingerprint kept for nobody. This release stops serving
it, stops writing it, and ships migration0027, which erases what was there — same shape and same
measurement as the IP purge, with the column removals deferred together to a later release.
Oncommercial_doc_viewsthe case was starker than anyone had noticed: unlike the sessions
table it has nodevice,osorbrowser, so it derived nothing from the string, wrote it, and
none of the six queries that touch these two tables has ever read it back. It went unnoticed
because the coverage that existed asked what a session hands out — and this table is never handed
out, so nothing asked what it merely keeps. A column nothing serves is not a column without a
question; it is a column whose question has no guardian, and one now exists.docs/RETENTION.md
also answers, precisely, from what date a purge is complete end to end — including the part that
is uncomfortable: the automatic sweep is opt-in, so on a host that enabled neither it nor a
manual run, the 13-month window describes an intention rather than an event. -
The reader's IP address is erased. This release stops serving it, stops writing it,
and migration0026erases what thirteen months of journal still held in the clear — the half
the code could not reach on its own. The column is emptied, not dropped, and emptying is what
actually erases — which is the reverse of the intuition, so it was measured rather than
assumed. AnALTER TABLE … DROPof a column marks the attribute dropped without rewriting the
rows: on PostgreSQL 16.13 withpageinspect, every address is still physically present after the
drop, still present after a routineVACUUM— the rows are live, so there is nothing to
reclaim — and only aVACUUM FULL, which rewrites the table under an exclusive lock, removes
them. Dropping the column alone would therefore have left every address on disk indefinitely,
invisible to any query and so never checked by anyone again, while the schema swore it was not
there. With theUPDATE … SET ip = NULL, ordinary autovacuum reclaims the old row versions by
itself, with no lock and no operator action. Verified end to end on a populated database: 200 rows
kept, 200 addresses gone after a routine vacuum, the migration replayable with no further effect.
The erasure is complete today; what is deferred is the shape of the schema. The column stays
because a migration here must be safe to apply while the previous version of the player is
running — the rule that makes the deployment order harmless, and a test enforces it: every
published version still writesip, PostgREST rejects a write carrying an unknown column, and dropping it today
would fail every session write of a host that migrates before deploying, with an error naming
a column rather than a version. Removal is a later release, once no supported version writes it;
until then the column is alwaysNULLand carries a comment in the database saying so, which is
also how a host attests that 0026 ran — a migration that only erases data leaves no trace in
information_schema, and a purge is precisely the migration a host is most likely to be asked to
prove. What the migration cannot reach — write-ahead logs, backups, exports — follows the host's
own retention policy and is stated indocs/RETENTION.mdrather than simulated, alongside a
notice written before the change for any host that queried the column directly. The rawuais
kept: it is the only source from whichdevice,osandbrowsercan be recomputed on rows
already written, and dropping it is a separate decision rather than one implied by this one. One
guard was missing and now exists: the list of session columns served or withheld with a reason
was checked in one direction only, so an entry motivating a column that no longer exists could
have sat there indefinitely.
Fixed
-
Four host-facing documents announced two versions that had never been published, in the past
tense — eighteen statements in all, naming the two version numbers immediately after the published
one and describing what they "stopped serving" and "stopped writing". The registry serves
0.1.145, which still serves and still writes the reader IP and the raw User-Agent, and ships
migrations only up to0024. (The numbers are not repeated here: putting an unpublished version
into a host-facing document is the artefact being removed, and the new guard refused this entry
until they came out — correctly.) An integrating host readdocs/RETENTION.md,
believed the change was live, and was then asked to apply migrations that were in no package; it
found the gap by unpacking the version the registry actually serves. Every such claim now names
something that exists — the next release, or a migration number — anddocs/RETENTION.mdopens
the purge section by saying plainly that none of it is published yet and what to check. A new
guard refuses any document naming a version greater thanpackage.json's, which
docs/RELEASING.mdalready holds equal to the tag and to the changelog's top section: between
releases it is the newest version that exists, and during a release the bump lands in the same
commit as the section describing it, so the window closes itself with no exception to write. The
guard caught a perimeter defect in its own first line — a double-star pathspec matches nothing in
git ls-files, so it read only the changelog while the sixteen documents holding the error went
unseen, with a non-zero count keeping its floor happy — and it now requires every declared root to
yield a subject. -
Reading sessions no longer carry the reader's IP address nor the raw User-Agent. Both
docshare.sessionsand
docshare.sessionsByRecipientreturned the stored row as-is, which includesip— the datum
docs/RETENTION.mdcalls the most sensitive in the schema, that nothing in the player reads
back, and that a sales record does not need in order to say someone read four pages in six
minutes. The same product had already decided the other way elsewhere: a presentation attendee's
address is kept as a salted HMAC bound to the slug, never in the clear. Two opposite decisions on
the same datum, and nothing had ever put them side by side. What a session hands out is now an
explicit allow-list rather than the whole row, in both theselect=and the projection, so a
column added later does not leave by default — in that direction an oversight is a leak, in the
other it is an absence the first reader reports. A bench reads the table's columns from
supabase/init.sqland fails when one is neither served nor withheld with a written reason. The
rawuagoes for a different reason than the IP: it is redundant.device,osand
browserare derived from it when the session is written and are served; the full string carries
nothing more that a reader of the record reads, only enough to recognise one device across
sessions. Both columns are still recorded — not serving a column and not keeping it are two
different decisions, and the second one touches thirteen months of journal. -
docshare.sessionsreturned every reading session of a document to any member allowed to call
it, and that table carries the recipient's address and IP — so a member read the prospects of
their colleagues, which is exactly what thelist/list.allsplit has prevented on
docshare.listsince a host asked for it. A strict door had a wide door beside it, and two calls
were enough to use the second.sessionsnow asks the same second question and answers with the
samescopefield. The scope follows the chain of origin, not the last link:createReshare
setscreated_byto the parent's recipient, so filtering oncreated_byalone would have hidden
from a salesperson the readings of their own forwarded links — the ones they caused. Each session
now also carries its filiation (parent_slugand the parent's recipient), without which the rule
is invisible to the caller. Hosts: seedocs/HOST-CONTRACT.md— a member you answer no to on
list.allnow sees only their own chain. -
The twelve regular-expression probes that neither their guard nor any bench could see are closed
out. Ten now die to a case that names them: what a fenced code block hides from a language check,
which of th...
v0.1.145
l'outillage : dix gardes de CI et leurs bancs, pas une ligne du code servi. Aucune migration, aucun
changement de contrat, aucun changement de comportement. Monter est sans effet visible ; ne pas
monter l'est tout autant.
Il est publié parce que main ne doit pas rester loin de ce que le registre sert, pas parce qu'il
apporte quelque chose à qui l'installe.
règle qui manquait. Les neuf autres corrigent la façon de prouver une règle déjà écrite — un
analyseur absent du chemin du rouge, un plancher aveugle aux disparitions, une discrimination qui
tenait au tri alphabétique, cinq gardes qui affirmaient une absence sans pouvoir distinguer « rien
trouvé » de « rien regardé ». Aucune n'était visible depuis la précédente.
Et cinq fois sur ces quatre jours, le remède existait déjà dans ce dépôt — écrit, commenté, et non
appliqué à l'endroit d'à côté. Une garde bien écrite explique son mécanisme, et cette explication
est ce qui rend le fichier crédible : on ne rouvre pas la phrase qui justifie l'outil.
Changed
-
⚠️ Les trois dernières gardes d'absence portent un témoin — et la mesure a imposé un mécanisme
différent de celui des deux précédentes.secrets-en-clair,renvois-par-positionet
portes-de-reponseaffirment chacune une absence sur un large périmètre. Aveuglées, mesuré : les
trois imprimaient leur résumé complet et sortaient 0.⚠️ La recette des deux gardes précédentes ne marche pas ici, et c'est la garde qui l'a dit. Un
témoin dérivé — « au moins un corps écrit sur place » — refuse sur un dépôt sain : zéro
corps reconnu pour onze.end(bruts, parce que tout passe par le module des portes. Il aurait
exigé la chose même que la garde décourage.Un témoin dérivé n'est possible que si la forme correcte est une chose que le dépôt est censé
CONTENIR. Un blocpermissions:et un appel au module crypto le sont ; un secret, un renvoi par
numéro de ligne et un corps sans type ne le sont pas. Pour ceux-là il faut fabriquer le cas :
poser un exemplaire fautif, vérifier que la sonde le VOIT, le jeter.⚠️ Ce mécanisme n'était pas neuf ici. L'étape RLS deci.ymlle pratique depuis des semaines
sur les politiques Postgres — « on pose une politique dont on sait qu'elle existe, on vérifie que
la sonde la VOIT, et on l'enlève. Sans ce détour, le zéro qui suit ne prouverait rien. » Il
n'avait jamais été porté jusqu'aux outils. Cinquième fois que le remède existe ici, inutilisé.⚠️ Et le témoin des secrets est assemblé à l'exécution, jamais écrit. Cette garde balaie 104
fichiers detools/, le sien compris : un faux identifiant en clair y serait signalé par elle-même,
on l'exempterait, et l'exemption deviendrait le trou que son propre en-tête décrit. Un banc vérifie
qu'aucun fichier suivi ne porte ce littéral.Pour
portes-de-reponse, le témoin distingue voir de juger : une sonde qui reconnaît la
forme mais ne la juge plus fautive laisse passer exactement ce que la garde attrape, et un témoin
qui ne vérifierait que « vu » ne le dirait pas. -
⚠️ Deux gardes qui affirment une ABSENCE portent désormais le témoin de leur RÈGLE, pas seulement
celui de leur périmètre. L'idée vient de la session STUDIO, qui a trouvé la même chose chez elle
sur 97 fichiers : notre témoin d'exception prouve qu'une exception a encore un sujet, celui-ci
prouve que la règle en a encore un. Deux moitiés de la même précaution.permissions-workflowsaffirme « aucune écriture à la racine » sur neuf fichiers ;
liaison-de-cryptoaffirme « aucun appel sur le global » sur trente et un. Leur panne la plus
probable — une sonde qui ne reconnaît plus la forme — produit elle aussi une absence. Le plancher
qui existait compte les FICHIERS LUS, jamais la FORME RECONNUE, et ne peut donc pas les distinguer.Mesuré en aveuglant chaque sonde :
avant : permissions : 9 workflows, aucune écriture à la racine code 0 après : GARDE NON CONCLUANTE — aucun bloc « permissions: » reconnu… code 2⚠️ Et la nuance, qui change la sévérité et qu'il serait malhonnête de taire : leurs bancs, eux,
attrapaient déjà la sonde aveugle. Ce n'était donc pas une garde morte — la RÈGLE était protégée.
C'est le verdict imprimé qui ne l'était pas, et c'est lui qui va dans le journal de la forge et
sous les yeux de quiconque lance l'outil à la main. La ligne comptait le périmètre et se lisait
comme une mesure.Les deux résumés disent maintenant ce qu'ils ont reconnu : « 9 bloc(s) lu(s) dans 9
workflow(s) », « 5 fichier(s) appellent le module parmi 31 ». Plancher à un dans les deux cas —
le compte du jour serait collé au relevé du jour.Pour
liaison-de-crypto, ce témoin est distinct de celui qui existait déjà :
methodesDuModuleSeul()refuse quand plus aucune méthode ne sépare le module du global, donc il
prouve que la question a encore un sens sur ce Node ; le neuf prouve que la sonde sait
encore lire la réponse. Deux cécités différentes, deux refus différents. -
⚠️ Deux bancs choisissaient leur cible par l'ORDRE DE TRI d'un dossier — la dette la plus vieille
de la série, soldée par construction plutôt que par mesure. Elle était signalée depuis trois
messages sans jamais avoir été cherchée.Le balayage n'a trouvé aucune nouvelle instance du défaut, et c'est la mesure qui le dit :
chaque candidat a été essayé, un par un. Dépouiller n'importe laquelle des deux images node
rougit ; abaisser n'importe laquelle des dix déclarations littérales rougit. Le vert n'était
pas un accident.Mais « mesuré aujourd'hui » et « ne peut pas dépendre du tri » ne sont pas la même affirmation :
la première a une date, la seconde n'en a pas. Les deux bancs bouclent donc désormais sur tous
les candidats. Personne ne relit un banc quand il ajoute un fichier.⚠️ Et le balayage a trouvé autre chose : un banc portait DEUX propriétés dans un seul test, ce
qui les affaiblissait toutes les deux. « Une étape dépouillée est refusée » vaut pour n'importe
laquelle — il ne faut donc pas en choisir une. « La mutation discrimine PAR ÉTAPE de PAR FICHIER »
n'a de sens que sur une cible dont le fichier garde d'autres déclarations — il faut donc en
choisir une, et délibérément. Fondues, la première héritait d'un choix dont elle n'avait pas
besoin, et la seconde d'un choix qu'elle ne faisait pas.Mesuré : trois de nos dix déclarations littérales vivent seules dans leur fichier, et sur
celles-là la discrimination ne tient pas. Le tri décidait donc si le banc prouvait sa seconde
propriété. Les deux sont maintenant séparées, et la seconde choisit sa cible pour ce qu'elle est. -
⚠️ Chaque exception écrite doit prouver qu'elle a encore un sujet — une entrée morte est une
porte ouverte d'avance. La distinction vient de la session STUDIO : une liste de ce qu'il faut
REGARDER cesse de couvrir dès qu'un fichier apparaît ; une liste de ce qui est PERMIS fait rougir
tout fichier qui n'y est pas. Nos deux listes de ce qui est permis étaient donc de la bonne forme
— mais tenues dans un seul sens.FICHIERS_MITdéclaresrc/bridge.tshors de l'AGPL. Le banc affirmait que le nom est dans
l'ensemble, jamais que le fichier existe. Le jour où il est renommé, l'entrée survit — et un
futur fichier à ce chemin exact serait relicencié MIT sans décision, alors que l'en-tête de la
liste dit qu'ajouter un fichier ici « se discute dans une PR, pas dans un correctif de garde ». Le
relicenciement se ferait par omission, sur une frontière de licence, garde verte.INTERNES_TOLERESdit « tout nouveau venu doit être décidé plutôt que découvert ». Cela n'était
tenu que contre les arrivants : un symbole qui cesse d'être exporté laissait son entrée
derrière lui, et son retour aurait été toléré au lieu d'être décidé.⚠️ Le remède existait déjà chez nous, inutilisé. La garde qui vérifie qu'aucune autre ne
déclare victoire sur zéro porte exactement ce patron depuis des semaines : chaque exemption a sa
raison et une fonction qui rougit quand le motif disparaît. Mécanisme inventé, commentaire
écrit, et non appliqué aux deux endroits où il manquait.⚠️ Et les éprouvettes ont corrigé la conception. Le contrôle des licences était d'abord dans
l'outil ; deux bancs ont rougi aussitôt, à juste titre —garde(racine)s'applique à une racine
QUELCONQUE et les éprouvettes lui passent des dépôts temporaires, tandis queFICHIERS_MITest
une constante du VRAI dépôt. Il accusait chaque éprouvette de ne pas contenirsrc/bridge.ts. Un
contrôle d'exception appartient là où le SUJET est connu : dans le banc pour les licences, dans
l'outil pour la surface publique, dont le sujet est toujours le module réel. -
⚠️ Le périmètre d'images-epingleesvient du disque — c'était une liste écrite, dans la garde où
ça coûtait le plus cher.ci.ymllui passaitDockerfile .zap/Dockerfileen dur. Cette garde
est la règle qui empêche une image de changer sous nos pieds : le jour où quelqu'un ajoutait un
troisième Dockerfile, elle aurait rendu « toutes épinglées » en n'ayant regardé que deux fichiers
sur trois. Son refus « zéro image » ne l'aurait pas dit — il compte ce qu'il a LU, il ne sait pas
ce qu'il n'a pas OUVERT.Le périmètre est désormais
git ls-files, partagé avecnode-de-l-imageplutôt que dupliqué :
deux exemplaires de « quels Dockerfiles existe-t-il ? » divergeraient. Un banc interdit à un
workflow de le remplacer par des arguments — dériver un périmètre ne sert à rien si un appel
l'écrase.⚠️ **Et ce lot a réintroduit, en le déplaçant, le défaut queresultat-garde...
v0.1.144
0.1.143 est partie à
15:10. La règle de cadence de docs/RELEASING.md dit « un train par jour au plus » avec trois
exceptions — sécurité, paquet cassé sur le registre, réparation de la chaîne de publication — et
celui-ci n'en est aucune. C'est une décision du mainteneur, dont le document dit qu'elle lui revient
(« ce qui est dans ce train, et s'il en vaut un »). Elle est consignée ici parce que le contraire a
déjà coûté : le 25/08, la règle annonçait deux exceptions, la pratique en utilisait une troisième, et
un audit externe a demandé laquelle des deux mentait. Un écart énoncé vaut mieux qu'un écart tu.
Changed
⚠️ La carte publie un NOM de migration, plus un chemin — parce qu'une garde de sécurité d'hôte
tirait dessus, et que la doctrine de ce dépôt dit que c'est à l'émetteur de céder. Une garde de
la session STUDIO refuse toute carte d'identité contenantsupabase|secret|key|token: un
balayage de texte volontairement grossier, qui protège une réponse publique contre la fuite
d'une URL de projet, d'une clé ou d'un jeton. Nos valeurs étaient préfixées
supabase/migrations/…— faux positif sans ambiguïté, mais le refus était bien fondé.
⚠️ Le préfixe part, la garde reste.presenceJetonsporte ce nom (et nonpresenceTokens)
parce que cette même garde avait déjà tiré une fois, et le commentaire qui l'accompagne posait
déjà la règle : le bon geste face à son refus est de changer ce qu'on émet, jamais de desserrer
la garde. Nous avions donc la doctrine, et nous l'avons ratée à la première occasion où l'émetteur
c'était nous sur une valeur plutôt que sur un nom de champ. Le répertoire ne se perd pas : il
est dit une fois dansHOST-CONTRACT.md, et le journal de l'exploitant continue d'imprimer le
chemin complet — ce message s'adresse à quelqu'un qui le lit, pas à un balayage.
⚠️ Le cas est instructif par la façon dont il s'est réveillé :manquantpubliait ces chemins
depuis toujours, mais restait[]chez cet hôte — la garde était donc verte depuis des mois dans
une configuration où son sujet ne pouvait pas apparaître. Il a falluconnues, qui les liste
sans condition, pour la faire tirer. Même motif que la carte qui disait « complet » sans couvrir
une migration, sur une garde de sécurité cette fois.
⚠️ Et retirer le préfixe ne règle que la collision du jour, pas la classe — remarque du même
hôte, et elle est juste : le nom de fichier reste une valeur que nous choisissons, donc
0031-refresh-token-rotation.sqlrouvrirait le même refus des mois plus tard, chez tous les hôtes
à la fois. Deux bancs ferment ça, et convertissent une habitude de nommage en règle mesurée :
l'un balaiesupabase/migrations/et refuse un nom fautif au moment où on l'écrit ; l'autre
rend la carte réelle et passe le JSON sérialisé, clés comprises, au motif de l'hôte. Chacun a
ses contrôles positifs : sans eux, ils passeraient aussi bien sur une carte vide ou un motif mort.
What changed in the package, by zone — 0.1.143 → 0.1.144
Measured on the two tarballs, by the release workflow. Not written by hand.
| Zone | What it is | Added | Removed | Changed |
|---|---|---|---|---|
documents |
what a human reads | 0 | 0 | 1 |
manifest |
package.json — version, exports, dependencies | 0 | 0 | 1 |
server |
the code the host executes | 0 | 0 | 1 |
context |
the injected-context implementations | 0 | 0 | 0 |
browser-types |
the declarations the host's tsc reads for « ./bridge » — breaks a build, never a page | 0 | 0 | 0 |
browser |
what the visitors' page executes | 0 | 0 | 0 |
cli |
the command-line entry point | 0 | 0 | 0 |
types |
declarations for the server and context entry points — breaks a build, never runtime | 0 | 0 | 0 |
database |
the schema and the migrations the host applies itself | 0 | 0 | 0 |
The files themselves
~ docs/HOST-CONTRACT.md
~ package.json
~ server/schema.js
v0.1.143
Changed
-
La rotation du présentateur suit désormais son audience en direct. Jusqu'ici elle restait
locale : le présentateur redressait un document couché et l'audience continuait de voir le document
couché pendant qu'il commentait un document droit. Migration 0024 —doc_presentationsgagne
view_rotation.
⚠️ view_rotationet nonrotation, pour deux raisons. Le nom dit rotation de la vue, par
opposition au/Rotateque porte le fichier — deux choses que le player compose au lieu de les
confondre. Et le nom court serait entré en collision avec l'optionrotationde pdf.js, présente
partout dans le code de la visionneuse : la garde qui vérifie qu'une colonne migrée n'est jamais
écrite sans condition aurait alors crié en permanence sur des lignes qui ne touchent pas la base.
Une alerte qui sonne quand tout va bien apprend à cliquer à côté.
⚠️ Sans la migration, rien ne casse — et c'est ce qui a demandé le plus de soin. PostgREST
rejette le PATCH entier sur une colonne inconnue : nommerview_rotationchez un hôte non
migré ne ferait pas perdre la rotation, ça ferait perdre aussi le changement de page, donc le
pilotage en direct tout entier. Le champ n'est donc écrit que derrière la sonde de schéma, et un
banc le prouve avec une doublure qui lève sur une colonne inconnue, comme PostgREST.
⚠️ La rotation voyage avec la page, par le même message. Lui donner son action propre lui aurait
donné son propre rang d'écriture, et deux écritures concurrentes auraient pu s'inverser — l'audience
recevant une rotation postérieure à la page qu'elle précède. Un message, un ordre.
⚠️ Elle est normalisée à la réception, des deux côtés. Sur la voiebroadcast, cette valeur
vient du navigateur du présentateur : un viewport oblique casserait la couche de texte de toute
l'audience, pas seulement de celui qui l'envoie. Le serveur applique une liste blanche ; le
module de décision ramène au quart de tour.⚠️ Et les deux portes ne disaient pas la même
chose —Number("90")vaut 90, donc le serveur acceptait une chaîne que le navigateur rejetait.
Relevé par un banc, pas par une relecture : deux validateurs du même geste qui divergent, c'est
l'un des deux qui ment et on ne sait pas lequel. -
Panneau de vignettes, à gauche du document — bouton dans la barre et entrée de menu, replié par
défaut, tiroir plutôt que colonne sous 860 px. Cliquer une vignette navigue ; la vignette courante
est marquée. Masqué sur un document image ou d'une seule page, où il n'aurait rien à montrer.
⚠️ Le moteur vient du chat, sauf la partie qui compte. Le générateur de vignettes de
gabarit-live.jsapporte le cache borné, la file de concurrence et le chargement paresseux. Ce qui
ne se transpose pas est songetDocumentsuivi d'undestroy: ce panneau montre les pages du
document déjà ouvert. Le reprendre tel quel téléchargerait le fichier une seconde fois et ferait
tourner deux workers sur le même PDF. Un banc compte les requêtes vers le fichier et exige zéro.
⚠️ Le suivi automatique se désarme dès que le lecteur touche le panneau — sinon chaque
changement de page ramène le panneau sur la page en cours et le lecteur se bat contre son outil. On
écoute les gestes, pas l'événement de défilement : un défilement que nous provoquons n'est pas une
intention du lecteur.
⚠️ Deux défauts trouvés en mesurant, tous deux miens. Un rendu de vignette en échec restait
marqué « faite » et n'était jamais retenté — précisément ce que le chemin principal des pages
documente et évite. Et le correctif naïf en introduisait un pire : ré-observer un élément déjà
visible rappelle l'observateur immédiatement, donc une boucle infinie sur un moteur où les
rendus échouent en série. La reprise est bornée à deux tentatives ; mesuré : la file s'arrête à 2
rejets au lieu de tourner sans fin.
⚠️ Et deux de mes sondes annonçaient « borné ✓ » et « libéré ✓ » sur zéro vignette rendue. Un
zéro qui vient de ce qui n'a jamais eu lieu satisfait « au plus 48 » aussi bien qu'un moteur qui
marche. Les quatre bancs portent désormais un plancher qui refuse un panneau vide.
⚠️ Et une course, trouvée par la forge et impossible à voir ailleurs. Ouvrir le panneau
reconstruit le document et reporte de 30 ms la restauration de la page courante — la géométrie
n'est pas stable avant. Ce report survivait à une navigation faite dans l'intervalle et la
défaisait : ouvrir le panneau puis cliquer aussitôt une vignette ramenait le lecteur à sa page
de départ. Invisible dans l'environnement de développement, où les vignettes ne se rendaient pas et
où le banc attendait donc bien au-delà des 30 ms. Toute navigation explicite périme désormais un
report en attente, et un banc ouvre le panneau et clique dans le même instant. -
Rotation du document à 90°, à gauche ou à droite — dans la barre et dans le menu « ⋯ », comme
le zoom, et repliée dans le menu sous 860 px par la même règle. Un quart de tour par clic ; quatre
clics ramènent à l'identique. Rotation du document, pas de la page : une rotation par page est
un autre modèle de données et une autre interface.
⚠️ La rotation du fichier est COMPOSÉE, jamais écrasée.getViewport({rotation})de pdf.js dit
« si omise, elle vaut la rotation de la page » : passer une valeur absolue écrase le/Rotateque
portent très couramment les documents numérisés en paysage. Un « remettre à zéro » naïf ne
redresserait pas un document de travers — il coucherait un document qui était droit. Un banc
ouvre un document portant/Rotate 90sans rien tourner et exige qu'il s'affiche couché.
⚠️ La proportion tournée protège le SUIVI DE LECTURE, pas seulement l'affichage. Elle fixe la
hauteur des gabarits posés avant rendu, qui fixe la longueur du document, qui décide de la page que
l'observateur d'intersection appelle « courante » — et c'est celle-là que le suivi enregistre.
Mesuré : la hauteur totale change bien avec la rotation, et la page courante survit au quart de tour.
⚠️ Les documents image pivotent aussi, par un second chemin. Sans pdf.js il n'y a pas de
viewport : la rotation est une transformation CSS, et un élément transformé occupe toujours sa boîte
d'origine — sans échanger les dimensions du cadre, la page suivante viendrait se poser par-dessus.
⚠️ Et le banc de ce cas passait à vide au premier jet : l'image du harnais est carrée, donc
échanger sa largeur et sa hauteur y est invisible. Le harnais porte désormais une image franchement
rectangulaire, et le banc refuse de tourner sur une image carrée.
Mesuré aussi : tourner pendant que le document est zoomé conserve le zoom, inverse la proportion, et
le pire canvas reste sous le budget de pixels annoncé. -
⚠️ Le pincement au trackpad zoomait l'écran entier — parce que personne n'écoutait. Sur
trackpad, un pincement n'est pas un événement tactile : le navigateur l'envoie comme unwheel
portantctrlKey. Rien dans la visionneuse ne le lisait, donc le navigateur appliquait son défaut.
⚠️ Et le défaut était plus large qu'un confort manquant : sous 860 px la barre replie les
boutons de zoom, donc sur mobile le pincement était le seul zoom qu'un lecteur pouvait tenter —
et il déformait toute l'interface. Le geste est désormais réclamé sur la surface du document
(trackpad, Ctrl+molette, événements de geste de Safari, deux doigts), et le zoom du navigateur
reste disponible sur le reste de l'interface : le retirer partout serait une régression
d'accessibilité pour qui grossit le chrome plutôt que le document.
⚠️ En deux temps, parce qu'un seul ne tient pas. Une reconstruction vide le conteneur, annule
les rendus en vol et recrée toutes les pages : juste pour un clic, ruineux pour un geste continu.
Pendant le pincement, une simple transformation ; la reconstruction arrive une fois, à l'arrêt.
Mesuré dans Chromium : 25 événements de pincement → 1 reconstruction.
⚠️ Le point visé ne fuit plus, et la mesure a corrigé le modèle. Première écriture : 14,1 px
de fuite mesurés dans un vrai navigateur. La cause n'était pas le geste mais l'arithmétique — les
22 px de marge en haut du conteneur arrivent avant la première page et ne s'étirent pas, alors
que les espaces entre pages sont proportionnels au nombre de pages au-dessus du point visé.
Épargner cette tête ramène la fuite à 0,9 px. Les deux valeurs sont figées dans les bancs.
⚠️ Et les boutons héritent de l'ancrage : leur dérive — les pages changent de largeur, le
défilement reste en pixels — existait déjà et disparaît.
⚠️ Défaut trouvé en chemin : le zoom ne faisait rien sur un document image. Le garde
if (pdfDoc)est faux pour une image, alors que le commentaire du rendu d'image annonce que « tout
le chrome — loader, zoom, plein écran… — fonctionne tel quel ». Inaperçu tant que le zoom tenait à
deux boutons ; intercepter le pincement sans le corriger aurait avalé le geste sur ces
documents, donc fait pire qu'avant. -
Socle du chantier « trois gestes » : l'arithmétique du zoom au geste et de la rotation entre dans
src/viewer.ts, sans aucun changement visible. Trois fonctions pures et une extension, couvertes
par vingt-deux bancs de plus — le module sans DOM est le seul endroit où ces calculs sont
éprouvables, le reste vivant dans un littéral de gabarit qu'aucun banc n'exécute.
⚠️ rotationEffectiveCOMPOSE la rotation du fichier avec celle demandée, au lieu de l'écraser.
getViewport({rotation})de pdf.js dit : « si omise, elle vaut la rotation de la page » — donner
une valeur absolue écrase donc le/Rotateque portent très couramment les documents numérisés en
paysage. Un « remettre à zéro » naïf ne redresserait pas un document de travers : il coucherait
...
v0.1.142
main
précédent la fusion du train — un commit où package.json déclare encore 0.1.140 et où cette
section n'existait pas. Le job verifier a refusé (tag v0.1.141 != package.json version) et
rien n'a été publié : ni npm, ni Release, ni attestation. La garde a fait exactement son
travail, sur la sortie même du dépôt.
Le tag ne peut pas être retiré — le ruleset des tags interdit leur suppression, ce que
docs/RELEASING.md annonçait déjà (« tag protection then makes awkward to withdraw »). Plutôt
que de désarmer cette protection pour contourner sa propre garde, ce train sort en 0.1.142 : le
tag mort cesse alors d'être le plus haut, et image-reconcile — qui exige que le plus haut tag ait
une image servie — redevient sain sans qu'on touche à rien. Le numéro sauté est le prix, et il est
écrit ici pour qu'aucun lecteur n'ait à deviner.
Security
⚠️ Dix étiquettes d'erreur que l'appelant choisissait, et rien ne les bornait.
route: String(body.action || "(sans action)"), écrit dix fois à l'identique dans trois fichiers
de routes.body.actionvient du corps de la requête, donc de n'importe qui muni d'un lien : un
mégaoctet de texte, des retours à la ligne, des guillemets, des octets de contrôle. Ça partait
ensuite dans le puits d'erreurs de l'hôte — Sentry, un journal ligne-par-ligne, un fichier —
dont ce paquet ne connaît ni le format ni les échappements.
⚠️ Ce qui est empêché n'est pas « une grosse chaîne », c'est la forgerie de structure : un
saut de ligne ouvre une entrée de journal qui n'a jamais eu lieu, un guillemet ouvre un champ dans
un puits qui concatène du JSON. Seuls les caractères dont une action est faite traversent
désormais, et la troncature se dit (…) plutôt que de se lire comme un nom véritable.
(sans action)et(action illisible)restent deux constats distincts. Aucune action légitime
n'est affectée. Rien à faire côté hôte.
Fixed
⚠️ docs/HOST-CONTRACT.mddemandait aux hôtes plus que ce dont la purge a besoin, et le geste
« évident » pour s'y conformer aurait été destructeur. La page disait « write it with the same
fingerprint the player computes, and nothing else » ; la seule propriété nécessaire est que le
hashde la ligne soit le nom de base de l'objet. Un hôte a rapporté le 27/08 un troisième
écrivain utilisantpreview-fr-v2là où le lecteur utilisev2: parfaitement sain, non conforme
sur le papier, et réaligner sa formule aurait orphelinné définitivement 908 objets — les fichiers
portent l'ANCIENNE empreinte dans leur nom. La page distingue désormais l'exigence (le balayage)
de l'option (partager un clip avec la route du lecteur), et nomme le banc qui tient la propriété.- Le tableau des verdicts de
schemaavait perdu sa cinquième valeur.indeterminevivait
depuis la 0.1.64 (18/08) en ligne de tableau avalée par le paragraphe précédent, faute d'une ligne
vide — donc rendue en prose, au milieu d'un avertissement sur un autre sujet. Un hôte qui parse ce
tableau y trouvait quatre valeurs pour cinq possibles. non-sondeetpartielportent maintenant leur piège dans leur cellule, comme le fait déjà
presenceDurcissementtrois lignes plus haut : la sonde est PARESSEUSE et locale au processus,
donc ces deux verdicts disent ce que ce processus a demandé jusqu'ici, jamais l'état du schéma.
Un hôte a relevé trois valeurs différentes en une journée sur une base inchangée et a failli
signaler une régression inexistante.⚠️ La sentinelle des exemples se réannonçait à chaque tour. L'issue #412 a reçu son corps puis
quatre commentaires identiques au caractère près en dix heures, pour zéro information nouvelle —
le mécanisme de fatigue d'alarme que l'en-tête depublication.ymlcondamne chez les autres.
Elle ne parle désormais que sur changement de FAIT (version servie ou état d'un exemple), via une
empreinte portée dans le corps de l'issue. Un aller-retour sur la même version reste un fait neuf.
⚠️ Et ce marqueur ne peut plus sortir de son commentaire : l'empreinte est bâtie sur des chemins
lus sur le disque, et un>les fermait en avance — auquel cas la forge aurait comparé à un
marqueur différent de celui publié, donc alarme répétée sans fin ou muette pour toujours. Relevé
par CodeQL sur le banc qui vérifiait la forme ; le défaut était sous l'assertion, pas dedans.
Added
ctx.has(name)est documentée, après avoir été mesurée : implémentée dans le contexte
autonome, appelée par aucune ligne deserver/, absente du contrat, et recopiée dans 57 fixtures
sur 45 fichiers. Un hôte l'implémentait correctement sans le savoir, parce que le type la
déclarait. Rien n'est câblé — la page dit ce qu'elle est, pour qu'une couture qui fonctionne
cesse d'être un accident à un renommage près de la suppression.
What changed in the package, by zone — 0.1.140 → 0.1.142
Measured on the two tarballs, by the release workflow. Not written by hand.
| Zone | What it is | Added | Removed | Changed |
|---|---|---|---|---|
documents |
what a human reads | 0 | 0 | 1 |
manifest |
package.json — version, exports, dependencies | 0 | 0 | 1 |
server |
the code the host executes | 0 | 0 | 4 |
context |
the injected-context implementations | 0 | 0 | 0 |
browser-types |
the declarations the host's tsc reads for « ./bridge » — breaks a build, never a page | 0 | 0 | 0 |
browser |
what the visitors' page executes | 0 | 0 | 0 |
cli |
the command-line entry point | 0 | 0 | 0 |
types |
declarations for the server and context entry points — breaks a build, never runtime | 0 | 0 | 0 |
database |
the schema and the migrations the host applies itself | 0 | 0 | 0 |
The files themselves
~ docs/HOST-CONTRACT.md
~ package.json
~ server/reponses.js
~ server/routes-agent.js
~ server/routes-direct.js
~ server/routes-liens.js
v0.1.140
Deux routes qui pouvaient coûter — l'une la disponibilité du serveur, l'autre de l'argent — une
migration qui échouait sur la donnée qu'elle venait réparer, et un assistant qui promettait une voix
que ce paquet ne câble pas.
docs/RELEASING.md autorise un train par jour ; celui-ci part le lendemain de la 0.1.139, donc la
cadence n'est même pas en cause. Ce qui l'est : bin/serve.js s'arrêtait sur un en-tête Host
malformé — une requête anonyme, sans configuration ni compte — et bot-tts acceptait n'importe
quel texte d'un porteur de lien public, à la facture de l'hôte. Les deux partent dès qu'ils sont
verts, ce que cette page appelle par son nom.
La 0020 (sortie en 0.1.135) s'arrêtait à mi-chemin sur toute base portant deux colonnes hors plage
dans une même ligne, laissant les contraintes posées et jamais validées. Un fichier 0024 aurait été
plus orthodoxe et inopérant : une base ancienne rejouant la chaîne s'arrête à la 0020 et ne
l'atteint jamais. Pour un hôte où elle est passée, le fichier corrigé est un no-op strict, vérifié
contre un vrai PostgreSQL 16. zones-du-tarball lèvera son alarme « a migration already applied
elsewhere must be immutable » sur cette livraison : c'est le traitement que ce changement mérite,
et il est attendu.
wiresVoice conditionne l'affichage
des contrôles de voix ; bot-tts exige un sessionId ; doc_tts_objects devient un point
d'écriture pour l'hôte. Un hôte qui ne touche à rien n'est affecté par aucune : la voix n'était
câblée nulle part, bot-tts n'était appelée par personne, et ne rien écrire dans tts-cache reste
sans conséquence. docs/HOST-CONTRACT.md porte les trois.
Signalé par l'audit CODEX du 26/08, puis par deux hôtes intégrateurs dont l'un a trouvé ce que ni la
forge ni l'audit n'avaient vu.
Security
-
The voice route could be used as a public, paid API — and the bill was ours.
bot-ttsaccepts
body.textas given: a valid public slug is enough, no session is required, and nothing ties the
text to an answer the bot actually produced. The per-IP rate limit (400/h) bounds a single
address's cadence; it bounds neither the cost per call, nor the number of concurrent outbound
calls, nor the size of what we accept back. Three bounds now exist, none of which changes the
protocol. Reported by the CODEX audit of 26 August.⚠️ A hundred concurrent requests for the same text produced a hundred syntheses. The cache
check is aHEAD, and aHEADcannot see what has not been written yet — so a burst missed the
cache together and paid for the same clip a hundred times. Requests are now grouped by
fingerprint: one synthesis, one stored object, one bill, all hundred served.⚠️ And the same primitive supplies the ceiling on concurrent paid calls.creerCache's
admission limit refuses the request past the ceiling with a retryable503instead of admitting
it.503tells the caller to wait a second;500would tell it to give up.⚠️ The three outboundfetchcalls had no deadline at all. A provider that answers slowly —
or stops answering — held the request, its socket and its admission slot until the platform
killed the function.appelHoteand the file relay already carry this lesson; this was the
route it had not reached. A real abort (AbortSignal), not a promise race: a race returns
without cancelling, so it frees nothing.⚠️ The response body is bounded before allocation.gen.json()read whatever arrived,
base64 audio and alignment array included; the body is a third party's, so its size was not
ours to assume. It is now read against a ceiling and refused at the first byte past it, without
reaching storage.- What is memoised is what is shareable, and nothing more:
spokenis composed per caller.
Two different texts can share one pronunciation — that is whatpronFixis for — hence one
fingerprint, whilespoken !== textholds for only one of them. Memoising the whole reply would
have handed the second caller the first one's spelling, and the karaoke would have aligned on
the wrong string. ⚠️ AND THE BINDING ITSELF IS NOW CLOSED — this bullet said "still open" for a few hours. A
call must carry asessionId, that session must belong to the requestedslug, and the text
must match something the assistant said in that session. Refused withsession(absent, or
opened on another document) ortexte(never said). No signed ticket was needed: the truth
comes from the database that produced the message, not from a token the client holds — so there
is no new secret to rotate, and a secret you never rotate is the one you forget to rotate.- The comparison is on the spoken form, not the written one, and that is stronger.
pronFix
can map two spellings onto the same pronunciation, and the pronunciation is what makes the cache
fingerprint. So an accepted text is either a real message, or one whose clip is already paid
for. Comparing spellings would refuse legitimate cases and admit billable ones. The idea came
from an integrating host and was better than ours. - It refuses by default. A message's shape comes from the host's plugin, which no contract
described. An unrecognisedroleis not treated as the assistant's, so an unreadable set yields
an empty one and everything is refused. On a route that spends money, "I could not verify"
must read as no. A read that fails answers503 indisponibleand is recorded — an
operator does not look in the same place for a broken read and a rejected text. ⚠️ The order of the guards is itself a property. Placed before the rate limit, the binding
offered a database read per request to a caller with no session at all — unbounded work
triggered under the limiter, which is what the limiter exists to prevent. Found by the existing
ceiling bench, which required429"before any call" and got500. It was already guarding
the property; we were not seeing it.
-
A host's messages carry their text in
body, and the reader would have refused all of them.
bot-ttsread the text fromtextorcontent. An integrating host reported — before hitting
it — that its messages usebodyand nothing else, with a correctrole: "bot". The reader
would have returned an empty string for every message, yielding an empty set, so every request
refused with400 texte: refuse-by-default doing exactly what it must, against a perfectly correct
integration. The list is nowtext,content,body, first non-empty wins — a present but
empty field no longer masks the next one.- The host offered to project
bodyintotextin its own plugin instead. We widened the reader:
listMessagesis the host's, and asking every host to rename columns for an undocumented
preference moves the transformation into all of them, forever, where forgetting it means a total
silent refusal. The field name carries no security — the role filter does, and it is unchanged.
- The host offered to project
-
doc_tts_objectsis documented as a host write point, not an internal table. The sweep removes
an object only when its fingerprint has a row, and only the player's route wrote one — so anything a
host puts in thetts-cachebucket itself was invisible to retention permanently, not just for
what was already there. The same host measured 908 objects it had written, under the player's
exact naming (same digest, same two files, same root) — a parity its own code says was deliberate,
so that one clip serves both surfaces. Nothing but the missing row distinguished them.
docs/HOST-CONTRACT.mdnow carries the fingerprint recipe and the idempotent insert. No schema
change, no grant: RLS is on with no policy, and theservice_rolekey thedbcapability already
uses bypasses it. -
The sweep's report says why
fichiersErreurcan be high without anything having failed. The
code claimed the missing alignment.jsoncame from pre-v2extracts. True, and not the main
cause: the provider does not always return an alignment. Measured on that host's bucket, 552
.mp3for 356.json— 196 audio files with no companion to remove, a live case rather than a
relic. The count stays unmasked; an operator finding two hundred "errors" on a first sweep would
otherwise hunt a failure that does not exist. -
Setting
ELEVENLABS_API_KEYmade voice controls appear that nothing in this package wires.
The key proves the server can synthesise; it says nothing about what happens on click — and this
package wires none of the sixty-four controls in the assistant, which is markup it ships and
behaviour the host ships. Three voice buttons and the audio-consent step were the only ones whose
appearance was driven by a server secret, so the key read as "the feature is on" and a visitor
who clicked got silence. Yourbotplugin must now declarewiresVoice: true; absent — or merely
truthy rather than exactlytrue— the four controls are not rendered. Reported on 26 August by
an integrating host who went looking for the caller ofbot-ttsand found none.⚠️ This file's own bench already carried the rule and looked at the wrong side. It says, word
for word, "a door that leads to silence is a broken promise" — and checked it without the
key, the one case where the door could not exist.⚠️ Anddocs/CONFIGURATION.mdclaimed the opposite, twice: "the browser asks this
instance". A host reading it set a paid API key and concluded voice worked. It has never
been wired, in...
v0.1.139
A graceful stop, an image with nothing left to fetch, and a guard that had died in silence.
docs/RELEASING.md allows one train per day; 0.1.138 shipped this morning.
This is not a security fix, not a broken package on the registry, and not a repair of the release
pipeline — 0.1.138 published all five of its artefacts. It ships today because the maintainer
decided it does, which is what that page says the decision is. Nothing here forced it.
Nothing an operator runs changes shape. Measured on the two tarballs rather than claimed:
64 files → 64, none added, none removed, five changed — package.json, docs/HOST-CONTRACT.md,
server/cache.js, server/handler.js, bin/serve.js. context, types, database and browser
are untouched, and the bundle a visitor's page executes is byte-identical to 0.1.138
(server/browser.generated.js, sha256 ad3af9dc56479147…, 16 809 bytes; shared.generated.js and
dist/bridge.js likewise).
it contains:
docker stopnow drains instead of cutting. In-flight requests get up to
PLAYER_SHUTDOWN_GRACE_MS(default 8 s) to finish. Keep it below your orchestrator's kill
delay —docker stopwaits 10 s by default.- The image no longer carries
dumb-init. Node runs as PID 1 and handles the signal itself. If
you extend the image with something that spawns child processes, add an init back or start it
withdocker run --init.
Fixed
⚠️ The.env.examplecheck read prose as data. It lived inline inci.ymland pulled every
backtick-quoted uppercase token out ofdocs/CONFIGURATION.md. The day that page mentioned
SIGTERM,SIGINTandSIGKILL— signal names, in a sentence — it demanded them in
.env.example. A check that asks you to bend your prose to please it teaches its readers to write
for the machine.⚠️ The obvious remedy was worse, and measuring said so before it was written. "Read only
the### \NAME`` headings" looked clean: the page carries two, while documenting
thirty-nine variables elsewhere in tables and inline mentions. The guard would have gone green
by looking at almost nothing — the too-tight pattern, fourth time this week.- What actually tells a variable from a word is that it exists elsewhere: the code reads it,
or the example file carries it. The rule now lives intools/env-exemple.mjs, which asks
env-lues.mjsfor its AST inventory instead of keeping a second one — and counts and names
what it sets aside, because a guard that hides what it did not look at claims coverage it does
not have. - The set-aside is not an escape hatch: a variable the code reads is kept, even when both files
forget it. Otherwise the exception would swallow the rule.
- The startup line printed the port it was asked for, not the one it got. With
PORT=0— where
the OS picks a free one — it announcedlocalhost:0, an address that leads nowhere, at exactly
the moment you need to know where to knock. Found by the shutdown bench, which could not reach
the server it had just started. Dockerfile:dumb-init's stated justification no longer held. It said Node had no default
signal handler, which was true and is not any more. What remains is zombie reaping — real in
general, empty here: this runtime spawns no subprocess (checked: nospawn,execFileor
forkinserver/,bin/orcontext/). It is kept out of caution rather than demonstrated
need, and the comment now says so instead of asserting a reason that has been fixed elsewhere.
It also records thatdumb-initis the one unpinned input of that image, and why the version
could not be resolved from where this was prepared.⚠️ The hourly publication guard had been dead for nineteen hours, and nobody could have seen
it.publication.ymlonly does acheckout— nonpm ci— because none of the tools it ran
had ever needednode_modules. Thenexemples-epingles.mjsgained a dependency onsemverin
0.1.137, to compare version intervals instead of demanding a literal string. That was the right
change; the workflow did not move with it. From that publication on, the step threw
ERR_MODULE_NOT_FOUNDbefore measuring anything.- It is the worst place to break. The job goes red — on the scheduled runs page, which
nobody opens. Meanwhile the issue that step maintains stayed frozen on its last true state:
it still announced0.1.128while the registry served0.1.138. A stale alert that looks alive
is worse than an absent one — it isAGENTS.md's third storey, an action that resembles a
success. - The breakage was contained: the earlier steps of the same job kept working, which is why
the version-gap alert for 0.1.138 opened and closed itself correctly. Found by reading that
job's log after noticing it was red on three consecutive runs — not by an alert.
- It is the worst place to break. The job goes red — on the scheduled runs page, which
⚠️ The changelog carried two### Fixedsections under one version. Same shape as the doubled
## [Unreleased]closed a day earlier, one level down: two branches each opened their own
subsection, git merged both without a conflict. The guard added for the first case reads only
##titles, so it stayed green — a rule fixed at one level does not protect the level below.
It now refuses a repeated###inside a version too. Found the same way as the first: by a merge,
not by the guard.docs/RELEASING.mdgave a command that returns404. Its post-release checklist said
docker manifest inspect ghcr.io/…:<version>, butimage.ymlpushes the git tag verbatim, so the
image is:v0.1.138. Thegh release view v<version>two lines above already carried thev—
the inconsistency lived four lines apart. Found by following the page during the 0.1.138 release
and getting the 404, which is the only way it could have been found: a registry answers404for
does not exist and for you asked for the wrong name with the same three digits. The page's own
closing rule applies to it — a procedure that cannot be carried out is worse than no procedure.
Removed
⚠️ dumb-initis gone from the container image, and the image now fetches nothing at build
time. Its written justification — "Node is PID 1 and has no default signal handler, so
docker stopwould wait ten seconds before killing" — was correct, and died whenbin/serve.js
gained aSIGTERMhandler: the kernel discards a signal on PID 1 only when no handler exists.
What remained was zombie reaping, a real job that is empty here — this runtime spawns no
subprocess.- It was the one unpinned input of the image.
apk addfetched the package over the network
with no version: same Dockerfile, same base digest, two differentdumb-initthree months
apart. Pinning it by checksum was written, then discarded: four moving parts — a build-time
network dependency, two digests to maintain, an architecture branch, and a hand-rolled
downloader because the alpine image carries no certificate store — for a component whose job is
empty. Removing it deletes the problem instead of checking it, and makes the build reproducible
unconditionally. ⚠️ The assumption is guarded, not left in a comment.bin/__tests__/sansSousProcessus.test.js
refuses the firstchild_processadded toserver/,bin/orcontext/— and its message
says the gesture: put an init back, or documentdocker run --init, then update the bench. The
decision is re-asked at the exact moment it becomes true again, instead of sleeping.⚠️ TheCMDstays in exec form, and that now matters.CMD node bin/serve.jswould put
/bin/shat PID 1, and a shell does not relay signals to its child: the handler would never
run and the graceful shutdown would be worthless. The bench holds that too.- Operators who need reaping are not stuck:
docker run --initinjects one without touching the
image, anddocs/CONFIGURATION.mdsays so.
- It was the one unpinned input of the image.
Changed
claude[bot]joins the CLA exemption list, on the maintainer's explicit decision. It is the
same agent asclaude, under the identity GitHub assigns depending on how the contribution
arrives: a pull request opened through the API comes out authored byclaude[bot], the same one
opened otherwise comes out asclaude. Measured on 25/08 — PR #392 was refused by this check for
that reason alone, on content identical to what had passed the day before.⚠️ This is not a technical fix, and it waited on purpose. Widening a CLA exemption list
decides who contributes without signing — governance, not tooling. On the day the refusal
landed, the tempting move was to loosen the guard to unblock a pull request; that is precisely
what one does not do. The PR was closed and reopened through the normal path, the gap was
reported, and the list moved only once the maintainer decided (26/08) — by the same reasoning as
its two neighbours, which are already there in their[bot]form.- The widening stops at named identities. A bench holds that
claude-fork[bot],
notclaude[bot]andclaudebotstill have to sign: the exemption covers accounts, never a
shape. Without it the list could drift toward anything ending in[bot], which would let
through any third-party app installed on the repository.
Added
⚠️ The standalone server shuts down gracefully — nothing listened forSIGTERMbefore. The
choice was between slow and abrupt, and the third way had never been put. Without a handler,
Node as PID 1 ignoresSIGTERM(the kernel discards a signal on PID 1 only when no ha...
v0.1.138
A security fix, and two doors that did not exist. A visitor holding a valid link could store a
value that made the statistics page exhaust the heap — one row was enough, it persisted, and it
fired when somebody else opened the overview. If you run an instance with tracked links, this is
the release to take.
0020 before or with this upgrade. It adds bounds to
commercial_doc_views and commercial_doc_sessions, repairs any out-of-range history, and only
then validates — in that order, because a validated constraint on an already-poisoned table fails.
supabase/init.sql carries the same constraints for a fresh install, and a catch-up block for a
base created before them.
Measured on the two tarballs rather than claimed: 62 files → 64, exactly two added
(server/reponses.js, supabase/migrations/0020-mesures-bornees.sql), none removed, nine
changed. All of the change is in server, cli, database and the manifest; documents,
context, types and browser are untouched — the bundle a visitor's page executes is
byte-identical across the two releases (server/browser.generated.js, sha256 ad3af9dc56479147…,
16 809 bytes; shared.generated.js and dist/bridge.js likewise).
Fixed
⚠️ Twenty JSON responses declared their type and none forbade sniffing. The JSON reply helper
was defined thirteen times, identically, under four names (jp,jd,j,jv) across four
route files — plus seven bodies written out by hand and five inline replies elsewhere. Found by
measuring the text fix below, not by looking for it.- This is not twenty oversights. It is what a recipe becomes once it is copied: the first
copy was correct, and it is the correction that does not propagate.nosniffwas added
repository-wide in 0.1.7; the API routes were the one place the rule stopped, because no scan
visits them. - There is now one module holding the doors —
server/reponses.js, depending on nothing, so
the route families can require it without closing a cycle withhandler.js. The short local
names stay, as a single line that delegates: the convenience was legitimate, the recipe
inside it was not. The 95 call sites do not move — a fix that rewrites 95 lines to correct
13 reads badly and verifies worse. - The bodies are unchanged, byte for byte — measured, because a body that changes shape would
change the contract hosts depend on.{"ok":false,"error":"unknown-action"}still goes out
exactly as it did.
- This is not twenty oversights. It is what a recipe becomes once it is copied: the first
⚠️ Three text responses left this server without the rule the repository had already written.
The500at the end of/docposted a status and a body and nothing else — no
Content-Typeat all, the one body a browser was allowed to guess. The400"no document
requested" posted the type but notnosniff. And both text responses ofbin/serve.jsrewrote
the recipe by hand, in the one file where the player could not post it itself. Found by the CODEX
5.6 audit, 25/08.- The rule was not new:
refuserEnTexte()exists precisely for this, added a month earlier when
the first ZAP baseline scan (rule 10019) found the relay's refusals bare. A rule reapplied by
hand is reapplied badly — the same lesson as the funnel bounds written in two places out of
three. So there is now one function through which a text body leaves this server, and
bin/serve.jscalls it rather than keeping a second copy, exactly as it already does for
POLITIQUE_PERMISSIONS. ⚠️ It now survives headers that have already gone out. Its first caller is thecatchof
/doc, and an error can arrive there aftersendHtmlhas begun writing:setHeaderthen
throwsERR_HTTP_HEADERS_SENTinside the recovery itself, turning a reported error into an
unhandled rejection. A naive fix would have introduced that. Nothing can be posted at that
point, so it closes the stream and stays quiet — the error has already gone to
errors.capture.⚠️ ZAP could not have seen any of this. The scan visits three served surfaces; an exception
500and a missing-parameter400are on none of them. What a scanner reaches depends on what
it is given to visit — which is exactly why the guard below reads files instead.
- The rule was not new:
⚠️ The[Unreleased]section had been written twice. Two branches each opened their own,
git merged both without a conflict — the file then carried two identical titles, and
sectionDe()(which the release preflight uses to extract a version's notes) stops at the first.
Half the notes would have shipped missing, silently. The two sections are merged here, and
the guard now refuses any repeated section title: this was invisible to it because it only
ever read version numbers and the footer link, never the headings themselves.⚠️ A stored denial of service in the analytics funnel. A visitor holding a valid link could
post{"event":"page","page":2147483647,"maxPage":2147483647}.logView()checked only that the
number was finite, theintegercolumn accepted it, and the overview's funnel then looped from 1
to that value — measured:FATAL ERROR: JavaScript heap out of memoryin eight seconds, on a
single stored row, with the process capped at 512 MB. One row was enough, it persisted, and the
trigger was someone else opening the statistics, later. Found by the CODEX 5.6 audit on
v0.1.137, reproduced here before being believed.- The bounds already existed 275 lines below, added against exactly this class of defect on
the two session paths.logViewwas the third path, missed when the other two were closed.
So the fix is not "bound here too": there is now one function through which a measurement
enters the database, and one through which a page is read back. There is no second place left
to forget. (AGENTS.md, the rule written while fixing this: you do not check the crossing, you
remove it.) - The database is no longer assumed clean. Bounding writes protects future rows; the ones
already stored remain. Every read of a page value is clamped, including the ones that are only
displayed — "this reader reached page 2 147 483 647" is a false number served to a human who
decides. - The funnel is now O(pages + sessions) — histogram plus descending cumulative — instead of
rescanning every session per page. Equivalence with the previous implementation checked on
3 000 random draws and six edge cases. On legitimate values it also matters: 10 000 pages ×
400 sessions was four million comparisons for a result two passes give exactly. - Migration
0020addsCHECKconstraints on both tables,NOT VALIDfirst, then repairs
out-of-range history, then validates — in that order, because a validated constraint on an
already-poisoned table fails, and a migration that fails on the data it came to repair is one
an operator stops running. Mirrored intosupabase/init.sql.
- The bounds already existed 275 lines below, added against exactly this class of defect on
⚠️ Nine route failures returned 500 without reporting anything. The body ofbot-tts— and
eight sibling routes acrossroutes-agent,routes-directandroutes-liens— was wrapped in a
barecatchthat returned{ ok: false }: no stack, no message, no call toerrors.capture.
That is why thecryptodefect fixed in 0.1.137 lived through two releases: a host's
monitoring could not have seen it even correctly wired — the route was silent, not their
instrument. It was found by reading the code, not by watching it.- It was a repeated omission, not a doctrine:
handler.js,presentations.jsandretention.js
have captured for a long time, and exactly one of the ten route catches did. All nine now
report the error and name the request's actual action before returning 500 — each catch
covers a block of actions (the one inroutes-agentcovers eight), so a fixed label would
have lied on eight calls out of nine. - All nine paths are covered by a bench, not just the one that broke. The first version covered
bot-ttsalone and said so; CI refused it on coverage, and was right — nine silent failures
replaced by nine untested reporting paths is the same fault, smaller. Statement coverage goes
from 90.31% to 90.81%.
- It was a repeated omission, not a doctrine:
Added
- A guard that refuses a hand-written text body sent without its type and
nosniff. It reads an
AST, not a pattern: this repository has paid three times for regex guards —uses:,FROM,
andcrypto, where the last one accused the very file it had just had fixed.- A second rule closes the JSON door rather than counting oversights. The thirteen copies all
sent a computed body (res.end(JSON.stringify(obj))), which the first rule does not look at
and should not. What they had in common was declaring the type — each deciding, on its own,
what accompanies that declaration. So outsideserver/reponses.js, no file may declare
application/json. A fourteenth copy is refused. Other types (text/html,text/javascript,
application/pdf) are deliberately outside this rule and the guard's header says so: they have
their own senders, which already setnosniff. ⚠️ Its first version was wrong, and measuring said so. It flagged every string literal and
accused sevenres.end('{"ok":…}')inroutes-liens.js— seven JSON bodies that post their
type, line by line. Seven false accusations out of seven findings, the same failure as the
docker runpattern the day before. Corrected in the guard, not in the correct code: the rule
is the one written by hand three times — a body in text (type absent, ortext/…) must
forbid sniffing. A JSON body declaringapplication/jsonis not that fault.
- A second rule closes the JSON door rather than counting oversights. The thirteen copies all
- The registry images CI pulls are pinned by digest.
postgres:16-alpine(the rea...
v0.1.137
One host-visible fix, one tightened declaration, one new field on the identity card. Measured on
the two tarballs rather than claimed: 0 files added, 0 removed, 35 changed — and 30 of those
35 differ only by their two SPDX licence lines. The five that carry real changes are
server/routes-agent.js, server/handler.js, docs/HOST-CONTRACT.md, package.json, and
server/browser.generated.js — the last of which changed only in its embedded source digest: the
browser bundle a visitor actually executes is byte-identical across the two releases
(sha256 c399acaed0caf66e…, 15 863 bytes).
Fixed
⚠️ bot-ttsreturned 500 on every call, on any runtime whose globalcryptohas no
createHash. The lot-3 extraction moved the route out ofhandler.jswithout bringing
require("node:crypto")with it, so it leaned onglobalThis.crypto— whose shape varies by
runtime. Where only WebCrypto is exposed, the route threw. The import is
now explicit, and the bench fails without it.⚠️ These notes first said "on the first synthesis". That was wrong, and wrong in the direction
that matters — it suggested cached calls still worked. They did not:keyFor()builds the
cache key, so it runs before the cache is read. The throw always precedes the lookup, and no
cache hit is ever reached. Corrected on 25/08 after an integrating host measured the ordering
in the version they were running. If you haveplugins.botset, the bot's voice was entirely
out of service from 0.1.135 until this release, not degraded.
Changed
⚠️ engines.nodeis now>=22.13.0, up from>=22. This is a correction, not a new
requirement:pdfjs-dist@6.2.108— the player's only production dependency, the one that renders
documents — has always declared>=22.13.0 || >=24. Between Node 22.0 and 22.12 the package
said it was supported and ran its rendering engine on a version that engine calls unsupported.
npm never stopped it:engine-strictisfalseby default, so it prints anEBADENGINEline in
the noise of an install and installs anyway.- If you self-host on Node 22.0–22.12, nothing about the player changed — but you were
already outsidepdfjs-dist's support, andnpm installwill now say so. Move to 22.13 or
later. - The number is derived from the lockfile, not chosen.
node tools/plancher-de-node.mjs
recomputes it.
- If you self-host on Node 22.0–22.12, nothing about the player changed — but you were
Added
- A guard confronting the declared Node floor with the real one. CI could not have caught the
above by simply running:node-version: "22"resolves to the latest 22.x, so the runner always
lands above the floor, whatever it is. A rule the verifying environment satisfies by construction
is assumed, not verified — so the guard reads the version ranges instead of testing them by its
own presence. It works frompackage-lock.jsonalone: nonode_modules, no network, and it
measures what will actually be installed rather than what happens to sit in a folder. - The development floor is now written where a contributor reads it, and kept honest. It is
higher than the package's and unrelated to it (jsdomrequires `^22.22.2 || ^24.15.0 ||=26.0.0
); below itvitestdoes not start, and what it prints instead is aStartup Errorabout an npm bug advising you to deletepackage-lock.json— advice that edits a tracked file for a problem that is a Node version. Measured on a host running 20.18.1.CONTRIBUTING.md`
carries the number and the guard refuses if it drifts from the lockfile. - The identity card now reports the runtime.
GET /api/doc?contract=1gains
runtime: { node, nodeRequired }— what the process is executing on, and the floor the package
declares. Additive, so thecontractnumber does not move (rule 2).⚠️ A configured runtime is an intention, and reading it back does not tell you what ran.
Measured on 25/08 at an integrating host: the project setting said24.xwhile the deployment
serving production rannodejs 22. They could not tell from the outside, and they were right
that nothing let them — no route anywhere renderedprocess.version, ours included.- Two numbers, no verdict. The card does not say "supported": that would put a semver range
evaluator in the server, and this repository has twice paid for parsing a structured format by
hand. Compare them with your own semver. - The patch level is given, not just major.minor — the floor is patch-level (
>=22.13.0), so a
truncated version would not answer the one question the field exists for.
- The three example wirings declare the real floor, and the rule that checks them derives it.
It used to demand the literal string">=22", inexamples/demoalone — so the moment the floor
moved it refused the correct value and named only one of three files. It now compares
intervals againstpackage.json#engines: stricter than us is fine, more permissive is not.
An example is copied verbatim into an integrator's project; the floor it announces has to be the
package's, not the one true on the day the rule was written. - The production floor is written in the document a host actually receives.
enginesis
machine-readable and npm only warns below it; the only human-readable statement was a
shields.io badge in the README — a remote image, invisible offline and insidenode_modules,
which is exactly where a self-hoster reads.docs/HOST-CONTRACT.md— the page hosts pin — did
not contain the word "node". It now carries the floor, and the same guard refuses if that number
drifts from the lockfile.
What changed in the package, by zone — 0.1.136 → 0.1.137
Measured on the two tarballs, by the release workflow. Not written by hand.
| Zone | What it is | Added | Removed | Changed |
|---|---|---|---|---|
documents |
what a human reads | 0 | 0 | 1 |
manifest |
package.json — version, exports, dependencies | 0 | 0 | 1 |
server |
the code the host executes | 0 | 0 | 26 |
context |
the injected-context implementations | 0 | 0 | 2 |
browser-types |
the declarations the host's tsc reads for « ./bridge » — breaks a build, never a page | 0 | 0 | 0 |
browser |
what the visitors' page executes | 0 | 0 | 1 |
cli |
the command-line entry point | 0 | 0 | 1 |
types |
declarations for the server and context entry points — breaks a build, never runtime | 0 | 0 | 3 |
database |
the schema and the migrations the host applies itself | 0 | 0 | 0 |
The files themselves
~ docs/HOST-CONTRACT.md
~ package.json
~ server/appelant.js
~ server/brands.js
~ server/browser.generated.js
~ server/cache.js
~ server/erreurs-base.js
~ server/gabarit-agent.js
~ server/gabarit-carte.js
~ server/gabarit-legal.js
~ server/gabarit-live.js
~ server/handler.js
~ server/page-audience.js
~ server/page-mur.js
~ server/page-visionneuse.js
~ server/presentations.js
~ server/publier.js
~ server/retention.js
~ server/routes-agent.js
~ server/routes-direct.js
~ server/routes-liens.js
~ server/routes-visiteur.js
~ server/schema.js
~ server/session-cles.js
~ server/shared.generated.js
~ server/shares.js
~ server/texte.js
~ server/tiers.js
~ context/standalone.js
~ context/storage.js
~ dist/bridge.js
~ bin/serve.js
~ types/context.d.ts
~ types/index.d.ts
~ types/standalone.d.ts