e5779ca850
Assertion fausse retirée de `openDocumentInbox` : « en amont, l'acte équivalent est le propriétaire qui commite AddInboxCap avec la clé du repo — personne d'autre ne le peut ». Personne d'autre ne le peut est inventé. Ce commit atterrit sur la branche User de CELUI QUI LE FAIT, donc n'importe qui peut en écrire un nommant le repo de n'importe qui. Le moteur ne pose aucune garde là-dessus. Ce qui protège en amont n'est pas une garde, c'est le mode de circulation : `inboxes: PubKey → RepoId` est une table du Verifier (`verifier.rs:105`), reconstruite vide à chaque session — l'association inbox→repo est LOCALE, pas publiée. Un déposant apprend une pubkey parce qu'on la lui a ENVOYÉE : dans un `ContactDetails` (`contact.inbox`) ou par le QR de profil. Une paire forgée n'atteint personne, faute que quiconque en ait été informé. D'où une divergence à assumer et non à maquiller : nous PUBLIONS l'adresse sur le document, seul moyen qu'un tiers la trouve dans une émulation sans canal de messages. Cela crée un vecteur que le moteur n'a pas — qui peut écrire le document peut rediriger ses dépôts — et c'est ce que la garde `ownsDocument` compense. Elle compense NOTRE conception ; elle ne reproduit aucune règle amont. Manquait aussi dans la carte des inbox dressée juste avant : elle disait qui A une inbox, et omettait comment l'adresse circule — la dimension dont tout le reste dépend.
1378 lines
63 KiB
TypeScript
1378 lines
63 KiB
TypeScript
/**
|
||
* 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:<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 "./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<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.
|
||
*/
|
||
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<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, AccountRecord>();
|
||
|
||
// 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. */
|
||
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 [];
|
||
}
|
||
|
||
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 AccountRecord 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,
|
||
): 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<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). */
|
||
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 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<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<AccountRecord | 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 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<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<AccountRecord>>();
|
||
|
||
/**
|
||
* 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<AccountRecord> {
|
||
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<AccountRecord> => {
|
||
// 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<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 `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<string, Promise<Nuri>>();
|
||
/** Resolved wallet inboxes, keyed by account key. Cleared with the registry cache. */
|
||
const inboxCache = new Map<string, Nuri>();
|
||
|
||
/**
|
||
* 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<Nuri> {
|
||
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<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);
|
||
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<boolean> {
|
||
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<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;
|
||
}
|
||
|
||
/**
|
||
* 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<PrivKey>`),
|
||
* 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<void> {
|
||
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<ReadCap[]> {
|
||
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<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`.
|
||
*/
|
||
/**
|
||
* The inbox of a document this user owns — resolved, and created on first ask.
|
||
*
|
||
* Upstream a repo carries `inbox: Option<PrivKey>` (`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<PrivKey>` 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<Nuri> {
|
||
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.
|
||
//
|
||
// **This guard compensates OUR design, not an upstream constraint** — an earlier
|
||
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
|
||
// false: that commit lands on the committer's OWN User branch, so anyone may write
|
||
// one naming anyone's repo. What protects upstream is that an inbox address is never
|
||
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
|
||
// code), and `inboxes: PubKey → RepoId` is a per-verifier local table
|
||
// (`engine/verifier/src/verifier.rs:105`, rebuilt empty each session). A forged pair
|
||
// reaches nobody, because nobody was told about it.
|
||
//
|
||
// We publish instead of transmitting — the only way a third party can find the
|
||
// address at all here — which creates a vector upstream does not have: whoever can
|
||
// write the document can redirect its deposits. Hence this guard. It is a real
|
||
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||
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<Nuri | undefined> {
|
||
// 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<boolean> {
|
||
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<Array<{ doc: Nuri; inbox: Nuri }>> {
|
||
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<Nuri | undefined> {
|
||
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<Nuri[]> {
|
||
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<void> {
|
||
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<ReadCap[]> {
|
||
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<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.publishRepoLink(d);
|
||
}
|
||
return docs;
|
||
}
|