2406afec8b
Glissement à corriger : de « le moteur ne le FAIT pas » (vrai) j'ai conclu « c'est notre anticipation » (faux). Le moteur le PERMET, et de façon générique par conception : - `inbox: Option<PrivKey>` est un champ de TOUT `Repo` (`repo.rs:126`), pas d'une structure de store ; - `AddInboxCapV0` est clé par `repo_id` — « Repo the Inbox is opened for » ; - `update_inbox_cap_v0` l'applique via `self.repos.get_mut(repo_id)` sans AUCUNE vérification `is_store` (`verifier.rs:1920`) ; - et à tout moment, `AddInboxCap` étant un commit de branche User dont le type documente le cas de mise à jour. Ce qui est vrai est plus étroit : aucun chemin de code n'en CRÉE une pour un document. « Ne fait pas » n'est pas « ne peut pas » — c'est précisément l'interdit que ma propre règle pose, et je l'ai enfreint en le formulant. Donc l'inbox par document s'aligne sur le modèle du moteur (niveau 1) ; ce qui est de nous est la surface JS, aucune n'étant exposée aux niveaux 2 et 3. Corrigé dans store-registry.ts, nextgraph-current-state.md et les deux briefs.
1365 lines
62 KiB
TypeScript
1365 lines
62 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. Upstream the equivalent act is the owner committing
|
||
// `AddInboxCap` with the repo's own key; nobody else can.
|
||
if (!(await ownsDocument(doc))) {
|
||
throw new Error(
|
||
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
|
||
`you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` +
|
||
`then inbox.post): ${JSON.stringify(doc)}`,
|
||
);
|
||
}
|
||
|
||
const inbox = await createDoc();
|
||
const s = await session();
|
||
const record = await ensureAccount(holder);
|
||
const store = record.docPrivate;
|
||
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
|
||
if (store) {
|
||
try {
|
||
await sparqlUpdate(
|
||
s.sessionId,
|
||
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`,
|
||
store,
|
||
"openDocumentInbox",
|
||
);
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
|
||
}
|
||
}
|
||
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
|
||
// Without this the inbox is reachable only by its owner — the opposite of what an
|
||
// inbox is for, and the bug this path shipped with.
|
||
await publishInboxAddress(doc, inbox);
|
||
return inbox;
|
||
}
|
||
|
||
/**
|
||
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
|
||
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
|
||
* function an app calls before `inbox.post`.
|
||
*
|
||
* Readable by whoever can read the document, because it lives on its Header branch —
|
||
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
|
||
* key). Conversely someone who cannot read the document learns nothing, which is
|
||
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
|
||
* reach you.
|
||
*
|
||
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
|
||
* only its owner opens one, and only on its own document.
|
||
*/
|
||
export async function documentInboxAddress(doc: Nuri): Promise<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;
|
||
}
|