/** * 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.share(doc, toUser)` — 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. * * And for a document in a PUBLIC store there is no sharing act at all: the store hands * its cap to whoever asks (`public-store.ts`), so what circulates is the bare * reference. Filed apart (`learnFromPublicStore`) because it grants reading only. * * ── What this module does NOT do ────────────────────────────────────────── * Enforce. The shape is right, delivered by cap-surface; the isolation is still * fake. Per-document encryption and closing the read paths that bypass the guard * (an ANCHORLESS `docs.sparqlQuery`, the inbox, `store-registry`, `subscribe`, * `open-repo`) are cap-enforcement's job. Nothing may be claimed "anonymous" or * "private" until then. * * The write caps below (`grantWrite`, `governsWrite`, `canWrite`, `hasWritePolicy`) are * **inert, not partial** — a distinction the docs got wrong until 2026-08-07, when an * adversarial review measured it. `grantWrite` has NO production caller, so * `hasWritePolicy()` is permanently false and the `ng-proxy` guard they feed never fires * at all. Writing is governed instead by OWNERSHIP, at the write door (`reach.ts` * `assertMayWrite`) — which is what upstream's `verify_permission` actually checks. These * four are dead surface kept for cap-enforcement; do not read them as a working policy. */ import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri"; import type { Nuri, PrincipalId, ReadCap, Scope } from "../model/types"; /** * The stand-in cap value, and the minting point — moved here from `model/nuri.ts` * on 2026-08-03 because it did not belong to the model. * * `model/` transcribes the target's addressing vocabulary; minting is not part of * that vocabulary. Upstream nothing on the surface turns a bare reference into a cap: * the engine mints at repo creation and you afterwards look a cap up in what you hold, * or you were given it. Keeping `mintCap` in the model module contradicted that module's * own header, and put the emulation's one invented value in the file that claims to hold * only verified target vocabulary. * * cap-enforcement replaces this single constant with a real key; migration deletes both. */ const STAND_IN_CAP = "OK"; /** * Build the cap-bearing form of `nuri` — `{target}:r:OK`. Passing an already * cap-bearing reference yields the same value. INTERNAL to the emulated verifier. */ export function mintCap(nuri: Nuri): ReadCap { return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`; } /** 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>(); /** * holder → the documents they CREATED in this session, through {@link mint}. * * Authorship, for the one path that records it nowhere else. `storeRegistry`'s * documents are recorded durably on a Store branch (the emulated `AddRepo`, which is * what upstream's `doc_create` commits), so `ownsDocument` finds them on a later * session. The raw `docs.docCreate` has no store to record into — so nothing about * such a document survives its session, and an in-session note of who made it is * exactly as durable as the thing it describes. * * Consulted by the write guard before it pays for a Store-branch read. Without it the * guard refused a caller a write to a document it had just created — caught by the * live-broker e2e, seven steps red, after the unit suite stayed green. */ private mintedByHolder = new Map>(); /** * Documents this session knows to sit in a PUBLIC store — a fact about each * DOCUMENT, so global rather than per-holder, unlike everything else here. * * It is not itself a right. What being in a public store buys is that the document's * cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`, * emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has * been, the holder holds it like any other and this set records only how it got there. */ private inPublicStore = new Set(); /** doc NURI → principals holding its WRITE cap. Decorative until cap-enforcement. */ 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 ---------------------------------------------- /** * The key of the holder currently connected — capture it when you DECIDE that a cap is * someone's, and hand it back to {@link learnFor} when you file. * * **A hazard closed, not a leak observed** — the distinction matters and I got it wrong * once while writing this. Filing resolves the holder at the moment it runs, and three * paths file several `await`s after the check that authorised them (connecting, reading * an inbox, listing one's own documents). So an application switching identity in the * gap COULD have the first identity's caps filed into the second one's ring. That is * structural and visible by reading. What was NOT established is that it happens: the * reproduction that seemed to show it turned out to be a broken test fake, and once the * fake was corrected the leak did not reproduce. * * The pairing stays because it costs one argument and removes the hazard by * construction, where a re-check at each of three sites is a discipline. It is not * evidence of a bug that was found. */ holderKey(): string { return this.holder() ?? ANONYMOUS; } /** What the current holder holds, created on first use. */ private heldCaps(): Map { return this.ringFor(this.holderKey()); } private ringFor(key: string): Map { 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, key: string = this.holderKey()): 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.ringFor(key); 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); const key = this.holder() ?? ANONYMOUS; let made = this.mintedByHolder.get(key); if (!made) this.mintedByHolder.set(key, (made = new Set())); made.add(targetOf(nuri)); return cap; } /** * Did the current holder CREATE this document in this session? Authorship, and * therefore the right to write — see {@link mintedByHolder}. */ mintedHere(nuri: Nuri): boolean { return this.mintedByHolder.get(this.holder() ?? ANONYMOUS)?.has(targetOf(nuri)) ?? false; } /** * 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); } /** * File a cap for a NAMED holder — the one the caller decided for, not whoever happens * to be connected when the `await` resumes. See {@link holderKey}. */ learnFor(key: string, cap: ReadCap): void { this.file(cap, key); } /** * File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the * emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading * needs no special case anywhere; recorded apart because of what it is NOT. * * It is a READ grant and nothing else. Upstream a public store makes its repos * world-readable, never world-writable — writing needs the write cap, and * `verify_permission` fires on WRITE only. Here the write guard still consults the * read cap (write caps are decorative until cap-enforcement, see the module header), so without * this distinction a bare reference to a public document would buy a WRITE — a * consumer would build on it, and have to unlearn it at migration. * * A stronger claim on the same document erases the mark: {@link mint} (I created it) * and {@link learn} (it was deposited for me) both go through {@link file}, which * clears it. So a public document of my own is never read-only to me. */ learnFromPublicStore(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)); } /** * Does the NAMED holder hold `nuri`'s cap? The reading counterpart of {@link learnFor}, * and it exists for the same caller: work decided for one holder that runs while ANOTHER * one is connected — the emulated inbox processor * (`emulated-verifier/inbox-processor.ts`), which drains an inbox on behalf of its owner * during someone else's session. Asking `capFor` there would consult the connected * identity's ring, which is not the ring the question is about. * * Reads without creating a ring, unlike {@link heldCaps}: asking about a holder must not * file one. Still no principal parameter in the model's sense — the question is "does * THIS ring hold the key", never "may principal P read D". */ capForHolder(key: string, nuri: Nuri): ReadCap | undefined { return this.heldByHolder.get(key)?.get(targetOf(nuri)); } // --- publication (the public store) ------------------------------------- /** * Record that `nuri` sits in a PUBLIC store. A fact about the DOCUMENT, not a right * of anyone — hence a global set rather than a per-holder one, and hence no minting * here: what sitting in a public store buys is that the cap is **obtainable** by * whoever asks (`emulated-verifier/public-store.ts`), which is a separate act from * this one holding it. * * Marking and minting were one method (`recordInPublicStore`) until they were split: * the fetch path files the cap it DOWNLOADED, and minting a second one beside it * would produce a different key the day the stand-in constant becomes a real one — * the same trap `holdOwnCap` already documents. * * Upstream nothing corresponds to this call: the store IS public, and the broker * exposes its outer overlay (`expose_outer`, * `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because * one broker here serves every virtual user identically. * * NOT recursive: a document in a public store may REFERENCE private ones, and the * reference grants nothing on what it references. That non-recursiveness is what lets * a public object point at private content without disclosing it. */ markInPublicStore(nuri: Nuri): void { this.inPublicStore.add(targetOf(nuri)); } /** Is `nuri` recorded as sitting in a public store? A fact about the document. */ isInPublicStore(nuri: Nuri): boolean { return this.inPublicStore.has(targetOf(nuri)); } /** * Record a document the current holder owns in `scope`: its cap lands among what * they hold, and a `public` one is additionally marked as sitting in a public store. * Returns the cap. Idempotent — the 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 cap-enforcement, and * arming their guard here would be enforcement this batch does not do. */ open(nuri: Nuri, scope: Scope): ReadCap { // `file`, NOT `mint` — and the difference is a hole that was open for one commit. // // Every caller of this method files a STRUCTURAL document: one of the holder's three // store documents, or an inbox. Those are not authored content, they are registers — // written only through `emulated-verifier/register-write.ts`. Minting them marked // them "created by me", which let the write guard through, which let a holder append // `contains ""` to their own store index through the PUBLISHED // `docs.sparqlUpdate` and forge ownership of it. `ownsDocument` reads that very // index, so the guard was fully bypassable from the surface. // // Found by re-running the adversary on the fix (2026-08-07). Filing without minting // closes it at the source: a structural document is owned by nobody in the authorship // sense, so both halves of `assertMayWrite` say no, which is correct. const cap = mintCap(nuri); this.file(cap); if (scope === "public") this.markInPublicStore(nuri); return cap; } // --- 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 cap-enforcement) ---------------------------------- /** 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.mintedByHolder.clear(); this.inPublicStore.clear(); this.writers.clear(); this.issued = false; this.notify(); } }