Align the cap emulation on NextGraph's model, and confine it to a virtual user

Two batches, verified against nextgraph-rs throughout.

P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>),
the exact inversion of key possession. It is now possession: `capFor(nuri)` is
the only question, there is no principal parameter anywhere, and nothing turns
a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link
deposit; receiving needs no operation. `Nuri` and `ReadCap` are template
literal types, so passing a bare reference where a cap belongs is a compile
error, with runtime guards behind it for JavaScript callers.

The virtual user boundary. Every access function is now confined to the
connected user, through two rules on one criterion (possession), implemented in
two places so a lapse in either is caught by the other: authorization at the
passage points, and "do not even attempt" at the callers. The polyfill's own
machinery moved to physical.ts — unguarded, never exported — which replaced an
exemption list: the machinery no longer gets waved through the guard, it calls
something the guard never saw.

Removed, as emulating capabilities the target does not have:
- discovery.ts and its global index. There is no discovery in NextGraph; you
  follow links. It also pooled user data across wallets.
- the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts,
  loadShim), which was cross-user enumeration by construction.
- resolveInboxAnchor, a single inbox common to every user.

Caps are now stored where NextGraph stores them, and read back rather than
recomputed: AddRepo on the store's Store branch for documents a user creates,
AddLink on its User branch for caps received. Inboxes belong to someone — the
user's own, plus one per document — and connecting a user drains them all;
that is the library's job, not the app's.

Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by
NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO
have a register (AddLink), contrary to what this repo's notes claimed; and
"wallet" upstream means keyring — what owns three stores is a user, so the
vocabulary follows.

The cap value is the constant OK: the only question the emulation answers is
whether a cap is held. P1b replaces that one constant with a real key.

After this the shape is right and the isolation is still fake. Nothing here may
be described as anonymous or private.
This commit is contained in:
Sylvain Duchesne
2026-08-03 11:22:01 +02:00
parent 6f0d0586e2
commit ae9c32e271
51 changed files with 4245 additions and 1543 deletions
+14 -9
View File
@@ -42,7 +42,8 @@
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { getCaps, getStoreRegistryDeps } from "./polyfill";
import { mustNotAttempt } from "./reach";
import { ensureReposOpen } from "./open-repo";
import { assertNuri } from "./sparql";
import type { Nuri } from "./types";
@@ -140,29 +141,33 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
const unique = [...new Set(docs.filter(Boolean))];
if (unique.length === 0) return [];
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
// hold BEFORE opening or reading anything: upstream you cannot address a repo you
// have no cap for, so asking about one is not "a read that will be refused", it is
// a read that has no meaning. (The passage points enforce rule 1 regardless — see
// reach.ts — so a lapse here is caught, not exploited.)
const reachable = unique.filter((d) => !mustNotAttempt(d));
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
// target repos are not yet in `self.repos`, so an anchored read would return 0
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
// initial-state push before the anchored reads. No-op once opened / when the
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
await ensureReposOpen(unique);
await ensureReposOpen(reachable);
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
const perDoc = await Promise.all(
unique.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
);
// Cap gate (defence-in-depth). A doc whose read policy the current user may not
// satisfy is dropped. Isolation holds both by construction (the app only resolves
// docs it is entitled to) and by filter here. Generic: the lib owns the cap
// registry; a doc under no policy (`!governsRead`) flows through unchanged. In this
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
// already excluded these, so this loop should never drop anything. In this
// polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI.
const caps = getCaps();
const user = getCurrentUser();
const bySubject = new Map<string, UnionSubject>();
for (const { doc, rows } of perDoc) {
if (caps.governsRead(doc) && !caps.canRead(doc, user)) continue;
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
// Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
// is stable regardless of the repo_graph_name overlay suffix the store carries.