diff --git a/docs/api-contract.md b/docs/api-contract.md index 5a48ac6..b3fef7b 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -612,7 +612,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat ### `@ng-eventually/client` — `src/index.ts` ```text -direct: BaseType, DeepSignalSet, DocChange, DocChangeType, InboxScope, NG, NgLike, Nuri, PrincipalId, ReadCap, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, assertNuri, docChangeType, ensureIdentity, escapeIri, escapeLiteral, hasReadCap, init, initNg, isNuri, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape +direct: BaseType, DeepSignalSet, DocChange, DocChangeType, InboxScope, NG, NgLike, Nuri, NuriLike, PrincipalId, ReadCap, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape docs: depositInto, docCreate, sparqlQuery, sparqlUpdate inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, shareCap, watch storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph diff --git a/examples/notebook/app.ts b/examples/notebook/app.ts index dee230f..29164bf 100644 --- a/examples/notebook/app.ts +++ b/examples/notebook/app.ts @@ -31,7 +31,6 @@ import { docs, ensureIdentity, inbox, - isNuri, readUnion, storeRegistry, subscribeDoc, @@ -121,10 +120,11 @@ async function myNotes(scope: Scope): Promise { * Read someone else's note from its link. * * The link is what circulates in this model — you do not discover a note, you are given - * its link. `hasReadCap` is the door an untrusted string goes through. + * its link. It arrives as a plain string, from a field or a URL, and goes straight in: + * the library validates it. Nothing to narrow, nothing to cast, and nothing that will + * have to change when the real SDK takes that same string. */ async function readSharedNote(link: string): Promise { - if (!isNuri(link)) return null; const [note] = await readUnion([link]); if (!note) return null; return { diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index 44235fe..77121de 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -41,7 +41,10 @@ import * as registryInternals from "../src/shared-wallet/account-registry"; import { getCaps, getCurrentUser } from "../src/shared-wallet/bootstrap"; import { documentInboxAddress } from "../src/emulated-verifier/branch-registers"; import * as virtualUsers from "../src/shared-wallet/virtual-users"; -import { isNuri, ensureIdentity } from "@ng-eventually/client"; +import { ensureIdentity } from "@ng-eventually/client"; +// The harness narrows for its OWN assertions; a consumer never has to (the entries take +// plain strings and validate inside). Internal path, like the rest of its machinery. +import { isNuri } from "../src/model/nuri"; import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client"; const { IdentityStore } = virtualUsers; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 260fa6e..39296fd 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -41,20 +41,9 @@ export { readUnion } from "./surface/read-model"; export type { UnionSubject } from "./surface/read-model"; export * as storeRegistry from "./surface/placement"; -// SPARQL injection-safety helpers — so the app can reuse the same escaping / -// validation when it builds SPARQL by interpolation. `escapeLiteral` for string -// literals, `escapeIri` to embed untrusted values in an IRI, `assertNuri` to -// validate trusted-shaped NURIs before embedding them in an IRI. -export { escapeLiteral, escapeIri, assertNuri } from "./surface/sparql"; -// NURI type guards — the doors through which an app's own `string` (read back -// from storage, a URL, JSON, a form) becomes a typed `Nuri` or `ReadCap`. `Nuri` -// and `ReadCap` are template literal types, so an app that narrows with these -// gets the same compile-time distinction the library uses internally — in -// particular, it cannot pass a bare reference where a cap is required. Narrow -// with these rather than casting: a cast re-opens exactly the confusion the -// types exist to close. -export { isNuri, hasReadCap } from "./model/nuri"; + + // SDK type re-exports — so the app imports these from @ng-eventually/client too, // not from @ng-org. `export type` is ERASED at build, so this adds NO runtime diff --git a/packages/client/src/model/nuri.ts b/packages/client/src/model/nuri.ts index d9ff6c0..da896d2 100644 --- a/packages/client/src/model/nuri.ts +++ b/packages/client/src/model/nuri.ts @@ -87,3 +87,29 @@ 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; +} diff --git a/packages/client/src/model/types.ts b/packages/client/src/model/types.ts index 0b4e12e..993ec62 100644 --- a/packages/client/src/model/types.ts +++ b/packages/client/src/model/types.ts @@ -62,4 +62,20 @@ export type UseShapeLike = (...args: any[]) => any; * 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; diff --git a/packages/client/src/surface/docs.ts b/packages/client/src/surface/docs.ts index 07855f1..fb27880 100644 --- a/packages/client/src/surface/docs.ts +++ b/packages/client/src/surface/docs.ts @@ -15,9 +15,9 @@ import { getCaps, getConfig } from "../shared-wallet/bootstrap"; import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log"; -import { isNuri } from "../model/nuri"; +import { isNuri, toNuri } from "../model/nuri"; import { assertMayReach } from "../emulated-verifier/reach"; -import type { Nuri } from "../model/types"; +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 @@ -85,10 +85,11 @@ export async function docCreate( export async function sparqlUpdate( sessionId: string, query: string, - anchor?: Nuri, + anchorLike?: NuriLike, label = "sparqlUpdate", ): Promise { const { ng } = getConfig(); + const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate"); // The boundary: a write may only touch what the connected virtual user reaches. if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlUpdate"); // `label` is a lib-internal access-log tag, NOT forwarded to `ng`. @@ -131,10 +132,11 @@ export async function sparqlQuery( sessionId: string, query: string, base?: string, - anchor?: Nuri, + anchorLike?: NuriLike, label = "sparqlQuery", ): Promise { 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. diff --git a/packages/client/src/surface/inbox.ts b/packages/client/src/surface/inbox.ts index 304ef83..c358ea1 100644 --- a/packages/client/src/surface/inbox.ts +++ b/packages/client/src/surface/inbox.ts @@ -33,7 +33,7 @@ import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/ import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers"; import { userInbox } from "../shared-wallet/account-registry"; import { escapeLiteral } from "./sparql"; -import { hasReadCap } from "../model/nuri"; +import { hasReadCap, toNuri } from "../model/nuri"; import { accessLogPrefix, enabled as accessLogEnabled, @@ -41,7 +41,7 @@ import { logStage, shortNuri, } from "../shared-wallet/access-log"; -import type { Nuri, PrincipalId, ReadCap } from "../model/types"; +import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types"; // --- deposit model -------------------------------------------------------- @@ -138,7 +138,8 @@ function readBindings(result: unknown): Array> * 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(targetInbox: Nuri, opts: PostOptions): Promise { +export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promise { + const targetInbox = toNuri(targetInboxLike, "inbox.post"); const current = getCurrentUser(); let from: PrincipalId | null; if (opts.from === undefined) { @@ -213,7 +214,8 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise * (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call * `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case. */ -export async function postToDocument(doc: Nuri, opts: PostOptions): Promise { +export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise { + const doc = toNuri(docLike, "inbox.postToDocument"); const target = await documentInboxAddress(doc); if (target === undefined) { throw new Error( @@ -302,7 +304,8 @@ export async function shareCap(cap: ReadCap, toUser: string): Promise { * 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(doc: Nuri): Promise { +export async function readForDocument(docLike: NuriLike): Promise { + const doc = toNuri(docLike, "inbox.readForDocument"); const address = await documentInboxAddress(doc); return address ? read(address) : []; } @@ -354,7 +357,8 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise { * 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(targetInbox: Nuri): Promise { +export async function read(targetInboxLike: NuriLike): Promise { + const targetInbox = toNuri(targetInboxLike, "inbox.read"); await assertOwnInbox(targetInbox, "read"); const sid = await sessionId(); // NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it @@ -456,7 +460,8 @@ export const materialize = read; * `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(targetInbox: Nuri): Promise { +export async function readSynced(targetInboxLike: NuriLike): Promise { + 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 @@ -481,7 +486,8 @@ export async function readSynced(targetInbox: Nuri): Promise { * second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as * {@link read} does — Links are never surfaced. */ -export async function processInbox(targetInbox: Nuri): Promise { +export async function processInbox(targetInboxLike: NuriLike): Promise { + 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 diff --git a/packages/client/src/surface/read-model.ts b/packages/client/src/surface/read-model.ts index 8f37b1d..800de57 100644 --- a/packages/client/src/surface/read-model.ts +++ b/packages/client/src/surface/read-model.ts @@ -46,8 +46,9 @@ 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 } from "../model/types"; +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 @@ -146,9 +147,14 @@ async function readDoc( * 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(docs: Nuri[]): Promise { +export async function readUnion(docsLike: NuriLike[]): Promise { const sid = await sessionId(); - const unique = [...new Set(docs.filter(Boolean))]; + // 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 []; // RULE 2 — do not even attempt. Drop the documents whose cap this user does not diff --git a/packages/client/src/surface/subscribe.ts b/packages/client/src/surface/subscribe.ts index 1e8be76..bed03fb 100644 --- a/packages/client/src/surface/subscribe.ts +++ b/packages/client/src/surface/subscribe.ts @@ -35,7 +35,8 @@ import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { assertMayReach } from "../emulated-verifier/reach"; -import type { Nuri } from "../model/types"; +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 @@ -102,9 +103,10 @@ async function sessionId(): Promise { * Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`). */ export function subscribeDoc( - nuri: Nuri, + 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"); diff --git a/packages/client/test/read-model.test.ts b/packages/client/test/read-model.test.ts index 7610a20..f25f761 100644 --- a/packages/client/test/read-model.test.ts +++ b/packages/client/test/read-model.test.ts @@ -138,3 +138,14 @@ test("readUnion drops a doc whose cap the holder does not hold", async () => { setCurrentUser("bob"); expect(await readUnion(both)).toEqual([]); }); + +test("readUnion tolerates holes in the list, and refuses a malformed reference", async () => { + // Two different things that must not be conflated, and conflating them broke a whole + // reconnect run: an EMPTY entry is absence — a scope index can carry one, and a caller + // assembling a list from optional values should not have to compact it — while a + // non-reference is a caller mistake worth a loud error. Validating before filtering + // turned the first into the second. + inject(); + await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]); + await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i); +});