feat(client): real per-document isolation + bilateral connections + deposit guards

The ReadCap filter now enforces on per-entity documents (consumers create one doc
per entity, so each has a declared policy — private→owner, protected→owner+
connections, public→all). Isolation is genuinely active, not dormant.

- connections.ts (new): a BILATERAL connection registry — a link grants protected
  read only when BOTH sides have asserted it (each assertion bound to its author).
  A unilateral/self-declared connection grants nothing (closes the confused-deputy
  hole). declareConnections is authenticated to the current identity.
- inbox.post: `from` is bound to the current identity — a spoofed `from` throws.
- discovery.submitToIndex: PUBLIC-ONLY — a governed non-public doc is refused
  (no protected/private leak into the world-readable index).
- docs/simulation.md: documents this as application-level emulated isolation on a
  shared wallet (not crypto); at NextGraph maturity → real caps, consumer unchanged.

89 tests pass (+10 covering: active protected isolation via bilateral connect,
unilateral grants nothing, from-spoof rejected, non-public submit refused). tsc rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-04 10:40:44 +02:00
parent 42174608f8
commit bf753770b8
9 changed files with 393 additions and 99 deletions
+35 -12
View File
@@ -11,7 +11,7 @@
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps";
import type { Connections } from "./isolation";
import { ConnectionRegistry, bilateralConnections } from "./connections";
/**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
@@ -47,6 +47,9 @@ let registryDeps: Required<StoreRegistryDeps> | null = null;
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
* while it has no read policy the read filter passes through (no regression). */
let caps = new CapRegistry();
/** The emulated BILATERAL connection registry. Accumulates directed assertions
* (each authored by the asserting identity); only two-sided links materialize. */
let connectionRegistry = new ConnectionRegistry();
export function configure(c: EventuallyConfig): void {
cfg = c;
@@ -108,24 +111,44 @@ export function getCaps(): CapRegistry {
}
/**
* 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.
* Declare the CURRENT identity's own connections to the SDK — the domain sharing
* act "a protected document is readable by its owner AND that owner's connections".
*
* 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.
* AUTHENTICATED / BILATERAL. Each entry in `peers` is a principal the CURRENT user
* (`getCurrentUser()`, or an explicit `as`) asserts a connection to. The lib
* records that assertion as authored BY the current identity — a session can only
* ever assert its OWN side. A protected read is granted between two principals only
* when BOTH have asserted the other (a materialized two-sided link). So a reader
* who unilaterally self-declares a connection to an owner gets NOTHING: the owner
* never asserted them back. Public stays world-readable; private stays owner-only.
* Re-callable whenever the connection graph changes (additive + idempotent).
*
* SDK-shaped: the consumer passes principals only — never a document NURI, a store
* id, or the cap registry internals. `as` names the asserting identity explicitly
* (defaults to the current user); the consumer normally omits it.
*/
export function declareConnections(connections: Connections): void {
caps.grantReadToConnections((owner) => connections.neighbors(owner));
export function declareConnections(peers: Iterable<PrincipalId>, as?: PrincipalId): void {
const self = as ?? currentUser;
if (self) for (const peer of peers) connectionRegistry.assert(self, peer);
// Re-derive protected grants from the CURRENT bilateral view (only two-sided
// links surface as neighbours). Idempotent: grants only ever accumulate.
caps.grantReadToConnections((owner) => connectionRegistry.neighbors(owner));
}
/** @internal — the bilateral connection registry (mainly for tests / adapters). */
export function getConnectionRegistry(): ConnectionRegistry {
return connectionRegistry;
}
/** The current bilateral connection view (only two-sided links surface). */
export function getConnections() {
return bilateralConnections(connectionRegistry);
}
/** Reset all emulated caps (mainly for tests / fresh sessions). */
export function resetCaps(): void {
caps = new CapRegistry();
connectionRegistry = new ConnectionRegistry();
}
// Cap surface — polyfill-era (caps are emulated now; native at migration).