feat(client): SDK-shaped scope resolvers (resolveScopeGraph/resolveInboxAnchor)

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 23:41:34 +02:00
parent bea9f51d91
commit e0d88b5076
3 changed files with 110 additions and 0 deletions
+52
View File
@@ -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<Nuri[]> {
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<Nuri> {
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<Nuri> {
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<Nuri> {
return scopeStoreNuri("private");
}
// --- per-entity documents + per-scope index -------------------------------
/**
@@ -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");