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
+23 -3
View File
@@ -90,11 +90,31 @@ function readBindings(result: unknown): Array<Record<string, { value: string }>>
*
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
* graph, so concurrent deposits don't collide. `from` is optional: pass `null`
* for an anonymous deposit; omit it entirely to default to the current user.
* graph, so concurrent deposits don't collide.
*
* `from` is BOUND TO THE CURRENT IDENTITY — it is authenticated, not
* caller-supplied. Omit it to stamp the current user; pass `null` to deposit
* ANONYMOUSLY (a legitimate choice — "identified if known, anonymous otherwise").
* A `from` naming ANOTHER principal is a SPOOF and is REJECTED: in the target the
* broker seals the sender from the wallet's own key, so a client cannot forge
* another's identity. (At migration this check is redundant — the seal enforces
* it — but until then it closes the spoof the shared wallet would otherwise allow.)
*/
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
const from = opts.from === undefined ? getCurrentUser() : opts.from;
const current = getCurrentUser();
let from: PrincipalId | null;
if (opts.from === undefined) {
from = current; // default: stamp the current identity
} else if (opts.from === null) {
from = null; // explicit anonymous deposit
} else if (opts.from === current) {
from = opts.from; // identifying as self — allowed
} else {
throw new Error(
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
"(anonymous) — depositing as another principal is a spoof.",
);
}
const ts = opts.ts ?? Date.now();
const sid = await sessionId();