refactor: renommer client → sdk, et fusionner les deux portes en une

Deux mouvements de surface, aucun changement de comportement.

**`packages/client` → `packages/sdk`, `@ng-eventually/client` → `@ng-eventually/sdk`.**
« client » ne disait rien : ce paquet EST le SDK que l'application appelle, et c'est
tout ce qu'elle appelle. L'ancien nom reste comme mot-clé de recherche dans
`docs/source-layout-by-fate.md` et le tableau des paquets du README.

**Une seule entrée.** L'entrée `./polyfill` disparaît ; ses symboles applicatifs —
`configure`, `configureStoreRegistry`, `setCurrentUser`, `connectedUser` et leurs types
— vivent dans un bloc `POLYFILL-ERA` de `src/index.ts`.

Ce que la seconde porte portait mérite d'être nommé avant d'être retiré : *ce qu'on
importe de ce chemin est exactement ce qu'on supprimera à la migration*. Une seule
porte perd ce signal — rien à la ligne d'import ne distingue `configure`, qui part, de
`docs`, que le vrai SDK remplace sur place. Trois choses le portent désormais : le bloc
lui-même, l'inventaire d'exports de `docs/api-contract.md` (épinglé par
`test/vocabulary.test.ts`, donc il ne peut pas rancir en silence), et le contrôle de
vocabulaire sur les noms publiés.

**Six symboles quittent la surface au passage**, et la fusion est ce qui a rendu le
choix visible plutôt qu'hérité :

- `getConfig` / `getStoreRegistryDeps` — câblage interne, atteint par
  `shared-wallet/bootstrap` ;
- `resetConfig` / `resetStoreRegistry` / `resetCaps` — remises à zéro de test, atteintes
  par leur chemin interne, ce qui est leur raison d'être ;
- le `share` direct — `inbox.share` a toujours été la même fonction, et la publier deux
  fois brouillait la frontière qu'elle servait à marquer.

Corrections d'affirmations fausses trouvées en chemin : le contrat annonçait `isNuri` /
`hasReadCap` sur la porte SDK alors qu'ils ne sont plus exportés depuis le passage au
permissif en entrée (`NuriLike` validé à la porte) ; le README du paquet documentait
`capFor`, `shareCap`, `getCaps` et `publishRepoLink`, dont aucun n'existe ; et le README
de l'app d'exemple affirmait que la suite e2e la pilote, ce qui reste à faire.

179 tests unitaires, typecheck bibliothèque / exemple / harnais, e2e 42/42 contre le
broker en ligne — mesuré une fois après le renommage, une fois après la fusion.
This commit is contained in:
Sylvain Duchesne
2026-08-07 11:05:19 +02:00
parent 0832338201
commit 0eb25286c8
85 changed files with 423 additions and 413 deletions
+362
View File
@@ -0,0 +1,362 @@
/**
* 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 (`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 { 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>>();
/**
* 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>();
/**
* holder → the documents whose cap they hold ONLY because a public store served it
* (see {@link learnFromPublicStore}).
*
* PER HOLDER, unlike the set above, and the difference is the whole point: *"this
* document is in a public store"* is a fact about the document, whereas *"the only
* claim I have on it is that the network handed me its key"* is a fact about one
* holder. Kept global, the owner of a public document would be refused writes to it
* the moment any third party fetched its cap.
*/
private servedByHolder = new Map<string, 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);
// Filing is the STRONG claim — I created this document, or its cap was deposited
// for me. Either one supersedes "a public store served it to me", so the read-only
// mark goes. {@link learnFromPublicStore} re-adds it after calling here, and only
// when nothing was held before.
this.servedToHolder().delete(target);
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);
}
/**
* 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 {
const target = targetOf(cap);
const alreadyHeld = this.heldCaps().has(target);
this.file(cap);
// Only when this is the ONLY reason I hold it — filing never downgrades a claim.
if (!alreadyHeld) this.servedToHolder().add(target);
}
/**
* Is the ONLY reason the current holder holds this document's cap that a public store
* served it? Then it grants reading and nothing more — see {@link learnFromPublicStore}.
*/
isReadOnlyPublicCap(nuri: Nuri): boolean {
return this.servedToHolder().has(targetOf(nuri));
}
/** The current holder's public-store-served set, created on first use. */
private servedToHolder(): Set<Nuri> {
const key = this.holder() ?? ANONYMOUS;
let s = this.servedByHolder.get(key);
if (!s) this.servedByHolder.set(key, (s = new Set()));
return s;
}
/**
* 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.servedByHolder.clear();
this.inPublicStore.clear();
this.writers.clear();
this.issued = false;
this.notify();
}
}