refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever l'ambiguïté à chaque phrase — et le contrat publié, lu par une application, était le pire endroit pour laisser traîner ça. packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill, contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts → e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md. Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes, y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est fait occurrence par occurrence, pas par substitution. Le contrat énonce désormais son identité en une phrase : « This package is a polyfill of NextGraph's SDK. »
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* 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 ----------------------------------------------
|
||||
|
||||
/**
|
||||
* 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<Nuri, ReadCap> {
|
||||
return this.ringFor(this.holderKey());
|
||||
}
|
||||
|
||||
private ringFor(key: string): Map<Nuri, ReadCap> {
|
||||
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 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 {
|
||||
// `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 "<anyone's document>"` 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 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user