0a2b8b1fbf
Le contrat publie et l'api-contract etaient traites avec le retrait lui-meme ; restaient les documents qui decrivaient readUnion comme la primitive de lecture du paquet, ou qui nommaient le module disparu. Ce que les lecteurs apprennent maintenant : lire un document, c'est docs.sparqlQuery ancre dessus, et lire plusieurs documents, c'est cet appel une fois par document -- avec la decision sur une lecture qui echoue ecrite au point d'appel. Deux consequences sont dites plutot que decouvertes : une lecture refusee leve au lieu de rendre une liste vide, et les sujets sous le prefixe reserve urn:ng-eventually: reviennent dans le resultat, a l'appelant de les ignorer -- sans aucun gain de droits, les deux registres qu'un document ordinaire porte etant l'adresse de sa boite et, pour un document public, la cle que son magasin sert deja a qui la demande. Les briefs dates ne sont pas touches : ils decrivent un etat de juillet, et le reecrire falsifierait ce qui avait ete decide alors.
1326 lines
66 KiB
TypeScript
1326 lines
66 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 virtualUsers.
|
||
*
|
||
* ── The indirection: pointer (store-root) → doc-shim (subscribable) ─────────
|
||
* On the real NextGraph platform "findable-without-lookup" and "subscribable"
|
||
* are DISJOINT (verified at the source, see docs/nextgraph-current-state.md
|
||
* § *Findable vs subscribable*):
|
||
* - the ONLY NURI a fresh session can name WITHOUT a lookup is the store-root
|
||
* `did:ng:${privateStoreId}` — but a store-root has NO first-`State` sync
|
||
* BARRIER, so a cold "0 rows" on it is AMBIGUOUS (could be sync-lag, could
|
||
* be truly empty);
|
||
* - the ONLY thing that DOES have a first-`State` barrier is a `did:ng:o:<RepoID>`
|
||
* doc from `doc_create` — but its RepoID is RANDOM, so a fresh session
|
||
* cannot GUESS it; it must be looked up.
|
||
* So a purely-barrier shim resolution is impossible: you cannot have a doc that
|
||
* is both guessable and authoritative on a cold read. The indirection bridges
|
||
* this: a well-known, write-ONCE **pointer** triple in the store-root names a
|
||
* **doc-shim** (`did:ng:o:...`) that holds all account records and IS
|
||
* subscribable. Resolution reads the pointer (a single oldest write-once triple,
|
||
* near-always synced), then opens the doc-shim through its `ensureRepoOpen`
|
||
* BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent).
|
||
*
|
||
* A pointer FORK (two devices writing the pointer before either synced) is
|
||
* reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every
|
||
* device converges on the SAME doc-shim. This is why the OLD account-level retry
|
||
* (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now
|
||
* barrier-authoritative, so it never needs to be retried to distinguish sync-lag
|
||
* from absence. A micro-guard remains ONLY on the pointer read (one write-once
|
||
* triple) — see resolvePointer.
|
||
*
|
||
* ── Generic by construction ──────────────────────────────────────────────
|
||
* This module knows only the three native scopes; it knows no application
|
||
* entity kind. The consumer maps its entities to a scope and calls
|
||
* `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native
|
||
* scope. No application domain here.
|
||
*
|
||
* ── What disappears at migration ─────────────────────────────────────────
|
||
* At the real multi-store migration the shim vanishes entirely: `(account,
|
||
* scope)` maps to the user's REAL store NURI instead of a document in the
|
||
* shared wallet, `docCreate` targets the real per-user store, and the
|
||
* per-scope index document (the store-container emulation) is replaced by the
|
||
* store itself. The consumer-facing surface (`createEntityDoc`,
|
||
* `listEntityDocs`, resolvers) is designed to survive that swap unchanged.
|
||
*
|
||
* All NextGraph I/O routes through the T01.a `docs` primitive (real injected
|
||
* `ng`), so this module imports **no** `@ng-org` package.
|
||
*/
|
||
|
||
import { sparqlQuery } from "../surface/docs";
|
||
import { registerUpdate } from "../emulated-verifier/register-write";
|
||
import { physicalCreate, physicalQuery, physicalUpdate } from "./physical";
|
||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./bootstrap";
|
||
// Cross-fate edge, deliberate: resolving a user is also when its own structure gets
|
||
// filed, and creating a document is when its cap does. See branch-registers.ts.
|
||
import {
|
||
fileOwnStructure,
|
||
fileOwnInbox,
|
||
holdOwnCap,
|
||
publishInboxAddress,
|
||
readStoreCaps,
|
||
readInboxCapsFor,
|
||
ownsDocument,
|
||
documentInboxAddress,
|
||
} from "../emulated-verifier/branch-registers";
|
||
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||
import { exposeReadCap } from "../emulated-verifier/public-store";
|
||
import { ensurePhysicalRepoOpen, subscribePhysicalDoc } from "./physical";
|
||
import { escapeLiteral, escapeIri, assertNuri } from "../surface/sparql";
|
||
import { hasReadCap, isNuri } from "../model/nuri";
|
||
import { mintCap } from "../emulated-verifier/caps";
|
||
import { mustNotAttempt } from "../emulated-verifier/reach";
|
||
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
|
||
import type { InboxScope, Nuri, StoreType } from "../model/types";
|
||
|
||
// --- sharedWalletShim model ----------------------------------------------
|
||
|
||
/**
|
||
* A NURI as read back from the shim, where `""` means "absent or corrupt".
|
||
*
|
||
* The empty case is NOT new — `canonicalDoc` has always returned `""` for a missing
|
||
* field, and callers have always had to test for it — but with {@link Nuri} typed it
|
||
* stops hiding inside a `string`. It is kept confined to the shim-reading functions
|
||
* below: `VirtualUserRecord` still promises real NURIs, because a record with an empty
|
||
* scope document is a corrupt record, not a valid state to spread through the API.
|
||
* Tightening that (reject the record rather than let it flow) is a change of
|
||
* behaviour and belongs to its own lot — see `recordFromRows`.
|
||
*/
|
||
type MaybeNuri = Nuri | "";
|
||
|
||
/** One account's three scope-document NURIs, as recorded in the shim. */
|
||
export interface VirtualUserRecord {
|
||
id: string;
|
||
docPublic: Nuri;
|
||
docProtected: Nuri;
|
||
docPrivate: Nuri;
|
||
}
|
||
|
||
const SHIM = "urn:ng-eventually:shim";
|
||
export const P = {
|
||
type: `${SHIM}:Account`,
|
||
id: `${SHIM}:id`,
|
||
docPublic: `${SHIM}:docPublic`,
|
||
docProtected: `${SHIM}:docProtected`,
|
||
docPrivate: `${SHIM}:docPrivate`,
|
||
contains: `${SHIM}:contains`, // scope-index → entity document NURI
|
||
docInbox: `${SHIM}:docInbox`, // (user, inboxScope) → ITS inbox document
|
||
link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document
|
||
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
|
||
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
|
||
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
|
||
exposedReadCap: `${SHIM}:exposedReadCap`, // header branch → the cap a PUBLIC store serves to anyone
|
||
isInbox: `${SHIM}:isInbox`, // shim → this NURI IS an inbox (see `assertIsInbox`)
|
||
inboxOwner: `${SHIM}:inboxOwner`, // shim → WHOSE inbox this NURI is (see `recordInbox`)
|
||
} as const;
|
||
// Fixed subject of the per-(account×scope) index document. The index doc plays
|
||
// the role of the future store-container: it lists the NURIs of the entity
|
||
// documents (one per entity) that live "in" that scope.
|
||
const MAIN_BRANCH_SUBJECT = `${SHIM}:index`;
|
||
/**
|
||
* Fixed subject of the **User branch** emulation, inside a user's PRIVATE store
|
||
* document. Upstream, `AddLink { read_cap }` is committed to the User branch of the
|
||
* private store — *"so that a user can share with all its device a new Link they
|
||
* received"*, and *"only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`).
|
||
* That is where a cap received from someone else durably lives.
|
||
*
|
||
* We have no branches, so the compartment is a distinct SUBJECT in the same
|
||
* document, kept separate from `MAIN_BRANCH_SUBJECT` (which emulates the store's Main
|
||
* branch, the `ldp:contains` listing). Two compartments, two subjects — the
|
||
* separation upstream makes with two branches.
|
||
*/
|
||
export const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`;
|
||
/**
|
||
* Fixed subject of the **Store branch** emulation, inside a user's store document.
|
||
*
|
||
* `doc_create` upstream writes TWICE (`engine/verifier/src/request_processor.rs:697-710`):
|
||
* `ldp:contains` on the store's **Main** branch — the listing — and
|
||
* `AddRepo { read_cap }` on its **Store** branch — the key. Two branches, two
|
||
* purposes, deliberately separate; replaying the Store branch is what reloads a
|
||
* store's documents WITH their caps (`AddRepo::verify` → `load_repo_from_read_cap`).
|
||
*
|
||
* We have no branches, so this is a distinct SUBJECT beside {@link MAIN_BRANCH_SUBJECT}
|
||
* in the same document — the same shape already used for {@link USER_BRANCH_SUBJECT}.
|
||
*
|
||
* **Honest about the emulation**: upstream the Store branch carries NO triples at all
|
||
* (`BranchCrdt::None`, `engine/repo/src/types.rs:1420`) — it is a stream of service
|
||
* commits. Representing it as RDF is our invention; what is faithful is *that the cap
|
||
* is stored beside the document rather than recomputed*, and that the listing and the
|
||
* keys stay separate.
|
||
*/
|
||
export const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`;
|
||
/**
|
||
* Fixed subject of the **Header branch** emulation, inside an ENTITY document — the
|
||
* first compartment we put in a document the consumer also reads, hence the filter in
|
||
* `read-document.ts` (every `${SHIM}:` subject is machinery, and the internal read drops it).
|
||
*
|
||
* It carries what must be readable by *whoever can read the document*, as opposed to
|
||
* what belongs to its owner alone. Today that is one thing: the ADDRESS of the
|
||
* document's inbox.
|
||
*
|
||
* Why the address must live here and not on the owner's User branch. Upstream an inbox
|
||
* is a KEYPAIR (`repo.inbox: Option<PrivKey>`, `engine/repo/src/repo.rs:126`) and the
|
||
* two halves have opposite audiences: a depositor seals with the PUBLIC key
|
||
* (`InboxMsg::new` → `crypto_box::seal(&to_inbox.to_dh_slice(), …)`,
|
||
* `engine/net/src/types.rs:4299`) and needs nothing else; only the owner holds the
|
||
* private half (`AddInboxCap`, on the User branch). An address is therefore public by
|
||
* nature — upstream it travels with the profile (`ContactDetails` carries
|
||
* `ng:site_inbox` / `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:823`).
|
||
* Keeping it only on the owner's User branch, as this lib first did, made the deposit
|
||
* side unreachable: a third party had no way to learn where to deposit.
|
||
*
|
||
* **Not `BranchType::Header` upstream.** That branch exists (`engine/repo/src/types.rs:1551`)
|
||
* but is CLOSED: `update_header` writes only `title`/`about`
|
||
* (`engine/verifier/src/request_processor.rs:173-211`) and `fetch_header` reads back
|
||
* only `title`/`about`/`class` (`:1240-1284`). It cannot carry an inbox address. The
|
||
* name is borrowed for the shape — a compartment of the document that is not its
|
||
* content — not for the upstream branch's contract.
|
||
*/
|
||
export const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`;
|
||
|
||
// --- pointer (store-root → doc-shim indirection) --------------------------
|
||
//
|
||
// The pointer is a SINGLE well-known triple written ONCE into the store-root
|
||
// graph on the very first login, then IMMUTABLE. Its object is the NURI of the
|
||
// doc-shim (a `did:ng:o:...` repo) where all AccountRecords actually live. The
|
||
// store-root is NOT subscribable (no first-`State` barrier), but the pointer is
|
||
// the OLDEST triple in that graph and is write-once, so it is near-always synced
|
||
// on a cold read — and even a transient miss is bounded by a small guard
|
||
// (resolvePointer), NOT by an account-level retry.
|
||
const POINTER_SUBJECT = `${SHIM}:root`;
|
||
const POINTER_PRED = `${SHIM}:shimDoc`;
|
||
|
||
function accountSubject(id: string): string {
|
||
// The id is UNTRUSTED and lands in an IRI position. Percent-encode it
|
||
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
||
// the `<...>` and inject triples into the shim graph (the account→doc trust
|
||
// root). accountKey() runs first so the subject stays stable per shim key.
|
||
return `${SHIM}:account:${escapeIri(accountKey(id))}`;
|
||
}
|
||
|
||
// --- reserved accounts -----------------------------------------------------
|
||
//
|
||
// Some accounts are internal to the lib (e.g. the discovery index owner) and
|
||
// must NOT collide with any user-chosen id. A reserved account is created
|
||
// via {@link reservedAccount}, which marks the name with a sentinel PREFIX that
|
||
// `normalizeId` (consumer-injected) can never produce: it strips a leading
|
||
// `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved
|
||
// keys therefore live in a disjoint namespace from every normalized id —
|
||
// a real user named "index"/"@index" can never resolve to the reserved
|
||
// `reservedAccount("index")` account.
|
||
const RESERVED_PREFIX = "\u0000reserved:";
|
||
|
||
/**
|
||
* Wrap an internal account name so it occupies a key that no user input can
|
||
* produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount}
|
||
* (and the other registry calls) instead of a bare id.
|
||
*/
|
||
export function reservedAccount(name: string): string {
|
||
return `${RESERVED_PREFIX}${name}`;
|
||
}
|
||
|
||
/** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */
|
||
function isReserved(id: string): boolean {
|
||
return id.startsWith(RESERVED_PREFIX);
|
||
}
|
||
|
||
/**
|
||
* The shim/cache key for an account. Reserved accounts bypass `normalizeId`
|
||
* entirely and key on their sentinel-prefixed name, so they cannot collide with
|
||
* a normalized id; everyone else normalizes as usual.
|
||
*/
|
||
export function accountKey(id: string): string {
|
||
return isReserved(id) ? id : normalize(id);
|
||
}
|
||
|
||
// --- session / normalization access (injected by the consumer) ------------
|
||
|
||
/** Minimal session shape the registry needs — provided by the consumer. */
|
||
export interface RegistrySession {
|
||
/** Relayed untouched to `ng` — upstream declares it `string | number` (`index.d.ts:266`). */
|
||
sessionId: string | number;
|
||
/** The shared wallet's private store id — the pointer anchor. */
|
||
privateStoreId: string;
|
||
/** The shared wallet's protected store id (native store). Optional: only the
|
||
* scope resolvers need it; the shim only needs the private anchor. */
|
||
protectedStoreId?: string;
|
||
/** The shared wallet's public store id (native store). Optional. */
|
||
publicStoreId?: string;
|
||
}
|
||
|
||
function normalize(id: string): string {
|
||
const key = getStoreRegistryDeps().normalizeId(id);
|
||
// The reserved namespace's whole guarantee is that no user id can land in it, and the
|
||
// rule that produces the key does not enforce it: the package's own `normalizeIdentityId`
|
||
// trims, strips a leading `@` and lowercases — nothing stops an id that already starts
|
||
// with the sentinel — and the internal wiring path lets a suite or the e2e harness
|
||
// substitute another rule entirely. A collision here is not a cosmetic clash: a user
|
||
// would key onto an infrastructure account and read or write documents that are not
|
||
// theirs. So it is checked rather than assumed.
|
||
if (isReserved(key)) {
|
||
throw new Error(
|
||
"[ng-eventually] account-registry: `normalizeId` produced a key inside the " +
|
||
`reserved namespace, which no user id may occupy: ${JSON.stringify(key)}`,
|
||
);
|
||
}
|
||
return key;
|
||
}
|
||
|
||
export async function session(): Promise<RegistrySession> {
|
||
return getStoreRegistryDeps().getSession();
|
||
}
|
||
|
||
/** The pointer lives in the shared wallet's private STORE-ROOT graph (the only
|
||
* always-known-without-lookup anchor). NOT subscribable — hence the pointer is
|
||
* a write-once triple, and the actual account records live in the doc-shim it
|
||
* names (see rootNuri vs the doc-shim). */
|
||
async function rootNuri(): Promise<Nuri> {
|
||
const s = await session();
|
||
return `did:ng:${s.privateStoreId}`;
|
||
}
|
||
|
||
// --- cache ----------------------------------------------------------------
|
||
|
||
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
|
||
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
|
||
// targeted resolve never forces a full shim scan. Both are cleared together.
|
||
const accountCache = new Map<string, VirtualUserRecord>();
|
||
|
||
// The resolved doc-shim NURI for the current session (cached: the pointer read +
|
||
// barrier open happen once, then every account read reuses this doc). Cleared on
|
||
// resetRegistryCache / wallet switch.
|
||
let shimDocNuri: Nuri | null = null;
|
||
// De-dupe concurrent pointer-resolutions so a fresh page firing many parallel
|
||
// ensureAccount/resolveAccount calls opens the doc-shim exactly once.
|
||
let shimDocInFlight: Promise<Nuri> | null = null;
|
||
|
||
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
|
||
export function resetRegistryCache(): void {
|
||
accountCache.clear();
|
||
inboxCache.clear();
|
||
inboxInFlight.clear();
|
||
shimDocNuri = null;
|
||
shimDocInFlight = null;
|
||
// The inbox index is session state like the rest: a NURI confirmed as an inbox in one
|
||
// session must not be taken for one in the next. (Left out at first, and the full unit
|
||
// suite went red on a NURI collision between two files — a memo that outlives its
|
||
// session is exactly the fault this review found elsewhere in the tests.)
|
||
knownInboxes.clear();
|
||
// …and so is who owns which inbox: it is read from the same index, in the same session.
|
||
inboxOwners.clear();
|
||
}
|
||
|
||
// --- SPARQL result helpers ------------------------------------------------
|
||
|
||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
||
export function readBindings(result: unknown): Array<Record<string, { value: string }>> {
|
||
if (!result) return [];
|
||
const anyRes = result as {
|
||
results?: { bindings?: Array<Record<string, { value: string }>> };
|
||
};
|
||
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
|
||
if (anyRes.results?.bindings) return anyRes.results.bindings;
|
||
return [];
|
||
}
|
||
|
||
export function bindingValue(row: Record<string, { value: string }>, key: string): string {
|
||
return row[key]?.value ?? "";
|
||
}
|
||
|
||
/**
|
||
* DETERMINISTIC resolution of an account's scope docs from a set of SPARQL
|
||
* bindings (all bindings for ONE account subject).
|
||
*
|
||
* ── Why this is load-bearing (residual fork residue) ───────────────────────
|
||
* A corrupted shim can carry the SAME account subject with MULTIPLE values for a
|
||
* scope predicate (e.g. 5 `shim:docPublic`) — the residue of past account FORKS
|
||
* (each stray provision appended another doc NURI). A query then returns several
|
||
* bindings (the cross-product of the duplicate values). Picking `rows[0]` is
|
||
* NON-DETERMINISTIC (binding order is not stable across sessions), so the session
|
||
* that WROTE an entity into one docPublic and a later fresh page that RESOLVED a
|
||
* DIFFERENT docPublic would disagree → the anchored `readUserStore` returns 0 →
|
||
* the home reads empty. When both happen to pick the same doc, it "works".
|
||
*
|
||
* The fix: for each scope field, collect EVERY distinct value across the bindings
|
||
* and choose the SAME one every time — the lexicographically-smallest NURI. NURIs
|
||
* are content-addressed and stable, so lexicographic order is a total, stable,
|
||
* session-independent order: writer and reader converge on the SAME canonical doc
|
||
* even on a wallet already corrupted by duplicates. (No creation timestamp is
|
||
* recorded in the shim, so lexicographic-min is the available deterministic key.)
|
||
*
|
||
* With the barrier-authoritative doc-shim read, account FORKS no longer occur (a
|
||
* fresh page reads the doc-shim through its first-`State` barrier, so a cold 0 is
|
||
* definitive and never triggers a fork-provision). `canonicalDoc` is RETAINED to
|
||
* stay robust against the residue of PAST forks already persisted in a wallet, and
|
||
* to reconcile a benign pointer fork the same content-addressed way.
|
||
*/
|
||
function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): MaybeNuri {
|
||
let chosen: MaybeNuri = "";
|
||
const distinct = new Set<string>();
|
||
for (const row of rows) {
|
||
const v = bindingValue(row, key);
|
||
// The SPARQL boundary: a binding is an untrusted string. Narrowing here (rather
|
||
// than casting) also discards a value that is not a NextGraph reference at all —
|
||
// shim corruption that used to flow straight through as a "document NURI".
|
||
if (!v || !isNuri(v)) continue;
|
||
distinct.add(v);
|
||
if (chosen === "" || v < chosen) chosen = v;
|
||
}
|
||
// Stage trace: which doc got picked, and out of how many DISTINCT candidate
|
||
// values — >1 flags residual fork residue (see the module doc above) even
|
||
// when resolution still converges correctly on the canonical (smallest) one.
|
||
logStage(
|
||
"canonicalDoc(" + key + ") → " + (chosen ? shortNuri(chosen) : "none") +
|
||
" (" + distinct.size + (distinct.size === 1 ? " candidate)" : " candidates)"),
|
||
);
|
||
return chosen;
|
||
}
|
||
|
||
/** Build an VirtualUserRecord by picking the canonical (lexicographically-smallest)
|
||
* doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */
|
||
function recordFromRows(
|
||
rows: Array<Record<string, { value: string }>>,
|
||
fallbackId: string,
|
||
): VirtualUserRecord {
|
||
let id = "";
|
||
for (const row of rows) {
|
||
const v = bindingValue(row, "id");
|
||
if (v) { id = v; break; }
|
||
}
|
||
// The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises
|
||
// real NURIs; a shim missing a scope document yields `""` here, exactly as it
|
||
// always has, and the cast records that this is a KNOWN gap rather than a proven
|
||
// invariant. Callers already test for the empty value (`listMyEntityDocs` reads
|
||
// nothing out of 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;
|
||
// Did ANY attempt come back with an answer — even an empty one? The guard retries a
|
||
// read that could not be made, and `""` means "there is no pointer yet", which is the
|
||
// signal `resolveShimDoc` MINTS a doc-shim on. A budget exhausted without a single
|
||
// answer has established nothing at all, so answering `""` there would fabricate a
|
||
// second registry root: a new doc-shim, a second pointer beside the one that already
|
||
// exists, and every account record split between two documents that only
|
||
// `canonicalDoc` reconciles — the ones written into the loser simply disappear. Only a
|
||
// read that ANSWERED may report an absence.
|
||
let answered = false;
|
||
let lastError: unknown = null;
|
||
for (let i = 0; i < attempts; i++) {
|
||
try {
|
||
const result = await physicalQuery(s.sessionId, query, undefined, root, "resolvePointer");
|
||
answered = true;
|
||
const doc = canonicalDoc(readBindings(result), "shimDoc");
|
||
if (doc) {
|
||
logStage("resolvePointer → 1 target: " + shortNuri(doc));
|
||
return doc;
|
||
}
|
||
} catch (error) {
|
||
lastError = error;
|
||
console.error(accessLogPrefix() + " resolvePointer failed:", error);
|
||
}
|
||
if (i < attempts - 1) {
|
||
await sleep(step);
|
||
step = Math.min(step * 2, maxStepMs);
|
||
}
|
||
}
|
||
if (!answered) throw lastError;
|
||
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.
|
||
*
|
||
* **Propagates a failed write.** The pointer is the ONLY way back to the doc-shim — it is
|
||
* the one NURI a fresh session can name without a lookup. A session that carried on over a
|
||
* pointer that never landed would go on writing every account record into a document no
|
||
* later session can find, and the next login, finding no pointer, would mint a second
|
||
* doc-shim and re-provision every account from scratch. There is nothing to recover
|
||
* afterwards, so the failure has to be seen at the moment it happens. */
|
||
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);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/** Create one graph document in the shared wallet's private store (→ a NURI). */
|
||
export async function createDoc(): Promise<Nuri> {
|
||
const s = await session();
|
||
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
|
||
// store_repo=undefined → shared wallet's private store.
|
||
return physicalCreate(s.sessionId);
|
||
}
|
||
|
||
/**
|
||
* Resolve (or on first login, create) the doc-shim NURI for this session — the
|
||
* `did:ng:o:...` repo that holds every VirtualUserRecord and IS subscribable.
|
||
*
|
||
* Steps (cached; runs at most once per session, concurrent callers share one):
|
||
* 1. Read the pointer from the store-root (resolvePointer). If present → that is
|
||
* the doc-shim; open it through its first-`State` BARRIER so subsequent account
|
||
* reads are AUTHORITATIVE.
|
||
* 2. No pointer → FIRST login:
|
||
* a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the
|
||
* session (`self.repos`) — so it is already open/synced in-session;
|
||
* b. publish the pointer (store-root → the new doc-shim), once;
|
||
* c. open it (barrier — trivially satisfied for a just-created in-session repo).
|
||
* The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim);
|
||
* on first-login creation it is a no-op, so the pointer is published first.
|
||
*/
|
||
async function resolveShimDoc(): Promise<Nuri> {
|
||
if (shimDocNuri) return shimDocNuri;
|
||
if (shimDocInFlight) return shimDocInFlight;
|
||
|
||
const p = (async (): Promise<Nuri> => {
|
||
const existing = await resolvePointer();
|
||
if (existing) {
|
||
// Open the doc-shim through its first-`State` barrier BEFORE any account read,
|
||
// so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag.
|
||
await ensurePhysicalRepoOpen(existing);
|
||
shimDocNuri = existing;
|
||
logStage("resolveShimDoc → " + shortNuri(existing));
|
||
return existing;
|
||
}
|
||
|
||
// FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish
|
||
// the pointer, then open (no-op barrier for a just-created repo).
|
||
const doc = await createDoc();
|
||
await writePointer(doc);
|
||
await ensurePhysicalRepoOpen(doc);
|
||
shimDocNuri = doc;
|
||
logStage("resolveShimDoc → " + shortNuri(doc));
|
||
return doc;
|
||
})();
|
||
|
||
shimDocInFlight = p;
|
||
try {
|
||
return await p;
|
||
} finally {
|
||
shimDocInFlight = null;
|
||
}
|
||
}
|
||
|
||
// --- shim load / account bootstrap ----------------------------------------
|
||
|
||
/**
|
||
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
|
||
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
|
||
* the account record at its known subject (`accountSubject(id)`) directly in the
|
||
* doc-shim, instead of scanning EVERY account like {@link loadShim}. Returns the
|
||
* account's record or `null` if it does not exist yet.
|
||
*
|
||
* ── Barrier-AUTHORITATIVE (the reconnection fix) ────────────────────────────
|
||
* The read targets the DOC-SHIM (`did:ng:o:...`), which resolveShimDoc opened
|
||
* through its first-`State` barrier. So a cold 0 rows here is DEFINITIVE ("account
|
||
* genuinely absent"), not ambiguous sync-lag — no account-level retry is needed or
|
||
* used. This is what replaced the old `resolveAccountReliably` / `provisionRetry`
|
||
* loop: the store-root ambiguity that forced the retry is gone once the read moves
|
||
* behind the doc-shim barrier.
|
||
*
|
||
* Cached per account (in `accountCache`); a hit skips the query entirely, so
|
||
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
|
||
*/
|
||
/**
|
||
* Does this account exist? **Propagates** a read failure instead of turning it into
|
||
* "no such account".
|
||
*
|
||
* `resolveAccount` below is tolerant by design — most callers want "carry on without a
|
||
* record". But a caller that REFUSES on absence needs the two apart: telling a user
|
||
* "nobody has signed in as bob" because one query timed out is a lie, and it made
|
||
* `inbox.share` reject a recipient who exists (found adversarially, 2026-08-10).
|
||
*/
|
||
export async function lookupAccount(id: string): Promise<VirtualUserRecord | null> {
|
||
const key = accountKey(id);
|
||
const cached = accountCache.get(key);
|
||
if (cached) return cached;
|
||
|
||
const s = await session();
|
||
const doc = await resolveShimDoc();
|
||
// `subj` is already IRI-safe (accountSubject → escapeIri). The read is anchored to
|
||
// the doc-shim's default graph (opened through its barrier by resolveShimDoc), so
|
||
// it is authoritative. The query is bounded to this one subject.
|
||
const subj = accountSubject(id);
|
||
const query = `
|
||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||
<${subj}> a <${P.type}> ;
|
||
<${P.id}> ?id ;
|
||
<${P.docPublic}> ?docPublic ;
|
||
<${P.docProtected}> ?docProtected ;
|
||
<${P.docPrivate}> ?docPrivate .
|
||
}`;
|
||
try {
|
||
const result = await physicalQuery(s.sessionId, query, undefined, doc, "resolveAccount");
|
||
const rows = readBindings(result);
|
||
if (rows.length === 0) {
|
||
logStage("resolveAccount(" + key + ") → null");
|
||
return null;
|
||
}
|
||
// DETERMINISTIC: a corrupted shim may return SEVERAL bindings for this one
|
||
// account subject (duplicate scope-doc values from past forks). Pick the
|
||
// canonical (lexicographically-smallest) doc per scope so writer and reader
|
||
// always resolve the SAME docPublic (robustness against PAST fork residue).
|
||
const record = recordFromRows(rows, id);
|
||
accountCache.set(key, record);
|
||
logStage("resolveAccount(" + key + ") → 1 record");
|
||
return record;
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " resolveAccount failed:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The account, or `null` when there is none — and `null` too when the read failed. The
|
||
* tolerant form every internal caller uses: none of them can act on the difference, and
|
||
* connecting must not break because a query did not come back. Use {@link lookupAccount}
|
||
* where absence is a REASON TO REFUSE.
|
||
*/
|
||
export async function resolveAccount(id: string): Promise<VirtualUserRecord | null> {
|
||
try {
|
||
return await lookupAccount(id);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Persist one VirtualUserRecord into the doc-shim (anchored default-graph write, the
|
||
* canonical always-safe shape — same convention as createEntityDoc).
|
||
*
|
||
* **Propagates a failed write.** `ensureAccount` caches and returns the record right
|
||
* after this call, so swallowing here handed back three scope documents that the shim
|
||
* never heard of: the session writes its entities into them, and the NEXT session,
|
||
* reading the shim and finding nothing, provisions the account again — a fork, with every
|
||
* document of the first set orphaned and no error anywhere. This is the same event as a
|
||
* failed pointer write one level down, and it gets the same answer. */
|
||
async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise<void> {
|
||
const s = await session();
|
||
const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`;
|
||
// `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position →
|
||
// escapeLiteral. The doc NURIs come from `ng` but are stored as literals here, so
|
||
// they are escaped as literals too (defence in depth). NO explicit `GRAPH <…>`
|
||
// wrapper: write the anchored DEFAULT graph (the `doc` anchor scopes it) — the
|
||
// canonical, always-safe shape the anchored default-graph read queries match.
|
||
const update = `
|
||
INSERT DATA {
|
||
<${subj}> a <${P.type}> ;
|
||
<${P.id}> "${escapeLiteral(record.id)}" ;
|
||
<${P.docPublic}> "${escapeLiteral(record.docPublic)}" ;
|
||
<${P.docProtected}> "${escapeLiteral(record.docProtected)}" ;
|
||
<${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" .
|
||
}`;
|
||
try {
|
||
await physicalUpdate(s.sessionId, update, doc, "writeRecord");
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
|
||
throw 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 (a listing per store, an inbox enumeration, the
|
||
* app's own effects). With the barrier-authoritative
|
||
* resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the
|
||
* account is GENUINELY new, N concurrent callers would still each see 0 and each
|
||
* provision a set of scope docs (an in-session fork). De-duping concurrent provisions
|
||
* collapses those N into ONE: the first caller resolves-or-provisions; every
|
||
* concurrent caller awaits the SAME promise and gets the SAME record. Not polling: a
|
||
* bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant
|
||
* it settles.
|
||
*/
|
||
const ensureInFlight = new Map<string, Promise<VirtualUserRecord>>();
|
||
|
||
/**
|
||
* Ensure an account exists in the shim, creating its 3 scope documents on
|
||
* first sight. Idempotent — returns the existing record if already present.
|
||
* Concurrency-safe: concurrent calls for the same account share one provision
|
||
* (see {@link ensureInFlight}) so a fresh page never FORKS the account.
|
||
*/
|
||
export async function ensureAccount(id: string): Promise<VirtualUserRecord> {
|
||
const key = accountKey(id);
|
||
// A completed provision/resolve is cached → no query, no fork risk.
|
||
const cached = accountCache.get(key);
|
||
if (cached) {
|
||
fileOwnStructure(id, cached);
|
||
return cached;
|
||
}
|
||
// A concurrent provision for the SAME account is already running → await it,
|
||
// instead of racing a second (forking) provision. This is the anti-fork guard.
|
||
const pending = ensureInFlight.get(key);
|
||
if (pending) return pending;
|
||
|
||
const p = (async (): Promise<VirtualUserRecord> => {
|
||
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
||
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
|
||
//
|
||
// Barrier-AUTHORITATIVE: the lookup 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.
|
||
//
|
||
// `lookupAccount`, NOT the tolerant `resolveAccount`: what follows an absence here is
|
||
// a PROVISION. The tolerant form answers `null` for a read that failed exactly as for
|
||
// one that found nothing, so a broker that could not answer minted a second set of
|
||
// three store documents and a second account record — the very account FORK the
|
||
// barrier was introduced to end, walking straight back in through the error path. The
|
||
// session then writes its entities into the new set while the old one holds
|
||
// everything the user had, and only `canonicalDoc` decides afterwards which of the
|
||
// two a later session sees. Absence is a reason to create; ignorance is not
|
||
// (`lookupAccount` exists for exactly this distinction, 2026-08-10).
|
||
const existing = await lookupAccount(id);
|
||
if (existing) {
|
||
fileOwnStructure(id, existing);
|
||
return existing;
|
||
}
|
||
|
||
const doc = await resolveShimDoc();
|
||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||
createDoc(),
|
||
createDoc(),
|
||
createDoc(),
|
||
]);
|
||
const record: VirtualUserRecord = { id, docPublic, docProtected, docPrivate };
|
||
// Persist the record INTO the doc-shim (not the store-root anymore).
|
||
await writeRecord(doc, record);
|
||
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
||
// (so allAccounts / the fan-out see the freshly-created account too).
|
||
accountCache.set(key, record);
|
||
fileOwnStructure(id, record);
|
||
return record;
|
||
})();
|
||
|
||
ensureInFlight.set(key, p);
|
||
try {
|
||
return await p;
|
||
} finally {
|
||
ensureInFlight.delete(key);
|
||
}
|
||
}
|
||
|
||
// --- resolvers ------------------------------------------------------------
|
||
|
||
/** Fixed subject of the shim's inbox INDEX — see {@link recordInbox}. */
|
||
const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`;
|
||
|
||
/**
|
||
* Record that `nuri` IS an inbox, in the shim — the emulated counterpart of what the
|
||
* broker knows by construction upstream.
|
||
*
|
||
* Upstream you cannot address a repo with a deposit: `InboxPost` seals to an inbox
|
||
* PUBKEY, and the broker routes it through `inboxes: PubKey → RepoId`
|
||
* (`engine/verifier/src/verifier.rs:105,1677`). Depositing into a plain document is not
|
||
* refused there — it is unrepresentable. Here an inbox is a document like any other, so
|
||
* "is this an inbox?" has to be asked of something, and the shim is where the emulation
|
||
* keeps what the network would know.
|
||
*
|
||
* Written through the PHYSICAL door: which NURIs are inboxes is not one virtual user's
|
||
* business, exactly like the account records beside it.
|
||
*
|
||
* ── And WHOSE inbox it is, recorded beside it ─────────────────────────────
|
||
* Same table, same reason. Upstream the routing entry `inboxes: PubKey → RepoId`
|
||
* (`engine/verifier/src/verifier.rs:105`) does not merely say "this is an inbox": it names
|
||
* the REPO, and the repo's own verifier — its owner's — is what unseals and applies the
|
||
* message (`verifier.rs:1677`). "Which inbox" and "whose" are one fact there, so they are
|
||
* one record here. Written for BOTH kinds of inbox, and by the same call: a user's own
|
||
* ({@link userInbox}) and a document's (`branch-registers.openDocumentInbox`), each of
|
||
* which knows its owner at the moment it mints one.
|
||
*
|
||
* Two `INSERT DATA`s rather than one: the fact keyed by the index subject and the fact
|
||
* keyed by the inbox are two subjects, and one statement carrying two subjects is a shape
|
||
* this library writes nowhere else. Both must land before the inbox is handed back, for the
|
||
* reason spelled out below.
|
||
*
|
||
* **Propagates a failed write, and only marks the session's index once the write landed.**
|
||
* This record is what the emulation puts in place of a fact the network knows by
|
||
* construction, and `inbox.post` refuses to deposit into anything it cannot confirm
|
||
* ({@link isKnownInbox}). Swallowing left an inbox that its owner resolves and addresses
|
||
* normally while every depositor is turned away — permanently, since nothing ever records
|
||
* it a second time. Marking `knownInboxes` regardless made it worse by hiding the damage
|
||
* from the only session able to see it: the one that opened the inbox believed the record
|
||
* existed, and every other session did not.
|
||
*/
|
||
export async function recordInbox(nuri: Nuri, ownerId: string): Promise<void> {
|
||
const s = await session();
|
||
const shimDoc = await resolveShimDoc();
|
||
try {
|
||
await physicalUpdate(
|
||
s.sessionId,
|
||
`INSERT DATA { <${INBOX_INDEX_SUBJECT}> <${P.isInbox}> "${escapeLiteral(nuri)}" }`,
|
||
shimDoc,
|
||
"recordInbox",
|
||
);
|
||
// The inbox NURI is the subject here — the routing entry is keyed by the inbox, exactly
|
||
// as upstream's table is keyed by its pubkey. It is a NURI (`did:ng:` + base64url and
|
||
// `:` segments), so it carries nothing that could break out of the `<…>`.
|
||
await physicalUpdate(
|
||
s.sessionId,
|
||
`INSERT DATA { <${assertNuri(nuri)}> <${P.inboxOwner}> "${escapeLiteral(ownerId)}" }`,
|
||
shimDoc,
|
||
"recordInbox:owner",
|
||
);
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " recordInbox failed:", error);
|
||
throw error;
|
||
}
|
||
knownInboxes.add(nuri);
|
||
inboxOwners.set(nuri, ownerId);
|
||
}
|
||
|
||
/** Inboxes this session has already confirmed — the index only ever grows. */
|
||
const knownInboxes = new Set<Nuri>();
|
||
/** inbox → the id of the user it belongs to, as this session has resolved it. */
|
||
const inboxOwners = new Map<Nuri, string>();
|
||
|
||
/**
|
||
* WHOSE inbox is this? The routing question, answered from the shim — see
|
||
* {@link recordInbox}. `null` when nothing records an owner for it, which for a NURI that
|
||
* IS an inbox means the record predates this association or its write did not land.
|
||
*
|
||
* Asked through the physical door, like {@link isKnownInbox} beside it: routing is not one
|
||
* virtual user's business, and the caller is precisely the one that is not the owner.
|
||
*
|
||
* **Propagates a failed read.** Its caller (`emulated-verifier/inbox-processor.ts`) decides
|
||
* on the answer whether to drain an inbox and, if so, for whom — so "the broker could not
|
||
* answer" and "nobody owns this" must not arrive as the same value. Answering `null` on a
|
||
* failed read would file a deposit under nobody and say nothing.
|
||
*/
|
||
export async function inboxOwner(nuri: Nuri): Promise<string | null> {
|
||
const memo = inboxOwners.get(nuri);
|
||
if (memo !== undefined) return memo;
|
||
const s = await session();
|
||
const shimDoc = await resolveShimDoc();
|
||
const res = await physicalQuery(
|
||
s.sessionId,
|
||
`SELECT ?u WHERE { <${assertNuri(nuri)}> <${P.inboxOwner}> ?u }`,
|
||
undefined,
|
||
shimDoc,
|
||
"inboxOwner",
|
||
);
|
||
for (const row of readBindings(res)) {
|
||
const id = bindingValue(row, "u");
|
||
if (id) {
|
||
inboxOwners.set(nuri, id);
|
||
return id;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Is `nuri` an inbox? Asked of the shim, through the physical door — a depositor is not
|
||
* the inbox's owner and holds nothing of it, which is the whole point of an inbox.
|
||
*/
|
||
export async function isKnownInbox(nuri: Nuri): Promise<boolean> {
|
||
if (knownInboxes.has(nuri)) return true;
|
||
const s = await session();
|
||
const shimDoc = await resolveShimDoc();
|
||
try {
|
||
const res = await physicalQuery(
|
||
s.sessionId,
|
||
`SELECT ?i WHERE { <${INBOX_INDEX_SUBJECT}> <${P.isInbox}> ?i }`,
|
||
undefined,
|
||
shimDoc,
|
||
"isKnownInbox",
|
||
);
|
||
for (const row of readBindings(res)) {
|
||
const v = bindingValue(row, "i");
|
||
if (v && isNuri(v)) knownInboxes.add(v);
|
||
}
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " isKnownInbox failed:", error);
|
||
}
|
||
return knownInboxes.has(nuri);
|
||
}
|
||
|
||
/** The index document NURI of an account for a scope (the store-container). */
|
||
export function storeOf(record: VirtualUserRecord, scope: StoreType): 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: StoreType): 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: StoreType): 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.
|
||
*/
|
||
// @provenance storeRegistry.resolveScopeGraph kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — `did:ng:` + `private_store_id | protected_store_id | public_store_id`, straight off the session
|
||
export async function resolveScopeGraph(scope: StoreType): Promise<Nuri> {
|
||
return scopeStoreNuri(scope);
|
||
}
|
||
|
||
/**
|
||
* In-flight `userInbox` resolutions, keyed by account key — so concurrent callers
|
||
* for the SAME wallet share ONE resolve-or-create instead of racing two documents
|
||
* into existence (mirrors {@link ensureInFlight}).
|
||
*/
|
||
const inboxInFlight = new Map<string, Promise<Nuri>>();
|
||
/** Resolved wallet inboxes, keyed by account key. Cleared with the registry cache. */
|
||
const inboxCache = new Map<string, Nuri>();
|
||
|
||
/**
|
||
* The inbox of a virtual user — where caps and messages addressed to THEM arrive.
|
||
*
|
||
* **Renamed from `walletInbox` on 2026-08-03, and the old name was wrong twice.**
|
||
* A *wallet* upstream is only a keyring; what owns stores — and therefore what an inbox
|
||
* belongs to — is a **user** (a *site*): `SensitiveWalletV0.sites: HashMap<String, SiteV0>`
|
||
* (`engine/wallet/src/types.rs:456`), `SiteV0` carrying `public`/`protected`/`private`
|
||
* (`engine/verifier/src/site.rs:31-37`). The library had corrected that vocabulary
|
||
* everywhere else and this name survived the pass — and it did cost: reasoning about
|
||
* "the inbox per wallet" hid that upstream a user has **two**.
|
||
*
|
||
* **Known divergence, deliberate.** Upstream a user has TWO inboxes, one on its public
|
||
* store repo and one on its protected store repo — the only two `AddInboxCap` commits in
|
||
* the engine (`engine/verifier/src/site.rs:128,149`; `new_store_default` attaches one
|
||
* only `if !private`, `engine/verifier/src/verifier.rs:2994`). They are distinguished
|
||
* right down to the predicates a contact record uses (`ng:site_inbox` vs
|
||
* `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:374-375`). This function
|
||
* exposes ONE. Collapsing them is a simplification this library has not yet had a reason
|
||
* to undo; the day a caller needs to address a user's public inbox distinctly from its
|
||
* protected one, this is the seam that has to split in two.
|
||
*
|
||
* The NURI of a virtual user's OWN inbox — where deposits addressed to that
|
||
* identity land, ReadCaps among them.
|
||
*
|
||
* ── Why a wallet owns an inbox, and why that is load-bearing ───────────────
|
||
* You cannot discover in NextGraph; you can only follow links. So a link crosses
|
||
* from one wallet to another through exactly one channel: a deposit into the
|
||
* recipient's inbox. That makes the inbox the **bootstrap of the whole
|
||
* reachability graph** rather than a side feature — and it is why an inbox has to
|
||
* BELONG to someone. Before this existed, an inbox was any NURI a caller passed,
|
||
* so "read the inbox" meant "read anyone's inbox", and since cap-surface routes
|
||
* caps through it, reading someone else's collected the caps addressed to them.
|
||
*
|
||
* Created on first sight and stable thereafter. Recorded in the doc-shim under its
|
||
* own predicate, read by its OWN query rather than added to the account SELECT: an
|
||
* account record written before this existed must keep resolving, which it would
|
||
* not if the fixed account pattern grew a fourth required field.
|
||
*
|
||
* Concurrency-safe (see {@link inboxInFlight}), and a fork is reconciled the same
|
||
* content-addressed way as everything else ({@link canonicalDoc}).
|
||
*
|
||
* ── TWO inboxes, because upstream a user has two ─────────────────────────
|
||
* A *site* carries an inbox on its **public** store repo and another on its
|
||
* **protected** one — the only two `AddInboxCap` commits in the engine
|
||
* (`engine/verifier/src/site.rs:127-152`), `new_store_default` attaching one solely
|
||
* `if !private` (`engine/verifier/src/verifier.rs:2994`). They are addressed
|
||
* separately right down to the contact records, which pick their predicate from the
|
||
* profile being reached: `ng:site_inbox` for a public profile, `ng:protected_inbox`
|
||
* otherwise (`engine/verifier/src/inbox_processor.rs:787,823-824`).
|
||
*
|
||
* This function used to expose ONE, which was a cardinality this library invented.
|
||
* Corrected 2026-08-03 by walking the cascade: neither the JS ORM nor the wasm binding
|
||
* says anything about inboxes — `@ng-org/web` has no method containing "inbox" and the
|
||
* session exposes none — so the engine's model is what decides, and it says two.
|
||
*
|
||
* **The private store has none**, hence {@link InboxScope} rather than `StoreType`: asking
|
||
* for a private inbox is not a lookup that returns nothing, it is a question the model
|
||
* has no meaning for.
|
||
*
|
||
* At migration these become the site's native store inboxes and the resolution moves
|
||
* there — the consumer-facing act (deposit to an inbox, process my own) is unchanged.
|
||
*/
|
||
// @provenance userInbox kind=aligned level=1 ref=engine/verifier/src/site.rs:create_individual_ — a user's inboxes are its public and protected STORE repos' — the engine's only two `AddInboxCap` commits
|
||
export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
|
||
const key = `${accountKey(id)}\u0000${scope}`;
|
||
const cached = inboxCache.get(key);
|
||
if (cached) {
|
||
fileOwnInbox(id, cached);
|
||
return cached;
|
||
}
|
||
const pending = inboxInFlight.get(key);
|
||
if (pending) return pending;
|
||
|
||
const p = (async (): Promise<Nuri> => {
|
||
const s = await session();
|
||
const shimDoc = await resolveShimDoc();
|
||
await ensureAccount(id); // the account must exist before it can own an inbox
|
||
const subj = accountSubject(id);
|
||
// One triple per (user, scope): the two inboxes are distinct documents, as the two
|
||
// store repos that carry them are distinct upstream.
|
||
const pred = `${P.docInbox}:${scope}`;
|
||
let existing: MaybeNuri;
|
||
try {
|
||
// The doc-shim is machinery: this reads WHICH inbox a virtual user owns,
|
||
// which is exactly the kind of question that cannot be confined to that user.
|
||
const res = await physicalQuery(
|
||
s.sessionId,
|
||
`SELECT ?d WHERE { <${subj}> <${pred}> ?d }`,
|
||
undefined,
|
||
shimDoc,
|
||
"userInbox",
|
||
);
|
||
existing = canonicalDoc(readBindings(res), "d");
|
||
} catch (error) {
|
||
// ONLY A VERIFIED ABSENCE MAY MINT. This catch logged and let execution fall
|
||
// through to the mint below, so a read that could not ANSWER was treated as "this
|
||
// user owns no inbox" — and a second document was created for one (user, scope).
|
||
// Two `docInbox` triples then sit on the same subject, and which one wins
|
||
// afterwards is decided by `canonicalDoc` picking among them: the owner can drain
|
||
// one while a depositor writes into the other, each of them right, with nothing
|
||
// anywhere to see. Same fault as `connect.connectedUser` (2026-08-13), except this
|
||
// one writes the mistake down.
|
||
console.error(accessLogPrefix() + " userInbox read failed:", error);
|
||
throw error;
|
||
}
|
||
if (existing) {
|
||
inboxCache.set(key, existing);
|
||
fileOwnInbox(id, existing);
|
||
return existing;
|
||
}
|
||
|
||
// The broker answered, and it answered nothing: this user genuinely owns no inbox for
|
||
// this scope yet. THAT is what mints one — and the three writes it takes all have to
|
||
// land before anyone is handed the result.
|
||
const doc = await createDoc();
|
||
await recordInbox(doc, id);
|
||
try {
|
||
await physicalUpdate(
|
||
s.sessionId,
|
||
`INSERT DATA { <${subj}> <${pred}> "${escapeLiteral(doc)}" }`,
|
||
shimDoc,
|
||
"userInbox",
|
||
);
|
||
} catch (error) {
|
||
// This triple IS the answer to "which inbox does this user own". Caching and
|
||
// returning the document over a failed write handed the owner a reference nobody
|
||
// else can resolve: a depositor reading the shim finds nothing and mints yet
|
||
// another. The owner then reads a box nobody writes to, and depositors write to a
|
||
// box nobody reads — the exact shape sharing broke in before.
|
||
console.error(accessLogPrefix() + " userInbox persist failed:", error);
|
||
throw error;
|
||
}
|
||
// Filed and cached only now, and in that order: until the association was written
|
||
// this document was not yet anyone's inbox, and holding a cap for it (or answering
|
||
// with it for the rest of the session) would outlive the failure that decided it.
|
||
fileOwnInbox(id, doc);
|
||
inboxCache.set(key, doc);
|
||
logStage("userInbox(" + key + "/" + scope + ") → " + shortNuri(doc));
|
||
return doc;
|
||
})();
|
||
|
||
inboxInFlight.set(key, p);
|
||
try {
|
||
return await p;
|
||
} finally {
|
||
inboxInFlight.delete(key);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a dedicated document for ONE entity — mirrors the target, where each
|
||
* such entity is its own document/repo (addressable, future inbox). The new
|
||
* document's NURI is appended to the account's scope index document (the
|
||
* store-container). Returns the entity document NURI (use it as `@graph`) — a
|
||
* CAP-LESS reference, exactly like `doc_create` upstream: it names the document,
|
||
* it does not carry its key. The key goes to what the creator holds (see
|
||
* {@link holdOwnCap}), which is where you look it up.
|
||
*/
|
||
export async function createEntityDoc(id: string, scope: StoreType): Promise<Nuri> {
|
||
const record = await ensureAccount(id);
|
||
const indexDoc = storeOf(record, scope);
|
||
const entityNuri = await createDoc();
|
||
const s = await session();
|
||
const cap = mintCap(entityNuri);
|
||
// TWO writes, both attempted, and the failure reported only after. They are separate
|
||
// because upstream they are two commits on two branches — `ldp:contains` on the store's
|
||
// Main branch and `AddRepo { read_cap }` on its Store branch
|
||
// (`engine/verifier/src/request_processor.rs:697-710`) — and because either is worth
|
||
// having without the other: the cap alone lets the creator re-open its document, the
|
||
// listing alone makes it findable.
|
||
//
|
||
// A first attempt at surfacing the failure (2026-08-07) threw on the FIRST one, and an
|
||
// adversarial pass showed it was wrong in both directions: throwing on the listing
|
||
// skipped the cap write, orphaning the document entirely — destroying the very recovery
|
||
// the comment above describes — and throwing on the cap left the document LISTED with
|
||
// no key, which is precisely the "reads empty forever" state it claimed to prevent.
|
||
// Attempt both, keep what lands, and say exactly what state the document is in.
|
||
const failures: string[] = [];
|
||
try {
|
||
await registerUpdate(
|
||
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-document.ts).
|
||
// 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);
|
||
failures.push(`it is not listed in its store (${String(error)})`);
|
||
}
|
||
try {
|
||
await registerUpdate(
|
||
s.sessionId,
|
||
`INSERT DATA { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> "${escapeLiteral(cap)}" }`,
|
||
indexDoc,
|
||
"createEntityDoc:addRepo",
|
||
);
|
||
} catch (error) {
|
||
console.error(accessLogPrefix() + " createEntityDoc cap append failed:", error);
|
||
failures.push(`its key is not recorded (${String(error)})`);
|
||
}
|
||
if (failures.length > 0) {
|
||
// The document EXISTS on the broker; what failed is the bookkeeping that makes it
|
||
// findable and re-openable. Returning its NURI would hand back a reference that reads
|
||
// empty on the next session, silently — so the caller is told instead.
|
||
throw new Error(
|
||
`[ng-eventually] createEntityDoc: the document ${entityNuri} was created but ` +
|
||
`${failures.join(", and ")}. It will not survive this session as it stands.`,
|
||
);
|
||
}
|
||
// …and the creator holds THAT cap for this session.
|
||
holdOwnCap(id, scope, entityNuri, cap);
|
||
// A document in a PUBLIC store hands its cap to whoever asks — upstream because the
|
||
// broker exposes the outer overlay and the ReadCap is downloaded from it
|
||
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`), here because the cap is put
|
||
// where the machinery can fetch it. This is what makes a BARE reference enough for a
|
||
// public document, so nothing in this library ever has to put a key into a link.
|
||
if (scope === "public") await exposeReadCap(entityNuri, cap);
|
||
// NO inbox here, and NOT the owner's own inbox published as this document's address.
|
||
// Upstream an inbox belongs to exactly ONE repo: the verifier routes an incoming
|
||
// message by `inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`)
|
||
// and unseals it with THAT repo's private half, while `InboxMsgBody` carries no
|
||
// target document at all (`engine/net/src/types.rs:4265`) — because it needs none,
|
||
// the address IS the identification. Pointing several documents at one inbox would
|
||
// emulate a many-to-one relation the model cannot express, and would teach consumers
|
||
// to tag deposits with their document, a habit that has to be unlearned at migration.
|
||
//
|
||
// So a document gets an inbox only when its owner opens one
|
||
// ({@link openDocumentInbox}), which is also what keeps the cost proportional: only
|
||
// documents meant to RECEIVE pay for one (see
|
||
// `docs/briefs/2026-08-03-document-inbox-addressing.md`).
|
||
return entityNuri;
|
||
}
|
||
|
||
/**
|
||
* Read the entity-document NURIs contained in ONE scope index document.
|
||
*
|
||
* **Propagates a failed read**, like {@link readStoreCaps} and `readLinks` next to it, and
|
||
* for the same reason: empty and unreadable are the same value here — "this account put
|
||
* nothing in this store" against "the store did not answer" — and the first is a fact an
|
||
* application acts on while the second is a breakdown it must be told about. Swallowing the
|
||
* difference is what {@link listMyEntityDocs} exists downstream of, and answering an empty
|
||
* listing for a read that never happened is the failure-as-absence this library was bitten
|
||
* by twice.
|
||
*
|
||
* A caller that genuinely prefers to carry on catches it itself, and one does: `ownsDocument`,
|
||
* whose answer is a REFUSAL either way (see it).
|
||
*/
|
||
export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
|
||
const s = await session();
|
||
const out: Nuri[] = [];
|
||
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
|
||
// scope-index repo (public OR protected — the protected one carries participations
|
||
// and is the one that most often reads empty) is not yet in `self.repos`, so this
|
||
// anchored read would return 0 NURIs → nothing gets listed → nothing gets
|
||
// subscribed (the self-inflicted circularity). Open/subscribe the index repo ONCE
|
||
// before reading it. Idempotent per session; no-op with the unit fake ng. See
|
||
// open-repo.ts.
|
||
await ensureRepoOpen(indexDoc);
|
||
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);
|
||
}
|
||
logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
|
||
return out;
|
||
}
|
||
|
||
// `userStoreDoc(id, scope)` was REMOVED in `1.0.0-dev.6`. It handed out the scope-INDEX
|
||
// document so that a reactive reader could SUBSCRIBE to it and re-resolve its document set
|
||
// when the index changed - and the reactive reader was the only thing that ever wanted it.
|
||
// `useShapeQuery` now reads through the SDK's own shape query, which follows the data
|
||
// rather than a container this package maintains, so the last caller went with the
|
||
// pipeline. `storeOf(await ensureAccount(id), scope)` is the two-line body, for whoever
|
||
// needs the value again.
|
||
|
||
/**
|
||
* 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`.
|
||
*
|
||
* **Only a VERIFIED answer comes back as a listing.** Both reads it stands on propagate —
|
||
* the store's Main branch, which says WHICH documents are there, and its Store branch,
|
||
* which hands back the KEY of each. A listing is a promise that what it names can be
|
||
* opened, and until 2026-08-17 the key read was tolerated here: the caller got the
|
||
* documents without the means to read them, and nothing on the surface told the two apart.
|
||
* From the outside a listing you can open and one you cannot are the same array — the
|
||
* difference only appears at the next read, coming back empty, long after the cause is
|
||
* gone. That is the shape of the outage this library shipped twice
|
||
* (`resolveAccount` answering "no such account" for a read that failed, `connectedUser`
|
||
* resolving over a restore that never ran), so the tolerance goes with it.
|
||
*
|
||
* Nothing to list is NOT a failure and stays silent: a store that answers with zero
|
||
* documents means this account created none, which is a fact.
|
||
*/
|
||
export async function listMyEntityDocs(id: string, scope: StoreType): 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
|
||
// (cap-enforcement) 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();
|
||
// The holder was decided just above; `readStoreCaps` awaits, and filing resolves the
|
||
// holder when it runs — so hand the captured key back rather than trust that the
|
||
// identity has not moved. See `caps.holderKey`.
|
||
const holderKey = caps.holderKey();
|
||
// PROPAGATES, like `readStoreCaps` itself and like `restoreOwnCaps` next door. Holding
|
||
// the listing already is exactly why this must not swallow: the array is in hand, so
|
||
// returning it costs nothing and says something false — these are yours, here they are.
|
||
// A caller has no second question to ask that would reveal the keys are missing.
|
||
for (const cap of await readStoreCaps(store)) caps.learnFor(holderKey, cap);
|
||
// Which store a document sits in is a registry fact, not one recorded beside the
|
||
// caps, so it is re-applied here. Marking only — the caps just came from the Store
|
||
// branch above, and minting a second one beside them is the trap `holdOwnCap` warns
|
||
// about.
|
||
if (scope === "public") for (const d of docs) caps.markInPublicStore(d);
|
||
}
|
||
return docs;
|
||
}
|