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);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* `@ng-eventually/polyfill` — the one door. Everything an application imports, it imports
|
||||
* from here.
|
||||
*
|
||||
* ── What the single entry costs, and how that cost is paid ────────────────
|
||||
* There were two entries until 2026-08-07 (`.` and `./polyfill`), and the second one
|
||||
* carried a signal worth naming before removing it: *what you import from that path is
|
||||
* exactly what you will delete at migration*. One door loses that — nothing at an
|
||||
* import line now distinguishes `configure`, which goes away, from `docs`, which the
|
||||
* real SDK replaces in place. Three things carry it instead:
|
||||
*
|
||||
* 1. **The `POLYFILL-ERA` block below**, which is the deletion list. It is short by
|
||||
* construction, and it is meant to keep shrinking.
|
||||
* 2. **`docs/api-contract.md`**, which rules on every symbol with an epistemic label
|
||||
* (PASSTHROUGH / LEVEL-1 SHAPE / ASSUMPTION / NO COUNTERPART) and whose export
|
||||
* inventory is pinned by `test/vocabulary.test.ts` — so it cannot go stale
|
||||
* quietly, which a hand-kept list would.
|
||||
* 3. **The names themselves.** Every published name is built from the target's own
|
||||
* vocabulary or carries a marker saying why it exists only here — pinned by the
|
||||
* same test. A name that has to disappear says so.
|
||||
*
|
||||
* ── What is deliberately NOT published ────────────────────────────────────
|
||||
* The entry publishes what an application CALLS, and nothing else. Not the machinery
|
||||
* accessors (`getConfig`, `getStoreRegistryDeps` — internal wiring the surface reaches
|
||||
* through `shared-wallet/bootstrap`), and not the test resets (`resetConfig`,
|
||||
* `resetStoreRegistry`, `resetCaps` — the suite reaches them by their internal path,
|
||||
* which is what they are for). Merging the entries made publishing those a visible
|
||||
* choice rather than an inherited one; the choice is no.
|
||||
*
|
||||
* Earlier removals, each because an application coding against it learns something it
|
||||
* must unlearn — the one failure this library exists to prevent:
|
||||
*
|
||||
* - `getCaps` / `CapRegistry` (2026-08-05) — the emulation's engine room. It has
|
||||
* neither a successor nor an inert form, so anything built on it must be rewritten.
|
||||
* - `getCurrentUser` (2026-08-05) — an application knows who it signed in; asking the
|
||||
* library back is a convenience of the shared wallet, not a brick of the model.
|
||||
* - `virtualUsers` / `IdentityStore` (2026-08-05) — remembering an identity between
|
||||
* sessions is the application's job upstream too. The gate persists what IT needs.
|
||||
* - `hasCap(doc)` (2026-08-06) — it read like "may I read this?", and once a public
|
||||
* store serves its caps to whoever asks (`emulated-verifier/public-store.ts`) the
|
||||
* two answers part company: a readable document answers `false` right up until
|
||||
* something asks. Upstream you open a document and find out.
|
||||
*/
|
||||
|
||||
// ── SDK-SHAPED — a target counterpart for every symbol ──────────────────────
|
||||
// At migration the build alias is removed and these resolve to the real SDK. The
|
||||
// per-symbol ruling, with its epistemic label, is in `docs/api-contract.md`.
|
||||
|
||||
// A type is published only when a PUBLISHED SIGNATURE uses it. `export *` published
|
||||
// eight in one gesture (2026-08-10: it was a blanket re-export), of which two named
|
||||
// nothing a consumer can reach — `ReadCap` (used only by two private helpers of
|
||||
// `surface/inbox.ts`) and `InboxScope` (used only by the unpublished
|
||||
// `account-registry.userInbox`). A published type with no published signature is a
|
||||
// promise about the target that nothing here keeps: it invites a consumer to hold a
|
||||
// value it has no call to obtain — and for `ReadCap`, the one value the model says a
|
||||
// caller must never be handed on request. They stay DEFINED in `model/types.ts`, where
|
||||
// the library uses them; they stop being surface. `docs/api-contract.md` § 10, § 14.
|
||||
// Each one, and the signature that earns it its place:
|
||||
// Nuri every reference the surface RETURNS
|
||||
// NuriLike every reference the surface ACCEPTS
|
||||
// Scope `storeRegistry.*`, `watchShape`
|
||||
// PrincipalId `ensureIdentity`, `inbox.Deposit`/`PostOptions`, `EventuallyConfig`
|
||||
// NgLike `EventuallyConfig.ng`
|
||||
// UseShapeLike `EventuallyConfig.useShape`
|
||||
export type { Nuri, NuriLike, Scope, PrincipalId, NgLike, UseShapeLike } from "./model/types";
|
||||
export { useShape } from "./surface/use-shape";
|
||||
export { watchShape } from "./surface/watch-shape";
|
||||
export type { ShapeQuery, ShapeObservable } from "./surface/watch-shape";
|
||||
export { init, initNg } from "./surface/lifecycle";
|
||||
export * as inbox from "./surface/inbox";
|
||||
export * as docs from "./surface/docs";
|
||||
export { subscribeDoc, subscribeDocs, docChangeType } from "./surface/subscribe";
|
||||
export type { DocChange, DocChangeType, Unsubscribe } from "./surface/subscribe";
|
||||
// `readUnion` is exposed as a function, not under a `readModel` namespace: "model" is
|
||||
// neither the target's vocabulary nor neutral glue, and the namespace bought nothing —
|
||||
// it held one published function. Renamed 2026-08-03 by the vocabulary check.
|
||||
export { readUnion } from "./surface/read-model";
|
||||
export type { UnionSubject } from "./surface/read-model";
|
||||
export * as storeRegistry from "./surface/placement";
|
||||
|
||||
// SDK type re-exports — so the app imports these from @ng-eventually/polyfill too, not from
|
||||
// @ng-org. `export type` is ERASED at build, so this adds NO runtime @ng-org import to
|
||||
// the lib (no risk of a duplicate SDK copy in the bundle).
|
||||
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
||||
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
||||
export type { NG } from "@ng-org/web";
|
||||
|
||||
// ── POLYFILL-ERA — THE DELETION LIST ────────────────────────────────────────
|
||||
// Everything below exists because one shared wallet hosts every user, and nothing
|
||||
// below has a target counterpart. At migration each call goes, and the imports with
|
||||
// them. Keep this block short: an addition here is a promise to delete it later.
|
||||
|
||||
/**
|
||||
* Inject the real SDK, and tell the library about the shared wallet. Upstream nothing
|
||||
* is injected — an application imports the SDK and opens its own wallet — so this call
|
||||
* is the shape of that absence. `docs/api-contract.md` § 1.
|
||||
*
|
||||
* **It is the ONLY call here**, and keeping it that way is the design target: an
|
||||
* application's bootstrap should be one line to delete, not four.
|
||||
*/
|
||||
export { configure } from "./shared-wallet/bootstrap";
|
||||
export type { EventuallyConfig } from "./shared-wallet/bootstrap";
|
||||
export type { RegistrySession } from "./shared-wallet/account-registry";
|
||||
|
||||
// --- what this block deliberately does NOT contain --------------------------
|
||||
//
|
||||
// Three calls were published here and removed on 2026-08-07, when the count had drifted
|
||||
// to four against a target of two. Each removal is a thing an application no longer does:
|
||||
//
|
||||
// - `configureStoreRegistry` — folded into `configure`. Two bootstrap calls existed
|
||||
// because the library has two internals, which is not a reason a caller should pay.
|
||||
// - `setCurrentUser` — the access gate sets the identity (`ensureIdentity`, below).
|
||||
// An application naming its own identity is the gesture that inverts the model, and
|
||||
// it must not have a published call to reach for. The e2e harness plays several
|
||||
// identities on one page and reaches it by its internal path, which is what a
|
||||
// harness is allowed to do and an application is not.
|
||||
// - `connectedUser` — `ensureIdentity` awaits it. Upstream, opening the session IS the
|
||||
// connection; no application awaits a second call, so ours should not either.
|
||||
|
||||
// ── the access gate — polyfill-era in substance, one line in the app ────────
|
||||
// One call before the app renders. It shows a technical barrier only while the shared
|
||||
// wallet needs one; the day the wallet supplies the identity it resolves silently, and
|
||||
// this line stays as it is (`shared-wallet/access-gate.ts`).
|
||||
export { ensureIdentity } from "./shared-wallet/access-gate";
|
||||
export type { SharedWalletConfig } from "./shared-wallet/access-gate";
|
||||
|
||||
import { makeNg } from "./surface/ng-proxy";
|
||||
|
||||
/** SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. */
|
||||
export const ng: Record<string, any> = makeNg();
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* NURI primitives — the cap-less / cap-bearing distinction, kept as ONE object.
|
||||
*
|
||||
* Upstream a NURI is a single type, `NuriV0` — TEN fields: `identity, target,
|
||||
* entire_store, objects, signature, branch, overlay, access, topic, locator`
|
||||
* (`engine/net/src/app_protocol.rs:181-194`) — and a cap-less NURI is simply one
|
||||
* whose `access` is empty. This module transcribes **two** of those ten (`target`,
|
||||
* and the cap half of `access`); the other eight have no counterpart here.
|
||||
* `did:ng:` is the URI SCHEME prefix (inboxes, branches and overlays all carry it) —
|
||||
* it does NOT mean "without cap". The discriminant is the `:r:` segment:
|
||||
*
|
||||
* did:ng:o:{doc}:v:{overlay} — names, does NOT read (a {@link Nuri})
|
||||
* did:ng:o:{doc}:v:{overlay}:r:{cap} — names AND reads (a {@link ReadCap})
|
||||
*
|
||||
* ── Why `:r:` and not `:k:` ────────────────────────────────────────────────
|
||||
* Reported by NextGraph's developer and verified in the source: a **ReadCap** is
|
||||
* `r:{base64url(serde_bare(ObjectRef))}` — `BlockRef::readcap_nuri()`,
|
||||
* `engine/repo/src/types.rs:518-521` — where id AND key are serialized together
|
||||
* into ONE opaque segment. The `:k:` forms are a different thing: they belong to
|
||||
* **objects, files and commits** (`j:{id}:k:{key}`, `c:{id}:k:{key}`, `:510`/`:514`),
|
||||
* where id and key are two separate segments. This library used `:k:` until
|
||||
* 2026-07-30; it was the wrong letter *and* the wrong structure.
|
||||
*
|
||||
* These helpers are INTERNAL to the library. The parsed form {@link parseNuri}
|
||||
* mirrors that PAIR — `target` and the cap — and not the type: it was described as a
|
||||
* "1:1 mirror of `NuriV0`" until 2026-08-10, which claimed eight fields it has never
|
||||
* carried. It never surfaces in the SDK-identical entry's signatures either — the
|
||||
* real SDK takes plain `String`s and enforces at runtime, through cryptography, so no
|
||||
* branded type and no parsed struct leaks outward.
|
||||
*
|
||||
* ── The stand-in key (deliberately NOT a secret) ───────────────────────────
|
||||
* This library is deliberately insecure (see docs/vision.md). The only question it
|
||||
* can answer is **do I hold this document's cap, or not** — so the key value is the
|
||||
* constant `OK`, which says exactly that and pretends nothing more. What identifies
|
||||
* the document is the NURI the key is attached to; the value carries no information.
|
||||
* Real per-document encryption is P1b's job, and it replaces this one constant.
|
||||
* Until then, possession is a SHAPE, not a protection.
|
||||
*/
|
||||
|
||||
import type { Nuri, ReadCap } from "./types";
|
||||
|
||||
/** The URI scheme prefix every NextGraph reference carries. */
|
||||
const SCHEME = "did:ng:";
|
||||
/** The segment that turns a naming NURI into a reading one — upstream's ReadCap
|
||||
* encoding (`readcap_nuri`), NOT the `:k:` used for objects/files/commits. */
|
||||
/**
|
||||
* The ReadCap discriminant. Exported because the emulated verifier mints with it
|
||||
* (`emulated-verifier/caps.ts`); the model owns the grammar, minting is not part of it.
|
||||
*/
|
||||
export const CAP_SEGMENT = ":r:";
|
||||
|
||||
/**
|
||||
* Is this string a NextGraph reference at all? A **type guard**: it is the door
|
||||
* through which an untrusted `string` — a SPARQL binding, an ORM `@graph`, a value
|
||||
* an app read back from storage or a URL — becomes a {@link Nuri}. Exported from
|
||||
* the SDK entry so a consumer narrows its own strings the same way, rather than
|
||||
* casting.
|
||||
*/
|
||||
export function isNuri(s: string): s is Nuri {
|
||||
return s.startsWith(SCHEME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this reference carry a read cap (a `:r:` segment)? A **type guard**: the
|
||||
* ONLY narrowing from a bare string (or a {@link Nuri}) to a {@link ReadCap}.
|
||||
* Nothing else may produce a `ReadCap` from a reference that carries no key —
|
||||
* that would be deriving a cap from a bare reference, which the model forbids.
|
||||
*/
|
||||
export function hasReadCap(s: string): s is ReadCap {
|
||||
return isNuri(s) && s.includes(CAP_SEGMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap-less form of a reference — what it NAMES, with any cap stripped.
|
||||
*
|
||||
* The one internal cast of this module, and it is load-bearing: `slice` returns
|
||||
* `string`, yet slicing a `did:ng:…` at the `:r:` boundary can only yield a
|
||||
* `did:ng:…` — which the compiler cannot know. Keeping the cast HERE, in the
|
||||
* primitive that defines the contract, is what lets every caller stay typed with
|
||||
* no cast of its own.
|
||||
*/
|
||||
export function targetOf(nuri: Nuri): Nuri {
|
||||
const i = nuri.indexOf(CAP_SEGMENT);
|
||||
return i === -1 ? nuri : (nuri.slice(0, i) as Nuri);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed form — upstream `NuriV0`'s `target` plus the cap half of its `access`,
|
||||
* and none of the type's eight other fields; a cap-less NURI has no `readCap`.
|
||||
* Library-internal (see the module header).
|
||||
*/
|
||||
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap } {
|
||||
return hasReadCap(nuri) ? { target: targetOf(nuri), readCap: nuri } : { target: nuri };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The one door a caller's string comes through — validated, then typed.
|
||||
*
|
||||
* Public entry points take {@link NuriLike} so a consumer never has to narrow what it
|
||||
* read from a URL, from storage or from JSON: the SDK will take a plain string too
|
||||
* (`doc_subscribe(repo_o: String)`, `sdk/js/lib-wasm/src/lib.rs:1908`), so demanding a
|
||||
* refined type here would manufacture a step to unlearn — and would force this library
|
||||
* to publish a type guard the SDK will never have.
|
||||
*
|
||||
* This is where that permissive edge is paid for: once, at the boundary. Past it the
|
||||
* whole library works on `Nuri`.
|
||||
*
|
||||
* Throws rather than returning `undefined`: a reference that is not one is a caller
|
||||
* mistake, and swallowing it would produce an empty read with no explanation — the
|
||||
* failure mode this library keeps paying for elsewhere.
|
||||
*/
|
||||
export function toNuri(s: string, op: string): Nuri {
|
||||
if (!isNuri(s)) {
|
||||
throw new Error(
|
||||
`[ng-eventually] ${op}: not a NextGraph reference — expected a "did:ng:…" string, ` +
|
||||
`got ${JSON.stringify(s)}`,
|
||||
);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Generic, NextGraph-shaped types. ZERO application domain.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A NextGraph URI (document / store / inbox) in its **cap-less** form — it NAMES
|
||||
* and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`.
|
||||
* `did:ng:` is the URI scheme prefix, not a "without cap" marker; the discriminant
|
||||
* is the `:r:` segment (see {@link ReadCap}).
|
||||
*/
|
||||
export type Nuri = `did:ng:${string}`;
|
||||
|
||||
/**
|
||||
* A NextGraph URI that carries the document's read cap — `…:r:{cap}`. It NAMES
|
||||
* *and* READS: reading is key possession, never an authorization list. This is the
|
||||
* upstream name (`ReadCap`).
|
||||
*
|
||||
* ── Why a template literal type, and not a branded one ────────────────────
|
||||
* Both this and {@link Nuri} are **still strings** — assignable to `string`,
|
||||
* JSON-serializable, no wrapper object — so nothing has to be *un*-typed when the
|
||||
* real SDK arrives and takes `nuri: String`. What the template buys is the one
|
||||
* direction that matters: a `ReadCap` is freely usable wherever a `Nuri` is
|
||||
* expected (a cap IS a NURI with the key inside — upstream's single `NuriV0`),
|
||||
* while a bare `Nuri` passed where a `ReadCap` is required is a **compile error**.
|
||||
* That confusion, left to runtime, silently turns "naming is not reading" into
|
||||
* "naming is reading" — the exact inversion this model exists to remove.
|
||||
*
|
||||
* A consumer holding a plain `string` (from storage, a URL, JSON, a form) does NOT
|
||||
* have to narrow it: every public entry takes {@link NuriLike} and validates at the
|
||||
* door (`toNuri`), which is why no type guard is exported. Permissive in, precise
|
||||
* out. The runtime checks stay regardless — a JavaScript consumer never meets the
|
||||
* compiler, and a cast bypasses it.
|
||||
*/
|
||||
export type ReadCap = `did:ng:${string}:r:${string}`;
|
||||
|
||||
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
|
||||
* consumer's concern; this layer only knows the three scopes exist. */
|
||||
export type Scope = "public" | "protected" | "private";
|
||||
|
||||
/** The current identity id. Target: the wallet user (`session.user`). Polyfill:
|
||||
* a chosen id, because everyone shares one wallet. */
|
||||
export type PrincipalId = string;
|
||||
|
||||
/**
|
||||
* Loose shape of the real `@ng-org/web` `ng` object that we wrap. Injected by
|
||||
* the consumer at {@link configure} — we never hard-import the SDK, which keeps
|
||||
* the build-alias safe (the app's `@ng-org/web` import can resolve to us) and
|
||||
* makes the wrapper testable with a fake. Permissive on purpose: the real `ng`
|
||||
* carries non-function members too, so we accept any property bag.
|
||||
*/
|
||||
export type NgLike = Record<string, any>;
|
||||
|
||||
/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */
|
||||
export type UseShapeLike = (...args: any[]) => any;
|
||||
|
||||
/**
|
||||
* The scopes that can carry an inbox. NOT `Scope`: upstream only the public and
|
||||
* protected store repos get one — `new_store_default` attaches an inbox solely
|
||||
* `if !private` (`engine/verifier/src/verifier.rs:2994`), and the engine's only two
|
||||
* `AddInboxCap` commits are for those two (`engine/verifier/src/site.rs:127-152`).
|
||||
*
|
||||
* Typing it out means "the private inbox" cannot be written, rather than being written
|
||||
* and returning nothing.
|
||||
*/
|
||||
/**
|
||||
* A reference as a CALLER may hand it over: any string.
|
||||
*
|
||||
* The library returns precise `Nuri`s and accepts loose ones, and that asymmetry is not
|
||||
* politeness — it is what keeps a consumer from writing something to unlearn. The wasm
|
||||
* binding takes `nuri: String` (`doc_subscribe(repo_o: String)`,
|
||||
* `sdk/js/lib-wasm/src/lib.rs:1908`), so the real SDK will accept a plain string too.
|
||||
* Demanding a `Nuri` here would force every caller to narrow whatever it read from a URL
|
||||
* or from storage — and therefore force this library to publish a type guard the SDK
|
||||
* will never have. The need would be manufactured by our own signature.
|
||||
*
|
||||
* So: precise on the way out, permissive on the way in, and validated inside
|
||||
* (`assertNuri`). The guards remain, internal, where the validation happens.
|
||||
*/
|
||||
export type NuriLike = Nuri | string;
|
||||
|
||||
export type InboxScope = Extract<Scope, "public" | "protected">;
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* The access gate — the whole shared-wallet sign-in, moved out of consumer applications.
|
||||
*
|
||||
* ── Why this lives in the library ─────────────────────────────────────────
|
||||
* Every step below exists ONLY because one wallet hosts several identities. An
|
||||
* application that implements them is writing code it will have to delete, and worse,
|
||||
* code that teaches its authors a model NextGraph does not have: *"I name my identity"*.
|
||||
* The first consumer had ~300 lines of it (a gate component, a screen, a wallet module,
|
||||
* an identity context, three BDD features). That is the library's work, not theirs.
|
||||
*
|
||||
* Upstream, none of this exists. A user opens THEIR wallet, it contains THEIR site
|
||||
* (`SensitiveWalletV0.personal_identity()`, `engine/wallet/src/types.rs:576-579`), and
|
||||
* `session_start(wallet_name, user_id)` takes an id that came FROM the wallet. There is
|
||||
* nothing to name and nothing to choose. So this module is pure scaffolding: it
|
||||
* evaporates whole, and the one call it exposes becomes a plain "open the session".
|
||||
*
|
||||
* ── The three steps, and why each is here ─────────────────────────────────
|
||||
* 1. **Hand over the wallet file.** A hosted broker cannot import a wallet inline during
|
||||
* web-app auth — a first-time device has no wallet, so the redirect dead-ends. So the
|
||||
* user downloads the `.ngw` and imports it once on the wallet app. The FILE is the
|
||||
* right primitive: a TextCode is a transient 5-minute device-to-device transfer,
|
||||
* unusable to embed.
|
||||
* 2. **Show the shared password**, for that import.
|
||||
* 3. **Take an identifier**, which names the virtual space. This is the step that
|
||||
* inverts the model, and the reason the whole gate is scaffolding.
|
||||
*
|
||||
* ── The identifier crosses a storage boundary, and that is not incidental ──
|
||||
* The flow runs in TWO contexts with SEPARATE localStorage partitions: the top-level
|
||||
* page and the broker iframe (browsers partition storage by top-level site). A value
|
||||
* written top-level is NOT the value the iframe reads. What DOES cross is the URL: the
|
||||
* redirect embeds the full app URL, query included, and reloads it in the iframe. Hence
|
||||
* the resolution order, which must not be "simplified":
|
||||
*
|
||||
* 1. `?ng-id=` in the URL — wins whenever present, because it is the only thing that
|
||||
* crosses the frontier;
|
||||
* 2. otherwise localStorage — same-partition convenience, and prefill on reload.
|
||||
*
|
||||
* Getting this wrong does not fail loudly: the iframe reads an empty identity, provisions
|
||||
* a second virtual user, and the returning user silently lands in an empty space.
|
||||
*/
|
||||
|
||||
import {
|
||||
getConfig,
|
||||
getCurrentUser,
|
||||
getStoreRegistryDeps,
|
||||
setCurrentUser,
|
||||
} from "./bootstrap";
|
||||
import { connectedUser } from "../emulated-verifier/connect";
|
||||
import type { PrincipalId } from "../model/types";
|
||||
|
||||
/**
|
||||
* Normalize an identifier the SAME way the shim keys accounts on.
|
||||
*
|
||||
* Not a detail: the identifier arrives from three places — typed at the gate, read from
|
||||
* the URL after the broker round-trip, read from storage — and if any of them normalizes
|
||||
* differently, that path keys onto a DIFFERENT virtual user. `@Erin` from the URL and
|
||||
* `erin` typed at the gate must be one space, not two. So there is one normalizer, the
|
||||
* injected one, and the gate borrows it rather than keeping its own `toLowerCase()`.
|
||||
*
|
||||
* Falls back to the library's own default when the registry is not configured yet, which
|
||||
* is possible since the gate can run before anything else.
|
||||
*/
|
||||
function normalizeIdentity(raw: string): string {
|
||||
try {
|
||||
return getStoreRegistryDeps().normalizeId(raw);
|
||||
} catch {
|
||||
return raw.trim().replace(/^@/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the gate stashes the identifier so a plain reload prefills it. */
|
||||
const STORAGE_KEY = "ng-eventually:identity";
|
||||
/** The URL parameter — the only channel that survives the broker round-trip. */
|
||||
const URL_PARAM = "ng-id";
|
||||
|
||||
/**
|
||||
* What a deployment must supply for the gate to run. These are not settings a user
|
||||
* tunes: they are the shared wallet this deployment hands out, so they belong to
|
||||
* whoever deploys, and they disappear with the gate.
|
||||
*
|
||||
* **The library reads no environment variable, ever.** The application resolves these at
|
||||
* its own build — copying the `.ngw` into its bundle, injecting the password — and
|
||||
* passes the VALUES here. A library that read `process.env` would impose its build
|
||||
* system on every consumer, and would be untestable with other values.
|
||||
*/
|
||||
export interface SharedWalletConfig {
|
||||
/** URL of the `.ngw` file served by the application's own bundle. */
|
||||
fileUrl: string;
|
||||
/** The shared password, shown for the one-time import. Zero-security by design. */
|
||||
password: string;
|
||||
/** The wallet app where the import happens. Defaults to the public one. */
|
||||
importUrl?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_IMPORT_URL = "https://nextgraph.eu/#/wallet/login";
|
||||
|
||||
/** The identifier this device already used, from the URL first, then storage. */
|
||||
function storedIdentity(): string | null {
|
||||
try {
|
||||
const fromUrl = new URLSearchParams(globalThis.location?.search ?? "").get(URL_PARAM);
|
||||
if (fromUrl && fromUrl.trim()) {
|
||||
// Normalized on the way IN: the URL carries whatever a user or a link put there
|
||||
// (`@Erin`), and an un-normalized value keys onto a different virtual user than the
|
||||
// same identifier typed at the gate.
|
||||
const normalized = normalizeIdentity(fromUrl);
|
||||
globalThis.localStorage?.setItem(STORAGE_KEY, normalized);
|
||||
return normalized;
|
||||
}
|
||||
const stored = globalThis.localStorage?.getItem(STORAGE_KEY);
|
||||
return stored ? normalizeIdentity(stored) : null;
|
||||
} catch {
|
||||
return null; // storage blocked (private mode, sandboxed iframe) — the gate asks again
|
||||
}
|
||||
}
|
||||
|
||||
/** Put the identifier where the round-trip can find it, then remember it locally. */
|
||||
function rememberIdentity(id: string): void {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(STORAGE_KEY, id);
|
||||
const url = new URL(globalThis.location!.href);
|
||||
url.searchParams.set(URL_PARAM, id);
|
||||
globalThis.history?.replaceState(null, "", url.toString());
|
||||
} catch {
|
||||
// Nothing to do: without the param the round-trip loses the identity and the gate
|
||||
// will ask again, which is the safe failure.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the gate and resolve with the identifier the user entered.
|
||||
*
|
||||
* No prefill parameter, deliberately: the gate is shown ONLY when no identity is known,
|
||||
* so there is never a value to prefill. The consumer this was moved from did prefill,
|
||||
* because its screen reappeared after the broker round-trip — here the URL carries the
|
||||
* identity across that round-trip, so a returning user does not see the barrier at all.
|
||||
* The need is met one level up rather than papered over in the form.
|
||||
*
|
||||
* Deliberately plain DOM: this is a technical barrier shown before an application
|
||||
* renders, like a password prompt on a closed beta. Binding it to a UI framework would
|
||||
* make every consumer adopt that framework for a screen that is going away.
|
||||
*/
|
||||
function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
|
||||
const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL;
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-ng-eventually", "access-gate");
|
||||
// A shadow root so the application's stylesheet cannot reshape the barrier, and the
|
||||
// barrier's cannot leak into the application.
|
||||
const root = host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
:host { all: initial; }
|
||||
.veil { position: fixed; inset: 0; z-index: 2147483647; display: flex;
|
||||
align-items: center; justify-content: center; background: #fff;
|
||||
font: 15px/1.5 system-ui, sans-serif; color: #222; padding: 24px; }
|
||||
.card { width: 100%; max-width: 420px; }
|
||||
h1 { font-size: 26px; margin: 0 0 2px; text-align: center; }
|
||||
.sub { text-align: center; color: #888; margin: 0 0 22px; }
|
||||
.step { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.n { flex: 0 0 24px; height: 24px; border-radius: 50%; background: #444; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 13px; }
|
||||
.t { font-weight: 600; font-size: 14px; margin: 1px 0 6px; }
|
||||
a, button, input { font: inherit; }
|
||||
a { color: #0b5ed7; }
|
||||
code { background: #f2f2f2; padding: 2px 6px; border-radius: 4px; user-select: all; }
|
||||
input { width: 100%; padding: 9px 10px; border: 1px solid #bbb; border-radius: 6px; box-sizing: border-box; }
|
||||
button.go { width: 100%; margin-top: 10px; padding: 10px; border: 0; border-radius: 6px;
|
||||
background: #222; color: #fff; cursor: pointer; }
|
||||
button.go[disabled] { opacity: .45; cursor: default; }
|
||||
.hint { color: #999; font-size: 12px; margin: 6px 0 0; }
|
||||
</style>
|
||||
<div class="veil"><div class="card">
|
||||
<h1>Accès</h1>
|
||||
<p class="sub">Environnement de test</p>
|
||||
<div class="step"><div class="n">1</div><div>
|
||||
<div class="t">Télécharger le portefeuille</div>
|
||||
<a href="${cfg.fileUrl}" download>Télécharger le fichier</a>
|
||||
</div></div>
|
||||
<div class="step"><div class="n">2</div><div>
|
||||
<div class="t">Mot de passe</div>
|
||||
<code>${cfg.password}</code>
|
||||
</div></div>
|
||||
<div class="step"><div class="n">3</div><div>
|
||||
<div class="t">Importer une fois</div>
|
||||
<a href="${importUrl}" target="_blank" rel="noreferrer">Ouvrir l'application portefeuille</a>
|
||||
</div></div>
|
||||
<div class="step"><div class="n">4</div><div>
|
||||
<div class="t">Votre identifiant</div>
|
||||
<input data-testid="ng-identity-input" placeholder="votre identifiant" />
|
||||
<p class="hint">Il identifie votre espace (mis en minuscules).</p>
|
||||
<button class="go" data-testid="ng-identity-enter" disabled>Entrer</button>
|
||||
</div></div>
|
||||
</div></div>`;
|
||||
|
||||
const input = root.querySelector("input") as HTMLInputElement;
|
||||
const go = root.querySelector("button.go") as HTMLButtonElement;
|
||||
const sync = (): void => { go.disabled = input.value.trim().length === 0; };
|
||||
const enter = (): void => {
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
host.remove();
|
||||
resolve(value);
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") enter(); });
|
||||
go.addEventListener("click", enter);
|
||||
sync();
|
||||
|
||||
document.body.appendChild(host);
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an identity is set for this session, showing the gate only if one is missing.
|
||||
*
|
||||
* The application calls this once, before it renders. It does NOT pass an identifier:
|
||||
* naming one is the step that will disappear, so it must not appear in the signature —
|
||||
* the day the wallet supplies the identity, this resolves without showing anything and
|
||||
* the caller's code is unchanged.
|
||||
*
|
||||
* A returning user never sees the gate: the identifier survives the broker round-trip in
|
||||
* the URL, and a plain reload finds it in storage.
|
||||
*
|
||||
* **It RETURNS the identity it settled**, and that is not a convenience — it is the only
|
||||
* way an application can know who it is. Upstream the question does not arise: an app
|
||||
* passes `user_id` to `session_start(wallet_name, user_id)` (`@ng-org/web`), having got it
|
||||
* from the wallet it opened, so it holds its identity before the session exists. Here the
|
||||
* GATE chooses it, so the gate is what hands it back. Without this the example
|
||||
* application had to read the gate's own private storage key — a boundary no consumer
|
||||
* should be able to see, let alone depend on.
|
||||
*/
|
||||
export async function ensureIdentity(): Promise<PrincipalId> {
|
||||
const already = getCurrentUser();
|
||||
if (already !== null) {
|
||||
await connected();
|
||||
return already;
|
||||
}
|
||||
|
||||
const known = storedIdentity();
|
||||
if (known) {
|
||||
setCurrentUser(known);
|
||||
await connected();
|
||||
return known;
|
||||
}
|
||||
|
||||
const cfg = getConfig().sharedWallet;
|
||||
if (!cfg) {
|
||||
// Not a misconfiguration to paper over: without a shared wallet there is nothing to
|
||||
// hand the user, and silently continuing would provision an anonymous space.
|
||||
throw new Error(
|
||||
"[ng-eventually] access gate: no shared wallet configured. Pass `sharedWallet` to " +
|
||||
"`configure()` — the wallet file URL and its password — or set the identity yourself.",
|
||||
);
|
||||
}
|
||||
if (typeof document === "undefined") {
|
||||
throw new Error(
|
||||
"[ng-eventually] access gate: no identity set and no DOM to ask on (server-side or " +
|
||||
"test context). Set one explicitly before calling.",
|
||||
);
|
||||
}
|
||||
|
||||
const chosen = await askForIdentity(cfg);
|
||||
const normalized = normalizeIdentity(chosen);
|
||||
rememberIdentity(normalized);
|
||||
setCurrentUser(normalized);
|
||||
await connected();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the connection work `setCurrentUser` fires — restoring what others shared
|
||||
* with this user, draining its inboxes — before this call resolves.
|
||||
*
|
||||
* **Not a convenience: a correctness fix, found by the applicative e2e.** Setting an
|
||||
* identity FIRES that work and does not wait for it. An application that rendered on
|
||||
* `ensureIdentity()` alone could read a note someone had just shared with it as
|
||||
* unreadable — which looks like a permission problem and is a timing one, in the one
|
||||
* place where the difference is invisible (nothing throws; a read is simply empty).
|
||||
*
|
||||
* Doing it here rather than exposing `connectedUser()` is the point: the awaited thing
|
||||
* has NO counterpart upstream — there, opening the session IS the connection, and no
|
||||
* application awaits a second call. So the polyfill absorbs it, and an application's
|
||||
* bootstrap keeps the shape it will still have after migration.
|
||||
*/
|
||||
async function connected(): Promise<void> {
|
||||
await connectedUser();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* access-log — an OFF-by-default observability probe for document access.
|
||||
*
|
||||
* Diagnostic tool for the shared-wallet isolation footgun: on ONE physical
|
||||
* wallet, several virtual identities coexist, and a read must never surface a
|
||||
* document scoped to another identity. When it does (identity B reading identity
|
||||
* A's doc), the leak is invisible in the data — it looks like a normal read. This
|
||||
* probe makes it VISIBLE: every real read/write is logged, prefixed by the ACTIVE
|
||||
* identity (the discriminating virtual identity, NOT the constant physical user
|
||||
* id), so replaying the scenario shows the exact line where a doc is accessed
|
||||
* under the wrong identity.
|
||||
*
|
||||
* OFF by default → zero overhead, zero output. Turned on either by the SDK config
|
||||
* option `debugAccessLog: true` (via {@link setAccessLog}) or, without touching
|
||||
* the calling code, by the env var `NG_EVENTUALLY_ACCESS_LOG=1`. The `enabled()`
|
||||
* gate is a single boolean read on the hot path when off.
|
||||
*
|
||||
* Polyfill-era, like the rest of /polyfill; removed at the real multi-store
|
||||
* migration where the broker/verifier enforces isolation natively.
|
||||
*/
|
||||
|
||||
import { getCurrentUser } from "./bootstrap";
|
||||
|
||||
/** Access kind: a document READ or a document WRITE. */
|
||||
export type AccessOp = "READ" | "WRITE";
|
||||
|
||||
// Config-driven toggle (set by configure() via setAccessLog); default OFF.
|
||||
let configEnabled = false;
|
||||
|
||||
/**
|
||||
* Env override: `NG_EVENTUALLY_ACCESS_LOG=1` (or `true`) turns the log on without
|
||||
* a code change in the caller. Read once, tolerant of env access throwing (e.g.
|
||||
* a locked-down runtime), so it never breaks the hot path.
|
||||
*/
|
||||
function envEnabled(): boolean {
|
||||
try {
|
||||
const v = (globalThis as any)?.process?.env?.NG_EVENTUALLY_ACCESS_LOG;
|
||||
return v === "1" || v === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the config-driven toggle (called from configure()). */
|
||||
export function setAccessLog(on: boolean): void {
|
||||
configEnabled = on;
|
||||
}
|
||||
|
||||
/** Whether access logging is currently on (config OR env). */
|
||||
export function enabled(): boolean {
|
||||
return configEnabled || envEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity to prefix an access line with: the ACTIVE virtual identity
|
||||
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
|
||||
* the discriminating signal for the isolation leak. NOT the physical user id
|
||||
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
|
||||
* Exported so every other polyfill-layer log site (store-registry, inbox,
|
||||
* outbox-log, …) shares the exact same identity resolution as the access log,
|
||||
* instead of re-deriving it — see {@link accessLogPrefix}.
|
||||
*/
|
||||
export function activeIdentity(): string {
|
||||
return getCurrentUser() ?? "(none)";
|
||||
}
|
||||
|
||||
/**
|
||||
* The common line prefix for every polyfill-layer low-level-data-path log:
|
||||
* `[<identity>][polyfill]` — identity FIRST (the discriminating scan signal),
|
||||
* `[polyfill]` glued right after with no space between the two brackets. Used by
|
||||
* {@link logAccess} itself, by the unified `console.error`s in store-registry.ts /
|
||||
* inbox.ts, and by the BARRIER / stage-resolution / OUTBOX lines (open-repo.ts,
|
||||
* store-registry.ts, outbox-log.ts) — one single prefix builder so every polyfill
|
||||
* log line is visually groupable by identity when scanning a live session.
|
||||
*/
|
||||
export function accessLogPrefix(): string {
|
||||
return "[" + activeIdentity() + "][polyfill]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one CONCISE diagnostic line — ONLY when {@link enabled} — prefixed by
|
||||
* {@link accessLogPrefix}. Used for the precise data-path trace (BARRIER
|
||||
* resolution, per-stage resolution outcome, the empty-OUTBOX line): a single
|
||||
* line per stage/event, never a dump. Callers pass the fully-composed suffix
|
||||
* (e.g. `"BARRIER " + shortNuri(nuri) + " synced (842ms)"`).
|
||||
*/
|
||||
export function logStage(line: string): void {
|
||||
if (!enabled()) return;
|
||||
console.log(accessLogPrefix() + " " + line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten a NURI for the access log: the full form (`did:ng:o:<RepoID>:v:<...>`,
|
||||
* ~100 chars) is too verbose to scan. Drop the `did:ng:o:` prefix and the `:v:<...>`
|
||||
* overlay suffix, and keep the first 8 chars of the RepoID + an ellipsis — short but
|
||||
* still identifiable (`vDlwbZio…`). A NURI that doesn't match the expected shape is
|
||||
* returned unchanged (best-effort, this is only a diagnostic label).
|
||||
*/
|
||||
export function shortNuri(nuri: string): string {
|
||||
const withoutPrefix = nuri.replace(/^did:ng:o:/, "");
|
||||
const repoId = withoutPrefix.split(":v:")[0] ?? withoutPrefix;
|
||||
return repoId.length > 8 ? repoId.slice(0, 8) + "…" : repoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log one document access — but ONLY when {@link enabled}. Off → returns
|
||||
* immediately, prints nothing. Format:
|
||||
* `[<identity>][polyfill] READ <shortNuri> (<label>)` — identity FIRST (the
|
||||
* discriminating scan signal), `[polyfill]` glued right after with no space
|
||||
* between the two brackets — optionally with `<extra>` appended (e.g. ` → 3
|
||||
* rows`, a strong signal a doc rendered data under an identity that should see
|
||||
* nothing). The `[polyfill]` tag marks these as SDK-layer access logs (distinct
|
||||
* from the consumer app's own logs). The NURI is shortened by {@link shortNuri}
|
||||
* to keep the line scannable.
|
||||
*/
|
||||
export function logAccess(
|
||||
op: AccessOp,
|
||||
nuri: string,
|
||||
label: string,
|
||||
extra?: string,
|
||||
): void {
|
||||
if (!enabled()) return;
|
||||
console.log(
|
||||
accessLogPrefix() + " " + op + " " + shortNuri(nuri) + " (" + label + ")" + (extra ?? ""),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* The injection store — where the consumer application plugs the real SDK in, and
|
||||
* where the emulation keeps the state that only exists because one wallet hosts every
|
||||
* identity: the injected `ng`/`useShape`, the registry dependencies, WHO is currently
|
||||
* connected, and the `CapRegistry` singleton keyed by that holder.
|
||||
*
|
||||
* **NO COUNTERPART at any layer, by construction.** Upstream nothing is injected: the
|
||||
* app imports the SDK, and "who am I" is the session — there is no current-user relay
|
||||
* because a wallet has exactly one user. This module is the shape of that absence, so
|
||||
* it belongs with the shared-wallet machinery and evaporates whole at migration.
|
||||
*
|
||||
* Extracted from `polyfill.ts` on 2026-08-03. Before that, every internal module
|
||||
* imported the published ENTRY to reach the config, which made the entry a dependency
|
||||
* of the code it publishes — cycles `polyfill` <-> `connect` and `polyfill` <-> `inbox`.
|
||||
* The entry now only re-exports; the internals import this module instead.
|
||||
*/
|
||||
|
||||
import type { NgLike, UseShapeLike, Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
|
||||
import type { SharedWalletConfig } from "./access-gate";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import type { RegistrySession } from "./account-registry";
|
||||
import { CapRegistry } from "../emulated-verifier/caps";
|
||||
import { resetPublicStoreFetches } from "../emulated-verifier/public-store";
|
||||
import { setAccessLog } from "./access-log";
|
||||
import { inspectOutbox } from "./outbox-log";
|
||||
import { startConnect } from "../emulated-verifier/connect";
|
||||
|
||||
/**
|
||||
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
||||
* registry itself is generic (it knows only native scopes); the consumer wires
|
||||
* up how to reach the shared-wallet session and how to normalize an identity id
|
||||
* used as the shim key. Removed at migration along with the whole shim.
|
||||
*/
|
||||
export interface StoreRegistryDeps {
|
||||
/** Resolve the current shared-wallet session (id + private-store anchor). */
|
||||
getSession: () => Promise<RegistrySession>;
|
||||
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
||||
normalizeId?: (id: string) => string;
|
||||
/**
|
||||
* POINTER micro-guard budget. The account records now live in a subscribable
|
||||
* doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
|
||||
* in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
|
||||
* need NO retry (this replaces the deleted account-level `provisionRetry`). The
|
||||
* ONLY residual sync-lag window is the store-root pointer read itself — one
|
||||
* write-once triple. This bounded guard re-reads JUST that pointer a few times if a
|
||||
* fresh cold read misses it; it can never provision or fork an account (worst case:
|
||||
* a couple extra reads before an existing pointer is seen). Enable it where the REAL
|
||||
* broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single
|
||||
* read, keeping the synchronous unit fakes fast and unchanged.
|
||||
*/
|
||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the polyfill needs, in ONE call.
|
||||
*
|
||||
* It used to take two — `configure` for the SDK injection, `configureStoreRegistry` for
|
||||
* the session — because the two belonged to different internals. That is a reason the
|
||||
* library has, not one an application should pay for: from a caller's side both are
|
||||
* "here is what you need to run", and two bootstrap calls is one more thing to delete
|
||||
* at migration than there needs to be. Merged 2026-08-07; the registry's own wiring
|
||||
* function stays internal.
|
||||
*/
|
||||
export interface EventuallyConfig {
|
||||
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
|
||||
ng: NgLike;
|
||||
/** The REAL `@ng-org/orm` `useShape`. */
|
||||
useShape: UseShapeLike;
|
||||
/**
|
||||
* Resolve the wallet session. Shared-wallet only: upstream the session IS the user, so
|
||||
* there is nothing to inject — an application opens its wallet and the SDK knows.
|
||||
* A thunk, so it may be given before the session exists.
|
||||
*/
|
||||
getSession?: () => Promise<RegistrySession>;
|
||||
/** Normalize an identity id for shim keying. Default: trim. */
|
||||
normalizeId?: (id: string) => string;
|
||||
/**
|
||||
* POINTER micro-guard budget — see {@link StoreRegistryDeps.pointerGuard}. Left unset
|
||||
* → a single read, which keeps the synchronous unit fakes fast.
|
||||
*/
|
||||
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||
/**
|
||||
* The shared wallet this deployment hands out, and what the access gate needs to do
|
||||
* it (`shared-wallet/access-gate.ts`). Absent → no gate; the caller sets the identity
|
||||
* itself. Disappears with the gate: upstream a user opens their own wallet.
|
||||
*/
|
||||
sharedWallet?: SharedWalletConfig;
|
||||
/** Initial current user; may also be set later via {@link setCurrentUser}. */
|
||||
currentUser?: PrincipalId;
|
||||
/**
|
||||
* Turn on the OFF-by-default document access log (see {@link ./access-log}):
|
||||
* every real read/write is printed, prefixed by the active identity, to
|
||||
* diagnose the shared-wallet isolation leak. Also enablable without a code
|
||||
* change via the env var `NG_EVENTUALLY_ACCESS_LOG=1`. Default: false.
|
||||
*/
|
||||
debugAccessLog?: boolean;
|
||||
/** REAL `@ng-org/web` `init` (lifecycle) — forwarded by the lib's `init()`. */
|
||||
init?: (...args: any[]) => any;
|
||||
/** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */
|
||||
initNg?: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
let cfg: EventuallyConfig | null = null;
|
||||
let currentUser: PrincipalId | null = null;
|
||||
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
|
||||
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
|
||||
type ResolvedRegistryDeps = Required<
|
||||
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
|
||||
>;
|
||||
let registryDeps: ResolvedRegistryDeps | null = null;
|
||||
/**
|
||||
* The map key of the current identity — deliberately NOT the raw id.
|
||||
*
|
||||
* A virtual user IS a shim account, and the shim keys accounts by the
|
||||
* consumer-injected `normalizeId` ("@Alice" and "alice" are ONE account, with one
|
||||
* set of scope documents). This record must key the same way, or a consumer that
|
||||
* spells its own id differently between two calls gets a SECOND record and stops
|
||||
* reading its own documents — the caps are filed under one spelling and looked up
|
||||
* under the other. Falls back to the raw id while the registry deps are not yet
|
||||
* configured (nothing can be filed before that anyway).
|
||||
*/
|
||||
function capsHolder(): PrincipalId | null {
|
||||
if (currentUser === null) return null;
|
||||
return registryDeps ? registryDeps.normalizeId(currentUser) : currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* The emulated cap registry — one record PER identity (per virtual user),
|
||||
* resolved through {@link capsHolder} on every call. So switching identity
|
||||
* SWITCHES heldByHolder (nothing to reset, nothing wiped); see `caps.ts`. Empty until
|
||||
* the first cap is issued, and while it is empty the read filter passes through
|
||||
* (no regression).
|
||||
*/
|
||||
let caps = new CapRegistry(capsHolder);
|
||||
|
||||
export function configure(c: EventuallyConfig): void {
|
||||
cfg = c;
|
||||
currentUser = c.currentUser ?? null;
|
||||
setAccessLog(c.debugAccessLog ?? false);
|
||||
// The session wiring is part of the same act — see {@link EventuallyConfig}. Omitted
|
||||
// only by unit suites that never touch the registry; those get the same
|
||||
// "must be configured" error they got before, from `getStoreRegistryDeps`.
|
||||
if (c.getSession) {
|
||||
configureStoreRegistry({
|
||||
getSession: c.getSession,
|
||||
...(c.normalizeId ? { normalizeId: c.normalizeId } : {}),
|
||||
...(c.pointerGuard ? { pointerGuard: c.pointerGuard } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
|
||||
export function getConfig(): EventuallyConfig {
|
||||
if (!cfg) throw new Error("[ng-eventually] configure() must be called before use");
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Reset the injected config back to un-configured (mainly for tests, so a
|
||||
* suite that calls configure() can restore the not-configured guard state). */
|
||||
export function resetConfig(): void {
|
||||
cfg = null;
|
||||
currentUser = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the storeRegistry's dependencies. INTERNAL since 2026-08-07: an application
|
||||
* passes these to {@link configure}, which calls this. Still exported for the library's
|
||||
* own suites, which wire the registry alone.
|
||||
*/
|
||||
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
||||
// Fire the outbox inspection (Volet 3 of the low-level data-path trace) once,
|
||||
// on the FIRST successful `getSession()` resolution — the most reliable
|
||||
// "a session is established" signal available: every low-level reader/writer
|
||||
// (store-registry, open-repo, read-model, subscribe, inbox) reaches its
|
||||
// session through this SAME injected `getSession`, so wrapping it HERE catches
|
||||
// the first success from whichever caller happens to run first, instead of
|
||||
// tying the probe to one particular call site. Only on SUCCESS (an error
|
||||
// propagates untouched, exactly as before) and only ONCE per
|
||||
// `configureStoreRegistry()` call (a fresh session config → a fresh check).
|
||||
let outboxInspected = false;
|
||||
const getSession = async (): Promise<RegistrySession> => {
|
||||
const session = await deps.getSession();
|
||||
if (!outboxInspected) {
|
||||
outboxInspected = true;
|
||||
inspectOutbox();
|
||||
}
|
||||
return session;
|
||||
};
|
||||
registryDeps = {
|
||||
getSession,
|
||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
||||
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
|
||||
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
|
||||
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal — used by the storeRegistry to reach its injected dependencies. */
|
||||
export function getStoreRegistryDeps(): ResolvedRegistryDeps {
|
||||
if (!registryDeps) {
|
||||
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
|
||||
}
|
||||
return registryDeps;
|
||||
}
|
||||
|
||||
/** Reset storeRegistry deps (mainly for tests). */
|
||||
export function resetStoreRegistry(): void {
|
||||
registryDeps = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current identity id — who the SDK is reading/writing as. In the target
|
||||
* this is the wallet user established at wallet-import time; here the consumer
|
||||
* relays that id through this call so the read filter and the inbox `from` know
|
||||
* who is acting. Passing `null` clears it (no identity yet, e.g. during startup).
|
||||
*/
|
||||
export function setCurrentUser(id: PrincipalId | null): void {
|
||||
const changed = currentUser !== id;
|
||||
currentUser = id;
|
||||
// Connecting a user is what triggers inbox processing — the library's job, not
|
||||
// the app's. Fire-and-forget: this setter is synchronous and every consumer calls
|
||||
// it from synchronous code, so the work announces itself through the cap
|
||||
// registry's change signal instead of making callers await. See `connect.ts`.
|
||||
//
|
||||
// Gated on the registry being configured, and that is not a test convenience: an
|
||||
// identity set before the session resolves has nothing to restore and no inbox to
|
||||
// reach, so firing would be I/O that can only fail. The consumer's real sequence
|
||||
// is `configureStoreRegistry` then `setCurrentUser`; anything else can call
|
||||
// `connectedUser()` explicitly.
|
||||
if (changed && id !== null && registryDeps !== null) startConnect();
|
||||
}
|
||||
|
||||
export function getCurrentUser(): PrincipalId | null {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
/** The emulated cap registry — what the current identity holds, plus the emulated
|
||||
* public store. The read filter and the read-model consult it. */
|
||||
export function getCaps(): CapRegistry {
|
||||
return caps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do I hold this document's key?
|
||||
*
|
||||
* The only question the model admits. There is no "may principal P read D" anywhere
|
||||
* upstream and there cannot be: reading IS key possession, so a cap-introspection API
|
||||
* would have to invent an ACL the engine does not have (`docs/api-contract.md` § 10).
|
||||
*
|
||||
* Returns a BOOLEAN, not the cap. It used to hand the value back, and the only consumer
|
||||
* that used it did so to pass it to `share` — which now takes the document instead.
|
||||
* Nothing an application does requires holding a key: upstream it never sees one, the
|
||||
* verifier fills `ContactDetails.read_cap` itself. So the surface answers the question
|
||||
* and keeps the key.
|
||||
*/
|
||||
export function hasCap(nuri: NuriLike): boolean {
|
||||
return caps.capFor(toNuri(nuri, "hasCap")) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop EVERY holder's caps (tests / a fresh wallet). This is **not** what an identity
|
||||
* change does: switching identity switches heldByHolder, it never wipes one — if it
|
||||
* wiped, durability would be a lie and per-session re-declaration would come back
|
||||
* under another name. Nothing in the library calls this on `setCurrentUser`.
|
||||
*/
|
||||
export function resetCaps(): void {
|
||||
// Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's
|
||||
// change signal (`watchShape`) stays subscribed to the live instance instead of
|
||||
// silently holding a listener on an orphaned one.
|
||||
caps.clear();
|
||||
// …and forget which documents were already asked about, or the emulated public-store
|
||||
// fetch would answer from a memo taken before the wipe and hand back caps this
|
||||
// registry no longer holds.
|
||||
resetPublicStoreFetches();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* outbox-log — read-only diagnostic inspection of `@ng-org/web`'s offline write
|
||||
* outbox, at session bootstrap (polyfill-era, low-level-data-path trace).
|
||||
*
|
||||
* ── What this surfaces ──────────────────────────────────────────────────────
|
||||
* `@ng-org/web` (the real injected SDK) queues writes made while offline/
|
||||
* disconnected in an "outbox", persisted client-side in `sessionStorage` by the
|
||||
* WASM verifier (see `sdk/rust/src/local_broker.rs` `JsStorageConfig::
|
||||
* get_js_storage_config` in the `nextgraph-rs` core repo — read-only reference,
|
||||
* NOT vendored here). A non-empty outbox at session start is an ANOMALY worth
|
||||
* surfacing unconditionally: it means writes from a previous (disconnected)
|
||||
* session are still queued and haven't reached the broker yet.
|
||||
*
|
||||
* ── sessionStorage key shapes (verified in the core repo, not guessed) ──────
|
||||
* The outbox is keyed per LOCAL PEER id (`peer_id`, the persistent local peer's
|
||||
* pubkey — NOT the ng-eventually shim's `account`/`identity` concept), via two
|
||||
* key families written by `session_write`/read by `session_read`:
|
||||
* - `ng_peer_last_seq@<peerId>` — the peer's last reserved seq number.
|
||||
* - `ng_outboxes@<peerId>@start` — the seq number the outbox starts at.
|
||||
* - `ng_outboxes@<peerId>@<00000-idx>` — one queued (base64url + BARE-encoded)
|
||||
* event per zero-padded index, contiguous from 0 until the first miss (the
|
||||
* exact shape `outbox_read_function` walks — see `local_broker.rs`).
|
||||
* We don't know `peerId` ahead of time (it's internal to the injected SDK), so
|
||||
* we DISCOVER it by scanning `sessionStorage` for `@start` markers instead of
|
||||
* requiring it to be passed in — this also means the probe works unchanged
|
||||
* however many peers/wallets the browser session has touched.
|
||||
*
|
||||
* ── Read-only, defensive, best-effort ────────────────────────────────────────
|
||||
* This NEVER writes or deletes a key (unlike the real `outbox_read_function`,
|
||||
* which drains on read) — it only counts. The queued event bytes are opaque
|
||||
* (BARE-encoded Rust structs, base64url'd); decoding them to report concrete
|
||||
* write TARGETS (topics/docs) would mean duplicating the WASM verifier's wire
|
||||
* format in this polyfill, which is explicitly out of scope (SDK internals live
|
||||
* in the `@ng-eventually/polyfill`-independent core repo, per this repo's
|
||||
* doctrine) — so only the pending COUNT is reported, never fabricated targets.
|
||||
* `sessionStorage` access itself can throw (sandboxed iframe, disabled storage —
|
||||
* see the exact error string handled in the core repo's `main.ts`
|
||||
* `convert_error`), so the whole probe is wrapped in one try/catch: unavailable
|
||||
* → skip silently, never throw into the caller.
|
||||
*
|
||||
* Polyfill-era; removed at the real multi-store migration alongside the rest of
|
||||
* this low-level trace instrumentation.
|
||||
*/
|
||||
|
||||
import { accessLogPrefix, logStage } from "./access-log";
|
||||
|
||||
/** Matches an outbox "start" marker key, capturing the peer id. */
|
||||
const OUTBOX_START_KEY = /^ng_outboxes@(.+)@start$/;
|
||||
|
||||
/** Safety bound on the per-peer index walk, so a corrupted/mocked storage
|
||||
* (e.g. a `@start` marker with no matching index gaps) can't spin forever.
|
||||
* Real outboxes are queued-while-offline writes — nowhere near this size. */
|
||||
const MAX_SCAN_PER_PEER = 10_000;
|
||||
|
||||
/**
|
||||
* Inspect the outbox NOW and log its state — count only, never targets (see
|
||||
* module doc). Non-empty → `console.warn`, ALWAYS printed (anomaly, not gated
|
||||
* by the access-log flag). Empty → a normal {@link logStage} line, gated by the
|
||||
* access-log flag like the rest of the low-level trace. Read-only: never
|
||||
* mutates `sessionStorage`. Never throws.
|
||||
*/
|
||||
export function inspectOutbox(): void {
|
||||
try {
|
||||
const storage = (globalThis as any)?.sessionStorage;
|
||||
if (!storage) return;
|
||||
|
||||
const peers = new Set<string>();
|
||||
const length: number = storage.length ?? 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const key = storage.key?.(i);
|
||||
if (!key) continue;
|
||||
const m = OUTBOX_START_KEY.exec(key);
|
||||
const peerId = m?.[1];
|
||||
if (peerId) peers.add(peerId);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const peer of peers) {
|
||||
let idx = 0;
|
||||
while (idx < MAX_SCAN_PER_PEER) {
|
||||
const idxKey = "ng_outboxes@" + peer + "@" + String(idx).padStart(5, "0");
|
||||
if (storage.getItem(idxKey) === null) break;
|
||||
total++;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
// Anomaly: ALWAYS visible, regardless of the access-log flag.
|
||||
console.warn(accessLogPrefix() + " OUTBOX " + total + " pending write(s)");
|
||||
} else {
|
||||
logStage("OUTBOX empty");
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable / access denied — skip silently. Diagnostic
|
||||
// only, never a hard dependency of the read/write path.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* physical — the polyfill's OWN machinery, operating on the PHYSICAL user.
|
||||
*
|
||||
* ── Two levels, two APIs, and only one of them is the app's ───────────────
|
||||
* NextGraph sees exactly one user: the physical one, whose wallet everybody opens.
|
||||
* On top of it the library fabricates **virtual users** — what the consumer calls
|
||||
* an identity. Those are two different levels, and conflating them is how a
|
||||
* boundary gets a hole in it:
|
||||
*
|
||||
* | | Level | Who calls it | Guarded |
|
||||
* |---|---|---|---|
|
||||
* | `docs.*`, `subscribeDoc` | the **virtual user** | the consumer app, and the library on the user's behalf | YES — confined to the connected user (`reach.ts`) |
|
||||
* | this module | the **physical user** | the library's own machinery, and nothing else | no — it *is* the machinery the boundary is built on |
|
||||
*
|
||||
* **Nothing here is exported from the package.** `index.ts` must never re-export
|
||||
* this module: an app holding these functions could read any document of any
|
||||
* virtual user, which is precisely the boundary they exist below.
|
||||
*
|
||||
* ── Why a separate module rather than exemptions ──────────────────────────
|
||||
* The store-root pointer and the doc-shim — the index of virtual users — cannot be
|
||||
* subject to the boundary: resolving *which* documents a virtual user owns is what
|
||||
* makes virtual users exist at all. An earlier version handled that with a list of
|
||||
* exempt NURIs consulted by the guard. Separating the FUNCTIONS is stronger: the
|
||||
* machinery does not call the guarded primitive and get waved through, it calls a
|
||||
* different primitive that was never guarded. There is no exemption list to widen,
|
||||
* to get wrong, or to infer.
|
||||
*
|
||||
* The rule for deciding which side a call belongs to:
|
||||
*
|
||||
* > Does this operate on the index of virtual users (the shim), or on the content
|
||||
* > of one virtual user? The first is machinery; everything else is the user's,
|
||||
* > and is confined.
|
||||
*
|
||||
* A virtual user's own stores, its inbox and its documents are the user's — they go
|
||||
* through `docs.*` and are guarded, even though the library is what calls them.
|
||||
*
|
||||
* At migration this module disappears with the shim: there is no physical/virtual
|
||||
* split once each user opens their own wallet.
|
||||
*/
|
||||
|
||||
import { getConfig } from "./bootstrap";
|
||||
import { logAccess } from "./access-log";
|
||||
import { subscribeDocUnguarded } from "../surface/subscribe";
|
||||
import { openRepoUnguarded } from "../emulated-verifier/open-repo";
|
||||
import type { DocChange, DocChangeType, Unsubscribe } from "../surface/subscribe";
|
||||
import { isNuri } from "../model/nuri";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* Create a document as the PHYSICAL user — the shim's own documents (the doc-shim,
|
||||
* a virtual user's store documents at provisioning time, an inbox document).
|
||||
*
|
||||
* Creation is the one operation with no boundary to check: the document does not
|
||||
* exist yet, so nobody can hold its cap. What matters is who is credited with it
|
||||
* afterwards, which the caller decides by filing the cap among the caps that holder holds.
|
||||
*/
|
||||
export async function physicalCreate(
|
||||
sessionId: string,
|
||||
crdt = "Graph",
|
||||
cls = "data:graph",
|
||||
dest = "store",
|
||||
store?: unknown,
|
||||
): Promise<Nuri> {
|
||||
const { ng } = getConfig();
|
||||
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
|
||||
if (typeof nuri !== "string" || !isNuri(nuri)) {
|
||||
throw new Error(
|
||||
`[ng-eventually] physicalCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
|
||||
);
|
||||
}
|
||||
logAccess("WRITE", nuri, "physicalCreate");
|
||||
return nuri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read as the PHYSICAL user — for the shim only (the store-root pointer, the
|
||||
* doc-shim's account records).
|
||||
*
|
||||
* Unguarded by design: this is how the library learns which documents a virtual
|
||||
* user owns, so it cannot itself depend on knowing that. Do not reach for it to
|
||||
* read a virtual user's content — that is `docs.sparqlQuery`, which is confined.
|
||||
*/
|
||||
export async function physicalQuery(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
base: string | undefined,
|
||||
anchor: Nuri,
|
||||
label = "physicalQuery",
|
||||
): Promise<unknown> {
|
||||
const { ng } = getConfig();
|
||||
const result = await ng.sparql_query(sessionId, query, base, anchor);
|
||||
logAccess("READ", anchor, label, " (physical)");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */
|
||||
export async function physicalUpdate(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
anchor: Nuri,
|
||||
label = "physicalUpdate",
|
||||
): Promise<void> {
|
||||
const { ng } = getConfig();
|
||||
logAccess("WRITE", anchor, label, " (physical)");
|
||||
return ng.sparql_update(sessionId, query, anchor);
|
||||
}
|
||||
|
||||
// --- the rest of the privileged door ---------------------------------------
|
||||
//
|
||||
// Moved here 2026-08-03 so that ONE module is the machinery's entire unguarded API,
|
||||
// which is what this module's own doctrine asked for (see the header: separate
|
||||
// functions, never exemptions). Before this they lived beside their guarded twins in
|
||||
// `surface/subscribe.ts` and `emulated-verifier/open-repo.ts` — one import away from
|
||||
// being reached by mistake.
|
||||
|
||||
/**
|
||||
* Subscribe as the PHYSICAL user — the shim's own documents. The machinery's
|
||||
* counterpart to `subscribeDoc`; never exported from the package.
|
||||
*/
|
||||
export function subscribePhysicalDoc(
|
||||
nuri: Nuri,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
return subscribeDocUnguarded(nuri, onChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a repo as the PHYSICAL user — the shim's own documents (store-root, doc-shim).
|
||||
* The machinery's counterpart to `ensureRepoOpen`: resolving WHICH documents a virtual
|
||||
* user owns cannot itself be confined to that user.
|
||||
*
|
||||
* Never exported from the package.
|
||||
*/
|
||||
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void> {
|
||||
if (!nuri) return;
|
||||
return openRepoUnguarded(nuri);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* accounts — a framework-agnostic store for the current identity id.
|
||||
*
|
||||
* The identity a session acts as is established when its wallet is imported; the
|
||||
* SDK is told who that is via the current-identity call. This small store just
|
||||
* persists that id (in an injected storage) so it survives reloads and a second
|
||||
* device, re-opening the same wallet, lands on the same identity. It carries no
|
||||
* notion of a login step, a password, or a username — only an opaque identity id.
|
||||
*
|
||||
* Framework-agnostic on purpose: no React, no DOM assumption beyond an optional
|
||||
* storage. A consumer's React `Context`/`Provider` wraps `useState` around
|
||||
* {@link IdentityStore.set}/{@link IdentityStore.clear}. The lib does not force a
|
||||
* React dependency. Removed against real NextGraph, where the wallet session is
|
||||
* the source of the identity id.
|
||||
*/
|
||||
|
||||
/** localStorage key holding the current identity id. */
|
||||
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id";
|
||||
|
||||
/**
|
||||
* Minimal storage contract (a subset of the Web `Storage` interface). The
|
||||
* consumer injects one — `window.localStorage` in a browser, a fake in tests —
|
||||
* so this stays framework/DOM-agnostic. When none is available (SSR, no
|
||||
* `window`), pass `null` and the store degrades to in-memory-null (no persist).
|
||||
*/
|
||||
export interface VirtualUserStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted current identity id. A tiny store around an injected
|
||||
* {@link VirtualUserStorage}. It holds no framework state; the consumer's Provider
|
||||
* mirrors `get()` into framework state and re-reads after `set`/`clear`.
|
||||
*/
|
||||
export class IdentityStore {
|
||||
private readonly storage: VirtualUserStorage | null;
|
||||
private readonly key: string;
|
||||
|
||||
constructor(storage: VirtualUserStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
|
||||
this.storage = storage;
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
/** The current identity id. null = no identity set yet. */
|
||||
get(): string | null {
|
||||
if (!this.storage) return null;
|
||||
try {
|
||||
return this.storage.getItem(this.key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the (trimmed) identity id. An empty/blank value is ignored and the
|
||||
* previous id is kept (returns the resulting id, or null). No NextGraph call.
|
||||
*/
|
||||
set(id: string): string | null {
|
||||
const clean = id.trim();
|
||||
if (!clean) return this.get();
|
||||
if (this.storage) {
|
||||
try {
|
||||
this.storage.setItem(this.key, clean);
|
||||
} catch {
|
||||
/* ignore — staging, no security */
|
||||
}
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Clear the persisted identity id. No NextGraph call. */
|
||||
clear(): void {
|
||||
if (this.storage) {
|
||||
try {
|
||||
this.storage.removeItem(this.key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience factory using `globalThis.localStorage` when present, else a
|
||||
* null (non-persisting) store — so the same call is safe in browser and SSR.
|
||||
*/
|
||||
export function browserIdentityStore(key: string = ACCOUNT_STORAGE_KEY): IdentityStore {
|
||||
const ls =
|
||||
typeof globalThis !== "undefined" &&
|
||||
(globalThis as { localStorage?: VirtualUserStorage }).localStorage
|
||||
? (globalThis as { localStorage: VirtualUserStorage }).localStorage
|
||||
: null;
|
||||
return new IdentityStore(ls, key);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Low-level document + SPARQL primitives.
|
||||
*
|
||||
* These call the real injected `ng` (`getConfig().ng`) directly — never the
|
||||
* public `ng` proxy (`makeNg`). This is a validated hard constraint, not a style
|
||||
* choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy,
|
||||
* and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
|
||||
* with **`DataCloneError: function ... could not be cloned`** — the footgun this
|
||||
* rule exists to prevent. Reaching the real `ng` held in the config avoids the
|
||||
* double-proxy. Do not import from `./ng-proxy`.
|
||||
*
|
||||
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
|
||||
* app's storeRegistry usage), so this is a drop-in for those raw calls.
|
||||
*/
|
||||
|
||||
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
|
||||
import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log";
|
||||
import { isNuri, toNuri } from "../model/nuri";
|
||||
import { assertMayReach, assertMayWrite } from "../emulated-verifier/reach";
|
||||
import { fetchReadCap } from "../emulated-verifier/public-store";
|
||||
import type { Nuri, NuriLike } from "../model/types";
|
||||
|
||||
// The low common point for ALL document access: every read in the SDK routes
|
||||
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
|
||||
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
|
||||
// access log is therefore instrumented HERE so no access path escapes it. Callers
|
||||
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
|
||||
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
|
||||
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
|
||||
// log is OFF (default) the extra param is inert and costs one boolean read.
|
||||
|
||||
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
|
||||
function rowCount(result: unknown): number {
|
||||
if (!result) return 0;
|
||||
if (Array.isArray(result)) return result.length;
|
||||
const anyRes = result as { results?: { bindings?: unknown[] } };
|
||||
return anyRes.results?.bindings?.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one document → its NURI.
|
||||
*
|
||||
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
|
||||
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
|
||||
* "store")` (store_repo left undefined → private store).
|
||||
*/
|
||||
export async function docCreate(
|
||||
sessionId: string,
|
||||
crdt: string,
|
||||
cls: string,
|
||||
dest: string,
|
||||
store?: unknown,
|
||||
): Promise<Nuri> {
|
||||
const { ng } = getConfig();
|
||||
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
|
||||
// The BROKER boundary. `ng` is a permissive property bag (`NgLike`), so what
|
||||
// comes back is `any` and this function's `Promise<Nuri>` would otherwise be an
|
||||
// unchecked promise — every typed NURI downstream rests on it. Validate once,
|
||||
// here, rather than let a non-reference propagate as a document.
|
||||
if (typeof nuri !== "string" || !isNuri(nuri)) {
|
||||
throw new Error(
|
||||
`[ng-eventually] docCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
|
||||
);
|
||||
}
|
||||
// **Creating a document gives you its cap.** Upstream that is not a courtesy but
|
||||
// the mechanism: `doc_create` commits `AddRepo { read_cap }` to the store's Store
|
||||
// branch, so the creator holds it from the first instant. Without this, a caller
|
||||
// could create a document through this primitive and then be refused reading or
|
||||
// writing it — which is what the e2e run against the live broker exposed.
|
||||
//
|
||||
// `physical.ts`'s counterpart deliberately does NOT do this: the shim's own
|
||||
// documents belong to no virtual user, and `store-registry` files their caps
|
||||
// itself, where it knows whose they are.
|
||||
getCaps().mint(nuri);
|
||||
// A container creation is a WRITE; the NURI only exists after the call.
|
||||
logAccess("WRITE", nuri, "docCreate");
|
||||
return nuri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
|
||||
*
|
||||
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
|
||||
* document NURI the update is scoped/base'd to (optional).
|
||||
*/
|
||||
export async function sparqlUpdate(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
anchorLike?: NuriLike,
|
||||
label = "sparqlUpdate",
|
||||
): Promise<void> {
|
||||
const { ng } = getConfig();
|
||||
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
|
||||
// The boundary, in two questions that are NOT the same one.
|
||||
//
|
||||
// Reaching is possession. Writing is OWNERSHIP — upstream the right to write is
|
||||
// membership of the repo (`verify_permission`, reachable only from `Commit::verify`,
|
||||
// so on commits and never on reads), and how you came by the READ key changes nothing
|
||||
// about it. A public store hands its read cap to whoever asks; a cap deposited in your
|
||||
// inbox is a Link someone gave you. Neither makes you a member.
|
||||
//
|
||||
// NO public-store fetch here, unlike the read below: asking the network for a read key
|
||||
// has no bearing on a write.
|
||||
if (anchor !== undefined) {
|
||||
assertMayReach(anchor, "docs.sparqlUpdate");
|
||||
await assertMayWrite(anchor, "docs.sparqlUpdate");
|
||||
}
|
||||
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
|
||||
logAccess("WRITE", anchor ?? "(no anchor)", label);
|
||||
return ng.sparql_update(sessionId, query, anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
|
||||
*
|
||||
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
|
||||
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
|
||||
*/
|
||||
export async function sparqlQuery(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
base?: string,
|
||||
anchorLike?: NuriLike,
|
||||
label = "sparqlQuery",
|
||||
): Promise<unknown> {
|
||||
const { ng } = getConfig();
|
||||
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlQuery");
|
||||
// The boundary: an ANCHORED read may only touch what the connected virtual user
|
||||
// reaches. An anchorless query spans the local union — a different problem (it is
|
||||
// O(wallet size), and the read path never uses it), not one this guard can bound.
|
||||
//
|
||||
// Asking the (emulated) network first, as `ensureRepoOpen` does: a document in a
|
||||
// public store gives its cap to whoever asks, and this is a door an application can
|
||||
// reach with nothing but a bare reference. Inert once the cap is held, memoised
|
||||
// otherwise — see public-store.ts.
|
||||
if (anchor !== undefined) {
|
||||
await fetchReadCap(anchor);
|
||||
assertMayReach(anchor, "docs.sparqlQuery");
|
||||
}
|
||||
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
|
||||
const result = await ng.sparql_query(sessionId, query, base, anchor);
|
||||
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
|
||||
// rows under an identity that should see nothing) can be appended. Skip the
|
||||
// rowCount work entirely when the log is off.
|
||||
if (accessLogEnabled()) {
|
||||
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
|
||||
// domain objects — one document's entity is spread across several triple rows.
|
||||
// Spell that out so the log isn't mistaken for an object count (the app-level
|
||||
// object/shape count is logged separately by useShapeQuery → dataStats).
|
||||
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
|
||||
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
|
||||
* decision). The mechanism itself knows no application domain: the consumer
|
||||
* supplies the inbox document NURI and interprets the `payload`. (An example
|
||||
* consumer mapping, purely illustrative: a consumer might use one inbox for a
|
||||
* registration deposit and another for submitting a reference to an index.)
|
||||
*
|
||||
* ── Real target vs this emulation ─────────────────────────────────────────
|
||||
* In real NextGraph, a message is sealed to the recipient's key and queued into
|
||||
* their inbox; the recipient's own verifier unseals each queued message and
|
||||
* applies it inline as it processes the inbox — there is no separate curator
|
||||
* process. There is NO sender-side JS call for this today: the verifier has no
|
||||
* `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`,
|
||||
* named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md` —
|
||||
* no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.)
|
||||
*
|
||||
* Here, on one shared wallet where everything is readable, both sides run in-lib:
|
||||
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
|
||||
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
|
||||
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
|
||||
* them. This in-lib read stands in for the recipient's own inbox processing
|
||||
* until a sealed-inbox path is exposed to JS.
|
||||
*
|
||||
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
|
||||
* never `makeNg`), so this module imports no `@ng-org` package.
|
||||
*/
|
||||
|
||||
import { sparqlQuery } from "./docs";
|
||||
import { depositInto } from "../emulated-verifier/register-write";
|
||||
import { subscribeDoc } from "./subscribe";
|
||||
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
|
||||
import { userInbox, isKnownInbox, lookupAccount } from "../shared-wallet/account-registry";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import { hasReadCap, toNuri } from "../model/nuri";
|
||||
import {
|
||||
accessLogPrefix,
|
||||
enabled as accessLogEnabled,
|
||||
logAccess,
|
||||
logStage,
|
||||
shortNuri,
|
||||
} from "../shared-wallet/access-log";
|
||||
import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
|
||||
|
||||
// --- deposit model --------------------------------------------------------
|
||||
|
||||
/** One deposit as materialized from an inbox document. */
|
||||
export interface Deposit {
|
||||
/** The sender, if identified; `null` when the deposit was anonymous. */
|
||||
from: PrincipalId | null;
|
||||
/** The consumer-defined payload (opaque here — JSON-serialized in storage). */
|
||||
payload: unknown;
|
||||
/** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** Options for {@link post}. `from` and `ts` are both optional. */
|
||||
export interface PostOptions {
|
||||
/**
|
||||
* Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
|
||||
* principal id to identify the sender. Defaults to the current polyfill user
|
||||
* ({@link getCurrentUser}) when the property is entirely absent, so callers
|
||||
* that want anonymity must pass `from: null` explicitly.
|
||||
*/
|
||||
from?: PrincipalId | null;
|
||||
/** The payload to deposit (interpreted only by the consumer). */
|
||||
payload: unknown;
|
||||
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
|
||||
* keeps tests deterministic. */
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
const SHIM = "urn:ng-eventually:inbox";
|
||||
const P = {
|
||||
type: `${SHIM}:Deposit`,
|
||||
from: `${SHIM}:from`,
|
||||
payload: `${SHIM}:payload`,
|
||||
ts: `${SHIM}:ts`,
|
||||
} as const;
|
||||
|
||||
// --- session access (shared with the storeRegistry) -----------------------
|
||||
|
||||
/** The inbox documents live in the shared wallet, so we reuse the registry's
|
||||
* injected session provider for the sessionId. Disappears at migration. */
|
||||
async function sessionId(): Promise<string> {
|
||||
return (await getStoreRegistryDeps().getSession()).sessionId;
|
||||
}
|
||||
|
||||
// --- diagnostic logging helper ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Best-effort, length-capped JSON rendering of a deposit payload for the
|
||||
* inbox diagnostic log (see {@link enabled}/{@link logAccess}). This module
|
||||
* stays domain-agnostic (see module header) — it never interprets payload
|
||||
* fields, it only dumps them verbatim so the consumer's own shape (e.g. a
|
||||
* Festipod participation: `{ participantId, eventId, … }`) is visible in the
|
||||
* log without this module knowing that shape. Capped so one oversized payload
|
||||
* can't blow up a log line; a payload that fails to stringify (e.g. a
|
||||
* circular structure a caller mistakenly passed) falls back to `String()`.
|
||||
*/
|
||||
function summarizePayload(payload: unknown): string {
|
||||
let s: string;
|
||||
try {
|
||||
s = JSON.stringify(payload) ?? String(payload);
|
||||
} catch {
|
||||
s = String(payload);
|
||||
}
|
||||
return s.length > 200 ? s.slice(0, 200) + "…" : s;
|
||||
}
|
||||
|
||||
// --- SPARQL result helpers ------------------------------------------------
|
||||
|
||||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
||||
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
|
||||
if (!result) return [];
|
||||
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
|
||||
const anyRes = result as {
|
||||
results?: { bindings?: Array<Record<string, { value: string }>> };
|
||||
};
|
||||
return anyRes.results?.bindings ?? [];
|
||||
}
|
||||
|
||||
// --- deposit (client side) ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deposit a payload into `targetInbox`.
|
||||
*
|
||||
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
|
||||
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
|
||||
* graph, so concurrent deposits don't collide.
|
||||
*
|
||||
* `from` is bound to the current identity — it is authenticated, not
|
||||
* caller-supplied. Omit it to stamp the current identity; pass `null` to deposit
|
||||
* anonymously (a legitimate choice — identified if known, anonymous otherwise).
|
||||
* A `from` naming another identity is rejected as a spoof: in the target the
|
||||
* broker seals the sender from the wallet's own key, so a client cannot forge
|
||||
* another's identity. This check is redundant once the seal enforces it, but
|
||||
* until then it closes the spoof the shared wallet would otherwise allow.
|
||||
*/
|
||||
export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promise<void> {
|
||||
const targetInbox = toNuri(targetInboxLike, "inbox.post");
|
||||
const current = getCurrentUser();
|
||||
let from: PrincipalId | null;
|
||||
if (opts.from === undefined) {
|
||||
from = current; // default: stamp the current identity
|
||||
} else if (opts.from === null) {
|
||||
from = null; // explicit anonymous deposit
|
||||
} else if (opts.from === current) {
|
||||
from = opts.from; // identifying as self — allowed
|
||||
} else {
|
||||
throw new Error(
|
||||
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
|
||||
"(anonymous) — depositing as another principal is a spoof.",
|
||||
);
|
||||
}
|
||||
const ts = opts.ts ?? Date.now();
|
||||
const sid = await sessionId();
|
||||
|
||||
// A unique subject per deposit (in the inbox graph) — no collisions.
|
||||
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
|
||||
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
|
||||
const fromTriple =
|
||||
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
|
||||
|
||||
// NO explicit `GRAPH <…>` wrapper — write the anchored DEFAULT graph:
|
||||
// `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's
|
||||
// default graph (same shape as read-model.ts readDoc/readUnion). This is the
|
||||
// CANONICAL, always-safe shape and the one the anchored default-graph read
|
||||
// queries. (Not a round-trip necessity on the current broker: the e2e harness
|
||||
// `packages/polyfill/e2e/` verified that an anchored `GRAPH <plainNuri>` write
|
||||
// ALSO round-trips here — it resolves to the same repo graph, no phantom graph.
|
||||
// The no-GRAPH form is kept as a simplicity/safety convention; re-verify with
|
||||
// that harness if the broker version changes.)
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
<${subject}> a <${P.type}> ;
|
||||
<${P.payload}> "${payloadLiteral}" ;
|
||||
<${P.ts}> "${ts}"${fromTriple} .
|
||||
}`;
|
||||
// The target must BE an inbox, and this is the one check standing between a deposit
|
||||
// and an arbitrary write into someone else's document.
|
||||
//
|
||||
// Upstream the question does not arise: `InboxPost` seals to an inbox PUBKEY and the
|
||||
// broker routes it by `inboxes: PubKey → RepoId` — addressing a plain repo with a
|
||||
// deposit is not refused, it is unrepresentable. Here an inbox is a document like any
|
||||
// other, so without this `inbox.post(someoneElsesDocument, …)` wrote four triples into
|
||||
// it, through a published door that skips both guards by design. Found by re-running
|
||||
// the adversary on the fix that un-published `depositInto` (2026-08-07) — moving that
|
||||
// function was not enough, because `post` reaches the same door.
|
||||
if (!(await isKnownInbox(targetInbox))) {
|
||||
throw new Error(
|
||||
"[ng-eventually] inbox.post: refused — this is not an inbox. A deposit is addressed " +
|
||||
"to an inbox, never to a document; upstream the two cannot even be confused, " +
|
||||
`because a deposit carries an inbox key and not a document reference. ${JSON.stringify(targetInbox)}`,
|
||||
);
|
||||
}
|
||||
// A deposit crosses the boundary on purpose — see `register-write.depositInto`.
|
||||
await depositInto(sid, update, targetInbox, "deposit");
|
||||
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
|
||||
// who deposited WHAT into which inbox — the decoded payload, not just the
|
||||
// triple-write. Gated by the same access-log flag; skip the JSON work when off.
|
||||
if (accessLogEnabled()) {
|
||||
logAccess(
|
||||
"WRITE",
|
||||
targetInbox,
|
||||
"inbox deposit",
|
||||
" from=" + (from ?? "anonymous") + " payload=" + summarizePayload(opts.payload ?? null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit into the inbox of a DOCUMENT — resolve where, then deposit there.
|
||||
*
|
||||
* The call an app makes to reach a document's owner: it needs the document (which it
|
||||
* must be able to read) and nothing else. Where the inbox is, and whether the owner
|
||||
* ever opened one, are the library's business.
|
||||
*
|
||||
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
|
||||
* to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals
|
||||
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody`
|
||||
* carries no document (`engine/net/src/types.rs:4265`), because the address already
|
||||
* identifies it. Tagging deposits with their document would be an invention consumers
|
||||
* would have to unlearn at migration, so this resolves the address and stops there.
|
||||
*
|
||||
* @throws if the document has no inbox — its owner never opened one, so there is
|
||||
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
|
||||
* this path: a deposit that vanishes without an error is worse than a refusal, and it
|
||||
* is exactly the bug per-document inboxes shipped with
|
||||
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). When "no inbox" is an expected
|
||||
* case for the caller, catch it — there is deliberately no published way to ask an
|
||||
* address in advance, because an application must name a document or a person, never an
|
||||
* inbox.
|
||||
*/
|
||||
export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise<void> {
|
||||
const doc = toNuri(docLike, "inbox.postToDocument");
|
||||
const target = await documentInboxAddress(doc);
|
||||
if (target === undefined) {
|
||||
throw new Error(
|
||||
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
|
||||
"never opened one, or you cannot read the document (the address rides on it): " +
|
||||
JSON.stringify(doc),
|
||||
);
|
||||
}
|
||||
return post(target, opts);
|
||||
}
|
||||
|
||||
|
||||
// --- cap delivery ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it
|
||||
* is the same one at all three stages: `InboxMsgContent::Link` is the message
|
||||
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
|
||||
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
|
||||
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
|
||||
* the recipient processes their inbox and files it.
|
||||
*
|
||||
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
|
||||
* no special case on the surface — a re-delivered cap is just another Link.
|
||||
*/
|
||||
const LINK_KIND = "urn:ng-eventually:inbox:link";
|
||||
|
||||
/** Links observed during the last read of an inbox, awaiting durable filing. */
|
||||
const seenByInbox = new Map<Nuri, ReadCap[]>();
|
||||
function capsSeenIn(inbox: Nuri): ReadCap[] {
|
||||
return seenByInbox.get(inbox) ?? [];
|
||||
}
|
||||
|
||||
|
||||
/** The cap a deposit carries, if it is a Link rather than consumer data. */
|
||||
function capOfPayload(payload: unknown): ReadCap | null {
|
||||
const p = payload as { kind?: unknown; cap?: unknown } | null;
|
||||
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
|
||||
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share ONE document with ONE recipient.
|
||||
*
|
||||
* The unit of sharing is the DOCUMENT: never hand over a store's cap, which would
|
||||
* give away everything the store contains, present and future. The recipient needs
|
||||
* no dedicated operation to receive it — the cap arrives as a deposit that their
|
||||
* existing {@link watch} absorbs into what they hold (see {@link read}).
|
||||
*
|
||||
* Reaching several recipients means calling this once per inbox, which is what the
|
||||
* real model does too: each delivery is sealed to one recipient.
|
||||
*
|
||||
* Upstream this path is a GAP, not a disagreement — verified at both ends:
|
||||
* - the field exists, `ContactDetails.read_cap: Option<ReadCap>`
|
||||
* (`engine/net/src/types.rs:4233`), but building a message that carries one is
|
||||
* `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`);
|
||||
* - and the receiver ignores it: `InboxMsgContent::ContactDetails` creates a fresh
|
||||
* contact document and writes `ng:site`/`ng:protected` + `ng:*_inbox`, a
|
||||
* `vcard:Individual` type, a `vcard:fn` name and an optional `vcard:hasEmail`,
|
||||
* then sets the header title (`engine/verifier/src/inbox_processor.rs:778-845`) —
|
||||
* but never `details.read_cap`. *(The list was "only the two `ng:` predicates"
|
||||
* until 2026-08-10, which understated what the arm writes; the load-bearing part
|
||||
* is the omission, not the length of the list.)*
|
||||
*
|
||||
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
|
||||
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
|
||||
*
|
||||
* The shape is right; the implementation is absent at both ends, so we emulate it
|
||||
* meanwhile.
|
||||
*/
|
||||
export async function share(doc: NuriLike, toUser: string): Promise<void> {
|
||||
const target = toNuri(doc, "inbox.share");
|
||||
// Names the DOCUMENT and the PERSON — the two things an application has. Neither the
|
||||
// key nor the address appears, because a caller will handle neither once this is
|
||||
// native: upstream the verifier fills `ContactDetails.read_cap` itself, and an inbox
|
||||
// is resolved from a profile. This took `(cap, toInbox)` at first, then `(cap, toUser)`;
|
||||
// both made the caller hold something it will not hold later.
|
||||
const cap = getCaps().capFor(target);
|
||||
if (!cap) {
|
||||
throw new Error(
|
||||
"[ng-eventually] inbox.share: this document is not yours to share — you hold no cap " +
|
||||
`for it. A cap is looked up in what you hold, or it was delivered to you: ${JSON.stringify(target)}`,
|
||||
);
|
||||
}
|
||||
// ── KNOWN DIVERGENCE: the protected inbox is hard-coded here ──────────────
|
||||
// Upstream the choice is not fixed. A contact record picks its inbox from the PROFILE
|
||||
// through which the person was reached: `a_or_b = if details.profile.is_public()
|
||||
// { "site" } else { "protected" }` (`engine/verifier/src/inbox_processor.rs:787`,
|
||||
// written as `ng:site_inbox` vs `ng:protected_inbox` at `:823-824`). Reach someone by
|
||||
// their public profile and the deposit goes to their public store's inbox; by their
|
||||
// protected profile, to the protected one.
|
||||
//
|
||||
// This library has no notion of "the profile by which I know this person", so it
|
||||
// always uses the protected one. Minor today — a consumer names a user and gets one
|
||||
// answer — but it flattens a distinction the model makes, and the day an application
|
||||
// shares with someone met through a public profile, this picks the wrong inbox.
|
||||
//
|
||||
// Not fixable in isolation: it needs a notion this library does not have, and about
|
||||
// which nothing has been established here. What IS verified: a wallet holds `sites`,
|
||||
// a `SiteV0` has an `id: PubKey`, a `name`, a `site_type` (Individual | Org) and three
|
||||
// stores (`engine/verifier/src/site.rs:23-40`); the `Identity` enum that would name
|
||||
// the rest is entirely COMMENTED OUT upstream (`engine/repo/src/types.rs:586-595`).
|
||||
// Do not build on an assumed profile model — there is none to read yet.
|
||||
//
|
||||
// (The private store has no inbox at all — `new_store_default` attaches one only
|
||||
// `if !private`, `verifier.rs:2994` — hence `InboxScope`, which makes "the private
|
||||
// inbox" unwritable rather than merely empty.)
|
||||
// The recipient must EXIST. `userInbox` provisions on first sight, so sharing with a
|
||||
// name nobody has signed in as used to succeed silently: it minted that name's three
|
||||
// stores and an inbox, and the cap landed where nobody will ever look. A mistyped
|
||||
// recipient is the ordinary case, and it produced no error at all.
|
||||
//
|
||||
// Upstream you cannot address a name you invented: a deposit is sealed to an inbox
|
||||
// PUBKEY (`InboxMsg::new`, `engine/net/src/types.rs:4299`) that reached you through an
|
||||
// inbound `ContactDetails` — someone has to have reached you first. Refusing is the
|
||||
// faithful behaviour; provisioning was the invention.
|
||||
//
|
||||
// `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read
|
||||
// that FAILED as well as for one that found nothing, so it would have told a user
|
||||
// "nobody has signed in as bob" because a query timed out. A refusal must not be
|
||||
// built on a value that conflates absence with ignorance.
|
||||
if ((await lookupAccount(toUser)) === null) {
|
||||
throw new Error(
|
||||
`[ng-eventually] inbox.share: no such recipient — nobody has signed in as ` +
|
||||
`${JSON.stringify(toUser)}. Sharing does not create the person you share with.`,
|
||||
);
|
||||
}
|
||||
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages left on a document YOU own — the read side of {@link postToDocument}.
|
||||
*
|
||||
* Named by the DOCUMENT, like the deposit side: an owner reading their own messages has
|
||||
* no more reason to handle an inbox address than a depositor does. Empty when the
|
||||
* document has no inbox, which is a state and not an error.
|
||||
*/
|
||||
export async function readForDocument(docLike: NuriLike): Promise<Deposit[]> {
|
||||
const doc = toNuri(docLike, "inbox.readForDocument");
|
||||
const address = await documentInboxAddress(doc);
|
||||
return address ? read(address) : [];
|
||||
}
|
||||
|
||||
// --- the read guard ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refuse to READ an inbox that is not the current wallet's.
|
||||
*
|
||||
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
|
||||
* is how a link reaches another wallet at all — see {@link post} / {@link share});
|
||||
* READING one is not, and it is not symmetric with it. Since caps travel as
|
||||
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
|
||||
* addressed to its owner, which defeats directed sharing entirely.
|
||||
*
|
||||
* Anonymous owns no inbox, so it can read none — an identity has to be established
|
||||
* first. At migration this disappears: an inbox is sealed to its owner's key, and
|
||||
* the guard is the cryptography.
|
||||
*/
|
||||
async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
|
||||
if (getCurrentUser() === null) {
|
||||
throw new Error(
|
||||
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
|
||||
"session — call `ensureIdentity()` first. Depositing (post/share) stays open.",
|
||||
);
|
||||
}
|
||||
if (!(await isOwnInbox(targetInbox))) {
|
||||
throw new Error(
|
||||
`[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` +
|
||||
"the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " +
|
||||
"your own — otherwise the caps addressed to its owner would be collectable by " +
|
||||
`whoever knows its NURI: ${JSON.stringify(targetInbox)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- read --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In
|
||||
* real NextGraph the recipient's own verifier applies queued messages inline as
|
||||
* it processes the inbox; here this read stands in for that until the
|
||||
* sealed-inbox path is available. The consumer interprets each deposit's
|
||||
* `payload`.
|
||||
*
|
||||
* Cap deliveries ({@link share}) are applied inline and NOT returned: they land
|
||||
* in what the current holder holds, like the verifier applying a queued message.
|
||||
* That is why receiving a cap needs no dedicated operation — a consumer already
|
||||
* watching its inbox gets them, and the resulting change re-triggers the
|
||||
* reads that were empty for want of that cap.
|
||||
*/
|
||||
export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
||||
const targetInbox = toNuri(targetInboxLike, "inbox.read");
|
||||
await assertOwnInbox(targetInbox, "read");
|
||||
// WHO this read belongs to, captured with the guard that authorised it — see the note
|
||||
// beside the filing below, and `caps.holderKey`.
|
||||
const owner = getCurrentUser();
|
||||
const ownerKey = getCaps().holderKey();
|
||||
const sid = await sessionId();
|
||||
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
|
||||
// (a cold reader that opens the repo before reading), NOT here — `inbox.watch`
|
||||
// already holds the repo open via its own `subscribeDoc`, so opening a second
|
||||
// bootstrap subscription from inside a watch's re-read would be redundant and can
|
||||
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
|
||||
// read leaves both callers correct: the watch path stays event-driven, and the
|
||||
// cold direct-read path opens the repo explicitly before calling `read`.
|
||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
|
||||
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
|
||||
// default graph, exactly where `post` writes.
|
||||
const query = `
|
||||
SELECT ?payload ?ts ?from WHERE {
|
||||
?d a <${P.type}> ;
|
||||
<${P.payload}> ?payload ;
|
||||
<${P.ts}> ?ts .
|
||||
OPTIONAL { ?d <${P.from}> ?from }
|
||||
}`;
|
||||
const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead");
|
||||
const deposits: Deposit[] = [];
|
||||
for (const row of readBindings(result)) {
|
||||
const rawPayload = row.payload?.value ?? "null";
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(rawPayload);
|
||||
} catch {
|
||||
payload = rawPayload; // tolerate a non-JSON literal
|
||||
}
|
||||
const tsRaw = row.ts?.value ?? "0";
|
||||
const ts = Number.parseInt(tsRaw, 10) || 0;
|
||||
const fromValue = row.from?.value;
|
||||
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
|
||||
}
|
||||
deposits.sort((a, b) => a.ts - b.ts);
|
||||
// Links are infrastructure, not consumer data: they never reach the caller. They
|
||||
// are only KEPT here (in memory, for this session) — FILING them durably is
|
||||
// `processInbox`'s job, because reading an inbox must not quietly write to a
|
||||
// user's store. Filing fires the registry's change signal, which is what makes a
|
||||
// view that was empty for want of that cap re-read instead of staying stale.
|
||||
const delivered: Deposit[] = [];
|
||||
const links: ReadCap[] = [];
|
||||
// The ownership guard ran at entry; the filing happens several awaits later, and filing
|
||||
// resolves WHO is holding at that moment. So an application switching identity in the
|
||||
// gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the
|
||||
// code, not a leak anyone reproduced — see `caps.holderKey`.
|
||||
//
|
||||
// Abandoning is the faithful answer: upstream an inbox is processed by ITS owner's
|
||||
// verifier, and switching user is another session. Nothing is lost — an inbox is not
|
||||
// consumed by reading, so the next connection under the right identity files them.
|
||||
const stillOwner = getCurrentUser() === owner;
|
||||
for (const d of deposits) {
|
||||
const cap = capOfPayload(d.payload);
|
||||
if (cap) {
|
||||
if (stillOwner) getCaps().learnFor(ownerKey, cap);
|
||||
links.push(cap);
|
||||
continue;
|
||||
}
|
||||
delivered.push(d);
|
||||
}
|
||||
if (links.length > 0) seenByInbox.set(targetInbox, links);
|
||||
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
|
||||
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
|
||||
// each — the exact visibility needed to trace materialization at the owner
|
||||
// side. Gated by the same access-log flag; skip the JSON work when off.
|
||||
if (accessLogEnabled()) {
|
||||
logAccess(
|
||||
"READ",
|
||||
targetInbox,
|
||||
"inbox materialize",
|
||||
" → " + delivered.length + " message(s)" +
|
||||
(deposits.length !== delivered.length
|
||||
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
|
||||
: ""),
|
||||
);
|
||||
for (const d of delivered) {
|
||||
logAccess(
|
||||
"READ",
|
||||
targetInbox,
|
||||
"inbox message",
|
||||
" ts=" + d.ts + " from=" + (d.from ?? "anonymous") + " payload=" + summarizePayload(d.payload),
|
||||
);
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
/** Alias for {@link read} — the name that reads as "process the inbox now". */
|
||||
export const materialize = read;
|
||||
|
||||
/**
|
||||
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
|
||||
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
|
||||
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
|
||||
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
|
||||
*
|
||||
* Why this over a plain {@link read}: on a FRESH session over the persistent
|
||||
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
|
||||
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
|
||||
* returns 0 deposits — even for a deposit a remote session already synced to the
|
||||
* broker. Gating on the sync barrier makes the read see the synced deposits. This
|
||||
* is the same cold-read heal any cold direct reader needs.
|
||||
*
|
||||
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
|
||||
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
|
||||
* re-read would be redundant and could race the watch's own initial-`State`
|
||||
* delivery. Use this from a COLD reader (materialize-at-connection), like
|
||||
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
|
||||
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
|
||||
*/
|
||||
export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
||||
const targetInbox = toNuri(targetInboxLike, "inbox.readSynced");
|
||||
// Marks the cold, connection-triggered entry point in the trace — the BARRIER
|
||||
// line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below
|
||||
// (from the read() this wraps) follow right after, so a live session shows
|
||||
// the whole owner-reconnect sequence together.
|
||||
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
|
||||
await assertOwnInbox(targetInbox, "readSynced");
|
||||
await ensureRepoOpen(targetInbox);
|
||||
return read(targetInbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* PROCESS an inbox: read it, and **apply** what it contains.
|
||||
*
|
||||
* Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`,
|
||||
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
|
||||
* the cap survives the session. Upstream this is what a verifier does when it
|
||||
* processes queued messages: an inbox is a **queue you consume**, not a store you
|
||||
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
|
||||
* database, and it is the thing this replaces.
|
||||
*
|
||||
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
|
||||
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
|
||||
* {@link read} does — Links are never surfaced.
|
||||
*/
|
||||
export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
||||
const targetInbox = toNuri(targetInboxLike, "inbox.processInbox");
|
||||
const deposits = await readSynced(targetInbox);
|
||||
// `readSynced` already put every Link in memory for this session; now make
|
||||
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
|
||||
// are taken from what the read just observed.
|
||||
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
|
||||
seenByInbox.delete(targetInbox);
|
||||
return deposits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
|
||||
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
||||
* `onDeposits` fires once on the initial state push and again on every subsequent
|
||||
* change to the inbox document — a local deposit OR a broker-synced remote one.
|
||||
* Returns an unsubscribe function.
|
||||
*
|
||||
* On each push it re-reads the full deposit list ({@link read}) and invokes
|
||||
* `onDeposits` only when the deposit count changed (grew), keeping the same
|
||||
* "fires on change" contract the polling watcher had — same callback signature
|
||||
* and same behaviour, just event-driven instead of `setInterval`.
|
||||
*
|
||||
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
|
||||
* there is no polling. (The inbox document is a single doc, so this is immune to
|
||||
* the ORM fan-out hang — see {@link subscribeDoc}.)
|
||||
*/
|
||||
export function watch(
|
||||
targetInboxLike: NuriLike,
|
||||
onDeposits: (deposits: Deposit[]) => void,
|
||||
_opts?: { intervalMs?: number },
|
||||
): () => void {
|
||||
// Permissive in, precise out — like every other public entry. It took a bare `Nuri`
|
||||
// until 2026-08-10, which contradicted the very reason no type guard is published.
|
||||
const targetInbox = toNuri(targetInboxLike, "inbox.watch");
|
||||
let stopped = false;
|
||||
let lastCount = -1;
|
||||
|
||||
// Re-read on every push; fire onDeposits only when the set changed (grew).
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const deposits = await read(targetInbox);
|
||||
const changed = deposits.length !== lastCount;
|
||||
// Owner-side processing decision: did this push actually grow the
|
||||
// deposit set (→ onDeposits fires, the polyfill's stand-in for
|
||||
// materialization) or was it a no-op push (→ skipped)? This is the
|
||||
// exact line to check for the "must reconnect an extra time" symptom:
|
||||
// a push whose read still sees the OLD count means the barrier/read
|
||||
// raced the write, not that watch itself failed to fire.
|
||||
if (accessLogEnabled()) {
|
||||
logAccess(
|
||||
"READ",
|
||||
targetInbox,
|
||||
"inbox watch",
|
||||
" → " + deposits.length + " message(s)" + (changed ? " (materializing)" : " (unchanged, skip)"),
|
||||
);
|
||||
}
|
||||
if (!stopped && changed) {
|
||||
lastCount = deposits.length;
|
||||
onDeposits(deposits);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " watch read failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to the inbox document: the initial State push fires the first read
|
||||
// (so onDeposits fires once immediately, as before), each later Patch a re-read.
|
||||
// The ownership guard runs inside `read`, so a watch on someone else's inbox
|
||||
// yields nothing but logged refusals rather than their deposits.
|
||||
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
|
||||
return () => {
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Lifecycle re-exports — SDK-shaped forwarders so the app imports `init` /
|
||||
* `initNg` from `@ng-eventually/polyfill` rather than from `@ng-org/*`. They
|
||||
* delegate to the REAL functions injected at `configure()`. Passthrough today;
|
||||
* a hook point later (e.g. opening the shared wallet on `init`).
|
||||
*/
|
||||
|
||||
import { getConfig } from "../shared-wallet/bootstrap";
|
||||
|
||||
/** Forwards to the real `@ng-org/web` `init`. */
|
||||
export function init(...args: any[]): any {
|
||||
const f = getConfig().init;
|
||||
if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()");
|
||||
return f(...args);
|
||||
}
|
||||
|
||||
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
|
||||
export function initNg(...args: any[]): any {
|
||||
const f = getConfig().initNg;
|
||||
if (!f) throw new Error("[ng-eventually] initNg() not injected — pass it to configure()");
|
||||
return f(...args);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The wrapped `ng`: a Proxy that forwards every method to the real SDK and
|
||||
* overrides only what the broker/verifier will do natively at migration. The
|
||||
* surface stays identical to `@ng-org/web`'s `ng`.
|
||||
*/
|
||||
|
||||
import { getConfig, getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
export function makeNg(): Record<string, any> {
|
||||
return new Proxy({} as Record<string, any>, {
|
||||
get(_target, prop: string) {
|
||||
const { ng } = getConfig();
|
||||
|
||||
// session_start → open the SHARED wallet invisibly.
|
||||
//
|
||||
// `login` used to be listed here too. `@ng-org/web` exposes no such method —
|
||||
// zero occurrences in the installed declarations and in `sdk/js/lib-wasm/src/lib.rs`
|
||||
// — so the proxy FABRICATED a member: `ng.login` answered a function instead of
|
||||
// `undefined`, and calling it threw. The one place this wrapper added to the SDK
|
||||
// surface, against its own header. Removed 2026-08-03.
|
||||
if (prop === "session_start") {
|
||||
return (...args: any[]) => {
|
||||
// TODO(polyfill): supply shared-wallet credentials so no wallet UI
|
||||
// is shown. For now, passthrough.
|
||||
return ng[prop]!(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// sparql_update → write guard (emulated write-cap check).
|
||||
// Mirrors the target broker/verifier: a write is refused unless the wallet
|
||||
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
|
||||
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
|
||||
if (prop === "sparql_update") {
|
||||
return (...args: any[]) => {
|
||||
const anchor = args[2] as Nuri | undefined;
|
||||
const caps = getCaps();
|
||||
// Passthrough (no regression) unless a WRITE policy exists AND this
|
||||
// specific document is governed by it. Ungoverned docs (mono-store
|
||||
// default, no cap declared) flow through exactly as before.
|
||||
if (
|
||||
typeof anchor === "string" &&
|
||||
caps.hasWritePolicy() &&
|
||||
caps.governsWrite(anchor) &&
|
||||
!caps.canWrite(anchor, getCurrentUser())
|
||||
) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ng.sparql_update!(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
|
||||
// here with their anticipated signatures, emulated for now.
|
||||
|
||||
// Everything else: passthrough to the real SDK, unchanged.
|
||||
const real = ng[prop];
|
||||
return typeof real === "function" ? real.bind(ng) : real;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The app-facing slice of `store-registry` — and the reason it exists as a file.
|
||||
*
|
||||
* `store-registry.ts` holds two things that must not be exported together: the
|
||||
* placement/addressing calls a consumer application legitimately makes, and the
|
||||
* shim machinery that makes virtual users work at all (account resolution, the
|
||||
* durable cap registers, the inbox-ownership predicate, cache resets). Until now
|
||||
* `index.ts` did `export * as storeRegistry from "../shared-wallet/account-registry"` and shipped
|
||||
* both, so an application could reach `ensureAccount`, `addLink` or
|
||||
* `resetRegistryCache` from the SDK-identical entry — machinery it must never call,
|
||||
* on the entry whose whole promise is "this survives migration unchanged".
|
||||
*
|
||||
* What is re-exported here is only what an application needs to do its own work,
|
||||
* and each has a target-SDK counterpart (see `docs/api-contract.md`). Everything
|
||||
* else stays reachable at `./store-registry` for the library's own modules, the
|
||||
* unit tests and the e2e harness — an internal path, not a published one.
|
||||
*
|
||||
* At migration this file disappears: placement becomes the user's real per-scope
|
||||
* stores and the calls below become native SDK ones.
|
||||
*
|
||||
* **No inbox ADDRESS is published here**, deliberately (`userInbox`,
|
||||
* `documentInboxAddress`, removed 2026-08-05). An application deposits with
|
||||
* `inbox.postToDocument(doc, …)`, shares with `inbox.share(doc, toUser)` and reads
|
||||
* its own with `inbox.readForDocument(doc)` — always naming a document or a person,
|
||||
* never an address. Upstream an address is resolved from a profile and never handled by
|
||||
* a caller, so exposing one taught a step that has to be unlearned. The example
|
||||
* application is the check: it must never name an inbox.
|
||||
*/
|
||||
|
||||
/**
|
||||
* **No IDENTITY parameter here either**, and that is the same reasoning one step further
|
||||
* (2026-08-10). The registry's own functions take `(id, scope)` — machinery needs to name
|
||||
* a user. An application does not: upstream `doc_create(session_id, …)` carries no user at
|
||||
* all, because a session IS one user's. Passing one's own identity to every placement
|
||||
* call is therefore a gesture with no successor, and it forced the application to KNOW
|
||||
* its identity — which it could only do by reading the access gate's private storage key.
|
||||
*
|
||||
* The identity comes from `ensureIdentity()`, which returns it; these calls take the
|
||||
* connected one from the session, exactly as the real SDK will.
|
||||
*/
|
||||
|
||||
import {
|
||||
createEntityDoc as registryCreateEntityDoc,
|
||||
listMyEntityDocs as registryListMyEntityDocs,
|
||||
resolveWriteGraph as registryResolveWriteGraph,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import { getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import type { Nuri, Scope } from "../model/types";
|
||||
|
||||
/** WHO is acting. Absent means the application has not signed in yet — a caller error,
|
||||
* and one worth naming rather than turning into an empty result. */
|
||||
function connectedIdentity(op: string): string {
|
||||
const id = getCurrentUser();
|
||||
if (id === null) {
|
||||
throw new Error(
|
||||
`[ng-eventually] storeRegistry.${op}: no identity is set. Call \`ensureIdentity()\` ` +
|
||||
"first — it settles who you are and returns it.",
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
|
||||
export async function createEntityDoc(scope: Scope): Promise<Nuri> {
|
||||
return registryCreateEntityDoc(connectedIdentity("createEntityDoc"), scope);
|
||||
}
|
||||
|
||||
/** The entity documents this user owns in `scope` — with their caps recovered. */
|
||||
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]> {
|
||||
return registryListMyEntityDocs(connectedIdentity("listMyEntityDocs"), scope);
|
||||
}
|
||||
|
||||
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
|
||||
export async function resolveWriteGraph(scope: Scope): Promise<Nuri> {
|
||||
return registryResolveWriteGraph(connectedIdentity("resolveWriteGraph"), scope);
|
||||
}
|
||||
|
||||
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
|
||||
export { resolveScopeGraph } from "../shared-wallet/account-registry";
|
||||
|
||||
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
|
||||
|
||||
// No `linkTo` here, and its absence is deliberate (it existed 2026-08-06, one day).
|
||||
//
|
||||
// It returned a document's KEY where a caller would ask for its reference, which turns
|
||||
// the access rule from "whoever has the reference AND the key reads" into "whoever has
|
||||
// the reference reads" — see `docs/readcap-and-nuri-model.md` § 0. That is not a leak of
|
||||
// hygiene, it is the rule changing: a document one circulates would grant everything it
|
||||
// MENTIONS, and confidentiality could no longer be composed inside a shared document.
|
||||
//
|
||||
// An application names a document with the reference it already has (every call here
|
||||
// returns bare ones), and grants access with `inbox.share(doc, toUser)`. What travels
|
||||
// with a key in it is a deliberate act, not the result of asking for a link.
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* read-model — the listing primitive of the polyfill: read a bounded, by-need set
|
||||
* of documents, each with its own anchored `sparql_query`, and return the triples
|
||||
* grouped per subject. This is the mechanism documented in docs/read-model.md.
|
||||
*
|
||||
* ── Why per-doc anchored, rather than an anchorless union-scan ─────────────
|
||||
* An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)`
|
||||
* is restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` →
|
||||
* `Some(repo_graph_name)`, which becomes the query's default graph. A body with no
|
||||
* `GRAPH` wrapper reads only that default graph → only that doc's triples, O(1) per
|
||||
* doc, independent of how many other graphs the local store holds.
|
||||
*
|
||||
* The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` →
|
||||
* `set_default_graph_as_union`) spans EVERY named graph currently in the session
|
||||
* store. On a shared / bloated wallet that accumulates across runs, that is
|
||||
* O(wallet size) → the observed ~90s timeouts. So the read path never union-scans
|
||||
* all graphs — it reads exactly the bounded by-need set, one anchored query per doc.
|
||||
*
|
||||
* NB (verified, docs/read-model.md § probe step 4): an explicit `GRAPH ?g { … }`
|
||||
* body iterates the named graphs regardless of the default graph, so an anchor does
|
||||
* not bound such a body. The per-doc read therefore uses a default-graph body (no
|
||||
* `GRAPH` wrapper) so the anchor's one-repo restriction actually applies.
|
||||
*
|
||||
* ── Why not the reactive ORM fan-out ──────────────────────────────────────
|
||||
* `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of
|
||||
* per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
|
||||
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
|
||||
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing
|
||||
* is instead a set of one-shot anchored `sparql_query`s. There is no reactive
|
||||
* union query, so reactivity is assembled by re-querying on a change signal.
|
||||
*
|
||||
* ── Generic by construction ───────────────────────────────────────────────
|
||||
* No application domain here: the consumer passes the doc NURIs to read (from
|
||||
* the discovery index for public events, or its own scope docs for my-entities)
|
||||
* and interprets the returned per-subject property bags. All NextGraph I/O routes
|
||||
* through the T01.a `docs` primitives (the real injected `ng`), so this module
|
||||
* imports no `@ng-org` package.
|
||||
*
|
||||
* At the real multi-store migration the per-doc anchored read is unchanged (native
|
||||
* SPARQL, anchored to one repo); only bringing a repo into the session (open by cap)
|
||||
* changes — the anchored query already resolves a same-session repo directly.
|
||||
*/
|
||||
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { getCaps, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
import { mustNotAttempt } from "../emulated-verifier/reach";
|
||||
import { ensureReposOpen } from "../emulated-verifier/open-repo";
|
||||
import { assertNuri } from "./sparql";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import { isMachinerySubject } from "../emulated-verifier/machinery";
|
||||
import type { Nuri, NuriLike } from "../model/types";
|
||||
|
||||
// Keep the primitives referenced so tree-shaking never drops the import used by
|
||||
// the (side-effecting) open step below; `docCreate`/`sparqlUpdate` are not used
|
||||
// here but the module intentionally depends only on the docs primitive surface.
|
||||
void docCreate;
|
||||
void sparqlUpdate;
|
||||
|
||||
/** One subject read from a doc, with its properties (predicate → values). */
|
||||
export interface UnionSubject {
|
||||
/**
|
||||
* The subject IRI (`?s`), exactly as the document carries it.
|
||||
*
|
||||
* Typed `string`, not `Nuri`: a subject is an ordinary RDF subject and may be ANY
|
||||
* IRI. An application that writes its entity under the document's own NURI gets a
|
||||
* NURI back here, but that is its convention, not this type's promise — a document
|
||||
* may hold several subjects under IRIs of the consumer's choosing, and each comes
|
||||
* back as written.
|
||||
*
|
||||
* The need that once made this `Nuri` is real and is met by {@link graph}: a
|
||||
* consumer must be able to pass back what it just read without a cast —
|
||||
* `shareNote(note.doc)`, `leaveMessage(note.doc)` — and a cast at that boundary
|
||||
* would re-open exactly the confusion the template literal types exist to close.
|
||||
* `graph` is the document reference, so that is the field to carry around.
|
||||
*/
|
||||
subject: string;
|
||||
/**
|
||||
* The document this subject was read from — the reference the caller passed to
|
||||
* {@link readUnion}, unchanged. This is the anchor, it identifies the document
|
||||
* stably, and it is what goes back into any call of this surface.
|
||||
*/
|
||||
graph: Nuri;
|
||||
/** predicate IRI → the list of object values (literals or IRIs) for it. */
|
||||
props: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
||||
function bindings(
|
||||
result: unknown,
|
||||
): Array<Record<string, { value: string } | undefined>> {
|
||||
if (!result) return [];
|
||||
if (Array.isArray(result))
|
||||
return result as Array<Record<string, { value: string }>>;
|
||||
const anyRes = result as {
|
||||
results?: { bindings?: Array<Record<string, { value: string }>> };
|
||||
};
|
||||
return anyRes.results?.bindings ?? [];
|
||||
}
|
||||
|
||||
async function sessionId(): Promise<string> {
|
||||
return (await getStoreRegistryDeps().getSession()).sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one doc with an anchored default-graph query, tolerant per-doc.
|
||||
*
|
||||
* The anchor (`doc` NURI) restricts the query to that repo's graph as the default
|
||||
* graph (`resolve_target_for_sparql(Repo)` → `Some(repo_graph_name)`); a body with
|
||||
* no `GRAPH` wrapper reads exactly that default graph → only this doc's triples.
|
||||
* This is O(1) in the doc's own size and independent of the rest of the (possibly
|
||||
* bloated / shared) session store — it never iterates other graphs.
|
||||
*
|
||||
* COLD-START (fresh session, same persistent wallet): the repo is NOT in
|
||||
* `self.repos` until something opens it, and an anchored query against an unopened
|
||||
* repo silently returns 0 rows (never `RepoNotFound`). {@link readUnion} therefore
|
||||
* opens the batch's repos ({@link ensureReposOpen}) BEFORE this read runs, so the
|
||||
* anchored query resolves a same-session repo directly. A genuinely-absent repo
|
||||
* still yields `[]` (in isolation, never aborting the others). Returns the doc's
|
||||
* rows, or `[]` on failure.
|
||||
*
|
||||
* At the real multi-store migration this becomes a real sync: opening a per-user
|
||||
* store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO).
|
||||
*/
|
||||
async function readDoc(
|
||||
sid: string,
|
||||
doc: Nuri,
|
||||
): Promise<Array<Record<string, { value: string } | undefined>>> {
|
||||
try {
|
||||
const nuri = assertNuri(doc);
|
||||
// Anchored to `nuri` → default graph = this repo. No `GRAPH ?g` wrapper, so
|
||||
// the anchor's one-repo restriction applies (an explicit `GRAPH ?g` body would
|
||||
// iterate all named graphs regardless of the anchor — see docs § probe step 4).
|
||||
const res = await sparqlQuery(
|
||||
sid,
|
||||
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
|
||||
undefined,
|
||||
nuri,
|
||||
"readDoc",
|
||||
);
|
||||
return bindings(res);
|
||||
} catch (error) {
|
||||
console.error("[read-model] read failed for", doc, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a BOUNDED, by-need set of docs — each with its OWN anchored query — and
|
||||
* return the triples grouped per subject. `docs` are the NURIs to read (the
|
||||
* consumer resolves them by need — index for public, own scope docs for mine).
|
||||
* Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
|
||||
* batch.
|
||||
*
|
||||
* A document holding SEVERAL subjects yields several entries — one per distinct
|
||||
* subject, carrying that subject as written, all sharing the document as their
|
||||
* `graph`. Properties of different subjects are never merged. Placing one business
|
||||
* entity per document stays the recommended practice (a key is per repo, so
|
||||
* isolation needs a repo per entity), but it is a recommendation about writing: the
|
||||
* read reports what is there.
|
||||
*
|
||||
* Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong
|
||||
* on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is
|
||||
* read with an anchored default-graph query, O(1) per doc, independent of wallet
|
||||
* size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
|
||||
*/
|
||||
export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
|
||||
const sid = await sessionId();
|
||||
// Drop the empties BEFORE validating, not after: this call has always tolerated a
|
||||
// list with holes in it — a scope index can carry a blank entry, and a caller
|
||||
// building a list from optional values should not have to compact it. Validating
|
||||
// first turned that tolerance into a throw, which took down a whole reconnect run.
|
||||
// Empty is absence, and absence is not a malformed reference.
|
||||
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
|
||||
if (unique.length === 0) return [];
|
||||
|
||||
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
|
||||
// target repos are not yet in `self.repos`, so an anchored read would return 0
|
||||
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
|
||||
// initial-state push before the anchored reads. No-op once opened / when the
|
||||
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
|
||||
//
|
||||
// Called on the WHOLE set, before the boundary is consulted, because opening is also
|
||||
// where a document in a PUBLIC store hands over its cap (see public-store.ts): a
|
||||
// document filtered out first would never get the chance to answer. `ensureRepoOpen`
|
||||
// still refuses to open what this user may not touch — it asks, it does not enter.
|
||||
await ensureReposOpen(unique);
|
||||
|
||||
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
|
||||
// hold before reading anything: upstream you cannot address a repo you have no cap
|
||||
// for, so asking about one is not "a read that will be refused", it is a read that
|
||||
// has no meaning. (The passage points enforce rule 1 regardless — see reach.ts — so
|
||||
// a lapse here is caught, not exploited.)
|
||||
const reachable = unique.filter((d) => !mustNotAttempt(d));
|
||||
|
||||
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
|
||||
const perDoc = await Promise.all(
|
||||
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
|
||||
);
|
||||
|
||||
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
|
||||
// already excluded these, so this loop should never drop anything. The read is
|
||||
// anchored per document, so the unit of possession is the document: the cap key is
|
||||
// the doc NURI, whatever subjects that document turns out to carry.
|
||||
const caps = getCaps();
|
||||
|
||||
// Keyed by (document, subject) — an entry is one subject INSIDE one graph, which is
|
||||
// the identity upstream gives an object too: the ORM carries `@id` and `@graph` as
|
||||
// two distinct read-only properties, and fabricates an `@id` when the writer leaves
|
||||
// it empty (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`). Several objects per
|
||||
// graph is therefore the PROVIDED case, and `@id` is what tells them apart within a
|
||||
// `@graph`. Two documents carrying the same subject IRI stay two entries: they are
|
||||
// two objects, distinguished by their graph.
|
||||
//
|
||||
// Placing one business entity per document remains the recommended practice — a key
|
||||
// is per repo, so isolating an entity requires a repo of its own. That is a
|
||||
// recommendation about WRITING, and the read does not get to enforce it by making
|
||||
// the other arrangement invisible.
|
||||
const bySubject = new Map<string, UnionSubject>();
|
||||
for (const { doc, rows } of perDoc) {
|
||||
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
|
||||
// Anchored to `doc`, so every row belongs to `doc` — hence `graph` is the caller's
|
||||
// reference to it. The subject comes back exactly as the document carries it.
|
||||
for (const row of rows) {
|
||||
// The polyfill's own compartments live as reserved SUBJECTS inside the very
|
||||
// documents the consumer reads (the Header branch carrying a document's inbox
|
||||
// address is the first). They are machinery, not this entity's properties —
|
||||
// drop them here, once, for every compartment present and future.
|
||||
const s = row.s?.value;
|
||||
if (isMachinerySubject(s)) continue;
|
||||
const p = row.p?.value;
|
||||
const o = row.o?.value;
|
||||
if (s === undefined || !p || o === undefined) continue;
|
||||
// NUL cannot appear in an IRI, so the pair never collides with either half.
|
||||
const key = `${doc}\u0000${s}`;
|
||||
let entry = bySubject.get(key);
|
||||
if (!entry) {
|
||||
entry = { subject: s, graph: doc, props: {} };
|
||||
bySubject.set(key, entry);
|
||||
}
|
||||
(entry.props[p] ??= []).push(o);
|
||||
}
|
||||
}
|
||||
return [...bySubject.values()];
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* SPARQL string-building safety helpers — shared by every module that builds
|
||||
* SPARQL by interpolation (inbox, store-registry).
|
||||
*
|
||||
* These exist because of SPARQL injection. When an untrusted value (an identity
|
||||
* id, a payload) is spliced verbatim into a query, a `"` closes a literal and a
|
||||
* `>` closes an IRI, letting the value inject arbitrary triples (or corrupt the
|
||||
* shim graph, the trust root mapping accounts → document NURIs). Every value that
|
||||
* reaches a query passes through one of these helpers first.
|
||||
*
|
||||
* Two positions, two strategies:
|
||||
* - Literal position (`"..."`): {@link escapeLiteral}. Escape rather than reject,
|
||||
* because literals legitimately carry arbitrary text (JSON payloads, display
|
||||
* names). Escaping is lossless and reversible.
|
||||
* - IRI position (`<...>`): two cases.
|
||||
* · Trusted-shaped NURIs coming back from `ng` (`did:ng:...`): validate with
|
||||
* {@link assertNuri} — they should never contain IRI-breaking chars; if one
|
||||
* does, something upstream is wrong, so it throws rather than silently
|
||||
* building a broken/injected query.
|
||||
* · Untrusted values embedded into an IRI (an identity id used to mint an
|
||||
* account-subject IRI): {@link escapeIri} percent-encodes every IRI-hostile
|
||||
* character. Encode rather than reject so any id (spaces, unicode,
|
||||
* punctuation) stays usable, while `<`, `>`, `"`, whitespace and control
|
||||
* chars can never break out of the IRI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape a value for embedding inside a SPARQL string literal (`"..."`).
|
||||
* Escapes backslash, double-quote and the C0 whitespace controls that would
|
||||
* otherwise terminate or corrupt the literal. Lossless / reversible.
|
||||
*/
|
||||
export function escapeLiteral(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\t/g, "\\t");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delimiter characters that must never appear raw inside a SPARQL/Turtle IRI
|
||||
* ref (`<...>`): the space plus `< > " { } | ^ backtick \`. Whitespace beyond
|
||||
* the space and all C0/C1 control characters are handled by the code-point
|
||||
* check in {@link isIriForbidden}. Any of these would let a value break out of
|
||||
* the `<...>` and inject arbitrary syntax.
|
||||
*/
|
||||
const IRI_FORBIDDEN_DELIMS = /[<>"{}|^`\\ ]/;
|
||||
|
||||
/** True if `ch` (a single code point) may not appear raw inside an IRI ref. */
|
||||
function isIriForbidden(ch: string): boolean {
|
||||
const code = ch.codePointAt(0)!;
|
||||
return IRI_FORBIDDEN_DELIMS.test(ch) || code < 0x20 || code === 0x7f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encode every IRI-hostile character in `value` so it is safe to embed
|
||||
* inside a SPARQL IRI ref (`<PREFIX:${escapeIri(value)}>`). Use this for
|
||||
* untrusted values (e.g. an identity id minted into an account-subject IRI):
|
||||
* encoding keeps every id usable while making breakout impossible.
|
||||
*
|
||||
* NOTE: this encodes only the delimiter/whitespace/control set, so ordinary
|
||||
* printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass
|
||||
* through unchanged and the resulting IRI stays human-readable.
|
||||
*/
|
||||
export function escapeIri(value: string): string {
|
||||
let out = "";
|
||||
for (const ch of value) {
|
||||
if (isIriForbidden(ch)) {
|
||||
// Percent-encode each UTF-8 byte of the offending character. Also encode
|
||||
// the chars encodeURIComponent leaves alone but which are IRI-hostile.
|
||||
out += encodeURIComponent(ch).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
|
||||
);
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that `nuri` is safe to embed verbatim inside a SPARQL IRI ref. NURIs
|
||||
* that come back from `ng` are trusted-SHAPED (`did:ng:...` or `urn:...`) and
|
||||
* should never carry IRI-breaking characters; if one does, we throw rather than
|
||||
* emit a query that could be malformed or injected. Returns the value unchanged
|
||||
* so it can be used inline: `<${assertNuri(doc)}>`.
|
||||
*
|
||||
* Generic in its argument so the caller's type flows THROUGH: passing a `Nuri`
|
||||
* gives back a `Nuri`, not a widened `string`. This function checks characters,
|
||||
* not the `did:ng:` shape (it legitimately accepts `urn:…` IRIs too), so it must
|
||||
* not be the thing that mints a `Nuri` — that is {@link isNuri}'s job.
|
||||
*/
|
||||
export function assertNuri<T extends string>(nuri: T): T {
|
||||
if (typeof nuri !== "string" || nuri.length === 0) {
|
||||
throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`);
|
||||
}
|
||||
for (const ch of nuri) {
|
||||
if (isIriForbidden(ch)) {
|
||||
throw new Error(
|
||||
`[sparql] NURI contains IRI-forbidden characters, refusing to embed: ${JSON.stringify(nuri)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return nuri;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Reactive single-document subscription — the polyfill's typed wrapper over the
|
||||
* platform's `doc_subscribe` primitive. This is the canonical NextGraph reactive
|
||||
* read at the document granularity: subscribe once, get the initial state pushed,
|
||||
* then a push on every subsequent commit to that document — whether the write was
|
||||
* local (this session) or a broker-synced remote change. NO POLLING.
|
||||
*
|
||||
* ── Why call the REAL injected `ng` directly (never `makeNg`) ──────────────
|
||||
* Same hard constraint as `docs.ts`: the public `ng` is a JS `Proxy` over
|
||||
* `@ng-org/web`'s iframe-RPC proxy. `doc_subscribe` is a STREAMED method — the
|
||||
* `@ng-org/web` RPC strips the callback (by positional index) BEFORE it posts to
|
||||
* the iframe and drives it locally via a `MessageChannel` port (the function is
|
||||
* never structured-cloned, so no `DataCloneError`). Layering our own Proxy on top
|
||||
* risks re-wrapping that surface; reaching the real `ng` held in the config avoids
|
||||
* the double-proxy exactly as the raw `docs` primitives do. Do not import from
|
||||
* `./ng-proxy`.
|
||||
*
|
||||
* ── The primitive shape (verified against nextgraph-rs) ────────────────────
|
||||
* `ng.doc_subscribe(repo_o: string, session_id, callback)`
|
||||
* (`sdk/js/lib-wasm/src/lib.rs:1907`) is **per-document** — one repo NURI, one
|
||||
* callback. It is `async`, resolving to a JS **unsubscribe function**. The
|
||||
* callback is invoked `callback(appResponse)` with a serialized `AppResponse`:
|
||||
* `{ V0: { State | Patch | TabInfo | ... } }`. It pushes an initial `State`
|
||||
* (plus a `TabInfo`) on subscribe, then a `Patch` per verified commit on the
|
||||
* branch. Returning `true` from the callback also cancels; we cancel by calling
|
||||
* the returned unsubscribe fn.
|
||||
*
|
||||
* ── Why per-document, never `orm_start_graph(graphs:[…])` ──────────────────
|
||||
* A single not-yet-synced repo in an ORM graph fan-out makes `RepoNotFound` abort
|
||||
* the WHOLE subscription (`initialize.rs:125-128`), so the readyPromise never
|
||||
* resolves → the ~75s hang. `doc_subscribe` is per-branch/per-doc and has no
|
||||
* fan-out: an absent doc breaks only its own subscription. {@link subscribeDocs}
|
||||
* builds a set of these with per-doc error isolation to preserve that property.
|
||||
*/
|
||||
|
||||
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
import { assertMayReach } from "../emulated-verifier/reach";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import type { Nuri, NuriLike } from "../model/types";
|
||||
|
||||
/**
|
||||
* A push from the platform to a document subscriber. Loosely typed: the raw
|
||||
* serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | ... } }`). The
|
||||
* consumer typically ignores the payload and uses the push purely as a
|
||||
* change SIGNAL (re-query on change — the read-model pattern), so this stays
|
||||
* permissive rather than modelling every AppResponse variant.
|
||||
*/
|
||||
export type DocChange = unknown;
|
||||
|
||||
/**
|
||||
* The discriminant of a {@link DocChange} — the single variant key of the raw
|
||||
* `AppResponse` payload (`{ V0: { State | Patch | TabInfo | … } }`). It is NOT a
|
||||
* closed enum: the platform may push other variants, so this is a bare `string`
|
||||
* (e.g. `"State"`, `"Patch"`, `"TabInfo"`), or `undefined` when the shape can't
|
||||
* be read. Verified against the CONTRACT-3 e2e probe (`e2e/polyfill-entry.ts`): the
|
||||
* variant is `Object.keys(resp.V0)[0]`. Exposed so a caller that needs the SYNC
|
||||
* BARRIER (the first `State`, per CONTRACT 3) can distinguish it from the earlier
|
||||
* `TabInfo`/`Patch` pushes — see `open-repo.ts`. Most callers ignore it and use
|
||||
* any push as a plain change signal.
|
||||
*/
|
||||
export type DocChangeType = string | undefined;
|
||||
|
||||
/**
|
||||
* Extract the variant key from a raw {@link DocChange}. Reads `resp.V0` (case-
|
||||
* tolerant to `v0`) and returns its first key — the AppResponse variant name
|
||||
* (`"State"` / `"Patch"` / `"TabInfo"` / …). Returns `undefined` if the payload
|
||||
* is not a recognisable `{ V0: { <Variant>: … } }` object. Inspects the variant
|
||||
* proplerly (no `any`-cast to force it) so a `State` push is identifiable.
|
||||
*/
|
||||
export function docChangeType(resp: DocChange): DocChangeType {
|
||||
if (!resp || typeof resp !== "object") return undefined;
|
||||
const outer = resp as { V0?: unknown; v0?: unknown };
|
||||
const v0 = outer.V0 ?? outer.v0;
|
||||
if (!v0 || typeof v0 !== "object") return undefined;
|
||||
const keys = Object.keys(v0 as Record<string, unknown>);
|
||||
return keys.length > 0 ? keys[0] : undefined;
|
||||
}
|
||||
|
||||
/** An unsubscribe function — idempotent (calling it twice is a no-op). */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
async function sessionId(): Promise<string> {
|
||||
return (await getStoreRegistryDeps().getSession()).sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to ONE document. `onChange` fires on the initial state push and on
|
||||
* every subsequent change to that doc (local write OR broker-synced remote
|
||||
* change). Returns an unsubscribe function.
|
||||
*
|
||||
* The wrapper is synchronous-returning (an unsubscribe fn) even though the
|
||||
* underlying `ng.doc_subscribe` is async: the real unsubscribe is captured when
|
||||
* the promise resolves; if the caller unsubscribes before setup completes, the
|
||||
* cancellation is honoured as soon as the real unsubscribe is available (and no
|
||||
* further `onChange` fires after unsubscribe).
|
||||
*
|
||||
* `onChange` receives the raw payload AND its variant type ({@link docChangeType},
|
||||
* e.g. `"State"`). The type is a NON-BREAKING second argument: existing callers
|
||||
* that ignore it (the change-signal pattern — `discovery.ts`, `inbox.ts`) are
|
||||
* unaffected; a caller that needs the sync barrier (`open-repo.ts`) reads it to
|
||||
* act only on the first `State`.
|
||||
*
|
||||
* Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`).
|
||||
*/
|
||||
export function subscribeDoc(
|
||||
nuriLike: NuriLike,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
const nuri = toNuri(nuriLike, "subscribeDoc");
|
||||
// RULE 1 — a subscription IS an access: the push carries the document's state.
|
||||
// Guarding the read paths while leaving this open would be a door beside the gate.
|
||||
assertMayReach(nuri, "subscribeDoc");
|
||||
return subscribeDocUnguarded(nuri, onChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* The unguarded core. Exported for ONE importer — `shared-wallet/physical.ts`, which
|
||||
* owns the machinery's entire privileged door — and for nobody else. It is not
|
||||
* re-exported by either entry point; the `Unguarded` suffix is the warning.
|
||||
*/
|
||||
export function subscribeDocUnguarded(
|
||||
nuri: Nuri,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
const { ng } = getConfig();
|
||||
let stopped = false;
|
||||
let realUnsub: (() => void) | null = null;
|
||||
|
||||
const cb = (resp: DocChange): void => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
onChange(resp, docChangeType(resp));
|
||||
} catch (error) {
|
||||
console.error("[subscribe] onChange handler threw for", nuri, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Kick off the async subscription. Errors are isolated to this doc (they never
|
||||
// reject a shared batch — see subscribeDocs). If setup fails, this doc simply
|
||||
// never fires; the caller's unsubscribe stays a safe no-op.
|
||||
void (async () => {
|
||||
try {
|
||||
const sid = await sessionId();
|
||||
const unsub = (await ng.doc_subscribe(nuri, sid, cb)) as (() => void) | undefined;
|
||||
if (stopped) {
|
||||
// Unsubscribed before setup resolved — cancel immediately.
|
||||
if (typeof unsub === "function") unsub();
|
||||
return;
|
||||
}
|
||||
realUnsub = typeof unsub === "function" ? unsub : null;
|
||||
} catch (error) {
|
||||
console.error("[subscribe] doc_subscribe failed for", nuri, error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (realUnsub) {
|
||||
try {
|
||||
realUnsub();
|
||||
} catch (error) {
|
||||
console.error("[subscribe] unsubscribe failed for", nuri, error);
|
||||
}
|
||||
realUnsub = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a SET of documents, one {@link subscribeDoc} per NURI, with
|
||||
* PER-DOC error isolation. `onChange(nuri, r)` fires for whichever doc changed.
|
||||
* Returns a single unsubscribe that tears down all of them.
|
||||
*
|
||||
* The per-doc isolation is the point: a bad / not-yet-synced doc breaks only its
|
||||
* own subscription and NEVER aborts the others (this is precisely what avoids the
|
||||
* ORM fan-out hang — do NOT replace this with `orm_start_graph(graphs:[…])`). The
|
||||
* set is deduplicated; an empty set returns a no-op unsubscribe.
|
||||
*/
|
||||
export function subscribeDocs(
|
||||
nuris: Nuri[],
|
||||
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
const unique = [...new Set(nuris.filter(Boolean))];
|
||||
const unsubs = unique.map((nuri) => {
|
||||
// Each subscription is independent: subscribeDoc already isolates its own
|
||||
// async setup failure (logged, never thrown), so one bad doc cannot abort the
|
||||
// construction of the others here.
|
||||
try {
|
||||
return subscribeDoc(nuri, (r, type) => onChange(nuri, r, type));
|
||||
} catch (error) {
|
||||
console.error("[subscribe] subscribeDocs: failed to subscribe", nuri, error);
|
||||
return () => {};
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
for (const u of unsubs) {
|
||||
try {
|
||||
u();
|
||||
} catch (error) {
|
||||
console.error("[subscribe] subscribeDocs: unsubscribe failed", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Wrapped `useShape`: same signature as `@ng-org/orm`. Once the cap emulation is
|
||||
* in force, the returned set is a read-filtered VIEW (only items in documents the
|
||||
* current holder has the ReadCap of); before the first cap is issued it passes the
|
||||
* real set through unchanged. At migration the filtering disappears — the broker
|
||||
* only delivers documents whose cap the wallet holds.
|
||||
*/
|
||||
|
||||
import { getConfig, getCaps } from "../shared-wallet/bootstrap";
|
||||
import { makeReadFilteredView } from "../emulated-verifier/read-filter";
|
||||
|
||||
export function useShape(shapeType: unknown, scope: unknown): unknown {
|
||||
const set = getConfig().useShape(shapeType, scope) as object;
|
||||
const caps = getCaps();
|
||||
if (!caps.isEnforcing()) return set; // no cap issued yet → passthrough
|
||||
return makeReadFilteredView(set, caps);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
|
||||
* one logical scope. This is the surface the consuming app will bind (phase B) with
|
||||
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
|
||||
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
|
||||
*
|
||||
* ── Why an observable, and why this exact shape ────────────────────────────
|
||||
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
|
||||
* natively distinguish "sync in progress" from "synced but empty". That distinction
|
||||
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
|
||||
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
|
||||
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
|
||||
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
|
||||
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
|
||||
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
|
||||
*
|
||||
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
|
||||
* 1. Resolve the logical scope → the doc set: the CURRENT wallet's own per-entity
|
||||
* documents for that scope (`storeRegistry.listMyEntityDocs`), and nothing
|
||||
* else. There is no "everything public" to fold in — **you cannot discover,
|
||||
* you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
|
||||
* and a link reaches you through an inbox or through a document you already
|
||||
* hold. A document whose cap you were given is read by NAMING it
|
||||
* (`readUnion`), not by turning up in a scope you never put it in.
|
||||
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
|
||||
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
|
||||
* fallback). `isPending` holds until the barrier is reached for the current
|
||||
* doc set AND the first `readUnion` has rendered.
|
||||
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
|
||||
* NOT double-filter), then FILTER the union by the requested shape's `@type`
|
||||
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
|
||||
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
|
||||
* ShapeType, not from any application concept.
|
||||
*
|
||||
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
|
||||
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
|
||||
* pushes, and the KEYRING — a cap that arrives asynchronously (an inbox delivery
|
||||
* absorbed by the consumer's `inbox.watch`) makes documents readable that were not,
|
||||
* so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
|
||||
* unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
|
||||
* in the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
|
||||
* entity appends a NURI to the scope-index doc), so we ALSO subscribe to the
|
||||
* scope-index document: a push there re-RESOLVES the scope and re-keys the
|
||||
* subscribed set. Subscriptions are idempotent — an already-followed
|
||||
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
|
||||
* parallel channel.
|
||||
*
|
||||
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
|
||||
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
|
||||
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
|
||||
* `isError` fires ONLY on a real thrown exception in the pipeline.
|
||||
*/
|
||||
|
||||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { ensureReposOpen, getSyncState } from "../emulated-verifier/open-repo";
|
||||
import { readUnion, type UnionSubject } from "./read-model";
|
||||
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
||||
import { listMyEntityDocs, userStoreDoc } from "../shared-wallet/account-registry";
|
||||
import type { Nuri, Scope } from "../model/types";
|
||||
|
||||
/**
|
||||
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
|
||||
* constraint on this predicate; we filter the read union by it.
|
||||
*/
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
|
||||
/**
|
||||
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
|
||||
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
|
||||
* the generic per-subject property bags of the read-model (NO application domain);
|
||||
* the app maps them to its own entity types in phase B.
|
||||
*/
|
||||
export interface ShapeQuery<T = UnionSubject> {
|
||||
/** The subjects of the requested shape/scope. Empty array when none (never undefined). */
|
||||
data: T[];
|
||||
/** True while the sync barrier for the current doc set is not yet reached OR the
|
||||
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
|
||||
isPending: boolean;
|
||||
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the
|
||||
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
|
||||
* `data: []` — the distinction this surface exists for. */
|
||||
isSuccess: boolean;
|
||||
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`). */
|
||||
isError: boolean;
|
||||
/** The caught error when `isError`, else `undefined`. */
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
/** The observable a caller binds with `useSyncExternalStore` (phase B). */
|
||||
export interface ShapeObservable<T = UnionSubject> {
|
||||
/** The current snapshot. STABLE across calls until it actually changes (so
|
||||
* `useSyncExternalStore` does not loop): the same reference is returned until a
|
||||
* state transition produces a new one. */
|
||||
getSnapshot(): ShapeQuery<T>;
|
||||
/** Register a change listener; returns an unsubscribe. The last listener's
|
||||
* unsubscribe tears down the underlying doc subscriptions. */
|
||||
subscribe(onChange: () => void): () => void;
|
||||
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent
|
||||
* w.r.t. subscriptions; never polls. */
|
||||
refetch(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
|
||||
* A generated shape constrains its subject's type via a triple constraint on the
|
||||
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
|
||||
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
|
||||
* and every subject in the doc set flows through). Purely structural — reads only
|
||||
* the SHEX schema, no application domain.
|
||||
*/
|
||||
function shapeTypeIris(shapeType: unknown): Set<string> | null {
|
||||
try {
|
||||
const st = shapeType as {
|
||||
shape?: string;
|
||||
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
|
||||
};
|
||||
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
|
||||
const preds = shape?.predicates ?? [];
|
||||
const iris = new Set<string>();
|
||||
for (const p of preds) {
|
||||
if (p?.iri !== RDF_TYPE) continue;
|
||||
for (const dt of p.dataTypes ?? []) {
|
||||
for (const lit of dt.literals ?? []) {
|
||||
if (typeof lit === "string") iris.add(lit);
|
||||
}
|
||||
}
|
||||
}
|
||||
return iris.size > 0 ? iris : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
|
||||
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
|
||||
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
|
||||
const types = subject.props[RDF_TYPE] ?? [];
|
||||
return types.some((t) => typeIris.has(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
|
||||
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
|
||||
* state that still holds the barrier open is `syncing` (subscribed, first `State`
|
||||
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
|
||||
* best-effort, not an error). `unknown` means the injected `ng` has no
|
||||
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
|
||||
* a completed open it can only mean that path, so it counts as reached (opened,
|
||||
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
|
||||
*/
|
||||
function barrierReached(docs: Nuri[]): boolean {
|
||||
for (const d of docs) {
|
||||
if (getSyncState(d) === "syncing") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
||||
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
|
||||
* the full pipeline. The returned observable is inert until its first
|
||||
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
|
||||
* what kicks off resolution, opening and the first read; before then `getSnapshot`
|
||||
* reports the initial pending snapshot.
|
||||
*/
|
||||
export function watchShape<T = UnionSubject>(
|
||||
shapeType: unknown,
|
||||
scope: Scope,
|
||||
): ShapeObservable<T> {
|
||||
const typeIris = shapeTypeIris(shapeType);
|
||||
|
||||
// The current, STABLE snapshot (same reference until a transition rebuilds it).
|
||||
let snapshot: ShapeQuery<UnionSubject> = {
|
||||
data: [],
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let started = false;
|
||||
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe.
|
||||
// Idempotent: a doc already here is not re-subscribed. Excludes the container
|
||||
// (scope-index / discovery-index) subscriptions, held separately.
|
||||
const docSubs = new Map<Nuri, Unsubscribe>();
|
||||
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
|
||||
// a push here means the doc SET may have changed → re-resolve.
|
||||
const containerSubs = new Map<Nuri, Unsubscribe>();
|
||||
// Unsubscribe from the held-caps change signal (see the subscription in `start`).
|
||||
let capsUnsub: (() => void) | null = null;
|
||||
// True while `resolveDocs` runs. Folding a repo link files a cap, which fires the
|
||||
// held-caps signal; the resolution in progress already accounts for it, so the
|
||||
// signal is ignored during that window instead of restarting the cycle.
|
||||
let resolving = false;
|
||||
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
|
||||
let refreshToken = 0;
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] listener threw", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setSnapshot(next: ShapeQuery<UnionSubject>): void {
|
||||
snapshot = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Resolve the logical scope → the current doc set: the CURRENT wallet's own
|
||||
* entity documents for that scope, and nothing else. Tolerant: a resolution
|
||||
* failure yields whatever resolved.
|
||||
*
|
||||
* There is no "everything public" to fold in. You cannot discover; you can only
|
||||
* follow links, and a link reaches you through an inbox or through a document
|
||||
* you already hold — never through a shared index. A document someone gave you
|
||||
* the cap for is read by naming it (`readUnion`), not by appearing in
|
||||
* a scope you did not put it in. */
|
||||
async function resolveDocs(): Promise<Nuri[]> {
|
||||
const user = getCurrentUser();
|
||||
const set = new Set<Nuri>();
|
||||
resolving = true;
|
||||
try {
|
||||
if (user) {
|
||||
try {
|
||||
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] listMyEntityDocs failed", error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resolving = false;
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** Subscribe to the CONTAINER document (the scope index) so a change to the doc
|
||||
* SET re-resolves. Idempotent per NURI. */
|
||||
async function ensureContainerSubs(): Promise<void> {
|
||||
const containers: Nuri[] = [];
|
||||
const user = getCurrentUser();
|
||||
if (user) {
|
||||
try {
|
||||
containers.push(await userStoreDoc(user, scope));
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] userStoreDoc failed", error);
|
||||
}
|
||||
}
|
||||
for (const c of containers) {
|
||||
if (!c || containerSubs.has(c)) continue;
|
||||
// A push on a container doc means the set may have changed → full re-resolve.
|
||||
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
|
||||
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
|
||||
function syncDocSubs(docs: Nuri[]): void {
|
||||
const wanted = new Set(docs.filter(Boolean));
|
||||
for (const [nuri, unsub] of docSubs) {
|
||||
if (!wanted.has(nuri)) {
|
||||
try {
|
||||
unsub();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
docSubs.delete(nuri);
|
||||
}
|
||||
}
|
||||
for (const nuri of wanted) {
|
||||
if (docSubs.has(nuri)) continue;
|
||||
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
|
||||
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
|
||||
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
|
||||
let subjects: UnionSubject[];
|
||||
try {
|
||||
subjects = await readUnion(docs);
|
||||
} catch (error) {
|
||||
if (token !== refreshToken) return;
|
||||
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
|
||||
return;
|
||||
}
|
||||
if (token !== refreshToken) return; // superseded by a newer refresh/reread
|
||||
const data = subjects.filter((s) => matchesShape(s, typeIris));
|
||||
const past = barrierReached(docs);
|
||||
setSnapshot({
|
||||
data,
|
||||
isPending: !past,
|
||||
isSuccess: past,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
|
||||
* (await the barrier), sync per-doc subs, then read + publish. */
|
||||
async function refresh(): Promise<void> {
|
||||
const token = ++refreshToken;
|
||||
try {
|
||||
await ensureContainerSubs();
|
||||
const docs = await resolveDocs();
|
||||
if (token !== refreshToken) return;
|
||||
syncDocSubs(docs);
|
||||
// Open/await the barrier (first State per doc, or timed-out). No-op once open.
|
||||
await ensureReposOpen(docs);
|
||||
if (token !== refreshToken) return;
|
||||
await readAndPublish(docs, token);
|
||||
} catch (error) {
|
||||
if (token !== refreshToken) return;
|
||||
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
|
||||
}
|
||||
}
|
||||
|
||||
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve,
|
||||
* the set is unchanged). Reuses the docs we are subscribed to. */
|
||||
async function reread(): Promise<void> {
|
||||
const token = ++refreshToken;
|
||||
const docs = [...docSubs.keys()];
|
||||
await readAndPublish(docs, token);
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
// A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the
|
||||
// consumer's `inbox.watch`) makes documents readable that were not. Without
|
||||
// this the view would stay stale until some unrelated push happened to fire —
|
||||
// so re-read whenever they change. This is the delivery channel key
|
||||
// ROTATION uses too, which is why keeping an access needs no subscription
|
||||
// obligation on the consumer's side.
|
||||
capsUnsub = getCaps().onChange(() => {
|
||||
if (!resolving) void refresh();
|
||||
});
|
||||
void refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot(): ShapeQuery<T> {
|
||||
return snapshot as unknown as ShapeQuery<T>;
|
||||
},
|
||||
subscribe(onChange: () => void): () => void {
|
||||
listeners.add(onChange);
|
||||
start();
|
||||
return () => {
|
||||
listeners.delete(onChange);
|
||||
if (listeners.size === 0) {
|
||||
// Last listener gone → tear down the underlying subscriptions. A later
|
||||
// subscribe restarts a fresh cycle.
|
||||
for (const u of docSubs.values()) {
|
||||
try {
|
||||
u();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const u of containerSubs.values()) {
|
||||
try {
|
||||
u();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
docSubs.clear();
|
||||
containerSubs.clear();
|
||||
if (capsUnsub) {
|
||||
capsUnsub();
|
||||
capsUnsub = null;
|
||||
}
|
||||
started = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
refetch(): void {
|
||||
start();
|
||||
void refresh();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user