Align the cap emulation on NextGraph's model, and confine it to a virtual user

Two batches, verified against nextgraph-rs throughout.

P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>),
the exact inversion of key possession. It is now possession: `capFor(nuri)` is
the only question, there is no principal parameter anywhere, and nothing turns
a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link
deposit; receiving needs no operation. `Nuri` and `ReadCap` are template
literal types, so passing a bare reference where a cap belongs is a compile
error, with runtime guards behind it for JavaScript callers.

The virtual user boundary. Every access function is now confined to the
connected user, through two rules on one criterion (possession), implemented in
two places so a lapse in either is caught by the other: authorization at the
passage points, and "do not even attempt" at the callers. The polyfill's own
machinery moved to physical.ts — unguarded, never exported — which replaced an
exemption list: the machinery no longer gets waved through the guard, it calls
something the guard never saw.

Removed, as emulating capabilities the target does not have:
- discovery.ts and its global index. There is no discovery in NextGraph; you
  follow links. It also pooled user data across wallets.
- the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts,
  loadShim), which was cross-user enumeration by construction.
- resolveInboxAnchor, a single inbox common to every user.

Caps are now stored where NextGraph stores them, and read back rather than
recomputed: AddRepo on the store's Store branch for documents a user creates,
AddLink on its User branch for caps received. Inboxes belong to someone — the
user's own, plus one per document — and connecting a user drains them all;
that is the library's job, not the app's.

Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by
NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO
have a register (AddLink), contrary to what this repo's notes claimed; and
"wallet" upstream means keyring — what owns three stores is a user, so the
vocabulary follows.

The cap value is the constant OK: the only question the emulation answers is
whether a cap is held. P1b replaces that one constant with a real key.

After this the shape is right and the isolation is still fake. Nothing here may
be described as anonymous or private.
This commit is contained in:
Sylvain Duchesne
2026-08-03 11:22:01 +02:00
parent 6f0d0586e2
commit ae9c32e271
51 changed files with 4245 additions and 1543 deletions
+487 -140
View File
@@ -59,15 +59,30 @@
* `ng`), so this module imports **no** `@ng-org` package.
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen } from "./open-repo";
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 { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { Nuri, Scope } from "./types";
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;
@@ -84,11 +99,47 @@ const P = {
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
} 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 INDEX_SUBJECT = `${SHIM}:index`;
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`;
// --- pointer (store-root → doc-shim indirection) --------------------------
//
@@ -178,10 +229,6 @@ async function rootNuri(): Promise<Nuri> {
// --- cache ----------------------------------------------------------------
// In-memory cache of the FULL shim (all accounts), keyed by account key. Set
// only once loadShim() has read every account — used by the all-accounts paths.
let cache: Map<string, AccountRecord> | null = null;
// 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.
@@ -197,8 +244,9 @@ let shimDocInFlight: Promise<Nuri> | null = null;
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
export function resetRegistryCache(): void {
cache = null;
accountCache.clear();
inboxCache.clear();
inboxInFlight.clear();
shimDocNuri = null;
shimDocInFlight = null;
}
@@ -231,7 +279,7 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
* 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 `readScopeIndex` returns 0 →
* 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
@@ -247,12 +295,15 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
* 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): Nuri {
let chosen = "";
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);
if (!v) continue;
// 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;
}
@@ -277,11 +328,17 @@ function recordFromRows(
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"),
docProtected: canonicalDoc(rows, "docProtected"),
docPrivate: canonicalDoc(rows, "docPrivate"),
docPublic: canonicalDoc(rows, "docPublic") as Nuri,
docProtected: canonicalDoc(rows, "docProtected") as Nuri,
docPrivate: canonicalDoc(rows, "docPrivate") as Nuri,
};
}
@@ -307,14 +364,14 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
* canonical (lexicographically-smallest) doc-shim NURI — content-addressed and
* stable, so every device converges on the SAME doc-shim.
*/
async function resolvePointer(): Promise<Nuri> {
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 ensureRepoOpen(root);
await ensurePhysicalRepoOpen(root);
const query = `
SELECT ?shimDoc WHERE {
GRAPH <${assertNuri(root)}> {
@@ -335,7 +392,7 @@ async function resolvePointer(): Promise<Nuri> {
let step = baseMs;
for (let i = 0; i < attempts; i++) {
try {
const result = await sparqlQuery(s.sessionId, query, undefined, root, "resolvePointer");
const result = await physicalQuery(s.sessionId, query, undefined, root, "resolvePointer");
const doc = canonicalDoc(readBindings(result), "shimDoc");
if (doc) {
logStage("resolvePointer → 1 target: " + shortNuri(doc));
@@ -359,7 +416,7 @@ async function resolvePointer(): Promise<Nuri> {
async function writePointer(doc: Nuri): Promise<void> {
const s = await session();
const root = await rootNuri();
await ensureRepoOpen(root);
await ensurePhysicalRepoOpen(root);
const update = `
INSERT DATA {
GRAPH <${assertNuri(root)}> {
@@ -367,7 +424,7 @@ async function writePointer(doc: Nuri): Promise<void> {
}
}`;
try {
await sparqlUpdate(s.sessionId, update, root, "writePointer");
await physicalUpdate(s.sessionId, update, root, "writePointer");
} catch (error) {
console.error(accessLogPrefix() + " writePointer failed:", error);
}
@@ -378,7 +435,7 @@ 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 docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
return physicalCreate(s.sessionId);
}
/**
@@ -406,7 +463,7 @@ async function resolveShimDoc(): Promise<Nuri> {
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 ensureRepoOpen(existing);
await ensurePhysicalRepoOpen(existing);
shimDocNuri = existing;
logStage("resolveShimDoc → " + shortNuri(existing));
return existing;
@@ -416,7 +473,7 @@ async function resolveShimDoc(): Promise<Nuri> {
// the pointer, then open (no-op barrier for a just-created repo).
const doc = await createDoc();
await writePointer(doc);
await ensureRepoOpen(doc);
await ensurePhysicalRepoOpen(doc);
shimDocNuri = doc;
logStage("resolveShimDoc → " + shortNuri(doc));
return doc;
@@ -432,52 +489,6 @@ async function resolveShimDoc(): Promise<Nuri> {
// --- shim load / account bootstrap ----------------------------------------
/** Load all accounts from the shim (the doc-shim) into the cache. */
export async function loadShim(): Promise<Map<string, AccountRecord>> {
if (cache) return cache;
const s = await session();
const doc = await resolveShimDoc();
const query = `
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
?acc a <${P.type}> ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}`;
const map = new Map<string, AccountRecord>();
// The doc-shim is opened (first-`State` barrier) by resolveShimDoc, so this read is
// authoritative.
await ensureRepoOpen(doc);
try {
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "loadShim");
// Group ALL bindings by account key first, then pick the CANONICAL doc per
// scope (see recordFromRows / canonicalDoc). A single account subject may carry
// duplicate scope-doc values (fork residue) → several bindings; grouping +
// canonical selection makes loadShim resolve the SAME doc the targeted
// resolveAccount does, so full-scan and hot-path readers never disagree.
const byKey = new Map<string, Array<Record<string, { value: string }>>>();
for (const row of readBindings(result)) {
const id = bindingValue(row, "id");
if (!id) continue;
const key = accountKey(id);
const bucket = byKey.get(key) ?? [];
bucket.push(row);
byKey.set(key, bucket);
}
for (const [key, rows] of byKey) {
const record = recordFromRows(rows, rows[0] ? bindingValue(rows[0], "id") : key);
map.set(key, record);
// Feed the per-account cache too, so a subsequent targeted resolve is free.
accountCache.set(key, record);
}
} catch (error) {
console.error(accessLogPrefix() + " loadShim failed:", error);
}
cache = map;
return map;
}
/**
* 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
@@ -516,7 +527,7 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
<${P.docPrivate}> ?docPrivate .
}`;
try {
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "resolveAccount");
const result = await physicalQuery(s.sessionId, query, undefined, doc, "resolveAccount");
const rows = readBindings(result);
if (rows.length === 0) {
logStage("resolveAccount(" + key + ") → null");
@@ -536,11 +547,6 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
}
}
/** All known accounts (from the shim). */
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()];
}
/** 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> {
@@ -560,7 +566,7 @@ async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
<${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" .
}`;
try {
await sparqlUpdate(s.sessionId, update, doc, "writeRecord");
await physicalUpdate(s.sessionId, update, doc, "writeRecord");
} catch (error) {
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
}
@@ -594,7 +600,10 @@ 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) return cached;
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);
@@ -609,7 +618,10 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
// 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) return existing;
if (existing) {
fileOwnStructure(id, existing);
return existing;
}
const doc = await resolveShimDoc();
const [docPublic, docProtected, docPrivate] = await Promise.all([
@@ -623,7 +635,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
// 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);
cache?.set(key, record);
fileOwnStructure(id, record);
return record;
})();
@@ -638,7 +650,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
// --- resolvers ------------------------------------------------------------
/** The index document NURI of an account for a scope (the store-container). */
function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
function storeOf(record: AccountRecord, scope: Scope): Nuri {
return scope === "public"
? record.docPublic
: scope === "protected"
@@ -653,13 +665,7 @@ function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
*/
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
}
/** NURIs of every account's document for `scope` (read fan-out). */
export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
const accounts = await allAccounts();
return accounts.map((a) => indexDocOf(a, scope));
return storeOf(record, scope);
}
// --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ----------
@@ -699,29 +705,170 @@ export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
}
/**
* The reserved account that OWNS the shared registration-inbox document. Like the
* discovery index's special account, it lives in the reserved namespace (no user
* can produce this key) and only HOSTS a document — its `public` scope document is
* the inbox anchor. Disappears at migration (native per-document inboxes).
* 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 INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
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 anchor NURI for the current session (where emulated inbox deposits
* physically land). SDK-shaped: the consumer never resolves a store itself.
* The NURI of a virtual user's OWN inbox — where deposits addressed to that
* identity land, ReadCaps among them.
*
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
* account→document trust root) is scanned on every `loadShim`; routing every inbox
* deposit into that SAME graph bloats it without bound (thousands of deposit triples
* across sessions). A separate inbox document keeps the shim graph small and the
* deposits isolated. At migration this becomes the host's native per-document inbox
* and the resolution moves here.
* ── 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 resolveInboxAnchor(): Promise<Nuri> {
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
return record.docPublic;
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 -------------------------------
@@ -730,11 +877,14 @@ export async function resolveInboxAnchor(): Promise<Nuri> {
* 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`).
* 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 = indexDocOf(record, scope);
const indexDoc = storeOf(record, scope);
const entityNuri = await createDoc();
const s = await session();
try {
@@ -742,24 +892,70 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
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 (readScopeIndex below, same as
// 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 { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
`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);
return entityNuri;
}
/**
* 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 readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
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
@@ -775,39 +971,21 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
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 { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
`SELECT ?e WHERE { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> ?e }`,
undefined,
indexDoc,
"readScopeIndex",
"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) out.push(v);
if (v && isNuri(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readScopeIndex failed:", error);
}
logStage("readScopeIndex(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
return out;
}
/**
* Every entity document NURI of `scope`, across all accounts — the read
* fan-out for per-entity scopes. Reads each account's scope index document and
* unions the contained NURIs. Use as `useShape(shape, { graphs })`.
*
* NOTE (read-by-need): this ALL-ACCOUNTS fan-out contradicts the read-by-need
* model (docs/read-model.md) — it opens/syncs other accounts' possibly-unsynced
* docs, which HANGS. Prefer {@link listMyEntityDocs} (my own account's scope
* docs) for "my entities", and the discovery index for "all public events".
* Retained for callers that legitimately need every account (tests).
*/
export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
const accounts = await allAccounts();
const out: Nuri[] = [];
for (const a of accounts) {
out.push(...(await readScopeIndex(indexDocOf(a, scope))));
console.error(accessLogPrefix() + " readUserStore failed:", error);
}
logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
return out;
}
@@ -820,9 +998,9 @@ export async function listEntityDocs(scope: Scope): Promise<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 scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
return storeOf(record, scope);
}
/**
@@ -833,7 +1011,176 @@ export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
* 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 document, 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:1969-1981`), the same branch that carries `AddLink`.
* So "which inboxes may I read" is answered by the User branch, and that is what
* this emulates.
*
* Lazy on purpose: 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.
*
* Only for documents this user holds — you cannot open an inbox on someone else's
* document, you can only deposit into it.
*/
export async function documentInbox(doc: Nuri): Promise<Nuri> {
const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] documentInbox: no identity is set");
const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known;
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,
"documentInbox",
);
} catch (error) {
console.error(accessLogPrefix() + " documentInbox persist failed:", error);
}
}
return inbox;
}
/** 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);
return readScopeIndex(indexDocOf(record, scope));
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;
}