refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever l'ambiguïté à chaque phrase — et le contrat publié, lu par une application, était le pire endroit pour laisser traîner ça. packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill, contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts → e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md. Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes, y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est fait occurrence par occurrence, pas par substitution. Le contrat énonce désormais son identité en une phrase : « This package is a polyfill of NextGraph's SDK. »
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* 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 { sparqlQuery } from "../surface/docs";
|
||||
import { registerUpdate } from "./register-write";
|
||||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { escapeLiteral } from "../surface/sparql";
|
||||
import { hasReadCap, isNuri, toNuri } from "../model/nuri";
|
||||
import { mustNotAttempt } from "./reach";
|
||||
import { fetchReadCap } from "./public-store";
|
||||
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,
|
||||
userInbox,
|
||||
createDoc,
|
||||
ensureAccount,
|
||||
recordInbox,
|
||||
type VirtualUserRecord,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import type { InboxScope, Nuri, NuriLike, 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;
|
||||
// Either of the user's two inboxes counts as its own.
|
||||
for (const scope of ["public", "protected"] as const) {
|
||||
if ((await userInbox(holder, scope)) === 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);
|
||||
// Which store the document sits in is a registry fact, applied separately — and a
|
||||
// MARK only, for the same reason the cap above is learned rather than re-minted.
|
||||
if (scope === "public") caps.markInPublicStore(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: VirtualUserRecord): 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 `register-write.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 registerUpdate(
|
||||
s.sessionId,
|
||||
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
|
||||
doc,
|
||||
"publishInboxAddress:clear",
|
||||
);
|
||||
await registerUpdate(
|
||||
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.
|
||||
// …but ask the (emulated) network first: a document in a public store serves its cap
|
||||
// to whoever asks (public-store.ts), and "where do I deposit for this public
|
||||
// document" is exactly the question a third party arrives with, holding nothing but
|
||||
// the reference.
|
||||
await fetchReadCap(doc);
|
||||
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. */
|
||||
/**
|
||||
* Encode the `(document, inbox)` pair of an emulated `AddInboxCap` record.
|
||||
*
|
||||
* Upstream this is a TYPED structure — `AddInboxCapV0 { repo_id, overlay, priv_key }`
|
||||
* (`engine/repo/src/types.rs:1973`) — carried by a service commit, not a string. Ours is
|
||||
* one RDF literal because our User branch is a subject in a document, so the pairing has
|
||||
* to live inside a value. That is the emulation's shape, and it is what migration
|
||||
* replaces: the fields become fields again.
|
||||
*
|
||||
* The separator is a space, which is safe for a reason worth stating rather than
|
||||
* assuming: a NURI is `did:ng:` followed by base64url and `:`-separated segments
|
||||
* (`NuriV0`, `engine/net/src/app_protocol.rs`), an alphabet that contains no space. The
|
||||
* assertion below turns that from an implicit property into a checked one — a silently
|
||||
* mis-split pair would file an inbox under a truncated document and lose deposits with
|
||||
* no error, which is exactly the failure class this whole path already paid for once.
|
||||
*/
|
||||
function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
|
||||
if (doc.includes(" ") || inbox.includes(" ")) {
|
||||
throw new Error(
|
||||
"[ng-eventually] branch-registers: a NURI containing a space cannot be paired in " +
|
||||
`an inbox-cap record — the separator would be ambiguous: ${JSON.stringify([doc, inbox])}`,
|
||||
);
|
||||
}
|
||||
return `${doc} ${inbox}`;
|
||||
}
|
||||
|
||||
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)) {
|
||||
// See `encodeInboxCap` for why a space is a safe separator here, and why this
|
||||
// pairing exists at all.
|
||||
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[] = [];
|
||||
// BOTH of the user's inboxes — public and protected — since upstream a site carries
|
||||
// one on each of those two store repos (`engine/verifier/src/site.rs:127-152`).
|
||||
if ((await resolveAccount(holder)) !== null) {
|
||||
for (const scope of ["public", "protected"] as const) out.push(await userInbox(holder, scope));
|
||||
}
|
||||
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 registerUpdate(
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The inbox of a document this user owns — resolved, and created on first ask.
|
||||
*
|
||||
* Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`):
|
||||
* an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is
|
||||
* recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the user branch,
|
||||
* so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the
|
||||
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
|
||||
* User branch, and that is what this emulates.
|
||||
*
|
||||
* **The engine SUPPORTS this; nothing exercises it automatically.** Those are two
|
||||
* different statements, and conflating them is what made an earlier version of this
|
||||
* comment call the feature an "anticipation". It is not. `inbox: Option<PrivKey>` is a
|
||||
* field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure;
|
||||
* `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and
|
||||
* `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no
|
||||
* `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by
|
||||
* construction, and at any time (see the User-branch note above).
|
||||
*
|
||||
* What is true is narrower: no code path CREATES one for a document — `new_store_default`
|
||||
* attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None`,
|
||||
* and the only two `AddInboxCap` commits in the engine are for the
|
||||
* public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the
|
||||
* capability exists and is simply unexposed above level 1: this function is aligned on
|
||||
* the engine's model, it does not bet past it.
|
||||
*
|
||||
* *(The `inbox: None` claim is true; its citation was wrong until 2026-08-10. It pointed
|
||||
* at `repo.rs:574`, inside `Repo::new_with_member` (`engine/repo/src/repo.rs:543`) —
|
||||
* a constructor reached only from `Repo::new_with_perms`, itself gated
|
||||
* `#[cfg(any(test, feature = "testing"))]` (`repo.rs:186-192`), and from `#[cfg(test)]`
|
||||
* blocks (`branch.rs:387,490`; `commit.rs:1659,1849,1919`). The PRODUCTION path is
|
||||
* `doc_create` → `Verifier::new_repo_default` (`engine/verifier/src/verifier.rs:3004`,
|
||||
* called at `request_processor.rs:689`) → `Store::create_repo_default`
|
||||
* (`engine/repo/src/store.rs:264`) → `create_repo_with_keys` (`store.rs:284`), which
|
||||
* builds the `Repo` with `inbox: None` at `store.rs:691`.)*
|
||||
*
|
||||
* Lazy on purpose, for the same reason: creating an inbox document for every entity up
|
||||
* front would double every `createEntityDoc` for inboxes most documents never receive
|
||||
* anything in. Upstream the keypair is cheap; here an inbox is a document, so it is
|
||||
* minted when first asked for.
|
||||
*
|
||||
* *(Not covered: ROTATING an inbox key — the engine's "update" case with a new
|
||||
* `priv_key`. This function is idempotent and returns the existing inbox instead. A
|
||||
* known limit, not an oversight.)*
|
||||
*
|
||||
* Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on
|
||||
* someone else's document would be usurpation, not a courtesy: the opener keeps the
|
||||
* reading half, so it would silently divert to itself the deposits meant for the
|
||||
* owner. To deposit into someone else's document, resolve
|
||||
* {@link documentInboxAddress} and `inbox.post` into it.
|
||||
*/
|
||||
export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
|
||||
// Permissive in, precise out — see `model/nuri.ts`. Published through
|
||||
// `surface/placement.ts`, so it is a door an application types against.
|
||||
const doc = toNuri(docLike, "openDocumentInbox");
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
|
||||
const known = (await readInboxCapsFor(doc)) ?? null;
|
||||
if (known) return known;
|
||||
|
||||
// OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be
|
||||
// received. Opening an inbox is what PUBLISHES this document's address, so a
|
||||
// non-owner doing it would route the owner's deposits to itself, silently, on a
|
||||
// document it merely reads.
|
||||
//
|
||||
// **This guard compensates OUR design, not an upstream constraint** — an earlier
|
||||
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
|
||||
// false: that commit lands on the committer's OWN User branch, so anyone may write
|
||||
// one naming anyone's repo. What protects upstream is that an inbox address is never
|
||||
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
|
||||
// code), and `inboxes: PubKey → RepoId` is a table of the VERIFIER
|
||||
// (`engine/verifier/src/verifier.rs:105`) — one per user. A forged pair reaches
|
||||
// nobody because it only ever lands in the forger's OWN table; nobody else was told.
|
||||
//
|
||||
// The motive matters, and it was wrong here until 2026-08-10: this comment said the
|
||||
// table is "rebuilt empty each session", which is not what the source does. It is
|
||||
// initialized empty at construction (`:520`, `:2820`) and then REPOPULATED at every
|
||||
// load — `Verifier::load` (`:534-566`) → `add_repo_without_saving` (`:2871`) →
|
||||
// `add_repo_` (`:2887`), which re-inserts `repo.inbox.to_pub() → repo.id` for each
|
||||
// repo it reloads — and the inbox private key itself is persisted per repo
|
||||
// (`INBOX_CAP`, `engine/verifier/src/user_storage/repo.rs:61,171,207,362`). So the
|
||||
// knowledge is durable; what it is not is SHARED. Per-verifier, not ephemeral.
|
||||
//
|
||||
// We publish instead of transmitting — the only way a third party can find the
|
||||
// address at all here — which creates a vector upstream does not have: whoever can
|
||||
// write the document can redirect its deposits. Hence this guard. It is a real
|
||||
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
|
||||
if (!(await ownsDocument(doc))) {
|
||||
throw new Error(
|
||||
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
|
||||
"you own. To reach its owner, name the DOCUMENT: `inbox.postToDocument(doc, …)`, " +
|
||||
`which resolves the address itself: ${JSON.stringify(doc)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const inbox = await createDoc();
|
||||
const s = await session();
|
||||
const record = await ensureAccount(holder);
|
||||
const store = record.docPrivate;
|
||||
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
|
||||
// …and the shim records that it IS an inbox, so a depositor can find that out without
|
||||
// holding anything of it. See `recordInbox`: upstream a deposit cannot address a plain
|
||||
// document at all, and this is what stands in for that impossibility.
|
||||
await recordInbox(inbox);
|
||||
if (store) {
|
||||
try {
|
||||
await registerUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`,
|
||||
store,
|
||||
"openDocumentInbox",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
|
||||
}
|
||||
}
|
||||
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
|
||||
// Without this the inbox is reachable only by its owner — the opposite of what an
|
||||
// inbox is for, and the bug this path shipped with.
|
||||
await publishInboxAddress(doc, inbox);
|
||||
return inbox;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Capability emulation — key POSSESSION, not an authorization list.
|
||||
*
|
||||
* In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
|
||||
* and there is no read-ACL anywhere. This module emulates that shape (see
|
||||
* `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
|
||||
* question — *do I hold this document's cap?* — and cannot answer "may principal P
|
||||
* read document D", because the real model cannot either.
|
||||
*
|
||||
* ── Where caps come from — and why this is NOT "a keyring" ────────────────
|
||||
* There is no keyring object in NextGraph, and calling this one invited a wrong
|
||||
* mental model: that some single place holds every key. It does not. Upstream the
|
||||
* caps of a user are in **two** places, by origin (see
|
||||
* `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
|
||||
*
|
||||
* - documents the user CREATED → `AddRepo { read_cap }` on the **Store branch**
|
||||
* of the store they live in — one such branch per store;
|
||||
* - caps RECEIVED for someone else's documents → `AddLink { read_cap }` on the
|
||||
* **User branch** of the private store.
|
||||
*
|
||||
* The wallet itself holds exactly one key per user: the private store's read cap,
|
||||
* from which everything else is reached. Hence the invariant:
|
||||
*
|
||||
* > You do not derive a cap from a bare reference. You look it up in what you
|
||||
* > hold — or you were given it.
|
||||
*
|
||||
* This class is the in-memory record of what the connected holder currently holds:
|
||||
* upstream's local user storage, not a durable register. The durable ones are
|
||||
* emulated in `store-registry.ts` — for created documents, `holdOwnCap` writes and
|
||||
* `readStoreCaps` reads the Store branch back; for received ones, `addLink` /
|
||||
* `readLinks` on the User branch. `connect.ts` restores the Links at connection;
|
||||
* the own-document caps come back through `listMyEntityDocs`.
|
||||
*
|
||||
* One record PER holder, since one shared wallet hosts every identity. Switching
|
||||
* identity therefore SWITCHES records; it never wipes one (a wipe would make
|
||||
* durability a lie and bring per-session re-declaration back under another name).
|
||||
*
|
||||
* ── Sharing ───────────────────────────────────────────────────────────────
|
||||
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
|
||||
* sharing is `inbox.share(doc, toUser)` — a **Link** deposit — and receiving is
|
||||
* the recipient processing their inbox. Handing over a store's cap is NOT the
|
||||
* gesture: it would give away everything that store contains, present and future.
|
||||
*
|
||||
* And for a document in a PUBLIC store there is no sharing act at all: the store hands
|
||||
* its cap to whoever asks (`public-store.ts`), so what circulates is the bare
|
||||
* reference. Filed apart (`learnFromPublicStore`) because it grants reading only.
|
||||
*
|
||||
* ── What this module does NOT do ──────────────────────────────────────────
|
||||
* Enforce. The shape is right after P1a; the isolation is still fake. Per-document
|
||||
* encryption and closing the read paths that bypass the guard (an ANCHORLESS
|
||||
* `docs.sparqlQuery`, the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b.
|
||||
* Nothing may be claimed "anonymous" or "private" until then.
|
||||
*
|
||||
* The write caps below (`grantWrite`, `governsWrite`, `canWrite`, `hasWritePolicy`) are
|
||||
* **inert, not partial** — a distinction the docs got wrong until 2026-08-07, when an
|
||||
* adversarial review measured it. `grantWrite` has NO production caller, so
|
||||
* `hasWritePolicy()` is permanently false and the `ng-proxy` guard they feed never fires
|
||||
* at all. Writing is governed instead by OWNERSHIP, at the write door (`reach.ts`
|
||||
* `assertMayWrite`) — which is what upstream's `verify_permission` actually checks. These
|
||||
* four are dead surface kept for P1b; do not read them as a working policy.
|
||||
*/
|
||||
|
||||
import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri";
|
||||
import type { Nuri, PrincipalId, ReadCap, Scope } from "../model/types";
|
||||
|
||||
/**
|
||||
* The stand-in cap value, and the minting point — moved here from `model/nuri.ts`
|
||||
* on 2026-08-03 because it did not belong to the model.
|
||||
*
|
||||
* `model/` transcribes the target's addressing vocabulary; minting is not part of
|
||||
* that vocabulary. Upstream nothing on the surface turns a bare reference into a cap:
|
||||
* the engine mints at repo creation and you afterwards look a cap up in what you hold,
|
||||
* or you were given it. Keeping `mintCap` in the model module contradicted that module's
|
||||
* own header, and put the emulation's one invented value in the file that claims to hold
|
||||
* only verified target vocabulary.
|
||||
*
|
||||
* P1b replaces this single constant with a real key; migration deletes both.
|
||||
*/
|
||||
const STAND_IN_CAP = "OK";
|
||||
|
||||
/**
|
||||
* Build the cap-bearing form of `nuri` — `{target}:r:OK`. Passing an already
|
||||
* cap-bearing reference yields the same value. INTERNAL to the emulated verifier.
|
||||
*/
|
||||
export function mintCap(nuri: Nuri): ReadCap {
|
||||
return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`;
|
||||
}
|
||||
|
||||
/** The map key of the anonymous holder (no identity established yet). */
|
||||
const ANONYMOUS = "";
|
||||
|
||||
export class CapRegistry {
|
||||
/** holder → the caps they hold, indexed by the cap-less NURI. */
|
||||
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
|
||||
/**
|
||||
* holder → the documents they CREATED in this session, through {@link mint}.
|
||||
*
|
||||
* Authorship, for the one path that records it nowhere else. `storeRegistry`'s
|
||||
* documents are recorded durably on a Store branch (the emulated `AddRepo`, which is
|
||||
* what upstream's `doc_create` commits), so `ownsDocument` finds them on a later
|
||||
* session. The raw `docs.docCreate` has no store to record into — so nothing about
|
||||
* such a document survives its session, and an in-session note of who made it is
|
||||
* exactly as durable as the thing it describes.
|
||||
*
|
||||
* Consulted by the write guard before it pays for a Store-branch read. Without it the
|
||||
* guard refused a caller a write to a document it had just created — caught by the
|
||||
* live-broker e2e, seven steps red, after the unit suite stayed green.
|
||||
*/
|
||||
private mintedByHolder = new Map<string, Set<Nuri>>();
|
||||
/**
|
||||
* Documents this session knows to sit in a PUBLIC store — a fact about each
|
||||
* DOCUMENT, so global rather than per-holder, unlike everything else here.
|
||||
*
|
||||
* It is not itself a right. What being in a public store buys is that the document's
|
||||
* cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`,
|
||||
* emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has
|
||||
* been, the holder holds it like any other and this set records only how it got there.
|
||||
*/
|
||||
private inPublicStore = new Set<Nuri>();
|
||||
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
|
||||
private writers = new Map<Nuri, Set<PrincipalId>>();
|
||||
/** Fired whenever a holder gains a cap — a cap delivered asynchronously must
|
||||
* re-trigger the reads that were empty for want of it. */
|
||||
private listeners = new Set<() => void>();
|
||||
/** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
|
||||
private issued = false;
|
||||
|
||||
/**
|
||||
* @param holder resolves WHO is holding — the current identity. Looked up through it on every
|
||||
* call, so an identity switch switches records with nothing to reset. Defaults to the anonymous holder.
|
||||
*/
|
||||
constructor(private readonly holder: () => PrincipalId | null = () => null) {}
|
||||
|
||||
// --- what the holder holds ----------------------------------------------
|
||||
|
||||
/**
|
||||
* The key of the holder currently connected — capture it when you DECIDE that a cap is
|
||||
* someone's, and hand it back to {@link learnFor} when you file.
|
||||
*
|
||||
* **A hazard closed, not a leak observed** — the distinction matters and I got it wrong
|
||||
* once while writing this. Filing resolves the holder at the moment it runs, and three
|
||||
* paths file several `await`s after the check that authorised them (connecting, reading
|
||||
* an inbox, listing one's own documents). So an application switching identity in the
|
||||
* gap COULD have the first identity's caps filed into the second one's ring. That is
|
||||
* structural and visible by reading. What was NOT established is that it happens: the
|
||||
* reproduction that seemed to show it turned out to be a broken test fake, and once the
|
||||
* fake was corrected the leak did not reproduce.
|
||||
*
|
||||
* The pairing stays because it costs one argument and removes the hazard by
|
||||
* construction, where a re-check at each of three sites is a discipline. It is not
|
||||
* evidence of a bug that was found.
|
||||
*/
|
||||
holderKey(): string {
|
||||
return this.holder() ?? ANONYMOUS;
|
||||
}
|
||||
|
||||
/** What the current holder holds, created on first use. */
|
||||
private heldCaps(): Map<Nuri, ReadCap> {
|
||||
return this.ringFor(this.holderKey());
|
||||
}
|
||||
|
||||
private ringFor(key: string): Map<Nuri, ReadCap> {
|
||||
let ring = this.heldByHolder.get(key);
|
||||
if (!ring) this.heldByHolder.set(key, (ring = new Map()));
|
||||
return ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* File `cap` among what the current holder holds — the ONE door in, so
|
||||
* the invariant is carried here rather than by each caller remembering it.
|
||||
*
|
||||
* A reference with no `:r:` is REFUSED. `Nuri` and `ReadCap` are both `string`
|
||||
* (deliberately — the real SDK takes `nuri: String`), so the compiler cannot
|
||||
* catch a caller passing the naming form where the reading form is meant. Left
|
||||
* unchecked, that mistake files a bare reference under its own name, `capFor`
|
||||
* then returns it, and the document reads — turning "naming is not reading" into
|
||||
* "naming is reading", which is the exact inversion this batch exists to remove.
|
||||
* The check is cheap and it is the only thing standing between the two.
|
||||
*
|
||||
* Returns whether the cap was new.
|
||||
*/
|
||||
private file(cap: ReadCap, key: string = this.holderKey()): boolean {
|
||||
if (!hasReadCap(cap)) {
|
||||
throw new Error(
|
||||
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
|
||||
`reference — naming is not reading, and no cap derives from one: ${JSON.stringify(cap)}`,
|
||||
);
|
||||
}
|
||||
const target = targetOf(cap);
|
||||
const ring = this.ringFor(key);
|
||||
if (ring.get(target) === cap) return false;
|
||||
ring.set(target, cap);
|
||||
this.issued = true;
|
||||
this.notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap of a document I just CREATED, filed among what I hold — the emulated
|
||||
* `AddRepo { read_cap }`. Idempotent. Returns the cap.
|
||||
*/
|
||||
mint(nuri: Nuri): ReadCap {
|
||||
const cap = mintCap(nuri);
|
||||
this.file(cap);
|
||||
const key = this.holder() ?? ANONYMOUS;
|
||||
let made = this.mintedByHolder.get(key);
|
||||
if (!made) this.mintedByHolder.set(key, (made = new Set()));
|
||||
made.add(targetOf(nuri));
|
||||
return cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the current holder CREATE this document in this session? Authorship, and
|
||||
* therefore the right to write — see {@link mintedByHolder}.
|
||||
*/
|
||||
mintedHere(nuri: Nuri): boolean {
|
||||
return this.mintedByHolder.get(this.holder() ?? ANONYMOUS)?.has(targetOf(nuri)) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* File a cap I was GIVEN — an inbox deposit of kind `cap`, or a repo link found
|
||||
* in world-readable content. This is the ONLY way a cap arrives from
|
||||
* outside: nothing turns a bare reference into a cap.
|
||||
*
|
||||
* @throws if `cap` carries no `:r:` — see {@link file}. Passing a bare `Nuri`
|
||||
* here is the one type confusion that would silently invert the model, and both
|
||||
* forms are `string`, so it is rejected at runtime instead.
|
||||
*/
|
||||
learn(cap: ReadCap): void {
|
||||
this.file(cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* File a cap for a NAMED holder — the one the caller decided for, not whoever happens
|
||||
* to be connected when the `await` resumes. See {@link holderKey}.
|
||||
*/
|
||||
learnFor(key: string, cap: ReadCap): void {
|
||||
this.file(cap, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the
|
||||
* emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading
|
||||
* needs no special case anywhere; recorded apart because of what it is NOT.
|
||||
*
|
||||
* It is a READ grant and nothing else. Upstream a public store makes its repos
|
||||
* world-readable, never world-writable — writing needs the write cap, and
|
||||
* `verify_permission` fires on WRITE only. Here the write guard still consults the
|
||||
* read cap (write caps are decorative until P1b, see the module header), so without
|
||||
* this distinction a bare reference to a public document would buy a WRITE — a
|
||||
* consumer would build on it, and have to unlearn it at migration.
|
||||
*
|
||||
* A stronger claim on the same document erases the mark: {@link mint} (I created it)
|
||||
* and {@link learn} (it was deposited for me) both go through {@link file}, which
|
||||
* clears it. So a public document of my own is never read-only to me.
|
||||
*/
|
||||
learnFromPublicStore(cap: ReadCap): void {
|
||||
this.file(cap);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold
|
||||
* none — which is the whole answer the model can give. Absorbs the former
|
||||
* `canRead(doc, principal)`: there is no principal parameter, because there is
|
||||
* no list to look a principal up in.
|
||||
*/
|
||||
capFor(nuri: Nuri): ReadCap | undefined {
|
||||
return this.heldCaps().get(targetOf(nuri));
|
||||
}
|
||||
|
||||
// --- publication (the public store) -------------------------------------
|
||||
|
||||
/**
|
||||
* Record that `nuri` sits in a PUBLIC store. A fact about the DOCUMENT, not a right
|
||||
* of anyone — hence a global set rather than a per-holder one, and hence no minting
|
||||
* here: what sitting in a public store buys is that the cap is **obtainable** by
|
||||
* whoever asks (`emulated-verifier/public-store.ts`), which is a separate act from
|
||||
* this one holding it.
|
||||
*
|
||||
* Marking and minting were one method (`recordInPublicStore`) until they were split:
|
||||
* the fetch path files the cap it DOWNLOADED, and minting a second one beside it
|
||||
* would produce a different key the day the stand-in constant becomes a real one —
|
||||
* the same trap `holdOwnCap` already documents.
|
||||
*
|
||||
* Upstream nothing corresponds to this call: the store IS public, and the broker
|
||||
* exposes its outer overlay (`expose_outer`,
|
||||
* `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because
|
||||
* one broker here serves every virtual user identically.
|
||||
*
|
||||
* NOT recursive: a document in a public store may REFERENCE private ones, and the
|
||||
* reference grants nothing on what it references. That non-recursiveness is what lets
|
||||
* a public object point at private content without disclosing it.
|
||||
*/
|
||||
markInPublicStore(nuri: Nuri): void {
|
||||
this.inPublicStore.add(targetOf(nuri));
|
||||
}
|
||||
|
||||
/** Is `nuri` recorded as sitting in a public store? A fact about the document. */
|
||||
isInPublicStore(nuri: Nuri): boolean {
|
||||
return this.inPublicStore.has(targetOf(nuri));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a document the current holder owns in `scope`: its cap lands among what
|
||||
* they hold, and a `public` one is additionally marked as sitting in a public store.
|
||||
* Returns the cap. Idempotent — the registry calls it both when creating a document
|
||||
* and when listing the holder's own documents back, which is how a holder's caps are
|
||||
* rebuilt on a fresh session.
|
||||
*
|
||||
* Deliberately does NOT touch write caps: those are decorative until P1b, and
|
||||
* arming their guard here would be enforcement this batch does not do.
|
||||
*/
|
||||
open(nuri: Nuri, scope: Scope): ReadCap {
|
||||
// `file`, NOT `mint` — and the difference is a hole that was open for one commit.
|
||||
//
|
||||
// Every caller of this method files a STRUCTURAL document: one of the holder's three
|
||||
// store documents, or an inbox. Those are not authored content, they are registers —
|
||||
// written only through `emulated-verifier/register-write.ts`. Minting them marked
|
||||
// them "created by me", which let the write guard through, which let a holder append
|
||||
// `contains "<anyone's document>"` to their own store index through the PUBLISHED
|
||||
// `docs.sparqlUpdate` and forge ownership of it. `ownsDocument` reads that very
|
||||
// index, so the guard was fully bypassable from the surface.
|
||||
//
|
||||
// Found by re-running the adversary on the fix (2026-08-07). Filing without minting
|
||||
// closes it at the source: a structural document is owned by nobody in the authorship
|
||||
// sense, so both halves of `assertMayWrite` say no, which is correct.
|
||||
const cap = mintCap(nuri);
|
||||
this.file(cap);
|
||||
if (scope === "public") this.markInPublicStore(nuri);
|
||||
return cap;
|
||||
}
|
||||
|
||||
// --- enforcement gate ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Is the cap emulation in force? False until the first cap is issued, so a
|
||||
* consumer that never touches caps keeps reading everything (no regression).
|
||||
* Once ANY cap exists the regime is possession for EVERY holder — including one
|
||||
* who holds nothing, which is exactly the isolation being emulated.
|
||||
*/
|
||||
isEnforcing(): boolean {
|
||||
return this.issued;
|
||||
}
|
||||
|
||||
// --- change signal ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscribe to changes in what the holder holds. A cap that arrives asynchronously (an inbox
|
||||
* deposit) must make the views that were empty for want of it re-read; without
|
||||
* this signal they stay stale until an unrelated change happens to fire.
|
||||
*/
|
||||
onChange(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const l of this.listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (error) {
|
||||
console.error("[caps] change listener threw", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- write caps (decorative until P1b) ----------------------------------
|
||||
|
||||
/** Grant `principal` the WRITE cap of document `doc`. */
|
||||
grantWrite(doc: Nuri, principal: PrincipalId): void {
|
||||
const target = targetOf(doc);
|
||||
let s = this.writers.get(target);
|
||||
if (!s) this.writers.set(target, (s = new Set()));
|
||||
s.add(principal);
|
||||
}
|
||||
|
||||
/** Is `doc` under any WRITE-cap policy? */
|
||||
governsWrite(doc: Nuri): boolean {
|
||||
return this.writers.has(targetOf(doc));
|
||||
}
|
||||
|
||||
/** Does `principal` hold a WRITE cap for `doc`? */
|
||||
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
|
||||
if (principal === null) return false;
|
||||
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
|
||||
}
|
||||
|
||||
/** No WRITE policy declared → the write guard stays inert (passthrough). */
|
||||
hasWritePolicy(): boolean {
|
||||
return this.writers.size > 0;
|
||||
}
|
||||
|
||||
/** Drop every holder's caps and every publication. Tests / a fresh wallet only —
|
||||
* NOT what an identity change does (that switches heldByHolder, see the header). */
|
||||
clear(): void {
|
||||
this.heldByHolder.clear();
|
||||
this.mintedByHolder.clear();
|
||||
this.inPublicStore.clear();
|
||||
this.writers.clear();
|
||||
this.issued = false;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* connect — what the polyfill does when the app connects a virtual user.
|
||||
*
|
||||
* ── Processing inboxes is the LIBRARY's job, not the app's ────────────────
|
||||
* Stated by the PO, 2026-07-30. A consumer must not have to remember to drain its
|
||||
* inbox for documents shared with it to become readable; forgetting would look
|
||||
* like "the share did not work" rather than "nobody processed the queue". So the
|
||||
* moment an identity is connected ({@link setCurrentUser}), this runs.
|
||||
*
|
||||
* Two steps, in order, and the order matters:
|
||||
*
|
||||
* 1. **Restore** — read the Links already applied (`storeRegistry.readLinks`, the
|
||||
* emulated `AddLink` records on the User branch of the private store) back into
|
||||
* what this user holds. This is durable state; it costs one read and needs no inbox.
|
||||
* 2. **Process** — drain the user's inbox (`inbox.processInbox`), which files any
|
||||
* new Link durably and puts it among what the user holds.
|
||||
*
|
||||
* Restoring first means a reconnecting user can read its shared documents
|
||||
* immediately, without waiting on the inbox round-trip.
|
||||
*
|
||||
* ── Fire-and-forget, on purpose ───────────────────────────────────────────
|
||||
* `setCurrentUser` is synchronous and every consumer calls it from synchronous
|
||||
* code. Making it async would push the wait onto the app, which is exactly the
|
||||
* obligation this removes. So the work runs in the background and announces itself
|
||||
* through the registry's change signal (`CapRegistry.onChange`), which is what
|
||||
* `watchShape` already listens to — a view that was empty for want of a cap
|
||||
* re-reads when the cap lands. {@link connectedUser} is there for a caller that
|
||||
* genuinely needs to await it (tests, an app that wants a deterministic start).
|
||||
*
|
||||
* ── Every inbox, at both levels ───────────────────────────────────────────
|
||||
* The user's own inbox AND the inbox of every document it opened one on. Upstream
|
||||
* both are answered by the same place — `AddInboxCap` records on the User branch
|
||||
* (`engine/repo/src/types.rs:1969`) — so `storeRegistry.myInboxes()` enumerates
|
||||
* them and this drains each in turn.
|
||||
*/
|
||||
|
||||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
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. */
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Restore and drain for the connected user. Idempotent per user while in flight.
|
||||
*
|
||||
* Tolerant by construction: it runs on every `setCurrentUser`, including in
|
||||
* contexts where the store registry was never configured (unit tests, an app
|
||||
* setting the identity before the session resolves). Those simply have nothing to
|
||||
* restore, and a failure here must never break connecting.
|
||||
*/
|
||||
export async function connectedUser(): Promise<void> {
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null) return;
|
||||
const pending = inFlight.get(holder);
|
||||
if (pending) return pending;
|
||||
|
||||
/**
|
||||
* Is `holder` still the connected identity?
|
||||
*
|
||||
* This work is fired un-awaited by `setCurrentUser`, and everything below resolves the
|
||||
* CURRENT holder when it reads a register — `readLinks` and `myInboxes` both ask
|
||||
* `getCurrentUser()` at the moment they run. After a switch they would therefore read
|
||||
* the WRONG user's registers.
|
||||
*
|
||||
* The observed symptom was narrower and entirely in the tests: in-flight work from one
|
||||
* test file armed the cap emulation in the next, making the suite's green depend on
|
||||
* file order. Abandoning is right for both reasons, and it is what upstream implies —
|
||||
* a session belongs to one user, and switching user is another session. Nothing is
|
||||
* lost: the next connection picks it up.
|
||||
*/
|
||||
const stillConnected = (): boolean => getCurrentUser() === holder;
|
||||
// Captured with the identity, handed back at filing time — see `caps.holderKey`.
|
||||
const holderKey = getCaps().holderKey();
|
||||
|
||||
const run = (async (): Promise<void> => {
|
||||
try {
|
||||
// Connecting must not PROVISION. `ensureAccount` would create the user on
|
||||
// first sight, so connecting an identity that does not exist yet would
|
||||
// silently mint its stores and their caps — arming the whole emulation as a
|
||||
// background side effect, at a moment nothing controls. An account that does
|
||||
// not exist has nothing to restore and no inbox to drain.
|
||||
if ((await resolveAccount(holder)) === null) return;
|
||||
if (!stillConnected()) return;
|
||||
// 1. Durable first: what this user has already applied.
|
||||
const links = await readLinks();
|
||||
if (!stillConnected()) return;
|
||||
for (const cap of links) getCaps().learnFor(holderKey, cap);
|
||||
// 2. Then the queues: ALL of them — the user's own inbox, plus one per
|
||||
// document it opened an inbox on. Both levels, as the PO specified, and
|
||||
// both are answered by the same User-branch record (`AddInboxCap`).
|
||||
// Sequential rather than parallel: each `processInbox` writes what it
|
||||
// applies to the SAME private store, and interleaving those writes buys
|
||||
// nothing on a queue that is nearly always empty.
|
||||
const inboxes = await myInboxes();
|
||||
for (const inbox of inboxes) {
|
||||
if (!stillConnected()) return;
|
||||
await processInbox(inbox);
|
||||
}
|
||||
} catch {
|
||||
// Not configured yet, or offline. Nothing to restore, and connecting must
|
||||
// not fail because a queue could not be reached — the next connection, or
|
||||
// an explicit `connectedUser()`, picks it up.
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.set(holder, run);
|
||||
try {
|
||||
await run;
|
||||
} finally {
|
||||
inFlight.delete(holder);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the connection work without awaiting it. Called by `setCurrentUser`. */
|
||||
export function startConnect(): void {
|
||||
void connectedUser();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The namespace this library reserves for its OWN triples, and the one predicate a
|
||||
* read path needs about it: *is this subject machinery, or is it the consumer's data?*
|
||||
*
|
||||
* ── Why this exists ────────────────────────────────────────────────────────
|
||||
* The polyfill has no branches, so it emulates each of a repo's compartments with a
|
||||
* distinct SUBJECT inside a document (`shim:index` for the store's Main branch,
|
||||
* `shim:storeBranch`, `shim:userBranch`, `shim:headerBranch` — see `store-registry.ts`).
|
||||
* That was invisible as long as those subjects only ever appeared in documents the
|
||||
* consumer never reads through the data path — store documents and the doc-shim.
|
||||
*
|
||||
* The Header branch broke that: it lives in an ENTITY document, the one the consumer
|
||||
* reads with `SELECT ?s ?p ?o`. Without a filter, the address of a document's inbox
|
||||
* would surface as one of that entity's properties — machinery leaking into domain
|
||||
* data. Filtering by SUBJECT rather than by predicate is what makes this hold for
|
||||
* every compartment, present and future: a new emulated branch needs no new filter.
|
||||
*
|
||||
* Upstream this problem does not exist, because there the separation is real — a
|
||||
* branch is a different CRDT with its own topic, not a subject in the same graph. This
|
||||
* module is the seam where our emulation pays for that.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The URN namespace every triple this library writes for itself lives under —
|
||||
* `urn:ng-eventually:shim:…` (store-registry's compartments) and
|
||||
* `urn:ng-eventually:inbox:…` (inbox deposits).
|
||||
*
|
||||
* A consumer that writes its own data under this prefix would have it filtered out of
|
||||
* its reads. That is a deliberate reservation, not a hazard to guard against: the
|
||||
* namespace names this library.
|
||||
*/
|
||||
export const MACHINERY_NS = "urn:ng-eventually:";
|
||||
|
||||
/**
|
||||
* Is `subject` one of this library's own, rather than consumer data?
|
||||
*
|
||||
* Tolerant of `undefined` so a read path can hand it a possibly-absent binding
|
||||
* without a preliminary check — an absent subject is not machinery.
|
||||
*/
|
||||
export function isMachinerySubject(subject: string | undefined): boolean {
|
||||
return subject !== undefined && subject.startsWith(MACHINERY_NS);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* open-repo — cold-start repo opening for the ANCHORED read path (polyfill-era).
|
||||
*
|
||||
* ── The cold-start defect this heals ──────────────────────────────────────
|
||||
* The anchored read path (`surface/read-model.ts` `readDoc`,
|
||||
* `shared-wallet/account-registry.ts` `readUserStore`) assumes the target repo is
|
||||
* already usable by the verifier — true within the session that CREATED the doc
|
||||
* (every `doc_create` opens it), but FALSE on a FRESH session over the same
|
||||
* persistent wallet (reconnection / new page / re-login): the repos are on the broker
|
||||
* and in the profile's cache, but this session has not synced them, so a persisted
|
||||
* document reads as empty.
|
||||
*
|
||||
* **The mechanism, corrected 2026-08-03.** This comment used to say the verifier
|
||||
* "silently returns 0 rows (never a `RepoNotFound`)" for a repo absent from
|
||||
* `self.repos`. That is FALSE at the source: `resolve_target_for_sparql` does
|
||||
* `self.repos.get(repo_id).ok_or(NgError::RepoNotFound)?`
|
||||
* (`engine/verifier/src/request_processor.rs:264,269`), which surfaces as a rejected
|
||||
* promise. Two things produce the 0 rows actually observed, and neither is silence in
|
||||
* the verifier: on a persistent profile `Verifier::load` repopulates `self.repos` from
|
||||
* user storage at construction (`engine/verifier/src/verifier.rs:535-560`), so the repo
|
||||
* is PRESENT but unsynced and the anchored query legitimately matches nothing; and this
|
||||
* library's own `readDoc` catches every error and returns `[]`
|
||||
* (`surface/read-model.ts:122`), so anything that did throw would reach the caller as
|
||||
* emptiness anyway. The fix below is right; the diagnosis written beside it was not.
|
||||
*
|
||||
* The circularity that made this self-inflicted: `doc_subscribe` WOULD open the
|
||||
* repo, but the reactive layer only subscribes AFTER the listing produced NURIs —
|
||||
* and the listing (`readUserStore`) is itself an anchored read of a not-yet-open
|
||||
* index repo → 0 rows → nothing to subscribe → nothing ever opens. Verified fix
|
||||
* (adversarial pass): on a fresh session, `doc_subscribe(<docNuri>)` THEN the
|
||||
* anchored re-read returns the data. So we OPEN the repo before the anchored read.
|
||||
*
|
||||
* ── How we open ───────────────────────────────────────────────────────────
|
||||
* We reuse the existing per-document primitive {@link subscribeDoc} (the typed
|
||||
* wrapper over the platform's `doc_subscribe`) — NOT a parallel channel. On
|
||||
* subscribe the platform pushes `TabInfo` FIRST (~1-3ms) and then the initial
|
||||
* `State` (~2-3ms); the FIRST `State` is the sync BARRIER — after it, presence is
|
||||
* guaranteed and absence definitive (pinned empirically by CONTRACT 3 in `e2e/`).
|
||||
* So we await that first `State` specifically (identified via the `type` argument
|
||||
* {@link DocChangeType} `subscribeDoc` now surfaces), NOT the first push of any
|
||||
* kind — resolving on `TabInfo` would return before the real barrier. The
|
||||
* subscription is kept ALIVE for the whole session (that is what keeps the repo
|
||||
* open) — it is a bootstrap open, distinct from any reactive subscription a caller
|
||||
* later establishes for change signals.
|
||||
*
|
||||
* ── Idempotence / perf (once per session, no polling) ─────────────────────
|
||||
* The registry opens each repo at most ONCE per session: `opened` records completed
|
||||
* opens (a hit skips everything), `inFlight` de-dupes concurrent opens of the same
|
||||
* repo. A brand-new page / module instance starts with an empty registry; and when
|
||||
* the injected session id CHANGES within the same page (an in-page re-login /
|
||||
* `session_stop`+`session_start`, whose new verifier has an empty `self.repos`) the
|
||||
* registry auto-resets (`syncSession`) so repos are re-opened against the new session
|
||||
* rather than wrongly skipped as "already open". No polling: we wait on the first
|
||||
* `State` push, with a bounded fallback timeout so a missing push can't hang.
|
||||
*
|
||||
* ── Sync state (per-nuri, lib-internal) ───────────────────────────────────
|
||||
* Each nuri carries an explicit sync state, readable via {@link getSyncState}:
|
||||
* `"syncing"` — subscribed, no `State` yet (barrier not reached);
|
||||
* `"synced"` — first `State` received (barrier reached — the TRUTH signal);
|
||||
* `"timed-out"` — the bounded fallback fired with NO `State` (open proceeded so
|
||||
* the read is never blocked, but this is NOT `"synced"`: a future
|
||||
* "ready" signal must not mistake a timeout for a real barrier);
|
||||
* `"unknown"` — never requested (or the fake-ng no-op path: no `State` semantics).
|
||||
* This distinction is the point — `"synced"` and `"timed-out"` are kept apart so a
|
||||
* later reactive readiness layer can trust `"synced"` and treat `"timed-out"` as
|
||||
* "opened best-effort, sync unconfirmed".
|
||||
*
|
||||
* ── Migration ─────────────────────────────────────────────────────────────
|
||||
* At the real multi-store migration this becomes "open the user's store repo by
|
||||
* cap" (a native broker fetch) done once at bootstrap; the anchored read then
|
||||
* resolves a same-session repo directly. Polyfill-era, removed with the shim.
|
||||
*/
|
||||
|
||||
import { mustNotAttempt } from "./reach";
|
||||
import { fetchReadCap } from "./public-store";
|
||||
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
import { subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
|
||||
import { logStage, shortNuri } from "../shared-wallet/access-log";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* The per-nuri bootstrap sync state (lib-internal). See the module header:
|
||||
* - `"syncing"` subscribed, first `State` not yet received;
|
||||
* - `"synced"` first `State` received — the real sync barrier (CONTRACT 3);
|
||||
* - `"timed-out"` fallback fired without a `State` — open proceeded, sync UNconfirmed;
|
||||
* `"unknown"` (from {@link getSyncState}) means "never requested / no `State` semantics".
|
||||
*/
|
||||
export type SyncState = "syncing" | "synced" | "timed-out";
|
||||
|
||||
/** Repos whose bootstrap open has completed (first `State` received OR timed out). */
|
||||
const opened = new Set<Nuri>();
|
||||
/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */
|
||||
const inFlight = new Map<Nuri, Promise<void>>();
|
||||
/** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */
|
||||
const held = new Map<Nuri, Unsubscribe>();
|
||||
/** Explicit per-nuri sync state — the barrier signal, distinct from `opened`.
|
||||
* `synced` and `timed-out` are NOT merged (see module header). */
|
||||
const syncState = new Map<Nuri, SyncState>();
|
||||
/** The session id the current `opened`/`held` entries belong to. A change means a
|
||||
* new verifier session (fresh `self.repos`) → the registry must be invalidated. */
|
||||
let boundSessionId: string | number | null = null;
|
||||
|
||||
/**
|
||||
* Max wait (ms) for the initial-state push before proceeding with the read anyway.
|
||||
* The push normally lands quickly once the repo loads; the timeout only guards the
|
||||
* pathological case (a doc that never pushes), so a read is never blocked forever —
|
||||
* it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc).
|
||||
*/
|
||||
let OPEN_TIMEOUT_MS = 8000;
|
||||
|
||||
/**
|
||||
* Override the bootstrap-open fallback timeout (ms). TEST-ONLY: the timed-out
|
||||
* branch (a doc that never pushes a `State`) is otherwise only reachable after the
|
||||
* 8s production wait, too slow for a unit test. Production never calls this — the
|
||||
* default stands. `resetOpenedRepos` restores the default.
|
||||
*/
|
||||
export function setOpenTimeoutForTests(ms: number): void {
|
||||
OPEN_TIMEOUT_MS = ms;
|
||||
}
|
||||
|
||||
/** Reset the open registry (mainly for tests / a switched wallet). Tears down the
|
||||
* held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */
|
||||
export function resetOpenedRepos(): void {
|
||||
for (const unsub of held.values()) {
|
||||
try {
|
||||
unsub();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
opened.clear();
|
||||
inFlight.clear();
|
||||
held.clear();
|
||||
syncState.clear();
|
||||
boundSessionId = null;
|
||||
OPEN_TIMEOUT_MS = 8000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bootstrap sync state of `nuri` (lib-internal accessor; NOT a reactive
|
||||
* app-facing hook — that is a later phase). Returns `"unknown"` if the repo was
|
||||
* never opened via {@link ensureRepoOpen} (or was opened on the fake-ng no-op
|
||||
* path, which has no `State` semantics). Otherwise `"syncing"` (subscribed, no
|
||||
* `State` yet), `"synced"` (first `State` received — the barrier), or
|
||||
* `"timed-out"` (fallback fired without a `State`). `"synced"` and `"timed-out"`
|
||||
* are deliberately distinct — a later readiness signal must not confuse them.
|
||||
*/
|
||||
export function getSyncState(nuri: Nuri): SyncState | "unknown" {
|
||||
return syncState.get(nuri) ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the registry if the active session id changed since it was populated.
|
||||
* A new session id means a new verifier with an EMPTY `self.repos`, so entries from
|
||||
* the previous session must NOT suppress re-opening under the new one. Tolerant: if
|
||||
* the session can't be resolved, keep the current registry (best effort).
|
||||
*/
|
||||
async function syncSession(): Promise<void> {
|
||||
let sid: string | number | null = null;
|
||||
try {
|
||||
sid = (await getStoreRegistryDeps().getSession()).sessionId;
|
||||
} catch {
|
||||
return; // no session deps wired (unit fake path) — nothing to invalidate against
|
||||
}
|
||||
if (boundSessionId !== null && boundSessionId !== sid) resetOpenedRepos();
|
||||
boundSessionId = sid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure `nuri`'s repo is OPEN in the current session before an anchored read,
|
||||
* so the cold-start (fresh session, same persistent wallet) resolves it instead
|
||||
* of returning 0 rows. Opens via {@link subscribeDoc} and awaits the first `State`
|
||||
* push — the sync barrier (bounded fallback marks the nuri `"timed-out"`, not
|
||||
* `"synced"`). Idempotent: a repo already opened (or in flight) is not re-opened.
|
||||
*
|
||||
* Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g.
|
||||
* the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged.
|
||||
* Never throws; a failed open just leaves the read to behave as it did before.
|
||||
*/
|
||||
export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
||||
if (!nuri) return;
|
||||
// A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on
|
||||
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
|
||||
// before deciding whether we may touch it, or the answer would be "no" purely for
|
||||
// want of asking, and a bare reference to a public document would never suffice.
|
||||
// Memoised and inert once the cap is held (see public-store.ts).
|
||||
await fetchReadCap(nuri);
|
||||
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
|
||||
// pulls its state. A user that holds no cap for it has no business asking.
|
||||
// (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.)
|
||||
if (mustNotAttempt(nuri)) return;
|
||||
return openRepoUnguarded(nuri);
|
||||
}
|
||||
|
||||
/**
|
||||
* The unguarded core. Exported for ONE importer — `shared-wallet/physical.ts` — and
|
||||
* for nobody else; neither entry point re-exports it. The `Unguarded` suffix is the
|
||||
* warning, and the single importer is what keeps it honest.
|
||||
*/
|
||||
export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
|
||||
// Drop the registry if the session changed (in-page re-login → fresh verifier).
|
||||
await syncSession();
|
||||
if (opened.has(nuri)) return;
|
||||
const pending = inFlight.get(nuri);
|
||||
if (pending) return pending;
|
||||
|
||||
// No reactive primitive on the injected ng (fake-ng unit suite): nothing to open,
|
||||
// no `State` to await → preserve the old immediate-resolve behaviour so `bun test`
|
||||
// does not regress. No sync state is recorded (getSyncState → "unknown"): the fake
|
||||
// path has no barrier semantics, and claiming "synced" here would be a lie.
|
||||
const ng = getConfig().ng as { doc_subscribe?: unknown };
|
||||
if (typeof ng.doc_subscribe !== "function") {
|
||||
opened.add(nuri);
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribed, first `State` not yet seen.
|
||||
syncState.set(nuri, "syncing");
|
||||
// Barrier clock: how long the subscribe→first-State (or fallback) round-trip
|
||||
// took, surfaced on the BARRIER trace line below — the most important line in
|
||||
// the whole low-level data-path trace: it distinguishes a genuine absence
|
||||
// (`synced` → a 0-row read means it) from a not-yet-synced read (`timed-out`).
|
||||
const barrierStartedAt = Date.now();
|
||||
|
||||
const p = (async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
// Resolve on the first `State` (the sync BARRIER), marking the nuri "synced".
|
||||
const onState = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
syncState.set(nuri, "synced");
|
||||
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
resolve();
|
||||
};
|
||||
// Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT
|
||||
// "synced". A genuinely-absent doc reads 0 rows anyway (same as before, never
|
||||
// a hang); the distinct state keeps a future "ready" signal from lying.
|
||||
const onTimeout = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
syncState.set(nuri, "timed-out");
|
||||
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
resolve();
|
||||
};
|
||||
// The bootstrap subscription is kept ALIVE for the session — holding it open
|
||||
// is the whole point. We wait for the FIRST `State` event specifically (the
|
||||
// barrier), NOT any push: the platform pushes `TabInfo` before `State`, and
|
||||
// resolving on `TabInfo` would return before the real sync barrier.
|
||||
// Unguarded on purpose: the caller already decided. `ensureRepoOpen` applied
|
||||
// rule 2 above; `ensurePhysicalRepoOpen` is the machinery's door and is not
|
||||
// subject to the boundary at all (see physical.ts).
|
||||
const unsub = subscribeDocUnguarded(nuri, (_r, type) => {
|
||||
if (type === "State") onState();
|
||||
});
|
||||
held.set(nuri, unsub);
|
||||
setTimeout(onTimeout, OPEN_TIMEOUT_MS);
|
||||
});
|
||||
opened.add(nuri);
|
||||
inFlight.delete(nuri);
|
||||
})();
|
||||
inFlight.set(nuri, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a SET of repos before an anchored batch read, in parallel, each tolerant
|
||||
* ({@link ensureRepoOpen} never throws). Empty / falsy entries are ignored.
|
||||
*/
|
||||
export async function ensureReposOpen(nuris: Nuri[]): Promise<void> {
|
||||
const unique = [...new Set(nuris.filter(Boolean))];
|
||||
if (unique.length === 0) return;
|
||||
await Promise.all(unique.map((n) => ensureRepoOpen(n)));
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* public-store — a document in a PUBLIC store gives up its ReadCap to whoever asks.
|
||||
*
|
||||
* ── The upstream mechanism this emulates — a DECLARED model, so a BET ──────
|
||||
* **Labelled VERIFIED until 2026-08-10, wrongly.** What supports it is a doc COMMENT
|
||||
* on a type nothing constructs — a statement of intent, not of behaviour — and this
|
||||
* repo's own rules say both halves of that: a comment describing the current state is
|
||||
* not the intent, and an absent implementation is not evidence either. So this is a
|
||||
* bet, and `docs/document-links.md` § 5 and `docs/readcap-and-nuri-model.md` § 4sexies
|
||||
* already called it one. This header now says the same word.
|
||||
*
|
||||
* What IS read in source: `PublicRepoLinkV0` (`engine/net/src/types.rs:5098-5124`)
|
||||
* carries `repo`, `public_store` and `peers` — and **no `read_cap`**. Its own doc
|
||||
* comment says why:
|
||||
*
|
||||
* > *"The latest ReadCap of the branch (or main branch) will be **downloaded from
|
||||
* > the outerOverlay**, **if the peer brokers listed below allow it**. […] This link
|
||||
* > is durable, because the public site are **served differently by brokers**."*
|
||||
*
|
||||
* So for a repo in a public store, the key is not something a sender hands over: it is
|
||||
* something the **network gives to whoever asks — and whom the peer brokers allow**.
|
||||
* That condition is part of the mechanism, not decoration: the broker decides, by
|
||||
* pinning the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`).
|
||||
* Nothing about the reader; everything about where the document sits and how brokers
|
||||
* serve it.
|
||||
*
|
||||
* And what is NOT wired, which is precisely why this is a bet: both `PinRepo`
|
||||
* constructors hard-code `expose_outer: false`
|
||||
* (`engine/net/src/actors/client/pin_repo.rs:51,79`), so no client ever asks for the
|
||||
* exposure; and `ExtTopicSyncReq` — the anonymous branch-sync such a link needs — is
|
||||
* declared and falls into `unimplemented!()` (`engine/net/src/types.rs:4523,4533`).
|
||||
* The emulation follows the model the engine DECLARES, in a place the engine does not
|
||||
* yet serve. That is this library's intended posture, named here as the bet it is.
|
||||
*
|
||||
* ── What that means for the model, and why nothing is special-cased ───────
|
||||
* Possession stays the ONE criterion. A public document is readable not because the
|
||||
* guard makes an exception for it, but because its cap is **obtainable**: you ask, you
|
||||
* receive, you hold it, and from there the ordinary path applies. `reach.ts` is
|
||||
* untouched, and "whoever has the reference AND the key reads" still describes
|
||||
* everything — a public store hands the key to whoever has the reference, where the
|
||||
* brokers serving that store allow it (see the condition above).
|
||||
*
|
||||
* The consequence an application must be able to rely on: **a bare reference to a
|
||||
* document in a public store is enough**, and that is why nothing in this library
|
||||
* needs to put a key into a link (see `readcap-and-nuri-model.md` § 0 — a call that
|
||||
* returns a key where a reference was asked for is the failure mode to watch for).
|
||||
*
|
||||
* Non-recursive, like everything else here: a public document may REFERENCE a
|
||||
* protected one, and following that reference gets you a name, not a key. Only the
|
||||
* document actually sitting in the public store exposes its cap.
|
||||
*
|
||||
* ── The two halves, and which door each uses ──────────────────────────────
|
||||
* - {@link exposeReadCap} — the OWNER's side, at creation: the cap is written on the
|
||||
* document's Header branch, the compartment meant for what any reader may see. It
|
||||
* goes through the guarded surface, because the owner holds the document.
|
||||
* - {@link fetchReadCap} — the NETWORK's side: read through the **physical** door
|
||||
* (`shared-wallet/physical.ts`), unguarded, because that is precisely the point —
|
||||
* the broker serving an outer overlay does not ask who is asking. Using the guarded
|
||||
* read here would be circular: you would need the cap to obtain the cap.
|
||||
*
|
||||
* ── Where the emulation is honest about its shape ─────────────────────────
|
||||
* Upstream nothing is *written* anywhere to make a repo public: the store is public,
|
||||
* and the broker exposes its outer overlay. Here there is one broker serving every
|
||||
* virtual user identically, so "which documents are in a public store" has to be
|
||||
* recorded somewhere the machinery can read — and the document itself is the one place
|
||||
* that needs no index and no enumeration. At migration this whole module goes: the
|
||||
* scope stops being a fact we record and becomes the store the document lives in.
|
||||
*
|
||||
* The gap that leaves: a reader learns a document is public by ASKING that document,
|
||||
* so a document it has never heard of stays invisible. Upstream the broker would serve
|
||||
* it just the same. That limits discovery, not access — an application that holds the
|
||||
* reference reads, which is the property this module exists to provide.
|
||||
*/
|
||||
|
||||
import { registerUpdate } from "./register-write";
|
||||
import { physicalQuery, ensurePhysicalRepoOpen } from "../shared-wallet/physical";
|
||||
import { getCaps } from "../shared-wallet/bootstrap";
|
||||
import { escapeLiteral } from "../surface/sparql";
|
||||
import { hasReadCap, targetOf } from "../model/nuri";
|
||||
import { accessLogPrefix } from "../shared-wallet/access-log";
|
||||
import {
|
||||
P,
|
||||
HEADER_BRANCH_SUBJECT,
|
||||
readBindings,
|
||||
bindingValue,
|
||||
session,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import type { Nuri, ReadCap } from "../model/types";
|
||||
|
||||
/**
|
||||
* Targets whose outer-overlay fetch has been attempted in this session, and WHAT it
|
||||
* returned — the cap, or `null` for "not in a public store".
|
||||
*
|
||||
* ── The memo caches the answer, never the filing ──────────────────────────
|
||||
* It cached a boolean until 2026-08-07, and that was a bug an adversarial review found:
|
||||
* the first holder to ask triggered the download, the cap was filed for THEM, and every
|
||||
* later holder in the same session hit the memo, got `true`, and held nothing. Their next
|
||||
* read was refused. Upstream a broker serving a pinned outer overlay answers EVERY asker
|
||||
* (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`), so
|
||||
* "served once, to whoever asked first" is a relation the model does not have.
|
||||
*
|
||||
* The round-trip is what is worth saving, not the filing. So the memo holds the value and
|
||||
* the caller files it for whoever is connected, every time.
|
||||
*
|
||||
* A scope never changes here (a document is created in a store and stays there), so a
|
||||
* cached `null` cannot go stale for a document that existed when it was taken. It CAN for
|
||||
* one created afterwards in the same page — {@link resetPublicStoreFetches} is the way
|
||||
* out, and a session or wallet reset calls it.
|
||||
*/
|
||||
const attempted = new Map<Nuri, Promise<ReadCap | null>>();
|
||||
|
||||
/** Forget every outer-overlay fetch (tests / a switched session or wallet). */
|
||||
export function resetPublicStoreFetches(): void {
|
||||
attempted.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose `cap` on `doc`'s Header branch — the emulated `expose_outer`. Called when a
|
||||
* document is created in a PUBLIC store, and only then: this is what makes the cap
|
||||
* obtainable by anyone, which for a public store is the intended property and for any
|
||||
* other scope would be a disclosure.
|
||||
*
|
||||
* Replacement, not addition, like every Header-branch register: one document has one
|
||||
* current cap, and two would leave a fetcher picking between them.
|
||||
*/
|
||||
export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
|
||||
const s = await session();
|
||||
try {
|
||||
// Two separate updates: `DELETE WHERE { … }` is the form verified against the real
|
||||
// broker (`docs/decisions/sparql-delete-for-orm-objects.md`); a `;`-joined update
|
||||
// is not exercised anywhere in this library.
|
||||
await registerUpdate(
|
||||
s.sessionId,
|
||||
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
|
||||
doc,
|
||||
"exposeReadCap:clear",
|
||||
);
|
||||
await registerUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> "${escapeLiteral(cap)}" }`,
|
||||
doc,
|
||||
"exposeReadCap",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " exposeReadCap failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the (emulated) network for `doc`'s ReadCap, and file it if it answers — the
|
||||
* emulated *"downloaded from the outerOverlay"*. Returns whether a cap was obtained.
|
||||
*
|
||||
* Nothing is asked when the cap is already held: a document you can read needs no
|
||||
* fetching, and skipping it keeps the ordinary path free of physical reads.
|
||||
*
|
||||
* Never throws — a document that is not in a public store simply answers nothing, which
|
||||
* is not an error but the normal case.
|
||||
*/
|
||||
export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
|
||||
const doc = targetOf(docLike);
|
||||
const caps = getCaps();
|
||||
// Inert until the emulation is in force, like the guard it serves: before the first
|
||||
// cap exists everything reads anyway, so there is nothing to obtain and asking would
|
||||
// be a physical round-trip bought for nothing.
|
||||
if (!caps.isEnforcing()) return false;
|
||||
if (caps.capFor(doc) !== undefined) return true;
|
||||
let pending = attempted.get(doc);
|
||||
if (pending === undefined) {
|
||||
pending = downloadReadCap(doc);
|
||||
attempted.set(doc, pending);
|
||||
}
|
||||
const cap = await pending;
|
||||
if (cap === null) return false;
|
||||
// Filed for whoever is connected NOW, on every call — the memo spares the round-trip,
|
||||
// not the filing. See the note on {@link attempted}.
|
||||
caps.learnFromPublicStore(cap);
|
||||
caps.markInPublicStore(doc);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The fetch itself, through the machinery's door. Returns the cap, or `null` when the
|
||||
* document is not in a public store — which is the normal case, not an error. */
|
||||
async function downloadReadCap(doc: Nuri): Promise<ReadCap | null> {
|
||||
const s = await session();
|
||||
try {
|
||||
// The repo has to be in the session before an anchored read resolves it — the
|
||||
// cold-start heal, through the PHYSICAL door: this is the emulated broker serving
|
||||
// an outer overlay, and it does not ask who is asking (see `open-repo.ts`).
|
||||
await ensurePhysicalRepoOpen(doc);
|
||||
const res = await physicalQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?c WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
|
||||
undefined,
|
||||
doc,
|
||||
"fetchReadCap",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
const cap = bindingValue(row, "c");
|
||||
// `targetOf` guards the one confusion that would matter: a cap exposed on
|
||||
// document A must not file a cap for document B. A document only ever speaks
|
||||
// for itself.
|
||||
if (cap && hasReadCap(cap) && targetOf(cap) === doc) return cap;
|
||||
}
|
||||
} catch (error) {
|
||||
// Not in a public store, not synced, or no such document — all of them mean the
|
||||
// same thing to the caller: no cap was obtained.
|
||||
console.error(accessLogPrefix() + " fetchReadCap failed:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a SET of documents' caps, in parallel — what a batch read does before it
|
||||
* decides which documents it may touch. Each fetch is independent and tolerant.
|
||||
*/
|
||||
export async function fetchReadCaps(docs: Nuri[]): Promise<void> {
|
||||
const unique = [...new Set(docs.filter(Boolean))];
|
||||
if (unique.length === 0) return;
|
||||
await Promise.all(unique.map((d) => fetchReadCap(d)));
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* reach — may the CONNECTED virtual user touch this document at all?
|
||||
*
|
||||
* The one predicate every path to `ng` consults, so the boundary is decided in a
|
||||
* single place instead of being re-argued at each call site.
|
||||
*
|
||||
* ── The boundary ──────────────────────────────────────────────────────────
|
||||
* A virtual user must simulate the boundary of the future single-user wallet:
|
||||
* every access function is confined to the user currently connected
|
||||
* (`setCurrentUser`), and no cross-user access is permitted. Otherwise the
|
||||
* consumer is coded against a reach that will never exist — the same failure mode
|
||||
* as an ACL where the real model is key possession, one level down.
|
||||
*
|
||||
* Two ways a document is legitimately reachable, and no others:
|
||||
*
|
||||
* 1. **You hold its cap.** Either because you created it (the store refiles the
|
||||
* cap) or because someone delivered it to you. This is the whole of the
|
||||
* access model, so it is the whole of the predicate.
|
||||
* There is no second way, and there used to be a third door here: an explicit list of
|
||||
* NURIs "declared infrastructure", exempt from the boundary. It was removed on
|
||||
* 2026-08-07 with **zero callers**, an always-empty set, and a header describing two
|
||||
* exempted documents that were never registered — dead scaffolding whose documentation
|
||||
* claimed a hole existed where none did. The machinery reaches the shim through
|
||||
* `shared-wallet/physical.ts`, which is a different FUNCTION rather than an exemption,
|
||||
* and that is the stronger arrangement the module below already argues for.
|
||||
*
|
||||
* ── What may be exempt, and why so little ─────────────────────────────────
|
||||
* > The only reads/writes not confined to a virtual user are those that make
|
||||
* > multi-user operation possible at all. Nothing common — only the indexing
|
||||
* > mechanisms that make the virtual users work.
|
||||
*
|
||||
* The test an exemption must pass: *does removing it stop the virtual users from
|
||||
* functioning, or does it merely stop users from seeing each other's content?* Only the
|
||||
* first qualifies — and the answer is that no exemption is needed at all: the one thing
|
||||
* that qualifies (the shim) is reached through its own unguarded FUNCTIONS
|
||||
* (`shared-wallet/physical.ts`), so nothing has to be waved through here.
|
||||
*
|
||||
* Depositing into another user's inbox is NOT handled here: it is a write to a
|
||||
* document you do not hold, and it is legitimate — the only channel by which a
|
||||
* link crosses from one user to another, hence the bootstrap of the whole
|
||||
* reachability graph. It is allowed at the inbox surface, which is where the
|
||||
* asymmetry (deposit yes, read no) is expressed.
|
||||
*
|
||||
* At migration this module disappears: the boundary becomes the wallet itself.
|
||||
*/
|
||||
|
||||
import { getCaps } from "../shared-wallet/bootstrap";
|
||||
import { targetOf } from "../model/nuri";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* Do we POSSESS the cap of `nuri`? Not "does this string carry one" — a caller may
|
||||
* legitimately be holding the bare form and possess the cap elsewhere, which is the
|
||||
* normal case: NURIs travel bare through content and indexes, while the cap sits in
|
||||
* what the user holds. Possession is what decides; the shape of the reference the
|
||||
* caller happens to have in hand decides nothing.
|
||||
*
|
||||
* `targetOf` first, so a cap-bearing reference and its bare form answer alike.
|
||||
*
|
||||
* Inert until the first cap exists (`caps.isEnforcing()`), so a consumer that never
|
||||
* touches caps keeps working. Once ANY cap has been issued the boundary applies to
|
||||
* every user, including one holding nothing: that is the isolation.
|
||||
*/
|
||||
export function mayReach(nuri: Nuri): boolean {
|
||||
const caps = getCaps();
|
||||
if (!caps.isEnforcing()) return true;
|
||||
const target = targetOf(nuri);
|
||||
return caps.capFor(target) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* **Rule 1 — authorization**, at the PASSAGE POINTS (`docs.*`, `subscribe`).
|
||||
*
|
||||
* Nothing reaches `ng` unless the connected user possesses the document's cap. This
|
||||
* is the guard: it fires on a request that should never have been made, and its job
|
||||
* is to make sure the attempt fails rather than succeeds quietly.
|
||||
*
|
||||
* Deliberately duplicated with rule 2 below — see {@link mustNotAttempt}. Two rules,
|
||||
* two places, one criterion: a lapse in either is caught by the other.
|
||||
*/
|
||||
export function assertMayReach(nuri: Nuri, op: string): void {
|
||||
if (mayReach(nuri)) return;
|
||||
throw new Error(
|
||||
`[ng-eventually] ${op}: refused — the connected user does not hold this document's ` +
|
||||
"cap. Naming a document does not grant access to it: a cap is looked up in what " +
|
||||
`you hold, or it was delivered to you. ${JSON.stringify(nuri)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* **Writing is OWNERSHIP, not possession of a read key.**
|
||||
*
|
||||
* Upstream the right to write is membership of the repo: `verify_permission`
|
||||
* (`engine/repo/src/repo.rs:584`) is reachable only through `Commit::verify_perm` →
|
||||
* `Commit::verify` (`engine/repo/src/commit.rs:780,897`), so it fires on commits and
|
||||
* never on reads. Nothing about HOW a reader came by the read key bears on it — a public
|
||||
* store hands its read cap to whoever asks (`PublicRepoLinkV0`,
|
||||
* `engine/net/src/types.rs:5098`), and a cap deposited in an inbox is a Link someone gave
|
||||
* you (`AddLinkV0`, "external repos only", `engine/repo/src/types.rs:1939-1948`). Neither
|
||||
* makes you a member.
|
||||
*
|
||||
* ── What this replaced, and why ───────────────────────────────────────────
|
||||
* Until 2026-08-07 this asked *"was this cap served to me by a public store?"* and
|
||||
* refused only then. That predicate was wrong in BOTH directions, and an adversarial
|
||||
* review found each end:
|
||||
*
|
||||
* - too lax — a cap received in an inbox passed, so an application could write into a
|
||||
* document it merely reads. Someone could ship collaborative editing on it and lose
|
||||
* it at migration. It was labelled "P1b's", but P1b is key MATERIAL and this is a
|
||||
* model relation;
|
||||
* - too strict — the owner of her own public document was refused, whenever she opened
|
||||
* it from its reference before her store had been listed (a deep link, a fresh
|
||||
* session). The comment beside the code asserted the opposite.
|
||||
*
|
||||
* One predicate pushed two ways is the signal that it was the wrong predicate. Ownership
|
||||
* is the right one, it is durable (it is read from the Store branch, the emulated
|
||||
* `AddRepo`, not from session memory), and it answers both.
|
||||
*
|
||||
* ── What it does NOT cover ────────────────────────────────────────────────
|
||||
* Delegated writing. Upstream a repo's owner may add members (`AddMember` /
|
||||
* `AddPermission`); this library emulates none of that, so here only the owner writes —
|
||||
* which is a repo's state upstream until someone is added. A narrowing, in the safe
|
||||
* direction, and one an application cannot build a habit on because the target's answer
|
||||
* (be granted permission) has no surface here to build on.
|
||||
*
|
||||
* The library's own registers do not come through here at all: they go through
|
||||
* `docs.registerUpdate`, because a store document is not OWNED in this sense — it IS a
|
||||
* store, and the verifier commits to it on its own behalf.
|
||||
*/
|
||||
export async function assertMayWrite(nuri: Nuri, op: string): Promise<void> {
|
||||
const caps = getCaps();
|
||||
if (!caps.isEnforcing()) return;
|
||||
const target = targetOf(nuri);
|
||||
// Created here — authorship, and the cheap answer. It is also the ONLY record for a
|
||||
// document made through the raw `docs.docCreate`, which has no store to file into.
|
||||
if (caps.mintedHere(target)) return;
|
||||
// Otherwise ask the durable register: the Store branch, the emulated `AddRepo`.
|
||||
const { ownsDocument } = await import("./branch-registers");
|
||||
if (await ownsDocument(target)) return;
|
||||
throw new Error(
|
||||
`[ng-eventually] ${op}: refused — writing needs the WRITE cap, and reading a document ` +
|
||||
"never grants it. A public store serves its read cap to anyone, and a cap deposited " +
|
||||
`in your inbox is one someone gave you; neither makes you the document's owner. ${JSON.stringify(nuri)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* **Rule 2 — do not even attempt**, at the CALLERS (`read-model`, `open-repo`,
|
||||
* `subscribe`'s callers…).
|
||||
*
|
||||
* A reader that does not hold a document's cap must not issue the operation at all.
|
||||
* Not attempting and being refused are different things: the first is a caller that
|
||||
* knows what it holds, the second is one that hoped and got caught. Only the first
|
||||
* is the model — upstream you cannot even address a repo you have no cap for.
|
||||
*
|
||||
* Practically it also stops the library from asking the broker for documents it has
|
||||
* no business asking about, which is work, noise, and a leak of intent.
|
||||
*/
|
||||
export function mustNotAttempt(nuri: Nuri): boolean {
|
||||
return !mayReach(nuri);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Read filter — the polyfill of capability-based read access.
|
||||
*
|
||||
* In the target, the broker only delivers documents the holder has the **ReadCap**
|
||||
* of, so `useShape` already returns an authorized subset. Here (single shared
|
||||
* wallet, everything readable) we reproduce that with a read-filtered VIEW over
|
||||
* the reactive set: it keeps only items whose **document** (its `@graph` = the
|
||||
* repo it lives in) is in what the current holder holds, per the
|
||||
* {@link CapRegistry}.
|
||||
*
|
||||
* Faithful to NextGraph: the access unit is the DOCUMENT, not the item. In a
|
||||
* mono-store layout (every item in one repo) the filter is therefore all-or-
|
||||
* nothing on that document — which is exactly the native behavior, and why
|
||||
* fine-grained isolation requires one document per entity. Removed at migration.
|
||||
*
|
||||
* ── What this filter cannot do, and where that shows ──────────────────────
|
||||
* It is SYNCHRONOUS and decides from what the holder holds at that instant. A document
|
||||
* in a PUBLIC store hands its cap to whoever asks (`public-store.ts`), but asking is a
|
||||
* round-trip — so this view drops such a document until some read path has asked.
|
||||
* Every path this library owns does ask (`readUnion`, `docs.sparqlQuery`,
|
||||
* `ensureRepoOpen`, `documentInboxAddress`), which covers `watchShape`; what it does
|
||||
* not cover is `useShape`, whose signature is the real ORM's and has no await to
|
||||
* spend. An application reaching a public document through `useShape` alone, having
|
||||
* read it nowhere first, sees nothing. A polyfill-era limit, removed with the module.
|
||||
*
|
||||
* Note there is no `user` parameter anywhere below, and that is the point: reading
|
||||
* is key possession, so the only question askable is "do I hold this document's
|
||||
* cap?". "May principal P read document D?" is an ACL question the real model
|
||||
* cannot answer either. Which holder's caps are consulted follows the identity the
|
||||
* registry resolves, so the view reflects the holder in effect at read time.
|
||||
*/
|
||||
|
||||
import type { CapRegistry } from "./caps";
|
||||
import { isNuri } from "../model/nuri";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/** The document (repo NURI) an item lives in — its `@graph`. The ORM boundary:
|
||||
* `@graph` is an untyped value on a property bag, so it is narrowed here rather
|
||||
* than cast. Anything that is not a NextGraph reference names no document. */
|
||||
function docOf(item: unknown): Nuri | null {
|
||||
const g = (item as Record<string, unknown> | null)?.["@graph"];
|
||||
return typeof g === "string" && isNuri(g) ? g : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do I hold this item's document? An item with no `@graph` is KEPT (it names no
|
||||
* document, so there is no cap to hold). Everything else needs the cap: a bare
|
||||
* reference names without reading.
|
||||
*/
|
||||
function readable(item: unknown, caps: CapRegistry): boolean {
|
||||
const doc = docOf(item);
|
||||
if (doc === null) return true;
|
||||
return caps.capFor(doc) !== undefined;
|
||||
}
|
||||
|
||||
/** Pure: keep only the items whose document the current holder holds. */
|
||||
export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
|
||||
const out: T[] = [];
|
||||
for (const item of items) if (readable(item, caps)) out.push(item);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
|
||||
*
|
||||
* ── The rule this proxy must never break ──────────────────────────────────
|
||||
* A filtered view may show LESS than the set holds. It may never show MORE. Until
|
||||
* 2026-08-07 it intercepted three members — `Symbol.iterator`, `size`, `forEach` — and
|
||||
* forwarded everything else through `Reflect.get` bound to the TARGET. So `.values()`,
|
||||
* `.keys()`, `.entries()`, `.map()`, `.getById()` returned another virtual user's items.
|
||||
* An adversarial review found it, and the damage was proportional: those are exactly the
|
||||
* members a reactive-set API puts forward, so a consumer reaches for them first.
|
||||
*
|
||||
* ── Why a whitelist, and why the default is to THROW ──────────────────────
|
||||
* There is no generic way to filter an unknown method: a `.getById()` on a filtered copy
|
||||
* loses the class it belongs to, and a wrapper that guesses would guess wrong. So the
|
||||
* members that yield items are handled explicitly, and **any other function member
|
||||
* throws** rather than forwarding.
|
||||
*
|
||||
* That is deliberate, and it is the safe direction. Forwarding is a silent leak — nothing
|
||||
* fails, the wrong items simply appear. Throwing is loud, greppable, and tells whoever
|
||||
* hits it exactly what to do: add the member here, filtered. A boundary whose unknown
|
||||
* cases leak is not a boundary.
|
||||
*
|
||||
* Everything that is not a function passes through untouched (`size` is handled above):
|
||||
* a plain property carries no items.
|
||||
*/
|
||||
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
|
||||
const keep = (item: unknown): boolean => readable(item, caps);
|
||||
/** The readable items, as a plain array — what every handled member works from. */
|
||||
const kept = (target: object): unknown[] => {
|
||||
const out: unknown[] = [];
|
||||
for (const item of target as Iterable<unknown>) if (keep(item)) out.push(item);
|
||||
return out;
|
||||
};
|
||||
return new Proxy(set, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === Symbol.iterator) return function* () { yield* kept(target); };
|
||||
if (prop === "size") return kept(target).length;
|
||||
// A Set yields the item for both halves of a `[key, value]` pair; `DeepSignalSet`
|
||||
// follows the same shape, so `keys`/`values`/`entries` are the Set contract.
|
||||
if (prop === "values" || prop === "keys") return () => kept(target)[Symbol.iterator]();
|
||||
if (prop === "entries") return () => kept(target).map((i) => [i, i] as const)[Symbol.iterator]();
|
||||
if (prop === "forEach") {
|
||||
return (cb: (v: unknown, v2: unknown, s: unknown) => void) => {
|
||||
for (const item of kept(target)) cb(item, item, receiver);
|
||||
};
|
||||
}
|
||||
// `has(item)` is filtered, not forwarded: the caller already holds the item, so the
|
||||
// answer discloses nothing new — but upstream an unreadable item is never delivered
|
||||
// at all, so "yes it is in there" would be an answer the target cannot give.
|
||||
if (prop === "has") return (item: unknown) => keep(item) && (target as Set<unknown>).has(item);
|
||||
// The reactive-set extras: they iterate, so they must iterate the filtered items.
|
||||
// The list is `iteratorHelperKeys` from `@ng-org/alien-deepsignals` — an earlier
|
||||
// pass whitelisted half of it and threw on the rest, so a holder's calls on their
|
||||
// OWN data crashed (`toArray`, `reduce`, `first`…). Filtering is the answer for all
|
||||
// of them; refusing is only for what is not on this list.
|
||||
if (prop === "map") return (fn: (v: unknown, i: number) => unknown) => kept(target).map(fn);
|
||||
if (prop === "filter") return (fn: (v: unknown, i: number) => boolean) => kept(target).filter(fn);
|
||||
if (prop === "find") return (fn: (v: unknown, i: number) => boolean) => kept(target).find(fn);
|
||||
if (prop === "some") return (fn: (v: unknown, i: number) => boolean) => kept(target).some(fn);
|
||||
if (prop === "every") return (fn: (v: unknown, i: number) => boolean) => kept(target).every(fn);
|
||||
if (prop === "toArray") return () => kept(target);
|
||||
if (prop === "first") return () => kept(target)[0];
|
||||
if (prop === "take") return (n: number) => kept(target).slice(0, n);
|
||||
if (prop === "drop") return (n: number) => kept(target).slice(n);
|
||||
if (prop === "flatMap") return (fn: (v: unknown, i: number) => unknown) => kept(target).flatMap(fn as never);
|
||||
if (prop === "reduce") {
|
||||
return (fn: (acc: unknown, v: unknown, i: number) => unknown, init?: unknown) =>
|
||||
init === undefined
|
||||
? kept(target).reduce(fn as never)
|
||||
: kept(target).reduce(fn as never, init);
|
||||
}
|
||||
if (prop === "getById" || prop === "getBy") {
|
||||
const inner = Reflect.get(target, prop, target) as ((...a: unknown[]) => unknown) | undefined;
|
||||
if (typeof inner !== "function") return inner;
|
||||
return (...args: unknown[]) => {
|
||||
const item = inner.apply(target, args);
|
||||
return keep(item) ? item : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// MUTATIONS forward untouched. They take an item and return void or a boolean, so
|
||||
// they yield nothing to leak — and the view must not break writes or the underlying
|
||||
// reactivity. (Caught by `test/read-filter.test.ts` when the blanket refusal below
|
||||
// was first written: refusing everything unknown also refused `add`.)
|
||||
if (prop === "add" || prop === "delete" || prop === "clear") {
|
||||
const fn = Reflect.get(target, prop, target);
|
||||
return typeof fn === "function" ? fn.bind(target) : fn;
|
||||
}
|
||||
|
||||
// RAW ESCAPE HATCHES. `DeepSignalSet` exposes the underlying collection on
|
||||
// dunder keys (`__raw__`, `__meta__` — `RAW_KEY` in `@ng-org/alien-deepsignals`),
|
||||
// and the header used to claim "a plain property carries no items". It does here:
|
||||
// `view.__raw__` handed back the unfiltered Set, every identity's items in it.
|
||||
// Found by re-running the adversary on the fix (2026-08-07). Any dunder key is
|
||||
// refused, because that is the convention the escape hatches follow.
|
||||
if (typeof prop === "string" && prop.startsWith("__")) {
|
||||
throw new Error(
|
||||
`[ng-eventually] read filter: \`${prop}\` reaches past the view to the raw ` +
|
||||
"collection, which holds every identity's items. There is no filtered form of it.",
|
||||
);
|
||||
}
|
||||
|
||||
const v = Reflect.get(target, prop, target);
|
||||
if (typeof v !== "function") return v;
|
||||
// UNKNOWN function member: refuse rather than forward. See the header — forwarding
|
||||
// is a silent leak, and this view's one job is that it cannot show more than the
|
||||
// holder may read.
|
||||
return () => {
|
||||
throw new Error(
|
||||
`[ng-eventually] read filter: \`${String(prop)}\` is not filtered, so calling it ` +
|
||||
"would return items this identity may not read. Add it to " +
|
||||
"`emulated-verifier/read-filter.ts`, filtered — do not bypass the view.",
|
||||
);
|
||||
};
|
||||
},
|
||||
}) as S;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The one door for the library's OWN register writes.
|
||||
*
|
||||
* ── Why it is a module of its own, and not a function in `surface/docs.ts` ──
|
||||
* It lived there for about ten minutes on 2026-08-07, and the contract check caught it:
|
||||
* `docs` is a PUBLISHED namespace, so any function in it reaches applications. A door
|
||||
* whose whole point is to skip a guard must not be one an application can open. It sits
|
||||
* here instead, in the emulated verifier, where nothing is exported from the package —
|
||||
* the same reasoning that put the unguarded READ door in `shared-wallet/physical.ts`.
|
||||
*/
|
||||
|
||||
import { getConfig } from "../shared-wallet/bootstrap";
|
||||
import { logAccess } from "../shared-wallet/access-log";
|
||||
import { assertMayReach } from "./reach";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* Write one of the library's OWN registers — the emulation of the service commits the
|
||||
* verifier makes on a repo's typed branches (`AddRepo` on a store's Store branch,
|
||||
* `AddLink` / `AddInboxCap` on the User branch, the Header branch's addresses).
|
||||
*
|
||||
* **Why this is a separate door rather than a flag.** The write guard above asks
|
||||
* *"do you own this document?"*, and a store document is owned by nobody in that sense:
|
||||
* it is not CONTAINED in a store, it IS one. Routing the registers through the same
|
||||
* guard would have refused the library its own bookkeeping — which is how a guard that
|
||||
* looks right locks out the very writes it exists to protect. Upstream these are not
|
||||
* application writes at all: they are commits the verifier makes on its own behalf, on
|
||||
* branches whose CRDT is `BranchCrdt::None`.
|
||||
*
|
||||
* Still subject to `assertMayReach`: the register of a virtual user is that user's, and
|
||||
* the machinery writes it while connected as them. What this door skips is ownership,
|
||||
* nothing else. Never exported from the package.
|
||||
*/
|
||||
export async function registerUpdate(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
anchor: Nuri,
|
||||
label = "registerUpdate",
|
||||
): Promise<void> {
|
||||
const { ng } = getConfig();
|
||||
assertMayReach(anchor, label);
|
||||
logAccess("WRITE", anchor, label, " (register)");
|
||||
return ng.sparql_update(sessionId, query, anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit into ANOTHER virtual user's inbox — the one write that legitimately
|
||||
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
|
||||
*
|
||||
* Moved out of the published `docs` namespace on 2026-08-07, and that is the whole
|
||||
* point of it living here. Its own docstring said "`inbox.post` is the only caller" —
|
||||
* true inside the library, false the moment it is published. An adversarial review
|
||||
* showed what publishing it bought: holding nothing but the bare reference of a public
|
||||
* document, one rewrites the inbox address posted on it and diverts every deposit meant
|
||||
* for its owner — exactly the vector `openDocumentInbox`'s ownership guard exists to
|
||||
* close. A door that skips a guard must not be one an application can open.
|
||||
*
|
||||
* Why this is a separate primitive rather than a flag: depositing is not "a write
|
||||
* that happens to be allowed", it is a different act. You cannot read the inbox you
|
||||
* deposit into, you hold no cap for it, and you get nothing back — upstream it is an
|
||||
* anonymous sealed box. Naming the exception makes it greppable and keeps
|
||||
* {@link sparqlUpdate} free of a bypass that would otherwise be reusable for
|
||||
* anything.
|
||||
*
|
||||
* The recipient's ownership of the inbox is what bounds this: `inbox.post` is the
|
||||
* only caller, and reading is guarded separately (`inbox.read`).
|
||||
*/
|
||||
export async function depositInto(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
targetInbox: Nuri,
|
||||
label = "deposit",
|
||||
): Promise<void> {
|
||||
const { ng } = getConfig();
|
||||
logAccess("WRITE", targetInbox, label, " (cross-user deposit)");
|
||||
return ng.sparql_update(sessionId, query, targetInbox);
|
||||
}
|
||||
Reference in New Issue
Block a user