fix!: un scope nomme refuse se rouvre quand la cle arrive, et la barriere cesse d-etre serie

Revue adversariale : la barriere livree au tour precedent rendait le refus
TERMINAL. Le signal du trousseau s'ecrit `if (query !== null) publish(...)`, parce
qu'avant la barriere il n'y avait qu'une seule raison de n'avoir pas de requete -
rien n'avait encore ete tente - et qu'une cle qui arrive ne pouvait changer que ce
qu'une requete OUVERTE avait le droit de montrer. La barriere en a ajoute une
seconde : elle abandonne AVANT que la requete existe, donc le seul canal qui fait
converger une cle tardive passait a cote de l'etat qui en avait le plus besoin.
Une page qui s'abonne pendant que son trousseau se restaure, ou un document
partage sous les yeux de son destinataire, publiait `isError` et y restait. Ce
n'est pas une surface plus stricte, c'est une pire : echanger un vide mensonger
contre une erreur bloquee ne repare rien, et cela casse une garantie que ce paquet
publie.

Le signal rouvre donc un scope refuse, et seulement quand le refus est leve
(`namedDocumentsReachable`). Le garde-fou porte autant que la reouverture :
rouvrir a chaque changement de trousseau rejouerait la barriere pour chaque cle
qu'une connexion depose, redemanderait au reseau tout document dont le
telechargement avait ECHOUE - un echec n'est deliberement pas memoise - et
republierait la meme erreur a chaque abonne. La variante indiscriminee casse deux
tests, dont celui de demontage qui preexistait.

La barriere passe en parallele. Elle etait serie, donc N documents qui ne poussent
jamais de `State` coutaient N replis bornes, 8s chacun en production. Les refus
restent lus dans l'ordre du scope apres coup.

Et ce qui a laisse passer les deux : l'e2e ne parcourait pas ce chemin. Les deux
journeys `useShapeQuery` lisent `{graphs: "did:ng:i"}`, donc 43/43 n'a jamais rien
prouve sur la barriere. Quatre controles ajoutes sur un scope NOMME, contre le
vrai broker : la proprietaire lit son document, un porteur de rien recoit
`isError` et jamais un succes vide, un document PUBLIC s'ouvre pour lui depuis la
seule reference via la requete de la barriere, et une cle qui tombe pour un AUTRE
document laisse le scope refuse en place.
This commit is contained in:
Sylvain Duchesne
2026-09-21 15:57:56 +02:00
parent aa38b37f33
commit f36ed5c164
6 changed files with 413 additions and 19 deletions
+93
View File
@@ -441,6 +441,99 @@ async function main(): Promise<void> {
await sdk(frame, "useShapeQueryStop", h);
});
// The NAMED half of the scope axis, which this suite did not walk until
// `1.0.0-dev.9` - both journeys above read `{ graphs: "did:ng:i" }`, so the barrier
// that `useShapeQuery` now crosses for every document a scope NAMES had no live
// coverage at all. Three readings of the same two documents, one per outcome: the
// owner reads hers, somebody holding nothing is REFUSED rather than told the
// document is empty, and the same somebody opens the PUBLIC one from nothing but its
// reference - which is the barrier's own fetch reaching this broker.
await step("useShapeQuery: a NAMED scope reads, refuses, and opens a public document from a bare reference", async () => {
const CLS = "urn:e2e:ws:Event";
const seed = await sdk<any>(frame, "useShapeQueryNamedSeed", CLS);
const stranger = "@wsn-stranger-" + Date.now();
const own = "named-own" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", own, seed.owner, seed.prot, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
own,
{ timeout: 30000 },
);
const ownSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", own);
check(
"the owner naming her own document reads it (the barrier does not break a live named read)",
ownSnap.isSuccess && !ownSnap.isError && ownSnap.titles.includes("named-protected"),
`snap=${JSON.stringify(ownSnap)}`,
);
await sdk(frame, "useShapeQueryStop", own);
const refused = "named-refused" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", refused, stranger, seed.prot, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
refused,
{ timeout: 30000 },
);
const refusedSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", refused);
const refusedWhy = await sdkGet<any>(frame, "useShapeQueryError", refused);
// The whole point of the change: NOT `isSuccess` with `data: []`, which is what
// this same call published before `1.0.0-dev.9` - the capability filter emptied
// the result and the snapshot said the document holds nothing of that shape.
check(
"a named document the holder cannot reach is isError, never an empty success",
refusedSnap.isError && !refusedSnap.isSuccess && refusedSnap.dataLen === 0 &&
typeof refusedWhy === "string" && refusedWhy.includes("does not hold this document"),
`snap=${JSON.stringify(refusedSnap)} why=${String(refusedWhy).slice(0, 80)}`,
);
// Left OPEN on purpose for the last reading below: a refused scope standing while
// a cap for some OTHER document lands is exactly what the keyring signal must not
// act on. (`useShapeQueryStop` would also clear the identity, which is why it is
// not called here - the next subscribe is the SAME holder.)
const publicRef = "named-public" + Date.now();
await sdk<any>(frame, "useShapeQueryNamedSubscribe", publicRef, stranger, seed.pub, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
return s && (s.isSuccess || s.isError);
},
publicRef,
{ timeout: 30000 },
);
const publicSnap = await sdkGet<any>(frame, "useShapeQuerySnapshot", publicRef);
// The acquisition the barrier newly performs on this call: the same identity that
// was just refused the protected document opens the public one holding nothing but
// its reference, because the barrier asks the public store and this broker answers.
check(
"a public document named by a holder of nothing opens, through the barrier's own fetch",
publicSnap.isSuccess && !publicSnap.isError && publicSnap.titles.includes("named-public"),
`snap=${JSON.stringify(publicSnap)}`,
);
// That fetch FILED a cap, which fires `CapRegistry.onChange` - the channel a
// refused scope is re-opened on since `1.0.0-dev.9`, because the barrier aborts
// before any query exists and `publish` has nothing to re-filter. It has to be
// TARGETED: this cap says nothing about the protected document, so the refusal
// stands rather than buying a round-trip per cap for the rest of the session.
// The positive direction - the cap for the refused document itself arriving -
// needs a second actor delivering it, and lives in the applicative suite.
const afterOtherCap = await sdkGet<any>(frame, "useShapeQuerySnapshot", refused);
check(
"a cap landing for ANOTHER document leaves a refused scope refused, not churning",
afterOtherCap.isError && !afterOtherCap.isSuccess && afterOtherCap.dataLen === 0,
`snap=${JSON.stringify(afterOtherCap)}`,
);
await sdk(frame, "useShapeQueryStop", refused);
await sdk(frame, "useShapeQueryStop", publicRef);
});
// ── caps / read-filter (in-memory cap model) ────────────────────────────
console.log("\n── caps / read-filter (in-memory cap model) ──");
await step("read-filter: you read what your keyring holds, nothing else", async () => {