diff --git a/.project/concepts/app-contract/_debt.md b/.project/concepts/app-contract/_debt.md new file mode 100644 index 0000000..f3dfdff --- /dev/null +++ b/.project/concepts/app-contract/_debt.md @@ -0,0 +1,7 @@ +# 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/inbox.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/.project/concepts/sign-in/_debt.md b/.project/concepts/sign-in/_debt.md new file mode 100644 index 0000000..c6daf88 --- /dev/null +++ b/.project/concepts/sign-in/_debt.md @@ -0,0 +1,8 @@ +# 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-16 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/src/shared-wallet/bootstrap.ts @2026-08-16 (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 bd5a7e1..0e154d0 100644 --- a/packages/polyfill/src/emulated-verifier/branch-registers.ts +++ b/packages/polyfill/src/emulated-verifier/branch-registers.ts @@ -34,7 +34,7 @@ */ import { sparqlQuery } from "../surface/docs"; -import { registerUpdate } from "./register-write"; +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"; @@ -60,7 +60,7 @@ import { recordInbox, type VirtualUserRecord, } from "../shared-wallet/account-registry"; -import type { InboxScope, Nuri, NuriLike, ReadCap, Scope } from "../model/types"; +import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, Scope } from "../model/types"; /** * Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the @@ -373,14 +373,21 @@ export async function myInboxes(): Promise { * * Idempotent — re-applying the same Link is a no-op, so re-processing an inbox * (a second tab, a reconnect) costs nothing. + * + * `forHolder` names WHOSE User branch this lands on when it is not the connected + * identity's — the emulated inbox processor + * (`emulated-verifier/inbox-processor.ts`) applying a Link for an inbox's owner during + * someone else's session. Omitted, it is the connected identity, unchanged. Getting this + * wrong is not a near-miss: a Link filed under the wrong holder gives one user another's + * capability and leaves the real recipient with nothing. */ -export async function addLink(cap: ReadCap): Promise { - const holder = getCurrentUser(); +export async function addLink(cap: ReadCap, forHolder?: PrincipalId): Promise { + const holder = forHolder ?? getCurrentUser(); if (holder === null) return; const record = await ensureAccount(holder); const store = record.docPrivate; if (!store) return; - if ((await readLinks()).includes(cap)) return; + if ((await readLinks(forHolder)).includes(cap)) return; const s = await session(); try { await registerUpdate( @@ -388,12 +395,25 @@ export async function addLink(cap: ReadCap): Promise { `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`, store, "addLink", + holderRing(forHolder), ); } catch (error) { console.error(accessLogPrefix() + " addLink failed:", error); } } +/** + * The cap-registry key of a NAMED holder, or `undefined` for "whoever is connected". + * + * One rule decides both, and it has to: the registry keys a holder's caps by + * `normalizeId` (`shared-wallet/bootstrap.ts` `capsHolder`), and the shim keys its accounts + * the same way ({@link accountKey}). A path that named a holder any other way would file + * into a ring the owner's own session never looks at. + */ +function holderRing(forHolder: PrincipalId | undefined): string | undefined { + return forHolder === undefined ? undefined : accountKey(forHolder); +} + @@ -409,24 +429,30 @@ export async function addLink(cap: ReadCap): Promise { * 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`. + * + * `forHolder` names whose User branch is read when it is not the connected identity's — + * see {@link addLink}, whose idempotence check is the caller that needs it. */ -export async function readLinks(): Promise { - const holder = getCurrentUser(); +export async function readLinks(forHolder?: PrincipalId): Promise { + const holder = forHolder ?? getCurrentUser(); if (holder === null) return []; const record = await ensureAccount(holder); const store = record.docPrivate; if (!store) return []; const s = await session(); const out: ReadCap[] = []; - await ensureRepoOpen(store); + const ring = holderRing(forHolder); + await ensureRepoOpen(store, ring); + const query = `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`; try { - const res = await sparqlQuery( - s.sessionId, - `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`, - undefined, - store, - "readLinks", - ); + // Two doors, one question. The connected identity's own register goes through the + // ordinary guarded read; a NAMED holder's goes through the processor's door, which + // asks the boundary about THAT holder's possession instead of the session's — see + // `register-write.readForHolder`. + const res = + ring === undefined + ? await sparqlQuery(s.sessionId, query, undefined, store, "readLinks") + : await readForHolder(s.sessionId, query, store, ring, "readLinks"); for (const row of readBindings(res)) { const v = bindingValue(row, "c"); if (v && hasReadCap(v)) out.push(v); @@ -541,8 +567,10 @@ export async function openDocumentInbox(docLike: NuriLike): Promise { getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs // …and the shim records that it IS an inbox, so a depositor can find that out without // holding anything of it. See `recordInbox`: upstream a deposit cannot address a plain - // document at all, and this is what stands in for that impossibility. - await recordInbox(inbox); + // document at all, and this is what stands in for that impossibility — and WHOSE it is, + // which is the other half of the same routing fact. The owner is `holder`: this branch is + // past the ownership guard, so the opener owns the document the inbox belongs to. + await recordInbox(inbox, holder); if (store) { try { await registerUpdate( diff --git a/packages/polyfill/src/emulated-verifier/caps.ts b/packages/polyfill/src/emulated-verifier/caps.ts index 5917c18..b881273 100644 --- a/packages/polyfill/src/emulated-verifier/caps.ts +++ b/packages/polyfill/src/emulated-verifier/caps.ts @@ -270,6 +270,22 @@ export class CapRegistry { return this.heldCaps().get(targetOf(nuri)); } + /** + * Does the NAMED holder hold `nuri`'s cap? The reading counterpart of {@link learnFor}, + * and it exists for the same caller: work decided for one holder that runs while ANOTHER + * one is connected — the emulated inbox processor + * (`emulated-verifier/inbox-processor.ts`), which drains an inbox on behalf of its owner + * during someone else's session. Asking `capFor` there would consult the connected + * identity's ring, which is not the ring the question is about. + * + * Reads without creating a ring, unlike {@link heldCaps}: asking about a holder must not + * file one. Still no principal parameter in the model's sense — the question is "does + * THIS ring hold the key", never "may principal P read D". + */ + capForHolder(key: string, nuri: Nuri): ReadCap | undefined { + return this.heldByHolder.get(key)?.get(targetOf(nuri)); + } + // --- publication (the public store) ------------------------------------- /** diff --git a/packages/polyfill/src/emulated-verifier/inbox-processor.ts b/packages/polyfill/src/emulated-verifier/inbox-processor.ts new file mode 100644 index 0000000..5769d5f --- /dev/null +++ b/packages/polyfill/src/emulated-verifier/inbox-processor.ts @@ -0,0 +1,174 @@ +/** + * The inbox-processing SERVICE this deployment has not got — emulated by a timer. + * + * ── What is missing, and what stands in for it ──────────────────────────── + * Upstream a deposit does not wait for anybody: the broker routes the sealed message + * (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs:105,1677`) and the + * recipient's verifier applies it when it processes the inbox + * (`engine/verifier/src/inbox_processor.rs`). A real NextGraph deployment will have + * something running continuously that does this; today, in this library, the ONLY thing + * that drains an inbox is its owner connecting (`connect.connectedUser` → + * `inbox.processInbox`), which can be hours away. + * + * So: a successful deposit arms a ONE-SHOT timer that processes the TARGET's inbox, twenty + * seconds later. Alice deposits into Bob's inbox; twenty seconds later this package drains + * Bob's inbox, while the connected session is still Alice's. + * + * ── That is identity usurpation, and it is deliberate ───────────────────── + * It is only possible because one shared wallet holds every virtual identity, and it is + * acceptable for exactly one reason: an application learns nothing false from it. What an + * application observes is that **deposits converge** — a share becomes readable without the + * recipient having to re-open the page — and that stays true when a real service takes over. + * What must never leak is the MECHANISM: nothing here is published, there is no way for a + * caller to ask for it, to name another user's inbox, or to turn it off. Publishing any of + * that would publish a call that processes someone else's inbox, and the confidentiality of + * the whole shared-wallet emulation rests on that being unreachable. + * + * ── Its accepted limit, stated rather than papered over ─────────────────── + * A timer lives in a page. If the page goes away before it fires, the deposit simply waits + * for its owner's next connection — exactly where it waited before. There is deliberately + * no persistence, no retry and no longer window to hide that: a stand-in for an absent + * service should look like what it is, and the fallback (the owner connects and drains) is + * the real path, not a repair. + */ + +import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log"; +import type { Nuri } from "../model/types"; + +/** + * How long a deposit waits before the emulated service picks it up. + * + * Not configurable, on purpose. A knob would be a published question — *how fast does + * delivery converge?* — that the target does not ask a caller, because upstream the answer + * belongs to the deployment. An application must be written so that the number does not + * matter, and the surest way to keep it that way is to give nobody a way to change it. + */ +const PROCESSING_DELAY_MS = 20_000; + +/** A drain waiting to happen: the timer, and what it will do. */ +interface Scheduled { + handle: ReturnType; + process: () => Promise; +} + +/** + * At most ONE pending drain per inbox — the coalescing this map exists for. + * + * Several deposits inside the window must produce ONE drain, not several: processing writes + * what it applies (`branch-registers.addLink`, on the owner's User branch), so two runs over + * the same inbox race each other or apply the same Link twice. The FIRST deposit arms the + * window and later ones join it, rather than each pushing it further out — a busy inbox must + * still converge, not be starved by its own traffic. + */ +const scheduled = new Map(); + +/** + * Drains in flight, per inbox — so a run that starts while another is still going CHAINS + * behind it instead of interleaving with it. Same reason `connect.connectedUser` drains its + * inboxes sequentially: two concurrent passes over one queue apply the same records twice. + */ +const running = new Map>(); + +/** + * Keep a pending drain from holding a runtime open. + * + * A browser's `setTimeout` answers a number and has no `unref`; a Node/Bun runtime answers a + * timer object that does, and without it a process would sit for twenty seconds after its + * last deposit waiting on work nobody asked for. Probed rather than assumed, because the two + * platforms genuinely differ and this library runs on both. + */ +function releaseFromTheEventLoop(handle: ReturnType): void { + const maybe: { unref?: unknown } = handle as unknown as { unref?: unknown }; + if (typeof maybe.unref === "function") (maybe.unref as () => void).call(handle); +} + +/** + * Where a deferred drain's failure goes. + * + * It cannot throw: nothing awaits it — the application never asked for this work, and an + * unhandled rejection would take down whatever runtime it is in over a drain it did not + * request. But "nowhere" is not the alternative. A drain that swallows its failure is + * indistinguishable from one that succeeded, which is precisely the defect family removed + * from this package on 2026-08-13. So it goes to the package's own log stream — the same + * `console.error` with the same `[][polyfill]` prefix that `startConnect` and + * `inbox.watch` use for their un-awaitable failures, and it is NOT gated by the access-log + * flag: a diagnostic may be opt-in, a failure may not. + */ +function reportFailure(inbox: Nuri, error: unknown): void { + console.error( + accessLogPrefix() + " deferred inbox processing failed for " + shortNuri(inbox) + ":", + error, + ); +} + +/** Run the drain of `inbox`, behind any run of the same inbox still in flight. */ +function fire(inbox: Nuri, process: () => Promise): void { + scheduled.delete(inbox); + const previous = running.get(inbox) ?? Promise.resolve(); + const next = previous + // A previous run that FAILED must not cancel this one: its failure was reported where + // failures go, and the deposits it did not apply are exactly what this run is for. + .catch(() => undefined) + .then(process) + .catch((error: unknown) => reportFailure(inbox, error)) + .then(() => { + if (running.get(inbox) === next) running.delete(inbox); + }); + running.set(inbox, next); +} + +/** + * Arm the emulated service for `inbox` — called by a deposit that LANDED, and by nothing + * else. A write that failed has nothing to converge. + * + * `process` is what to do when the window closes; the scheduler itself knows nothing about + * inboxes' contents, so the drain stays where the inbox's own vocabulary lives. + * + * Idempotent inside the window: a second deposit into the same inbox joins the pending run. + */ +export function scheduleInboxProcessing(inbox: Nuri, process: () => Promise): void { + if (scheduled.has(inbox)) return; + const handle = setTimeout(() => fire(inbox, process), PROCESSING_DELAY_MS); + releaseFromTheEventLoop(handle); + scheduled.set(inbox, { handle, process }); +} + +/** + * Close every open window NOW and wait for the drains to finish. + * + * **The emulation's clock, not a door.** It cannot name an inbox, so it can only bring + * forward work a deposit has already armed — the same runs, at the same holder, with the + * same effects, minus the twenty seconds. That is what makes it usable by a suite that has + * to observe convergence without waiting for it, and useless as a way to reach anyone's + * inbox. Never exported from the package. + * + * Resolves rather than rejects, like the timer path it stands in for: a drain's failure is + * reported where failures go, and a caller of this is not the party that asked for the work. + */ +export async function runScheduledInboxProcessingNow(): Promise { + for (const [inbox, entry] of [...scheduled]) { + clearTimeout(entry.handle); + fire(inbox, entry.process); + } + await Promise.all([...running.values()]); +} + +/** + * Drop every pending drain without running it — what an un-configured library must do. + * + * Called by `resetConfig`: once the injected `ng` is gone, a drain cannot read or write + * anything, so leaving armed timers behind would produce a burst of failures about a + * session that no longer exists. In-flight runs are not cancelled (nothing can un-issue a + * write already sent); they end where they end. + */ +export function cancelScheduledInboxProcessing(): void { + for (const entry of scheduled.values()) clearTimeout(entry.handle); + scheduled.clear(); +} + +/** Trace one completed drain — diagnostics, so gated by the access-log flag. */ +export function traceProcessed(inbox: Nuri, holder: string, applied: number): void { + logStage( + "DEFERRED PROCESS " + shortNuri(inbox) + " for " + holder + " → " + applied + " link(s) applied", + ); +} diff --git a/packages/polyfill/src/emulated-verifier/open-repo.ts b/packages/polyfill/src/emulated-verifier/open-repo.ts index 258d123..09488a2 100644 --- a/packages/polyfill/src/emulated-verifier/open-repo.ts +++ b/packages/polyfill/src/emulated-verifier/open-repo.ts @@ -176,19 +176,26 @@ async function syncSession(): Promise { * Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g. * the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged. * Never throws; a failed open just leaves the read to behave as it did before. + * + * `holderKey` names WHOSE possession decides, when it is not the connected identity's — + * the emulated inbox processor opening an inbox repo for its owner during someone else's + * session (`emulated-verifier/inbox-processor.ts`). The public-store fetch is then skipped + * on purpose: it files what it obtains for whoever is CONNECTED, so asking on behalf of + * another holder would put a cap in the wrong ring, and a caller on that path has already + * established what its holder holds. */ -export async function ensureRepoOpen(nuri: Nuri): Promise { +export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise { if (!nuri) return; // A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on // the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK // before deciding whether we may touch it, or the answer would be "no" purely for // want of asking, and a bare reference to a public document would never suffice. // Memoised and inert once the cap is held (see public-store.ts). - await fetchReadCap(nuri); + if (holderKey === undefined) await fetchReadCap(nuri); // RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and // pulls its state. A user that holds no cap for it has no business asking. // (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.) - if (mustNotAttempt(nuri)) return; + if (mustNotAttempt(nuri, holderKey)) return; return openRepoUnguarded(nuri); } diff --git a/packages/polyfill/src/emulated-verifier/reach.ts b/packages/polyfill/src/emulated-verifier/reach.ts index 9276a0d..3eef759 100644 --- a/packages/polyfill/src/emulated-verifier/reach.ts +++ b/packages/polyfill/src/emulated-verifier/reach.ts @@ -60,12 +60,24 @@ import type { Nuri } from "../model/types"; * Inert until the first cap exists (`caps.isEnforcing()`), so a consumer that never * touches caps keeps working. Once ANY cap has been issued the boundary applies to * every user, including one holding nothing: that is the isolation. + * + * ── Whose possession, when it is not the connected identity's ───────────── + * `holderKey` names the ring to consult instead of the connected one. Exactly one caller + * passes it: the emulated inbox processor (`inbox-processor.ts`), which drains an inbox on + * behalf of its OWNER while another identity holds the session. Left out — which is every + * other call site — the question is asked of whoever is connected, unchanged. + * + * It does not WIDEN the boundary: the named holder must hold the cap just the same. What it + * fixes is which ring the question is about, so that work decided for one identity is not + * judged against another's possession. */ -export function mayReach(nuri: Nuri): boolean { +export function mayReach(nuri: Nuri, holderKey?: string): boolean { const caps = getCaps(); if (!caps.isEnforcing()) return true; const target = targetOf(nuri); - return caps.capFor(target) !== undefined; + const held = + holderKey === undefined ? caps.capFor(target) : caps.capForHolder(holderKey, target); + return held !== undefined; } /** @@ -77,11 +89,16 @@ export function mayReach(nuri: Nuri): boolean { * * Deliberately duplicated with rule 2 below — see {@link mustNotAttempt}. Two rules, * two places, one criterion: a lapse in either is caught by the other. + * + * `holderKey` asks the question of a NAMED ring rather than the connected one — see + * {@link mayReach}. The refusal names it too, or a processor's refusal would read as the + * connected identity's, which is the one thing nobody would then go and check. */ -export function assertMayReach(nuri: Nuri, op: string): void { - if (mayReach(nuri)) return; +export function assertMayReach(nuri: Nuri, op: string, holderKey?: string): void { + if (mayReach(nuri, holderKey)) return; + const who = holderKey === undefined ? "the connected user" : `holder ${JSON.stringify(holderKey)}`; throw new Error( - `[ng-eventually] ${op}: refused — the connected user does not hold this document's ` + + `[ng-eventually] ${op}: refused — ${who} does not hold this document's ` + "cap. Naming a document does not grant access to it: a cap is looked up in what " + `you hold, or it was delivered to you. ${JSON.stringify(nuri)}`, ); @@ -155,7 +172,9 @@ export async function assertMayWrite(nuri: Nuri, op: string): Promise { * * Practically it also stops the library from asking the broker for documents it has * no business asking about, which is work, noise, and a leak of intent. + * + * `holderKey` names the ring the question is about — see {@link mayReach}. */ -export function mustNotAttempt(nuri: Nuri): boolean { - return !mayReach(nuri); +export function mustNotAttempt(nuri: Nuri, holderKey?: string): boolean { + return !mayReach(nuri, holderKey); } diff --git a/packages/polyfill/src/emulated-verifier/register-write.ts b/packages/polyfill/src/emulated-verifier/register-write.ts index 156d16d..de8e89e 100644 --- a/packages/polyfill/src/emulated-verifier/register-write.ts +++ b/packages/polyfill/src/emulated-verifier/register-write.ts @@ -1,5 +1,10 @@ /** - * The one door for the library's OWN register writes. + * The doors the emulated verifier opens ON ITS OWN BEHALF — its register writes, the + * cross-user deposit, and the read it makes for a holder other than the connected one. + * + * All three are acts of the verifier rather than of an application, which is why they are + * not in `surface/docs.ts`: each treats the boundary differently from an application call, + * and every one of those differences would be a hole if an application could reach it. * * ── Why it is a module of its own, and not a function in `surface/docs.ts` ── * It lived there for about ten minutes on 2026-08-07, and the contract check caught it: @@ -30,19 +35,64 @@ import type { Nuri } from "../model/types"; * Still subject to `assertMayReach`: the register of a virtual user is that user's, and * the machinery writes it while connected as them. What this door skips is ownership, * nothing else. Never exported from the package. + * + * `holderKey` says WHOSE possession authorises the write when it is not the connected + * identity's — the emulated inbox processor filing a Link on the inbox owner's User branch + * during someone else's session. The guard still fires, against the named ring; see + * {@link assertMayReach}. */ export async function registerUpdate( sessionId: string | number, query: string, anchor: Nuri, label = "registerUpdate", + holderKey?: string, ): Promise { const { ng } = getConfig(); - assertMayReach(anchor, label); - logAccess("WRITE", anchor, label, " (register)"); + assertMayReach(anchor, label, holderKey); + logAccess("WRITE", anchor, label, " (register)" + forHolderSuffix(holderKey)); return ng.sparql_update(sessionId, query, anchor); } +/** The access-log suffix that makes an act done for someone OTHER than the connected + * identity visible in the trace — the log's prefix is the connected one, so without this + * a drain of Bob's inbox reads as Alice touching it for reasons of her own. */ +function forHolderSuffix(holderKey: string | undefined): string { + return holderKey === undefined ? "" : " (for holder " + holderKey + ")"; +} + +/** + * READ a document on behalf of a NAMED holder — the emulated verifier processing an inbox + * for its owner while a DIFFERENT identity holds the session + * (`emulated-verifier/inbox-processor.ts`). + * + * Not `docs.sparqlQuery`, and the difference is the whole point: that door resolves the + * CONNECTED identity when it consults the boundary, so a drain of Bob's inbox started + * during Alice's session would be judged against Alice's possession and refused — for a + * document Bob holds. This one asks the same question of Bob's ring instead. It is not a + * bypass: the named holder must hold the cap, and a holder that does not is refused exactly + * as an application would be. + * + * No public-store fetch, unlike `docs.sparqlQuery`: that fetch files what it obtains for + * whoever is CONNECTED (`public-store.fetchReadCap`), so asking here would put a cap in the + * wrong hands. A caller on this door has already established what its holder holds. + * + * Never exported from the package — it reads a document the connected identity may not. + */ +export async function readForHolder( + sessionId: string | number, + query: string, + anchor: Nuri, + holderKey: string, + label = "readForHolder", +): Promise { + const { ng } = getConfig(); + assertMayReach(anchor, label, holderKey); + const result = await ng.sparql_query(sessionId, query, undefined, anchor); + logAccess("READ", anchor, label, forHolderSuffix(holderKey)); + return result; +} + /** * Deposit into ANOTHER virtual user's inbox — the one write that legitimately * crosses the boundary, and therefore the one that skips {@link assertMayReach}. diff --git a/packages/polyfill/src/shared-wallet/account-registry.ts b/packages/polyfill/src/shared-wallet/account-registry.ts index 6fa8e00..4ec1b8f 100644 --- a/packages/polyfill/src/shared-wallet/account-registry.ts +++ b/packages/polyfill/src/shared-wallet/account-registry.ts @@ -125,6 +125,7 @@ export const P = { inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document exposedReadCap: `${SHIM}:exposedReadCap`, // header branch → the cap a PUBLIC store serves to anyone isInbox: `${SHIM}:isInbox`, // shim → this NURI IS an inbox (see `assertIsInbox`) + inboxOwner: `${SHIM}:inboxOwner`, // shim → WHOSE inbox this NURI is (see `recordInbox`) } as const; // Fixed subject of the per-(account×scope) index document. The index doc plays // the role of the future store-container: it lists the NURIs of the entity @@ -319,6 +320,8 @@ export function resetRegistryCache(): void { // suite went red on a NURI collision between two files — a memo that outlives its // session is exactly the fault this review found elsewhere in the tests.) knownInboxes.clear(); + // …and so is who owns which inbox: it is read from the same index, in the same session. + inboxOwners.clear(); } // --- SPARQL result helpers ------------------------------------------------ @@ -798,6 +801,20 @@ 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. * + * ── And WHOSE inbox it is, recorded beside it ───────────────────────────── + * Same table, same reason. Upstream the routing entry `inboxes: PubKey → RepoId` + * (`engine/verifier/src/verifier.rs:105`) does not merely say "this is an inbox": it names + * the REPO, and the repo's own verifier — its owner's — is what unseals and applies the + * message (`verifier.rs:1677`). "Which inbox" and "whose" are one fact there, so they are + * one record here. Written for BOTH kinds of inbox, and by the same call: a user's own + * ({@link userInbox}) and a document's (`branch-registers.openDocumentInbox`), each of + * which knows its owner at the moment it mints one. + * + * Two `INSERT DATA`s rather than one: the fact keyed by the index subject and the fact + * keyed by the inbox are two subjects, and one statement carrying two subjects is a shape + * this library writes nowhere else. Both must land before the inbox is handed back, for the + * reason spelled out below. + * * **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 @@ -807,7 +824,7 @@ const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`; * 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 { +export async function recordInbox(nuri: Nuri, ownerId: string): Promise { const s = await session(); const shimDoc = await resolveShimDoc(); try { @@ -817,15 +834,62 @@ export async function recordInbox(nuri: Nuri): Promise { shimDoc, "recordInbox", ); + // The inbox NURI is the subject here — the routing entry is keyed by the inbox, exactly + // as upstream's table is keyed by its pubkey. It is a NURI (`did:ng:` + base64url and + // `:` segments), so it carries nothing that could break out of the `<…>`. + await physicalUpdate( + s.sessionId, + `INSERT DATA { <${assertNuri(nuri)}> <${P.inboxOwner}> "${escapeLiteral(ownerId)}" }`, + shimDoc, + "recordInbox:owner", + ); } catch (error) { console.error(accessLogPrefix() + " recordInbox failed:", error); throw error; } knownInboxes.add(nuri); + inboxOwners.set(nuri, ownerId); } /** Inboxes this session has already confirmed — the index only ever grows. */ const knownInboxes = new Set(); +/** inbox → the id of the user it belongs to, as this session has resolved it. */ +const inboxOwners = new Map(); + +/** + * WHOSE inbox is this? The routing question, answered from the shim — see + * {@link recordInbox}. `null` when nothing records an owner for it, which for a NURI that + * IS an inbox means the record predates this association or its write did not land. + * + * Asked through the physical door, like {@link isKnownInbox} beside it: routing is not one + * virtual user's business, and the caller is precisely the one that is not the owner. + * + * **Propagates a failed read.** Its caller (`emulated-verifier/inbox-processor.ts`) decides + * on the answer whether to drain an inbox and, if so, for whom — so "the broker could not + * answer" and "nobody owns this" must not arrive as the same value. Answering `null` on a + * failed read would file a deposit under nobody and say nothing. + */ +export async function inboxOwner(nuri: Nuri): Promise { + const memo = inboxOwners.get(nuri); + if (memo !== undefined) return memo; + const s = await session(); + const shimDoc = await resolveShimDoc(); + const res = await physicalQuery( + s.sessionId, + `SELECT ?u WHERE { <${assertNuri(nuri)}> <${P.inboxOwner}> ?u }`, + undefined, + shimDoc, + "inboxOwner", + ); + for (const row of readBindings(res)) { + const id = bindingValue(row, "u"); + if (id) { + inboxOwners.set(nuri, id); + return id; + } + } + return null; +} /** * Is `nuri` an inbox? Asked of the shim, through the physical door — a depositor is not @@ -1031,7 +1095,7 @@ export async function userInbox(id: string, scope: InboxScope): Promise { // 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(); - await recordInbox(doc); + await recordInbox(doc, id); try { await physicalUpdate( s.sessionId, diff --git a/packages/polyfill/src/shared-wallet/bootstrap.ts b/packages/polyfill/src/shared-wallet/bootstrap.ts index df2d37e..bf52525 100644 --- a/packages/polyfill/src/shared-wallet/bootstrap.ts +++ b/packages/polyfill/src/shared-wallet/bootstrap.ts @@ -24,6 +24,7 @@ import { resetPublicStoreFetches } from "../emulated-verifier/public-store"; import { setAccessLog } from "./access-log"; import { inspectOutbox } from "./outbox-log"; import { startConnect } from "../emulated-verifier/connect"; +import { cancelScheduledInboxProcessing } from "../emulated-verifier/inbox-processor"; import { resetSharedWalletSession, sharedWalletSession } from "./session"; /** @@ -217,6 +218,10 @@ export function getConfig(): EventuallyConfig { export function resetConfig(): void { cfg = null; currentUser = null; + // Any deferred inbox drain still waiting goes with it: it would fire into a library that + // no longer has an `ng` to read or write with, and report a failure about a session that + // no longer exists. See `emulated-verifier/inbox-processor.ts`. + cancelScheduledInboxProcessing(); // The hand-over goes with it, for the same reason: it is made of the config's injected // `init`, so leaving it behind would let a revived barrier delegate to the PREVIOUS // application's SDK. diff --git a/packages/polyfill/src/surface/inbox.ts b/packages/polyfill/src/surface/inbox.ts index 45a8c38..a159785 100644 --- a/packages/polyfill/src/surface/inbox.ts +++ b/packages/polyfill/src/surface/inbox.ts @@ -27,12 +27,23 @@ */ import { sparqlQuery } from "./docs"; -import { depositInto } from "../emulated-verifier/register-write"; +import { depositInto, readForHolder } from "../emulated-verifier/register-write"; import { subscribeDoc } from "./subscribe"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers"; -import { userInbox, isKnownInbox, lookupAccount } from "../shared-wallet/account-registry"; +import { mintCap } from "../emulated-verifier/caps"; +import { + scheduleInboxProcessing, + traceProcessed, +} from "../emulated-verifier/inbox-processor"; +import { + accountKey, + inboxOwner, + userInbox, + isKnownInbox, + lookupAccount, +} from "../shared-wallet/account-registry"; import { escapeLiteral } from "./sparql"; import { hasReadCap, toNuri } from "../model/nuri"; import { @@ -112,6 +123,25 @@ function summarizePayload(payload: unknown): string { // --- SPARQL result helpers ------------------------------------------------ +/** + * The SELECT that materializes an inbox document's deposits. + * + * NO explicit `GRAPH <…>` clause — it reads the anchored DEFAULT graph (see the note in + * {@link post}). The anchor scopes the query to that repo's default graph, exactly where + * `post` writes. + * + * Shared by the two readers of an inbox — its owner ({@link read}) and the emulated + * processor draining it for that owner ({@link processForOwner}) — so the two cannot + * come to disagree about what a deposit looks like. + */ +const DEPOSITS_QUERY = ` + SELECT ?payload ?ts ?from WHERE { + ?d a <${P.type}> ; + <${P.payload}> ?payload ; + <${P.ts}> ?ts . + OPTIONAL { ?d <${P.from}> ?from } + }`; + /** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ function readBindings(result: unknown): Array> { if (!result) return []; @@ -198,6 +228,11 @@ export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promis } // A deposit crosses the boundary on purpose — see `register-write.depositInto`. await depositInto(sid, update, targetInbox, "deposit"); + // The deposit LANDED, so something has to pick it up. Upstream that is a service; here it + // is a one-shot timer on the TARGET's inbox — see `emulated-verifier/inbox-processor.ts` + // for what that emulates, what it usurps, and the limit it does not hide. Armed only on a + // write that succeeded: a deposit that never landed has nothing to converge. + scheduleInboxProcessing(targetInbox, () => processForOwner(targetInbox)); // Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log): // who deposited WHAT into which inbox — the decoded payload, not just the // triple-write. Gated by the same access-log flag; skip the JSON work when off. @@ -270,6 +305,29 @@ function capsSeenIn(inbox: Nuri): ReadCap[] { } +/** + * The deposits a raw SELECT result carries, sorted by `ts` ascending — the one place the + * stored shape is turned back into {@link Deposit}s, for both readers of an inbox. + */ +function depositsFrom(result: unknown): Deposit[] { + const deposits: Deposit[] = []; + for (const row of readBindings(result)) { + const rawPayload = row.payload?.value ?? "null"; + let payload: unknown; + try { + payload = JSON.parse(rawPayload); + } catch { + payload = rawPayload; // tolerate a non-JSON literal + } + const tsRaw = row.ts?.value ?? "0"; + const ts = Number.parseInt(tsRaw, 10) || 0; + const fromValue = row.from?.value; + deposits.push({ from: fromValue ? fromValue : null, payload, ts }); + } + deposits.sort((a, b) => a.ts - b.ts); + return deposits; +} + /** The cap a deposit carries, if it is a Link rather than consumer data. */ function capOfPayload(payload: unknown): ReadCap | null { const p = payload as { kind?: unknown; cap?: unknown } | null; @@ -441,32 +499,8 @@ export async function read(targetInboxLike: NuriLike): Promise { // race the watch's own initial-`State` delivery. Keeping `read` a pure anchored // read leaves both callers correct: the watch path stays event-driven, and the // cold direct-read path opens the repo explicitly before calling `read`. - // NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the - // note in `post`). The anchor (`targetInbox`) scopes the query to that repo's - // default graph, exactly where `post` writes. - const query = ` - SELECT ?payload ?ts ?from WHERE { - ?d a <${P.type}> ; - <${P.payload}> ?payload ; - <${P.ts}> ?ts . - OPTIONAL { ?d <${P.from}> ?from } - }`; - const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead"); - const deposits: Deposit[] = []; - for (const row of readBindings(result)) { - const rawPayload = row.payload?.value ?? "null"; - let payload: unknown; - try { - payload = JSON.parse(rawPayload); - } catch { - payload = rawPayload; // tolerate a non-JSON literal - } - const tsRaw = row.ts?.value ?? "0"; - const ts = Number.parseInt(tsRaw, 10) || 0; - const fromValue = row.from?.value; - deposits.push({ from: fromValue ? fromValue : null, payload, ts }); - } - deposits.sort((a, b) => a.ts - b.ts); + const result = await sparqlQuery(sid, DEPOSITS_QUERY, undefined, targetInbox, "inboxRead"); + const deposits = depositsFrom(result); // Links are infrastructure, not consumer data: they never reach the caller. They // are only KEPT here (in memory, for this session) — FILING them durably is // `processInbox`'s job, because reading an inbox must not quietly write to a @@ -581,6 +615,86 @@ export async function processInbox(targetInboxLike: NuriLike): Promise { + // WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see + // `account-registry.recordInbox`). Everything below is decided for this identity. + const ownerId = await inboxOwner(targetInbox); + if (ownerId === null) { + throw new Error( + "[ng-eventually] deferred inbox processing: the shim records no owner for this inbox, " + + "so there is nobody to process it for — its deposits wait for its owner to connect: " + + JSON.stringify(targetInbox), + ); + } + // The ring the owner's own session would use — one rule for both, see + // `branch-registers.holderRing`. + const ownerRing = accountKey(ownerId); + // `lookupAccount`, not `resolveAccount`: this decides whether to drain at all, so a read + // that could not ANSWER must not arrive looking like "that user does not exist". + const record = await lookupAccount(ownerId); + if (record === null) { + throw new Error( + "[ng-eventually] deferred inbox processing: the owner recorded for this inbox has no " + + `account — nothing can be filed for them: ${JSON.stringify(ownerId)}`, + ); + } + const caps = getCaps(); + // What the owner's OWN verifier holds by construction: the inbox it is about to process, + // and the private store where a Link is filed. Both are what `fileOwnInbox` / + // `fileOwnStructure` put in that ring when the owner connects — the same two facts, named + // rather than resolved. Nothing new is granted: a holder that may not reach a document is + // still refused by `readForHolder` below. + // + // ONE visible consequence, and it is deliberate: filing a cap fires the registry's change + // signal, which is global rather than per-holder, so a `watchShape` open in the connected + // session re-reads. It re-reads the SAME data — the connected identity gains nothing — + // and the signal cannot be suppressed for the other case this same path serves: an owner + // draining an inbox on their own document, where re-reading is exactly the contract + // (`caps.onChange`). + caps.learnFor(ownerRing, mintCap(targetInbox)); + if (record.docPrivate) caps.learnFor(ownerRing, mintCap(record.docPrivate)); + + const sid = await sessionId(); + await ensureRepoOpen(targetInbox, ownerRing); + const deposits = depositsFrom( + await readForHolder(sid, DEPOSITS_QUERY, targetInbox, ownerRing, "inboxProcess"), + ); + let applied = 0; + for (const deposit of deposits) { + const cap = capOfPayload(deposit.payload); + if (cap === null) continue; // consumer data — not this service's to consume + // In memory first, then durably: the same order and the same two acts as + // `read` + `processInbox`, with the holder named instead of resolved. + caps.learnFor(ownerRing, cap); + await addLink(cap, ownerId); + applied += 1; + } + traceProcessed(targetInbox, ownerRing, applied); +} + /** * Subscription over an inbox — **event-driven, not polled**. Subscribes to the * inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push): diff --git a/packages/polyfill/test/deferred-inbox-processing.test.ts b/packages/polyfill/test/deferred-inbox-processing.test.ts new file mode 100644 index 0000000..9afc88e --- /dev/null +++ b/packages/polyfill/test/deferred-inbox-processing.test.ts @@ -0,0 +1,530 @@ +/** + * The inbox-processing service this deployment has not got, emulated by a timer. + * + * A deposit used to sit in an inbox until its owner next connected — hours, on a page + * nobody reloads. `emulated-verifier/inbox-processor.ts` arms a one-shot timer on the + * TARGET's inbox instead, so the deposit converges while the depositor's session is still + * the one in the page. That is identity usurpation, and every test here is about the two + * things that make it acceptable: the result lands in the OWNER's space and nowhere else, + * and the depositor's session comes out of it exactly as it went in. + * + * ── What the actors hand each other, and what they do not ───────────────── + * Nobody is handed an inbox address. Each depositor calls `inbox.share(doc, toUser)` and + * names a document and a person, which is all an application has; the address is resolved + * inside. The one NURI the TEST resolves for itself (`userInbox`) is used only to count + * reads and to inspect what landed — never given to an actor. + * + * ── Time is closed, not slept through ──────────────────────────────────── + * `runScheduledInboxProcessingNow()` fires the windows a deposit has ALREADY armed — the + * same runs, the same holder, the same effects, minus the twenty seconds. It cannot name an + * inbox, so it fabricates nothing: every state asserted below is one the timer produces on + * its own. + */ +import { test, expect, mock, afterEach } from "bun:test"; +import * as polyfill from "../src/index"; +import { configure } from "../src/index"; +import { + configureStoreRegistry, + getCaps, + resetCaps, + resetConfig, + resetStoreRegistry, + setCurrentUser, + getCurrentUser, +} from "../src/shared-wallet/bootstrap"; +import { + createEntityDoc, + resetRegistryCache, + resolveAccount, + resolveWriteGraph, + userInbox, +} from "../src/shared-wallet/account-registry"; +import type { RegistrySession } from "../src/shared-wallet/account-registry"; +import { connectedUser } from "../src/emulated-verifier/connect"; +import { + cancelScheduledInboxProcessing, + runScheduledInboxProcessingNow, +} from "../src/emulated-verifier/inbox-processor"; +import { resetOpenedRepos } from "../src/emulated-verifier/open-repo"; +import { share } from "../src/surface/inbox"; +import { readUnion } from "../src/surface/read-model"; +import { sparqlUpdate } from "../src/surface/docs"; +import type { Nuri } from "../src/model/types"; + +const SESSION: RegistrySession = { sessionId: "sid-deferred", privateStoreId: "PRIV-DEFERRED" }; +const SHIM = "urn:ng-eventually:shim"; +const INBOX = "urn:ng-eventually:inbox"; +const SECRET = "urn:e2e:secret"; + +interface Quad { g: string; s: string; p: string; o: string } + +/** What the broker refuses to do. Mutable after `inject`, so a test builds a healthy world + * first and breaks only the one call it is about — the fault is the broker's, never a + * reach into the library to make one of its own functions reject. */ +interface Faults { + /** The anchored read of THIS inbox document's deposits throws. */ + depositsRead: Nuri | null; +} + +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`: the shim SPARQL, the inbox SPARQL, and the anchored per-doc read. */ +function makeFakeNg(faults: Faults) { + const quads: Quad[] = []; + let docCounter = 0; + + const doc_create = mock(async () => `did:ng:o:doc${++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; + const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/); + if (del) { + const [s0, p0] = [del[1]!, del[2]!]; + for (let i = quads.length - 1; i >= 0; i--) { + const q = quads[i]!; + if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1); + } + return undefined; + } + 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 pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g; + let m: RegExpExecArray | null; + const after = body.slice(body.indexOf(sm[0]) + sm[0].length); + 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 sparql_query = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const anchor = a[3] as string | undefined; + const byPred = (pred: string, v: string) => ({ + results: { + bindings: quads + .filter((q) => q.g === anchor && q.p === pred) + .map((q) => ({ [v]: { value: q.o } })), + }, + }); + if (query.includes(`<${SHIM}:shimDoc>`)) return byPred(`${SHIM}:shimDoc`, "shimDoc"); + if (query.includes(`<${SHIM}:id>`)) { + 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 ?? "" }, + })), + }, + }; + } + if (query.includes(`<${INBOX}:payload>`)) { + // The one failure this fake can inject, at the platform boundary: the anchored read + // of an inbox document throws, exactly as the engine hard-errors for a repo it + // cannot resolve (`RepoNotFound`, `engine/verifier/src/request_processor.rs:264`). + if (faults.depositsRead !== null && faults.depositsRead === anchor) { + 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; + }), + }, + }; + } + if (query.includes(`<${SHIM}:inboxCap>`)) return byPred(`${SHIM}:inboxCap`, "c"); + if (query.includes(`<${SHIM}:inboxAddress>`)) return byPred(`${SHIM}:inboxAddress`, "a"); + if (query.includes(`<${SHIM}:readCap>`)) return byPred(`${SHIM}:readCap`, "c"); + if (query.includes(`<${SHIM}:link>`)) return byPred(`${SHIM}:link`, "c"); + if (query.includes(`${SHIM}:isInbox`)) return byPred(`${SHIM}:isInbox`, "i"); + // Shim `inboxOwner` SELECT — WHOSE inbox a NURI is, the emulated routing entry the + // deferred service resolves its holder from. Keyed by the INBOX (the subject), as + // upstream's `inboxes: PubKey → RepoId` is keyed by the inbox's pubkey. + if (query.includes(`<${SHIM}:inboxOwner>`)) { + const sm = query.match(/<([^>]+)>\s+/); + const subj = sm ? sm[1]! : null; + return { + results: { + bindings: quads + .filter((q) => q.g === anchor && q.p === `${SHIM}:inboxOwner` && q.s === subj) + .map((q) => ({ u: { value: q.o } })), + }, + }; + } + 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 } })), + }, + }; + } + if (query.includes(`<${SHIM}:exposedReadCap>`)) return byPred(`${SHIM}:exposedReadCap`, "c"); + if (query.includes(`<${SHIM}:contains>`)) return byPred(`${SHIM}:contains`, "e"); + 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_update, sparql_query, quads }; +} + +let fake: ReturnType; +let faults: Faults; + +function inject() { + faults = { depositsRead: null }; + fake = makeFakeNg(faults); + configure({ ng: fake as never, useShape: (() => {}) as never }); + configureStoreRegistry({ getSession: async () => SESSION }); + resetRegistryCache(); + resetOpenedRepos(); + resetCaps(); + setCurrentUser(null); +} + +afterEach(() => { + cancelScheduledInboxProcessing(); + resetConfig(); + resetStoreRegistry(); + resetCaps(); + resetRegistryCache(); + resetOpenedRepos(); + setCurrentUser(null); +}); + +/** + * Bob has been in the page once — which is what makes him someone a share can NAME + * (`inbox.share` refuses a recipient nobody has ever signed in as). Nothing of his crosses + * to the depositors afterwards: they name the person, never his address. + */ +async function bobSignsInOnce(): Promise { + setCurrentUser("bob"); + await resolveWriteGraph("bob", "protected"); + setCurrentUser(null); +} + +/** Write one triple into `doc`, as the 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"); +} + +/** The values `p` carries in `docs`, as the CURRENT holder reads them. */ +async function readValues(docs: Nuri[], p: string): Promise { + const subjects = await readUnion(docs); + return subjects.flatMap((s) => s.props[p] ?? []); +} + +/** How many times an inbox document's deposits have been read — one per drain. */ +function depositReadsOf(inbox: Nuri): number { + return fake.sparql_query.mock.calls.filter( + (c) => c[3] === inbox && String(c[1]).includes(`${INBOX}:payload`), + ).length; +} + +/** The Links durably filed on a user's User branch — the emulated `AddLink` records. */ +async function linksFiledFor(id: string): Promise { + const record = await resolveAccount(id); + const store = record?.docPrivate; + if (!store) return []; + return fake.quads.filter((q) => q.g === store && q.p === `${SHIM}:link`).map((q) => q.o); +} + +// --- the drain acts for the OWNER, and touches nothing of the depositor's --- + +test("a deposit is drained for the inbox's OWNER, while the depositor still holds the session", async () => { + inject(); + await bobSignsInOnce(); + + // Carol shares one of her documents with Bob. Her deposit is in Bob's inbox, unapplied. + setCurrentUser("carol"); + const carolDoc = await createEntityDoc("carol", "protected"); + await write(carolDoc, SECRET, "carol's content"); + await share(carolDoc, "bob"); + + // Alice, later, shares one of hers with Bob too. Neither of them is ever handed an + // address: `share` names a document and a person. + setCurrentUser("alice"); + const aliceDoc = await createEntityDoc("alice", "protected"); + await write(aliceDoc, SECRET, "alice's content"); + await share(aliceDoc, "bob"); + + // Before the window closes: nothing has been applied for anyone. + const caps = getCaps(); + expect(caps.capForHolder("bob", carolDoc)).toBeUndefined(); + expect(caps.capForHolder("bob", aliceDoc)).toBeUndefined(); + + // The service picks the deposits up. Alice is the connected identity throughout. + await runScheduledInboxProcessingNow(); + + // The session is untouched — and this is the assertion that matters, because the drain + // read an inbox holding a Link for CAROL's document. Had it filed against whoever was + // connected, Alice would now hold a capability nobody gave her. + expect(getCurrentUser()).toBe("alice"); + expect(caps.capForHolder("alice", carolDoc)).toBeUndefined(); + expect(await readValues([carolDoc], SECRET)).toEqual([]); + expect(await linksFiledFor("alice")).toEqual([]); + + // …and both Links landed in Bob's space instead. + expect(caps.capForHolder("bob", carolDoc)).toBeDefined(); + expect(caps.capForHolder("bob", aliceDoc)).toBeDefined(); + expect((await linksFiledFor("bob")).length).toBe(2); +}); + +test("what the drain filed for the owner is DURABLE: he restores it with his inbox emptied", async () => { + inject(); + await bobSignsInOnce(); + + setCurrentUser("alice"); + const aliceDoc = await createEntityDoc("alice", "protected"); + await write(aliceDoc, SECRET, "the-protected-content"); + await share(aliceDoc, "bob"); + await runScheduledInboxProcessingNow(); + + // EMPTY the inbox, as a consumed queue would be, and drop every in-memory cap — so what + // Bob recovers below can only have come from his own User branch, where the drain filed + // it. Without that, he would simply be draining the queue himself on connection, which + // is the behaviour this service exists to get ahead of. + const bobInbox = await userInbox("bob", "protected"); + for (let k = fake.quads.length - 1; k >= 0; k--) { + if (fake.quads[k]!.g === bobInbox) fake.quads.splice(k, 1); + } + resetCaps(); + setCurrentUser("alice"); + await createEntityDoc("alice", "private"); // re-arms the emulation: a cap exists again + + setCurrentUser("bob"); + expect(getCaps().capFor(aliceDoc)).toBeUndefined(); // he holds nothing yet + await connectedUser(); + expect(getCaps().capFor(aliceDoc)).toBeDefined(); + expect(await readValues([aliceDoc], SECRET)).toEqual(["the-protected-content"]); +}); + +// --- coalescing -------------------------------------------------------------- + +test("several deposits inside the window produce exactly ONE drain, and lose nothing", async () => { + inject(); + await bobSignsInOnce(); + + setCurrentUser("alice"); + const docs: Nuri[] = []; + for (const marker of ["one", "two", "three"]) { + const doc = await createEntityDoc("alice", "protected"); + await write(doc, SECRET, marker); + docs.push(doc); + } + const bobInbox = await userInbox("bob", "protected"); + const readsBefore = depositReadsOf(bobInbox); + + for (const doc of docs) await share(doc, "bob"); + // The window is open, not running: depositing processes nothing by itself. + expect(depositReadsOf(bobInbox)).toBe(readsBefore); + + await runScheduledInboxProcessingNow(); + + // ONE pass over the queue for three deposits. Two passes would race each other on the + // very writes the pass makes (`addLink`, on the owner's User branch). + expect(depositReadsOf(bobInbox)).toBe(readsBefore + 1); + // …and coalescing loses nothing: the one pass reads the whole queue. + for (const doc of docs) expect(getCaps().capForHolder("bob", doc)).toBeDefined(); + expect((await linksFiledFor("bob")).length).toBe(3); +}); + +test("a deposit after the window has closed arms a NEW one", async () => { + inject(); + await bobSignsInOnce(); + + setCurrentUser("alice"); + const first = await createEntityDoc("alice", "protected"); + const second = await createEntityDoc("alice", "protected"); + const bobInbox = await userInbox("bob", "protected"); + const readsBefore = depositReadsOf(bobInbox); + + await share(first, "bob"); + await runScheduledInboxProcessingNow(); + await share(second, "bob"); + await runScheduledInboxProcessingNow(); + + expect(depositReadsOf(bobInbox)).toBe(readsBefore + 2); + expect(getCaps().capForHolder("bob", second)).toBeDefined(); +}); + +// --- failure ---------------------------------------------------------------- + +test("a drain that fails says so in the package's log, and rejects into nobody", async () => { + inject(); + await bobSignsInOnce(); + + setCurrentUser("alice"); + const doc = await createEntityDoc("alice", "protected"); + await write(doc, SECRET, "content"); + const bobInbox = await userInbox("bob", "protected"); + + // The deposit itself must still succeed — what breaks is the drain that follows it. + await share(doc, "bob"); + faults.depositsRead = bobInbox; + + const errors: string[] = []; + const realError = console.error; + console.error = ((...args: unknown[]) => { + errors.push(args.map((a) => String(a)).join(" ")); + }) as typeof console.error; + try { + // Resolves. The application never asked for this work, so it must not be handed a + // rejection for it — and an unhandled one would take the runtime down. + await expect(runScheduledInboxProcessingNow()).resolves.toBeUndefined(); + } finally { + console.error = realError; + } + + const reported = errors.filter((line) => /deferred inbox processing failed/.test(line)); + expect(reported.length).toBe(1); + // Prefixed by the CONNECTED identity, like every other polyfill-layer line — which is + // what makes a drain running under someone else's session legible in a live trace. + expect(reported[0]).toContain("[alice][polyfill]"); + expect(reported[0]).toContain("RepoNotFound"); + + // Nothing was applied on a drain that could not read, and nothing was applied for the + // depositor either: a failure leaves the deposit where it was, waiting for its owner. + expect(getCaps().capForHolder("bob", doc)).toBeUndefined(); + expect(await linksFiledFor("alice")).toEqual([]); +}); + +test("an inbox the shim records no owner for is reported, not drained for whoever is connected", async () => { + inject(); + await bobSignsInOnce(); + + setCurrentUser("alice"); + const doc = await createEntityDoc("alice", "protected"); + await write(doc, SECRET, "content"); + const bobInbox = await userInbox("bob", "protected"); + await share(doc, "bob"); + + // A wallet written before inboxes carried a routing entry — the record simply is not + // there. Drained for the connected identity, this is exactly how a capability addressed + // to Bob ends up in Alice's hands, so it has to refuse and say so. + for (let k = fake.quads.length - 1; k >= 0; k--) { + if (fake.quads[k]!.p === `${SHIM}:inboxOwner`) fake.quads.splice(k, 1); + } + resetRegistryCache(); // …and the session's memo of it goes too + + const errors: string[] = []; + const realError = console.error; + console.error = ((...args: unknown[]) => { + errors.push(args.map((a) => String(a)).join(" ")); + }) as typeof console.error; + try { + await runScheduledInboxProcessingNow(); + } finally { + console.error = realError; + } + + expect(errors.some((l) => /records no owner for this inbox/.test(l))).toBe(true); + expect(getCaps().capForHolder("alice", doc)).toBeDefined(); // hers, she created it + expect(await linksFiledFor("alice")).toEqual([]); // and nothing of Bob's was filed for her + expect(depositReadsOf(bobInbox)).toBe(0); // the queue was never even read +}); + +// --- the surface ------------------------------------------------------------ + +test("nothing about the deferred service reaches the published surface", () => { + // The published entry, as an application really sees it at runtime. A "process now", a + // delay knob, or any way to name another user's inbox would show up here — and each of + // them would publish a call that processes somebody else's queue, which is the one thing + // the shared-wallet emulation cannot survive. + expect(Object.keys(polyfill).sort()).toEqual([ + "configure", + "docChangeType", + "docs", + "ensureIdentity", + "inbox", + "init", + "initNg", + "ng", + "readUnion", + "storeRegistry", + "subscribeDoc", + "subscribeDocs", + "useShape", + "watchShape", + ]); + expect(Object.keys(polyfill.inbox).sort()).toEqual([ + "post", + "postToDocument", + "processInbox", + "read", + "readForDocument", + "readSynced", + "share", + "watch", + ]); + expect(Object.keys(polyfill.docs).sort()).toEqual([ + "docCreate", + "sparqlQuery", + "sparqlUpdate", + ]); + expect(Object.keys(polyfill.storeRegistry).sort()).toEqual([ + "createEntityDoc", + "listMyEntityDocs", + "openDocumentInbox", + "resolveScopeGraph", + "resolveWriteGraph", + ]); +});