/** * storeRegistry — resolves (account, scope) → document NURI. * * Stopgap / polyfill-era. Emulates the target infrastructure — where each * user owns their own public/protected/private stores — on top of one shared * wallet. It creates one document per (account × scope) inside that shared * wallet (via `physical.physicalCreate` — the UNGUARDED primitive, since a store is * machinery and its cap is filed here, where it is known whose it is; the public * `docs.docCreate` files the creator's cap itself), so the `scope` * (`public|protected|private`) is a logical attribute tracked here, not a * physical NextGraph store. Isolation is enforced by the app layer + the * emulated cap registry, not by crypto. * * The mapping (account → its 3 document NURIs) is the **sharedWalletShim**. It * is persisted as RDF, but NOT in the store-root graph anymore — see the * indirection below. That makes login cross-device: another device opening the * same wallet reads the same shim and finds the same virtualUsers. * * ── The indirection: pointer (store-root) → doc-shim (subscribable) ───────── * On the real NextGraph platform "findable-without-lookup" and "subscribable" * are DISJOINT (verified at the source, see docs/nextgraph-current-state.md * § *Findable vs subscribable*): * - the ONLY NURI a fresh session can name WITHOUT a lookup is the store-root * `did:ng:${privateStoreId}` — but a store-root has NO first-`State` sync * BARRIER, so a cold "0 rows" on it is AMBIGUOUS (could be sync-lag, could * be truly empty); * - the ONLY thing that DOES have a first-`State` barrier is a `did:ng:o:` * doc from `doc_create` — but its RepoID is RANDOM, so a fresh session * cannot GUESS it; it must be looked up. * So a purely-barrier shim resolution is impossible: you cannot have a doc that * is both guessable and authoritative on a cold read. The indirection bridges * this: a well-known, write-ONCE **pointer** triple in the store-root names a * **doc-shim** (`did:ng:o:...`) that holds all account records and IS * subscribable. Resolution reads the pointer (a single oldest write-once triple, * near-always synced), then opens the doc-shim through its `ensureRepoOpen` * BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent). * * A pointer FORK (two devices writing the pointer before either synced) is * reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every * device converges on the SAME doc-shim. This is why the OLD account-level retry * (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now * barrier-authoritative, so it never needs to be retried to distinguish sync-lag * from absence. A micro-guard remains ONLY on the pointer read (one write-once * triple) — see resolvePointer. * * ── Generic by construction ────────────────────────────────────────────── * This module knows only the three native scopes; it knows no application * entity kind. The consumer maps its entities to a scope and calls * `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native * scope. No application domain here. * * ── What disappears at migration ───────────────────────────────────────── * At the real multi-store migration the shim vanishes entirely: `(account, * scope)` maps to the user's REAL store NURI instead of a document in the * shared wallet, `docCreate` targets the real per-user store, and the * per-scope index document (the store-container emulation) is replaced by the * store itself. The consumer-facing surface (`createEntityDoc`, * `listEntityDocs`, resolvers) is designed to survive that swap unchanged. * * All NextGraph I/O routes through the T01.a `docs` primitive (real injected * `ng`), so this module imports **no** `@ng-org` package. */ import { sparqlUpdate, sparqlQuery } from "../surface/docs"; import { physicalCreate, physicalQuery, physicalUpdate } from "./physical"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./bootstrap"; // Cross-fate edge, deliberate: resolving a user is also when its own structure gets // filed, and creating a document is when its cap does. See branch-registers.ts. import { fileOwnStructure, fileOwnInbox, holdOwnCap, publishInboxAddress, readStoreCaps, readInboxCapsFor, ownsDocument, documentInboxAddress, } from "../emulated-verifier/branch-registers"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { ensurePhysicalRepoOpen, subscribePhysicalDoc } from "./physical"; import { escapeLiteral, escapeIri, assertNuri } from "../surface/sparql"; import { hasReadCap, isNuri } from "../model/nuri"; import { mintCap } from "../emulated-verifier/caps"; import { mustNotAttempt } from "../emulated-verifier/reach"; import { accessLogPrefix, logStage, shortNuri } from "./access-log"; import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types"; // --- sharedWalletShim model ---------------------------------------------- /** * A NURI as read back from the shim, where `""` means "absent or corrupt". * * The empty case is NOT new — `canonicalDoc` has always returned `""` for a missing * field, and callers have always had to test for it — but with {@link Nuri} typed it * stops hiding inside a `string`. It is kept confined to the shim-reading functions * below: `VirtualUserRecord` still promises real NURIs, because a record with an empty * scope document is a corrupt record, not a valid state to spread through the API. * Tightening that (reject the record rather than let it flow) is a change of * behaviour and belongs to its own lot — see `recordFromRows`. */ type MaybeNuri = Nuri | ""; /** One account's three scope-document NURIs, as recorded in the shim. */ export interface VirtualUserRecord { id: string; docPublic: Nuri; docProtected: Nuri; docPrivate: Nuri; } const SHIM = "urn:ng-eventually:shim"; export const P = { type: `${SHIM}:Account`, id: `${SHIM}:id`, docPublic: `${SHIM}:docPublic`, docProtected: `${SHIM}:docProtected`, docPrivate: `${SHIM}:docPrivate`, contains: `${SHIM}:contains`, // scope-index → entity document NURI docInbox: `${SHIM}:docInbox`, // (user, inboxScope) → ITS inbox document link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document } 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 // documents (one per entity) that live "in" that scope. const MAIN_BRANCH_SUBJECT = `${SHIM}:index`; /** * Fixed subject of the **User branch** emulation, inside a user's PRIVATE store * document. Upstream, `AddLink { read_cap }` is committed to the User branch of the * private store — *"so that a user can share with all its device a new Link they * received"*, and *"only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`). * That is where a cap received from someone else durably lives. * * We have no branches, so the compartment is a distinct SUBJECT in the same * document, kept separate from `MAIN_BRANCH_SUBJECT` (which emulates the store's Main * branch, the `ldp:contains` listing). Two compartments, two subjects — the * separation upstream makes with two branches. */ export const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`; /** * Fixed subject of the **Store branch** emulation, inside a user's store document. * * `doc_create` upstream writes TWICE (`engine/verifier/src/request_processor.rs:697-710`): * `ldp:contains` on the store's **Main** branch — the listing — and * `AddRepo { read_cap }` on its **Store** branch — the key. Two branches, two * purposes, deliberately separate; replaying the Store branch is what reloads a * store's documents WITH their caps (`AddRepo::verify` → `load_repo_from_read_cap`). * * We have no branches, so this is a distinct SUBJECT beside {@link MAIN_BRANCH_SUBJECT} * in the same document — the same shape already used for {@link USER_BRANCH_SUBJECT}. * * **Honest about the emulation**: upstream the Store branch carries NO triples at all * (`BranchCrdt::None`, `engine/repo/src/types.rs:1420`) — it is a stream of service * commits. Representing it as RDF is our invention; what is faithful is *that the cap * is stored beside the document rather than recomputed*, and that the listing and the * keys stay separate. */ export const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`; /** * Fixed subject of the **Header branch** emulation, inside an ENTITY document — the * first compartment we put in a document the consumer also reads, hence the filter in * `read-model.ts` (every `${SHIM}:` subject is machinery and never surfaces as data). * * It carries what must be readable by *whoever can read the document*, as opposed to * what belongs to its owner alone. Today that is one thing: the ADDRESS of the * document's inbox. * * Why the address must live here and not on the owner's User branch. Upstream an inbox * is a KEYPAIR (`repo.inbox: Option`, `engine/repo/src/repo.rs:126`) and the * two halves have opposite audiences: a depositor seals with the PUBLIC key * (`InboxMsg::new` → `crypto_box::seal(&to_inbox.to_dh_slice(), …)`, * `engine/net/src/types.rs:4299`) and needs nothing else; only the owner holds the * private half (`AddInboxCap`, on the User branch). An address is therefore public by * nature — upstream it travels with the profile (`ContactDetails` carries * `ng:site_inbox` / `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:823`). * Keeping it only on the owner's User branch, as this lib first did, made the deposit * side unreachable: a third party had no way to learn where to deposit. * * **Not `BranchType::Header` upstream.** That branch exists (`engine/repo/src/types.rs:1551`) * but is CLOSED: `update_header` writes only `title`/`about` * (`engine/verifier/src/request_processor.rs:173-211`) and `fetch_header` reads back * only `title`/`about`/`class` (`:1240-1284`). It cannot carry an inbox address. The * name is borrowed for the shape — a compartment of the document that is not its * content — not for the upstream branch's contract. */ export const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`; // --- pointer (store-root → doc-shim indirection) -------------------------- // // The pointer is a SINGLE well-known triple written ONCE into the store-root // graph on the very first login, then IMMUTABLE. Its object is the NURI of the // doc-shim (a `did:ng:o:...` repo) where all AccountRecords actually live. The // store-root is NOT subscribable (no first-`State` barrier), but the pointer is // the OLDEST triple in that graph and is write-once, so it is near-always synced // on a cold read — and even a transient miss is bounded by a small guard // (resolvePointer), NOT by an account-level retry. const POINTER_SUBJECT = `${SHIM}:root`; const POINTER_PRED = `${SHIM}:shimDoc`; function accountSubject(id: string): string { // The id is UNTRUSTED and lands in an IRI position. Percent-encode it // (escapeIri) so no `>` / `"` / whitespace / control char can break out of // the `<...>` and inject triples into the shim graph (the account→doc trust // root). accountKey() runs first so the subject stays stable per shim key. return `${SHIM}:account:${escapeIri(accountKey(id))}`; } // --- reserved accounts ----------------------------------------------------- // // Some accounts are internal to the lib (e.g. the discovery index owner) and // must NOT collide with any user-chosen id. A reserved account is created // via {@link reservedAccount}, which marks the name with a sentinel PREFIX that // `normalizeId` (consumer-injected) can never produce: it strips a leading // `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved // keys therefore live in a disjoint namespace from every normalized id — // a real user named "index"/"@index" can never resolve to the reserved // `reservedAccount("index")` account. const RESERVED_PREFIX = "\u0000reserved:"; /** * Wrap an internal account name so it occupies a key that no user input can * produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount} * (and the other registry calls) instead of a bare id. */ export function reservedAccount(name: string): string { return `${RESERVED_PREFIX}${name}`; } /** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */ function isReserved(id: string): boolean { return id.startsWith(RESERVED_PREFIX); } /** * The shim/cache key for an account. Reserved accounts bypass `normalizeId` * entirely and key on their sentinel-prefixed name, so they cannot collide with * a normalized id; everyone else normalizes as usual. */ export function accountKey(id: string): string { return isReserved(id) ? id : normalize(id); } // --- session / normalization access (injected by the consumer) ------------ /** Minimal session shape the registry needs — provided by the consumer. */ export interface RegistrySession { sessionId: string; /** The shared wallet's private store id — the pointer anchor. */ privateStoreId: string; /** The shared wallet's protected store id (native store). Optional: only the * scope resolvers need it; the shim only needs the private anchor. */ protectedStoreId?: string; /** The shared wallet's public store id (native store). Optional. */ publicStoreId?: string; } function normalize(id: string): string { const key = getStoreRegistryDeps().normalizeId(id); // The reserved namespace's whole guarantee is that no user id can land in it, and // that guarantee is NOT ours to make: `normalizeId` is injected by the consumer // application, and the library's own default only trims — nothing stops a caller // from passing an id that already starts with the sentinel. A collision here is not // a cosmetic clash: a user would key onto an infrastructure account and read or // write documents that are not theirs. So it is checked rather than assumed. if (isReserved(key)) { throw new Error( "[ng-eventually] account-registry: `normalizeId` produced a key inside the " + `reserved namespace, which no user id may occupy: ${JSON.stringify(key)}`, ); } return key; } export async function session(): Promise { return getStoreRegistryDeps().getSession(); } /** The pointer lives in the shared wallet's private STORE-ROOT graph (the only * always-known-without-lookup anchor). NOT subscribable — hence the pointer is * a write-once triple, and the actual account records live in the doc-shim it * names (see rootNuri vs the doc-shim). */ async function rootNuri(): Promise { const s = await session(); return `did:ng:${s.privateStoreId}`; } // --- cache ---------------------------------------------------------------- // Per-account cache, keyed by account key. Populated by the TARGETED resolver // (resolveAccount) and by loadShim(). Independent of `cache` so a single // targeted resolve never forces a full shim scan. Both are cleared together. const accountCache = new Map(); // The resolved doc-shim NURI for the current session (cached: the pointer read + // barrier open happen once, then every account read reuses this doc). Cleared on // resetRegistryCache / wallet switch. let shimDocNuri: Nuri | null = null; // De-dupe concurrent pointer-resolutions so a fresh page firing many parallel // ensureAccount/resolveAccount calls opens the doc-shim exactly once. let shimDocInFlight: Promise | null = null; /** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ export function resetRegistryCache(): void { accountCache.clear(); inboxCache.clear(); inboxInFlight.clear(); shimDocNuri = null; shimDocInFlight = null; } // --- SPARQL result helpers ------------------------------------------------ /** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ export function readBindings(result: unknown): Array> { if (!result) return []; const anyRes = result as { results?: { bindings?: Array> }; }; if (Array.isArray(result)) return result as Array>; if (anyRes.results?.bindings) return anyRes.results.bindings; return []; } export function bindingValue(row: Record, key: string): string { return row[key]?.value ?? ""; } /** * DETERMINISTIC resolution of an account's scope docs from a set of SPARQL * bindings (all bindings for ONE account subject). * * ── Why this is load-bearing (residual fork residue) ─────────────────────── * A corrupted shim can carry the SAME account subject with MULTIPLE values for a * scope predicate (e.g. 5 `shim:docPublic`) — the residue of past account FORKS * (each stray provision appended another doc NURI). A query then returns several * bindings (the cross-product of the duplicate values). Picking `rows[0]` is * NON-DETERMINISTIC (binding order is not stable across sessions), so the session * that WROTE an entity into one docPublic and a later fresh page that RESOLVED a * DIFFERENT docPublic would disagree → the anchored `readUserStore` returns 0 → * the home reads empty. When both happen to pick the same doc, it "works". * * The fix: for each scope field, collect EVERY distinct value across the bindings * and choose the SAME one every time — the lexicographically-smallest NURI. NURIs * are content-addressed and stable, so lexicographic order is a total, stable, * session-independent order: writer and reader converge on the SAME canonical doc * even on a wallet already corrupted by duplicates. (No creation timestamp is * recorded in the shim, so lexicographic-min is the available deterministic key.) * * With the barrier-authoritative doc-shim read, account FORKS no longer occur (a * fresh page reads the doc-shim through its first-`State` barrier, so a cold 0 is * definitive and never triggers a fork-provision). `canonicalDoc` is RETAINED to * stay robust against the residue of PAST forks already persisted in a wallet, and * to reconcile a benign pointer fork the same content-addressed way. */ function canonicalDoc(rows: Array>, key: string): MaybeNuri { let chosen: MaybeNuri = ""; const distinct = new Set(); for (const row of rows) { const v = bindingValue(row, key); // The SPARQL boundary: a binding is an untrusted string. Narrowing here (rather // than casting) also discards a value that is not a NextGraph reference at all — // shim corruption that used to flow straight through as a "document NURI". if (!v || !isNuri(v)) continue; distinct.add(v); if (chosen === "" || v < chosen) chosen = v; } // Stage trace: which doc got picked, and out of how many DISTINCT candidate // values — >1 flags residual fork residue (see the module doc above) even // when resolution still converges correctly on the canonical (smallest) one. logStage( "canonicalDoc(" + key + ") → " + (chosen ? shortNuri(chosen) : "none") + " (" + distinct.size + (distinct.size === 1 ? " candidate)" : " candidates)"), ); return chosen; } /** Build an VirtualUserRecord by picking the canonical (lexicographically-smallest) * doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */ function recordFromRows( rows: Array>, fallbackId: string, ): VirtualUserRecord { let id = ""; for (const row of rows) { const v = bindingValue(row, "id"); if (v) { id = v; break; } } // The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises // real NURIs; a shim missing a scope document yields `""` here, exactly as it // always has, and the cast records that this is a KNOWN gap rather than a proven // invariant. Callers already test for the empty value (e.g. `watchShape` skips a // falsy container). Rejecting such a record outright would be the right fix and is // a behaviour change — its own lot, not this one. return { id: id || fallbackId, docPublic: canonicalDoc(rows, "docPublic") as Nuri, docProtected: canonicalDoc(rows, "docProtected") as Nuri, docPrivate: canonicalDoc(rows, "docPrivate") as Nuri, }; } // --- pointer resolution + doc-shim bootstrap ------------------------------ const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); /** * Read the pointer(s) from the store-root graph → the canonical doc-shim NURI, or * `""` if no pointer exists yet. * * The store-root is NOT subscribable, so this read has no first-`State` barrier: * a cold 0 is ambiguous. But the pointer is ONE write-once triple (the OLDEST in * that graph), so it is near-always synced. The ONLY residual guard is a small * bounded re-read here (NOT an account retry): a handful of quick re-reads of that * single triple. It is bounded, benign, and — crucially — it can never re-provision * an account or fork data; the worst it can do is take a couple extra reads to see a * pointer that is still landing. The account records themselves are read * authoritatively through the doc-shim barrier, never through this guard. * * If MULTIPLE pointers exist (a pointer fork: two devices each wrote a pointer to * their own freshly-created doc-shim before either synced), reconcile to the * canonical (lexicographically-smallest) doc-shim NURI — content-addressed and * stable, so every device converges on the SAME doc-shim. */ async function resolvePointer(): Promise { const s = await session(); const root = await rootNuri(); // COLD-START heal: open the store-root repo before the anchored read, so a fresh // wallet whose store-root isn't yet in `self.repos` resolves instead of throwing // `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no // barrier, so this open cannot make the read authoritative — the guard below does. await ensurePhysicalRepoOpen(root); const query = ` SELECT ?shimDoc WHERE { GRAPH <${assertNuri(root)}> { <${POINTER_SUBJECT}> <${POINTER_PRED}> ?shimDoc . } }`; // Micro-guard (POINTER only): a small bounded re-read to bridge the store-root // sync-lag window on the ONE write-once pointer triple. Bounded, and it can only // ever DELAY seeing an existing pointer — never provision, never fork. Uses the // injected pointerGuard budget (defaults to a single read when unset, so unit // fakes stay synchronous). NB this is NOT the deleted account-level provisionRetry. const budget = getStoreRegistryDeps().pointerGuard; const attempts = Math.max(1, budget.attempts ?? 1); const baseMs = budget.baseMs ?? 150; const maxStepMs = budget.maxStepMs ?? 2000; let step = baseMs; for (let i = 0; i < attempts; i++) { try { const result = await physicalQuery(s.sessionId, query, undefined, root, "resolvePointer"); const doc = canonicalDoc(readBindings(result), "shimDoc"); if (doc) { logStage("resolvePointer → 1 target: " + shortNuri(doc)); return doc; } } catch (error) { console.error(accessLogPrefix() + " resolvePointer failed:", error); } if (i < attempts - 1) { await sleep(step); step = Math.min(step * 2, maxStepMs); } } logStage("resolvePointer → 0 targets"); return ""; } /** Write the pointer (store-root → doc-shim), once, at first login. Idempotent in * practice (only called when no pointer was found); a concurrent double-write is * reconciled by canonicalDoc on read. */ async function writePointer(doc: Nuri): Promise { const s = await session(); const root = await rootNuri(); await ensurePhysicalRepoOpen(root); const update = ` INSERT DATA { GRAPH <${assertNuri(root)}> { <${POINTER_SUBJECT}> <${POINTER_PRED}> <${assertNuri(doc)}> . } }`; try { await physicalUpdate(s.sessionId, update, root, "writePointer"); } catch (error) { console.error(accessLogPrefix() + " writePointer failed:", error); } } /** Create one graph document in the shared wallet's private store (→ a NURI). */ export async function createDoc(): Promise { const s = await session(); // crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store", // store_repo=undefined → shared wallet's private store. return physicalCreate(s.sessionId); } /** * Resolve (or on first login, create) the doc-shim NURI for this session — the * `did:ng:o:...` repo that holds every VirtualUserRecord and IS subscribable. * * Steps (cached; runs at most once per session, concurrent callers share one): * 1. Read the pointer from the store-root (resolvePointer). If present → that is * the doc-shim; open it through its first-`State` BARRIER so subsequent account * reads are AUTHORITATIVE. * 2. No pointer → FIRST login: * a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the * session (`self.repos`) — so it is already open/synced in-session; * b. publish the pointer (store-root → the new doc-shim), once; * c. open it (barrier — trivially satisfied for a just-created in-session repo). * The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim); * on first-login creation it is a no-op, so the pointer is published first. */ async function resolveShimDoc(): Promise { if (shimDocNuri) return shimDocNuri; if (shimDocInFlight) return shimDocInFlight; const p = (async (): Promise => { const existing = await resolvePointer(); if (existing) { // Open the doc-shim through its first-`State` barrier BEFORE any account read, // so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag. await ensurePhysicalRepoOpen(existing); shimDocNuri = existing; logStage("resolveShimDoc → " + shortNuri(existing)); return existing; } // FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish // the pointer, then open (no-op barrier for a just-created repo). const doc = await createDoc(); await writePointer(doc); await ensurePhysicalRepoOpen(doc); shimDocNuri = doc; logStage("resolveShimDoc → " + shortNuri(doc)); return doc; })(); shimDocInFlight = p; try { return await p; } finally { shimDocInFlight = null; } } // --- shim load / account bootstrap ---------------------------------------- /** * Resolve ONE account by its shim key with a BOUNDED query — O(1), independent * of the number of accounts in the shim. This is the HOT-PATH lookup: it hits * the account record at its known subject (`accountSubject(id)`) directly in the * doc-shim, instead of scanning EVERY account like {@link loadShim}. Returns the * account's record or `null` if it does not exist yet. * * ── Barrier-AUTHORITATIVE (the reconnection fix) ──────────────────────────── * The read targets the DOC-SHIM (`did:ng:o:...`), which resolveShimDoc opened * through its first-`State` barrier. So a cold 0 rows here is DEFINITIVE ("account * genuinely absent"), not ambiguous sync-lag — no account-level retry is needed or * used. This is what replaced the old `resolveAccountReliably` / `provisionRetry` * loop: the store-root ambiguity that forced the retry is gone once the read moves * behind the doc-shim barrier. * * Cached per account (in `accountCache`); a hit skips the query entirely, so * repeated resolves of the same account are free. `resetRegistryCache` clears it. */ export async function resolveAccount(id: string): Promise { const key = accountKey(id); const cached = accountCache.get(key); if (cached) return cached; const s = await session(); const doc = await resolveShimDoc(); // `subj` is already IRI-safe (accountSubject → escapeIri). The read is anchored to // the doc-shim's default graph (opened through its barrier by resolveShimDoc), so // it is authoritative. The query is bounded to this one subject. const subj = accountSubject(id); const query = ` SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE { <${subj}> a <${P.type}> ; <${P.id}> ?id ; <${P.docPublic}> ?docPublic ; <${P.docProtected}> ?docProtected ; <${P.docPrivate}> ?docPrivate . }`; try { const result = await physicalQuery(s.sessionId, query, undefined, doc, "resolveAccount"); const rows = readBindings(result); if (rows.length === 0) { logStage("resolveAccount(" + key + ") → null"); return null; } // DETERMINISTIC: a corrupted shim may return SEVERAL bindings for this one // account subject (duplicate scope-doc values from past forks). Pick the // canonical (lexicographically-smallest) doc per scope so writer and reader // always resolve the SAME docPublic (robustness against PAST fork residue). const record = recordFromRows(rows, id); accountCache.set(key, record); logStage("resolveAccount(" + key + ") → 1 record"); return record; } catch (error) { console.error(accessLogPrefix() + " resolveAccount failed:", error); return null; } } /** Persist one VirtualUserRecord into the doc-shim (anchored default-graph write, the * canonical always-safe shape — same convention as createEntityDoc). */ async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise { const s = await session(); const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`; // `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position → // escapeLiteral. The doc NURIs come from `ng` but are stored as literals here, so // they are escaped as literals too (defence in depth). NO explicit `GRAPH <…>` // wrapper: write the anchored DEFAULT graph (the `doc` anchor scopes it) — the // canonical, always-safe shape the anchored default-graph read queries match. const update = ` INSERT DATA { <${subj}> a <${P.type}> ; <${P.id}> "${escapeLiteral(record.id)}" ; <${P.docPublic}> "${escapeLiteral(record.docPublic)}" ; <${P.docProtected}> "${escapeLiteral(record.docProtected)}" ; <${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" . }`; try { await physicalUpdate(s.sessionId, update, doc, "writeRecord"); } catch (error) { console.error(accessLogPrefix() + " writeRecord persist failed:", error); } } /** * In-flight `ensureAccount` promises, keyed by account key — so CONCURRENT * `ensureAccount(id)` calls for the SAME account share ONE resolve-or-provision. * * ── Why this is load-bearing (concurrent-provision de-dup) ────────────────── * On a fresh page over the persistent wallet, SEVERAL independent callers hit * `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container * subscriptions, the app's owned-events effect). With the barrier-authoritative * resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the * account is GENUINELY new, N concurrent callers would still each see 0 and each * provision a set of scope docs (an in-session fork). De-duping concurrent provisions * collapses those N into ONE: the first caller resolves-or-provisions; every * concurrent caller awaits the SAME promise and gets the SAME record. Not polling: a * bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant * it settles. */ const ensureInFlight = new Map>(); /** * Ensure an account exists in the shim, creating its 3 scope documents on * first sight. Idempotent — returns the existing record if already present. * Concurrency-safe: concurrent calls for the same account share one provision * (see {@link ensureInFlight}) so a fresh page never FORKS the account. */ export async function ensureAccount(id: string): Promise { const key = accountKey(id); // A completed provision/resolve is cached → no query, no fork risk. const cached = accountCache.get(key); if (cached) { fileOwnStructure(id, cached); return cached; } // A concurrent provision for the SAME account is already running → await it, // instead of racing a second (forking) provision. This is the anti-fork guard. const pending = ensureInFlight.get(key); if (pending) return pending; const p = (async (): Promise => { // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead // of a full-shim scan (loadShim). Off the read/write hot path entirely. // // Barrier-AUTHORITATIVE: resolveAccount reads the doc-shim behind its first-`State` // barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY // absent — not sync-lag. No account-level retry: the store-root ambiguity that // forced the old provisionRetry loop is gone once the read moves behind the barrier. const existing = await resolveAccount(id); if (existing) { fileOwnStructure(id, existing); return existing; } const doc = await resolveShimDoc(); const [docPublic, docProtected, docPrivate] = await Promise.all([ createDoc(), createDoc(), createDoc(), ]); const record: VirtualUserRecord = { id, docPublic, docProtected, docPrivate }; // Persist the record INTO the doc-shim (not the store-root anymore). await writeRecord(doc, record); // Feed the per-account cache, and the full-shim cache if it is already loaded // (so allAccounts / the fan-out see the freshly-created account too). accountCache.set(key, record); fileOwnStructure(id, record); return record; })(); ensureInFlight.set(key, p); try { return await p; } finally { ensureInFlight.delete(key); } } // --- resolvers ------------------------------------------------------------ /** The index document NURI of an account for a scope (the store-container). */ export function storeOf(record: VirtualUserRecord, scope: Scope): Nuri { return scope === "public" ? record.docPublic : scope === "protected" ? record.docProtected : record.docPrivate; } /** * NURI of the document where `id` writes GROUPED entities of `scope` (a single * per-scope index document, for entities that need no per-entity document / no * inbox). For per-entity scopes use {@link createEntityDoc} instead. */ export async function resolveWriteGraph(id: string, scope: Scope): Promise { const record = await ensureAccount(id); return storeOf(record, scope); } // --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ---------- // // The consumer asks by SCOPE ("give me the graph to write/read entities of // scope X", "give me the inbox anchor") and NEVER constructs a `did:ng:${…}` // store NURI itself. The lib owns the physical placement — which is the whole // point of the SDK boundary. In THIS polyfill the placement is the shared // wallet's native stores (Axis A, per the two-axes doctrine in // docs/simulation.md): a scope maps to a native store NURI resolved from the // injected session. `public` currently co-locates with `protected` because // `doc_create`/ORM cannot target a non-private/protected native store today // (the SDK blocker recorded in migration-guide.md); at migration each scope // resolves to the user's REAL per-scope store and this mapping changes here, // in the lib, with no consumer change. /** The native store NURI backing `scope`, resolved from the injected session. * Requires `protectedStoreId` on the session for the non-private scopes. */ async function scopeStoreNuri(scope: Scope): Promise { const s = await session(); if (scope === "private") return `did:ng:${s.privateStoreId}`; // public + protected → the protected native store (see note above). Falls // back to the private store if the session didn't carry a protected id. const store = s.protectedStoreId ?? s.privateStoreId; return `did:ng:${store}`; } /** * The graph NURI where the current session WRITES entities of `scope`, and * whose repo `useShape` must subscribe to read them back. SDK-shaped: the * consumer passes a logical scope and gets an opaque graph NURI — it holds no * store-id and builds no NURI. Use the returned value as both the read scope * (`useShape(shape, nuri)`) and the `@graph` write target. */ export async function resolveScopeGraph(scope: Scope): Promise { return scopeStoreNuri(scope); } /** * In-flight `userInbox` resolutions, keyed by account key — so concurrent callers * for the SAME wallet share ONE resolve-or-create instead of racing two documents * into existence (mirrors {@link ensureInFlight}). */ const inboxInFlight = new Map>(); /** Resolved wallet inboxes, keyed by account key. Cleared with the registry cache. */ const inboxCache = new Map(); /** * The inbox of a virtual user — where caps and messages addressed to THEM arrive. * * **Renamed from `walletInbox` on 2026-08-03, and the old name was wrong twice.** * A *wallet* upstream is only a keyring; what owns stores — and therefore what an inbox * belongs to — is a **user** (a *site*): `SensitiveWalletV0.sites: HashMap` * (`engine/wallet/src/types.rs:456`), `SiteV0` carrying `public`/`protected`/`private` * (`engine/verifier/src/site.rs:31-37`). The library had corrected that vocabulary * everywhere else and this name survived the pass — and it did cost: reasoning about * "the inbox per wallet" hid that upstream a user has **two**. * * **Known divergence, deliberate.** Upstream a user has TWO inboxes, one on its public * store repo and one on its protected store repo — the only two `AddInboxCap` commits in * the engine (`engine/verifier/src/site.rs:128,149`; `new_store_default` attaches one * only `if !private`, `engine/verifier/src/verifier.rs:2994`). They are distinguished * right down to the predicates a contact record uses (`ng:site_inbox` vs * `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:374-375`). This function * exposes ONE. Collapsing them is a simplification this library has not yet had a reason * to undo; the day a caller needs to address a user's public inbox distinctly from its * protected one, this is the seam that has to split in two. * * The NURI of a virtual user's OWN inbox — where deposits addressed to that * identity land, ReadCaps among them. * * ── Why a wallet owns an inbox, and why that is load-bearing ─────────────── * You cannot discover in NextGraph; you can only follow links. So a link crosses * from one wallet to another through exactly one channel: a deposit into the * recipient's inbox. That makes the inbox the **bootstrap of the whole * reachability graph** rather than a side feature — and it is why an inbox has to * BELONG to someone. Before this existed, an inbox was any NURI a caller passed, * so "read the inbox" meant "read anyone's inbox", and since P1a routes caps * through it, reading someone else's collected the caps addressed to them. * * Created on first sight and stable thereafter. Recorded in the doc-shim under its * own predicate, read by its OWN query rather than added to the account SELECT: an * account record written before this existed must keep resolving, which it would * not if the fixed account pattern grew a fourth required field. * * Concurrency-safe (see {@link inboxInFlight}), and a fork is reconciled the same * content-addressed way as everything else ({@link canonicalDoc}). * * ── TWO inboxes, because upstream a user has two ───────────────────────── * A *site* carries an inbox on its **public** store repo and another on its * **protected** one — the only two `AddInboxCap` commits in the engine * (`engine/verifier/src/site.rs:127-152`), `new_store_default` attaching one solely * `if !private` (`engine/verifier/src/verifier.rs:2994`). They are addressed * separately right down to the contact records, which pick their predicate from the * profile being reached: `ng:site_inbox` for a public profile, `ng:protected_inbox` * otherwise (`engine/verifier/src/inbox_processor.rs:787,823-824`). * * This function used to expose ONE, which was a cardinality this library invented. * Corrected 2026-08-03 by walking the cascade: neither the JS ORM nor the wasm binding * says anything about inboxes — `@ng-org/web` has no method containing "inbox" and the * session exposes none — so the engine's model is what decides, and it says two. * * **The private store has none**, hence {@link InboxScope} rather than `Scope`: asking * for a private inbox is not a lookup that returns nothing, it is a question the model * has no meaning for. * * At migration these become the site's native store inboxes and the resolution moves * there — the consumer-facing act (deposit to an inbox, process my own) is unchanged. */ export async function userInbox(id: string, scope: InboxScope): Promise { const key = `${accountKey(id)}\u0000${scope}`; const cached = inboxCache.get(key); if (cached) { fileOwnInbox(id, cached); return cached; } const pending = inboxInFlight.get(key); if (pending) return pending; const p = (async (): Promise => { const s = await session(); const shimDoc = await resolveShimDoc(); await ensureAccount(id); // the account must exist before it can own an inbox const subj = accountSubject(id); // One triple per (user, scope): the two inboxes are distinct documents, as the two // store repos that carry them are distinct upstream. const pred = `${P.docInbox}:${scope}`; try { // The doc-shim is machinery: this reads WHICH inbox a virtual user owns, // which is exactly the kind of question that cannot be confined to that user. const res = await physicalQuery( s.sessionId, `SELECT ?d WHERE { <${subj}> <${pred}> ?d }`, undefined, shimDoc, "userInbox", ); const existing = canonicalDoc(readBindings(res), "d"); if (existing) { inboxCache.set(key, existing); fileOwnInbox(id, existing); return existing; } } catch (error) { console.error(accessLogPrefix() + " userInbox read failed:", error); } const doc = await createDoc(); fileOwnInbox(id, doc); try { await physicalUpdate( s.sessionId, `INSERT DATA { <${subj}> <${pred}> "${escapeLiteral(doc)}" }`, shimDoc, "userInbox", ); } catch (error) { console.error(accessLogPrefix() + " userInbox persist failed:", error); } inboxCache.set(key, doc); logStage("userInbox(" + key + "/" + scope + ") → " + shortNuri(doc)); return doc; })(); inboxInFlight.set(key, p); try { return await p; } finally { inboxInFlight.delete(key); } } /** * Create a dedicated document for ONE entity — mirrors the target, where each * such entity is its own document/repo (addressable, future inbox). The new * document's NURI is appended to the account's scope index document (the * store-container). Returns the entity document NURI (use it as `@graph`) — a * CAP-LESS reference, exactly like `doc_create` upstream: it names the document, * it does not carry its key. The key goes to what the creator holds (see * {@link holdOwnCap}), which is where you look it up. */ export async function createEntityDoc(id: string, scope: Scope): Promise { const record = await ensureAccount(id); const indexDoc = storeOf(record, scope); const entityNuri = await createDoc(); const s = await session(); try { await sparqlUpdate( s.sessionId, // NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the // `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the // anchored default-graph read queries (readUserStore below, same as // read-model.ts). Not a round-trip necessity on the current broker: the e2e // harness (`packages/client/e2e/`) verified an anchored `GRAPH ` // write ALSO round-trips here (same repo graph, no phantom graph); no-GRAPH // is kept as a simplicity/safety convention. entityNuri is a NURI stored as // a literal → escapeLiteral. `INSERT DATA { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`, indexDoc, "createEntityDoc", ); } catch (error) { console.error(accessLogPrefix() + " createEntityDoc index append failed:", error); } // The second write: `AddRepo { read_cap }` on the Store branch. A separate // statement, not a second triple in the one above, because upstream these are two // commits on two branches — and because the cap must be recoverable even if the // listing write failed. // // One literal suffices: a ReadCap CARRIES its document (`targetOf`), so storing the // cap stores the pair. const cap = mintCap(entityNuri); try { await sparqlUpdate( s.sessionId, `INSERT DATA { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> "${escapeLiteral(cap)}" }`, indexDoc, "createEntityDoc:addRepo", ); } catch (error) { console.error(accessLogPrefix() + " createEntityDoc cap append failed:", error); } // …and the creator holds THAT cap for this session. holdOwnCap(id, scope, entityNuri, cap); // NO inbox here, and NOT the owner's own inbox published as this document's address. // Upstream an inbox belongs to exactly ONE repo: the verifier routes an incoming // message by `inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`) // and unseals it with THAT repo's private half, while `InboxMsgBody` carries no // target document at all (`engine/net/src/types.rs:4265`) — because it needs none, // the address IS the identification. Pointing several documents at one inbox would // emulate a many-to-one relation the model cannot express, and would teach consumers // to tag deposits with their document, a habit that has to be unlearned at migration. // // So a document gets an inbox only when its owner opens one // ({@link openDocumentInbox}), which is also what keeps the cost proportional: only // documents meant to RECEIVE pay for one (see // `docs/briefs/2026-08-03-document-inbox-addressing.md`). return entityNuri; } /** Read the entity-document NURIs contained in ONE scope index document. */ export async function readUserStore(indexDoc: Nuri): Promise { const s = await session(); const out: Nuri[] = []; // COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the // scope-index repo (public OR protected — the protected one carries participations // and is the one that most often reads empty) is not yet in `self.repos`, so this // anchored read would return 0 NURIs → nothing gets listed → nothing gets // subscribed (the self-inflicted circularity). Open/subscribe the index repo ONCE // before reading it. Idempotent per session; no-op with the unit fake ng. See // open-repo.ts. await ensureRepoOpen(indexDoc); try { const res = await sparqlQuery( s.sessionId, // NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see // the note in createEntityDoc). The `indexDoc` anchor scopes the query. `SELECT ?e WHERE { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> ?e }`, undefined, indexDoc, "readUserStore", ); for (const row of readBindings(res)) { // SPARQL boundary again (see canonicalDoc): narrow, do not cast — a stored // value that is not a NextGraph reference is not an entity document. const v = bindingValue(row, "e"); if (v && isNuri(v)) out.push(v); } } catch (error) { console.error(accessLogPrefix() + " readUserStore failed:", error); } logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities"); return out; } /** * The scope-INDEX document NURI of ONE account (`id`) for `scope` — the * store-container document that LISTS the account's per-entity document NURIs * (what {@link listMyEntityDocs} reads). Exposed so a reactive reader * ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the * entity-doc set when the index changes (a new entity created appends a NURI * here). Idempotent via `ensureAccount`'s cache. At migration this becomes the * user's real per-scope store NURI (the container the store itself provides). */ export async function userStoreDoc(id: string, scope: Scope): Promise { const record = await ensureAccount(id); return storeOf(record, scope); } /** * The entity-document NURIs of `scope` belonging to ONE account (`id`) — * the read-by-need path for one account's own entities. Bounded to a SINGLE * account: it resolves only that account's scope index doc (via `ensureAccount`) * and reads the contained NURIs — NO cross-account fan-out, so it never touches * another account's unsynced docs. This is the helper a consumer application uses * for its own my-entities path, instead of the all-accounts `listEntityDocs`. */ export async function listMyEntityDocs(id: string, scope: Scope): Promise { const record = await ensureAccount(id); const store = storeOf(record, scope); const docs = await readUserStore(store); // Recover the caps by READING the Store branch, never by recomputing them from // the NURIs — that is the whole point of storing them. A fresh session gets back // exactly what was recorded, and the day the stand-in value becomes a real key // (P1b) this path needs no change at all. // // Scoped to the current holder: another user's store caps are not ours to hold. const holder = getCurrentUser(); if (holder !== null && accountKey(holder) === accountKey(id)) { const caps = getCaps(); for (const cap of await readStoreCaps(store)) caps.learn(cap); // A `public` store's documents are also published links — the publication fact // lives in the registry, not in the store, so it is re-applied here. if (scope === "public") for (const d of docs) caps.recordInPublicStore(d); } return docs; }