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:
@@ -27,12 +27,23 @@
|
||||
*/
|
||||
|
||||
import { sparqlQuery } from "./docs";
|
||||
import { depositInto } from "../emulated-verifier/register-write";
|
||||
import { depositInto, readForHolder } from "../emulated-verifier/register-write";
|
||||
import { subscribeDoc } from "./subscribe";
|
||||
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
|
||||
import { userInbox, isKnownInbox, lookupAccount } from "../shared-wallet/account-registry";
|
||||
import { mintCap } from "../emulated-verifier/caps";
|
||||
import {
|
||||
scheduleInboxProcessing,
|
||||
traceProcessed,
|
||||
} from "../emulated-verifier/inbox-processor";
|
||||
import {
|
||||
accountKey,
|
||||
inboxOwner,
|
||||
userInbox,
|
||||
isKnownInbox,
|
||||
lookupAccount,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import { hasReadCap, toNuri } from "../model/nuri";
|
||||
import {
|
||||
@@ -112,6 +123,25 @@ function summarizePayload(payload: unknown): string {
|
||||
|
||||
// --- SPARQL result helpers ------------------------------------------------
|
||||
|
||||
/**
|
||||
* The SELECT that materializes an inbox document's deposits.
|
||||
*
|
||||
* NO explicit `GRAPH <…>` clause — it reads the anchored DEFAULT graph (see the note in
|
||||
* {@link post}). The anchor scopes the query to that repo's default graph, exactly where
|
||||
* `post` writes.
|
||||
*
|
||||
* Shared by the two readers of an inbox — its owner ({@link read}) and the emulated
|
||||
* processor draining it for that owner ({@link processForOwner}) — so the two cannot
|
||||
* come to disagree about what a deposit looks like.
|
||||
*/
|
||||
const DEPOSITS_QUERY = `
|
||||
SELECT ?payload ?ts ?from WHERE {
|
||||
?d a <${P.type}> ;
|
||||
<${P.payload}> ?payload ;
|
||||
<${P.ts}> ?ts .
|
||||
OPTIONAL { ?d <${P.from}> ?from }
|
||||
}`;
|
||||
|
||||
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
||||
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
|
||||
if (!result) return [];
|
||||
@@ -198,6 +228,11 @@ export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promis
|
||||
}
|
||||
// A deposit crosses the boundary on purpose — see `register-write.depositInto`.
|
||||
await depositInto(sid, update, targetInbox, "deposit");
|
||||
// The deposit LANDED, so something has to pick it up. Upstream that is a service; here it
|
||||
// is a one-shot timer on the TARGET's inbox — see `emulated-verifier/inbox-processor.ts`
|
||||
// for what that emulates, what it usurps, and the limit it does not hide. Armed only on a
|
||||
// write that succeeded: a deposit that never landed has nothing to converge.
|
||||
scheduleInboxProcessing(targetInbox, () => processForOwner(targetInbox));
|
||||
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
|
||||
// who deposited WHAT into which inbox — the decoded payload, not just the
|
||||
// triple-write. Gated by the same access-log flag; skip the JSON work when off.
|
||||
@@ -270,6 +305,29 @@ function capsSeenIn(inbox: Nuri): ReadCap[] {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The deposits a raw SELECT result carries, sorted by `ts` ascending — the one place the
|
||||
* stored shape is turned back into {@link Deposit}s, for both readers of an inbox.
|
||||
*/
|
||||
function depositsFrom(result: unknown): Deposit[] {
|
||||
const deposits: Deposit[] = [];
|
||||
for (const row of readBindings(result)) {
|
||||
const rawPayload = row.payload?.value ?? "null";
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(rawPayload);
|
||||
} catch {
|
||||
payload = rawPayload; // tolerate a non-JSON literal
|
||||
}
|
||||
const tsRaw = row.ts?.value ?? "0";
|
||||
const ts = Number.parseInt(tsRaw, 10) || 0;
|
||||
const fromValue = row.from?.value;
|
||||
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
|
||||
}
|
||||
deposits.sort((a, b) => a.ts - b.ts);
|
||||
return deposits;
|
||||
}
|
||||
|
||||
/** The cap a deposit carries, if it is a Link rather than consumer data. */
|
||||
function capOfPayload(payload: unknown): ReadCap | null {
|
||||
const p = payload as { kind?: unknown; cap?: unknown } | null;
|
||||
@@ -441,32 +499,8 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
||||
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
|
||||
// read leaves both callers correct: the watch path stays event-driven, and the
|
||||
// cold direct-read path opens the repo explicitly before calling `read`.
|
||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
|
||||
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
|
||||
// default graph, exactly where `post` writes.
|
||||
const query = `
|
||||
SELECT ?payload ?ts ?from WHERE {
|
||||
?d a <${P.type}> ;
|
||||
<${P.payload}> ?payload ;
|
||||
<${P.ts}> ?ts .
|
||||
OPTIONAL { ?d <${P.from}> ?from }
|
||||
}`;
|
||||
const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead");
|
||||
const deposits: Deposit[] = [];
|
||||
for (const row of readBindings(result)) {
|
||||
const rawPayload = row.payload?.value ?? "null";
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(rawPayload);
|
||||
} catch {
|
||||
payload = rawPayload; // tolerate a non-JSON literal
|
||||
}
|
||||
const tsRaw = row.ts?.value ?? "0";
|
||||
const ts = Number.parseInt(tsRaw, 10) || 0;
|
||||
const fromValue = row.from?.value;
|
||||
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
|
||||
}
|
||||
deposits.sort((a, b) => a.ts - b.ts);
|
||||
const result = await sparqlQuery(sid, DEPOSITS_QUERY, undefined, targetInbox, "inboxRead");
|
||||
const deposits = depositsFrom(result);
|
||||
// Links are infrastructure, not consumer data: they never reach the caller. They
|
||||
// are only KEPT here (in memory, for this session) — FILING them durably is
|
||||
// `processInbox`'s job, because reading an inbox must not quietly write to a
|
||||
@@ -581,6 +615,86 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
||||
return deposits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process `targetInbox` on behalf of its OWNER — the body of the deferred drain, run while
|
||||
* a DIFFERENT identity holds the session. Never exported: see the module it is scheduled by
|
||||
* (`emulated-verifier/inbox-processor.ts`) for why this must be unreachable.
|
||||
*
|
||||
* ── Every step names its holder; not one of them reads the ambient one ────
|
||||
* That is the whole difficulty. {@link processInbox} resolves WHO at each step it takes —
|
||||
* `isOwnInbox`, `readLinks`, `addLink` all ask `getCurrentUser()` when they run — so
|
||||
* calling it here would read ALICE's registers and file BOB's capabilities into Alice's
|
||||
* ring, and the deposit would be lost while looking applied. The same hazard
|
||||
* `connect.connectedUser` carries `stillConnected()` for, one step worse: there the holder
|
||||
* merely might change, here it is known to be somebody else from the start. So the owner is
|
||||
* resolved first, from the shim's routing record, and handed to every step after it.
|
||||
*
|
||||
* What it establishes for that owner is what the owner's OWN verifier holds by
|
||||
* construction: the cap of its private store and the cap of the inbox it is processing —
|
||||
* the same two `caps.open` calls `ensureAccount`/`userInbox` make when the owner connects
|
||||
* (`branch-registers.fileOwnStructure` / `fileOwnInbox`). It grants nothing new; it names
|
||||
* the ring those facts belong to.
|
||||
*
|
||||
* Consumer deposits are left where they are, exactly as {@link read} leaves them: an inbox
|
||||
* is a queue its owner consumes, and a Link is the only thing this may consume for it.
|
||||
*/
|
||||
async function processForOwner(targetInbox: Nuri): Promise<void> {
|
||||
// WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see
|
||||
// `account-registry.recordInbox`). Everything below is decided for this identity.
|
||||
const ownerId = await inboxOwner(targetInbox);
|
||||
if (ownerId === null) {
|
||||
throw new Error(
|
||||
"[ng-eventually] deferred inbox processing: the shim records no owner for this inbox, " +
|
||||
"so there is nobody to process it for — its deposits wait for its owner to connect: " +
|
||||
JSON.stringify(targetInbox),
|
||||
);
|
||||
}
|
||||
// The ring the owner's own session would use — one rule for both, see
|
||||
// `branch-registers.holderRing`.
|
||||
const ownerRing = accountKey(ownerId);
|
||||
// `lookupAccount`, not `resolveAccount`: this decides whether to drain at all, so a read
|
||||
// that could not ANSWER must not arrive looking like "that user does not exist".
|
||||
const record = await lookupAccount(ownerId);
|
||||
if (record === null) {
|
||||
throw new Error(
|
||||
"[ng-eventually] deferred inbox processing: the owner recorded for this inbox has no " +
|
||||
`account — nothing can be filed for them: ${JSON.stringify(ownerId)}`,
|
||||
);
|
||||
}
|
||||
const caps = getCaps();
|
||||
// What the owner's OWN verifier holds by construction: the inbox it is about to process,
|
||||
// and the private store where a Link is filed. Both are what `fileOwnInbox` /
|
||||
// `fileOwnStructure` put in that ring when the owner connects — the same two facts, named
|
||||
// rather than resolved. Nothing new is granted: a holder that may not reach a document is
|
||||
// still refused by `readForHolder` below.
|
||||
//
|
||||
// ONE visible consequence, and it is deliberate: filing a cap fires the registry's change
|
||||
// signal, which is global rather than per-holder, so a `watchShape` open in the connected
|
||||
// session re-reads. It re-reads the SAME data — the connected identity gains nothing —
|
||||
// and the signal cannot be suppressed for the other case this same path serves: an owner
|
||||
// draining an inbox on their own document, where re-reading is exactly the contract
|
||||
// (`caps.onChange`).
|
||||
caps.learnFor(ownerRing, mintCap(targetInbox));
|
||||
if (record.docPrivate) caps.learnFor(ownerRing, mintCap(record.docPrivate));
|
||||
|
||||
const sid = await sessionId();
|
||||
await ensureRepoOpen(targetInbox, ownerRing);
|
||||
const deposits = depositsFrom(
|
||||
await readForHolder(sid, DEPOSITS_QUERY, targetInbox, ownerRing, "inboxProcess"),
|
||||
);
|
||||
let applied = 0;
|
||||
for (const deposit of deposits) {
|
||||
const cap = capOfPayload(deposit.payload);
|
||||
if (cap === null) continue; // consumer data — not this service's to consume
|
||||
// In memory first, then durably: the same order and the same two acts as
|
||||
// `read` + `processInbox`, with the holder named instead of resolved.
|
||||
caps.learnFor(ownerRing, cap);
|
||||
await addLink(cap, ownerId);
|
||||
applied += 1;
|
||||
}
|
||||
traceProcessed(targetInbox, ownerRing, applied);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
|
||||
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
||||
|
||||
Reference in New Issue
Block a user