0d9e2bbe97
Second tour adverse sur le lot C. Six trouvailles, dont trois sur ce que je venais de livrer. **Le correctif de `createEntityDoc` reproduisait le défaut qu'il annonçait avoir fermé.** Il levait sur la PREMIÈRE écriture de registre en échec. Or : - lever sur le listing sautait l'écriture de la clé — détruisant le chemin de récupération que le commentaire d'à côté décrit explicitement (« la clé doit rester récupérable même si le listing a échoué »), et laissant le document orphelin ; - lever sur la clé laissait le document LISTÉ sans clé — précisément l'état « se lit vide pour toujours » que je prétendais empêcher, en pire, puisque l'appelant n'a même plus sa référence. Les deux écritures sont désormais tentées, ce qui atterrit reste, et l'échec est rapporté après en nommant la moitié manquante. **Le refus de `share` reposait sur une valeur qui confond absence et ignorance.** `resolveAccount` avale toute erreur de lecture et rend `null`, si bien qu'un incident réseau faisait répondre « personne ne s'est connecté sous ce nom » à propos de quelqu'un qui existe. `lookupAccount` propage désormais l'erreur ; `resolveAccount` reste la forme tolérante que tous les autres appelants veulent. **J'avais livré ce comportement sans un seul test.** `test/app-surface.test.ts` en ajoute huit, tous sur ce qu'un APPELANT voit : `ensureIdentity` rend l'identité, un appel de placement avant connexion nomme l'erreur, le placement agit comme l'utilisateur connecté, `share` refuse un nom inventé mais laisse remonter une panne, et une création à moitié écrite échoue en disant quelle moitié — dont le cas « le listing a échoué, la clé est quand même là ». En écrivant ces tests j'ai refait dans leur faux la faute que cette revue a corrigée ailleurs : ignorer le sujet dans la requête de compte, ce qui rendait le dossier d'un autre utilisateur. Deux des huit échouaient pour cette raison, sans rapport avec le code. **Et la documentation contredisait le code livré dans le même commit** : le README enseignait encore `createEntityDoc(me, "protected")` — en JS la portée devient `"alice"` — et le contrat déclarait `Nuri` là où le code et la feuille disent `NuriLike`. 197 tests unitaires, e2e 40/40 et applicatif 12/12.
125 lines
6.4 KiB
Markdown
125 lines
6.4 KiB
Markdown
# @ng-eventually/sdk
|
|
|
|
One entry point. Most of what it publishes has the same signature as the future SDK —
|
|
`ng`, `useShape`, `watchShape`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
|
|
and is a drop-in for `@ng-org/web` / `@ng-org/orm`: as NextGraph matures it resolves to
|
|
the real SDK (build alias removed) with no code change.
|
|
|
|
**One call does not, and it is the whole of what you will delete:** `configure`. It
|
|
exists because one shared wallet hosts every user; upstream, an application imports the
|
|
SDK and each user opens their own wallet. `src/index.ts` groups it under a heading that
|
|
says so. (`ensureIdentity` is a second in substance — the shared-wallet gate — but its
|
|
call site survives: an application still awaits a session before it renders.)
|
|
|
|
*(There were two entry points until 2026-08-07, `.` and `./polyfill`, and the second one
|
|
WAS that list. One door is easier to import from and says less — hence the grouping, and
|
|
hence `docs/api-contract.md`, whose export inventory a test keeps honest.)*
|
|
|
|
Per-symbol, with the target signature and an epistemic label on every claim:
|
|
[`docs/api-contract.md`](../../docs/api-contract.md).
|
|
|
|
> **Reading is key possession, and the isolation here is still fake.** The cap surface
|
|
> has the shape of the real model — you hold a document's `ReadCap` or you do not read
|
|
> it, and there is no authorization list anywhere — but nothing is encrypted yet and
|
|
> the stand-in key is a constant. Nothing this library does may be described as
|
|
> "anonymous" or "private" until per-document encryption lands (P1b).
|
|
|
|
```ts
|
|
import {
|
|
// SDK-shaped — the real SDK replaces these in place.
|
|
ensureIdentity, storeRegistry, inbox, readUnion, docs,
|
|
// Polyfill-era — one call, and it is the whole of what goes away.
|
|
configure,
|
|
} from "@ng-eventually/sdk";
|
|
|
|
configure({ ng: realNg, useShape: realUseShape, getSession, sharedWallet });
|
|
await ensureIdentity(); // who I am (returned), connection work awaited
|
|
const doc = await storeRegistry.createEntityDoc("protected");
|
|
await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
|
|
const subjects = await readUnion(await storeRegistry.listMyEntityDocs("protected"));
|
|
```
|
|
|
|
## Principle — the polyfill compensates, it never extends
|
|
|
|
**Its only reason to exist is to bridge a NextGraph implementation gap.** Every
|
|
non-SDK surface must map to something NextGraph will provide natively, and must fall
|
|
away at that point — no bespoke features, no observability, no convenience API that
|
|
isn't strictly *"NextGraph will do this later"*. The test for any proposed addition:
|
|
*does it compensate a real, exhibited gap?* If not, it belongs in the consumer
|
|
application. And a compensation whose gap is not actually exhibited on the target
|
|
broker is dead weight, not defensive code.
|
|
|
|
Both halves are binding — **the surface AND the implementation** stay as close as
|
|
possible to what NextGraph plans. The question to ask at every choice: *would this
|
|
make a caller learn something it has to UNLEARN at migration?* If yes, it is a
|
|
deviation, whatever it buys.
|
|
|
|
What the polyfill adds, each emulated now and native later:
|
|
|
|
- **Shared-wallet identity** — one wallet hosts every user, so the library fabricates
|
|
*virtual users* and confines every access to the connected one
|
|
(`emulated-verifier/reach.ts`). Upstream, each user opens their own wallet.
|
|
- **Capability emulation** — per-identity cap possession plus a read filter over it:
|
|
you read the documents whose cap you hold. There is no authorization list, because
|
|
the real model has none.
|
|
- **Inbox** — `post`, `postToDocument`, `share`, and the recipient's processing.
|
|
The model is verified (an inbox is a keypair on one repo); no JS surface exists yet.
|
|
|
|
Generic by construction: no application domain here. See
|
|
[`examples/notebook`](../../examples/notebook) for an application written against it,
|
|
which the e2e suite drives.
|
|
|
|
## How a document is reached — the three acts, and no others
|
|
|
|
```ts
|
|
import { storeRegistry, inbox, readUnion } from "@ng-eventually/sdk";
|
|
|
|
// 1. CREATE — you hold its cap, with nothing to declare. No identity parameter: a
|
|
// session belongs to one user, exactly as the target's own `doc_create` assumes.
|
|
const doc = await storeRegistry.createEntityDoc("protected");
|
|
|
|
// 2. GIVE TO READ — name the document and the person. The key is looked up and
|
|
// sealed into a deposit; the recipient applies it by connecting, with nothing
|
|
// to call. Irreversible: there is no revoking a key already handed out.
|
|
await inbox.share(doc, "bob");
|
|
|
|
// 3. CIRCULATE THE REFERENCE — no call at all. Every reference this surface returns
|
|
// is BARE: it names the document and grants nothing. If the document sits in a
|
|
// PUBLIC store, the store serves its read cap to whoever asks, so the bare
|
|
// reference is enough to read it — and if it does not, the reference still names
|
|
// it and opens nothing.
|
|
const publicDoc = await storeRegistry.createEntityDoc("public");
|
|
// …put `publicDoc` in a QR code, a message, another document. Nothing else to do.
|
|
await readUnion([publicDoc]); // a stranger holding only this reads it
|
|
```
|
|
|
|
**The invariant behind all three: you never derive a cap from a bare reference.** You
|
|
look it up in what you hold, you were given it, or a public store served it. A
|
|
`did:ng:o:…` without `:r:` names a document and opens nothing — which is what makes
|
|
confidentiality composable: a widely circulated document may point at a restricted
|
|
one, and following the reference gets you a name, not a key. See
|
|
[`docs/readcap-and-nuri-model.md`](../../docs/readcap-and-nuri-model.md) § 0.
|
|
|
|
## The types carry that invariant
|
|
|
|
`Nuri` and `ReadCap` are **template literal types**, not `string` aliases:
|
|
|
|
```ts
|
|
type Nuri = `did:ng:${string}`
|
|
type ReadCap = `did:ng:${string}:r:${string}`
|
|
```
|
|
|
|
They are still strings — assignable to `string`, JSON-serializable, no wrapper — but
|
|
the distinction is checked. A `ReadCap` goes wherever a `Nuri` is expected (a cap *is*
|
|
a NURI with the key inside); the reverse does not compile.
|
|
|
|
**Permissive in, precise out.** Public entries take `NuriLike` (`Nuri | string`) and
|
|
validate at the door, so a value coming from storage, a URL or a form needs no
|
|
narrowing and no cast on your side; what they *return* is a precise `Nuri`. The
|
|
runtime checks stay regardless — a JavaScript caller never meets the compiler.
|
|
|
|
```ts
|
|
const saved = localStorage.getItem("doc"); // string | null
|
|
if (saved) await readUnion([saved]); // ✓ validated at the door
|
|
```
|