/** * 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 accounts. * * ── 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 "./docs"; import { physicalCreate, physicalQuery, physicalUpdate } from "./physical"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill"; import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo"; import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import { hasReadCap, isNuri, mintCap } from "./nuri"; import { mustNotAttempt } from "./reach"; import { accessLogPrefix, logStage, shortNuri } from "./access-log"; import type { Nuri, ReadCap, Scope } from "./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: `AccountRecord` 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 AccountRecord { id: string; docPublic: Nuri; docProtected: Nuri; docPrivate: Nuri; } const SHIM = "urn:ng-eventually:shim"; 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`, // account → ITS OWN 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. */ 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. */ 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. */ 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. */ 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 { return getStoreRegistryDeps().normalizeId(id); } 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. */ 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 []; } 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 AccountRecord 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, ): AccountRecord { 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. `AccountRecord` 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). */ 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 AccountRecord 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 AccountRecord into the doc-shim (anchored default-graph write, the * canonical always-safe shape — same convention as createEntityDoc). */ async function writeRecord(doc: Nuri, record: AccountRecord): 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: AccountRecord = { 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). */ function storeOf(record: AccountRecord, 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 `walletInbox` 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 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}). * * At migration this becomes the identity's native inbox and the resolution moves * here — the consumer-facing act (deposit to an inbox, process my own) is unchanged. */ export async function walletInbox(id: string): Promise { const key = accountKey(id); 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); 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}> <${P.docInbox}> ?d }`, undefined, shimDoc, "walletInbox", ); const existing = canonicalDoc(readBindings(res), "d"); if (existing) { inboxCache.set(key, existing); fileOwnInbox(id, existing); return existing; } } catch (error) { console.error(accessLogPrefix() + " walletInbox read failed:", error); } const doc = await createDoc(); fileOwnInbox(id, doc); try { await physicalUpdate( s.sessionId, `INSERT DATA { <${subj}> <${P.docInbox}> "${escapeLiteral(doc)}" }`, shimDoc, "walletInbox", ); } catch (error) { console.error(accessLogPrefix() + " walletInbox persist failed:", error); } inboxCache.set(key, doc); logStage("walletInbox(" + key + ") → " + shortNuri(doc)); return doc; })(); inboxInFlight.set(key, p); try { return await p; } finally { inboxInFlight.delete(key); } } /** * Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the * inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false * for everyone until an identity is set. */ export async function isOwnInbox(nuri: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) return false; if ((await walletInbox(holder)) === nuri) return true; // …and the inbox of any document this user opened one on (the emulated // `AddInboxCap` records on its User branch). return (await readInboxCapPairs()).some((p) => p.inbox === nuri); } // --- the cap side of a user's store ---------------------------------- /** * File the caps of documents the CURRENT holder owns into what they hold — the * emulated `AddRepo { read_cap }`. * * Upstream, creating a document commits an `AddRepo { read_cap }` into a typed * branch of the store, and that branch — listing the store's documents, each with * its read key — carries the owner's caps. Here the per-(account × scope) index * document plays the store-container role, so it carries the caps too: a * document appended to it on creation, or read back from it on a later session, * puts its cap in the owner's hands with nothing for the consumer to do. That is * what makes the invariant hold both ways — you never derive a cap from a bare * reference, and yet a document's own creator is never locked out of it. * * Scoped to the current holder ON PURPOSE: another account's documents are listed * by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically * not ours to hold. `id` is compared through the shim key, so it matches however * the consumer spells the identity. */ function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; const caps = getCaps(); // `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was // written to the Store branch, not a second one minted from the NURI. They agree // today only because the stand-in value is a constant; with a real key (P1b) a // second mint would produce a DIFFERENT key and the document would be unreadable // by the very session that created it. Mint once, store it, hold that one. caps.learn(cap); // Publication is a registry fact, not a stored one, so it is applied separately. if (scope === "public") caps.publishRepoLink(doc); } /** * File the caps of the documents a virtual user owns BY BEING one: its three * stores, and its inbox. They are as much its documents as any entity it creates, * and without them it cannot even list its own content — the boundary would lock a * user out of itself. * * Scoped to the current holder, like {@link holdOwnCap}: another user's stores are * emphatically not ours to hold. */ function fileOwnStructure(id: string, record: AccountRecord): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; const caps = getCaps(); if (record.docPublic) caps.open(record.docPublic, "public"); if (record.docProtected) caps.open(record.docProtected, "protected"); if (record.docPrivate) caps.open(record.docPrivate, "private"); } /** Same, for the user's own inbox — it is its document, and it must be able to * read it. Depositing into someone else's needs no cap (see `docs.depositInto`). */ function fileOwnInbox(id: string, inbox: Nuri): void { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; getCaps().open(inbox, "private"); } // --- per-entity documents + per-scope index ------------------------------- /** * 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; } /** * Publish WHERE to deposit for `doc`, on its Header branch — the compartment any * holder of the document can read. * * Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's * `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option`), * so two addresses on one document is a state the model has no meaning for — and a * depositor picking the stale one writes where nobody reads. */ async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise { const s = await session(); try { // Two separate updates, not one compound statement: `DELETE WHERE { … }` is the // form verified against the real broker (see // `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update // is not exercised anywhere in this lib. await sparqlUpdate( s.sessionId, `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, doc, "publishInboxAddress:clear", ); await sparqlUpdate( s.sessionId, `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`, doc, "publishInboxAddress", ); } catch (error) { console.error(accessLogPrefix() + " publishInboxAddress failed:", error); } } /** * The ReadCaps recorded on a store's Store branch — its documents, each with its * key. The emulated replay of `AddRepo`, and the reason a fresh session recovers * what it owns without recomputing anything. */ async function readStoreCaps(storeDoc: Nuri): Promise { const s = await session(); const out: ReadCap[] = []; try { const res = await sparqlQuery( s.sessionId, `SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`, undefined, storeDoc, "readStoreCaps", ); for (const row of readBindings(res)) { const v = bindingValue(row, "c"); if (v && hasReadCap(v)) out.push(v); } } catch (error) { console.error(accessLogPrefix() + " readStoreCaps failed:", error); } return out; } /** Read the entity-document NURIs contained in ONE scope index document. */ 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`. */ /** * The inbox of a document this user owns — resolved, and created on first ask. * * Upstream a repo carries `inbox: Option` (`engine/repo/src/repo.rs:126`): * an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is * recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the user branch, * so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the * same branch that carries `AddLink`. So "which inboxes may I read" is answered by the * User branch, and that is what this emulates. * * **The engine SUPPORTS this; nothing exercises it automatically.** Those are two * different statements, and conflating them is what made an earlier version of this * comment call the feature an "anticipation". It is not. `inbox: Option` is a * field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure; * `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and * `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no * `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by * construction, and at any time (see the User-branch note above). * * What is true is narrower: no code path CREATES one for a document — `new_store_default` * attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None` * (`repo.rs:574`), and the only two `AddInboxCap` commits in the engine are for the * public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the * capability exists and is simply unexposed above level 1: this function is aligned on * the engine's model, it does not bet past it. * * Lazy on purpose, for the same reason: creating an inbox document for every entity up * front would double every `createEntityDoc` for inboxes most documents never receive * anything in. Upstream the keypair is cheap; here an inbox is a document, so it is * minted when first asked for. * * *(Not covered: ROTATING an inbox key — the engine's "update" case with a new * `priv_key`. This function is idempotent and returns the existing inbox instead. A * known limit, not an oversight.)* * * Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on * someone else's document would be usurpation, not a courtesy: the opener keeps the * reading half, so it would silently divert to itself the deposits meant for the * owner. To deposit into someone else's document, resolve * {@link documentInboxAddress} and `inbox.post` into it. */ export async function openDocumentInbox(doc: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set"); const known = (await readInboxCapsFor(doc)) ?? null; if (known) return known; // OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be // received. Opening an inbox is what PUBLISHES this document's address, so a // non-owner doing it would route the owner's deposits to itself, silently, on a // document it merely reads. Upstream the equivalent act is the owner committing // `AddInboxCap` with the repo's own key; nobody else can. if (!(await ownsDocument(doc))) { throw new Error( "[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " + `you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` + `then inbox.post): ${JSON.stringify(doc)}`, ); } const inbox = await createDoc(); const s = await session(); const record = await ensureAccount(holder); const store = record.docPrivate; getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs if (store) { try { await sparqlUpdate( s.sessionId, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`, store, "openDocumentInbox", ); } catch (error) { console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error); } } // …and the PUBLIC half, in the document itself, so a depositor can find it at all. // Without this the inbox is reachable only by its owner — the opposite of what an // inbox is for, and the bug this path shipped with. await publishInboxAddress(doc, inbox); return inbox; } /** * WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never * opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the * function an app calls before `inbox.post`. * * Readable by whoever can read the document, because it lives on its Header branch — * an address is public by nature (upstream a depositor needs only the inbox PUBLIC * key). Conversely someone who cannot read the document learns nothing, which is * faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to * reach you. * * **Never creates.** Asking where to deposit must not bring an inbox into existence — * only its owner opens one, and only on its own document. */ export async function documentInboxAddress(doc: Nuri): Promise { // RULE 2 — do not even attempt. Not holding the document, we have no address to // find: upstream the inbox pubkey travels WITH what you can read, so "where do I // deposit for a document I cannot read" is not a refused question, it is a question // with no referent. Answering `undefined` here keeps the caller's shape (an address // or none) instead of turning the boundary into an exception it must catch. if (mustNotAttempt(doc)) return undefined; const s = await session(); try { const res = await sparqlQuery( s.sessionId, `SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, undefined, doc, "documentInboxAddress", ); for (const row of readBindings(res)) { const a = bindingValue(row, "a"); if (a && isNuri(a)) return a; } } catch (error) { // Unreadable document (no cap) or not synced → no address to give. Refusing to // read is the boundary doing its job, not an error to propagate here. console.error(accessLogPrefix() + " documentInboxAddress failed:", error); } return undefined; } /** * Does the connected user own `doc`? Answered from its **Store branches** — the * register of the documents it created — across the three scopes, which is the only * place that records authorship. Holding a cap is NOT ownership: a cap can be * received, and a recipient must not be able to open an inbox on what it merely reads. */ async function ownsDocument(doc: Nuri): Promise { const holder = getCurrentUser(); if (holder === null) return false; const record = await resolveAccount(holder); if (record === null) return false; for (const scope of ["public", "protected", "private"] as const) { const store = storeOf(record, scope); if (!store) continue; if ((await readUserStore(store)).includes(doc)) return true; } return false; } /** The `(document, inbox)` pairs recorded on this user's User branch. */ async function readInboxCapPairs(): Promise> { const holder = getCurrentUser(); if (holder === null) return []; const record = await resolveAccount(holder); const store = record?.docPrivate; if (!store) return []; const s = await session(); const out: Array<{ doc: Nuri; inbox: Nuri }> = []; try { const res = await sparqlQuery( s.sessionId, `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> ?c }`, undefined, store, "readInboxCaps", ); for (const row of readBindings(res)) { const [doc, inbox] = bindingValue(row, "c").split(" "); if (doc && inbox && isNuri(doc) && isNuri(inbox)) out.push({ doc, inbox }); } } catch (error) { console.error(accessLogPrefix() + " readInboxCaps failed:", error); } return out; } /** The inbox recorded for one document, if this user opened one. */ async function readInboxCapsFor(doc: Nuri): Promise { return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox; } /** * Every inbox this user may READ: its own, plus one per document it opened an * inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from. */ export async function myInboxes(): Promise { const holder = getCurrentUser(); if (holder === null) return []; const out: Nuri[] = []; if ((await resolveAccount(holder)) !== null) out.push(await walletInbox(holder)); for (const { inbox } of await readInboxCapPairs()) out.push(inbox); return out; } /** * File a cap received for someone ELSE's document — the emulated * `AddLink { read_cap }` on the User branch of the current user's private store. * * This is what makes a received cap DURABLE. Before it, a shared document survived * only by re-reading the inbox every session, which uses a queue as a database: * upstream an inbox is consumed, and processing a message *applies* it. Applying a * Link means writing it here. * * Idempotent — re-applying the same Link is a no-op, so re-processing an inbox * (a second tab, a reconnect) costs nothing. */ export async function addLink(cap: ReadCap): Promise { const holder = getCurrentUser(); if (holder === null) return; const record = await ensureAccount(holder); const store = record.docPrivate; if (!store) return; if ((await readLinks()).includes(cap)) return; const s = await session(); try { await sparqlUpdate( s.sessionId, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`, store, "addLink", ); } catch (error) { console.error(accessLogPrefix() + " addLink failed:", error); } } /** * The caps this user has received and applied — the User branch read back. Called * at connection to restore what was shared with them, without touching any inbox. */ export async function readLinks(): Promise { const holder = 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); try { const res = await sparqlQuery( s.sessionId, `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`, undefined, store, "readLinks", ); for (const row of readBindings(res)) { const v = bindingValue(row, "c"); if (v && hasReadCap(v)) out.push(v); } } catch (error) { console.error(accessLogPrefix() + " readLinks failed:", error); } return out; } 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.publishRepoLink(d); } return docs; }