refactor(layout): séparer les registres de branche du shim

`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.
This commit is contained in:
Sylvain Duchesne
2026-08-04 14:09:06 +02:00
parent 36c0148750
commit e01a8dbab1
6 changed files with 395 additions and 318 deletions
@@ -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<RegistrySession> {
export async function session(): Promise<RegistrySession> {
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<Record<string, { value: string }>> {
export function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
@@ -298,7 +310,7 @@ function readBindings(result: unknown): Array<Record<string, { value: string }>>
return [];
}
function bindingValue(row: Record<string, { value: string }>, key: string): string {
export function bindingValue(row: Record<string, { value: string }>, key: string): string {
return row[key]?.value ?? "";
}
@@ -684,7 +696,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
// --- 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<Nuri> {
}
}
/**
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
* inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false
* for everyone until an identity is set.
*/
export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
if ((await walletInbox(holder)) === nuri) return true;
// …and the inbox of any document this user opened one on (the emulated
// `AddInboxCap` records on its User branch).
return (await readInboxCapPairs()).some((p) => p.inbox === nuri);
}
// --- the cap side of a user's store ----------------------------------
/**
* File the caps of documents the CURRENT holder owns into what they hold — the
* emulated `AddRepo { read_cap }`.
*
* Upstream, creating a document commits an `AddRepo { read_cap }` into a typed
* branch of the store, and that branch — listing the store's documents, each with
* its read key — carries the owner's caps. Here the per-(account × scope) index
* document plays the store-container role, so it carries the caps too: a
* document appended to it on creation, or read back from it on a later session,
* puts its cap in the owner's hands with nothing for the consumer to do. That is
* what makes the invariant hold both ways — you never derive a cap from a bare
* reference, and yet a document's own creator is never locked out of it.
*
* Scoped to the current holder ON PURPOSE: another account's documents are listed
* by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically
* not ours to hold. `id` is compared through the shim key, so it matches however
* the consumer spells the identity.
*/
function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
// `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was
// written to the Store branch, not a second one minted from the NURI. They agree
// today only because the stand-in value is a constant; with a real key (P1b) a
// second mint would produce a DIFFERENT key and the document would be unreadable
// by the very session that created it. Mint once, store it, hold that one.
caps.learn(cap);
// Publication is a registry fact, not a stored one, so it is applied separately.
if (scope === "public") caps.publishRepoLink(doc);
}
/**
* File the caps of the documents a virtual user owns BY BEING one: its three
* stores, and its inbox. They are as much its documents as any entity it creates,
* and without them it cannot even list its own content — the boundary would lock a
* user out of itself.
*
* Scoped to the current holder, like {@link holdOwnCap}: another user's stores are
* emphatically not ours to hold.
*/
function fileOwnStructure(id: string, record: AccountRecord): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
if (record.docPublic) caps.open(record.docPublic, "public");
if (record.docProtected) caps.open(record.docProtected, "protected");
if (record.docPrivate) caps.open(record.docPrivate, "private");
}
/** Same, for the user's own inbox — it is its document, and it must be able to
* read it. Depositing into someone else's needs no cap (see `docs.depositInto`). */
function fileOwnInbox(id: string, inbox: Nuri): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
getCaps().open(inbox, "private");
}
// --- per-entity documents + per-scope index -------------------------------
/**
* Create a dedicated document for ONE entity — mirrors the target, where each
* such entity is its own document/repo (addressable, future inbox). The new
@@ -975,67 +911,8 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
return entityNuri;
}
/**
* Publish WHERE to deposit for `doc`, on its Header branch — the compartment any
* holder of the document can read.
*
* Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
* so two addresses on one document is a state the model has no meaning for — and a
* depositor picking the stale one writes where nobody reads.
*/
async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
const s = await session();
try {
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
// form verified against the real broker (see
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
// is not exercised anywhere in this lib.
await sparqlUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
doc,
"publishInboxAddress:clear",
);
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
doc,
"publishInboxAddress",
);
} catch (error) {
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
}
}
/**
* The ReadCaps recorded on a store's Store branch — its documents, each with its
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
* what it owns without recomputing anything.
*/
async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
const s = await session();
const out: ReadCap[] = [];
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
undefined,
storeDoc,
"readStoreCaps",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
}
return out;
}
/** Read the entity-document NURIs contained in ONE scope index document. */
async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
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
@@ -1188,175 +1065,6 @@ export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
return inbox;
}
/**
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
* function an app calls before `inbox.post`.
*
* Readable by whoever can read the document, because it lives on its Header branch —
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
* key). Conversely someone who cannot read the document learns nothing, which is
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
* reach you.
*
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
* only its owner opens one, and only on its own document.
*/
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
// RULE 2 — do not even attempt. Not holding the document, we have no address to
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
// deposit for a document I cannot read" is not a refused question, it is a question
// with no referent. Answering `undefined` here keeps the caller's shape (an address
// or none) instead of turning the boundary into an exception it must catch.
if (mustNotAttempt(doc)) return undefined;
const s = await session();
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
undefined,
doc,
"documentInboxAddress",
);
for (const row of readBindings(res)) {
const a = bindingValue(row, "a");
if (a && isNuri(a)) return a;
}
} catch (error) {
// Unreadable document (no cap) or not synced → no address to give. Refusing to
// read is the boundary doing its job, not an error to propagate here.
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
}
return undefined;
}
/**
* Does the connected user own `doc`? Answered from its **Store branches** — the
* register of the documents it created — across the three scopes, which is the only
* place that records authorship. Holding a cap is NOT ownership: a cap can be
* received, and a recipient must not be able to open an inbox on what it merely reads.
*/
async function ownsDocument(doc: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
const record = await resolveAccount(holder);
if (record === null) return false;
for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope);
if (!store) continue;
if ((await readUserStore(store)).includes(doc)) return true;
}
return false;
}
/** The `(document, inbox)` pairs recorded on this user's User branch. */
async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
const holder = getCurrentUser();
if (holder === null) return [];
const record = await resolveAccount(holder);
const store = record?.docPrivate;
if (!store) return [];
const s = await session();
const out: Array<{ doc: Nuri; inbox: Nuri }> = [];
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> ?c }`,
undefined,
store,
"readInboxCaps",
);
for (const row of readBindings(res)) {
const [doc, inbox] = bindingValue(row, "c").split(" ");
if (doc && inbox && isNuri(doc) && isNuri(inbox)) out.push({ doc, inbox });
}
} catch (error) {
console.error(accessLogPrefix() + " readInboxCaps failed:", error);
}
return out;
}
/** The inbox recorded for one document, if this user opened one. */
async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox;
}
/**
* Every inbox this user may READ: its own, plus one per document it opened an
* inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from.
*/
export async function myInboxes(): Promise<Nuri[]> {
const holder = getCurrentUser();
if (holder === null) return [];
const out: Nuri[] = [];
if ((await resolveAccount(holder)) !== null) out.push(await walletInbox(holder));
for (const { inbox } of await readInboxCapPairs()) out.push(inbox);
return out;
}
/**
* File a cap received for someone ELSE's document — the emulated
* `AddLink { read_cap }` on the User branch of the current user's private store.
*
* This is what makes a received cap DURABLE. Before it, a shared document survived
* only by re-reading the inbox every session, which uses a queue as a database:
* upstream an inbox is consumed, and processing a message *applies* it. Applying a
* Link means writing it here.
*
* Idempotent — re-applying the same Link is a no-op, so re-processing an inbox
* (a second tab, a reconnect) costs nothing.
*/
export async function addLink(cap: ReadCap): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
const record = await ensureAccount(holder);
const store = record.docPrivate;
if (!store) return;
if ((await readLinks()).includes(cap)) return;
const s = await session();
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`,
store,
"addLink",
);
} catch (error) {
console.error(accessLogPrefix() + " addLink failed:", error);
}
}
/**
* The caps this user has received and applied — the User branch read back. Called
* at connection to restore what was shared with them, without touching any inbox.
*/
export async function readLinks(): Promise<ReadCap[]> {
const holder = getCurrentUser();
if (holder === null) return [];
const record = await ensureAccount(holder);
const store = record.docPrivate;
if (!store) return [];
const s = await session();
const out: ReadCap[] = [];
await ensureRepoOpen(store);
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`,
undefined,
store,
"readLinks",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readLinks failed:", error);
}
return out;
}
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(id);
const store = storeOf(record, scope);