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:
@@ -248,6 +248,18 @@ async function main(): Promise<void> {
|
||||
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
|
||||
await sdk(frame, "inboxWatchStop");
|
||||
});
|
||||
await step("a document's inbox: owner opens, a third party resolves and deposits", async () => {
|
||||
const t = Date.now();
|
||||
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
|
||||
check(
|
||||
"the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data",
|
||||
r.sameInbox === true &&
|
||||
r.openRefused === true &&
|
||||
JSON.stringify(r.deposits) === JSON.stringify([{ joining: true }]) &&
|
||||
!r.props.some((p: string) => p.startsWith("urn:ng-eventually:")),
|
||||
`sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`,
|
||||
);
|
||||
});
|
||||
await step("inbox spoof guard", async () => {
|
||||
const r = await sdk<any>(frame, "inboxSpoofGuard");
|
||||
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
|
||||
|
||||
@@ -802,6 +802,48 @@ const identity = new IdentityStore(
|
||||
* "receive" operation exists, and no principal is ever named to the registry.
|
||||
* Runs against the REAL broker inbox document, so it exercises the whole path.
|
||||
*/
|
||||
/**
|
||||
* The DEPOSIT side of a document's inbox, end to end against the real broker: the
|
||||
* owner opens it, a third party RESOLVES its address from the document itself and
|
||||
* deposits, the owner reads it back.
|
||||
*
|
||||
* The point of the step is the resolution: nothing hands `depositorId` the address.
|
||||
* It gets the document's link (which is what circulates in this model) and must find
|
||||
* where to deposit on its own — which is exactly what a consumer app has to do, and
|
||||
* what a unit test passing the NURI through a variable cannot prove.
|
||||
*/
|
||||
async documentInboxDeposit(ownerId: string, depositorId: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
setCurrentUser(ownerId);
|
||||
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
|
||||
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
|
||||
const link = capFor(doc)!; // the repo link the owner circulates
|
||||
|
||||
setCurrentUser(depositorId);
|
||||
getCaps().learn(link);
|
||||
const resolved = await storeRegistry.documentInboxAddress(doc);
|
||||
// Opening one on someone else's document must be refused, not silently forked.
|
||||
let openRefused = false;
|
||||
try {
|
||||
await storeRegistry.openDocumentInbox(doc);
|
||||
} catch {
|
||||
openRefused = true;
|
||||
}
|
||||
if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 });
|
||||
|
||||
setCurrentUser(ownerId);
|
||||
const deposits = await inbox.read(ownerInbox);
|
||||
// The address is machinery: it must not surface among the document's properties.
|
||||
const subjects = await readModel.readUnion([doc]);
|
||||
const props = Object.keys(subjects[0]?.props ?? {});
|
||||
setCurrentUser(null);
|
||||
return {
|
||||
sameInbox: resolved === ownerInbox,
|
||||
openRefused,
|
||||
deposits: deposits.map((d) => d.payload),
|
||||
props,
|
||||
};
|
||||
},
|
||||
async capsShareCap(friendId: string) {
|
||||
const s = await sessionReady;
|
||||
resetCaps();
|
||||
|
||||
Reference in New Issue
Block a user