diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index 61efa06..f40f917 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -9,6 +9,7 @@ */ import type { NgLike, UseShapeLike, PrincipalId } from "./types"; +import type { GrantResolver } from "./read-filter"; export interface EventuallyConfig { /** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */ @@ -23,14 +24,18 @@ export interface EventuallyConfig { init?: (...args: any[]) => any; /** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */ initNg?: (...args: any[]) => any; + /** How to read a document's emulated grant. When set, reads are filtered. */ + grantOf?: GrantResolver; } let cfg: EventuallyConfig | null = null; let currentUser: PrincipalId | null = null; +let grantOf: GrantResolver | null = null; export function configure(c: EventuallyConfig): void { cfg = c; currentUser = c.currentUser ?? null; + grantOf = c.grantOf ?? null; } /** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */ @@ -48,6 +53,16 @@ export function getCurrentUser(): PrincipalId | null { return currentUser; } +/** Set/clear how reads resolve a document's grant. null → reads pass through. */ +export function setGrantOf(fn: GrantResolver | null): void { + grantOf = fn; +} + +export function getGrantOf(): GrantResolver | null { + return grantOf; +} + // Capability helpers — polyfill-era surface (caps are emulated now; native at // migration). Re-exported here so the whole polyfill API lives under /polyfill. export { canRead, canWrite, defaultGrant, grantRead } from "./access"; +export type { GrantResolver } from "./read-filter"; diff --git a/packages/client/src/read-filter.ts b/packages/client/src/read-filter.ts new file mode 100644 index 0000000..3ff5536 --- /dev/null +++ b/packages/client/src/read-filter.ts @@ -0,0 +1,72 @@ +/** + * Read filter — the polyfill of capability-based read access. + * + * In the target, the broker only delivers documents the user holds a read cap + * for, so `useShape` already returns an authorized subset. Here (single shared + * wallet, everything readable) we reproduce that: a read-filtered VIEW over the + * reactive set that yields only items whose emulated grant satisfies the current + * user. Removed at migration (the broker does it natively). + */ + +import { canRead } from "./access"; +import type { Grant, PrincipalId } from "./types"; + +/** + * Reads a document's emulated grant. Return `undefined` when the item carries no + * grant — such items are kept (the filter only restricts items that DECLARE a + * grant). The consumer injects this (the lib stays domain-agnostic). + */ +export type GrantResolver = (item: unknown) => Grant | undefined; + +/** Pure: keep only the items the user may read. Ungranted items are kept. */ +export function filterReadable( + items: Iterable, + grantOf: GrantResolver, + user: PrincipalId | null, +): T[] { + const out: T[] = []; + for (const item of items) { + const g = grantOf(item); + if (g === undefined || canRead(g, user)) out.push(item); + } + return out; +} + +/** + * A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like). + * Iteration / `size` / `forEach` yield only readable items; everything else + * (`add`, `delete`, `has`, `getById`, …) forwards to the target, so writes and + * the underlying reactivity are preserved. The current user is read lazily (via + * `getUser`) so the view reflects the user in effect at read time. + */ +export function makeReadFilteredView( + set: S, + grantOf: GrantResolver, + getUser: () => PrincipalId | null, +): S { + const keep = (item: unknown): boolean => { + const g = grantOf(item); + return g === undefined || canRead(g, getUser()); + }; + return new Proxy(set, { + get(target, prop, receiver) { + if (prop === Symbol.iterator) { + return function* () { + for (const item of target as Iterable) if (keep(item)) yield item; + }; + } + if (prop === "size") { + let n = 0; + for (const item of target as Iterable) if (keep(item)) n++; + return n; + } + if (prop === "forEach") { + return (cb: (v: unknown, v2: unknown, s: unknown) => void) => { + for (const item of target as Iterable) if (keep(item)) cb(item, item, receiver); + }; + } + const v = Reflect.get(target, prop, target); + return typeof v === "function" ? v.bind(target) : v; + }, + }) as S; +} diff --git a/packages/client/src/use-shape.ts b/packages/client/src/use-shape.ts index 26e6fe8..78047c6 100644 --- a/packages/client/src/use-shape.ts +++ b/packages/client/src/use-shape.ts @@ -1,16 +1,16 @@ /** - * Wrapped `useShape`: same signature as `@ng-org/orm`. The returned reactive set - * is filtered to what the current user may read (emulated caps). At migration - * this filtering disappears — the broker only syncs authorized documents. + * Wrapped `useShape`: same signature as `@ng-org/orm`. When a grant resolver is + * configured, the returned set is a read-filtered VIEW (only items the current + * user may read); otherwise it passes the real set through unchanged. At + * migration the filtering disappears — the broker only delivers authorized docs. */ -import { getConfig } from "./polyfill"; +import { getConfig, getCurrentUser, getGrantOf } from "./polyfill"; +import { makeReadFilteredView } from "./read-filter"; export function useShape(shapeType: unknown, scope: unknown): unknown { - const { useShape: real } = getConfig(); - const set = real(shapeType, scope); - // TODO(polyfill): wrap `set` so iteration yields only entries whose emulated - // grant satisfies access.canRead(grant, getCurrentUser()), while preserving - // the reactivity of the underlying DeepSignalSet. This is the trickiest piece. - return set; + const set = getConfig().useShape(shapeType, scope) as object; + const grantOf = getGrantOf(); + if (!grantOf) return set; // no policy configured → passthrough (filter inert) + return makeReadFilteredView(set, grantOf, getCurrentUser); } diff --git a/packages/client/test/read-filter.test.ts b/packages/client/test/read-filter.test.ts new file mode 100644 index 0000000..334f084 --- /dev/null +++ b/packages/client/test/read-filter.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "bun:test"; +import { filterReadable, makeReadFilteredView } from "../src/read-filter"; +import type { Grant } from "../src/types"; + +interface Item { id: string; grant?: Grant } +const grantOf = (i: unknown): Grant | undefined => (i as Item).grant; + +const A: Item = { id: "a", grant: { read: ["alice"], write: ["alice"] } }; +const PUB: Item = { id: "p", grant: { read: ["public"], write: ["bob"] } }; +const NOGRANT: Item = { id: "n" }; // no grant → always kept + +test("filterReadable keeps public, owner-readable, and ungranted items", () => { + const items = [A, PUB, NOGRANT]; + expect(filterReadable(items, grantOf, "alice").map(i => (i as Item).id)).toEqual(["a", "p", "n"]); + expect(filterReadable(items, grantOf, "bob").map(i => (i as Item).id)).toEqual(["p", "n"]); + expect(filterReadable(items, grantOf, null).map(i => (i as Item).id)).toEqual(["p", "n"]); +}); + +test("makeReadFilteredView filters iteration/size, reflects the current user", () => { + const set = new Set([A, PUB, NOGRANT]); + let user: string | null = "bob"; + const view = makeReadFilteredView(set, grantOf, () => user); + + expect([...view].map(i => i.id)).toEqual(["p", "n"]); + expect(view.size).toBe(2); + + user = "alice"; // read lazily → view updates without rewrapping + expect([...view].map(i => i.id)).toEqual(["a", "p", "n"]); + expect(view.size).toBe(3); +}); + +test("makeReadFilteredView forwards mutations and membership to the target", () => { + const set = new Set([PUB]); + const view = makeReadFilteredView(set, grantOf, () => "bob"); + const C: Item = { id: "c", grant: { read: ["bob"], write: ["bob"] } }; + + view.add(C); + expect(set.has(C)).toBe(true); // mutation reached the real set + expect([...view].map(i => i.id)).toEqual(["p", "c"]); + + view.delete(C); + expect(set.has(C)).toBe(false); +}); + +test("forEach is filtered too", () => { + const set = new Set([A, PUB]); + const seen: string[] = []; + makeReadFilteredView(set, grantOf, () => "bob").forEach((i) => seen.push((i as Item).id)); + expect(seen).toEqual(["p"]); +});