diff --git a/.project/concepts/app-contract/_debt.md b/.project/concepts/app-contract/_debt.md deleted file mode 100644 index 2135e25..0000000 --- a/.project/concepts/app-contract/_debt.md +++ /dev/null @@ -1,8 +0,0 @@ -# Doc-debt — app-contract - -> 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/surface/lifecycle.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) -- TOUCHED docs/api-contract.md @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/.project/concepts/sign-in/_debt.md b/.project/concepts/sign-in/_debt.md deleted file mode 100644 index 92f3b45..0000000 --- a/.project/concepts/sign-in/_debt.md +++ /dev/null @@ -1,10 +0,0 @@ -# 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/bootstrap.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) -- TOUCHED packages/polyfill/src/shared-wallet/access-gate.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) -- TOUCHED packages/polyfill/src/surface/lifecycle.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) -- TOUCHED packages/polyfill/src/shared-wallet/session.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/.project/concepts/sign-in/_overview.md b/.project/concepts/sign-in/_overview.md index da48874..b0f74ef 100644 --- a/.project/concepts/sign-in/_overview.md +++ b/.project/concepts/sign-in/_overview.md @@ -29,4 +29,3 @@ The whole thing is scaffolding: upstream, a person opens **their** wallet, it co - `knowledge_how-a-user-gets-in` — the flow end to end, and which layer owns each step. - `knowledge_settling-is-not-connecting` — the split, and the cycle that forces it. -- `caveat_connect-memoizes-an-abandoned-run` — a live trap, unfixed. diff --git a/.project/concepts/sign-in/caveat_connect-memoizes-an-abandoned-run.md b/.project/concepts/sign-in/caveat_connect-memoizes-an-abandoned-run.md deleted file mode 100644 index c4560b4..0000000 --- a/.project/concepts/sign-in/caveat_connect-memoizes-an-abandoned-run.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -type: caveat -summary: A connection attempt that finds no account returns silently and leaves its abandoned run joinable, so a later legitimate attempt does nothing -last_checked: 2026-08-11 ---- - -# A connection run that gives up stays joinable - -In `emulated-verifier/connect.ts`, a connection attempt that finds no account for the acting identity **returns silently** — no restore, no drain — and it has already registered itself as the in-flight run for that identity. Any concurrent caller joins that run and resolves having done nothing. - -That is exactly how sharing broke once (see `knowledge_settling-is-not-connecting`): the trigger was fixed, this mechanism was not. - -**It is still live.** Any future caller that starts connecting before its session can answer will reproduce the same silence: work skipped, promise resolved, no error anywhere. The trap is documented on the setter that fires the connection, but a comment is not a mechanism — nothing prevents it. - -Two things make it nasty: giving up is indistinguishable from succeeding at the call site, and the memoization spreads the damage to callers that did nothing wrong. - -To validate the state of this: look at what the connection routine does when the account lookup answers nothing, and at whether the in-flight registration happens before or after that decision. - -Fixing it properly means deciding what an attempt that cannot answer should be — a failure, a retry, or something never memoized — and that decision has its own blast radius, which is why it was deliberately left out of the fix that closed the symptom. diff --git a/packages/polyfill/src/emulated-verifier/branch-registers.ts b/packages/polyfill/src/emulated-verifier/branch-registers.ts index 4791c58..e49f3cb 100644 --- a/packages/polyfill/src/emulated-verifier/branch-registers.ts +++ b/packages/polyfill/src/emulated-verifier/branch-registers.ts @@ -294,6 +294,14 @@ function encodeInboxCap(doc: Nuri, inbox: Nuri): string { return `${doc} ${inbox}`; } +/** + * The (document, inbox) pairs this user may read — the emulated `AddInboxCap` records. + * + * **Propagates a failed read**, for the reason spelled out on {@link readLinks}: this is + * half of the drain list `connect.connectedUser` works from, and an empty answer would + * make a document's queue silently un-drained — a share that was delivered and never + * applied, with nothing to see anywhere. + */ export async function readInboxCapPairs(): Promise> { const holder = getCurrentUser(); if (holder === null) return []; @@ -318,6 +326,7 @@ export async function readInboxCapPairs(): Promise { /** * The caps this user has received and applied — the User branch read back. Called * at connection to restore what was shared with them, without touching any inbox. + * + * **Propagates a failed read** rather than answering `[]`. Empty and unreadable are the + * same value here and could not be more different: "nobody has shared anything with me" + * against "everything shared with me is invisible and nothing said so". The caller that + * matters is `connect.connectedUser`, whose whole contract is that it either did the + * restore or says it did not (2026-08-13) — an empty answer would let it report success + * over a restore that never happened. Same reason `lookupAccount` exists beside + * `resolveAccount`. */ export async function readLinks(): Promise { const holder = getCurrentUser(); @@ -407,6 +424,7 @@ export async function readLinks(): Promise { } } catch (error) { console.error(accessLogPrefix() + " readLinks failed:", error); + throw error; } return out; } diff --git a/packages/polyfill/src/emulated-verifier/connect.ts b/packages/polyfill/src/emulated-verifier/connect.ts index 1ec4ba1..e622a24 100644 --- a/packages/polyfill/src/emulated-verifier/connect.ts +++ b/packages/polyfill/src/emulated-verifier/connect.ts @@ -35,7 +35,8 @@ */ import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; -import { resolveAccount } from "../shared-wallet/account-registry"; +import { lookupAccount } from "../shared-wallet/account-registry"; +import { accessLogPrefix } from "../shared-wallet/access-log"; import { myInboxes, readLinks } from "./branch-registers"; import { processInbox } from "../surface/inbox"; @@ -45,10 +46,32 @@ const inFlight = new Map>(); /** * Restore and drain for the connected user. Idempotent per user while in flight. * - * Tolerant by construction: it runs on every `setCurrentUser`, including in - * contexts where the store registry was never configured (unit tests, an app - * setting the identity before the session resolves). Those simply have nothing to - * restore, and a failure here must never break connecting. + * ── It either DID THE WORK or SAYS IT DID NOT ───────────────────────────── + * `ensureIdentity()` awaits this, and the contract it publishes is that the call + * *completes the connection work it starts*. So the one thing this must never do is + * resolve after failing: an application then renders, shows empty lists, and nothing + * anywhere says the restore never happened. What a person sees is "the app works but the + * documents shared with me never appear" — which reads like a permission decision and is + * 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: + * + * - **no identity connected** — anonymous holds nothing and owns no inbox; + * - **the identity has no account yet** — nothing to restore, no queue to drain, and + * connecting must not create one (see the note in `run` below). A GENUINE absence: + * `lookupAccount` is used precisely so a lookup that could not ANSWER throws instead + * of borrowing that silence; + * - **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. + * + * 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. */ export async function connectedUser(): Promise { const holder = getCurrentUser(); @@ -75,33 +98,40 @@ export async function connectedUser(): Promise { const holderKey = getCaps().holderKey(); const run = (async (): Promise => { - try { - // Connecting must not PROVISION. `ensureAccount` would create the user on - // first sight, so connecting an identity that does not exist yet would - // silently mint its stores and their caps — arming the whole emulation as a - // background side effect, at a moment nothing controls. An account that does - // not exist has nothing to restore and no inbox to drain. - if ((await resolveAccount(holder)) === null) return; + // Connecting must not PROVISION. `ensureAccount` would create the user on + // first sight, so connecting an identity that does not exist yet would + // silently mint its stores and their caps — arming the whole emulation as a + // background side effect, at a moment nothing controls. An account that does + // not exist has nothing to restore and no inbox to drain. + // + // `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read + // that FAILED exactly as for one that found nothing, so an unreachable broker looked + // like "this identity has no account" — the one absence connecting is entitled to + // pass over in silence. The whole restore was skipped and the promise resolved like a + // success. That is how sharing broke once (`.project/concepts/sign-in/` + // `knowledge_settling-is-not-connecting`), and conflating absence with ignorance is + // 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. + const links = await readLinks(); + if (!stillConnected()) return; + for (const cap of links) getCaps().learnFor(holderKey, cap); + // 2. Then the queues: ALL of them — the user's own inbox, plus one per + // document it opened an inbox on. Both levels, as the PO specified, and + // both are answered by the same User-branch record (`AddInboxCap`). + // Sequential rather than parallel: each `processInbox` writes what it + // 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. + const inboxes = await myInboxes(); + for (const inbox of inboxes) { if (!stillConnected()) return; - // 1. Durable first: what this user has already applied. - const links = await readLinks(); - if (!stillConnected()) return; - for (const cap of links) getCaps().learnFor(holderKey, cap); - // 2. Then the queues: ALL of them — the user's own inbox, plus one per - // document it opened an inbox on. Both levels, as the PO specified, and - // both are answered by the same User-branch record (`AddInboxCap`). - // Sequential rather than parallel: each `processInbox` writes what it - // applies to the SAME private store, and interleaving those writes buys - // nothing on a queue that is nearly always empty. - const inboxes = await myInboxes(); - for (const inbox of inboxes) { - if (!stillConnected()) return; - await processInbox(inbox); - } - } catch { - // Not configured yet, or offline. Nothing to restore, and connecting must - // not fail because a queue could not be reached — the next connection, or - // an explicit `connectedUser()`, picks it up. + await processInbox(inbox); } })(); @@ -113,7 +143,20 @@ export async function connectedUser(): Promise { } } -/** Fire the connection work without awaiting it. Called by `setCurrentUser`. */ +/** + * Fire the connection work without awaiting it. Called by `setCurrentUser`. + * + * There is no caller to reject at here — that is what fire-and-forget means — so this is + * the one place a failure cannot surface as a rejection. It is logged instead, and NOT + * left to become an unhandled rejection: that would take down whatever runtime the + * consumer is in, over a connection the consumer never awaited. + * + * A caller that needs the outcome awaits `connectedUser()` — it joins this very run and + * inherits it, failure included. That is what `ensureIdentity()` does, and it is the path + * on which the guarantee is published. + */ export function startConnect(): void { - void connectedUser(); + void connectedUser().catch((error: unknown) => { + console.error(accessLogPrefix() + " connect failed:", error); + }); } diff --git a/packages/polyfill/test/access-gate.test.ts b/packages/polyfill/test/access-gate.test.ts index 4f914e2..589373d 100644 --- a/packages/polyfill/test/access-gate.test.ts +++ b/packages/polyfill/test/access-gate.test.ts @@ -71,9 +71,31 @@ afterEach(() => { for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name); }); +/** + * A broker over an EMPTY wallet: every read answers zero bindings, every write lands + * nowhere, and `doc_create` mints a distinct NURI. + * + * These tests are about the gate, not about storage — but `ensureIdentity()` goes on to + * connect the identity it settled, and connecting reaches the broker. It was handed `{}` + * here, which no real platform ever presents: `sparql_query` was simply missing, and the + * `TypeError` that produced was swallowed by the connection work. Now that a failed + * connection is reported (`emulated-verifier/connect.ts`), a fake that cannot answer would + * fail every test in this file for a reason none of them is about. An empty wallet — a page + * where nobody has signed in yet — is a state the real system produces constantly, and it + * changes nothing this file asserts: connecting finds no account and returns. + */ +function emptyWallet() { + let created = 0; + return { + sparql_query: async (): Promise => ({ results: { bindings: [] } }), + sparql_update: async (): Promise => undefined, + doc_create: async (): Promise => `did:ng:o:gate${++created}`, + }; +} + function configured(injectedInit?: (...args: unknown[]) => unknown) { configure({ - ng: {} as never, + ng: emptyWallet() as never, useShape: (() => {}) as never, sharedWallet: { fileUrl: "/w.ngw", password: "pw" }, // Only where a test needs a hand-over to come back FROM: the delegation is what the diff --git a/packages/polyfill/test/connection-surfaces-failure.test.ts b/packages/polyfill/test/connection-surfaces-failure.test.ts new file mode 100644 index 0000000..a0fa276 --- /dev/null +++ b/packages/polyfill/test/connection-surfaces-failure.test.ts @@ -0,0 +1,576 @@ +/** + * Connecting a user either DID THE WORK or SAYS IT DID NOT — the whole case space. + * + * `connectedUser()` restores the caps a person was given (the Links already applied on + * their User branch) and drains their inboxes. `ensureIdentity()` awaits it, and the + * published contract says that call "completes the connection work it starts". So the one + * thing it must never do is resolve after failing: an application then renders, shows empty + * lists, and nothing anywhere says the restore never happened. The symptom a consumer sees + * is *"the app works but the documents shared with me never appear"* — the worst kind, + * because it looks like a permission decision and is a swallowed error. + * + * The rule this file pins, one line: **any failure must surface; only "there was nothing to + * do" may resolve quietly.** Nothing to do means exactly two things — no identity is + * connected, or the identity has no account yet (connecting must never PROVISION one, see + * `connect.ts`) — plus abandoning when the identity changed under the run, which is not a + * failure either: the next connection picks it up. + * + * ── Why every branch is here, not just the interesting ones ─────────────── + * A swallowed failure is invisible by construction, so a suite that covers "the happy path + * and one error" leaves exactly the places a bug hides. Every path through the function is + * asserted below: no holder, no account, a lookup that could not answer, each of the three + * identity checkpoints, each of the three collaborators failing, joining a run in flight + * (both outcomes), the fire-and-forget entry, and success. + * + * ── The faults are the broker's, not the test's ─────────────────────────── + * Every failure below 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/verifier.rs`, and what `cold-start-anchor.test.ts` models too), or a + * `doc_create` that cannot reach the broker. Nothing here reaches into the library to make + * one of its functions reject artificially: 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, + getCaps, + resetCaps, + resetConfig, + resetStoreRegistry, + setCurrentUser, +} from "../src/shared-wallet/bootstrap"; +import { + createEntityDoc, + ensureAccount, + resetRegistryCache, + userInbox, +} from "../src/shared-wallet/account-registry"; +import type { RegistrySession } from "../src/shared-wallet/account-registry"; +import { resetOpenedRepos } from "../src/emulated-verifier/open-repo"; +import { connectedUser } from "../src/emulated-verifier/connect"; +import { share } from "../src/surface/inbox"; +import { sparqlUpdate } from "../src/surface/docs"; +import type { Nuri } from "../src/model/types"; + +const SESSION: RegistrySession = { sessionId: "sid-connect", privateStoreId: "PRIV-CONNECT" }; +const SHIM = "urn:ng-eventually:shim"; +const INBOX = "urn:ng-eventually:inbox"; +const SECRET = "urn:connect-test:secret"; + +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; `null` means "answer normally". + */ +interface Faults { + /** The doc-shim read that answers "does this account exist" throws. */ + accountLookup: boolean; + /** The User-branch read that answers "which Links has this user applied" throws. */ + linksRead: boolean; + /** `doc_create` throws — the broker cannot mint the document an inbox needs. */ + docCreate: boolean; + /** The User-branch read that answers "which document inboxes may I read" throws. */ + inboxCapRead: boolean; + /** Reading THIS inbox throws (a repo the session cannot resolve). */ + inboxRead: Nuri | null; + /** Called before every anchored read — the seam the identity-switch cases use. */ + onQuery: ((query: string, anchor: string | undefined) => void) | null; +} + +function noFaults(): Faults { + return { + accountLookup: false, + linksRead: false, + docCreate: false, + inboxCapRead: false, + inboxRead: null, + onQuery: null, + }; +} + +/** 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 — the shim SPARQL, the User-branch + * registers, the inbox SPARQL and the anchored per-doc read. Same shape as the one + * `cross-user-access.test.ts` drives the model with, plus the fault switches above. + * + * No `doc_subscribe`: `ensureRepoOpen` is then a no-op by design (`open-repo.ts`), which + * keeps every failure below attributable to the call that was armed. + */ +function makeFakeNg(faults: Faults) { + const quads: Quad[] = []; + /** The anchors an inbox READ was issued against, in order — what "was it drained" reads. */ + const inboxReads: string[] = []; + /** How many times the Links register was read — what "the work ran once" reads. */ + let linksReads = 0; + let docCounter = 0; + + const doc_create = mock(async () => { + if (faults.docCreate) throw new Error("BrokerError: cannot create document"); + return `did:ng:o:cdoc${++docCounter}`; + }); + + const sparql_update = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const anchor = a[2] as string | undefined; + if (!anchor) 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 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] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`); + const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); + quads.push({ g: anchor, 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; + faults.onQuery?.(query, anchor); + // Store-root pointer → the doc-shim. + if (query.includes(`<${SHIM}:shimDoc>`)) return rows(anchor, `${SHIM}:shimDoc`, "shimDoc"); + // The account record — the read `lookupAccount` issues. + 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 ?? "" }, + })), + }, + }; + } + // An inbox READ — the deposits queued for its owner. + if (query.includes(`<${INBOX}:payload>`)) { + inboxReads.push(anchor ?? ""); + if (faults.inboxRead !== null && anchor === faults.inboxRead) throw new Error("RepoNotFound"); + const bySubject = new Map>(); + for (const q of quads) { + if (q.g !== anchor) continue; + const rec = bySubject.get(q.s) ?? {}; + if (q.p === `${INBOX}:payload`) rec.payload = q.o; + if (q.p === `${INBOX}:ts`) rec.ts = q.o; + if (q.p === `${INBOX}:from`) rec.from = q.o; + bySubject.set(q.s, rec); + } + return { + results: { + bindings: [...bySubject.values()] + .filter((r) => r.payload !== undefined && r.ts !== undefined) + .map((r) => { + const row: Record = { + payload: { value: r.payload! }, + ts: { value: r.ts! }, + }; + if (r.from !== undefined) row.from = { value: r.from }; + return row; + }), + }, + }; + } + // The User branch: the applied Links, and the inbox caps. + if (query.includes(`<${SHIM}:link>`)) { + linksReads += 1; + if (faults.linksRead) throw new Error("RepoNotFound"); + return rows(anchor, `${SHIM}:link`, "c"); + } + if (query.includes(`<${SHIM}:inboxCap>`)) { + if (faults.inboxCapRead) throw new Error("RepoNotFound"); + return rows(anchor, `${SHIM}:inboxCap`, "c"); + } + if (query.includes(`<${SHIM}:inboxAddress>`)) return rows(anchor, `${SHIM}:inboxAddress`, "a"); + if (query.includes(`<${SHIM}:readCap>`)) return rows(anchor, `${SHIM}:readCap`, "c"); + if (query.includes(`<${SHIM}:exposedReadCap>`)) return rows(anchor, `${SHIM}:exposedReadCap`, "c"); + if (query.includes(`<${SHIM}:contains>`)) return rows(anchor, `${SHIM}:contains`, "e"); + if (query.includes(`${SHIM}:isInbox`)) return rows(anchor, `${SHIM}:isInbox`, "i"); + if (query.includes(`${SHIM}:docInbox`)) { + 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 } })), + }, + }; + } + // 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 } })), + }, + }; + }); + + return { + doc_create, + sparql_query, + sparql_update, + inboxReads, + linksReadCount: (): number => linksReads, + }; +} + +/** 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 => SESSION, + normalizeId: (id: string) => id.trim().toLowerCase(), + }); + resetRegistryCache(); + resetOpenedRepos(); + resetCaps(); + setCurrentUser(null); + return ng; +} + +afterEach(() => { + setCurrentUser(null); + resetConfig(); + resetStoreRegistry(); + resetRegistryCache(); + resetOpenedRepos(); + resetCaps(); +}); + +/** Write one triple into `doc`, as a consumer's write path would. */ +async function write(doc: Nuri, p: string, o: string): Promise { + await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test"); +} + +/** + * Alice owns a protected note and shares it with Bob — the ordinary way a cap reaches + * someone. Bob's account and inbox come into existence through the system (he signs in + * once), never through a value the test hands across the identity boundary. + * + * Returns the note Alice shared, so a later assertion can ask "does Bob hold it". + */ +async function aliceSharesANoteWithBob(): Promise { + adoptCurrentUser("bob"); + await ensureAccount("bob"); // Bob has signed in before: he exists, so he can be shared with. + + adoptCurrentUser("alice"); + const note = await createEntityDoc("alice", "protected"); + await write(note, SECRET, "the-protected-content"); + await share(note, "bob"); + + adoptCurrentUser(null); + return note; +} + +/** Does the connected identity hold this document's cap? */ +function holds(doc: Nuri): boolean { + return getCaps().capFor(doc) !== undefined; +} + +// --- nothing to do: the only two silences that are legitimate --------------- + +test("no identity connected: nothing to restore, and the call resolves", async () => { + // Anonymous holds nothing and owns no inbox, so there is genuinely no work. This is the + // one branch `ensureIdentity` can never reach — it has settled an identity by then — and + // it exists for the internal callers (`startConnect` is gated on a non-null id; a test or + // the e2e harness may call in before signing in). + const ng = inject(noFaults()); + + await expect(connectedUser()).resolves.toBeUndefined(); + + expect(ng.linksReadCount()).toBe(0); + expect(ng.inboxReads).toEqual([]); +}); + +test("an identity with no account yet: nothing to restore, and the call resolves", async () => { + // Connecting must not PROVISION (see `connect.ts`): an account that does not exist has no + // Links to restore and no inbox to drain. A genuine absence is therefore silence, and the + // ONLY silence a failed read may not borrow — see the next test. + const ng = inject(noFaults()); + adoptCurrentUser("nobody-has-signed-in-as-this"); + + await expect(connectedUser()).resolves.toBeUndefined(); + + expect(ng.linksReadCount()).toBe(0); + expect(ng.inboxReads).toEqual([]); +}); + +// --- the failures, one per collaborator ------------------------------------- + +test("an account lookup that could not answer is not an absent account: the call rejects", async () => { + // The shipped shape of this defect. The tolerant resolver answers `null` for a read that + // FAILED exactly as for one that found nothing, so a broker that cannot answer looks like + // "you have no account" — which connecting is entitled to pass over in silence. The whole + // restore is then skipped and the promise resolves like a success. + const faults = noFaults(); + inject(faults); + await aliceSharesANoteWithBob(); + + adoptCurrentUser("bob"); + resetRegistryCache(); // a fresh session: nothing is answered from a warm cache + faults.accountLookup = true; + + await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); +}); + +test("a Links register that cannot be read fails the connection", async () => { + // The restore step. Its failure is the one with no other symptom at all: every document + // ever shared with this person stays invisible, and an application has no way to tell + // that from "nobody has shared anything with me". + const faults = noFaults(); + inject(faults); + const note = await aliceSharesANoteWithBob(); + + adoptCurrentUser("bob"); + await connectedUser(); // a first, healthy connection: the cap is applied durably + resetCaps(); + resetRegistryCache(); + faults.linksRead = true; + + await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); + expect(holds(note)).toBe(false); // and it really did not restore +}); + +test("an inbox list that cannot be built fails the connection", async () => { + // Enumerating the inboxes resolves the user's own two, minting the document on first + // ask. A broker that cannot create it leaves the drain list unknowable — not empty. + const faults = noFaults(); + inject(faults); + + adoptCurrentUser("bob"); + await ensureAccount("bob"); // exists, but has never opened an inbox + faults.docCreate = true; + + await expect(connectedUser()).rejects.toThrow(/cannot create document/); +}); + +test("a document-inbox list that cannot be read fails the connection", async () => { + // The other half of the drain list: the inboxes this user opened on its own documents. + // Unreadable is not empty — a queue skipped for want of knowing it exists holds a share + // that was delivered and will never be applied, with nothing to see anywhere. + const faults = noFaults(); + inject(faults); + + adoptCurrentUser("bob"); + await userInbox("bob", "public"); // so the OWN inboxes resolve and the fault lands later + await userInbox("bob", "protected"); + faults.inboxCapRead = true; + + await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); +}); + +test("an inbox that cannot be drained fails the connection, and the rest stay undrained", async () => { + // Draining is what APPLIES a share. An inbox that could not be read may hold the cap this + // very reconnection was for, so a silent skip loses it with no trace — and it took the + // remaining inboxes down with it, silently too. It still stops at the first failure; the + // difference is that stopping is now audible. + const faults = noFaults(); + const ng = inject(faults); + await aliceSharesANoteWithBob(); + + adoptCurrentUser("bob"); + const publicInbox = await userInbox("bob", "public"); // the first of the two drained + faults.inboxRead = publicInbox; + + await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); + expect(ng.inboxReads).toEqual([publicInbox]); // the protected one was never reached +}); + +// --- abandoning on an identity switch: not a failure ------------------------ + +test("the identity changes right after the account resolved: abandons, and resolves", async () => { + // Everything below the checkpoint resolves the CURRENT holder when it reads a register, + // so after a switch it would read the WRONG user's. Abandoning loses nothing: the new + // identity's own connection does its own work. + const faults = noFaults(); + const ng = inject(faults); + await aliceSharesANoteWithBob(); + + adoptCurrentUser("bob"); + resetRegistryCache(); + faults.onQuery = (query) => { + if (query.includes(`<${SHIM}:id>`)) adoptCurrentUser("carol"); + }; + + await expect(connectedUser()).resolves.toBeUndefined(); + expect(ng.linksReadCount()).toBe(0); + expect(ng.inboxReads).toEqual([]); +}); + +test("the identity changes while the Links are being read: abandons, and resolves", async () => { + const faults = noFaults(); + const ng = inject(faults); + const note = await aliceSharesANoteWithBob(); + + adoptCurrentUser("bob"); + await connectedUser(); // apply the cap durably, so there IS something to restore + resetCaps(); + resetRegistryCache(); + const drainedBefore = ng.inboxReads.length; // the healthy connection above drained them + faults.onQuery = (query) => { + if (query.includes(`<${SHIM}:link>`)) adoptCurrentUser("carol"); + }; + + await expect(connectedUser()).resolves.toBeUndefined(); + expect(holds(note)).toBe(false); // nothing was filed under the wrong holder + expect(ng.inboxReads.length).toBe(drainedBefore); // and it never reached the queues +}); + +test("the identity changes between two inboxes: abandons, and resolves", async () => { + const faults = noFaults(); + const ng = inject(faults); + + adoptCurrentUser("bob"); + const publicInbox = await userInbox("bob", "public"); + const protectedInbox = await userInbox("bob", "protected"); + expect(protectedInbox).not.toBe(publicInbox); + faults.onQuery = (query, anchor) => { + if (query.includes(`<${INBOX}:payload>`) && anchor === publicInbox) adoptCurrentUser("carol"); + }; + + await expect(connectedUser()).resolves.toBeUndefined(); + expect(ng.inboxReads).toEqual([publicInbox]); // the checkpoint stopped the loop +}); + +// --- joining a run already in flight ---------------------------------------- + +test("a concurrent caller joins the run in flight and inherits its success", async () => { + const ng = inject(noFaults()); + await aliceSharesANoteWithBob(); + adoptCurrentUser("bob"); + const publicInbox = await userInbox("bob", "public"); + const protectedInbox = await userInbox("bob", "protected"); + resetRegistryCache(); + + const first = connectedUser(); + const second = connectedUser(); + + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBeUndefined(); + // ONE run, two callers: each inbox was drained exactly once. Two independent runs + // would show each anchor twice — the memoisation is what the joiner depends on. + expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]); +}); + +test("a concurrent caller joins the run in flight and inherits its FAILURE", async () => { + // The sibling defect: a run that gives up registers itself as the run in flight FIRST, so + // a caller that did nothing wrong joins it and resolves having done nothing. Inheriting + // the outcome — failure included — is what makes joining safe. + const faults = noFaults(); + inject(faults); + await aliceSharesANoteWithBob(); + + adoptCurrentUser("bob"); + resetRegistryCache(); + faults.accountLookup = 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([connectedUser(), connectedUser()]); + + expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected"]); + const reasons = outcomes.flatMap((o) => (o.status === "rejected" ? [String(o.reason)] : [])); + expect(reasons).toEqual([expect.stringContaining("RepoNotFound"), expect.stringContaining("RepoNotFound")]); +}); + +// --- the fire-and-forget entry ---------------------------------------------- + +test("the fire-and-forget entry reports the failure instead of dropping it", async () => { + // `setCurrentUser` fires the work un-awaited, so there is no caller to reject at. The + // failure must still leave a trace rather than vanish — and it must not surface as an + // unhandled rejection, which would take down whatever runtime the consumer is in. + const faults = noFaults(); + inject(faults); + await aliceSharesANoteWithBob(); + resetRegistryCache(); + faults.accountLookup = true; + + const logged: string[] = []; + const original = console.error; + console.error = (...args: unknown[]): void => void logged.push(args.map(String).join(" ")); + try { + setCurrentUser("bob"); + await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); + } finally { + console.error = original; + } + + expect(logged.some((line) => /connect(ing)? failed/i.test(line))).toBe(true); +}); + +// --- success ---------------------------------------------------------------- + +test("a healthy connection restores the applied Links and drains every inbox", async () => { + // The normal case, and the reason all of the above matters: this is what an application + // is entitled to assume happened when `ensureIdentity()` came back. + const faults = noFaults(); + const ng = inject(faults); + const note = await aliceSharesANoteWithBob(); + + adoptCurrentUser("bob"); + const publicInbox = await userInbox("bob", "public"); + const protectedInbox = await userInbox("bob", "protected"); + + // First connection: the cap is in the inbox, draining APPLIES it (durably, as a Link). + await expect(connectedUser()).resolves.toBeUndefined(); + expect(holds(note)).toBe(true); + expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]); + + // Second connection, as a later session would: nothing left in the queue, and the cap + // comes back from the durable register alone. + resetCaps(); + resetRegistryCache(); + await expect(connectedUser()).resolves.toBeUndefined(); + expect(holds(note)).toBe(true); + expect(ng.linksReadCount()).toBeGreaterThanOrEqual(1); +}); diff --git a/packages/polyfill/test/lifecycle.test.ts b/packages/polyfill/test/lifecycle.test.ts index f93de17..3bb5d80 100644 --- a/packages/polyfill/test/lifecycle.test.ts +++ b/packages/polyfill/test/lifecycle.test.ts @@ -170,9 +170,29 @@ function consumerWiring(session: Record = BROKER_SESSION) { return { sessionReady, calls, injectedInit, returned, getSession: () => sessionReady }; } +/** + * A broker over an EMPTY wallet — nobody has signed in yet. + * + * This file is about the lifecycle, not about storage, but `ensureIdentity()` goes on to + * connect the identity it settled and connecting reaches the broker. It was handed `{}`, + * a shape no real platform presents: the `TypeError` that produced was swallowed by the + * connection work, so the tests below were passing over a run that had died of the + * fixture. A failed connection is reported now (`emulated-verifier/connect.ts`), so the + * fake answers like a broker with nothing in it — which is what a first sign-in meets, and + * which changes nothing any test here asserts: connecting finds no account and returns. + */ +function emptyWallet() { + let created = 0; + return { + sparql_query: async (): Promise => ({ results: { bindings: [] } }), + sparql_update: async (): Promise => undefined, + doc_create: async (): Promise => `did:ng:o:life${++created}`, + }; +} + function configured(wiring: ReturnType, opts: { sharedWallet?: boolean } = {}) { configure({ - ng: {} as never, + ng: emptyWallet() as never, useShape: (() => {}) as never, init: wiring.injectedInit, ...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }), @@ -350,7 +370,7 @@ function flush(): Promise { function anApplicationThatConfigured() { let deliver: ((event: unknown) => void) | null = null; configure({ - ng: {} as never, + ng: emptyWallet() as never, useShape: (() => {}) as never, sharedWallet: { fileUrl: "/w.ngw", password: "pw" }, init: (...args: unknown[]): Promise => { diff --git a/packages/polyfill/test/sign-in-connects.test.ts b/packages/polyfill/test/sign-in-connects.test.ts index 1a75566..ef61bb5 100644 --- a/packages/polyfill/test/sign-in-connects.test.ts +++ b/packages/polyfill/test/sign-in-connects.test.ts @@ -80,6 +80,16 @@ function inBrokerIframe(url: string): void { const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const; +/** A broker over an empty wallet: reads answer nothing, `doc_create` mints a NURI. */ +function emptyWallet() { + let created = 0; + return { + sparql_query: async (): Promise => ({ results: { bindings: [] } }), + sparql_update: async (): Promise => undefined, + doc_create: async (): Promise => `did:ng:o:signin${++created}`, + }; +} + afterEach(() => { setCurrentUser(null); resetConfig(); @@ -109,7 +119,14 @@ function bootTheApplication(identifier: string) { const thunk = { asked: 0, answered: 0, refused: 0 }; configure({ - ng: {} as never, // no store behind it: the assertions are about what is REACHED + // A broker over an EMPTY wallet — nobody has signed in yet. The assertions below are + // about what is REACHED on the way to the session, not about anything stored; what + // this must NOT be is `{}`, a shape no real platform presents. The connection work + // called into it, got a `TypeError`, and swallowed it — so the counters were read off + // a run that had already died of the fixture. Now that a failed connection is reported + // (`emulated-verifier/connect.ts`), the fake has to answer like a broker with nothing + // in it, which is exactly the state a first sign-in meets. + ng: emptyWallet() as never, useShape: (() => {}) as never, init: (...args: unknown[]): Promise => { const callback = args[0];