feat(client): read filter — capability-based view over the reactive set

makeReadFilteredView wraps a DeepSignalSet in a Proxy whose iteration/size/forEach
yield only items the current user may read (canRead on the item's emulated grant), while
add/delete and the underlying reactivity pass through. filterReadable is the pure core.
useShape applies it only when a grantOf resolver is configured (else passthrough, so
ungranted apps are unaffected). grantOf/setGrantOf added to the polyfill surface. Mirrors
the broker delivering only authorized docs; removed at migration. 4 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-06-29 10:28:40 +02:00
parent f4ded6d8f7
commit 672067d513
4 changed files with 147 additions and 10 deletions
+15
View File
@@ -9,6 +9,7 @@
*/ */
import type { NgLike, UseShapeLike, PrincipalId } from "./types"; import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { GrantResolver } from "./read-filter";
export interface EventuallyConfig { export interface EventuallyConfig {
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */ /** 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; init?: (...args: any[]) => any;
/** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */ /** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */
initNg?: (...args: any[]) => any; 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 cfg: EventuallyConfig | null = null;
let currentUser: PrincipalId | null = null; let currentUser: PrincipalId | null = null;
let grantOf: GrantResolver | null = null;
export function configure(c: EventuallyConfig): void { export function configure(c: EventuallyConfig): void {
cfg = c; cfg = c;
currentUser = c.currentUser ?? null; currentUser = c.currentUser ?? null;
grantOf = c.grantOf ?? null;
} }
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */ /** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
@@ -48,6 +53,16 @@ export function getCurrentUser(): PrincipalId | null {
return currentUser; 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 // Capability helpers — polyfill-era surface (caps are emulated now; native at
// migration). Re-exported here so the whole polyfill API lives under /polyfill. // migration). Re-exported here so the whole polyfill API lives under /polyfill.
export { canRead, canWrite, defaultGrant, grantRead } from "./access"; export { canRead, canWrite, defaultGrant, grantRead } from "./access";
export type { GrantResolver } from "./read-filter";
+72
View File
@@ -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<T>(
items: Iterable<T>,
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<S extends object>(
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<unknown>) if (keep(item)) yield item;
};
}
if (prop === "size") {
let n = 0;
for (const item of target as Iterable<unknown>) 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<unknown>) if (keep(item)) cb(item, item, receiver);
};
}
const v = Reflect.get(target, prop, target);
return typeof v === "function" ? v.bind(target) : v;
},
}) as S;
}
+10 -10
View File
@@ -1,16 +1,16 @@
/** /**
* Wrapped `useShape`: same signature as `@ng-org/orm`. The returned reactive set * Wrapped `useShape`: same signature as `@ng-org/orm`. When a grant resolver is
* is filtered to what the current user may read (emulated caps). At migration * configured, the returned set is a read-filtered VIEW (only items the current
* this filtering disappears — the broker only syncs authorized documents. * 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 { export function useShape(shapeType: unknown, scope: unknown): unknown {
const { useShape: real } = getConfig(); const set = getConfig().useShape(shapeType, scope) as object;
const set = real(shapeType, scope); const grantOf = getGrantOf();
// TODO(polyfill): wrap `set` so iteration yields only entries whose emulated if (!grantOf) return set; // no policy configured → passthrough (filter inert)
// grant satisfies access.canRead(grant, getCurrentUser()), while preserving return makeReadFilteredView(set, grantOf, getCurrentUser);
// the reactivity of the underlying DeepSignalSet. This is the trickiest piece.
return set;
} }
+50
View File
@@ -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<Item>([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<Item>([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<Item>([A, PUB]);
const seen: string[] = [];
makeReadFilteredView(set, grantOf, () => "bob").forEach((i) => seen.push((i as Item).id));
expect(seen).toEqual(["p"]);
});