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:
Sylvain Duchesne
2026-08-10 17:14:25 +02:00
parent 49b046268e
commit 737729c9ce
88 changed files with 122 additions and 106 deletions
@@ -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();
}
}