fix: ng-helpers rejoint la surface dev.6, et son faux suit le contrat

This commit is contained in:
Sylvain Duchesne
2026-09-21 12:16:42 +02:00
parent e566c5b0b3
commit 71667ca14e
11 changed files with 482 additions and 90 deletions
+125 -6
View File
@@ -27,9 +27,17 @@ import type { Nuri } from "../src/port";
* (`"DEL" + "ETE …"`) is one string by the time it arrives, so the split buys
* nothing.
*
* ## Every rule below is one the polyfill actually enforces
* ## Every rule below is one `contract_polyfill-surface` states
*
* - only a document's owner writes to it (`docs.sparqlUpdate`'s `assertMayWrite`);
* - a store is named by its REFERENCE and by nothing else: `privateStore()`,
* `protectedStore()` and `publicStore()` hand one out, `createEntityDoc(store?)`
* takes one, and every entry "accepts `NuriLike` and validates at the door" -- so
* a word where a reference belongs is refused rather than understood;
* - omitting the store places the document in the PRIVATE store;
* - "you read a document whose key you hold: you created it, it was shared with you,
* or it sits in a public store, which serves its read key to whoever asks" -- so a
* stranger reaches a document in its owner's public store, and no other;
* - only a document's owner writes to it, and holding a read key never grants a write;
* - anyone may DEPOSIT into a document's inbox (`inbox.postToDocument`);
* - depositing into a document whose owner never opened an inbox THROWS, rather
* than going nowhere;
@@ -37,6 +45,14 @@ import type { Nuri } from "../src/port";
* (`openDocumentInbox`: doing so publishes the document's address);
* - a document with no inbox has no address to read — a state, not an error.
*
* ## The three withdrawn placement entries are modelled by their ABSENCE
*
* `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` left the engagement.
* Nothing here answers for them, so reaching for one throws by name (see
* {@link namespace}) -- which is the only thing a fake can honestly say about a call
* that no longer exists. The same holds for `useShapeQuery`: this package reads
* nothing, and the end-to-end application is where that read is exercised.
*
* ## What is NOT modelled, and what happens then
*
* Anything the adapter reaches for on `docs`, `inbox` or `storeRegistry` that is
@@ -241,6 +257,8 @@ export interface RecordedCall {
interface StoredDocument {
readonly nuri: Nuri;
readonly owner: string;
/** The store it was recorded in -- a REFERENCE, which is the only name a store has. */
readonly store: Nuri;
readonly subjects: Map<string, Map<string, string[]>>;
/** `undefined` until the owner opens one — the state `postToDocument` refuses. */
deposits: Deposit[] | undefined;
@@ -255,6 +273,15 @@ export interface FakePolyfill {
sessionId(): string;
/** Every triple in a document, read from outside the adapter. */
contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[];
/**
* The store a document was recorded in, as the reference the accessors hand out.
* An INSPECTION: placement is not something this package can read back, and the
* point of holding it is that a document placed in the wrong store is visible here
* rather than only in a broker's refusal.
*/
storeOf(doc: string): Nuri;
/** The three stores of an identity, as `privateStore()` and its two siblings answer. */
storesOf(user: string): { private: Nuri; protected: Nuri; public: Nuri };
/**
* What is waiting in a document's inbox, oldest first — or `null` when its owner
* never opened one. An INSPECTION, not an entry of the polyfill: this package
@@ -270,6 +297,7 @@ export interface FakePolyfill {
*/
export function installFakePolyfill(): FakePolyfill {
const documents = new Map<string, StoredDocument>();
const stores = new Map<string, { private: Nuri; protected: Nuri; public: Nuri }>();
const calls: RecordedCall[] = [];
let currentUser = "nobody";
let documentCount = 0;
@@ -339,6 +367,16 @@ export function installFakePolyfill(): FakePolyfill {
const inboxImpl = {
async postToDocument(doc: unknown, options: unknown): Promise<void> {
const stored = require(String(doc));
if (!mayRead(currentUser, stored)) {
// Reaching a document's owner goes THROUGH the document, so a depositor that
// cannot open it has no way in. This is what makes the public store a
// load-bearing choice of `create` rather than a decoration: place an index
// anywhere else and no stranger can hand it anything.
throw new Error(
`[ng-eventually] inbox.postToDocument: you cannot read the document ` +
`${JSON.stringify(doc)}, so you cannot reach its inbox`,
);
}
if (stored.deposits === undefined) {
throw new Error(
`[ng-eventually] inbox.postToDocument: this document has no inbox — either its ` +
@@ -354,14 +392,89 @@ export function installFakePolyfill(): FakePolyfill {
};
/**
* The three stores an identity has, minted on first ask and stable afterwards.
*
* There is no identity parameter on any of the accessors -- a session is one user's
* -- so these are keyed by whoever is signed in, exactly as the engagement has it.
* A store is a REFERENCE: it carries no key, and it is told from every other
* reference only by being one of the three.
*/
function storesOf(user: string): { private: Nuri; protected: Nuri; public: Nuri } {
const known = stores.get(user);
if (known !== undefined) return known;
const minted = {
private: `did:ng:o:store-private-${user}` as Nuri,
protected: `did:ng:o:store-protected-${user}` as Nuri,
public: `did:ng:o:store-public-${user}` as Nuri,
};
stores.set(user, minted);
return minted;
}
/**
* Whether this identity holds a key that opens the document.
*
* Its creator does; so does anyone at all when it sits in a PUBLIC store, which
* serves its read key to whoever asks. Nothing else here shares a key, because
* nothing here calls `inbox.share`.
*/
function mayRead(user: string, stored: StoredDocument): boolean {
return stored.owner === user || stored.store === storesOf(stored.owner).public;
}
const storeRegistryImpl = {
async createEntityDoc(scope: unknown): Promise<Nuri> {
if (scope !== "public" && scope !== "mine") {
throw new Error(`[fake-polyfill] unknown scope ${JSON.stringify(scope)}`);
async privateStore(): Promise<Nuri> {
return storesOf(currentUser).private;
},
async protectedStore(): Promise<Nuri> {
return storesOf(currentUser).protected;
},
async publicStore(): Promise<Nuri> {
return storesOf(currentUser).public;
},
async createEntityDoc(store?: unknown): Promise<Nuri> {
const mine = storesOf(currentUser);
// Omitted means the PRIVATE store, as `doc_create` does when no store is given.
// It is a real placement and not a default to shrug at: a document that lands
// there is one no stranger can open.
let placement: Nuri;
if (store === undefined) {
placement = mine.private;
} else {
// Validated AT THE DOOR, which is what makes a word where a reference belongs
// a refusal rather than a silent placement. This is the exact shape that was
// green here for a whole version while the broker rejected it.
if (typeof store !== "string" || !store.startsWith("did:ng:")) {
throw new Error(
"[fake-polyfill] storeRegistry.createEntityDoc: not a NextGraph reference " +
"-- a store is named by the reference privateStore(), protectedStore() " +
`or publicStore() hands back, got ${JSON.stringify(store)}`,
);
}
const named = [mine.private, mine.protected, mine.public].find((s) => s === store);
if (named === undefined) {
// "A rejection means unknown, never absent": a store this identity does not
// have is a document that could not be recorded, so the call throws.
throw new Error(
`[fake-polyfill] storeRegistry.createEntityDoc: ${store} is not one of this ` +
`identity's stores, so the document could not be recorded in it`,
);
}
placement = named;
}
documentCount += 1;
const nuri = `did:ng:o:doc-${documentCount}` as Nuri;
documents.set(nuri, { nuri, owner: currentUser, subjects: new Map(), deposits: undefined });
documents.set(nuri, {
nuri,
owner: currentUser,
store: placement,
subjects: new Map(),
deposits: undefined,
});
return nuri;
},
@@ -440,6 +553,12 @@ export function installFakePolyfill(): FakePolyfill {
if (stored.deposits === undefined) return null;
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
},
storeOf(doc: string) {
return require(doc).store;
},
storesOf(user: string) {
return storesOf(user);
},
contentsOf(doc: string) {
const stored = require(doc);
const out: { subject: string; predicate: string; values: string[] }[] = [];