diff --git a/.project/concepts/sign-in/_debt.md b/.project/concepts/sign-in/_debt.md new file mode 100644 index 0000000..834742c --- /dev/null +++ b/.project/concepts/sign-in/_debt.md @@ -0,0 +1,7 @@ +# Doc-debt — sign-in + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED packages/polyfill/src/shared-wallet/account-registry.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/packages/polyfill/src/emulated-verifier/branch-registers.ts b/packages/polyfill/src/emulated-verifier/branch-registers.ts index e49f3cb..bd5a7e1 100644 --- a/packages/polyfill/src/emulated-verifier/branch-registers.ts +++ b/packages/polyfill/src/emulated-verifier/branch-registers.ts @@ -150,6 +150,14 @@ export function fileOwnInbox(id: string, inbox: Nuri): void { * `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option`), * so two addresses on one document is a state the model has no meaning for — and a * depositor picking the stale one writes where nobody reads. + * + * **Propagates a failed write.** Publishing is the ONLY way a third party learns where to + * deposit for a document (we publish because an emulation has no message channel — see the + * module header), and `openDocumentInbox` short-circuits on the inbox it already recorded, + * so nothing ever tries again. Swallowing therefore produced an inbox its owner drains + * forever while no one can reach it — and worse when the `DELETE` landed and the `INSERT` + * did not: the address that WAS published is gone, and every future deposit on that + * document is refused as "this document has no inbox". */ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise { const s = await session(); @@ -172,6 +180,7 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise ); } catch (error) { console.error(accessLogPrefix() + " publishInboxAddress failed:", error); + throw error; } } @@ -543,7 +552,15 @@ export async function openDocumentInbox(docLike: NuriLike): Promise { "openDocumentInbox", ); } catch (error) { + // This record is what makes the inbox DRAINABLE: `myInboxes` builds the connection's + // drain list from it, and `readInboxCapsFor` answers "have I already opened one" + // from it. Swallowing here went straight on to PUBLISH the address, so depositors + // were invited to write into a queue its own owner never enumerates — every message + // delivered and none ever applied, permanently. Failing before the address is + // published is the honest state: nobody is told where to deposit, and asking again + // opens a fresh inbox. console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error); + throw error; } } // …and the PUBLIC half, in the document itself, so a depositor can find it at all. diff --git a/packages/polyfill/src/emulated-verifier/public-store.ts b/packages/polyfill/src/emulated-verifier/public-store.ts index 21dfcbc..c5707f7 100644 --- a/packages/polyfill/src/emulated-verifier/public-store.ts +++ b/packages/polyfill/src/emulated-verifier/public-store.ts @@ -122,6 +122,13 @@ export function resetPublicStoreFetches(): void { * * Replacement, not addition, like every Header-branch register: one document has one * current cap, and two would leave a fetcher picking between them. + * + * **Propagates a failed write.** This runs exactly once, at creation, and nothing exposes + * the cap afterwards — so a swallowed failure leaves a document that IS in a public store + * and serves nothing: every third party's {@link fetchReadCap} answers "no cap", the read + * guard refuses, and the document is unreadable by anyone but its creator, for good. Its + * caller `createEntityDoc` already refuses to hand back a reference whose bookkeeping did + * not land, for the same reason and in the same words. */ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise { const s = await session(); @@ -143,6 +150,7 @@ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise { ); } catch (error) { console.error(accessLogPrefix() + " exposeReadCap failed:", error); + throw error; } } diff --git a/packages/polyfill/src/shared-wallet/account-registry.ts b/packages/polyfill/src/shared-wallet/account-registry.ts index 1dddea1..6fda803 100644 --- a/packages/polyfill/src/shared-wallet/account-registry.ts +++ b/packages/polyfill/src/shared-wallet/account-registry.ts @@ -459,15 +459,27 @@ async function resolvePointer(): Promise { const maxStepMs = budget.maxStepMs ?? 2000; let step = baseMs; + // Did ANY attempt come back with an answer — even an empty one? The guard retries a + // read that could not be made, and `""` means "there is no pointer yet", which is the + // signal `resolveShimDoc` MINTS a doc-shim on. A budget exhausted without a single + // answer has established nothing at all, so answering `""` there would fabricate a + // second registry root: a new doc-shim, a second pointer beside the one that already + // exists, and every account record split between two documents that only + // `canonicalDoc` reconciles — the ones written into the loser simply disappear. Only a + // read that ANSWERED may report an absence. + let answered = false; + let lastError: unknown = null; for (let i = 0; i < attempts; i++) { try { const result = await physicalQuery(s.sessionId, query, undefined, root, "resolvePointer"); + answered = true; const doc = canonicalDoc(readBindings(result), "shimDoc"); if (doc) { logStage("resolvePointer → 1 target: " + shortNuri(doc)); return doc; } } catch (error) { + lastError = error; console.error(accessLogPrefix() + " resolvePointer failed:", error); } if (i < attempts - 1) { @@ -475,13 +487,21 @@ async function resolvePointer(): Promise { step = Math.min(step * 2, maxStepMs); } } + if (!answered) throw lastError; logStage("resolvePointer → 0 targets"); return ""; } /** Write the pointer (store-root → doc-shim), once, at first login. Idempotent in * practice (only called when no pointer was found); a concurrent double-write is - * reconciled by canonicalDoc on read. */ + * reconciled by canonicalDoc on read. + * + * **Propagates a failed write.** The pointer is the ONLY way back to the doc-shim — it is + * the one NURI a fresh session can name without a lookup. A session that carried on over a + * pointer that never landed would go on writing every account record into a document no + * later session can find, and the next login, finding no pointer, would mint a second + * doc-shim and re-provision every account from scratch. There is nothing to recover + * afterwards, so the failure has to be seen at the moment it happens. */ async function writePointer(doc: Nuri): Promise { const s = await session(); const root = await rootNuri(); @@ -496,6 +516,7 @@ async function writePointer(doc: Nuri): Promise { await physicalUpdate(s.sessionId, update, root, "writePointer"); } catch (error) { console.error(accessLogPrefix() + " writePointer failed:", error); + throw error; } } @@ -640,7 +661,14 @@ export async function resolveAccount(id: string): Promise { const s = await session(); const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`; @@ -661,6 +689,7 @@ async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise await physicalUpdate(s.sessionId, update, doc, "writeRecord"); } catch (error) { console.error(accessLogPrefix() + " writeRecord persist failed:", error); + throw error; } } @@ -705,11 +734,21 @@ export async function ensureAccount(id: string): Promise { // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead // of a full-shim scan (loadShim). Off the read/write hot path entirely. // - // Barrier-AUTHORITATIVE: resolveAccount reads the doc-shim behind its first-`State` + // Barrier-AUTHORITATIVE: the lookup reads the doc-shim behind its first-`State` // barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY // absent — not sync-lag. No account-level retry: the store-root ambiguity that // forced the old provisionRetry loop is gone once the read moves behind the barrier. - const existing = await resolveAccount(id); + // + // `lookupAccount`, NOT the tolerant `resolveAccount`: what follows an absence here is + // a PROVISION. The tolerant form answers `null` for a read that failed exactly as for + // one that found nothing, so a broker that could not answer minted a second set of + // three store documents and a second account record — the very account FORK the + // barrier was introduced to end, walking straight back in through the error path. The + // session then writes its entities into the new set while the old one holds + // everything the user had, and only `canonicalDoc` decides afterwards which of the + // two a later session sees. Absence is a reason to create; ignorance is not + // (`lookupAccount` exists for exactly this distinction, 2026-08-10). + const existing = await lookupAccount(id); if (existing) { fileOwnStructure(id, existing); return existing; @@ -757,6 +796,15 @@ const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`; * * Written through the PHYSICAL door: which NURIs are inboxes is not one virtual user's * business, exactly like the account records beside it. + * + * **Propagates a failed write, and only marks the session's index once the write landed.** + * This record is what the emulation puts in place of a fact the network knows by + * construction, and `inbox.post` refuses to deposit into anything it cannot confirm + * ({@link isKnownInbox}). Swallowing left an inbox that its owner resolves and addresses + * normally while every depositor is turned away — permanently, since nothing ever records + * it a second time. Marking `knownInboxes` regardless made it worse by hiding the damage + * from the only session able to see it: the one that opened the inbox believed the record + * existed, and every other session did not. */ export async function recordInbox(nuri: Nuri): Promise { const s = await session(); @@ -770,6 +818,7 @@ export async function recordInbox(nuri: Nuri): Promise { ); } catch (error) { console.error(accessLogPrefix() + " recordInbox failed:", error); + throw error; } knownInboxes.add(nuri); } @@ -947,6 +996,7 @@ export async function userInbox(id: string, scope: InboxScope): Promise { // One triple per (user, scope): the two inboxes are distinct documents, as the two // store repos that carry them are distinct upstream. const pred = `${P.docInbox}:${scope}`; + let existing: MaybeNuri; try { // The doc-shim is machinery: this reads WHICH inbox a virtual user owns, // which is exactly the kind of question that cannot be confined to that user. @@ -957,18 +1007,29 @@ export async function userInbox(id: string, scope: InboxScope): Promise { shimDoc, "userInbox", ); - const existing = canonicalDoc(readBindings(res), "d"); - if (existing) { - inboxCache.set(key, existing); - fileOwnInbox(id, existing); - return existing; - } + existing = canonicalDoc(readBindings(res), "d"); } catch (error) { + // ONLY A VERIFIED ABSENCE MAY MINT. This catch logged and let execution fall + // through to the mint below, so a read that could not ANSWER was treated as "this + // user owns no inbox" — and a second document was created for one (user, scope). + // Two `docInbox` triples then sit on the same subject, and which one wins + // afterwards is decided by `canonicalDoc` picking among them: the owner can drain + // one while a depositor writes into the other, each of them right, with nothing + // anywhere to see. Same fault as `connect.connectedUser` (2026-08-13), except this + // one writes the mistake down. console.error(accessLogPrefix() + " userInbox read failed:", error); + throw error; + } + if (existing) { + inboxCache.set(key, existing); + fileOwnInbox(id, existing); + return existing; } + // The broker answered, and it answered nothing: this user genuinely owns no inbox for + // this scope yet. THAT is what mints one — and the three writes it takes all have to + // land before anyone is handed the result. const doc = await createDoc(); - fileOwnInbox(id, doc); await recordInbox(doc); try { await physicalUpdate( @@ -978,8 +1039,18 @@ export async function userInbox(id: string, scope: InboxScope): Promise { "userInbox", ); } catch (error) { + // This triple IS the answer to "which inbox does this user own". Caching and + // returning the document over a failed write handed the owner a reference nobody + // else can resolve: a depositor reading the shim finds nothing and mints yet + // another. The owner then reads a box nobody writes to, and depositors write to a + // box nobody reads — the exact shape sharing broke in before. console.error(accessLogPrefix() + " userInbox persist failed:", error); + throw error; } + // Filed and cached only now, and in that order: until the association was written + // this document was not yet anyone's inbox, and holding a cap for it (or answering + // with it for the rest of the session) would outlive the failure that decided it. + fileOwnInbox(id, doc); inboxCache.set(key, doc); logStage("userInbox(" + key + "/" + scope + ") → " + shortNuri(doc)); return doc; diff --git a/packages/polyfill/test/app-surface.test.ts b/packages/polyfill/test/app-surface.test.ts index 894d36a..f63f09a 100644 --- a/packages/polyfill/test/app-surface.test.ts +++ b/packages/polyfill/test/app-surface.test.ts @@ -197,3 +197,15 @@ test("a failed key write is reported too, and names that half", async () => { await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/key is not recorded/i); }); +// The third write a PUBLIC creation makes, and the one that was still silent: exposing the +// cap on the document is what lets anyone else read it (`public-store.exposeReadCap`, the +// emulated `expose_outer`), it happens once at creation and nothing ever redoes it. A +// swallowed failure therefore left a document sitting in a public store serving nothing — +// unreadable by everyone but its creator, permanently, with a reference handed back as +// though it had worked. +test("a public document whose cap could not be exposed is reported, not returned", async () => { + inject(/shim:exposedReadCap/); + setCurrentUser("alice"); + await expect(storeRegistry.createEntityDoc("public")).rejects.toThrow(/broker refused/i); +}); + diff --git a/packages/polyfill/test/user-inbox-surfaces-failure.test.ts b/packages/polyfill/test/user-inbox-surfaces-failure.test.ts new file mode 100644 index 0000000..674c6ae --- /dev/null +++ b/packages/polyfill/test/user-inbox-surfaces-failure.test.ts @@ -0,0 +1,670 @@ +/** + * Only a VERIFIED ABSENCE may mint an inbox — the whole case space of `userInbox`. + * + * `userInbox(id, scope)` answers *which inbox document does this virtual user own for this + * scope*, and mints one the first time. Everything downstream is addressed through that + * answer: `share` deposits into it, `connect.connectedUser` drains it, `isOwnInbox` guards + * reads with it. So the one thing it must never do is treat a failure as an absence — where + * `connect.ts` lost a restore for the same fault (2026-08-13), this one WRITES: + * + * - a read that could not answer, taken for "no inbox exists", MINTS a second document for + * one (user, scope). Two `shim:docInbox` triples then sit on the same subject and which + * one wins later is decided by `canonicalDoc` picking among them — the owner and a + * depositor can resolve different documents; + * - a persist that failed, followed by handing the document back anyway, gives the owner a + * reference whose triple was never written. Nobody else can resolve it: a depositor + * finding nothing mints yet another. The owner reads a box nobody writes to, depositors + * write to boxes nobody reads. + * + * The rule this file pins, one line: **a failure surfaces; only an absence the broker + * actually confirmed may mint.** Resolving from the cache, joining a run in flight, and + * minting on a confirmed 0 are unchanged — they are the states that are true. + * + * ── Why every branch is here, not just the interesting ones ─────────────── + * A swallowed failure is invisible by construction, so a suite covering the happy path and + * one error leaves exactly the places a bug hides. Every path through the function is + * asserted below: the cache, a run in flight (both outcomes), each of the four collaborators + * it awaits before deciding (session, doc-shim, account, the read), the read answering and + * the read finding nothing, and each of the three writes the mint performs. + * + * ── The faults are the broker's, not the test's ─────────────────────────── + * Every failure is injected at the `ng` boundary — a read that throws `RepoNotFound` (what + * the engine hard-errors when a repo is not in `self.repos`, + * `engine/verifier/src/request_processor.rs:264`), a write or a `doc_create` that cannot + * reach the broker — or at the consumer's injected `getSession`, which is the other edge of + * the library. Nothing reaches into the library to make one of its own functions reject: a + * fake that fabricates a state the real system never produces goes green while leaving the + * real state untested. + */ +import { test, expect, mock, afterEach } from "bun:test"; +import { configure } from "../src/index"; +import { + adoptCurrentUser, + configureStoreRegistry, + resetCaps, + resetConfig, + resetStoreRegistry, + setCurrentUser, +} from "../src/shared-wallet/bootstrap"; +import { + createEntityDoc, + ensureAccount, + isKnownInbox, + resetRegistryCache, + userInbox, +} from "../src/shared-wallet/account-registry"; +import type { RegistrySession } from "../src/shared-wallet/account-registry"; +import { + documentInboxAddress, + openDocumentInbox, +} from "../src/emulated-verifier/branch-registers"; +import { resetOpenedRepos } from "../src/emulated-verifier/open-repo"; +import type { InboxScope, Nuri } from "../src/model/types"; + +const SESSION: RegistrySession = { sessionId: "sid-inbox", privateStoreId: "PRIV-INBOX" }; +const SHIM = "urn:ng-eventually:shim"; + +interface Quad { g: string; s: string; p: string; o: string } + +/** + * What the broker refuses to do, and when. Every field arms a REAL failure of the + * corresponding platform call; `false` means "answer normally". Mutable after `inject`, so + * a test can build a healthy world first and only then break the one call it is about. + */ +interface Faults { + /** The consumer's `getSession` thunk rejects — the wallet cannot answer. */ + session: boolean; + /** `doc_create` throws — the broker cannot mint a document. */ + docCreate: boolean; + /** The doc-shim read that answers "which inbox does this user own" throws. */ + inboxLookup: boolean; + /** The shim write that records "this NURI IS an inbox" throws. */ + inboxRecord: boolean; + /** The shim write that associates the inbox with its (user, scope) throws. */ + inboxPersist: boolean; + /** The store-root read that answers "where is the doc-shim" throws. */ + pointerRead: boolean; + /** The store-root write that publishes the doc-shim's address throws. */ + pointerWrite: boolean; + /** The doc-shim read that answers "does this account exist" throws. */ + accountLookup: boolean; + /** The doc-shim write that records an account's three scope documents throws. */ + accountRecord: boolean; + /** The User-branch write that records "I opened this document's inbox" throws. */ + inboxCapPersist: boolean; + /** The Header-branch write that publishes WHERE to deposit for a document throws. */ + addressPublish: boolean; +} + +function noFaults(): Faults { + return { + session: false, + docCreate: false, + inboxLookup: false, + inboxRecord: false, + inboxPersist: false, + pointerRead: false, + pointerWrite: false, + accountLookup: false, + accountRecord: false, + inboxCapPersist: false, + addressPublish: false, + }; +} + +/** Reverse of the lib's `escapeLiteral`: one left-to-right pass over `\x`. */ +function unescapeLiteral(s: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && i + 1 < s.length) { + const next = s[++i]; + out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!; + } else out += s[i]; + } + return out; +} + +/** + * A stateful fake `ng` over an in-memory quad store, modelling the pointer → doc-shim + * indirection the registry is built on: the store-root graph carries the write-once pointer, + * the doc-shim carries the account records, the inbox index and the (user, scope) → inbox + * associations. Same shape as the one `anti-fork.test.ts` drives the registry with, plus the + * fault switches above. + * + * `doc_subscribe` pushes a first `State` so the barrier `ensureRepoOpen` waits on resolves + * at once — the platform's real behaviour, and it keeps every failure below attributable to + * the call that was armed rather than to a missing primitive. + */ +function makeFakeNg(faults: Faults) { + const quads: Quad[] = []; + let docCounter = 0; + /** How many times the (user, scope) → inbox association was READ. */ + let inboxLookups = 0; + + const doc_create = mock(async () => { + if (faults.docCreate) throw new Error("BrokerError: cannot create document"); + return `did:ng:o:idoc${++docCounter}`; + }); + + const sparql_update = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const anchor = a[2] as string | undefined; + if (query.includes(`${SHIM}:isInbox`) && faults.inboxRecord) { + throw new Error("BrokerError: cannot write the inbox index"); + } + if (query.includes(`${SHIM}:docInbox`) && faults.inboxPersist) { + throw new Error("BrokerError: cannot write the inbox association"); + } + if (query.includes(`${SHIM}:shimDoc`) && faults.pointerWrite) { + throw new Error("BrokerError: cannot write the pointer"); + } + if (query.includes(`${SHIM}:docPublic`) && faults.accountRecord) { + throw new Error("BrokerError: cannot write the account record"); + } + if (query.includes(`${SHIM}:inboxCap`) && faults.inboxCapPersist) { + throw new Error("BrokerError: cannot write the inbox cap"); + } + // Only the INSERT half: the `DELETE` that clears the previous address lands, which is + // the state that bites — the old address gone and the new one never written. + if ( + query.includes("INSERT DATA") && query.includes(`${SHIM}:inboxAddress`) && + faults.addressPublish + ) { + throw new Error("BrokerError: cannot publish the inbox address"); + } + // `DELETE WHERE {

?x }` — the clear half of a Header-branch replacement. + const dm = query.match(/DELETE WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?\w+\s*\}/); + if (dm) { + for (let i = quads.length - 1; i >= 0; i--) { + const q = quads[i]!; + if (q.g === anchor && q.s === dm[1] && q.p === dm[2]) quads.splice(i, 1); + } + return undefined; + } + // `INSERT DATA { GRAPH { … } }` — the shape the store-ROOT pointer write uses; + // everything else writes the anchored default graph. + const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/); + const g = gm ? gm[1]! : anchor; + if (!g) return undefined; + const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, ""); + const sm = body.match(/<([^>]+)>/); + if (!sm) return undefined; + const s = sm[1]!; + const after = body.slice(body.indexOf(sm[0]) + sm[0].length); + const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g; + let m: RegExpExecArray | null; + while ((m = pairRe.exec(after)) !== null) { + const p = m[1] ?? `${SHIM}:Account`; + const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); + quads.push({ g, s, p, o }); + } + return undefined; + }); + + const rows = (anchor: string | undefined, pred: string, name: string) => ({ + results: { + bindings: quads + .filter((q) => q.g === anchor && q.p === pred) + .map((q) => ({ [name]: { value: q.o } })), + }, + }); + + const sparql_query = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const anchor = a[3] as string | undefined; + // Store-root pointer → the doc-shim. + if (query.includes(`<${SHIM}:shimDoc>`)) { + if (faults.pointerRead) throw new Error("RepoNotFound"); + return rows(anchor, `${SHIM}:shimDoc`, "shimDoc"); + } + // The account record — the read `lookupAccount` issues, bounded to one subject. + if (query.includes(`<${SHIM}:id>`)) { + if (faults.accountLookup) throw new Error("RepoNotFound"); + const subjM = query.match(/<([^>]+)>\s+a\s+/); + const only = subjM ? subjM[1]! : null; + const bySubject = new Map>(); + for (const q of quads) { + if (q.g !== anchor) continue; + if (only !== null && q.s !== only) continue; + const rec = bySubject.get(q.s) ?? {}; + if (q.p === `${SHIM}:id`) rec.id = q.o; + if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o; + if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o; + if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o; + bySubject.set(q.s, rec); + } + return { + results: { + bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({ + id: { value: r.id! }, + docPublic: { value: r.docPublic ?? "" }, + docProtected: { value: r.docProtected ?? "" }, + docPrivate: { value: r.docPrivate ?? "" }, + })), + }, + }; + } + // Which inbox does this (user, scope) own — the read the whole file is about. + if (query.includes(`${SHIM}:docInbox`)) { + inboxLookups += 1; + if (faults.inboxLookup) throw new Error("RepoNotFound"); + const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/); + const pred = pm ? pm[1]! : ""; + const sm = query.match(/<([^>]+)>\s+ q.g === anchor && q.p === pred && (subj === null || q.s === subj)) + .map((q) => ({ d: { value: q.o } })), + }, + }; + } + if (query.includes(`${SHIM}:isInbox`)) return rows(anchor, `${SHIM}:isInbox`, "i"); + // The registers a document and a store carry — enough for `createEntityDoc`, + // `ownsDocument` and `openDocumentInbox` to run against this wallet. + if (query.includes(`<${SHIM}:exposedReadCap>`)) return rows(anchor, `${SHIM}:exposedReadCap`, "c"); + if (query.includes(`<${SHIM}:readCap>`)) return rows(anchor, `${SHIM}:readCap`, "c"); + if (query.includes(`<${SHIM}:contains>`)) return rows(anchor, `${SHIM}:contains`, "e"); + if (query.includes(`<${SHIM}:inboxCap>`)) return rows(anchor, `${SHIM}:inboxCap`, "c"); + if (query.includes(`<${SHIM}:inboxAddress>`)) return rows(anchor, `${SHIM}:inboxAddress`, "a"); + // Anchored per-doc read (`SELECT ?s ?p ?o`). + return { + results: { + bindings: quads + .filter((q) => q.g === anchor) + .map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })), + }, + }; + }); + + // Push a `State` on subscribe — the sync barrier `open-repo.ts` waits for. + const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: unknown) => { + if (typeof cb === "function") (cb as (r: unknown, t?: string) => void)({ V0: { State: {} } }); + return () => {}; + }); + + return { + doc_create, + sparql_query, + sparql_update, + doc_subscribe, + /** Documents minted so far — what "did it mint a second one" reads. */ + created: (): number => docCounter, + /** Times the (user, scope) → inbox association was read. */ + lookups: (): number => inboxLookups, + /** The inbox NURIs DURABLY associated with `scope`, across every account subject. */ + associated: (scope: InboxScope): string[] => + quads.filter((q) => q.p === `${SHIM}:docInbox:${scope}`).map((q) => q.o), + /** Every triple written with `pred`, whatever its subject or graph. */ + written: (pred: string): string[] => + quads.filter((q) => q.p === `${SHIM}:${pred}`).map((q) => q.o), + }; +} + +/** Wire a clean world: fresh fake broker, fresh caches, nobody connected. */ +function inject(faults: Faults) { + const ng = makeFakeNg(faults); + configure({ ng: ng as never, useShape: (() => {}) as never }); + configureStoreRegistry({ + getSession: async (): Promise => { + if (faults.session) throw new Error("WalletError: the session cannot answer yet"); + return SESSION; + }, + normalizeId: (id: string) => id.trim().toLowerCase(), + }); + resetRegistryCache(); + resetOpenedRepos(); + resetCaps(); + setCurrentUser(null); + return ng; +} + +afterEach(() => { + setCurrentUser(null); + resetConfig(); + resetStoreRegistry(); + resetRegistryCache(); + resetOpenedRepos(); + resetCaps(); +}); + +/** A later session over the SAME wallet: every in-memory cache gone, the quads kept. */ +function freshSession(): void { + resetRegistryCache(); + resetOpenedRepos(); +} + +// --- resolving without asking: the two states that are already true --------- + +test("A — an inbox already resolved comes back from the cache, untouched", async () => { + const ng = inject(noFaults()); + adoptCurrentUser("bob"); + const inbox = await userInbox("bob", "public"); + const createdBefore = ng.created(); + const lookupsBefore = ng.lookups(); + + const again = await userInbox("bob", "public"); + + expect(again).toBe(inbox); + expect(ng.lookups()).toBe(lookupsBefore); // no read at all + expect(ng.created()).toBe(createdBefore); // and nothing minted +}); + +test("B — a concurrent caller joins the resolution in flight instead of racing a second inbox", async () => { + // Two independent callers ask at once on a cold cache — the ordinary shape on a fresh + // page. Without the join each would see "no inbox" and mint one, forking the (user, + // scope) association in-session. + const ng = inject(noFaults()); + adoptCurrentUser("bob"); + await ensureAccount("bob"); + + const [first, second] = await Promise.all([ + userInbox("bob", "public"), + userInbox("bob", "public"), + ]); + + expect(second).toBe(first); + expect(ng.associated("public")).toEqual([first]); // ONE association, not two +}); + +test("B — a concurrent caller joins the resolution in flight and inherits its FAILURE", async () => { + // The join is only safe if what it hands out is the run's real outcome. A joiner that + // inherits a resolved promise over a failed run is the sibling defect fixed in + // `connect.ts`: a caller that did nothing wrong carries on over work that never happened. + const faults = noFaults(); + inject(faults); + adoptCurrentUser("bob"); + await ensureAccount("bob"); + faults.inboxPersist = true; + + // Both handlers attached in the same tick, as two real concurrent callers would: awaiting + // one and only THEN the other leaves the second rejection momentarily unobserved, which + // the runtime reports as an unhandled rejection rather than as the outcome under test. + const outcomes = await Promise.allSettled([ + userInbox("bob", "public"), + userInbox("bob", "public"), + ]); + + expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected"]); +}); + +// --- the collaborators awaited before any decision is taken ----------------- + +test("C — a session that cannot answer fails the call", async () => { + // The consumer's `getSession` is the other edge of the library. A wallet that cannot + // answer says nothing about which inbox a user owns, so there is nothing to conclude. + const faults = noFaults(); + inject(faults); + adoptCurrentUser("bob"); + faults.session = true; + + await expect(userInbox("bob", "public")).rejects.toThrow(/the session cannot answer/); +}); + +test("D — a doc-shim that cannot be reached fails the call", async () => { + // No pointer yet (a first login), so resolving the doc-shim mints it. A broker that + // cannot create it leaves the registry itself unresolved — every account record and every + // inbox association lives in that document. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + faults.docCreate = true; + + await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/); + expect(ng.created()).toBe(0); +}); + +test("E — an account that cannot be provisioned fails the call", async () => { + // A user must exist before it can own an inbox. `ensureAccount` mints its three scope + // documents on first sight; a broker that cannot returns no record, and an inbox filed + // under a user with no stores is filed under nothing. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("alice"); + await ensureAccount("alice"); // the doc-shim now exists, so the fault lands on the account + const createdBefore = ng.created(); + faults.docCreate = true; + + await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/); + expect(ng.associated("public")).toEqual([]); + expect(ng.created()).toBe(createdBefore); +}); + +// --- the read: an answer, an absence, or a failure -------------------------- + +test("F — a lookup that could not answer is not an absent inbox: no second one is minted", async () => { + // The shipped defect. The read that asks *which inbox does this user own* was wrapped in a + // catch that only logged, and execution fell through to the mint — so a broker that could + // not answer produced a SECOND document for one (user, scope). Which of the two later wins + // is then decided by `canonicalDoc` picking among the triples: the owner drains one, a + // depositor may write to the other. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + const inbox = await userInbox("bob", "public"); + freshSession(); + const createdBefore = ng.created(); + faults.inboxLookup = true; + + await expect(userInbox("bob", "public")).rejects.toThrow(/RepoNotFound/); + + // The durable consequence: the wallet still holds exactly ONE inbox for (bob, public), + // and no document was minted behind the failure. + expect(ng.associated("public")).toEqual([inbox]); + expect(ng.created()).toBe(createdBefore); + // …so once the broker answers again, it is that same inbox that comes back. + faults.inboxLookup = false; + freshSession(); + expect(await userInbox("bob", "public")).toBe(inbox); +}); + +test("G — an inbox the shim already records is resolved, never re-minted", async () => { + const ng = inject(noFaults()); + adoptCurrentUser("bob"); + const inbox = await userInbox("bob", "public"); + freshSession(); // a later session over the same wallet: nothing warm + const createdBefore = ng.created(); + + const resolved = await userInbox("bob", "public"); + + expect(resolved).toBe(inbox); + expect(ng.created()).toBe(createdBefore); + expect(ng.associated("public")).toEqual([inbox]); +}); + +test("H — a first ask on a VERIFIED absence mints the inbox, records it, and associates it", async () => { + // The normal case, and the only one entitled to write: the broker answered, and it + // answered nothing. The three writes that make the answer usable all have to land — the + // document, the shim's "this IS an inbox", and the (user, scope) association. + const ng = inject(noFaults()); + adoptCurrentUser("bob"); + + const publicInbox = await userInbox("bob", "public"); + const protectedInbox = await userInbox("bob", "protected"); + + expect(protectedInbox).not.toBe(publicInbox); // two store repos upstream, two documents + expect(ng.associated("public")).toEqual([publicInbox]); + expect(ng.associated("protected")).toEqual([protectedInbox]); + // Durably an inbox, not merely one this session happens to remember. + freshSession(); + expect(await isKnownInbox(publicInbox)).toBe(true); + expect(await isKnownInbox(protectedInbox)).toBe(true); +}); + +// --- the mint: three writes, and none of them may fail in silence ----------- + +test("I — a broker that cannot mint the document fails the call", async () => { + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + await ensureAccount("bob"); // exists, and has never opened an inbox + faults.docCreate = true; + + await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/); + expect(ng.associated("public")).toEqual([]); +}); + +test("J — an inbox the shim could not record as one is not handed back", async () => { + // `recordInbox` is what stands in for the broker's `inboxes: PubKey → RepoId` table: it is + // how a depositor learns that a NURI is an inbox at all, and `inbox.post` refuses anything + // it cannot confirm. It swallowed its own write failure, so the call went on to associate a + // document that no depositor will ever be allowed to write to — the owner's inbox, silently + // closed to everyone, forever. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + await ensureAccount("bob"); + faults.inboxRecord = true; + + await expect(userInbox("bob", "public")).rejects.toThrow(/cannot write the inbox index/); + expect(ng.associated("public")).toEqual([]); // nothing points at a box deposits bounce off +}); + +test("K — an association that could not be persisted is not returned as an inbox", async () => { + // The other shipped defect. The `INSERT DATA` that records WHICH inbox belongs to (user, + // scope) was wrapped in a catch that only logged, and the function cached and returned the + // document anyway. The owner then holds a reference whose triple was never written: nobody + // else resolves it, so a depositor finding nothing mints yet another. The owner reads a box + // nobody writes to, depositors write to a box nobody reads. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + await ensureAccount("bob"); + faults.inboxPersist = true; + + await expect(userInbox("bob", "public")).rejects.toThrow(/cannot write the inbox association/); + expect(ng.associated("public")).toEqual([]); + + // …and the reference a healthy resolution DOES hand back is one everybody resolves the + // same. Alice obtains Bob's inbox the way `inbox.share` does — by asking the registry + // under her own identity — rather than being handed a value across the boundary. + faults.inboxPersist = false; + freshSession(); + const ownersView: Nuri = await userInbox("bob", "protected"); + freshSession(); + adoptCurrentUser("alice"); + const depositorsView: Nuri = await userInbox("bob", "protected"); + + expect(depositorsView).toBe(ownersView); + expect(ng.associated("protected")).toEqual([ownersView]); +}); + +// --- the same shape, swept out of the rest of the registry ------------------- +// +// `userInbox` was the third member of this family found by accident, so the sweep that +// followed the fix looked for every other `catch` that logs and lets execution carry on as +// though the thing looked for was absent — especially where what follows creates or writes. +// These are the ones whose consequence was a DURABLE false state; each is pinned here, +// because a swallow that comes back is invisible again by construction. + +test("SWEEP — a pointer read that never answered does not mint a second registry root", async () => { + // `resolvePointer` retries a bounded number of times and answered `""` when the budget ran + // out — the same value it uses for "no pointer yet", which is what makes `resolveShimDoc` + // CREATE one. A store-root that could not be read therefore forked the registry itself: a + // second doc-shim, a second pointer, and every account record afterwards split between two + // documents, the loser's simply invisible. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + await ensureAccount("bob"); + const pointersBefore = ng.written("shimDoc"); + const createdBefore = ng.created(); + freshSession(); + faults.pointerRead = true; + + await expect(ensureAccount("bob")).rejects.toThrow(/RepoNotFound/); + + expect(ng.written("shimDoc")).toEqual(pointersBefore); // still ONE registry root + expect(ng.created()).toBe(createdBefore); // and nothing minted behind the failure +}); + +test("SWEEP — a pointer that could not be written fails the first login", async () => { + // The pointer is the only NURI a fresh session can name without a lookup. Carrying on + // without it means writing every account into a doc-shim no later session can find — and + // the next login, seeing no pointer, mints another one and re-provisions everybody. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + faults.pointerWrite = true; + + await expect(ensureAccount("bob")).rejects.toThrow(/cannot write the pointer/); + expect(ng.written("id")).toEqual([]); // no account was filed into an unreachable shim +}); + +test("SWEEP — an account read that could not answer does not provision a second account", async () => { + // `ensureAccount` asked through the TOLERANT resolver, which answers `null` for a read + // that failed exactly as for one that found nothing — and what follows an absence here is + // a PROVISION. So a broker hiccup minted a second set of three store documents and a + // second record: the account fork the doc-shim barrier was introduced to end, walking back + // in through the error path. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + const first = await ensureAccount("bob"); + const createdBefore = ng.created(); + freshSession(); + faults.accountLookup = true; + + await expect(ensureAccount("bob")).rejects.toThrow(/RepoNotFound/); + + expect(ng.created()).toBe(createdBefore); // no second set of scope documents + faults.accountLookup = false; + freshSession(); + expect(await ensureAccount("bob")).toEqual(first); // and the one account is intact +}); + +test("SWEEP — an account record that could not be persisted is not handed back", async () => { + // `ensureAccount` cached and returned the record right after the write. The session then + // wrote its entities into three documents the shim never heard of, and the next session, + // finding no record, provisioned the account again — everything created in between + // orphaned, with no error anywhere. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + await ensureAccount("alice"); // the doc-shim exists, so the fault lands on the record + faults.accountRecord = true; + + await expect(ensureAccount("bob")).rejects.toThrow(/cannot write the account record/); + + // Nothing remembers a user that was never filed: asking again re-provisions from scratch + // rather than handing back the set the first attempt abandoned. + faults.accountRecord = false; + const createdBefore = ng.created(); + const record = await ensureAccount("bob"); + expect(ng.created()).toBe(createdBefore + 3); + expect(record.docPrivate).not.toBe(""); +}); + +test("SWEEP — a document inbox the owner's branch could not record is never published", async () => { + // `openDocumentInbox` writes twice: the pair on the owner's User branch (what makes the + // inbox DRAINABLE — `myInboxes` builds the connection's drain list from it) and the + // address on the document (what makes it REACHABLE). Swallowing the first went on to + // publish the second, inviting depositors into a queue its owner never enumerates: every + // message delivered, none ever applied, permanently. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + const note = await createEntityDoc("bob", "protected"); + faults.inboxCapPersist = true; + + await expect(openDocumentInbox(note)).rejects.toThrow(/cannot write the inbox cap/); + + expect(ng.written("inboxAddress")).toEqual([]); // nobody was told where to deposit + expect(await documentInboxAddress(note)).toBeUndefined(); +}); + +test("SWEEP — an address that could not be published fails opening the inbox", async () => { + // The other half. Publishing is the only way a third party learns where to deposit here, + // and the address is a REPLACEMENT: the `DELETE` lands, the `INSERT` does not, and the + // document is left with no address at all while its owner is handed an inbox and told + // nothing. + const faults = noFaults(); + const ng = inject(faults); + adoptCurrentUser("bob"); + const note = await createEntityDoc("bob", "protected"); + faults.addressPublish = true; + + await expect(openDocumentInbox(note)).rejects.toThrow(/cannot publish the inbox address/); + expect(ng.written("inboxAddress")).toEqual([]); +});