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:
Sylvain Duchesne
2026-08-10 17:14:25 +02:00
parent 49b046268e
commit 737729c9ce
88 changed files with 122 additions and 106 deletions
@@ -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;
}