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:
@@ -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<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.
|
||||
*/
|
||||
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<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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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. */
|
||||
export 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. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user