feat: un dépôt est traité vingt secondes après, sans attendre son destinataire
Un dépôt attendait la prochaine connexion de son destinataire — potentiellement des heures. Un vrai NextGraph aura un service qui traite les inbox en continu ; il n'existe pas. On l'émule : après une écriture dans une inbox, une échéance unique de vingt secondes draine l'inbox DE LA CIBLE. C'est une usurpation d'identité, possible seulement parce qu'un portefeuille partagé détient toutes les identités virtuelles. Elle est acceptable parce que l'application n'apprend rien de faux : elle observe que les dépôts finissent par converger, ce qui restera vrai avec un vrai service. Ce qui ne doit pas fuir, c'est le mécanisme. Trois gardes, tenues par du code et non par des consignes. Rien n'atteint la surface publiée : les exports sont épinglés par un test, et publier ceci reviendrait à publier un appel qui traite l'inbox d'autrui — après quoi il ne resterait rien du modèle de confidentialité. Le drainage agit avec un détenteur EXPLICITE, jamais l'identité ambiante. Le propriétaire vient d'un enregistrement de routage (shim:inboxOwner), et cet identifiant est passé à chaque étape. C'était le vrai danger : readLinks et myInboxes demandent getCurrentUser() au moment où elles s'exécutent, donc un drainage lancé pendant la session d'Alice aurait classé les capacités de Bob chez elle. Le test l'épingle — après le drainage, Alice n'a aucune capacité sur le document concerné, et les deux Links sont bien chez Bob, durablement. Et les échecs remontent au journal d'accès au lieu de disparaître. Une boucle différée qui avale ses erreurs, c'est la famille retirée en8c8ade7ete32b6d0. Coalescence : une seule échéance en attente par cible, et deux drainages d'une même inbox ne se chevauchent jamais — processInbox écrit ce qu'il applique. Limite assumée : si la page disparaît avant l'échéance, le dépôt attend la prochaine connexion. C'est le comportement honnête d'une émulation qui tient la place d'un service absent.
This commit is contained in:
@@ -125,6 +125,7 @@ export const P = {
|
||||
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
|
||||
exposedReadCap: `${SHIM}:exposedReadCap`, // header branch → the cap a PUBLIC store serves to anyone
|
||||
isInbox: `${SHIM}:isInbox`, // shim → this NURI IS an inbox (see `assertIsInbox`)
|
||||
inboxOwner: `${SHIM}:inboxOwner`, // shim → WHOSE inbox this NURI is (see `recordInbox`)
|
||||
} 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
|
||||
@@ -319,6 +320,8 @@ export function resetRegistryCache(): void {
|
||||
// suite went red on a NURI collision between two files — a memo that outlives its
|
||||
// session is exactly the fault this review found elsewhere in the tests.)
|
||||
knownInboxes.clear();
|
||||
// …and so is who owns which inbox: it is read from the same index, in the same session.
|
||||
inboxOwners.clear();
|
||||
}
|
||||
|
||||
// --- SPARQL result helpers ------------------------------------------------
|
||||
@@ -798,6 +801,20 @@ const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`;
|
||||
* Written through the PHYSICAL door: which NURIs are inboxes is not one virtual user's
|
||||
* business, exactly like the account records beside it.
|
||||
*
|
||||
* ── And WHOSE inbox it is, recorded beside it ─────────────────────────────
|
||||
* Same table, same reason. Upstream the routing entry `inboxes: PubKey → RepoId`
|
||||
* (`engine/verifier/src/verifier.rs:105`) does not merely say "this is an inbox": it names
|
||||
* the REPO, and the repo's own verifier — its owner's — is what unseals and applies the
|
||||
* message (`verifier.rs:1677`). "Which inbox" and "whose" are one fact there, so they are
|
||||
* one record here. Written for BOTH kinds of inbox, and by the same call: a user's own
|
||||
* ({@link userInbox}) and a document's (`branch-registers.openDocumentInbox`), each of
|
||||
* which knows its owner at the moment it mints one.
|
||||
*
|
||||
* Two `INSERT DATA`s rather than one: the fact keyed by the index subject and the fact
|
||||
* keyed by the inbox are two subjects, and one statement carrying two subjects is a shape
|
||||
* this library writes nowhere else. Both must land before the inbox is handed back, for the
|
||||
* reason spelled out below.
|
||||
*
|
||||
* **Propagates a failed write, and only marks the session's index once the write landed.**
|
||||
* This record is what the emulation puts in place of a fact the network knows by
|
||||
* construction, and `inbox.post` refuses to deposit into anything it cannot confirm
|
||||
@@ -807,7 +824,7 @@ const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`;
|
||||
* from the only session able to see it: the one that opened the inbox believed the record
|
||||
* existed, and every other session did not.
|
||||
*/
|
||||
export async function recordInbox(nuri: Nuri): Promise<void> {
|
||||
export async function recordInbox(nuri: Nuri, ownerId: string): Promise<void> {
|
||||
const s = await session();
|
||||
const shimDoc = await resolveShimDoc();
|
||||
try {
|
||||
@@ -817,15 +834,62 @@ export async function recordInbox(nuri: Nuri): Promise<void> {
|
||||
shimDoc,
|
||||
"recordInbox",
|
||||
);
|
||||
// The inbox NURI is the subject here — the routing entry is keyed by the inbox, exactly
|
||||
// as upstream's table is keyed by its pubkey. It is a NURI (`did:ng:` + base64url and
|
||||
// `:` segments), so it carries nothing that could break out of the `<…>`.
|
||||
await physicalUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${assertNuri(nuri)}> <${P.inboxOwner}> "${escapeLiteral(ownerId)}" }`,
|
||||
shimDoc,
|
||||
"recordInbox:owner",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " recordInbox failed:", error);
|
||||
throw error;
|
||||
}
|
||||
knownInboxes.add(nuri);
|
||||
inboxOwners.set(nuri, ownerId);
|
||||
}
|
||||
|
||||
/** Inboxes this session has already confirmed — the index only ever grows. */
|
||||
const knownInboxes = new Set<Nuri>();
|
||||
/** inbox → the id of the user it belongs to, as this session has resolved it. */
|
||||
const inboxOwners = new Map<Nuri, string>();
|
||||
|
||||
/**
|
||||
* WHOSE inbox is this? The routing question, answered from the shim — see
|
||||
* {@link recordInbox}. `null` when nothing records an owner for it, which for a NURI that
|
||||
* IS an inbox means the record predates this association or its write did not land.
|
||||
*
|
||||
* Asked through the physical door, like {@link isKnownInbox} beside it: routing is not one
|
||||
* virtual user's business, and the caller is precisely the one that is not the owner.
|
||||
*
|
||||
* **Propagates a failed read.** Its caller (`emulated-verifier/inbox-processor.ts`) decides
|
||||
* on the answer whether to drain an inbox and, if so, for whom — so "the broker could not
|
||||
* answer" and "nobody owns this" must not arrive as the same value. Answering `null` on a
|
||||
* failed read would file a deposit under nobody and say nothing.
|
||||
*/
|
||||
export async function inboxOwner(nuri: Nuri): Promise<string | null> {
|
||||
const memo = inboxOwners.get(nuri);
|
||||
if (memo !== undefined) return memo;
|
||||
const s = await session();
|
||||
const shimDoc = await resolveShimDoc();
|
||||
const res = await physicalQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?u WHERE { <${assertNuri(nuri)}> <${P.inboxOwner}> ?u }`,
|
||||
undefined,
|
||||
shimDoc,
|
||||
"inboxOwner",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
const id = bindingValue(row, "u");
|
||||
if (id) {
|
||||
inboxOwners.set(nuri, id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `nuri` an inbox? Asked of the shim, through the physical door — a depositor is not
|
||||
@@ -1031,7 +1095,7 @@ export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
|
||||
// this scope yet. THAT is what mints one — and the three writes it takes all have to
|
||||
// land before anyone is handed the result.
|
||||
const doc = await createDoc();
|
||||
await recordInbox(doc);
|
||||
await recordInbox(doc, id);
|
||||
try {
|
||||
await physicalUpdate(
|
||||
s.sessionId,
|
||||
|
||||
Reference in New Issue
Block a user