b5f05472d9
J'ai relancé un adversaire sur les correctifs du commit précédent, comme la règle l'exige. Il en a trouvé quatre. Le premier annulait la garde que je venais d'écrire. **Le registre de propriété était écrivable depuis la surface publiée.** `assertMayWrite` lit la propriété dans l'index de store de l'appelant — et `caps.open` marquait les documents de STRUCTURE (les trois stores, les inbox) comme « créés par moi ». Donc un porteur pouvait, par le `docs.sparqlUpdate` publié, insérer `contains "<n'importe quel document>"` dans son propre index et s'en déclarer propriétaire. Démontré : Bob écrit dans le document protégé d'Alice, et détourne l'inbox d'un de ses documents — exactement le vecteur que le commit précédent prétendait fermer. `open` classe désormais sans marquer : un document de structure n'est possédé par personne au sens de la paternité, donc les deux moitiés de la garde répondent non, ce qui est correct. **`inbox.post` acceptait n'importe quel NURI.** Déplacer `depositInto` hors de la surface ne suffisait pas : `post` atteint la même porte, qui saute les deux gardes par conception. Bob, ne détenant rien, écrivait quatre triplets dans le document d'Alice. En amont la confusion est impossible — `InboxPost` scelle vers une CLÉ d'inbox et le broker route par `inboxes: PubKey → RepoId` ; adresser un document n'est pas refusé, c'est inexprimable. Le shim tient maintenant un index des inbox, l'équivalent émulé de ce que le broker sait par construction, et `post` refuse ce qui n'en est pas une. **Le filtre de lecture fuyait encore par les clés dunder.** `DeepSignalSet` expose la collection brute sur `__raw__` / `__meta__` : `[...view]` rendait zéro élément pendant que `view.__raw__` rendait le Set complet, tous utilisateurs confondus. Mon en-tête affirmait qu'« une propriété simple ne porte aucun élément » — faux pour ce type. **Et il cassait des membres légitimes** : ma liste blanche couvrait la moitié des helpers d'itération, si bien que `toArray`, `reduce`, `first`, `take`, `drop`, `flatMap` levaient sur les données du porteur lui-même. Tous filtrés désormais ; le refus ne vaut que pour l'inconnu. **Deux tests réparés à la source plutôt qu'en affaiblissant les gardes.** Le faux `doc_create` de `inbox.test.ts` rendait une CONSTANTE — tous les documents créés étaient le même NURI, donc la garde de propriété n'avait rien à distinguer et deux tests lisaient l'inbox d'Alice sous l'identité de Bob sans que rien ne proteste. Et le harnais e2e utilisait un document ordinaire comme inbox. Enfin, mon propre cache d'inbox a reproduit la faute que la revue avait relevée ailleurs : un mémo qui survit à sa session. Rattaché à `resetRegistryCache`. 189 tests unitaires (six régressions de plus), e2e 40/40 et applicatif 10/10 — après un échec réseau non reproductible, relancé sans modification.
606 lines
29 KiB
TypeScript
606 lines
29 KiB
TypeScript
/**
|
|
* 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.)
|
|
*
|
|
* ── Real target vs this emulation ─────────────────────────────────────────
|
|
* In real NextGraph, a message is sealed to the recipient's key and queued into
|
|
* their inbox; the recipient's own verifier unseals each queued message and
|
|
* applies it inline as it processes the inbox — there is no separate curator
|
|
* process. There is NO sender-side JS call for this today: the verifier has no
|
|
* `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`,
|
|
* named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md` —
|
|
* no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.)
|
|
*
|
|
* Here, on one shared wallet where everything is readable, both sides run in-lib:
|
|
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
|
|
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
|
|
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
|
|
* them. This in-lib read stands in for the recipient's own inbox processing
|
|
* until a sealed-inbox path is exposed to JS.
|
|
*
|
|
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
|
|
* never `makeNg`), so this module imports no `@ng-org` package.
|
|
*/
|
|
|
|
import { sparqlQuery } from "./docs";
|
|
import { depositInto } 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 } from "../shared-wallet/account-registry";
|
|
import { escapeLiteral } from "./sparql";
|
|
import { hasReadCap, toNuri } from "../model/nuri";
|
|
import {
|
|
accessLogPrefix,
|
|
enabled as accessLogEnabled,
|
|
logAccess,
|
|
logStage,
|
|
shortNuri,
|
|
} from "../shared-wallet/access-log";
|
|
import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
|
|
|
|
// --- deposit model --------------------------------------------------------
|
|
|
|
/** One deposit as materialized from an inbox document. */
|
|
export interface Deposit {
|
|
/** The sender, if identified; `null` when the deposit was anonymous. */
|
|
from: PrincipalId | null;
|
|
/** The consumer-defined payload (opaque here — JSON-serialized in storage). */
|
|
payload: unknown;
|
|
/** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
|
|
ts: number;
|
|
}
|
|
|
|
/** Options for {@link post}. `from` and `ts` are both optional. */
|
|
export interface PostOptions {
|
|
/**
|
|
* Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
|
|
* principal id to identify the sender. Defaults to the current polyfill user
|
|
* ({@link getCurrentUser}) when the property is entirely absent, so callers
|
|
* that want anonymity must pass `from: null` explicitly.
|
|
*/
|
|
from?: PrincipalId | null;
|
|
/** The payload to deposit (interpreted only by the consumer). */
|
|
payload: unknown;
|
|
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
|
|
* keeps tests deterministic. */
|
|
ts?: number;
|
|
}
|
|
|
|
const SHIM = "urn:ng-eventually:inbox";
|
|
const P = {
|
|
type: `${SHIM}:Deposit`,
|
|
from: `${SHIM}:from`,
|
|
payload: `${SHIM}:payload`,
|
|
ts: `${SHIM}:ts`,
|
|
} as const;
|
|
|
|
// --- session access (shared with the storeRegistry) -----------------------
|
|
|
|
/** The inbox documents live in the shared wallet, so we reuse the registry's
|
|
* injected session provider for the sessionId. Disappears at migration. */
|
|
async function sessionId(): Promise<string> {
|
|
return (await getStoreRegistryDeps().getSession()).sessionId;
|
|
}
|
|
|
|
// --- diagnostic logging helper ---------------------------------------------
|
|
|
|
/**
|
|
* Best-effort, length-capped JSON rendering of a deposit payload for the
|
|
* inbox diagnostic log (see {@link enabled}/{@link logAccess}). This module
|
|
* stays domain-agnostic (see module header) — it never interprets payload
|
|
* fields, it only dumps them verbatim so the consumer's own shape (e.g. a
|
|
* Festipod participation: `{ participantId, eventId, … }`) is visible in the
|
|
* log without this module knowing that shape. Capped so one oversized payload
|
|
* can't blow up a log line; a payload that fails to stringify (e.g. a
|
|
* circular structure a caller mistakenly passed) falls back to `String()`.
|
|
*/
|
|
function summarizePayload(payload: unknown): string {
|
|
let s: string;
|
|
try {
|
|
s = JSON.stringify(payload) ?? String(payload);
|
|
} catch {
|
|
s = String(payload);
|
|
}
|
|
return s.length > 200 ? s.slice(0, 200) + "…" : s;
|
|
}
|
|
|
|
// --- SPARQL result helpers ------------------------------------------------
|
|
|
|
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
|
|
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
|
|
if (!result) return [];
|
|
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
|
|
const anyRes = result as {
|
|
results?: { bindings?: Array<Record<string, { value: string }>> };
|
|
};
|
|
return anyRes.results?.bindings ?? [];
|
|
}
|
|
|
|
// --- deposit (client side) ------------------------------------------------
|
|
|
|
/**
|
|
* Deposit a payload into `targetInbox`.
|
|
*
|
|
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
|
|
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
|
|
* graph, so concurrent deposits don't collide.
|
|
*
|
|
* `from` is bound to the current identity — it is authenticated, not
|
|
* caller-supplied. Omit it to stamp the current identity; pass `null` to deposit
|
|
* anonymously (a legitimate choice — identified if known, anonymous otherwise).
|
|
* A `from` naming another identity is rejected as a spoof: in the target the
|
|
* broker seals the sender from the wallet's own key, so a client cannot forge
|
|
* another's identity. This check is redundant once the seal enforces it, but
|
|
* until then it closes the spoof the shared wallet would otherwise allow.
|
|
*/
|
|
export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promise<void> {
|
|
const targetInbox = toNuri(targetInboxLike, "inbox.post");
|
|
const current = getCurrentUser();
|
|
let from: PrincipalId | null;
|
|
if (opts.from === undefined) {
|
|
from = current; // default: stamp the current identity
|
|
} else if (opts.from === null) {
|
|
from = null; // explicit anonymous deposit
|
|
} else if (opts.from === current) {
|
|
from = opts.from; // identifying as self — allowed
|
|
} else {
|
|
throw new Error(
|
|
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
|
|
"(anonymous) — depositing as another principal is a spoof.",
|
|
);
|
|
}
|
|
const ts = opts.ts ?? Date.now();
|
|
const sid = await sessionId();
|
|
|
|
// A unique subject per deposit (in the inbox graph) — no collisions.
|
|
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
|
|
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
|
|
const fromTriple =
|
|
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
|
|
|
|
// NO explicit `GRAPH <…>` wrapper — write the anchored DEFAULT graph:
|
|
// `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's
|
|
// default graph (same shape as read-model.ts readDoc/readUnion). This is the
|
|
// CANONICAL, always-safe shape and the one the anchored default-graph read
|
|
// queries. (Not a round-trip necessity on the current broker: the e2e harness
|
|
// `packages/sdk/e2e/` verified that an anchored `GRAPH <plainNuri>` write
|
|
// ALSO round-trips here — it resolves to the same repo graph, no phantom graph.
|
|
// The no-GRAPH form is kept as a simplicity/safety convention; re-verify with
|
|
// that harness if the broker version changes.)
|
|
const update = `
|
|
INSERT DATA {
|
|
<${subject}> a <${P.type}> ;
|
|
<${P.payload}> "${payloadLiteral}" ;
|
|
<${P.ts}> "${ts}"${fromTriple} .
|
|
}`;
|
|
// The target must BE an inbox, and this is the one check standing between a deposit
|
|
// and an arbitrary write into someone else's document.
|
|
//
|
|
// Upstream the question does not arise: `InboxPost` seals to an inbox PUBKEY and the
|
|
// broker routes it by `inboxes: PubKey → RepoId` — addressing a plain repo with a
|
|
// deposit is not refused, it is unrepresentable. Here an inbox is a document like any
|
|
// other, so without this `inbox.post(someoneElsesDocument, …)` wrote four triples into
|
|
// it, through a published door that skips both guards by design. Found by re-running
|
|
// the adversary on the fix that un-published `depositInto` (2026-08-07) — moving that
|
|
// function was not enough, because `post` reaches the same door.
|
|
if (!(await isKnownInbox(targetInbox))) {
|
|
throw new Error(
|
|
"[ng-eventually] inbox.post: refused — this is not an inbox. A deposit is addressed " +
|
|
"to an inbox, never to a document; upstream the two cannot even be confused, " +
|
|
`because a deposit carries an inbox key and not a document reference. ${JSON.stringify(targetInbox)}`,
|
|
);
|
|
}
|
|
// A deposit crosses the boundary on purpose — see `register-write.depositInto`.
|
|
await depositInto(sid, update, targetInbox, "deposit");
|
|
// 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.
|
|
if (accessLogEnabled()) {
|
|
logAccess(
|
|
"WRITE",
|
|
targetInbox,
|
|
"inbox deposit",
|
|
" from=" + (from ?? "anonymous") + " payload=" + summarizePayload(opts.payload ?? null),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deposit into the inbox of a DOCUMENT — resolve where, then deposit there.
|
|
*
|
|
* The call an app makes to reach a document's owner: it needs the document (which it
|
|
* must be able to read) and nothing else. Where the inbox is, and whether the owner
|
|
* ever opened one, are the library's business.
|
|
*
|
|
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
|
|
* to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals
|
|
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody`
|
|
* carries no document (`engine/net/src/types.rs:4265`), because the address already
|
|
* identifies it. Tagging deposits with their document would be an invention consumers
|
|
* would have to unlearn at migration, so this resolves the address and stops there.
|
|
*
|
|
* @throws if the document has no inbox — its owner never opened one, so there is
|
|
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
|
|
* this path: a deposit that vanishes without an error is worse than a refusal, and it
|
|
* is exactly the bug per-document inboxes shipped with
|
|
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
|
|
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
|
|
*/
|
|
export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise<void> {
|
|
const doc = toNuri(docLike, "inbox.postToDocument");
|
|
const target = await documentInboxAddress(doc);
|
|
if (target === undefined) {
|
|
throw new Error(
|
|
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
|
|
"never opened one, or you cannot read the document (the address rides on it): " +
|
|
JSON.stringify(doc),
|
|
);
|
|
}
|
|
return post(target, opts);
|
|
}
|
|
|
|
|
|
// --- 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
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[] {
|
|
return seenByInbox.get(inbox) ?? [];
|
|
}
|
|
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* Share ONE document with ONE recipient.
|
|
*
|
|
* The unit of sharing is the DOCUMENT: never hand over a store's cap, which would
|
|
* give away everything the store contains, present and future. The recipient needs
|
|
* no dedicated operation to receive it — the cap arrives as a deposit that their
|
|
* existing {@link watch} absorbs into what they hold (see {@link read}).
|
|
*
|
|
* Reaching several recipients means calling this once per inbox, which is what the
|
|
* real model does too: each delivery is sealed to one recipient.
|
|
*
|
|
* Upstream this path is a GAP, not a disagreement — verified at both ends:
|
|
* - the field exists, `ContactDetails.read_cap: Option<ReadCap>`
|
|
* (`engine/net/src/types.rs:4233`), but building a message that carries one is
|
|
* `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`);
|
|
* - and the receiver ignores it: `InboxMsgContent::ContactDetails` writes only
|
|
* `ng:site`/`ng:protected` + `ng:*_inbox` into a fresh contact document
|
|
* (`engine/verifier/src/inbox_processor.rs:778-830`), never `details.read_cap`.
|
|
*
|
|
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
|
|
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
|
|
*
|
|
* The shape is right; the implementation is absent at both ends, so we emulate it
|
|
* meanwhile.
|
|
*/
|
|
export async function share(doc: NuriLike, toUser: string): Promise<void> {
|
|
const target = toNuri(doc, "inbox.share");
|
|
// Names the DOCUMENT and the PERSON — the two things an application has. Neither the
|
|
// key nor the address appears, because a caller will handle neither once this is
|
|
// native: upstream the verifier fills `ContactDetails.read_cap` itself, and an inbox
|
|
// is resolved from a profile. This took `(cap, toInbox)` at first, then `(cap, toUser)`;
|
|
// both made the caller hold something it will not hold later.
|
|
const cap = getCaps().capFor(target);
|
|
if (!cap) {
|
|
throw new Error(
|
|
"[ng-eventually] inbox.share: this document is not yours to share — you hold no cap " +
|
|
`for it. A cap is looked up in what you hold, or it was delivered to you: ${JSON.stringify(target)}`,
|
|
);
|
|
}
|
|
// ── KNOWN DIVERGENCE: the protected inbox is hard-coded here ──────────────
|
|
// Upstream the choice is not fixed. A contact record picks its inbox from the PROFILE
|
|
// through which the person was reached: `a_or_b = if details.profile.is_public()
|
|
// { "site" } else { "protected" }` (`engine/verifier/src/inbox_processor.rs:787`,
|
|
// written as `ng:site_inbox` vs `ng:protected_inbox` at `:823-824`). Reach someone by
|
|
// their public profile and the deposit goes to their public store's inbox; by their
|
|
// protected profile, to the protected one.
|
|
//
|
|
// This library has no notion of "the profile by which I know this person", so it
|
|
// always uses the protected one. Minor today — a consumer names a user and gets one
|
|
// answer — but it flattens a distinction the model makes, and the day an application
|
|
// shares with someone met through a public profile, this picks the wrong inbox.
|
|
//
|
|
// Not fixable in isolation: it needs a notion this library does not have, and about
|
|
// which nothing has been established here. What IS verified: a wallet holds `sites`,
|
|
// a `SiteV0` has an `id: PubKey`, a `name`, a `site_type` (Individual | Org) and three
|
|
// stores (`engine/verifier/src/site.rs:23-40`); the `Identity` enum that would name
|
|
// the rest is entirely COMMENTED OUT upstream (`engine/repo/src/types.rs:586-595`).
|
|
// Do not build on an assumed profile model — there is none to read yet.
|
|
//
|
|
// (The private store has no inbox at all — `new_store_default` attaches one only
|
|
// `if !private`, `verifier.rs:2994` — hence `InboxScope`, which makes "the private
|
|
// inbox" unwritable rather than merely empty.)
|
|
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
|
|
}
|
|
|
|
/**
|
|
* The messages left on a document YOU own — the read side of {@link postToDocument}.
|
|
*
|
|
* Named by the DOCUMENT, like the deposit side: an owner reading their own messages has
|
|
* no more reason to handle an inbox address than a depositor does. Empty when the
|
|
* document has no inbox, which is a state and not an error.
|
|
*/
|
|
export async function readForDocument(docLike: NuriLike): Promise<Deposit[]> {
|
|
const doc = toNuri(docLike, "inbox.readForDocument");
|
|
const address = await documentInboxAddress(doc);
|
|
return address ? read(address) : [];
|
|
}
|
|
|
|
// --- the read guard ------------------------------------------------------
|
|
|
|
/**
|
|
* Refuse to READ an inbox that is not the current wallet's.
|
|
*
|
|
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
|
|
* is how a link reaches another wallet at all — see {@link post} / {@link share});
|
|
* READING one is not, and it is not symmetric with it. Since caps travel as
|
|
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
|
|
* addressed to its owner, which defeats directed sharing entirely.
|
|
*
|
|
* Anonymous owns no inbox, so it can read none — an identity has to be established
|
|
* first. At migration this disappears: an inbox is sealed to its owner's key, and
|
|
* the guard is the cryptography.
|
|
*/
|
|
async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
|
|
if (getCurrentUser() === null) {
|
|
throw new Error(
|
|
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
|
|
"session — call setCurrentUser() first. Depositing (post/share) stays open.",
|
|
);
|
|
}
|
|
if (!(await isOwnInbox(targetInbox))) {
|
|
throw new Error(
|
|
`[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` +
|
|
"the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " +
|
|
"your own — otherwise the caps addressed to its owner would be collectable by " +
|
|
`whoever knows its NURI: ${JSON.stringify(targetInbox)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- read --------------------------------------------------------------
|
|
|
|
/**
|
|
* Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In
|
|
* real NextGraph the recipient's own verifier applies queued messages inline as
|
|
* it processes the inbox; here this read stands in for that until the
|
|
* sealed-inbox path is available. The consumer interprets each deposit's
|
|
* `payload`.
|
|
*
|
|
* Cap deliveries ({@link share}) are applied inline and NOT returned: they land
|
|
* in what the current holder holds, like the verifier applying a queued message.
|
|
* That is why receiving a cap needs no dedicated operation — a consumer already
|
|
* watching its inbox gets them, and the resulting change re-triggers the
|
|
* reads that were empty for want of that cap.
|
|
*/
|
|
export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
|
const targetInbox = toNuri(targetInboxLike, "inbox.read");
|
|
await assertOwnInbox(targetInbox, "read");
|
|
const sid = await sessionId();
|
|
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
|
|
// (a cold reader that opens the repo before reading), NOT here — `inbox.watch`
|
|
// already holds the repo open via its own `subscribeDoc`, so opening a second
|
|
// bootstrap subscription from inside a watch's re-read would be redundant and can
|
|
// 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);
|
|
// 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.
|
|
const delivered: Deposit[] = [];
|
|
const links: ReadCap[] = [];
|
|
for (const d of deposits) {
|
|
const cap = capOfPayload(d.payload);
|
|
if (cap) {
|
|
getCaps().learn(cap);
|
|
links.push(cap);
|
|
continue;
|
|
}
|
|
delivered.push(d);
|
|
}
|
|
if (links.length > 0) seenByInbox.set(targetInbox, links);
|
|
// 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
|
|
// side. Gated by the same access-log flag; skip the JSON work when off.
|
|
if (accessLogEnabled()) {
|
|
logAccess(
|
|
"READ",
|
|
targetInbox,
|
|
"inbox materialize",
|
|
" → " + delivered.length + " message(s)" +
|
|
(deposits.length !== delivered.length
|
|
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
|
|
: ""),
|
|
);
|
|
for (const d of delivered) {
|
|
logAccess(
|
|
"READ",
|
|
targetInbox,
|
|
"inbox message",
|
|
" ts=" + d.ts + " from=" + (d.from ?? "anonymous") + " payload=" + summarizePayload(d.payload),
|
|
);
|
|
}
|
|
}
|
|
return delivered;
|
|
}
|
|
|
|
/** Alias for {@link read} — the name that reads as "process the inbox now". */
|
|
export const materialize = read;
|
|
|
|
/**
|
|
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
|
|
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
|
|
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
|
|
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
|
|
*
|
|
* Why this over a plain {@link read}: on a FRESH session over the persistent
|
|
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
|
|
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
|
|
* returns 0 deposits — even for a deposit a remote session already synced to the
|
|
* broker. Gating on the sync barrier makes the read see the synced deposits. This
|
|
* is the same cold-read heal any cold direct reader needs.
|
|
*
|
|
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
|
|
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
|
|
* re-read would be redundant and could race the watch's own initial-`State`
|
|
* delivery. Use this from a COLD reader (materialize-at-connection), like
|
|
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
|
|
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
|
|
*/
|
|
export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
|
const targetInbox = toNuri(targetInboxLike, "inbox.readSynced");
|
|
// Marks the cold, connection-triggered entry point in the trace — the BARRIER
|
|
// line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below
|
|
// (from the read() this wraps) follow right after, so a live session shows
|
|
// the whole owner-reconnect sequence together.
|
|
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
|
|
await assertOwnInbox(targetInbox, "readSynced");
|
|
await ensureRepoOpen(targetInbox);
|
|
return read(targetInbox);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
|
const targetInbox = toNuri(targetInboxLike, "inbox.processInbox");
|
|
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.
|
|
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
|
|
seenByInbox.delete(targetInbox);
|
|
return deposits;
|
|
}
|
|
|
|
/**
|
|
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
|
|
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
|
* `onDeposits` fires once on the initial state push and again on every subsequent
|
|
* change to the inbox document — a local deposit OR a broker-synced remote one.
|
|
* Returns an unsubscribe function.
|
|
*
|
|
* On each push it re-reads the full deposit list ({@link read}) and invokes
|
|
* `onDeposits` only when the deposit count changed (grew), keeping the same
|
|
* "fires on change" contract the polling watcher had — same callback signature
|
|
* and same behaviour, just event-driven instead of `setInterval`.
|
|
*
|
|
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
|
|
* there is no polling. (The inbox document is a single doc, so this is immune to
|
|
* the ORM fan-out hang — see {@link subscribeDoc}.)
|
|
*/
|
|
export function watch(
|
|
targetInbox: Nuri,
|
|
onDeposits: (deposits: Deposit[]) => void,
|
|
_opts?: { intervalMs?: number },
|
|
): () => void {
|
|
let stopped = false;
|
|
let lastCount = -1;
|
|
|
|
// Re-read on every push; fire onDeposits only when the set changed (grew).
|
|
const refresh = async (): Promise<void> => {
|
|
if (stopped) return;
|
|
try {
|
|
const deposits = await read(targetInbox);
|
|
const changed = deposits.length !== lastCount;
|
|
// Owner-side processing decision: did this push actually grow the
|
|
// deposit set (→ onDeposits fires, the polyfill's stand-in for
|
|
// materialization) or was it a no-op push (→ skipped)? This is the
|
|
// exact line to check for the "must reconnect an extra time" symptom:
|
|
// a push whose read still sees the OLD count means the barrier/read
|
|
// raced the write, not that watch itself failed to fire.
|
|
if (accessLogEnabled()) {
|
|
logAccess(
|
|
"READ",
|
|
targetInbox,
|
|
"inbox watch",
|
|
" → " + deposits.length + " message(s)" + (changed ? " (materializing)" : " (unchanged, skip)"),
|
|
);
|
|
}
|
|
if (!stopped && changed) {
|
|
lastCount = deposits.length;
|
|
onDeposits(deposits);
|
|
}
|
|
} catch (error) {
|
|
console.error(accessLogPrefix() + " watch read failed:", error);
|
|
}
|
|
};
|
|
|
|
// Subscribe to the inbox document: the initial State push fires the first read
|
|
// (so onDeposits fires once immediately, as before), each later Patch a re-read.
|
|
// The ownership guard runs inside `read`, so a watch on someone else's inbox
|
|
// yields nothing but logged refusals rather than their deposits.
|
|
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
|
|
return () => {
|
|
stopped = true;
|
|
unsubscribe();
|
|
};
|
|
}
|