feat(inbox): l'inbox d'un document est adressable par tout détenteur
Répond au brief 2026-08-03 remonté depuis le consommateur. `documentInbox(doc)` répondait « quelle inbox est-ce que MOI je connais pour ce document » et en créait une quand la réponse était « aucune » : un tiers n'atteignait jamais l'inbox du propriétaire, il en obtenait une à lui, que personne ne lit, et son dépôt disparaissait sans erreur. C'est l'acte central du consommateur — s'inscrire à l'événement d'un autre — qui était silencieusement perdu. Lire une inbox et savoir où y déposer sont deux actes opposés, avec des publics opposés. Ils sont désormais deux fonctions : - `openDocumentInbox(doc)` — le PROPRIÉTAIRE ouvre une inbox dédiée. Refuse sur la PROPRIÉTÉ (lue depuis les branches Store), pas sur la possession du cap : un cap se reçoit, et un destinataire ne doit pas pouvoir rediriger vers lui les dépôts destinés au propriétaire. - `documentInboxAddress(doc)` — n'importe quel détenteur trouve où déposer. Ne crée jamais rien. L'adresse est publiée dès la CRÉATION, sur la branche Header émulée du document — un sujet réservé à l'intérieur du document, donc lisible par qui détient le document. Publier seulement le jour où le propriétaire ouvre une inbox dédiée laisserait une fenêtre pendant laquelle un tiers lit le document, ne trouve aucune adresse, et ne peut pas joindre le propriétaire du tout. Sur le coût mesuré par le brief (9m37 → 21m30) : il venait de la création d'un DOCUMENT supplémentaire par document. L'adresse publiée pointe vers l'inbox propre du propriétaire, qui existe déjà et s'amortit sur tous ses documents ; la création grandit d'un triple, pas d'un document. Le dépôt porte le document concerné, donc le propriétaire matérialise toujours par document. La forme « dérivable » du brief n'était pas disponible : notre inbox est un document, et un NURI dérivé nommerait un repo que `doc_create` n'a jamais créé. Le tout reflète la séparation d'amont : un déposant scelle avec la clé PUBLIQUE de l'inbox et n'a besoin de rien d'autre, seul le propriétaire détient la moitié privée — une adresse est donc publique par nature. `src/machinery.ts` : l'espace de noms `urn:ng-eventually:` que la bibliothèque se réserve, et le prédicat que le chemin de lecture utilise. La branche Header est le premier compartiment logé dans un document que le consommateur lit ; `read-model` écarte désormais tout sujet de cet espace, par SUJET et non par prédicat — ce qui couvre toutes les branches émulées, présentes et futures. Question ouverte du brief, tranchée : « une inbox de document adressable par tout détenteur » est une invention de cette bibliothèque, pas de l'amont — aucun document n'y a d'inbox, ni le store privé. Ce qui EST vérifié, c'est la forme qui rend l'anticipation défendable : `AddInboxCapV0` est clé par `repo_id`. Tests : le test qui validait « n'importe qui dépose » passait le NURI d'inbox au déposant par une variable du test — chemin qu'aucune app n'a. Réécrit avec les deux acteurs cloisonnés : le déposant reçoit le lien du document, qui est la seule chose qui circule dans ce modèle, et doit trouver l'adresse lui-même. Le fake `ng` gagne le SELECT de la branche Header et le `DELETE WHERE` (sans quoi un remplacement devenait une accumulation, précisément le bug qu'il évite). 157 tests unitaires, e2e 40/40 contre le broker en ligne.
This commit is contained in:
@@ -18,7 +18,13 @@
|
||||
* no authorization list anywhere, and nobody was named to the registry.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, documentInbox, resetRegistryCache, walletInbox } from "../src/store-registry";
|
||||
import {
|
||||
createEntityDoc,
|
||||
documentInboxAddress,
|
||||
openDocumentInbox,
|
||||
resetRegistryCache,
|
||||
walletInbox,
|
||||
} from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
import {
|
||||
configure,
|
||||
@@ -76,6 +82,19 @@ function makeFakeNg() {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
if (!anchor) return undefined;
|
||||
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
|
||||
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
|
||||
// fake would treat the delete as an insert and the replacement would silently
|
||||
// become an accumulation — the exact bug a replacement exists to prevent.
|
||||
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
|
||||
if (del) {
|
||||
const [s0, p0] = [del[1]!, del[2]!];
|
||||
for (let i = quads.length - 1; i >= 0; i--) {
|
||||
const q = quads[i]!;
|
||||
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
@@ -149,6 +168,10 @@ function makeFakeNg() {
|
||||
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
|
||||
if (query.includes(`<${SHIM}:inboxAddress>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } };
|
||||
}
|
||||
// Store-branch `readCap` SELECT (the emulated AddRepo records).
|
||||
if (query.includes(`<${SHIM}:readCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
@@ -383,28 +406,96 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const docInbox = await documentInbox(doc);
|
||||
expect(docInbox).not.toBe(await walletInbox("alice"));
|
||||
const aliceInbox = await openDocumentInbox(doc);
|
||||
expect(aliceInbox).not.toBe(await walletInbox("alice"));
|
||||
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
|
||||
|
||||
// Bob deposits into the document's inbox — the cross-user act, open to all.
|
||||
// Bob RESOLVES the address himself, from the document. The only thing he is handed
|
||||
// is the link, which is the one thing the model says circulates. The address is not
|
||||
// passed to him — if it had to be, there would be no way for an app to get it.
|
||||
setCurrentUser("bob");
|
||||
await post(docInbox, { payload: { joining: true }, ts: 1 });
|
||||
getCaps().learn(link);
|
||||
const bobTarget = await documentInboxAddress(doc);
|
||||
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
|
||||
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
|
||||
|
||||
// …and cannot read it back: depositing grants nothing.
|
||||
await expect(readInbox(docInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||
// …and he cannot read it back: depositing grants nothing.
|
||||
await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||
|
||||
// Alice reads her document's inbox, because she opened it.
|
||||
setCurrentUser("alice");
|
||||
const deposits = await readInbox(docInbox);
|
||||
const deposits = await readInbox(aliceInbox);
|
||||
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
|
||||
});
|
||||
|
||||
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const aliceInbox = await openDocumentInbox(doc);
|
||||
|
||||
const link = capFor(doc)!;
|
||||
|
||||
// Bob holds the document — that is a READ right, and it is not ownership.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
|
||||
// The address he resolves is still alice's, so his deposits reach her.
|
||||
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
|
||||
});
|
||||
|
||||
test("a document is addressable from creation — its owner's inbox, no second document", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const aliceInbox = await walletInbox("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const link = capFor(doc)!;
|
||||
|
||||
// No `openDocumentInbox` anywhere: a depositor must not have to wait for the owner
|
||||
// to open one. This is the window the consumer's central act falls into — signing up
|
||||
// to someone else's document, before that owner ever touched an inbox.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
|
||||
});
|
||||
|
||||
test("opening a dedicated inbox REPLACES the published address — it never accumulates", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const aliceInbox = await walletInbox("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
expect(await documentInboxAddress(doc)).toBe(aliceInbox); // the general one, first
|
||||
const dedicated = await openDocumentInbox(doc);
|
||||
expect(dedicated).not.toBe(aliceInbox);
|
||||
|
||||
// A depositor resolving now must reach the DEDICATED one, and only it: a stale
|
||||
// address left beside the new one is a deposit written where nobody reads.
|
||||
const link = capFor(doc)!;
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
expect(await documentInboxAddress(doc)).toBe(dedicated);
|
||||
});
|
||||
|
||||
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
await write(doc, SECRET, "s1");
|
||||
await openDocumentInbox(doc);
|
||||
|
||||
// The consumer read returns the entity's properties and nothing of the compartment
|
||||
// that carries the address — the Header branch is beside the content, not in it.
|
||||
const subjects = await readUnion([doc]);
|
||||
const props = subjects[0]?.props ?? {};
|
||||
expect(Object.keys(props)).toEqual([SECRET]);
|
||||
});
|
||||
|
||||
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const protDoc = await createEntityDoc("alice", "protected");
|
||||
const pubDoc = await createEntityDoc("alice", "public");
|
||||
const docInbox = await documentInbox(pubDoc);
|
||||
const docInbox = await openDocumentInbox(pubDoc);
|
||||
const aliceInbox = await walletInbox("alice");
|
||||
|
||||
// Two deposits, one at each level, both made by someone else.
|
||||
@@ -422,3 +513,13 @@ test("connecting drains BOTH levels: the user's inbox and its documents'", async
|
||||
const left = await readInbox(docInbox);
|
||||
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
|
||||
});
|
||||
|
||||
// The same resolution property one level up: a user's own inbox.
|
||||
test("a third party resolves another user's inbox (the wallet level)", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const aliceView = await walletInbox("alice");
|
||||
setCurrentUser("bob");
|
||||
const bobView = await walletInbox("alice");
|
||||
expect(bobView).toBe(aliceView);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user