/** * The published `useShape` - what an identity may see of what the SDK handed back. * * -- Why this file exists, and what it is the answer to -------------------- * One shared wallet hosts every identity, so the hook's own answer is every identity's * objects and the capability filter is the whole of the isolation. Until 2026-09-24 that * filter was written for the collection `@ng-org/orm@0.1.2-alpha.18` returned, while the * version this package builds against - `alpha.21` - returns a `UseShapeResult` ENVELOPE * around it. Three plain members of that envelope (`data`, `promise`, `subscription`) each * carried the items and each came back RAW, and no test in this repository could see it, * because no fake returned the shape the SDK ships. `test/use-shape-fake.ts` now does, read * off the installed package's own `.d.ts`, and this file is what it made possible. * * Every document below is OBTAINED through the published placement call under the identity * that owns it - never named by the test - so "hers" and "his" are facts of the system. * Nothing hands a `ReadCap` across the boundary: the stranger holds what a stranger holds. */ import { test, expect, afterEach } from "bun:test"; import { configure, storeRegistry, useShape } from "../src/index"; import { configureStoreRegistry, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, } from "../src/shared-wallet/bootstrap"; import { resetRegistryCache } from "../src/shared-wallet/account-registry"; import { makeWallet, SESSION, type Quad } from "./wallet-fake"; import { makeUseShapeHook } from "./use-shape-fake"; import type { FakeShapeHook } from "./use-shape-fake"; import type { NgLike, Nuri, ShapeResult, UseShapeLike } from "../src/model/types"; import type { BaseType, ShapeType } from "@ng-org/shex-orm"; // TYPE-only: no `@ng-org/orm` module is loaded at runtime here. What this line checks is // the claim the injection type MAKES - that the SDK's OWN React hook is assignable to // `UseShapeLike` as it stands, with no wrapper and no cast, so `configure({ useShape })` // is the whole of what a caller writes. It is also what keeps that type honest: it was // `(...args: any[]) => any`, which no shape could ever contradict. import type { reactUseShape } from "@ng-org/orm/all"; type _TheSdkHookFits = typeof reactUseShape extends UseShapeLike ? true : never; const _sdkFits: _TheSdkHookFits = true; void _sdkFits; const NoteShape = { shape: "urn:use-shape-test:NoteShape", schema: { "urn:use-shape-test:NoteShape": { iri: "urn:use-shape-test:NoteShape", predicates: [ { iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", readablePredicate: "@type", dataTypes: [{ valType: "iri", literals: ["urn:use-shape-test:Note"] }], minCardinality: 1, maxCardinality: 1, }, ], }, }, } satisfies ShapeType; /** Everything the local dataset holds - the scope under which this filter is the only * thing between the caller and every identity's objects. */ const EVERYTHING = { graphs: "did:ng:i" }; function inject(hook: FakeShapeHook): void { const quads: Quad[] = []; const ng: NgLike = makeWallet(quads); configure({ ng, useShape: hook.hook }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); resetRegistryCache(); resetCaps(); setCurrentUser(null); } /** A document that really belongs to `who` - created by them, through the published call, * which is also what files its cap in their keyring. */ async function documentOf(who: string): Promise { setCurrentUser(who); return storeRegistry.createEntityDoc(await storeRegistry.protectedStore()); } /** One ORM object placed in `doc`, as the ORM itself carries it. */ function noteIn(doc: Nuri, id: string): BaseType { return { "@id": `${doc}:q:${id}`, "@graph": doc } as BaseType; } afterEach(() => { resetConfig(); resetStoreRegistry(); resetCaps(); setCurrentUser(null); }); // -- THE RIGHTS CASE: the stranger ------------------------------------------ // The defect, stated as a test. Each of the three members below is one the `alpha.21` // declaration gives the items to, and each used to hand back the unfiltered collection. test("useShape: no member of the result reaches a document this identity holds no cap for", async () => { const hook = makeUseShapeHook(); inject(hook); const hers = await documentOf("alice"); const his = await documentOf("bob"); hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1")); setCurrentUser("bob"); const result: ShapeResult = useShape(NoteShape, EVERYTHING); // `data` - what the SDK's own example destructures first. expect([...(result.data as Iterable)].map((o) => o["@graph"])).toEqual([his]); expect((result.data as { size: number }).size).toBe(1); // `promise` - the same collection, one await away. expect([...((await result.promise) as Iterable)].map((o) => o["@graph"])).toEqual([his]); // `subscription` - the SDK's own handle, which reads and writes with no filter at all. expect(() => result.subscription).toThrow(/RdfOrmSubscription/); // and the collection underneath really does hold both, so it is the filter that // removed one rather than the fake never having offered it. expect([...(hook.raw() as Iterable)]).toHaveLength(2); }); // Iterating is not the only way in, and the members a reactive set puts forward are the // ones a consumer reaches for first. Same rule as the collection view's own regression. test("useShape: the item-yielding members of `data` are filtered too, not just iteration", async () => { const hook = makeUseShapeHook(); inject(hook); const hers = await documentOf("alice"); const his = await documentOf("bob"); const foreign = noteIn(hers, "a1"); hook.items.push(foreign, noteIn(his, "b1")); setCurrentUser("bob"); const data = useShape(NoteShape, EVERYTHING).data as { values(): Iterable; forEach(cb: (v: BaseType) => void): void; has(v: BaseType): boolean; }; expect([...data.values()].map((o) => o["@graph"])).toEqual([his]); const seen: string[] = []; data.forEach((o) => seen.push(o["@graph"])); expect(seen).toEqual([his]); expect(data.has(foreign)).toBe(false); }); // -- THE OTHER DIRECTION: the holder is served ------------------------------ // A repair that closes a leak by making the call unusable has traded one defect for // another. Every member the declaration offers a holder is exercised here on their OWN // data, and the two page commands with it. test("useShape: the holder reads their own items through every member the SDK offers", async () => { const hook = makeUseShapeHook({ paginated: true }); inject(hook); const hers = await documentOf("alice"); const his = await documentOf("bob"); hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"), noteIn(hers, "a2")); setCurrentUser("alice"); const result: ShapeResult = useShape(NoteShape, EVERYTHING); expect([...(result.data as Iterable)].map((o) => o["@graph"])).toEqual([hers, hers]); expect((result.data as { size: number }).size).toBe(2); expect([...((await result.promise) as Iterable)]).toHaveLength(2); // The load state is a boolean and passes on its own, with no entry naming it. It reads // `true` on a payload that HAS arrived, because the shipped hook computes it as // `ormSubscription.isReady` while the declaration beside it says the opposite. That // inversion is upstream's; the fake reproduces it rather than correcting it, and this // assertion is what stops anyone here quietly making the target nicer than it is. expect(result.isLoading).toBe(true); // The page commands forward: they return `void`, and what they load lands in a // collection only ever read back through the filter. result.nextPage?.(); result.previousPage?.(); expect(hook.pages).toEqual(["next", "previous"]); // Reading `data` twice hands back the same view, so a caller may put it in a // dependency array without it looking like new data on every read. expect(result.data).toBe(result.data); }); // Before the first cap exists the emulation is not in force, exactly as `getObjects` and // `useShapeQuery` behave: a consumer that never touches caps keeps reading everything, // and gets the SDK's own object back untouched. test("useShape: passthrough while no cap has been issued", () => { const hook = makeUseShapeHook(); inject(hook); hook.items.push(noteIn("did:ng:o:someone" as Nuri, "x")); const result: ShapeResult = useShape(NoteShape, EVERYTHING); expect([...(result.data as Iterable)]).toHaveLength(1); expect(result.subscription).not.toBeUndefined(); }); // Upstream: "If the conf is explicitly set to `undefined`, the returned data is // `undefined` and `isLoading` is false". Nothing was opened, so there is nothing to // refuse, and `subscription` answers the SDK's own value rather than throwing. test("useShape: an undefined config answers undefined rather than refusing", async () => { const hook = makeUseShapeHook(); inject(hook); await documentOf("alice"); setCurrentUser("alice"); const result: ShapeResult = useShape(NoteShape, undefined); expect(result.data).toBeUndefined(); expect(result.promise).toBeUndefined(); expect(result.subscription).toBeUndefined(); expect(result.isLoading).toBe(false); }); // -- A MUTATION MAY NOT REACH PAST WHAT THE VIEW SHOWS --------------------- // THE RIGHTS CASE, second kind, and it is the member the SDK's own example reaches for // first (`expenses.add({...})`). `Set.prototype.add` RETURNS THE COLLECTION so calls can // be chained - measured, not assumed - so a mutation forwarded bound to the target handed // the unfiltered collection back as its return value. The view filtered every way IN and // gave the whole set away on the way OUT. test("useShape: adding an item does not hand back the unfiltered collection", async () => { const hook = makeUseShapeHook(); inject(hook); const hers = await documentOf("alice"); const his = await documentOf("bob"); hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1")); setCurrentUser("bob"); const data = useShape(NoteShape, EVERYTHING).data as { add(item: BaseType): unknown; [Symbol.iterator](): Iterator; }; expect([...data].map((o) => o["@graph"])).toEqual([his]); // the way in is filtered const handedBack = data.add(noteIn(his, "b2")); expect(handedBack).not.toBe(hook.raw()); // Chaining still works, and what it chains onto is the VIEW: still bob's documents only. expect([...(handedBack as Iterable)].map((o) => o["@graph"])).toEqual([his, his]); // and the write really did reach the collection underneath. expect([...(hook.raw() as Iterable)]).toHaveLength(3); }); // `has(item)` has always refused to answer about an item the holder may not read. Acting // on one is the same question with a worse consequence, and the two disagreed: `has` said // no while `delete` removed it. test("useShape: deleting reaches only what the view shows", async () => { const hook = makeUseShapeHook(); inject(hook); const hers = await documentOf("alice"); const his = await documentOf("bob"); const hersItem = noteIn(hers, "a1"); hook.items.push(hersItem, noteIn(his, "b1")); setCurrentUser("bob"); const data = useShape(NoteShape, EVERYTHING).data as { delete(item: BaseType): boolean; has(item: BaseType): boolean; clear(): void; }; expect(data.has(hersItem)).toBe(false); expect(data.delete(hersItem)).toBe(false); expect([...(hook.raw() as Iterable)]).toHaveLength(2); // hers survives // and emptying the view empties the view, not the collection every identity shares. data.clear(); expect([...(hook.raw() as Iterable)].map((o) => o["@graph"])).toEqual([hers]); }); // The page commands forward, and their RESULT is held to the same rule as every other // member. `nextPage` is declared `() => void` and yields nothing today, so this pins the // RULE rather than a live defect - which is the point: "it returns void" was the written, // never-executed ground on which `add` was forwarded, and `add` returns the collection. test("useShape: a page command that handed something back would be refused, not forwarded", async () => { const base = makeUseShapeHook({ paginated: true }); const leaky: FakeShapeHook = { ...base, hook: ((shapeType, conf) => ({ ...base.hook(shapeType, conf), nextPage: (): void => base.raw() as undefined, })) as UseShapeLike, }; inject(leaky); const hers = await documentOf("alice"); const his = await documentOf("bob"); base.items.push(noteIn(hers, "a1"), noteIn(his, "b1")); setCurrentUser("bob"); const result: ShapeResult = useShape(NoteShape, EVERYTHING); expect(() => result.nextPage?.()).toThrow(/not filtered/i); }); // THE THIRD MUTATION, and the one that was ungated. `delete` and `clear` asked `keep` and // `add` did not, which made the published rule true by coincidence: the ungated `add` // MANUFACTURED the state the other two then handled partially. Bob plants an object into // ALICE's document, never sees it again - the view filters it straight back out - and the // ORM's two-way binding commits it there. Then `clear()` leaves it behind. // // Each of the three is run here against a document this identity does not hold, and the // collection underneath is checked afterwards. That is the contract's mutation sentence, // clause by clause, executed rather than read. test("useShape: none of the three mutations reaches a document this identity holds no cap for", async () => { const hook = makeUseShapeHook(); inject(hook); const hers = await documentOf("alice"); const his = await documentOf("bob"); const hersItem = noteIn(hers, "a1"); hook.items.push(hersItem, noteIn(his, "b1")); setCurrentUser("bob"); const data = useShape(NoteShape, EVERYTHING).data as { add(item: BaseType): unknown; delete(item: BaseType): boolean; clear(): void; has(item: BaseType): boolean; }; // add - refuses, because it has no return value meaning "it did not happen". expect(() => data.add(noteIn(hers, "PLANT"))).toThrow(/does not hold the cap/i); // delete - answers `false`, which is the Set contract's own word for "not there" and is // true of the view. expect(data.has(hersItem)).toBe(false); expect(data.delete(hersItem)).toBe(false); // clear - empties the view, which holds none of hers. data.clear(); // The collection underneath is exactly as it was, minus bob's own item. expect([...(hook.raw() as Iterable)].map((o) => o["@graph"])).toEqual([hers]); // And the holder's OWN write still goes through and still reaches the collection. expect([...(data.add(noteIn(his, "b2")) as Iterable)].map((o) => o["@graph"])) .toEqual([his]); expect([...(hook.raw() as Iterable)]).toHaveLength(2); }); // A write to the collection itself is refused. Upstream's ordered `readOnlyArray` refuses // these too, so here the two agree - which is the point of the fake now building the REAL // collection kinds. That the VIEW refuses on its own account, whatever the target allows, // is proved one floor down in `read-filter.test.ts` against a plain array, because relying // on the target's guarantee is relying on something this view does not promise and does // not control. `length = 0` in particular defeated the gated `clear` outright. test("useShape: a write to the view itself is refused, whatever the collection underneath allows", async () => { const hook = makeUseShapeHook(); inject(hook); const hers = await documentOf("alice"); await documentOf("bob"); hook.items.push(noteIn(hers, "a1"), noteIn(hers, "a2")); setCurrentUser("bob"); const data = useShape(NoteShape, { ...EVERYTHING, orderBy: { liveMark: "asc" } }).data as Record & { length: number }; expect(() => { data[0] = { "@id": "SPY", "@graph": "did:ng:o:spy" }; }).toThrow(/not filtered/i); expect(() => { delete data[0]; }).toThrow(/not filtered/i); expect(() => { data.length = 0; }).toThrow(/not filtered/i); // The collection underneath is untouched. expect([...(hook.raw() as Iterable)].map((o) => o["@graph"])).toEqual([hers, hers]); }); // The descriptor trap handed an ACCESSOR back untouched, so the getter behind it - which // reads the TARGET - was a door around the view. Not reachable on the shape that ships, // where every member reports a data descriptor; reachable on the next one, which is what // this view claims to be safe for. test("useShape: an accessor member is read through the view, not around it", async () => { const base = makeUseShapeHook(); const withGetter: FakeShapeHook = { ...base, hook: ((shapeType, conf) => { const built = base.hook(shapeType, conf); const raw = base.raw(); return { isLoading: built.isLoading, promise: built.promise, subscription: built.subscription, get data(): unknown { return raw; }, }; }) as UseShapeLike, }; inject(withGetter); const hers = await documentOf("alice"); const his = await documentOf("bob"); base.items.push(noteIn(hers, "a1"), noteIn(his, "b1")); setCurrentUser("bob"); const result: ShapeResult = useShape(NoteShape, EVERYTHING); const described = Object.getOwnPropertyDescriptor(result, "data"); expect(typeof described?.get).toBe("function"); const throughTheDescriptor = described?.get?.(); expect(throughTheDescriptor).not.toBe(base.raw()); expect([...(throughTheDescriptor as Iterable)].map((o) => o["@graph"])).toEqual([his]); }); // -- SAFE FOR A SHAPE NOBODY HAS SEEN -------------------------------------- /** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two * nobody has declared - one holding the collection, one holding a count. */ interface LaterResult extends ShapeResult { readonly rows: unknown; readonly totalCount: number; readonly sample: () => unknown; } // The whole point of deciding by TYPE rather than by a list of names. A name list is what // breaks silently on the next bump - which is exactly how this defect arrived - so the // question asked of an unnamed member is what no version number can change: can the value // behind it reach an item at all? test("useShape: a member no version has declared yet cannot leak, while a scalar one still passes", async () => { const base = makeUseShapeHook(); const later: FakeShapeHook = { ...base, hook: ((shapeType, conf) => { const result: LaterResult = { ...base.hook(shapeType, conf), rows: base.raw(), totalCount: 7, sample: () => [...(base.raw() as Iterable)][0], }; return result; }) as UseShapeLike, }; inject(later); const hers = await documentOf("alice"); const his = await documentOf("bob"); base.items.push(noteIn(hers, "a1"), noteIn(his, "b1")); setCurrentUser("bob"); const result = useShape(NoteShape, EVERYTHING) as LaterResult; // An OBJECT member nobody named holds the collection, so it is refused at the read - // there is no later moment to refuse it at. expect(() => result.rows).toThrow(/not filtered/i); // A FUNCTION member nobody named is refused at the CALL, so reading it (a logger, a // `typeof` guard) is not itself refused. expect(typeof result.sample).toBe("function"); expect(() => result.sample()).toThrow(/not filtered/i); // A PRIMITIVE cannot hold a reference to anything, so it passes with no entry of its // own - which is why `isLoading` needs none either. expect(result.totalCount).toBe(7); }); // An `orderBy` config makes the collection an ARRAY (upstream `SubscriptionData`), whose // count is spelled `length` and is a number - so it used to pass as a harmless primitive // while publishing how many items the holder may NOT read. test("useShape: an ordered result does not publish the unfiltered count", async () => { const hook = makeUseShapeHook(); inject(hook); const hers = await documentOf("alice"); const his = await documentOf("bob"); hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"), noteIn(his, "b2")); setCurrentUser("alice"); const data = useShape(NoteShape, { ...EVERYTHING, orderBy: { liveMark: "asc" } }).data; expect(Array.isArray(hook.raw())).toBe(true); expect((hook.raw() as BaseType[]).length).toBe(3); expect((data as { length: number }).length).toBe(1); expect([...(data as Iterable)].map((o) => o["@graph"])).toEqual([hers]); // An index names a position in the READABLE items, so the holder reads their own by // index and the positions past them do not exist at all. `Object.keys` and `in` have to // agree with `length`, or the count comes back out through the other three. expect((data as Record)[0]!["@graph"]).toBe(hers); expect((data as Record)[1]).toBeUndefined(); expect(Object.keys(data as object)).toEqual(["0"]); expect(1 in (data as object)).toBe(false); expect(JSON.parse(JSON.stringify(data)) as BaseType[]).toHaveLength(1); }); // REGRESSION, found by re-running the adversary on the first fix (2026-09-24). // `Object.getOwnPropertyDescriptor` reads a property WITHOUT the `get` trap, so a view // that filtered `get` alone handed the raw value straight back to whoever asked for the // descriptor - including `subscription`, the one member `get` refuses outright. Everything // built on it goes the same way: a spread, `Object.entries`, a console inspection. test("useShape: the descriptor of a member answers exactly what reading it answers", async () => { const hook = makeUseShapeHook(); inject(hook); const hers = await documentOf("alice"); const his = await documentOf("bob"); hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1")); setCurrentUser("bob"); const result: ShapeResult = useShape(NoteShape, EVERYTHING); const described = Object.getOwnPropertyDescriptor(result, "data"); expect(described?.value).toBe(result.data); expect(described?.value).not.toBe(hook.raw()); expect([...(described?.value as Iterable)].map((o) => o["@graph"])).toEqual([his]); expect(() => Object.getOwnPropertyDescriptor(result, "subscription")).toThrow( /RdfOrmSubscription/, ); });