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 { 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";
|
||||||
|
|||||||
@@ -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
|
* 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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