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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user