From 7db5eef33f7a3a792bc5eb299d2296c486aa97e3 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 3 Jul 2026 23:59:27 +0200 Subject: [PATCH] feat(client): activate ReadCap isolation via current identity + connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Isolation was dormant (no current identity ever set). Now: setCurrentUser records who is reading; declareConnections(neighborsOf) grants each protected document's read cap to owner + connections. Reads discriminate through the ReadCap filter: private→owner, protected→owner+connections, public→all. Generic (the consumer injects identity + connections). Write-guard coverage limits documented honestly in docs/simulation.md (real write paths bypass the JS proxy; full enforcement awaits native caps). isolation-active.test.ts proves the protected+connections path. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/simulation.md | 41 +++++++++++++++++++ packages/client/src/caps.ts | 27 ++++++++++++ packages/client/src/polyfill.ts | 17 ++++++++ packages/client/test/isolation-active.test.ts | 35 ++++++++++++++++ 4 files changed, 120 insertions(+) diff --git a/docs/simulation.md b/docs/simulation.md index 98cc255..7de90ea 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -179,6 +179,47 @@ In a mono-store layout (every item in one repo) this is all-or-nothing on that document — exactly the native behaviour, and why fine-grained isolation requires one document per entity (axis B). +### Making the ReadCap ACTIVE — current user + connection-driven grants + +The filter only discriminates once the consumer (a) tells the SDK **who is +reading** and (b) declares the access policy on the documents. Both are plain SDK +calls; the consumer never touches the registry internals: + +- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call. + `useShape`'s filtered view reads it lazily, so the delivered subset always + reflects the identity in effect at read time. Until it is set, the filter has no + principal and (per `canRead(doc, null)`) only public documents pass — which is + why isolation stayed **dormant** while the consumer never made this call. +- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the + consumer creates it: `public` → world-readable; `protected`/`private` → owner + reads, owner holds the write cap. `open` now also **remembers** `(scope, owner)` + per document so a later connection-driven grant can find the protected ones. +- **`declareConnections(connections)` (`polyfill.ts`)** — the SDK-shaped + **protected sharing act**. The consumer hands its social graph (a `Connections`: + who-is-connected-to-whom) and the SDK issues, for every **protected** document, + that document's read cap to the owner's direct connections + (`CapRegistry.grantReadToConnections`). Public docs stay world-readable; private + docs stay owner-only. Re-callable whenever the graph changes; additive and + idempotent. The consumer passes only principals — no document NURI, no store id. + +The result is the target's discrimination reproduced end-to-end: **private** → +owner; **protected** → owner + connections; **public** → all. Proven in +`test/isolation-active.test.ts` (an unconnected principal is denied a protected +document, granted it after `declareConnections`, and reads the public document +throughout). + +### Write-guard coverage (honest scope) + +The emulated write guard (`ng-proxy.ts`, `sparql_update` override) enforces the +per-document write cap **on the public `ng` proxy only**. In practice the +consumer's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the **real injected +`ng` directly** — never the public proxy — for the validated `DataCloneError` +reason above. So the guard is **best-effort**: it fires for any write routed +through the public proxy, but the consumer's real write paths bypass it and are +**not** guarded today. This is a deliberate, recorded limitation of the emulation +(the write guard becomes effective only when the broker/verifier enforces caps +natively at migration); the READ side is what makes isolation observably active. + ### Emulated ReadCap ≠ application isolation — they COEXIST `isolation.ts` is a **separate, deliberately non-merged** axis: diff --git a/packages/client/src/caps.ts b/packages/client/src/caps.ts index fdc2206..2d9aa06 100644 --- a/packages/client/src/caps.ts +++ b/packages/client/src/caps.ts @@ -28,6 +28,11 @@ export class CapRegistry { private writers = new Map>(); /** doc NURIs readable by everyone (public_store repos — no cap needed). */ private publicDocs = new Set(); + /** doc NURI → its declared (scope, owner), as recorded at {@link open}. Lets + * a later, connection-aware sharing act (see {@link grantReadToConnections}) + * re-derive which documents are `protected` and who owns them, without the + * consumer re-supplying that per-document — it already declared it at open. */ + private policy = new Map(); /** Grant `principal` the READ cap of document `doc`. */ grantRead(doc: Nuri, principal: PrincipalId): void { @@ -53,6 +58,27 @@ export class CapRegistry { if (scope === "public") this.makePublic(doc); else this.grantRead(doc, owner); this.grantWrite(doc, owner); + this.policy.set(doc, { scope, owner }); + } + + /** + * Extend the read caps of every `protected` document so its owner's direct + * connections may read it — the sharing act "protected = owner + connections". + * `neighborsOf(owner)` yields the principals connected to `owner` (the consumer + * supplies its social graph; this layer invents no relationship). Public docs + * are already world-readable; private docs are untouched (owner only). Additive + * and idempotent: re-running after the connection graph changes only ever adds + * read caps for the current connections. + * + * This is the per-document ReadCap image of a native cap operation: in the + * target, sharing a protected repo with a connection issues that connection the + * repo's ReadCap. Here it grants the emulated read cap on the same unit. + */ + grantReadToConnections(neighborsOf: (owner: PrincipalId) => Iterable): void { + for (const [doc, { scope, owner }] of this.policy) { + if (scope !== "protected") continue; + for (const connection of neighborsOf(owner)) this.grantRead(doc, connection); + } } /** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */ @@ -92,6 +118,7 @@ export class CapRegistry { this.readers.clear(); this.writers.clear(); this.publicDocs.clear(); + this.policy.clear(); } } diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index 77294c3..d7b8f3d 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -11,6 +11,7 @@ import type { NgLike, UseShapeLike, PrincipalId } from "./types"; import type { RegistrySession } from "./store-registry"; import { CapRegistry } from "./caps"; +import type { Connections } from "./isolation"; /** * Consumer-injected dependencies of the storeRegistry (polyfill-era). The @@ -106,6 +107,22 @@ export function getCaps(): CapRegistry { return caps; } +/** + * Declare the current session's CONNECTIONS to the SDK — the domain sharing act + * "a protected document is readable by its owner AND that owner's connections". + * The consumer knows who is connected to whom (its own social graph) and hands + * that graph to the SDK; the SDK issues the corresponding read access on every + * protected document it governs (public stays world-readable, private stays + * owner-only). Re-call whenever the connection graph changes. + * + * SDK-shaped: the consumer passes a {@link Connections} (who-is-connected-to-whom) + * and gets access enforcement — it never touches a document NURI, a store id, or + * the cap registry internals. + */ +export function declareConnections(connections: Connections): void { + caps.grantReadToConnections((owner) => connections.neighbors(owner)); +} + /** Reset all emulated caps (mainly for tests / fresh sessions). */ export function resetCaps(): void { caps = new CapRegistry(); diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index 4bf2e64..cddf8ce 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -18,8 +18,10 @@ import { resetConfig, getCaps, resetCaps, + declareConnections, } from "../src/polyfill"; import { filterReadable } from "../src/read-filter"; +import { connectionsFromLinks } from "../src/isolation"; afterAll(() => { resetConfig(); @@ -79,3 +81,36 @@ test("ReadCap active: a private entity doc created via the real registry is hidd // registry has an active read policy. expect(getCaps().hasReadPolicy()).toBe(true); }); + +test("ReadCap active: a PROTECTED entity doc is hidden from an unconnected principal, revealed after they connect, PUBLIC readable regardless", async () => { + inject(); + + // Alice creates a PROTECTED entity document + a PUBLIC one, declaring the caps + // exactly as the app wrapper does (createEntityDoc → getCaps().open). + const aliceProtected = await createEntityDoc("alice", "protected"); + getCaps().open(aliceProtected, "protected", "alice"); + const alicePublic = await createEntityDoc("alice", "public"); + getCaps().open(alicePublic, "public", "alice"); + + const items = [ + { "@graph": aliceProtected, "@id": "p1", label: "alice-protected" }, + { "@graph": alicePublic, "@id": "u1", label: "alice-public" }, + ]; + + const view = (user: string) => filterReadable(items, getCaps(), user).map((i) => i["@id"]).sort(); + + // BEFORE any connection: bob (unconnected) sees ONLY alice's public item, NOT + // her protected one. Alice sees both. + expect(view("bob")).toEqual(["u1"]); + expect(view("alice")).toEqual(["p1", "u1"]); + + // The app declares the CONNECTIONS graph to the SDK (domain sharing act): now + // alice and bob are connected. The SDK issues the protected doc's read cap to + // bob (owner's connection). Public is unaffected. + declareConnections(connectionsFromLinks([{ a: "alice", b: "bob" }])); + + // AFTER connecting: bob reads alice's PROTECTED item too; PUBLIC still readable. + expect(view("bob")).toEqual(["p1", "u1"]); + // A THIRD, still-unconnected principal (carol) sees only the public one. + expect(view("carol")).toEqual(["u1"]); +});