feat: un dépôt est traité vingt secondes après, sans attendre son destinataire

Un dépôt attendait la prochaine connexion de son destinataire — potentiellement
des heures. Un vrai NextGraph aura un service qui traite les inbox en continu ;
il n'existe pas. On l'émule : après une écriture dans une inbox, une échéance
unique de vingt secondes draine l'inbox DE LA CIBLE.

C'est une usurpation d'identité, possible seulement parce qu'un portefeuille
partagé détient toutes les identités virtuelles. Elle est acceptable parce que
l'application n'apprend rien de faux : elle observe que les dépôts finissent par
converger, ce qui restera vrai avec un vrai service. Ce qui ne doit pas fuir,
c'est le mécanisme.

Trois gardes, tenues par du code et non par des consignes.

Rien n'atteint la surface publiée : les exports sont épinglés par un test, et
publier ceci reviendrait à publier un appel qui traite l'inbox d'autrui — après
quoi il ne resterait rien du modèle de confidentialité.

Le drainage agit avec un détenteur EXPLICITE, jamais l'identité ambiante. Le
propriétaire vient d'un enregistrement de routage (shim:inboxOwner), et cet
identifiant est passé à chaque étape. C'était le vrai danger : readLinks et
myInboxes demandent getCurrentUser() au moment où elles s'exécutent, donc un
drainage lancé pendant la session d'Alice aurait classé les capacités de Bob
chez elle. Le test l'épingle — après le drainage, Alice n'a aucune capacité sur
le document concerné, et les deux Links sont bien chez Bob, durablement.

Et les échecs remontent au journal d'accès au lieu de disparaître. Une boucle
différée qui avale ses erreurs, c'est la famille retirée en 8c8ade7 et e32b6d0.

Coalescence : une seule échéance en attente par cible, et deux drainages d'une
même inbox ne se chevauchent jamais — processInbox écrit ce qu'il applique.

Limite assumée : si la page disparaît avant l'échéance, le dépôt attend la
prochaine connexion. C'est le comportement honnête d'une émulation qui tient la
place d'un service absent.
This commit is contained in:
Sylvain Duchesne
2026-08-16 15:17:49 +02:00
parent 9e7aeef269
commit 07dfe68473
12 changed files with 1082 additions and 60 deletions
@@ -176,19 +176,26 @@ async function syncSession(): Promise<void> {
* Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g.
* the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged.
* Never throws; a failed open just leaves the read to behave as it did before.
*
* `holderKey` names WHOSE possession decides, when it is not the connected identity's —
* the emulated inbox processor opening an inbox repo for its owner during someone else's
* session (`emulated-verifier/inbox-processor.ts`). The public-store fetch is then skipped
* on purpose: it files what it obtains for whoever is CONNECTED, so asking on behalf of
* another holder would put a cap in the wrong ring, and a caller on that path has already
* established what its holder holds.
*/
export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise<void> {
if (!nuri) return;
// A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
// before deciding whether we may touch it, or the answer would be "no" purely for
// want of asking, and a bare reference to a public document would never suffice.
// Memoised and inert once the cap is held (see public-store.ts).
await fetchReadCap(nuri);
if (holderKey === undefined) await fetchReadCap(nuri);
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
// pulls its state. A user that holds no cap for it has no business asking.
// (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.)
if (mustNotAttempt(nuri)) return;
if (mustNotAttempt(nuri, holderKey)) return;
return openRepoUnguarded(nuri);
}