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:
Sylvain Duchesne
2026-08-03 16:02:11 +02:00
parent e24a20cc46
commit 8a382f29f8
11 changed files with 487 additions and 26 deletions
+167 -6
View File
@@ -67,6 +67,7 @@ import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo";
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import { hasReadCap, isNuri, mintCap } from "./nuri";
import { mustNotAttempt } from "./reach";
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { Nuri, ReadCap, Scope } from "./types";
@@ -105,6 +106,7 @@ const P = {
link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
} as const;
// Fixed subject of the per-(account×scope) index document. The index doc plays
// the role of the future store-container: it lists the NURIs of the entity
@@ -142,6 +144,34 @@ const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`;
* keys stay separate.
*/
const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`;
/**
* Fixed subject of the **Header branch** emulation, inside an ENTITY document — the
* first compartment we put in a document the consumer also reads, hence the filter in
* `read-model.ts` (every `${SHIM}:` subject is machinery and never surfaces as data).
*
* It carries what must be readable by *whoever can read the document*, as opposed to
* what belongs to its owner alone. Today that is one thing: the ADDRESS of the
* document's inbox.
*
* Why the address must live here and not on the owner's User branch. Upstream an inbox
* is a KEYPAIR (`repo.inbox: Option<PrivKey>`, `engine/repo/src/repo.rs:126`) and the
* two halves have opposite audiences: a depositor seals with the PUBLIC key
* (`InboxMsg::new` → `crypto_box::seal(&to_inbox.to_dh_slice(), …)`,
* `engine/net/src/types.rs:4299`) and needs nothing else; only the owner holds the
* private half (`AddInboxCap`, on the User branch). An address is therefore public by
* nature — upstream it travels with the profile (`ContactDetails` carries
* `ng:site_inbox` / `ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:823`).
* Keeping it only on the owner's User branch, as this lib first did, made the deposit
* side unreachable: a third party had no way to learn where to deposit.
*
* **Not `BranchType::Header` upstream.** That branch exists (`engine/repo/src/types.rs:1551`)
* but is CLOSED: `update_header` writes only `title`/`about`
* (`engine/verifier/src/request_processor.rs:173-211`) and `fetch_header` reads back
* only `title`/`about`/`class` (`:1240-1284`). It cannot carry an inbox address. The
* name is borrowed for the shape — a compartment of the document that is not its
* content — not for the upstream branch's contract.
*/
const HEADER_BRANCH_SUBJECT = `${SHIM}:headerBranch`;
// --- pointer (store-root → doc-shim indirection) --------------------------
//
@@ -927,9 +957,59 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
}
// …and the creator holds THAT cap for this session.
holdOwnCap(id, scope, entityNuri, cap);
// The THIRD write: WHERE to deposit for this document, on its Header branch, so any
// holder can find it. Done at creation because the alternative — publishing it the
// day the owner opens a dedicated inbox — leaves a window in which a third party
// reads the document, finds no address, and cannot reach its owner at all. That
// window is precisely the consumer's central act (signing up to someone else's
// document), so it cannot be left open.
//
// It points at the owner's OWN inbox, which already exists: one inbox per user,
// amortized over every document they create — NOT one document per document. That
// distinction is the whole cost question (see
// `docs/briefs/2026-08-03-document-inbox-addressing.md`: publishing at creation was
// measured at 9m37 → 21m30 on the consumer's suite, because it created a second
// DOCUMENT each time). Here creation grows by one triple, and the deposit carries
// the document it concerns, so the owner still materializes per document.
//
// `openDocumentInbox` later REPLACES this address with a dedicated inbox for owners
// who want one document's deposits kept apart; the resolution is the same either way.
await publishInboxAddress(entityNuri, await walletInbox(id));
return entityNuri;
}
/**
* Publish WHERE to deposit for `doc`, on its Header branch — the compartment any
* holder of the document can read. Idempotent by replacement: a document has exactly
* one address, and re-publishing (when a dedicated inbox is opened) must not leave the
* previous one behind for a depositor to pick.
*/
async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
const s = await session();
try {
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
// form verified against the real broker (see
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
// is not exercised anywhere in this lib. Deleting first is what makes this a
// replacement — a document has ONE address, and a stale one left beside the new
// one is a depositor writing where nobody reads.
await sparqlUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
doc,
"publishInboxAddress:clear",
);
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
doc,
"publishInboxAddress",
);
} catch (error) {
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
}
}
/**
* The ReadCaps recorded on a store's Store branch — its documents, each with its
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
@@ -1038,15 +1118,31 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
* in. Upstream the keypair is cheap; here an inbox is a document, so it is minted
* when first asked for.
*
* Only for documents this user holds — you cannot open an inbox on someone else's
* document, you can only deposit into it.
* Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on
* someone else's document would be usurpation, not a courtesy: the opener keeps the
* reading half, so it would silently divert to itself the deposits meant for the
* owner. To deposit into someone else's document, resolve
* {@link documentInboxAddress} and `inbox.post` into it.
*/
export async function documentInbox(doc: Nuri): Promise<Nuri> {
export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] documentInbox: no identity is set");
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known;
// OWNERSHIP is the criterion — not "is there an address yet", since every document
// carries one from creation (its owner's inbox). Opening a dedicated inbox REPLACES
// that address, so letting a non-owner do it would redirect the owner's deposits to
// the caller: usurpation, silent, and on a document the caller merely reads.
// Holding a cap is not ownership; a cap can be received.
if (!(await ownsDocument(doc))) {
throw new Error(
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
`you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` +
`then inbox.post): ${JSON.stringify(doc)}`,
);
}
const inbox = await createDoc();
const s = await session();
const record = await ensureAccount(holder);
@@ -1058,15 +1154,80 @@ export async function documentInbox(doc: Nuri): Promise<Nuri> {
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`,
store,
"documentInbox",
"openDocumentInbox",
);
} catch (error) {
console.error(accessLogPrefix() + " documentInbox persist failed:", error);
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
}
}
// …and the PUBLIC half, in the document itself, so a depositor finds THIS inbox
// instead of the owner's general one that `createEntityDoc` published. Replacement,
// not addition: one document, one address.
await publishInboxAddress(doc, inbox);
return inbox;
}
/**
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
* function an app calls before `inbox.post`.
*
* Readable by whoever can read the document, because it lives on its Header branch —
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
* key). Conversely someone who cannot read the document learns nothing, which is
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
* reach you.
*
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
* only its owner opens one, and only on its own document.
*/
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
// RULE 2 — do not even attempt. Not holding the document, we have no address to
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
// deposit for a document I cannot read" is not a refused question, it is a question
// with no referent. Answering `undefined` here keeps the caller's shape (an address
// or none) instead of turning the boundary into an exception it must catch.
if (mustNotAttempt(doc)) return undefined;
const s = await session();
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
undefined,
doc,
"documentInboxAddress",
);
for (const row of readBindings(res)) {
const a = bindingValue(row, "a");
if (a && isNuri(a)) return a;
}
} catch (error) {
// Unreadable document (no cap) or not synced → no address to give. Refusing to
// read is the boundary doing its job, not an error to propagate here.
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
}
return undefined;
}
/**
* Does the connected user own `doc`? Answered from its **Store branches** — the
* register of the documents it created — across the three scopes, which is the only
* place that records authorship. Holding a cap is NOT ownership: a cap can be
* received, and a recipient must not be able to open an inbox on what it merely reads.
*/
async function ownsDocument(doc: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
const record = await resolveAccount(holder);
if (record === null) return false;
for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope);
if (!store) continue;
if ((await readUserStore(store)).includes(doc)) return true;
}
return false;
}
/** The `(document, inbox)` pairs recorded on this user's User branch. */
async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
const holder = getCurrentUser();