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
+59 -36
View File
@@ -194,19 +194,33 @@ calls; the consumer never touches the registry internals:
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.
- **`declareConnections(peers, as?)` (`polyfill.ts`)** — the SDK-shaped
**protected sharing act**, now **AUTHENTICATED / BILATERAL** (`connections.ts`).
Each call declares the CURRENT identity's OWN peers (`as` defaults to
`getCurrentUser()`); the lib records that as a **directed assertion authored by
the current identity** — a session can only ever assert its own side. A protected
read cap is issued between two principals only when **both have asserted the
other** (a materialized two-sided link, `ConnectionRegistry.neighbors` →
`CapRegistry.grantReadToConnections`). Public docs stay world-readable; private
docs stay owner-only. Re-callable; additive + idempotent. The consumer passes
only principals — no document NURI, no store id.
**Why bilateral (adversarial finding).** If a single directed assertion granted
access, any reader could read any owner's protected documents by unilaterally
self-declaring a connection. The two-sided requirement is the emulation of the
target's mutual capability exchange: only a reciprocated link grants the cap. A
unilateral / self-declared connection grants **nothing** (proven in
`test/connections.test.ts` and `test/isolation-active.test.ts` case (b)).
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).
owner; **protected** → owner + BILATERAL connections; **public** → all. Proven in
`test/isolation-active.test.ts`: (a) an unconnected principal is denied a protected
document, granted it after a two-sided `declareConnections`, and reads the public
document throughout; (b) a unilateral/self-declared connection is denied.
This discrimination is only observable because each entity is **its own document**
(the consumer creates per-entity docs via `createEntityDoc` and `open`s each) — in
a mono-store layout the per-document ReadCap is all-or-nothing.
### Write-guard coverage (honest scope)
@@ -220,25 +234,24 @@ through the public proxy, but the consumer's real write paths bypass it and are
(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
### The per-document ReadCap is now THE isolation path (item-level filter retired)
`isolation.ts` is a **separate, deliberately non-merged** axis:
Isolation is enforced by the **per-document ReadCap** (`caps.ts` + `read-filter.ts`)
alone: the access unit is the DOCUMENT (`@graph` = repo), grants are explicit
(`open` / `grantRead` / `makePublic`) and, for `protected`, driven by the
**bilateral connection registry** (`connections.ts`). Because the consumer now
writes **one document per entity** (`createEntityDoc` + `open` per entity), the
per-document cap discriminates at entity granularity — the target's behaviour.
| | ReadCap (`caps.ts` + `read-filter.ts`) | isolation (`isolation.ts`) |
|---|---|---|
| Unit | the DOCUMENT (`@graph` = repo) | the ITEM / record |
| Question | does the principal HOLD this doc's read cap? | given WHO is connected to WHOM, may this principal see it? |
| Models | NextGraph's native capability delivery (broker-enforced) | an application social-visibility policy, above the doc layer |
| Grants | explicit, per-document (`grantRead` / `makePublic`) | implicit, from the connection graph + item scope |
`isolation.ts` honors a visibility matrix (public = everyone; protected = owner +
direct connections; private = owner only) with **pure** functions — no NextGraph,
no React, zero domain. The consumer injects the connection graph (`Connections`)
and the `ownerOf`/`scopeOf` accessors. The connection-derived `protected`
visibility has no equivalent in the per-document cap model, so the two are not
redundant. Each is a removable scaffold that disappears against a different piece
of real infra (caps → native ReadCaps; isolation → real per-account social graph
+ per-account wallets).
The old **item-level application-visibility filter** (`isolation.ts`
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is **retired**
from the consumer path: the app carries **no** access logic — it declares its
identity and its bilateral connections and trusts the SDK. `isolation.ts` survives
only as the home of the generic `Connections` interface (consumed by
`connections.ts` / `caps.grantReadToConnections`) plus its own unit tests; its
matrix functions are dead scaffolding kept for reference and removed at migration.
There is no longer a second, coexisting app-layer filter to reconcile — the single
axis is the per-document cap, exactly as in the target.
## Emulated inbox + curator (`inbox.ts`)
@@ -254,12 +267,17 @@ fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib
emulated in-lib.
- **`post(targetInbox, opts)`** appends a deposit `{ from, payload, ts }` as RDF
into the inbox DOCUMENT (in the shared wallet) via `docs.sparqlUpdate`. Each
deposit is a unique RDF subject → concurrent deposits don't collide. `from` is
optional: pass `null` for an ANONYMOUS deposit; omit it to default to the
current polyfill user (`getCurrentUser`). This reproduces the protocol's
"identified if known, anonymous otherwise" — though the emulation stores
`from = null` as *absence of a triple*, it does not provide the target's
deposit is a unique RDF subject → concurrent deposits don't collide. **`from` is
BOUND to the current identity** (`getCurrentUser`) — it is authenticated, not
caller-supplied: omit it to stamp the current user, pass `null` to deposit
ANONYMOUSLY, and a `from` naming ANOTHER principal is **rejected as a spoof**.
This reproduces the protocol's "identified if known, anonymous otherwise" AND
the target's guarantee that a client cannot forge another's sender identity (in
the target the broker seals `from` from the wallet's own key; here the check
closes the spoof the shared wallet would otherwise allow). The emulation stores
`from = null` as *absence of a triple*, so it does not provide the target's
**crypto** anonymity (`from = None` sealed), which only a native inbox would.
Proven in `test/inbox.test.ts` case (c).
- **`read` / `materialize` (alias)** play the **emulated CURATOR**: they read the
deposits back via `docs.sparqlQuery`, JSON-parse each payload, sort by `ts`.
- **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it
@@ -297,9 +315,14 @@ into entries. Materialization is the natural **dedup / moderation point**.
shared index.
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
the inbox convention (anonymous when `null`). `ref` is **opaque** here — the
consumer serializes whatever locates the entity (e.g. an entity document NURI +
discovery metadata).
the inbox convention (bound to the current identity; anonymous when `null`).
`ref` is **opaque** here — the consumer serializes whatever locates the entity
(e.g. an entity document NURI + discovery metadata). **PUBLIC-ONLY guard:** when
`opts.doc` names the document being surfaced, a document under a non-public
(protected/private) read policy is **REFUSED** (`caps.governsRead(doc) &&
!caps.canRead(doc, null)`) — the global index is world-readable, so admitting a
governed doc's NURI would leak it past its scope. Proven in
`test/discovery.test.ts` case (d).
- **`readIndex()`** — the EMULATED CURATOR. Reads every submission, **dedups by
serialized `ref`** (the moderation point: a duplicate submission surfaces
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the