From e0d88b50768eef44f87eb24548ea4d2ff06d0b51 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 3 Jul 2026 23:41:34 +0200 Subject: [PATCH] feat(client): SDK-shaped scope resolvers (resolveScopeGraph/resolveInboxAnchor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose a clean scope-based surface so consumers work by scope (public/protected/ private) and never see a physical store id — the library resolves placement and performs the shared-wallet simulation internally. RegistrySession gains optional protected/public store ids, supplied at the single injection point (configureStoreRegistry). Zero domain knowledge. docs/simulation.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/simulation.md | 29 ++++++++++++ packages/client/src/store-registry.ts | 52 +++++++++++++++++++++ packages/client/test/store-registry.test.ts | 29 ++++++++++++ 3 files changed, 110 insertions(+) diff --git a/docs/simulation.md b/docs/simulation.md index f6ef624..98cc255 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -80,6 +80,35 @@ The `store≠document` two axes materialize here directly: the registry moves al axis B (more documents = more isolation), never axis A (it always writes into the one private store via `docCreate(..., undefined)`). +### SDK-shaped scope resolvers — the consumer holds NO store-id + +The consumer must never construct a `did:ng:${store_id}` NURI itself: physical +placement is the lib's job (the whole point of the SDK boundary). Two resolvers +turn a **logical scope** into an **opaque graph NURI** without exposing any +store-id: + +- **`resolveScopeGraph(scope)`** — the graph where the current session writes + entities of `scope`, and whose repo `useShape` subscribes to read them back. + Use the returned value as BOTH the read scope (`useShape(shape, nuri)`) and the + `@graph` write target. Placement lives HERE (Axis A): `private` → the private + native store; `public` + `protected` → the **protected** native store, because + `doc_create`/ORM cannot target a non-private/protected native store today (SDK + blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope + resolves to the user's REAL per-scope store — the change is in this function, + the consumer is unchanged. +- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land + (today the shared wallet's private store — a real repo NURI, required because + the broker rejects a `urn:` anchor). At migration it becomes the host's native + inbox NURI. + +Both resolve the native store ids from the **injected session** +(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing +`privateStoreId` anchor). The consumer hands the whole session to the lib at the +ONE injection point (`configureStoreRegistry({ getSession })`) — that is wiring, +not placement logic; everything else in the consumer speaks only in scopes. If +the session omits `protectedStoreId`, the non-private scopes fall back to the +private store rather than emit a broken NURI. + ## `RepoNotFound` and the `orm_start_graph` scope rule A hard constraint inherited from the SDK: to read **and** write entities through diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index 91867a7..7029178 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -76,6 +76,11 @@ export interface RegistrySession { sessionId: string; /** The shared wallet's private store id — the shim anchor. */ privateStoreId: string; + /** The shared wallet's protected store id (native store). Optional: only the + * scope resolvers need it; the shim only needs the private anchor. */ + protectedStoreId?: string; + /** The shared wallet's public store id (native store). Optional. */ + publicStoreId?: string; } function normalize(username: string): string { @@ -239,6 +244,53 @@ export async function resolveReadGraphs(scope: Scope): Promise { return accounts.map((a) => indexDocOf(a, scope)); } +// --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ---------- +// +// The consumer asks by SCOPE ("give me the graph to write/read entities of +// scope X", "give me the inbox anchor") and NEVER constructs a `did:ng:${…}` +// store NURI itself. The lib owns the physical placement — which is the whole +// point of the SDK boundary. In THIS polyfill the placement is the shared +// wallet's native stores (Axis A, per the two-axes doctrine in +// docs/simulation.md): a scope maps to a native store NURI resolved from the +// injected session. `public` currently co-locates with `protected` because +// `doc_create`/ORM cannot target a non-private/protected native store today +// (the SDK blocker recorded in migration-guide.md); at migration each scope +// resolves to the user's REAL per-scope store and this mapping changes here, +// in the lib, with no consumer change. + +/** The native store NURI backing `scope`, resolved from the injected session. + * Requires `protectedStoreId` on the session for the non-private scopes. */ +async function scopeStoreNuri(scope: Scope): Promise { + const s = await session(); + if (scope === "private") return `did:ng:${s.privateStoreId}`; + // public + protected → the protected native store (see note above). Falls + // back to the private store if the session didn't carry a protected id. + const store = s.protectedStoreId ?? s.privateStoreId; + return `did:ng:${store}`; +} + +/** + * The graph NURI where the current session WRITES entities of `scope`, and + * whose repo `useShape` must subscribe to read them back. SDK-shaped: the + * consumer passes a logical scope and gets an opaque graph NURI — it holds no + * store-id and builds no NURI. Use the returned value as both the read scope + * (`useShape(shape, nuri)`) and the `@graph` write target. + */ +export async function resolveScopeGraph(scope: Scope): Promise { + return scopeStoreNuri(scope); +} + +/** + * The inbox anchor NURI for the current session (where emulated inbox deposits + * physically land). SDK-shaped: the consumer never resolves the private store + * itself. In the polyfill this is the shared wallet's private store (a real + * repo NURI — required because the broker rejects a `urn:` anchor); at + * migration it becomes the host's native inbox and this resolution moves here. + */ +export async function resolveInboxAnchor(): Promise { + return scopeStoreNuri("private"); +} + // --- per-entity documents + per-scope index ------------------------------- /** diff --git a/packages/client/test/store-registry.test.ts b/packages/client/test/store-registry.test.ts index b1da1d7..721fad5 100644 --- a/packages/client/test/store-registry.test.ts +++ b/packages/client/test/store-registry.test.ts @@ -5,6 +5,8 @@ import { loadShim, resolveWriteGraph, resolveReadGraphs, + resolveScopeGraph, + resolveInboxAnchor, createEntityDoc, listEntityDocs, resetRegistryCache, @@ -177,6 +179,33 @@ test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]); }); +test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => { + // Session with all three store ids: private → private store; public+protected + // co-locate on the protected native store (the polyfill's Axis-A placement). + const ng = makeFakeNg(); + configure({ ng: ng as any, useShape: (() => {}) as any }); + configureStoreRegistry({ + getSession: async () => ({ + sessionId: "sid-2", + privateStoreId: "PRIV", + protectedStoreId: "PROT", + publicStoreId: "PUB", + }), + }); + resetRegistryCache(); + expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV"); + expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT"); + expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located + expect(await resolveInboxAnchor()).toBe("did:ng:PRIV"); +}); + +test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => { + // The default SESSION carries only privateStoreId — non-private scopes fall + // back to the private store rather than emitting a broken NURI. + expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV"); + expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV"); +}); + test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => { const rec = await ensureAccount("Dave"); const e1 = await createEntityDoc("dave", "public");