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
+27 -3
View File
@@ -41,6 +41,7 @@
import * as inbox from "./inbox";
import { ensureAccount, reservedAccount } from "./store-registry";
import { getCaps } from "./polyfill";
import type { Nuri, PrincipalId } from "./types";
/**
@@ -66,11 +67,19 @@ export interface IndexEntry {
/** Options for {@link submitToIndex}. */
export interface SubmitOptions {
/**
* Who is submitting. Omit (or pass `null`) for an ANONYMOUS submission; pass a
* principal id to identify the submitter. Defaults to the current polyfill user
* when the property is entirely absent (mirrors {@link inbox.post}).
* Who is submitting. Omit for the current identity, or pass `null` for an
* ANONYMOUS submission. `from` is BOUND to the current identity by the inbox
* (naming another principal is rejected as a spoof — see {@link inbox.post}).
*/
from?: PrincipalId | null;
/**
* The NURI of the document being made discoverable. When given, the index
* enforces PUBLIC-ONLY: a document under a non-public (protected/private) read
* policy is REFUSED — the public index must never leak a governed document's
* NURI. Omit it only for a ref with no addressable document (rare); a governed
* doc always passes it so the guard can fire.
*/
doc?: Nuri;
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
* keeps tests deterministic. */
ts?: number;
@@ -100,8 +109,23 @@ async function indexInboxNuri(): Promise<Nuri> {
* entry. GENERIC: `ref` is opaque here (the consumer serializes whatever a
* client needs to later locate the entity — e.g. an entity document NURI plus
* discovery metadata). `from` follows the inbox convention (anonymous if `null`).
*
* PUBLIC-ONLY: when `opts.doc` names the document being surfaced, a document under
* a non-public read policy (protected/private) is REFUSED — the global index is
* world-readable, so admitting a governed doc's NURI would leak it past its scope.
*/
export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> {
const doc = opts?.doc;
if (doc !== undefined) {
const caps = getCaps();
// A governed doc is submittable ONLY if it is public (anonymous may read it).
if (caps.governsRead(doc) && !caps.canRead(doc, null)) {
throw new Error(
"[ng-eventually] submitToIndex: only PUBLIC documents may be submitted to " +
"the discovery index — a protected/private document must not be surfaced.",
);
}
}
const target = await indexInboxNuri();
await inbox.post(target, {
payload: ref,