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
+230 -101
View File
@@ -1,117 +1,249 @@
/**
* Capability emulation — generic, with no domain rules. It models NextGraph
* ReadCaps (and write caps) as a data layer can.
* Capability emulation — key POSSESSION, not an authorization list.
*
* In NextGraph a ReadCap is possession of a document's (repo's) read key: the
* broker only delivers documents the wallet holds a cap for. The access unit is
* therefore the document = repo, identified here by its NURI — the `@graph` an
* item lives in, rather than the item. (A store is just a container repo, and
* holding a store's cap does not grant the repos it references — each document
* carries its own cap — so this registry is purely per-document, with no
* store-level inheritance.)
* In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
* and there is no read-ACL anywhere. This module emulates that shape (see
* `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
* question — *do I hold this document's cap?* — and cannot answer "may principal P
* read document D", because the real model cannot either.
*
* Sharing here is DIRECTED: a grant issues one grantee the read cap of one
* document (`grantRead(doc, granteeId)`). Whether two identities are "connected"
* — and therefore whether such a grant should be issued — is an application
* concept the consumer owns; this layer only records the resulting per-document
* grants. At migration this whole layer disappears: the broker/verifier enforces
* the real caps and `useShape` returns only authorized documents.
* ── Where caps come from — and why this is NOT "a keyring" ────────────────
* There is no keyring object in NextGraph, and calling this one invited a wrong
* mental model: that some single place holds every key. It does not. Upstream the
* caps of a user are in **two** places, by origin (see
* `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
*
* - documents the user CREATED → `AddRepo { read_cap }` on the **Store branch**
* of the store they live in — one such branch per store;
* - caps RECEIVED for someone else's documents → `AddLink { read_cap }` on the
* **User branch** of the private store.
*
* The wallet itself holds exactly one key per user: the private store's read cap,
* from which everything else is reached. Hence the invariant:
*
* > You do not derive a cap from a bare reference. You look it up in what you
* > hold — or you were given it.
*
* This class is the in-memory record of what the connected holder currently holds:
* upstream's local user storage, not a durable register. The durable ones are
* emulated in `store-registry.ts` (`fileOwnCaps` for created documents, `addLink` /
* `readLinks` for received ones), and `connect.ts` restores from them.
*
* One record PER holder, since one shared wallet hosts every identity. Switching
* identity therefore SWITCHES records; it never wipes one (a wipe would make
* durability a lie and bring per-session re-declaration back under another name).
*
* ── Sharing ───────────────────────────────────────────────────────────────
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
* sharing is `inbox.shareCap(cap, toInbox)` — a **Link** deposit — and receiving is
* the recipient processing their inbox. Handing over a store's cap is NOT the
* gesture: it would give away everything that store contains, present and future.
*
* ── What this module does NOT do ──────────────────────────────────────────
* Enforce. The shape is right after P1a; the isolation is still fake. Per-document
* encryption and closing the read paths that bypass the guard (`docs.sparqlQuery`,
* the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b. Nothing may be
* claimed "anonymous" or "private" until then. The write caps below are likewise
* decorative — the guard they feed (`ng-proxy`) is bypassed by every internal
* writer; they are left as-is and belong to P1b.
*/
import type { Nuri, PrincipalId, Scope } from "./types";
import { hasReadCap, mintCap, targetOf } from "./nuri";
import type { Nuri, PrincipalId, ReadCap, Scope } from "./types";
/** The map key of the anonymous holder (no identity established yet). */
const ANONYMOUS = "";
/**
* Who holds the read/write cap of each document. The consumer populates it via
* cap operations (make-public, directed grant…) exactly as it will in the
* target; this layer enforces possession generically, with no policy of its own.
*/
export class CapRegistry {
/** doc NURI → principals holding its READ cap. */
private readers = new Map<Nuri, Set<PrincipalId>>();
/** doc NURI → principals holding its WRITE cap. */
/** holder → the caps they hold, indexed by the cap-less NURI. */
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
/**
* Documents published as a shareable repo link (`RepoLinkV0`) — the emulated
* public store. This is NOT a read grant: a published document is read by
* whoever HOLDS the link, exactly like §5 of the brief says ("whoever has the
* URL reads the content"), and holding it means having received it. The set
* exists so the library can refuse to surface a document its holder never
* published (see `discovery.submitToIndex`).
*/
private published = new Set<Nuri>();
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
private writers = new Map<Nuri, Set<PrincipalId>>();
/** doc NURIs readable by everyone (public_store reposno cap needed). */
private publicDocs = new Set<Nuri>();
/** doc NURI → its declared (scope, owner), as recorded at {@link open}. Lets
* the consumer re-derive which documents are `protected` and who owns them
* (see {@link protectedDocsOf}) so it can issue directed grants, without
* re-supplying that per-document — it already declared it at open. */
private policy = new Map<Nuri, { scope: Scope; owner: PrincipalId }>();
/** Fired whenever a holder gains a capa cap delivered asynchronously must
* re-trigger the reads that were empty for want of it. */
private listeners = new Set<() => void>();
/** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
private issued = false;
/** Grant `grantee` the READ cap of document `doc` — a directed grant. */
grantRead(doc: Nuri, grantee: PrincipalId): void {
add(this.readers, doc, grantee);
/**
* @param holder resolves WHO is holding — the current identity. Looked up through it on every
* call, so an identity switch switches records with nothing to reset. Defaults to the anonymous holder.
*/
constructor(private readonly holder: () => PrincipalId | null = () => null) {}
// --- what the holder holds ----------------------------------------------
/** What the current holder holds, created on first use. */
private heldCaps(): Map<Nuri, ReadCap> {
const key = this.holder() ?? ANONYMOUS;
let ring = this.heldByHolder.get(key);
if (!ring) this.heldByHolder.set(key, (ring = new Map()));
return ring;
}
/**
* File `cap` among what the current holder holds — the ONE door in, so
* the invariant is carried here rather than by each caller remembering it.
*
* A reference with no `:r:` is REFUSED. `Nuri` and `ReadCap` are both `string`
* (deliberately — the real SDK takes `nuri: String`), so the compiler cannot
* catch a caller passing the naming form where the reading form is meant. Left
* unchecked, that mistake files a bare reference under its own name, `capFor`
* then returns it, and the document reads — turning "naming is not reading" into
* "naming is reading", which is the exact inversion this batch exists to remove.
* The check is cheap and it is the only thing standing between the two.
*
* Returns whether the cap was new.
*/
private file(cap: ReadCap): boolean {
if (!hasReadCap(cap)) {
throw new Error(
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
`reference — naming is not reading, and no cap derives from one: ${JSON.stringify(cap)}`,
);
}
const target = targetOf(cap);
const ring = this.heldCaps();
if (ring.get(target) === cap) return false;
ring.set(target, cap);
this.issued = true;
this.notify();
return true;
}
/**
* The cap of a document I just CREATED, filed among what I hold — the emulated
* `AddRepo { read_cap }`. Idempotent. Returns the cap.
*/
mint(nuri: Nuri): ReadCap {
const cap = mintCap(nuri);
this.file(cap);
return cap;
}
/**
* File a cap I was GIVEN — an inbox deposit of kind `cap`, or a repo link found
* in world-readable content. This is the ONLY way a cap arrives from
* outside: nothing turns a bare reference into a cap.
*
* @throws if `cap` carries no `:r:` — see {@link file}. Passing a bare `Nuri`
* here is the one type confusion that would silently invert the model, and both
* forms are `string`, so it is rejected at runtime instead.
*/
learn(cap: ReadCap): void {
this.file(cap);
}
/**
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold
* none — which is the whole answer the model can give. Absorbs the former
* `canRead(doc, principal)`: there is no principal parameter, because there is
* no list to look a principal up in.
*/
capFor(nuri: Nuri): ReadCap | undefined {
return this.heldCaps().get(targetOf(nuri));
}
// --- publication (the public store) -------------------------------------
/**
* Publish `nuri` as a shareable repo link and return it — the upstream
* `RepoLinkV0 { read_cap }`, which whoever receives it can open. The consumer
* puts this link (not the bare NURI) in what it makes discoverable.
*
* NOT recursive: the published document may REFERENCE private documents, and the
* reference grants nothing on what it references — that non-recursiveness is
* what lets a public object point at a private identity without disclosing it.
*/
publishRepoLink(nuri: Nuri): ReadCap {
const target = targetOf(nuri);
this.published.add(target);
return this.mint(target);
}
/** Was `nuri` published as a repo link? (An emitter-side guard, not a right.) */
isPublished(nuri: Nuri): boolean {
return this.published.has(targetOf(nuri));
}
/**
* Record a document the current holder owns in `scope`: its cap lands in their
* what they hold, and a `public` one is additionally published as a repo link. Returns
* the cap (the shareable link when public). Idempotent — the store-registry calls
* it both when creating a document and when listing the holder's own documents
* back, which is how a holder's caps are rebuilt on a fresh session.
*
* Deliberately does NOT touch write caps: those are decorative until P1b, and
* arming their guard here would be enforcement this batch does not do.
*/
open(nuri: Nuri, scope: Scope): ReadCap {
return scope === "public" ? this.publishRepoLink(nuri) : this.mint(nuri);
}
// --- enforcement gate ---------------------------------------------------
/**
* Is the cap emulation in force? False until the first cap is issued, so a
* consumer that never touches caps keeps reading everything (no regression).
* Once ANY cap exists the regime is possession for EVERY holder — including one
* who holds nothing, which is exactly the isolation being emulated.
*/
isEnforcing(): boolean {
return this.issued;
}
// --- change signal ------------------------------------------------------
/**
* Subscribe to changes in what the holder holds. A cap that arrives asynchronously (an inbox
* deposit) must make the views that were empty for want of it re-read; without
* this signal they stay stale until an unrelated change happens to fire.
*/
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notify(): void {
for (const l of this.listeners) {
try {
l();
} catch (error) {
console.error("[caps] change listener threw", error);
}
}
}
// --- write caps (decorative until P1b) ----------------------------------
/** Grant `principal` the WRITE cap of document `doc`. */
grantWrite(doc: Nuri, principal: PrincipalId): void {
add(this.writers, doc, principal);
}
/** Mark `doc` public (readable without a cap — a public_store repo). */
makePublic(doc: Nuri): void {
this.publicDocs.add(doc);
}
/**
* Apply the caps a creator attaches to a fresh document, by scope. Public →
* world-readable; protected/private → only the owner reads. The owner always
* holds the write cap. Further sharing is a separate explicit grant.
*/
open(doc: Nuri, scope: Scope, owner: PrincipalId): void {
if (scope === "public") this.makePublic(doc);
else this.grantRead(doc, owner);
this.grantWrite(doc, owner);
this.policy.set(doc, { scope, owner });
}
/**
* The `protected` documents owned by `owner`, as recorded at {@link open}. The
* consumer uses this to issue directed read grants: it decides who may read an
* owner's protected documents (its own relationship concept) and calls
* {@link grantRead} on each of these documents for each such reader. Public
* documents are already world-readable and private documents stay owner-only,
* so only the protected ones are surfaced here.
*
* This mirrors a native cap operation: in the target, sharing a protected repo
* with another identity issues that identity the repo's ReadCap. Here the
* consumer selects the documents via this accessor and grants the emulated read
* cap on the same unit.
*/
protectedDocsOf(owner: PrincipalId): Nuri[] {
const out: Nuri[] = [];
for (const [doc, { scope, owner: o }] of this.policy) {
if (scope === "protected" && o === owner) out.push(doc);
}
return out;
}
/** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */
governsRead(doc: Nuri): boolean {
return this.publicDocs.has(doc) || this.readers.has(doc);
}
/** Does `principal` hold a READ cap for `doc` (or is `doc` public)? */
canRead(doc: Nuri, principal: PrincipalId | null): boolean {
if (this.publicDocs.has(doc)) return true;
if (principal === null) return false;
return this.readers.get(doc)?.has(principal) ?? false;
const target = targetOf(doc);
let s = this.writers.get(target);
if (!s) this.writers.set(target, (s = new Set()));
s.add(principal);
}
/** Is `doc` under any WRITE-cap policy? */
governsWrite(doc: Nuri): boolean {
return this.writers.has(doc);
return this.writers.has(targetOf(doc));
}
/** Does `principal` hold a WRITE cap for `doc`? */
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
if (principal === null) return false;
return this.writers.get(doc)?.has(principal) ?? false;
}
/** No READ policy declared → the read filter stays inert (passthrough). */
hasReadPolicy(): boolean {
return this.readers.size > 0 || this.publicDocs.size > 0;
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
}
/** No WRITE policy declared → the write guard stays inert (passthrough). */
@@ -119,16 +251,13 @@ export class CapRegistry {
return this.writers.size > 0;
}
/** Drop every holder's caps and every publication. Tests / a fresh wallet only —
* NOT what an identity change does (that switches heldByHolder, see the header). */
clear(): void {
this.readers.clear();
this.heldByHolder.clear();
this.published.clear();
this.writers.clear();
this.publicDocs.clear();
this.policy.clear();
this.issued = false;
this.notify();
}
}
function add(m: Map<Nuri, Set<PrincipalId>>, doc: Nuri, principal: PrincipalId): void {
let s = m.get(doc);
if (!s) m.set(doc, (s = new Set()));
s.add(principal);
}