Skip to content

Releases: Juli1artha/discovery-media-player

v0.1.148

Choose a tag to compare

@github-actions github-actions released this 01 Sep 21:36
19d5195

Added

  • ?contract=1&schema=1 now says what is still stored, not only what may be purged. A purge
    block counts the rows that still carry a reader IP or a raw User-Agent, per table, with a
    three-state vide: 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
    null when 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. retentionSweep said "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's 42703 is read that way; any other
    error stays null, and so does a host whose db capability does not return a parsed body.

Changed

  • docs/RETENTION.md no 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 that DROP in supabase/migrations/,
    which every host replays, would hand an irreversible gesture to installations we have never seen;
    on 0.1.145 and 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 — 0026 and 0027 removed 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: the col_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.1470.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

Choose a tag to compare

@github-actions github-actions released this 01 Sep 20:41
12845dc

⚠️ 0.1.146 n'existera jamais, et voici pourquoi — pour que personne n'ait à le deviner en
voyant un trou dans la suite. Le tag v0.1.146 a été posé sur le commit qui PRÉCÈDE la coupe,
lequel déclarait encore 0.1.145 et ne portait aucune section [0.1.146]. verifier refuse un
tag qui ne s'accorde pas avec package.json : rien n'a été publié — ni npm, ni Release, ni
attestation — et le registre est resté sur 0.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.md décrit depuis la 0.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.147 posé, le tag mort cesse d'être le plus haut et image-reconcile redevient 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 which device, os and browser could 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 migration 0027, which erases what was there — same shape and same
    measurement as the IP purge, with the column removals deferred together to a later release.
    On commercial_doc_views the case was starker than anyone had noticed: unlike the sessions
    table it has no device, os or browser, 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 migration 0026 erases 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. An ALTER TABLE … DROP of a column marks the attribute dropped without rewriting the
    rows: on PostgreSQL 16.13 with pageinspect, every address is still physically present after the
    drop, still present after a routine VACUUM — the rows are live, so there is nothing to
    reclaim — and only a VACUUM 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 the UPDATE … 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 writes ip, 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 always NULL and 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 in docs/RETENTION.md rather than simulated, alongside a
    notice written before the change for any host that queried the column directly. The raw ua is
    kept: it is the only source from which device, os and browser can 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 to 0024. (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 read docs/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 — and docs/RETENTION.md opens
    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 than package.json's
    , which
    docs/RELEASING.md already 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.sessions and
    docshare.sessionsByRecipient returned the stored row as-is, which includes ip — the datum
    docs/RETENTION.md calls 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 the select= 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.sql and fails when one is neither served nor withheld with a written reason. The
    raw ua goes for a different reason than the IP: it is redundant. device, os and
    browser are 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.sessions returned 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 the list / list.all split has prevented on
    docshare.list since a host asked for it. A strict door had a wide door beside it, and two calls
    were enough to use the second. sessions now asks the same second question and answers with the
    same scope field. The scope follows the chain of origin, not the last link: createReshare
    sets created_by to the parent's recipient, so filtering on created_by alone 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_slug and the parent's recipient), without which the rule
    is invisible to the caller. Hosts: see docs/HOST-CONTRACT.md — a member you answer no to on
    list.all now 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...

Read more

v0.1.145

Choose a tag to compare

@github-actions github-actions released this 31 Aug 12:05
570f8f5

⚠️ Rien à faire pour un hôte, et c'est le seul message de ce train. Il ne porte que de
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.

⚠️ Ce que ces dix entrées ont en commun, et qui vaut mieux que leur liste. Une seule ajoute une
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-position et
    portes-de-reponse affirment 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 bloc permissions: 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 de ci.yml le 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 de tools/, 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-workflows affirme « aucune écriture à la racine » sur neuf fichiers ;
    liaison-de-crypto affirme « 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_MIT déclare src/bridge.ts hors 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_TOLERES dit « 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 que FICHIERS_MIT est
    une constante du VRAI dépôt. Il accusait chaque éprouvette de ne pas contenir src/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-epinglees vient du disque — c'était une liste écrite, dans la garde où
    ça coûtait le plus cher.
    ci.yml lui passait Dockerfile .zap/Dockerfile en 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é avec node-de-l-image plutô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 que resultat-garde ...

Read more

v0.1.144

Choose a tag to compare

@github-actions github-actions released this 30 Aug 21:13
c5b019f

⚠️ Second train du même jour, et la raison est écrite plutôt que tue. 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é contenant supabase|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. presenceJetons porte ce nom (et non presenceTokens)
    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 dans HOST-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é : manquant publiait 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 fallu connues, 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.sql rouvrirait 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 balaie supabase/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.1430.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

Choose a tag to compare

@github-actions github-actions released this 30 Aug 15:11
200158f

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 0024doc_presentations gagne
    view_rotation.
    ⚠️ view_rotation et non rotation, pour deux raisons. Le nom dit rotation de la vue, par
    opposition au /Rotate que porte le fichier — deux choses que le player compose au lieu de les
    confondre. Et le nom court serait entré en collision avec l'option rotation de 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 : nommer view_rotation chez 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 voie broadcast, 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.js apporte le cache borné, la file de concurrence et le chargement paresseux. Ce qui
    ne se transpose pas est son getDocument suivi d'un destroy : 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 /Rotate que
    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 90 sans 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 un wheel
    portant ctrlKey. 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.
    ⚠️ rotationEffective COMPOSE 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 /Rotate que 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
    ...

Read more

v0.1.142

Choose a tag to compare

@github-actions github-actions released this 27 Aug 20:46
1ea363b

⚠️ La 0.1.141 n'existe pas, et voici pourquoi. Son tag a été poussé sur le commit de 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.action vient 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.md demandait 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
    hash de la ligne soit le nom de base de l'objet. Un hôte a rapporté le 27/08 un troisième
    écrivain utilisant preview-fr-v2 là où le lecteur utilise v2 : 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 schema avait perdu sa cinquième valeur. indetermine vivait
    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-sonde et partiel portent maintenant leur piège dans leur cellule, comme le fait déjà
    presenceDurcissement trois 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 de publication.yml condamne 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 de server/, 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.1400.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

Choose a tag to compare

@github-actions github-actions released this 27 Aug 06:44
578c6f0

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.

⚠️ CE TRAIN SORT SOUS L'EXCEPTION « CORRECTIF DE SÉCURITÉ », ET DEUX FOIS PLUTÔT QU'UNE.
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.

⚠️ ET UNE MIGRATION DÉJÀ PUBLIÉE EST CORRIGÉE SUR PLACE, ce que ce dépôt interdit par écrit.
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.

⚠️ LA FRONTIÈRE HÔTE A BOUGÉ TROIS FOIS, toutes additives. 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-tts accepts
    body.text as 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 a HEAD, and a HEAD cannot 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 retryable 503 instead of admitting
      it. 503 tells the caller to wait a second; 500 would tell it to give up.
    • ⚠️ The three outbound fetch calls 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. appelHote and 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: spoken is composed per caller.
      Two different texts can share one pronunciation — that is what pronFix is for — hence one
      fingerprint, while spoken !== text holds 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 a sessionId, that session must belong to the requested slug, and the text
      must match something the assistant said in that session. Refused with session (absent, or
      opened on another document) or texte (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 unrecognised role is 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 answers 503 indisponible and 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 required 429 "before any call" and got 500. 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-tts read the text from text or content. An integrating host reported — before hitting
    it
    — that its messages use body and nothing else, with a correct role: "bot". The reader
    would have returned an empty string for every message, yielding an empty set, so every request
    refused with 400 texte: refuse-by-default doing exactly what it must, against a perfectly correct
    integration. The list is now text, content, body, first non-empty wins — a present but
    empty field no longer masks the next one.

    • The host offered to project body into text in its own plugin instead. We widened the reader:
      listMessages is 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.
  • doc_tts_objects is 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 the tts-cache bucket 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.md now carries the fingerprint recipe and the idempotent insert. No schema
    change, no grant: RLS is on with no policy, and the service_role key the db capability already
    uses bypasses it.

  • The sweep's report says why fichiersErreur can be high without anything having failed. The
    code claimed the missing alignment .json came from pre-v2 extracts. True, and not the main
    cause: the provider does not always return an alignment. Measured on that host's bucket, 552
    .mp3 for 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_KEY made 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. Your bot plugin must now declare wiresVoice: true; absent — or merely
    truthy rather than exactly true — the four controls are not rendered. Reported on 26 August by
    an integrating host who went looking for the caller of bot-tts and 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.
    • ⚠️ And docs/CONFIGURATION.md claimed 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...
Read more

v0.1.139

Choose a tag to compare

@github-actions github-actions released this 26 Aug 11:53
cea9738

A graceful stop, an image with nothing left to fetch, and a guard that had died in silence.

⚠️ This train leaves out of cadence, and none of the three exceptions applies.
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 changedpackage.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).

⚠️ Two changes are worth reading before you deploy, both about how the container stops and what
it contains:

  • docker stop now 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 stop waits 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
    with docker run --init.

Fixed

  • ⚠️ The .env.example check read prose as data. It lived inline in ci.yml and pulled every
    backtick-quoted uppercase token out of docs/CONFIGURATION.md. The day that page mentioned
    SIGTERM, SIGINT and SIGKILLsignal 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 in tools/env-exemple.mjs, which asks
      env-lues.mjs for 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 announced localhost: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: no spawn, execFile or
    fork in server/, bin/ or context/). 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 that dumb-init is 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.yml only does a checkout — no npm ci — because none of the tools it ran
    had ever needed node_modules. Then exemples-epingles.mjs gained a dependency on semver in
    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_FOUND before 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 announced 0.1.128 while the registry served 0.1.138. A stale alert that looks alive
      is worse than an absent one — it is AGENTS.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.
  • ⚠️ The changelog carried two ### Fixed sections 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.md gave a command that returns 404. Its post-release checklist said
    docker manifest inspect ghcr.io/…:<version>, but image.yml pushes the git tag verbatim, so the
    image is :v0.1.138. The gh release view v<version> two lines above already carried the v
    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 answers 404 for
    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-init is 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 stop would wait ten seconds before killing"
    — was correct, and died when bin/serve.js
    gained a SIGTERM handler: 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 add fetched the package over the network
      with no version: same Dockerfile, same base digest, two different dumb-init three 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 first child_process added to server/, bin/ or context/ — and its message
      says the gesture: put an init back, or document docker run --init, then update the bench. The
      decision is re-asked at the exact moment it becomes true again, instead of sleeping.
    • ⚠️ The CMD stays in exec form, and that now matters. CMD node bin/serve.js would put
      /bin/sh at 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 --init injects one without touching the
      image, and docs/CONFIGURATION.md says so.

Changed

  • claude[bot] joins the CLA exemption list, on the maintainer's explicit decision. It is the
    same agent as claude, under the identity GitHub assigns depending on how the contribution
    arrives: a pull request opened through the API comes out authored by claude[bot], the same one
    opened otherwise comes out as claude. 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] and claudebot still 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 for SIGTERM before. The
    choice was between slow and abrupt, and the third way had never been put. Without a handler,
    Node as PID 1 ignores SIGTERM (the kernel discards a signal on PID 1 only when no ha...
Read more

v0.1.138

Choose a tag to compare

@github-actions github-actions released this 26 Aug 06:25
c285ffa

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.

⚠️ Operators: apply migration 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. nosniff was 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 with handler.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.
  • ⚠️ Three text responses left this server without the rule the repository had already written.
    The 500 at the end of /doc posted a status and a body and nothing else — no
    Content-Type at all, the one body a browser was allowed to guess. The 400 "no document
    requested" posted the type but not nosniff. And both text responses of bin/serve.js rewrote
    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.js calls 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 the catch of
      /doc, and an error can arrive there after sendHtml has begun writing: setHeader then
      throws ERR_HTTP_HEADERS_SENT inside 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
      500 and a missing-parameter 400 are 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 [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, the integer column accepted it, and the overview's funnel then looped from 1
    to that value — measured: FATAL ERROR: JavaScript heap out of memory in 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. logView was 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 0020 adds CHECK constraints on both tables, NOT VALID first, 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 into supabase/init.sql.
  • ⚠️ Nine route failures returned 500 without reporting anything. The body of bot-tts — and
    eight sibling routes across routes-agent, routes-direct and routes-liens — was wrapped in a
    bare catch that returned { ok: false }: no stack, no message, no call to errors.capture.
    That is why the crypto defect 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.js and retention.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 in routes-agent covers 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-tts alone 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%.

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,
    and crypto, 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 outside server/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 set nosniff.
    • ⚠️ Its first version was wrong, and measuring said so. It flagged every string literal and
      accused seven res.end('{"ok":…}') in routes-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 run pattern 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, or text/…) must
      forbid sniffing
      . A JSON body declaring application/json is not that fault.
  • The registry images CI pulls are pinned by digest. postgres:16-alpine (the rea...
Read more

v0.1.137

Choose a tag to compare

@github-actions github-actions released this 25 Aug 14:53
407e07f

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-tts returned 500 on every call, on any runtime whose global crypto has no
    createHash.
    The lot-3 extraction moved the route out of handler.js without bringing
    require("node:crypto") with it, so it leaned on globalThis.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 have plugins.bot set, the bot's voice was entirely
      out of service
      from 0.1.135 until this release, not degraded.

Changed

  • ⚠️ engines.node is 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-strict is false by default, so it prints an EBADENGINE line 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 outside pdfjs-dist's support, and npm install will 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.

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 from package-lock.json alone: no node_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 (jsdom requires `^22.22.2 || ^24.15.0 ||

    =26.0.0); below it vitestdoes 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=1 gains
    runtime: { node, nodeRequired } — what the process is executing on, and the floor the package
    declares. Additive, so the contract number 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 said 24.x while the deployment
      serving production ran nodejs 22. They could not tell from the outside, and they were right
      that nothing let them — no route anywhere rendered process.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", in examples/demo alone — so the moment the floor
    moved it refused the correct value and named only one of three files. It now compares
    intervals against package.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. engines is
    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 inside node_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.1360.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