Files
ng-eventually/packages/client/src/shared-wallet/account-registry.ts
T
Sylvain Duchesne 3c981ffadb docs: le nommage de NextGraph l'emporte toujours — y compris sur nos propres règles
La règle que je venais d'écrire bannissait « publish » sans réserve. Or le
moteur emploie `publisher` 126 fois — `as_publisher`, `publisher_advert` — pour
un rôle pub/sub sur un topic. Une règle appliquée à la lettre aurait fait
rejeter du vocabulaire amont, ce qui est exactement l'inverse du but.

Le principe est donc posé au-dessus, et il prime sur tout : là où la cible a un
mot, c'est le mot, point. Ce qui reste banni est NOTRE « publier un document »,
qui n'a aucun référent en amont et recouvre trois actes distincts — placer dans
un store public, rendre trouvable, remettre une clé. Ça n'autorise jamais à
renommer ce que l'amont appelle `publisher`.

`publisher`, `topic` et `advert` rejoignent le vocabulaire déclaré du contrôle
de noms, avec la raison en commentaire.

Le renommage `publishRepoLink` → `recordInPublicStore` reste justifié : cette
méthode n'a pas de pendant amont, elle enregistre un document en store public
(et, artefact d'émulation, lui frappe une clé).
2026-08-06 15:50:54 +02:00

1045 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:<RepoID>`
* 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<PrivKey>`, `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<RegistrySession> {
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<Nuri> {
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<string, VirtualUserRecord>();
// 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<Nuri> | 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<Record<string, { value: string }>> {
if (!result) return [];
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
if (anyRes.results?.bindings) return anyRes.results.bindings;
return [];
}
export function bindingValue(row: Record<string, { value: string }>, 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<Record<string, { value: string }>>, key: string): MaybeNuri {
let chosen: MaybeNuri = "";
const distinct = new Set<string>();
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<Record<string, { value: string }>>,
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<void> => 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<MaybeNuri> {
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<void> {
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<Nuri> {
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<Nuri> {
if (shimDocNuri) return shimDocNuri;
if (shimDocInFlight) return shimDocInFlight;
const p = (async (): Promise<Nuri> => {
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<VirtualUserRecord | null> {
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<void> {
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<string, Promise<VirtualUserRecord>>();
/**
* 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<VirtualUserRecord> {
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<VirtualUserRecord> => {
// 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<Nuri> {
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<Nuri> {
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<Nuri> {
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<string, Promise<Nuri>>();
/** Resolved wallet inboxes, keyed by account key. Cleared with the registry cache. */
const inboxCache = new Map<string, Nuri>();
/**
* 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<String, SiteV0>`
* (`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<Nuri> {
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<Nuri> => {
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<Nuri> {
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 <plainNuri>`
// 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<Nuri[]> {
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<Nuri> {
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<Nuri[]> {
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;
}