refactor(api): précis en sortie, permissif en entrée — plus de guard publié

Un polyfill ne doit rien faire de plus que ce qui est prévu. `isNuri` /
`hasReadCap` et les utilitaires SPARQL `escapeLiteral` / `escapeIri` /
`assertNuri` n'ont de pendant à aucun niveau et n'en auront pas : le binding
prend `nuri: String`, le moteur est fortement typé en Rust et n'a besoin
d'aucun prédicat, l'ORM n'expose rien de tel. Le contrat les justifiait parce
qu'ils « restent utiles à n'importe quelle app » — c'est exactement le
raisonnement à refuser : utile n'est pas prévu, et chacun serait un appel à
réécrire le jour du SDK.

Le besoin d'un guard venait de notre propre signature : les entrées publiques
exigeaient `Nuri`, donc un consommateur devait narrower ce qu'il lisait d'une
URL ou du stockage. Elles prennent désormais `NuriLike` — n'importe quelle
chaîne — et valident à l'intérieur (`toNuri`). Ce que la bibliothèque REND
reste typé `Nuri` : l'app en profite gratuitement, et un type plus large ne
cassera rien quand le SDK rendra des chaînes.

Les guards et les utilitaires restent, internes, là où la validation se fait.

Un défaut introduit puis corrigé en chemin, qui valait le test qu'il a produit :
`readUnion` a toujours toléré les trous dans sa liste — un index de scope peut
porter une entrée blanche, et un appelant qui assemble depuis des valeurs
optionnelles n'a pas à compacter. Valider AVANT de filtrer a transformé cette
tolérance en exception. Vide est une absence, pas une référence malformée ; les
deux sont désormais distingués par un test.

170 tests unitaires, e2e 42/42 contre le broker, typecheck vert sur la
bibliothèque, l'exemple et le harnais.
This commit is contained in:
Sylvain Duchesne
2026-08-06 10:51:36 +02:00
parent 54f8389e9e
commit ebf866b1f2
11 changed files with 96 additions and 35 deletions
+2 -13
View File
@@ -41,20 +41,9 @@ export { readUnion } from "./surface/read-model";
export type { UnionSubject } from "./surface/read-model";
export * as storeRegistry from "./surface/placement";
// SPARQL injection-safety helpers — so the app can reuse the same escaping /
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string
// literals, `escapeIri` to embed untrusted values in an IRI, `assertNuri` to
// validate trusted-shaped NURIs before embedding them in an IRI.
export { escapeLiteral, escapeIri, assertNuri } from "./surface/sparql";
// NURI type guards — the doors through which an app's own `string` (read back
// from storage, a URL, JSON, a form) becomes a typed `Nuri` or `ReadCap`. `Nuri`
// and `ReadCap` are template literal types, so an app that narrows with these
// gets the same compile-time distinction the library uses internally — in
// particular, it cannot pass a bare reference where a cap is required. Narrow
// with these rather than casting: a cast re-opens exactly the confusion the
// types exist to close.
export { isNuri, hasReadCap } from "./model/nuri";
// SDK type re-exports — so the app imports these from @ng-eventually/client too,
// not from @ng-org. `export type` is ERASED at build, so this adds NO runtime
+26
View File
@@ -87,3 +87,29 @@ export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap } {
return hasReadCap(nuri) ? { target: targetOf(nuri), readCap: nuri } : { target: nuri };
}
/**
* The one door a caller's string comes through — validated, then typed.
*
* Public entry points take {@link NuriLike} so a consumer never has to narrow what it
* read from a URL, from storage or from JSON: the SDK will take a plain string too
* (`doc_subscribe(repo_o: String)`, `sdk/js/lib-wasm/src/lib.rs:1908`), so demanding a
* refined type here would manufacture a step to unlearn — and would force this library
* to publish a type guard the SDK will never have.
*
* This is where that permissive edge is paid for: once, at the boundary. Past it the
* whole library works on `Nuri`.
*
* Throws rather than returning `undefined`: a reference that is not one is a caller
* mistake, and swallowing it would produce an empty read with no explanation — the
* failure mode this library keeps paying for elsewhere.
*/
export function toNuri(s: string, op: string): Nuri {
if (!isNuri(s)) {
throw new Error(
`[ng-eventually] ${op}: not a NextGraph reference — expected a "did:ng:…" string, ` +
`got ${JSON.stringify(s)}`,
);
}
return s;
}
+16
View File
@@ -62,4 +62,20 @@ export type UseShapeLike = (...args: any[]) => any;
* Typing it out means "the private inbox" cannot be written, rather than being written
* and returning nothing.
*/
/**
* A reference as a CALLER may hand it over: any string.
*
* The library returns precise `Nuri`s and accepts loose ones, and that asymmetry is not
* politeness — it is what keeps a consumer from writing something to unlearn. The wasm
* binding takes `nuri: String` (`doc_subscribe(repo_o: String)`,
* `sdk/js/lib-wasm/src/lib.rs:1908`), so the real SDK will accept a plain string too.
* Demanding a `Nuri` here would force every caller to narrow whatever it read from a URL
* or from storage — and therefore force this library to publish a type guard the SDK
* will never have. The need would be manufactured by our own signature.
*
* So: precise on the way out, permissive on the way in, and validated inside
* (`assertNuri`). The guards remain, internal, where the validation happens.
*/
export type NuriLike = Nuri | string;
export type InboxScope = Extract<Scope, "public" | "protected">;
+6 -4
View File
@@ -15,9 +15,9 @@
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log";
import { isNuri } from "../model/nuri";
import { isNuri, toNuri } from "../model/nuri";
import { assertMayReach } from "../emulated-verifier/reach";
import type { Nuri } from "../model/types";
import type { Nuri, NuriLike } from "../model/types";
// The low common point for ALL document access: every read in the SDK routes
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
@@ -85,10 +85,11 @@ export async function docCreate(
export async function sparqlUpdate(
sessionId: string,
query: string,
anchor?: Nuri,
anchorLike?: NuriLike,
label = "sparqlUpdate",
): Promise<void> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
// The boundary: a write may only touch what the connected virtual user reaches.
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlUpdate");
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
@@ -131,10 +132,11 @@ export async function sparqlQuery(
sessionId: string,
query: string,
base?: string,
anchor?: Nuri,
anchorLike?: NuriLike,
label = "sparqlQuery",
): Promise<unknown> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlQuery");
// The boundary: an ANCHORED read may only touch what the connected virtual user
// reaches. An anchorless query spans the local union — a different problem (it is
// O(wallet size), and the read path never uses it), not one this guard can bound.
+14 -8
View File
@@ -33,7 +33,7 @@ import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/
import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
import { userInbox } from "../shared-wallet/account-registry";
import { escapeLiteral } from "./sparql";
import { hasReadCap } from "../model/nuri";
import { hasReadCap, toNuri } from "../model/nuri";
import {
accessLogPrefix,
enabled as accessLogEnabled,
@@ -41,7 +41,7 @@ import {
logStage,
shortNuri,
} from "../shared-wallet/access-log";
import type { Nuri, PrincipalId, ReadCap } from "../model/types";
import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
// --- deposit model --------------------------------------------------------
@@ -138,7 +138,8 @@ function readBindings(result: unknown): Array<Record<string, { value: string }>>
* another's identity. This check is redundant once the seal enforces it, but
* until then it closes the spoof the shared wallet would otherwise allow.
*/
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promise<void> {
const targetInbox = toNuri(targetInboxLike, "inbox.post");
const current = getCurrentUser();
let from: PrincipalId | null;
if (opts.from === undefined) {
@@ -213,7 +214,8 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
*/
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void> {
export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise<void> {
const doc = toNuri(docLike, "inbox.postToDocument");
const target = await documentInboxAddress(doc);
if (target === undefined) {
throw new Error(
@@ -302,7 +304,8 @@ export async function shareCap(cap: ReadCap, toUser: string): Promise<void> {
* no more reason to handle an inbox address than a depositor does. Empty when the
* document has no inbox, which is a state and not an error.
*/
export async function readForDocument(doc: Nuri): Promise<Deposit[]> {
export async function readForDocument(docLike: NuriLike): Promise<Deposit[]> {
const doc = toNuri(docLike, "inbox.readForDocument");
const address = await documentInboxAddress(doc);
return address ? read(address) : [];
}
@@ -354,7 +357,8 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
* watching its inbox gets them, and the resulting change re-triggers the
* reads that were empty for want of that cap.
*/
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.read");
await assertOwnInbox(targetInbox, "read");
const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
@@ -456,7 +460,8 @@ export const materialize = read;
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
*/
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.readSynced");
// Marks the cold, connection-triggered entry point in the trace — the BARRIER
// line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below
// (from the read() this wraps) follow right after, so a live session shows
@@ -481,7 +486,8 @@ export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
* {@link read} does — Links are never surfaced.
*/
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]> {
export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.processInbox");
const deposits = await readSynced(targetInbox);
// `readSynced` already put every Link in memory for this session; now make
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
+9 -3
View File
@@ -46,8 +46,9 @@ import { getCaps, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { mustNotAttempt } from "../emulated-verifier/reach";
import { ensureReposOpen } from "../emulated-verifier/open-repo";
import { assertNuri } from "./sparql";
import { toNuri } from "../model/nuri";
import { isMachinerySubject } from "../emulated-verifier/machinery";
import type { Nuri } from "../model/types";
import type { Nuri, NuriLike } from "../model/types";
// Keep the primitives referenced so tree-shaking never drops the import used by
// the (side-effecting) open step below; `docCreate`/`sparqlUpdate` are not used
@@ -146,9 +147,14 @@ async function readDoc(
* read with an anchored default-graph query, O(1) per doc, independent of wallet
* size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
*/
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
const sid = await sessionId();
const unique = [...new Set(docs.filter(Boolean))];
// Drop the empties BEFORE validating, not after: this call has always tolerated a
// list with holes in it — a scope index can carry a blank entry, and a caller
// building a list from optional values should not have to compact it. Validating
// first turned that tolerance into a throw, which took down a whole reconnect run.
// Empty is absence, and absence is not a malformed reference.
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
if (unique.length === 0) return [];
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
+4 -2
View File
@@ -35,7 +35,8 @@
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { assertMayReach } from "../emulated-verifier/reach";
import type { Nuri } from "../model/types";
import { toNuri } from "../model/nuri";
import type { Nuri, NuriLike } from "../model/types";
/**
* A push from the platform to a document subscriber. Loosely typed: the raw
@@ -102,9 +103,10 @@ async function sessionId(): Promise<string> {
* Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`).
*/
export function subscribeDoc(
nuri: Nuri,
nuriLike: NuriLike,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const nuri = toNuri(nuriLike, "subscribeDoc");
// RULE 1 — a subscription IS an access: the push carries the document's state.
// Guarding the read paths while leaving this open would be a door beside the gate.
assertMayReach(nuri, "subscribeDoc");