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
+154 -3
View File
@@ -27,7 +27,8 @@ import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { mintCap } from "../src/emulated-verifier/caps";
import { makeOrm, type FakeOrm } from "./orm-fake";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
import type { NgLike, UseShapeLike } from "../src/model/types";
@@ -58,7 +59,12 @@ interface SubRec { nuri: string; cb: (r: unknown) => void }
* The stateful fake `ng` - the shim account, the per-scope index and the document
* creation the page does around the read. It is NOT what answers the read any more.
*/
function makeFake() {
function makeFake(options: { pushState?: boolean } = {}) {
// A broker that never pushes the first `State` is the pathological case the bootstrap
// open's bounded fallback exists for - not the ordinary one, which CONTRACT 3 in
// `e2e/` measures at about a millisecond. It is what makes the barrier's cost visible
// without waiting for it.
const pushState = options.pushState !== false;
const quads: Quad[] = [];
let docCounter = 0;
const subs: SubRec[] = [];
@@ -148,7 +154,7 @@ function makeFake() {
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subs.push({ nuri, cb });
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
setTimeout(() => cb({ V0: { State: {} } }), 0);
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {};
});
@@ -657,6 +663,151 @@ describe("useShapeQuery", () => {
expect(orm.openCount()).toBe(0);
});
// -- the cap that arrives LATE, which is the ordinary case --------------
//
// A page reload, a `settle` still in flight, an inbox delivery landing while the
// person is looking at the screen: in all three the view is subscribed BEFORE the
// keyring holds what it needs. The contract promises that such a view converges by
// itself - "a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and
// publishes the document it now opens" - and `CapRegistry.onChange` is the channel.
// A refusal that cannot be re-tried on that signal is not a stricter answer, it is a
// dead view.
it("(r) a NAMED document whose cap arrives after the subscribe converges, it does not stay isError", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
// Alice writes the note. Bob is the one reading, and he holds nothing yet - the
// state a page has while its connection work is still restoring the keyring.
setCurrentUser("alice");
const note = await createEntityDoc("alice", "protected");
seedEvent(orm, note, `${note}:q:one`, "Shared");
setCurrentUser("bob");
expect(getCaps().isEnforcing()).toBe(true);
const obs = useShapeQuery(EventShape, { graphs: [note] });
const unsub = obs.subscribe(() => {});
await tick();
await tick();
// Honest for now: he cannot read it, and being told so beats an empty success.
expect(obs.getSnapshot().isError).toBe(true);
// ...and now the cap lands, exactly as the applied share does it
// (`branch-registers.addLink` for the connected holder, `inbox-dispatch` for a named
// one): filed into the keyring, which fires `CapRegistry.onChange`.
getCaps().learn(mintCap(note));
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(snap.isSuccess).toBe(true);
expect(titles(snap.data)).toEqual(["Shared"]);
unsub();
});
it("(s) a cap for some OTHER document does not re-open a scope that is still unreachable", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("alice");
const note = await createEntityDoc("alice", "protected");
seedEvent(orm, note, `${note}:q:one`, "Shared");
setCurrentUser("bob");
const obs = useShapeQuery(EventShape, { graphs: [note] });
let notes = 0;
const unsub = obs.subscribe(() => {
notes += 1;
});
await tick();
await tick();
expect(obs.getSnapshot().isError).toBe(true);
const notesAfterRefusal = notes;
// A keyring change that says nothing about THIS scope - every connection files
// several, and an inbox delivery for another document files one more. Re-opening on
// each of them would re-run the barrier, re-ask the network for any document whose
// fetch had FAILED (a failure is deliberately not memoised) and re-publish the same
// error to every subscriber, for as long as the session lasts.
//
// The count of NOTIFICATIONS is what separates the targeted retry from the
// indiscriminate one: re-opening republishes, and republishing wakes every view
// bound to this observable. Asserting on the ORM instead proves nothing here - the
// barrier refuses before the engine is ever asked, either way.
getCaps().learn(mintCap("did:ng:o:something-else"));
await tick();
await tick();
expect(obs.getSnapshot().isError).toBe(true);
expect(notes).toBe(notesAfterRefusal);
unsub();
});
// -- what the barrier COSTS, which is not nothing -------------------------
it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => {
const ng = makeFake({ pushState: false });
const orm = makeOrm();
inject(ng, orm);
setOpenTimeoutForTests(40); // after `inject`, which resets it to the 8s default
setCurrentUser("quinn");
const a = await createEntityDoc("quinn", "protected");
const b = await createEntityDoc("quinn", "protected");
const c = await createEntityDoc("quinn", "protected");
seedEvent(orm, a, `${a}:q:one`, "A");
seedEvent(orm, b, `${b}:q:one`, "B");
seedEvent(orm, c, `${c}:q:one`, "C");
const named: string[] = [a, b, c];
const subscribesTo = (): number =>
ng.doc_subscribe.mock.calls.filter((call) => named.includes(String(call[0]))).length;
expect(subscribesTo()).toBe(0);
const obs = useShapeQuery(EventShape, { graphs: [a, b, c] });
const unsub = obs.subscribe(() => {});
await tick();
// The deterministic form of "the waits overlap": all three opens are in flight
// before any of them has answered. A serial barrier would show exactly one here and
// pay the bounded fallback three times over before publishing anything - which on
// the production timeout is 24 seconds of `isPending` for a scope naming three
// documents that never push.
expect(subscribesTo()).toBe(3);
// ...and it still answers, once the fallbacks have fired.
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(titles(obs.getSnapshot().data)).toEqual(["A", "B", "C"]);
unsub();
});
it("(u) INERT is not untouched: no refusal, but the cold-start open is real and is paid", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("rex");
const early = "did:ng:o:inert-named";
seedEvent(orm, early, `${early}:q:one`, "Early");
expect(getCaps().isEnforcing()).toBe(false);
const obs = useShapeQuery(EventShape, { graphs: [early] });
const unsub = obs.subscribe(() => {});
await tick();
await tick();
// Nothing REFUSES - that half is genuinely inert, since `mayReach` answers true for
// everything before the first cap exists. But the open is not: `ensureRepoOpen` runs
// whatever the registry is enforcing, and issues a real `doc_subscribe` held for the
// session. That is deliberate and it is the cold-start heal - without it a named
// document this session has never synced answers `isSuccess` with `data: []`, the
// very lie the barrier is here to remove - but it is a CHANGE, and a scope naming a
// document now waits on that barrier where it used to open the query at once.
expect(ng.doc_subscribe.mock.calls.some((call) => call[0] === early)).toBe(true);
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(titles(snap.data)).toEqual(["Early"]);
unsub();
});
// -- teardown ------------------------------------------------------------
it("(k) the last unsubscribe closes the shape query", async () => {