feat: un dépôt est aiguillé sur son type, comme en amont, et l'index a son bras
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
|
||||
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
|
||||
* decision). The mechanism itself knows no application domain: the consumer
|
||||
* supplies the inbox document NURI and interprets the `payload`. (An example
|
||||
* consumer mapping, purely illustrative: a consumer might use one inbox for a
|
||||
* registration deposit and another for submitting a reference to an index.)
|
||||
* decision). The consumer supplies the inbox document NURI and interprets its own
|
||||
* `payload`: this module knows no application domain, and the only payloads it reads
|
||||
* are the ones that DECLARE themselves to this package — see
|
||||
* `emulated-verifier/inbox-dispatch.ts`, which owns that question and every arm that
|
||||
* answers it. Everything else is handed back untouched.
|
||||
*
|
||||
* ── Real target vs this emulation ─────────────────────────────────────────
|
||||
* In real NextGraph, a message is sealed to the recipient's key and queued into
|
||||
@@ -31,12 +32,18 @@ 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 { documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
|
||||
import { mintCap } from "../emulated-verifier/caps";
|
||||
import {
|
||||
scheduleInboxProcessing,
|
||||
traceProcessed,
|
||||
} from "../emulated-verifier/inbox-processor";
|
||||
import {
|
||||
applyDeposit,
|
||||
capOfPayload,
|
||||
kindOf,
|
||||
linkPayload,
|
||||
} from "../emulated-verifier/inbox-dispatch";
|
||||
import {
|
||||
accountKey,
|
||||
inboxOwner,
|
||||
@@ -45,7 +52,7 @@ import {
|
||||
lookupAccount,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import { hasReadCap, toNuri } from "../model/nuri";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import {
|
||||
accessLogPrefix,
|
||||
enabled as accessLogEnabled,
|
||||
@@ -53,7 +60,7 @@ import {
|
||||
logStage,
|
||||
shortNuri,
|
||||
} from "../shared-wallet/access-log";
|
||||
import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
|
||||
import type { Nuri, NuriLike, PrincipalId } from "../model/types";
|
||||
|
||||
// --- deposit model --------------------------------------------------------
|
||||
|
||||
@@ -290,21 +297,16 @@ export async function postToDocument(docLike: NuriLike, opts: PostOptions): Prom
|
||||
// --- cap delivery ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it
|
||||
* is the same one at all three stages: `InboxMsgContent::Link` is the message
|
||||
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
|
||||
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
|
||||
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
|
||||
* the recipient processes their inbox and files it.
|
||||
* The payloads the last read of an inbox saw DECLARE themselves to this package —
|
||||
* every variant of the dispatch, not only Links — awaiting the applying pass.
|
||||
*
|
||||
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
|
||||
* no special case on the surface — a re-delivered cap is just another Link.
|
||||
* Kept in memory for this session and nothing more: APPLYING them is
|
||||
* {@link processInbox}'s job, because reading an inbox must not quietly write to a
|
||||
* user's store. Reading the raw deposits a second time would mean re-parsing them, so
|
||||
* what the read already decoded is what the pass works from.
|
||||
*/
|
||||
const LINK_KIND = "urn:ng-eventually:inbox:link";
|
||||
|
||||
/** Links observed during the last read of an inbox, awaiting durable filing. */
|
||||
const seenByInbox = new Map<Nuri, ReadCap[]>();
|
||||
function capsSeenIn(inbox: Nuri): ReadCap[] {
|
||||
const seenByInbox = new Map<Nuri, unknown[]>();
|
||||
function declaredIn(inbox: Nuri): unknown[] {
|
||||
return seenByInbox.get(inbox) ?? [];
|
||||
}
|
||||
|
||||
@@ -332,12 +334,8 @@ function depositsFrom(result: unknown): Deposit[] {
|
||||
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;
|
||||
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
|
||||
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
|
||||
}
|
||||
// What a deposit DECLARES itself to be, and what each declaration means, live in
|
||||
// `emulated-verifier/inbox-dispatch.ts` — the `match` this surface hands its deposits to.
|
||||
|
||||
/**
|
||||
* Share ONE document with ONE recipient.
|
||||
@@ -426,7 +424,7 @@ export async function share(doc: NuriLike, toUser: string): Promise<void> {
|
||||
`${JSON.stringify(toUser)}. Sharing does not create the person you share with.`,
|
||||
);
|
||||
}
|
||||
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
|
||||
await post(await userInbox(toUser, "protected"), { payload: linkPayload(cap) });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -529,13 +527,19 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
||||
// cold direct-read path opens the repo explicitly before calling `read`.
|
||||
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
|
||||
// user's store. Filing fires the registry's change signal, which is what makes a
|
||||
// view that was empty for want of that cap re-read instead of staying stale.
|
||||
// A KEY is the one thing that never reaches the caller: it is infrastructure, and the
|
||||
// model's whole point is that an application never handles one. Every other deposit is
|
||||
// handed back exactly as before, INCLUDING the ones that declare themselves to this
|
||||
// package — a reference is public information, so withholding it would buy nothing and
|
||||
// would take from an application something it can already read.
|
||||
//
|
||||
// What every declared payload does get is a place in the session's list, which is what
|
||||
// {@link processInbox} works from. It is only KEPT here — APPLYING is that pass's job,
|
||||
// because reading an inbox must not quietly write to a user's store. Filing fires the
|
||||
// registry's change signal, which is what makes a view that was empty for want of a cap
|
||||
// re-read instead of staying stale.
|
||||
const delivered: Deposit[] = [];
|
||||
const links: ReadCap[] = [];
|
||||
const declared: unknown[] = [];
|
||||
// The ownership guard ran at entry; the filing happens several awaits later, and filing
|
||||
// resolves WHO is holding at that moment. So an application switching identity in the
|
||||
// gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the
|
||||
@@ -546,15 +550,15 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
||||
// consumed by reading, so the next connection under the right identity files them.
|
||||
const stillOwner = getCurrentUser() === owner;
|
||||
for (const d of deposits) {
|
||||
if (kindOf(d.payload) !== null) declared.push(d.payload);
|
||||
const cap = capOfPayload(d.payload);
|
||||
if (cap) {
|
||||
if (stillOwner) getCaps().learnFor(ownerKey, cap);
|
||||
links.push(cap);
|
||||
continue;
|
||||
}
|
||||
delivered.push(d);
|
||||
}
|
||||
if (links.length > 0) seenByInbox.set(targetInbox, links);
|
||||
if (declared.length > 0) seenByInbox.set(targetInbox, declared);
|
||||
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
|
||||
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
|
||||
// each — the exact visibility needed to trace materialization at the owner
|
||||
@@ -659,16 +663,19 @@ export async function readSyncedForDocument(docLike: NuriLike): Promise<Deposit[
|
||||
/**
|
||||
* PROCESS an inbox: read it, and **apply** what it contains.
|
||||
*
|
||||
* Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`,
|
||||
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
|
||||
* the cap survives the session. Upstream this is what a verifier does when it
|
||||
* processes queued messages: an inbox is a **queue you consume**, not a store you
|
||||
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
|
||||
* database, and it is the thing this replaces.
|
||||
* Applying is a DISPATCH on what each deposit declares itself to be, one arm per type,
|
||||
* exactly as `Verifier::process_inbox` is a `match` over `InboxMsgContent` — the arms,
|
||||
* and what a kind with no arm gets, are `emulated-verifier/inbox-dispatch.ts`. Applying a
|
||||
* {@link share} Link, for instance, means filing it durably (`branch-registers.addLink`,
|
||||
* the emulated `AddLink { read_cap }` on the User branch of the private store) so the cap
|
||||
* survives the session. Upstream this is what a verifier does when it processes queued
|
||||
* messages: an inbox is a **queue you consume**, not a store you re-read. Re-reading an
|
||||
* inbox every session to recover caps is using a queue as a database, and it is the thing
|
||||
* this replaces.
|
||||
*
|
||||
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
|
||||
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
|
||||
* {@link read} does — Links are never surfaced.
|
||||
* Idempotent, arm by arm — `addLink` ignores a cap it already holds, an object already in
|
||||
* an index is passed over — so processing twice (a second tab, a reconnect) costs nothing.
|
||||
* Returns exactly what {@link read} returned: only a cap is ever withheld.
|
||||
*
|
||||
* ── The holder is captured, not re-resolved after the read ────────────────
|
||||
* The ownership guard runs at entry (inside {@link readSynced}) and the FILING happens a
|
||||
@@ -693,13 +700,18 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
||||
// WHO this processing is for, captured before the read that authorises it — see above.
|
||||
const holder = getCurrentUser();
|
||||
const deposits = await readSynced(targetInbox);
|
||||
// `readSynced` already put every Link in memory for this session; now make
|
||||
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
|
||||
// are taken from what the read just observed.
|
||||
const seen = capsSeenIn(targetInbox);
|
||||
// `readSynced` already decoded every declared payload for this session; now APPLY them.
|
||||
// Reading the raw deposits again would mean re-parsing, so the pass works from what the
|
||||
// read just observed.
|
||||
const declared = declaredIn(targetInbox);
|
||||
seenByInbox.delete(targetInbox);
|
||||
for (const cap of seen) {
|
||||
// Re-checked per cap, not once: `addLink` reads and writes, so the identity can move
|
||||
// `readSynced`'s guard refuses a session with no identity, so the holder captured above
|
||||
// cannot be null past it. Narrowed rather than defaulted: applying "for nobody" is
|
||||
// precisely the mis-filing the capture exists to prevent, and it must not have a value.
|
||||
if (holder === null) return deposits;
|
||||
const ring = accountKey(holder);
|
||||
for (const payload of declared) {
|
||||
// Re-checked per item, not once: an arm reads and writes, so the identity can move
|
||||
// between two of them just as easily as during the read.
|
||||
if (getCurrentUser() !== holder) {
|
||||
logStage(
|
||||
@@ -708,7 +720,13 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
||||
);
|
||||
return deposits;
|
||||
}
|
||||
await addLink(cap, holder ?? undefined);
|
||||
// The `match`, one arm per declared type — `emulated-verifier/inbox-dispatch.ts`.
|
||||
await applyDeposit(payload, {
|
||||
owner: holder,
|
||||
ring,
|
||||
inbox: targetInbox,
|
||||
ownerHoldsTheSession: true,
|
||||
});
|
||||
}
|
||||
return deposits;
|
||||
}
|
||||
@@ -733,8 +751,10 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
|
||||
* (`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.
|
||||
* Consumer deposits are left where they are, exactly as {@link read} leaves them — and so
|
||||
* is every deposit whose arm would write application data into the owner's own documents:
|
||||
* an inbox is a queue its OWNER consumes, and what this may apply on somebody else's
|
||||
* behalf is the verifier's own bookkeeping (see `Applying.ownerHoldsTheSession`).
|
||||
*/
|
||||
async function processForOwner(targetInbox: Nuri): Promise<void> {
|
||||
// WHO owns this inbox — the emulated `inboxes: PubKey → RepoId` (see
|
||||
@@ -782,13 +802,17 @@ async function processForOwner(targetInbox: Nuri): Promise<void> {
|
||||
);
|
||||
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;
|
||||
// The SAME `match` the owner's own session runs, with the holder named instead of
|
||||
// resolved — and told whether the owner is the one holding the page, because an arm
|
||||
// that would write application data into the owner's documents must not run from a
|
||||
// stranger's session (`emulated-verifier/inbox-dispatch.ts`).
|
||||
const done = await applyDeposit(deposit.payload, {
|
||||
owner: ownerId,
|
||||
ring: ownerRing,
|
||||
inbox: targetInbox,
|
||||
ownerHoldsTheSession: getCurrentUser() === ownerId,
|
||||
});
|
||||
if (done) applied += 1;
|
||||
}
|
||||
traceProcessed(targetInbox, ownerRing, applied);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user