fix: à la reconnexion, on retrouve ce qu'on possède — pas seulement ce qu'on a reçu
Une application signalait deux symptômes. Ils sont indépendants, et ils ont une racine commune : se connecter ne rejouait qu'UN des registres durables du portefeuille. connectedUser lisait les AddLink de la branche User — ce qu'on vous a partagé — et rien d'autre. Les capacités des documents que vous avez FAITS vivent sur la branche Store, et un seul chemin les relisait : listMyEntityDocs. Une application qui recharge et va droit à sa note ne tenait donc rien pour elle. Constaté : capFor(note) vaut undefined juste après une connexion résolue, et devient défini dès que listMyEntityDocs passe. Le public survivait en lecture parce que fetchReadCap va rechercher la clé dans le store ; il échouait quand même à l'écriture, qui ne fait pas cette démarche. Deuxième défaut : myInboxes énumérait les inbox sans en remettre la clé au détenteur. Se connecter demandait donc un document qu'on n'avait pas de quoi lire — sur une inbox, que l'application n'a jamais nommée puisque rien ne le lui permet. Troisième défaut, et c'est lui qui rendait tout ça fatal : un drainage refusé faisait échouer toute la connexion. Une inbox n'étant pas consommée par un échec, elle refusait à chaque tentative suivante. D'où le « trois fois sur trois », et d'où un verrouillage plutôt qu'un partage manquant. C'est ma spécification qui l'a créé. En rendant les échecs visibles j'avais écrasé une distinction : ne pas ATTEINDRE la file est une panne, et refuser la session est juste ; ne pas pouvoir APPLIQUER un élément est une donnée, et ça ne doit priver personne de sa session. Le drainage par inbox est désormais isolé — signalé haut et fort, les autres files drainées, la session accordée — tandis qu'énumérer les files et restaurer rejettent toujours. Le parcours e2e ratait les deux : personne ne se reconnecte après avoir ouvert son document aux messages. Il s'arrêtait une reconnexion trop tôt.
This commit is contained in:
@@ -37,7 +37,7 @@ import { sparqlQuery } from "../surface/docs";
|
||||
import { readForHolder, registerUpdate } from "./register-write";
|
||||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { escapeLiteral } from "../surface/sparql";
|
||||
import { hasReadCap, isNuri, toNuri } from "../model/nuri";
|
||||
import { hasReadCap, isNuri, targetOf, toNuri } from "../model/nuri";
|
||||
import { mustNotAttempt } from "./reach";
|
||||
import { fetchReadCap } from "./public-store";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
session,
|
||||
readBindings,
|
||||
bindingValue,
|
||||
lookupAccount,
|
||||
resolveAccount,
|
||||
storeOf,
|
||||
readUserStore,
|
||||
@@ -188,28 +189,97 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void>
|
||||
* The ReadCaps recorded on a store's Store branch — its documents, each with its
|
||||
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
|
||||
* what it owns without recomputing anything.
|
||||
*
|
||||
* **Propagates a failed read**, like {@link readLinks} beside it and for the same reason:
|
||||
* empty and unreadable are the same value here and could not mean more different things —
|
||||
* "I own nothing in this store" against "every document I own is invisible and nothing
|
||||
* said so". {@link restoreOwnCaps} is the caller that cannot survive the confusion, since
|
||||
* its whole contract is that the connection either did the restore or says it did not.
|
||||
* A caller that genuinely prefers to carry on catches it itself, and one of them does.
|
||||
*/
|
||||
export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
|
||||
const s = await session();
|
||||
const out: ReadCap[] = [];
|
||||
try {
|
||||
const res = await sparqlQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
|
||||
undefined,
|
||||
storeDoc,
|
||||
"readStoreCaps",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
const v = bindingValue(row, "c");
|
||||
if (v && hasReadCap(v)) out.push(v);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
|
||||
const res = await sparqlQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
|
||||
undefined,
|
||||
storeDoc,
|
||||
"readStoreCaps",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
const v = bindingValue(row, "c");
|
||||
if (v && hasReadCap(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back in the connected user's hands the key of EVERY document it owns — the three
|
||||
* Store branches read back, which is the emulated replay of `AddRepo { read_cap }`
|
||||
* (`AddRepo::verify` → `Verifier::load_repo_from_read_cap`,
|
||||
* `engine/verifier/src/verifier.rs:2237`).
|
||||
*
|
||||
* ── Why connecting has to do this, and why the store record is not enough ──
|
||||
* A document's cap is written in exactly one durable place — the Store branch of the
|
||||
* store it was created in ({@link holdOwnCap}) — and until 2026-08-16 exactly one path
|
||||
* read it back: `listMyEntityDocs`. So a creator that reloaded the page and went straight
|
||||
* to the document it had just made, without listing that scope first, held nothing for it:
|
||||
* the read was refused, the batch read (`readUnion`) dropped it silently, and the write
|
||||
* was refused. Its own document, denied to its own author, on the first refresh.
|
||||
*
|
||||
* Restoring what a user HOLDS is already what connecting means here — that is what the
|
||||
* User-branch Links step does for caps RECEIVED from others. Its own documents are the
|
||||
* other half of the same act, and the more fundamental one: a received cap is a bonus, a
|
||||
* created document is the user's own content.
|
||||
*
|
||||
* The scope stores are filed first ({@link fileOwnStructure}), because the Store branch
|
||||
* lives INSIDE the store document and reading it is itself a guarded read. Not left to a
|
||||
* side effect of whatever ran before: the order is carried by this composition rather than
|
||||
* by where the call happens to sit.
|
||||
*
|
||||
* `holderKey` is the ring these land in — captured by the caller when it decided whose
|
||||
* connection this is, and handed back here, exactly as `readLinks`' caller does. See
|
||||
* `caps.holderKey`.
|
||||
*
|
||||
* Nothing to restore is not a failure: an identity with no account yet owns no store to
|
||||
* read. A failed READ is one, and it propagates — the caller is `connect.connectedUser`,
|
||||
* whose contract is that it did the work or said it did not.
|
||||
*/
|
||||
export async function restoreOwnCaps(holderKey: string): Promise<void> {
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null) return;
|
||||
// `lookupAccount`, not `resolveAccount`: a read that could not ANSWER must not arrive
|
||||
// here as "this identity has no account", which is the one absence entitled to silence.
|
||||
// Its caller resolved the same account a moment ago, so this is a cache hit.
|
||||
const record = await lookupAccount(holder);
|
||||
if (record === null) return;
|
||||
// The store documents themselves, or the reads below would be refused for want of the
|
||||
// very structure this user owns by BEING a user.
|
||||
fileOwnStructure(holder, record);
|
||||
const caps = getCaps();
|
||||
for (const scope of ["public", "protected", "private"] as const) {
|
||||
const store = storeOf(record, scope);
|
||||
if (!store) continue;
|
||||
// COLD-START heal, and it is the whole point of this function running at CONNECTION
|
||||
// time: on a fresh session the store repo is not yet in the verifier's `self.repos`,
|
||||
// and an anchored read of a repo that is not open returns 0 rows — it does not fail.
|
||||
// Without this the restore would answer "you own nothing" on exactly the session that
|
||||
// needs it, silently, which is the failure-as-absence this whole path exists against.
|
||||
// Same guard `readUserStore` and `readLinks` already apply to the same documents.
|
||||
await ensureRepoOpen(store);
|
||||
for (const cap of await readStoreCaps(store)) {
|
||||
// `learnFor`, never a fresh mint: the cap must be the value the Store branch
|
||||
// carries — see the note in `holdOwnCap` on why a second mint locks the owner out
|
||||
// the day the stand-in value becomes a real key.
|
||||
caps.learnFor(holderKey, cap);
|
||||
// Which store a document sits in is a registry fact, not one recorded beside the
|
||||
// caps, so it is re-applied here — marking only, like `listMyEntityDocs` does.
|
||||
if (scope === "public") caps.markInPublicStore(targetOf(cap));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
|
||||
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
|
||||
@@ -348,6 +418,24 @@ export async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
|
||||
/**
|
||||
* Every inbox this user may READ: its own, plus one per document it opened an
|
||||
* inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from.
|
||||
*
|
||||
* ── Listing an inbox PUTS IT IN YOUR HANDS, both levels alike ─────────────
|
||||
* An `AddInboxCap` record is not a note that an inbox exists — upstream it IS the key:
|
||||
* `AddInboxCapV0 { repo_id, overlay, priv_key }` (`engine/repo/src/types.rs:1973`), which
|
||||
* `update_inbox_cap_v0` applies straight into the repo (`verifier.rs:1920`). So a session
|
||||
* that can enumerate an inbox from that record can read it, by construction.
|
||||
*
|
||||
* Here the record carries the pair and not the key, so the entitlement it expresses is
|
||||
* honoured the same way {@link fileOwnInbox} already honours it for a user's OWN two
|
||||
* inboxes, one line above: the cap is minted from the NURI for the holder the record
|
||||
* belongs to. Same emulation, same stand-in, and at migration both are replaced by the
|
||||
* `priv_key` the record carries.
|
||||
*
|
||||
* Left out until 2026-08-16 for the DOCUMENT inboxes, and the consequence was not a
|
||||
* missing feature: connecting enumerated an inbox it then had no key to read, the guarded
|
||||
* read refused it, and the refusal came back out of `ensureIdentity()` — a rejected
|
||||
* sign-in, on a document the application had never named, repeating at every connection
|
||||
* because the record is durable. Reported live, three sign-ins out of three.
|
||||
*/
|
||||
export async function myInboxes(): Promise<Nuri[]> {
|
||||
const holder = getCurrentUser();
|
||||
@@ -358,7 +446,11 @@ export async function myInboxes(): Promise<Nuri[]> {
|
||||
if ((await resolveAccount(holder)) !== null) {
|
||||
for (const scope of ["public", "protected"] as const) out.push(await userInbox(holder, scope));
|
||||
}
|
||||
for (const { inbox } of await readInboxCapPairs()) out.push(inbox);
|
||||
for (const { inbox } of await readInboxCapPairs()) {
|
||||
// The record entitles this holder to read it — see the note above.
|
||||
fileOwnInbox(holder, inbox);
|
||||
out.push(inbox);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,28 @@
|
||||
*
|
||||
* Two steps, in order, and the order matters:
|
||||
*
|
||||
* 1. **Restore** — read the Links already applied (`storeRegistry.readLinks`, the
|
||||
* emulated `AddLink` records on the User branch of the private store) back into
|
||||
* what this user holds. This is durable state; it costs one read and needs no inbox.
|
||||
* 1. **Restore** — replay the durable registers back into what this user holds. All of
|
||||
* them: the Store branches of its three stores (the emulated `AddRepo { read_cap }` —
|
||||
* the documents it CREATED, `branch-registers.restoreOwnCaps`) and the User branch's
|
||||
* Links (the emulated `AddLink` — the caps it was GIVEN, `readLinks`). Durable state,
|
||||
* a handful of reads, no inbox needed.
|
||||
* 2. **Process** — drain the user's inbox (`inbox.processInbox`), which files any
|
||||
* new Link durably and puts it among what the user holds.
|
||||
*
|
||||
* Restoring first means a reconnecting user can read its shared documents
|
||||
* immediately, without waiting on the inbox round-trip.
|
||||
* Restoring first means a reconnecting user can read its documents immediately, without
|
||||
* waiting on the inbox round-trip.
|
||||
*
|
||||
* ── Restoring means ALL the registers, and that is a scar ─────────────────
|
||||
* Until 2026-08-16 this step read the Links and nothing else, so a fresh page put back the
|
||||
* caps a person had been GIVEN and not the ones for documents they had MADE. The Store
|
||||
* branch was read by exactly one other path — `listMyEntityDocs` — so a creator who
|
||||
* reloaded and went straight back to its own note (a route, a deep link) was refused it:
|
||||
* the read threw, the batch read dropped it silently, and the write was refused. Its own
|
||||
* document, denied to its own author, on the first refresh.
|
||||
*
|
||||
* The lesson generalises past that one register: what connecting owes is *everything the
|
||||
* wallet durably says this user holds*, and any register left out of this step is a
|
||||
* capability that exists on disk and not in the session.
|
||||
*
|
||||
* ── Fire-and-forget, on purpose ───────────────────────────────────────────
|
||||
* `setCurrentUser` is synchronous and every consumer calls it from synchronous
|
||||
@@ -36,9 +50,30 @@
|
||||
|
||||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { lookupAccount } from "../shared-wallet/account-registry";
|
||||
import { accessLogPrefix } from "../shared-wallet/access-log";
|
||||
import { myInboxes, readLinks } from "./branch-registers";
|
||||
import { accessLogPrefix, shortNuri } from "../shared-wallet/access-log";
|
||||
import { myInboxes, readLinks, restoreOwnCaps } from "./branch-registers";
|
||||
import { processInbox } from "../surface/inbox";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* Where the failure of ONE queue goes.
|
||||
*
|
||||
* Not a rejection — see the rule on {@link connectedUser} for why a queue is not the
|
||||
* connection — and emphatically not silence. Same channel, same prefix and the same
|
||||
* ungated `console.error` as {@link startConnect} below and as the deferred drain
|
||||
* (`emulated-verifier/inbox-processor.ts`, whose header argues this exact point): a
|
||||
* diagnostic may be opt-in, a failure may not.
|
||||
*/
|
||||
function reportUndrained(inbox: Nuri, error: unknown): void {
|
||||
console.error(
|
||||
accessLogPrefix() +
|
||||
" connected, but this queue could not be drained — a share deposited in it is not " +
|
||||
"applied yet, and connecting again will try it again: " +
|
||||
shortNuri(inbox) +
|
||||
":",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
/** The in-flight connection work, per user key — so two calls do not race. */
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
@@ -55,8 +90,9 @@ const inFlight = new Map<string, Promise<void>>();
|
||||
* a swallowed error. It swallowed **every** failure until 2026-08-13, offline broker
|
||||
* included, and that is what this shape replaces.
|
||||
*
|
||||
* The rule, one line: **any failure rejects; only "there was nothing to do" resolves
|
||||
* quietly.** Exactly three cases are nothing to do, and none of them is a failure:
|
||||
* The rule, one line: **failing to ESTABLISH the session rejects; failing to apply one of
|
||||
* its queues is reported and does not deny anyone their session.** And "there was nothing
|
||||
* to do" resolves quietly. Exactly three cases are nothing to do, and none is a failure:
|
||||
*
|
||||
* - **no identity connected** — anonymous holds nothing and owns no inbox;
|
||||
* - **the identity has no account yet** — nothing to restore, no queue to drain, and
|
||||
@@ -66,9 +102,26 @@ const inFlight = new Map<string, Promise<void>>();
|
||||
* - **the identity changed under the run** — abandoning is correct, and the next
|
||||
* connection picks the work up (see `stillConnected`).
|
||||
*
|
||||
* A restore that did not happen makes every shared document invisible, and an inbox that
|
||||
* was not drained leaves a share unapplied. Both are the same event for the person using
|
||||
* the application, so both are failures — there is no third category here.
|
||||
* ── Why a queue is NOT the connection, corrected 2026-08-16 ───────────────
|
||||
* This said "there is no third category here" and rejected on an undrained inbox too. The
|
||||
* reasoning was that an unapplied share and a lost restore look the same to a person. What
|
||||
* it missed is what happens NEXT: an inbox is not consumed by failing, so a queue that
|
||||
* cannot be applied is still on the list at the following connection, and the one after
|
||||
* that. One unapplicable item therefore did not delay a share — it **locked the person out
|
||||
* of the application, permanently**, and got worse rather than better with time. Reported
|
||||
* live by a consuming application, three sign-ins out of three.
|
||||
*
|
||||
* That is strictly worse than the silence the 2026-08-13 shape replaced, and it collapsed
|
||||
* a distinction that has to stay open: **failing to REACH the queues** (the session cannot
|
||||
* proceed at all — reject, unchanged) against **failing to apply what is in one** (data,
|
||||
* one queue, one person's share — report it, drain the others, let them in).
|
||||
*
|
||||
* The split needs no error classification, and that is what makes it trustworthy: the
|
||||
* restore step is a plain `await` and still rejects, so a broker that cannot answer fails
|
||||
* the connection there, before any queue is reached. Only the per-inbox drain is caught.
|
||||
*
|
||||
* Not silence: {@link reportUndrained} is loud and ungated, and the caps a share carries
|
||||
* are not lost — the deposit stays in the queue, so the next connection applies it.
|
||||
*
|
||||
* Not fixed with a retry, a timeout or a flag on purpose: deciding *what to do* about a
|
||||
* broker that cannot answer belongs to the caller, and it can only decide if it is told.
|
||||
@@ -113,7 +166,12 @@ export async function connectedUser(): Promise<void> {
|
||||
// the same fault `inbox.share` was fixed for on 2026-08-10.
|
||||
if ((await lookupAccount(holder)) === null) return;
|
||||
if (!stillConnected()) return;
|
||||
// 1. Durable first: what this user has already applied.
|
||||
// 1. Durable first, and ALL of it — see the header. The documents this user MADE, from
|
||||
// the Store branches of its own stores, and then the caps it was GIVEN, from the
|
||||
// User branch. Own documents first: they are the user's own content, and a person
|
||||
// coming back to the note they wrote should not be waiting on anybody's share.
|
||||
await restoreOwnCaps(holderKey);
|
||||
if (!stillConnected()) return;
|
||||
const links = await readLinks();
|
||||
if (!stillConnected()) return;
|
||||
for (const cap of links) getCaps().learnFor(holderKey, cap);
|
||||
@@ -124,14 +182,24 @@ export async function connectedUser(): Promise<void> {
|
||||
// applies to the SAME private store, and interleaving those writes buys
|
||||
// nothing on a queue that is nearly always empty.
|
||||
//
|
||||
// A queue that cannot be read stops the loop, so the inboxes after it are left
|
||||
// undrained — as before. What changed is that stopping is now audible: the caller
|
||||
// is told the connection did not complete instead of being handed a resolved
|
||||
// promise over a half-drained wallet.
|
||||
// A queue that fails is reported and the next one is still drained — see the rule
|
||||
// above for why one unapplicable item must not deny a person their session, nor
|
||||
// take the OTHER queues down with it. Enumerating them is a different act and is
|
||||
// not caught: not knowing which queues exist is the session failing to establish.
|
||||
const inboxes = await myInboxes();
|
||||
for (const inbox of inboxes) {
|
||||
if (!stillConnected()) return;
|
||||
await processInbox(inbox);
|
||||
try {
|
||||
await processInbox(inbox);
|
||||
} catch (error) {
|
||||
// An identity that moved MID-DRAIN throws here too — `processInbox` resolves the
|
||||
// holder at each step, so the new one is refused an inbox that is not theirs. That
|
||||
// is abandoning, which this function has always called "not a failure", and
|
||||
// reporting it would put a broker-looking error in the log every time a page
|
||||
// switches user. The loop is about to return for the same reason.
|
||||
if (!stillConnected()) return;
|
||||
reportUndrained(inbox, error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1291,7 +1291,18 @@ export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]
|
||||
// holder when it runs — so hand the captured key back rather than trust that the
|
||||
// identity has not moved. See `caps.holderKey`.
|
||||
const holderKey = caps.holderKey();
|
||||
for (const cap of await readStoreCaps(store)) caps.learnFor(holderKey, cap);
|
||||
// Tolerant HERE, and only here: `readStoreCaps` propagates (see it, and `readLinks`
|
||||
// beside it), because the connection's restore cannot report success over a read that
|
||||
// never answered. This caller returns the LISTING, which it already has by now, and a
|
||||
// listing without its keys is what it answered before this distinction existed —
|
||||
// unchanged rather than quietly turned into a throw on a published call.
|
||||
let stored: ReadCap[] = [];
|
||||
try {
|
||||
stored = await readStoreCaps(store);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
|
||||
}
|
||||
for (const cap of stored) caps.learnFor(holderKey, cap);
|
||||
// Which store a document sits in is a registry fact, not one recorded beside the
|
||||
// caps, so it is re-applied here. Marking only — the caps just came from the Store
|
||||
// branch above, and minting a second one beside them is the trap `holdOwnCap` warns
|
||||
|
||||
Reference in New Issue
Block a user