/** * Capability emulation — key POSSESSION, not an authorization list. * * 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. * * ── 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` — for created documents, `holdOwnCap` writes and * `readStoreCaps` reads the Store branch back; for received ones, `addLink` / * `readLinks` on the User branch. `connect.ts` restores the Links at connection; * the own-document caps come back through `listMyEntityDocs`. * * 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 { 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 = ""; export class CapRegistry { /** holder → the caps they hold, indexed by the cap-less NURI. */ private heldByHolder = new Map>(); /** * 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. *(This fed `discovery.submitToIndex`, removed 2026-07-30; the flag is kept because publishing is still what turns a document into a shareable link.)* */ private published = new Set(); /** doc NURI → principals holding its WRITE cap. Decorative until P1b. */ private writers = new Map>(); /** Fired whenever a holder gains a cap — a 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; /** * @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 { 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 { 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(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(targetOf(doc))?.has(principal) ?? false; } /** No WRITE policy declared → the write guard stays inert (passthrough). */ hasWritePolicy(): boolean { 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.heldByHolder.clear(); this.published.clear(); this.writers.clear(); this.issued = false; this.notify(); } }