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:
+59
-36
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user