0b936d2119
Suite de la revue adverse. Quatre trous de frontière, tous hors du champ « l'isolation est fausse jusqu'à P1b » — P1b parle de matériau de clé, ceux-ci sont des défauts de FORME et resteraient des trous avec une vraie clé. **La garde d'écriture reposait sur la mauvaise question.** Elle demandait « ce cap m'a-t-il été servi par un store public ? ». Ce prédicat était faux dans les deux sens à la fois : trop laxiste — une clé reçue dans une inbox donnait l'écriture, alors qu'en amont un Link est « external repos only » et qu'écrire est l'appartenance au repo ; trop strict — la propriétaire de son propre document public était refusée dès qu'elle l'ouvrait depuis sa référence avant que son store ne soit listé. Un prédicat poussé dans deux sens est le signe que c'était le mauvais prédicat. Écrire dépend désormais de la PROPRIÉTÉ, lue sur la branche Store (l'`AddRepo` émulé), plus la paternité de session pour les documents créés par la primitive brute qui n'a aucun store où s'inscrire. Conséquence assumée et documentée : seul le propriétaire écrit, ce qui est l'état amont d'un repo tant qu'aucun membre n'a été ajouté — mécanisme qu'on n'émule pas. **`docs.depositInto` quittait la frontière en la publiant.** Sa doc disait « `inbox.post` est le seul appelant » : vrai dans la bibliothèque, faux dès qu'on le publie. Démontré : avec la seule référence nue d'un document public, on réécrit l'adresse d'inbox posée dessus et on détourne les dépôts destinés à son propriétaire. Une porte qui saute une garde ne doit pas être ouvrable par une application — elle rejoint la machinerie. **Le filtre de lecture n'interceptait que trois membres** et transmettait tout le reste lié à la CIBLE : `.values()`, `.map()`, `.getById()` rendaient le contenu d'un autre utilisateur — précisément les membres qu'une API de set réactif met en avant. Les membres qui rendent des éléments sont désormais filtrés, les mutations passent (elles ne rendent rien), et **tout membre inconnu lève** au lieu de transmettre : une transmission est une fuite silencieuse, une levée est bruyante et greppable. **Le mémo du store public était par document.** Le premier demandeur déclenchait le téléchargement, le cap était classé chez LUI, et tout demandeur suivant recevait « oui » en ne détenant rien. En amont un broker qui sert un overlay externe répond à TOUS. Le mémo garde la valeur, l'appelant la classe pour qui est connecté. Aussi : l'exemption `declareInfrastructure` supprimée — zéro appelant, ensemble toujours vide, et une doc décrivant deux documents exemptés qui ne l'ont jamais été. Et les caps d'écriture décrits comme « partiels » sont dits **inertes**, ce qu'ils sont : `grantWrite` n'a aucun appelant de production. **Ce que l'e2e a rattrapé.** Ma première version de la garde refusait au créateur l'écriture sur un document fait par `docs.docCreate` — 7 étapes rouges contre le broker, après une suite unitaire restée verte. La primitive brute n'inscrit la paternité nulle part ; c'est ce que `mintedHere` couvre désormais. 185 tests unitaires (dont quatre régressions : la propriétaire écrit, le destinataire non, le store public sert tout demandeur, aucun membre non filtré ne transmet), e2e 40/40 et applicatif 10/10.
361 lines
16 KiB
TypeScript
361 lines
16 KiB
TypeScript
/**
|
|
* 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 after P1a; 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 P1b.
|
|
* 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 P1b; 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.
|
|
*
|
|
* P1b 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<string, Map<Nuri, ReadCap>>();
|
|
/**
|
|
* 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<string, Set<Nuri>>();
|
|
/**
|
|
* 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<Nuri>();
|
|
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
|
|
private writers = new Map<Nuri, Set<PrincipalId>>();
|
|
/** 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<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);
|
|
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 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 P1b, 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));
|
|
}
|
|
|
|
// --- 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 P1b, and
|
|
* arming their guard here would be enforcement this batch does not do.
|
|
*/
|
|
open(nuri: Nuri, scope: Scope): ReadCap {
|
|
const cap = this.mint(nuri);
|
|
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 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.mintedByHolder.clear();
|
|
this.inPublicStore.clear();
|
|
this.writers.clear();
|
|
this.issued = false;
|
|
this.notify();
|
|
}
|
|
}
|