From e01a8dbab1e34eb4105649c530c1eeb6f7d8341e Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 4 Aug 2026 14:09:06 +0200 Subject: [PATCH] =?UTF-8?q?refactor(layout):=20s=C3=A9parer=20les=20regist?= =?UTF-8?q?res=20de=20branche=20du=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `store-registry.ts` portait deux destins dans un seul fichier de 1379 lignes. `emulated-verifier/branch-registers.ts` prend les compartiments durables — registre de caps de branche Store (`AddRepo`), registre de Links de branche User (`AddLink`), enregistrements d'inbox (`AddInboxCap`), adresses de branche Header — chacun nommant son mécanisme natif. Ils émulent la comptabilité du VERIFIER et survivent conceptuellement : à la migration le natif les reprend, seule notre représentation RDF disparaît. `shared-wallet/account-registry.ts` garde le shim proprement dit — indirection pointeur → doc-shim, résolution et provisionnement des comptes, cache. Aucun pendant amont, s'évapore en entier. Les imports croisés entre les deux sont délibérés et visibles : un registre a besoin du shim pour savoir À QUI il est, le shim classe la structure d'un user au moment où il le résout. Tout l'usage est en corps de fonction, donc le cycle de modules est inerte à l'évaluation. 157 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker. --- .../src/emulated-verifier/branch-registers.ts | 368 ++++++++++++++++++ .../client/src/emulated-verifier/connect.ts | 3 +- .../src/shared-wallet/account-registry.ts | 336 ++-------------- packages/client/src/surface/inbox.ts | 2 +- packages/client/src/surface/placement.ts | 2 +- .../client/test/cross-user-access.test.ts | 2 +- 6 files changed, 395 insertions(+), 318 deletions(-) create mode 100644 packages/client/src/emulated-verifier/branch-registers.ts diff --git a/packages/client/src/emulated-verifier/branch-registers.ts b/packages/client/src/emulated-verifier/branch-registers.ts new file mode 100644 index 0000000..a430c8d --- /dev/null +++ b/packages/client/src/emulated-verifier/branch-registers.ts @@ -0,0 +1,368 @@ +/** + * The durable cap/inbox registers — this library's stand-in for the compartments the + * verifier maintains on a repo's own branches. + * + * Upstream these are not RDF at all: they are streams of service commits on branches + * whose CRDT is `BranchCrdt::None` (`engine/repo/src/types.rs:1420`). Each register here + * names its native counterpart: + * + * - **Store branch** — `AddRepo { read_cap }` (`engine/repo/src/types.rs:1890-1899`): + * the cap of a document you CREATED, filed beside the store that holds it. Replaying + * it is what reloads a store's documents with their keys (`AddRepo::verify` -> + * `Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`). + * - **User branch, links** — `AddLink { read_cap }` (`types.rs:1939-1948`), *"so that a + * user can share with all its device a new Link they received"*, external repos only. + * - **User branch, inbox caps** — `AddInboxCap { repo_id, overlay, priv_key }` + * (`types.rs:1969-1981`): which inboxes you may READ. Keyed by `repo_id`, hence valid + * for ANY repo — `update_inbox_cap_v0` applies it with no `is_store` check + * (`engine/verifier/src/verifier.rs:1920`). + * - **Header branch** — a document's deposit ADDRESS, readable by any holder of it. + * The one register with NO native counterpart: upstream an address is TRANSMITTED + * (a message, a profile QR code), never published, and `inboxes: PubKey -> RepoId` is + * a per-session local table (`verifier.rs:105`). Publishing is our divergence, taken + * because an emulation has no message channel — see + * `docs/briefs/2026-08-03-document-inbox-addressing.md`. + * + * Why separate from the shim next door: these emulate the VERIFIER's bookkeeping and + * survive conceptually — at migration the native side keeps them, only our RDF + * representation goes. `shared-wallet/account-registry.ts` has no counterpart at all and + * evaporates. One file until 2026-08-03, two fates. + * + * The imports back into `shared-wallet/` are deliberate, visible cross-fate edges: a + * register needs the shim to know WHOSE it is, and where its store document lives. Every + * use sits inside a function body, so the module cycle is inert at evaluation time. + */ + +import { sparqlUpdate, sparqlQuery } from "../surface/docs"; +import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; +import { escapeLiteral } from "../surface/sparql"; +import { hasReadCap, isNuri } from "../model/nuri"; +import { mustNotAttempt } from "./reach"; +import { ensureRepoOpen } from "./open-repo"; +import { accessLogPrefix } from "../shared-wallet/access-log"; +import { + P, + USER_BRANCH_SUBJECT, + STORE_BRANCH_SUBJECT, + HEADER_BRANCH_SUBJECT, + accountKey, + session, + readBindings, + bindingValue, + resolveAccount, + storeOf, + readUserStore, + walletInbox, + ensureAccount, + type AccountRecord, +} from "../shared-wallet/account-registry"; +import type { Nuri, ReadCap, Scope } from "../model/types"; + +/** + * 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 { + 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. + */ +export 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. + */ +export 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`). */ +export 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 ------------------------------- + +/** + * Publish WHERE to deposit for `doc`, on its Header branch — the compartment any + * holder of the document can read. + * + * Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's + * `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option`), + * so two addresses on one document is a state the model has no meaning for — and a + * depositor picking the stale one writes where nobody reads. + */ +export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise { + const s = await session(); + try { + // Two separate updates, not one compound statement: `DELETE WHERE { … }` is the + // form verified against the real broker (see + // `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update + // is not exercised anywhere in this lib. + await sparqlUpdate( + s.sessionId, + `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, + doc, + "publishInboxAddress:clear", + ); + await sparqlUpdate( + s.sessionId, + `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`, + doc, + "publishInboxAddress", + ); + } catch (error) { + console.error(accessLogPrefix() + " publishInboxAddress failed:", error); + } +} + +/** + * The ReadCaps recorded on a store's Store branch — its documents, each with its + * key. The emulated replay of `AddRepo`, and the reason a fresh session recovers + * what it owns without recomputing anything. + */ +export async function readStoreCaps(storeDoc: Nuri): Promise { + 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; +} + +/** + * WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never + * opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the + * function an app calls before `inbox.post`. + * + * Readable by whoever can read the document, because it lives on its Header branch — + * an address is public by nature (upstream a depositor needs only the inbox PUBLIC + * key). Conversely someone who cannot read the document learns nothing, which is + * faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to + * reach you. + * + * **Never creates.** Asking where to deposit must not bring an inbox into existence — + * only its owner opens one, and only on its own document. + */ +export async function documentInboxAddress(doc: Nuri): Promise { + // RULE 2 — do not even attempt. Not holding the document, we have no address to + // find: upstream the inbox pubkey travels WITH what you can read, so "where do I + // deposit for a document I cannot read" is not a refused question, it is a question + // with no referent. Answering `undefined` here keeps the caller's shape (an address + // or none) instead of turning the boundary into an exception it must catch. + if (mustNotAttempt(doc)) return undefined; + const s = await session(); + try { + const res = await sparqlQuery( + s.sessionId, + `SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, + undefined, + doc, + "documentInboxAddress", + ); + for (const row of readBindings(res)) { + const a = bindingValue(row, "a"); + if (a && isNuri(a)) return a; + } + } catch (error) { + // Unreadable document (no cap) or not synced → no address to give. Refusing to + // read is the boundary doing its job, not an error to propagate here. + console.error(accessLogPrefix() + " documentInboxAddress failed:", error); + } + return undefined; +} + +/** + * Does the connected user own `doc`? Answered from its **Store branches** — the + * register of the documents it created — across the three scopes, which is the only + * place that records authorship. Holding a cap is NOT ownership: a cap can be + * received, and a recipient must not be able to open an inbox on what it merely reads. + */ +export async function ownsDocument(doc: Nuri): Promise { + const holder = getCurrentUser(); + if (holder === null) return false; + const record = await resolveAccount(holder); + if (record === null) return false; + for (const scope of ["public", "protected", "private"] as const) { + const store = storeOf(record, scope); + if (!store) continue; + if ((await readUserStore(store)).includes(doc)) return true; + } + return false; +} + +/** The `(document, inbox)` pairs recorded on this user's User branch. */ +export async function readInboxCapPairs(): Promise> { + 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. */ +export async function readInboxCapsFor(doc: Nuri): Promise { + 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 { + 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 { + 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 { + 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; +} + diff --git a/packages/client/src/emulated-verifier/connect.ts b/packages/client/src/emulated-verifier/connect.ts index a3befdb..daf1e84 100644 --- a/packages/client/src/emulated-verifier/connect.ts +++ b/packages/client/src/emulated-verifier/connect.ts @@ -35,7 +35,8 @@ */ import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; -import { myInboxes, readLinks, resolveAccount } from "../shared-wallet/account-registry"; +import { resolveAccount } from "../shared-wallet/account-registry"; +import { myInboxes, readLinks } from "./branch-registers"; import { processInbox } from "../surface/inbox"; /** The in-flight connection work, per user key — so two calls do not race. */ diff --git a/packages/client/src/shared-wallet/account-registry.ts b/packages/client/src/shared-wallet/account-registry.ts index ad523c0..7e59916 100644 --- a/packages/client/src/shared-wallet/account-registry.ts +++ b/packages/client/src/shared-wallet/account-registry.ts @@ -64,6 +64,18 @@ import { sparqlUpdate, sparqlQuery } from "../surface/docs"; import { physicalCreate, physicalQuery, physicalUpdate } from "./physical"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./bootstrap"; +// Cross-fate edge, deliberate: resolving a user is also when its own structure gets +// filed, and creating a document is when its cap does. See branch-registers.ts. +import { + fileOwnStructure, + fileOwnInbox, + holdOwnCap, + publishInboxAddress, + readStoreCaps, + readInboxCapsFor, + ownsDocument, + documentInboxAddress, +} from "../emulated-verifier/branch-registers"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { ensurePhysicalRepoOpen, subscribePhysicalDoc } from "./physical"; import { escapeLiteral, escapeIri, assertNuri } from "../surface/sparql"; @@ -97,7 +109,7 @@ export interface AccountRecord { } const SHIM = "urn:ng-eventually:shim"; -const P = { +export const P = { type: `${SHIM}:Account`, id: `${SHIM}:id`, docPublic: `${SHIM}:docPublic`, @@ -126,7 +138,7 @@ const MAIN_BRANCH_SUBJECT = `${SHIM}:index`; * branch, the `ldp:contains` listing). Two compartments, two subjects — the * separation upstream makes with two branches. */ -const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`; +export const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`; /** * Fixed subject of the **Store branch** emulation, inside a user's store document. * @@ -145,7 +157,7 @@ const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`; * is stored beside the document rather than recomputed*, and that the listing and the * keys stay separate. */ -const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`; +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 @@ -173,7 +185,7 @@ const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`; * name is borrowed for the shape — a compartment of the document that is not its * content — not for the upstream branch's contract. */ -const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`; +export const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`; // --- pointer (store-root → doc-shim indirection) -------------------------- // @@ -226,7 +238,7 @@ function isReserved(id: string): boolean { * entirely and key on their sentinel-prefixed name, so they cannot collide with * a normalized id; everyone else normalizes as usual. */ -function accountKey(id: string): string { +export function accountKey(id: string): string { return isReserved(id) ? id : normalize(id); } @@ -248,7 +260,7 @@ function normalize(id: string): string { return getStoreRegistryDeps().normalizeId(id); } -async function session(): Promise { +export async function session(): Promise { return getStoreRegistryDeps().getSession(); } @@ -288,7 +300,7 @@ export function resetRegistryCache(): void { // --- SPARQL result helpers ------------------------------------------------ /** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ -function readBindings(result: unknown): Array> { +export function readBindings(result: unknown): Array> { if (!result) return []; const anyRes = result as { results?: { bindings?: Array> }; @@ -298,7 +310,7 @@ function readBindings(result: unknown): Array> return []; } -function bindingValue(row: Record, key: string): string { +export function bindingValue(row: Record, key: string): string { return row[key]?.value ?? ""; } @@ -684,7 +696,7 @@ export async function ensureAccount(id: string): Promise { // --- resolvers ------------------------------------------------------------ /** The index document NURI of an account for a scope (the store-container). */ -function storeOf(record: AccountRecord, scope: Scope): Nuri { +export function storeOf(record: AccountRecord, scope: Scope): Nuri { return scope === "public" ? record.docPublic : scope === "protected" @@ -831,82 +843,6 @@ export async function walletInbox(id: string): Promise { } } -/** - * 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 { - const holder = getCurrentUser(); - if (holder === null) return false; - if ((await walletInbox(holder)) === nuri) return true; - // …and the inbox of any document this user opened one on (the emulated - // `AddInboxCap` records on its User branch). - return (await readInboxCapPairs()).some((p) => p.inbox === nuri); -} - -// --- the cap side of a user's store ---------------------------------- - -/** - * File the caps of documents the CURRENT holder owns into what they hold — the - * emulated `AddRepo { read_cap }`. - * - * Upstream, creating a document commits an `AddRepo { read_cap }` into a typed - * branch of the store, and that branch — listing the store's documents, each with - * its read key — carries the owner's caps. Here the per-(account × scope) index - * document plays the store-container role, so it carries the caps too: a - * document appended to it on creation, or read back from it on a later session, - * puts its cap in the owner's hands with nothing for the consumer to do. That is - * what makes the invariant hold both ways — you never derive a cap from a bare - * reference, and yet a document's own creator is never locked out of it. - * - * Scoped to the current holder ON PURPOSE: another account's documents are listed - * by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically - * not ours to hold. `id` is compared through the shim key, so it matches however - * the consumer spells the identity. - */ -function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void { - const holder = getCurrentUser(); - if (holder === null || accountKey(holder) !== accountKey(id)) return; - const caps = getCaps(); - // `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was - // written to the Store branch, not a second one minted from the NURI. They agree - // today only because the stand-in value is a constant; with a real key (P1b) a - // second mint would produce a DIFFERENT key and the document would be unreadable - // by the very session that created it. Mint once, store it, hold that one. - caps.learn(cap); - // Publication is a registry fact, not a stored one, so it is applied separately. - if (scope === "public") caps.publishRepoLink(doc); -} - -/** - * File the caps of the documents a virtual user owns BY BEING one: its three - * stores, and its inbox. They are as much its documents as any entity it creates, - * and without them it cannot even list its own content — the boundary would lock a - * user out of itself. - * - * Scoped to the current holder, like {@link holdOwnCap}: another user's stores are - * emphatically not ours to hold. - */ -function fileOwnStructure(id: string, record: AccountRecord): void { - const holder = getCurrentUser(); - if (holder === null || accountKey(holder) !== accountKey(id)) return; - const caps = getCaps(); - if (record.docPublic) caps.open(record.docPublic, "public"); - if (record.docProtected) caps.open(record.docProtected, "protected"); - if (record.docPrivate) caps.open(record.docPrivate, "private"); -} - -/** Same, for the user's own inbox — it is its document, and it must be able to - * read it. Depositing into someone else's needs no cap (see `docs.depositInto`). */ -function fileOwnInbox(id: string, inbox: Nuri): void { - const holder = getCurrentUser(); - if (holder === null || accountKey(holder) !== accountKey(id)) return; - getCaps().open(inbox, "private"); -} - -// --- per-entity documents + per-scope index ------------------------------- - /** * Create a dedicated document for ONE entity — mirrors the target, where each * such entity is its own document/repo (addressable, future inbox). The new @@ -975,67 +911,8 @@ export async function createEntityDoc(id: string, scope: Scope): Promise { return entityNuri; } -/** - * Publish WHERE to deposit for `doc`, on its Header branch — the compartment any - * holder of the document can read. - * - * Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's - * `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option`), - * so two addresses on one document is a state the model has no meaning for — and a - * depositor picking the stale one writes where nobody reads. - */ -async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise { - const s = await session(); - try { - // Two separate updates, not one compound statement: `DELETE WHERE { … }` is the - // form verified against the real broker (see - // `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update - // is not exercised anywhere in this lib. - await sparqlUpdate( - s.sessionId, - `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, - doc, - "publishInboxAddress:clear", - ); - await sparqlUpdate( - s.sessionId, - `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`, - doc, - "publishInboxAddress", - ); - } catch (error) { - console.error(accessLogPrefix() + " publishInboxAddress failed:", error); - } -} - -/** - * The ReadCaps recorded on a store's Store branch — its documents, each with its - * key. The emulated replay of `AddRepo`, and the reason a fresh session recovers - * what it owns without recomputing anything. - */ -async function readStoreCaps(storeDoc: Nuri): Promise { - const s = await session(); - const out: ReadCap[] = []; - try { - const res = await sparqlQuery( - s.sessionId, - `SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`, - undefined, - storeDoc, - "readStoreCaps", - ); - for (const row of readBindings(res)) { - const v = bindingValue(row, "c"); - if (v && hasReadCap(v)) out.push(v); - } - } catch (error) { - console.error(accessLogPrefix() + " readStoreCaps failed:", error); - } - return out; -} - /** Read the entity-document NURIs contained in ONE scope index document. */ -async function readUserStore(indexDoc: Nuri): Promise { +export async function readUserStore(indexDoc: Nuri): Promise { const s = await session(); const out: Nuri[] = []; // COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the @@ -1188,175 +1065,6 @@ export async function openDocumentInbox(doc: Nuri): Promise { return inbox; } -/** - * WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never - * opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the - * function an app calls before `inbox.post`. - * - * Readable by whoever can read the document, because it lives on its Header branch — - * an address is public by nature (upstream a depositor needs only the inbox PUBLIC - * key). Conversely someone who cannot read the document learns nothing, which is - * faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to - * reach you. - * - * **Never creates.** Asking where to deposit must not bring an inbox into existence — - * only its owner opens one, and only on its own document. - */ -export async function documentInboxAddress(doc: Nuri): Promise { - // RULE 2 — do not even attempt. Not holding the document, we have no address to - // find: upstream the inbox pubkey travels WITH what you can read, so "where do I - // deposit for a document I cannot read" is not a refused question, it is a question - // with no referent. Answering `undefined` here keeps the caller's shape (an address - // or none) instead of turning the boundary into an exception it must catch. - if (mustNotAttempt(doc)) return undefined; - const s = await session(); - try { - const res = await sparqlQuery( - s.sessionId, - `SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, - undefined, - doc, - "documentInboxAddress", - ); - for (const row of readBindings(res)) { - const a = bindingValue(row, "a"); - if (a && isNuri(a)) return a; - } - } catch (error) { - // Unreadable document (no cap) or not synced → no address to give. Refusing to - // read is the boundary doing its job, not an error to propagate here. - console.error(accessLogPrefix() + " documentInboxAddress failed:", error); - } - return undefined; -} - -/** - * Does the connected user own `doc`? Answered from its **Store branches** — the - * register of the documents it created — across the three scopes, which is the only - * place that records authorship. Holding a cap is NOT ownership: a cap can be - * received, and a recipient must not be able to open an inbox on what it merely reads. - */ -async function ownsDocument(doc: Nuri): Promise { - const holder = getCurrentUser(); - if (holder === null) return false; - const record = await resolveAccount(holder); - if (record === null) return false; - for (const scope of ["public", "protected", "private"] as const) { - const store = storeOf(record, scope); - if (!store) continue; - if ((await readUserStore(store)).includes(doc)) return true; - } - return false; -} - -/** The `(document, inbox)` pairs recorded on this user's User branch. */ -async function readInboxCapPairs(): Promise> { - 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 { - 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 { - 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 { - 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 { - 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 { const record = await ensureAccount(id); const store = storeOf(record, scope); diff --git a/packages/client/src/surface/inbox.ts b/packages/client/src/surface/inbox.ts index c0f80e8..cb8f02c 100644 --- a/packages/client/src/surface/inbox.ts +++ b/packages/client/src/surface/inbox.ts @@ -30,7 +30,7 @@ import { depositInto, sparqlQuery } from "./docs"; import { subscribeDoc } from "./subscribe"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; -import { addLink, documentInboxAddress, isOwnInbox } from "../shared-wallet/account-registry"; +import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers"; import { escapeLiteral } from "./sparql"; import { hasReadCap } from "../model/nuri"; import { diff --git a/packages/client/src/surface/placement.ts b/packages/client/src/surface/placement.ts index 064becd..2ce2339 100644 --- a/packages/client/src/surface/placement.ts +++ b/packages/client/src/surface/placement.ts @@ -33,5 +33,5 @@ export { /** Open an inbox on a document you OWN, so others can deposit into it. */ openDocumentInbox, /** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */ - documentInboxAddress, } from "../shared-wallet/account-registry"; +export { documentInboxAddress } from "../emulated-verifier/branch-registers"; diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index 70f3832..a6c2608 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -20,11 +20,11 @@ import { test, expect, mock, afterAll } from "bun:test"; import { createEntityDoc, - documentInboxAddress, openDocumentInbox, resetRegistryCache, walletInbox, } from "../src/shared-wallet/account-registry"; +import { documentInboxAddress } from "../src/emulated-verifier/branch-registers"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; import { configure,