0d9e2bbe97
Second tour adverse sur le lot C. Six trouvailles, dont trois sur ce que je venais de livrer. **Le correctif de `createEntityDoc` reproduisait le défaut qu'il annonçait avoir fermé.** Il levait sur la PREMIÈRE écriture de registre en échec. Or : - lever sur le listing sautait l'écriture de la clé — détruisant le chemin de récupération que le commentaire d'à côté décrit explicitement (« la clé doit rester récupérable même si le listing a échoué »), et laissant le document orphelin ; - lever sur la clé laissait le document LISTÉ sans clé — précisément l'état « se lit vide pour toujours » que je prétendais empêcher, en pire, puisque l'appelant n'a même plus sa référence. Les deux écritures sont désormais tentées, ce qui atterrit reste, et l'échec est rapporté après en nommant la moitié manquante. **Le refus de `share` reposait sur une valeur qui confond absence et ignorance.** `resolveAccount` avale toute erreur de lecture et rend `null`, si bien qu'un incident réseau faisait répondre « personne ne s'est connecté sous ce nom » à propos de quelqu'un qui existe. `lookupAccount` propage désormais l'erreur ; `resolveAccount` reste la forme tolérante que tous les autres appelants veulent. **J'avais livré ce comportement sans un seul test.** `test/app-surface.test.ts` en ajoute huit, tous sur ce qu'un APPELANT voit : `ensureIdentity` rend l'identité, un appel de placement avant connexion nomme l'erreur, le placement agit comme l'utilisateur connecté, `share` refuse un nom inventé mais laisse remonter une panne, et une création à moitié écrite échoue en disant quelle moitié — dont le cas « le listing a échoué, la clé est quand même là ». En écrivant ces tests j'ai refait dans leur faux la faute que cette revue a corrigée ailleurs : ignorer le sujet dans la requête de compte, ce qui rendait le dossier d'un autre utilisateur. Deux des huit échouaient pour cette raison, sans rapport avec le code. **Et la documentation contredisait le code livré dans le même commit** : le README enseignait encore `createEntityDoc(me, "protected")` — en JS la portée devient `"alice"` — et le contrat déclarait `Nuri` là où le code et la feuille disent `NuriLike`. 197 tests unitaires, e2e 40/40 et applicatif 12/12.
644 lines
31 KiB
TypeScript
644 lines
31 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, lookupAccount } 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`). When "no inbox" is an expected
|
|
* case for the caller, catch it — there is deliberately no published way to ask an
|
|
* address in advance, because an application must name a document or a person, never an
|
|
* inbox.
|
|
*/
|
|
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.)
|
|
// The recipient must EXIST. `userInbox` provisions on first sight, so sharing with a
|
|
// name nobody has signed in as used to succeed silently: it minted that name's three
|
|
// stores and an inbox, and the cap landed where nobody will ever look. A mistyped
|
|
// recipient is the ordinary case, and it produced no error at all.
|
|
//
|
|
// Upstream you cannot address a name you invented: a deposit is sealed to an inbox
|
|
// PUBKEY (`InboxMsg::new`, `engine/net/src/types.rs:4299`) that reached you through an
|
|
// inbound `ContactDetails` — someone has to have reached you first. Refusing is the
|
|
// faithful behaviour; provisioning was the invention.
|
|
//
|
|
// `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read
|
|
// that FAILED as well as for one that found nothing, so it would have told a user
|
|
// "nobody has signed in as bob" because a query timed out. A refusal must not be
|
|
// built on a value that conflates absence with ignorance.
|
|
if ((await lookupAccount(toUser)) === null) {
|
|
throw new Error(
|
|
`[ng-eventually] inbox.share: no such recipient — nobody has signed in as ` +
|
|
`${JSON.stringify(toUser)}. Sharing does not create the person you share with.`,
|
|
);
|
|
}
|
|
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 `ensureIdentity()` 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");
|
|
// WHO this read belongs to, captured with the guard that authorised it — see the note
|
|
// beside the filing below, and `caps.holderKey`.
|
|
const owner = getCurrentUser();
|
|
const ownerKey = getCaps().holderKey();
|
|
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[] = [];
|
|
// 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
|
|
// code, not a leak anyone reproduced — see `caps.holderKey`.
|
|
//
|
|
// Abandoning is the faithful answer: upstream an inbox is processed by ITS owner's
|
|
// verifier, and switching user is another session. Nothing is lost — an inbox is not
|
|
// consumed by reading, so the next connection under the right identity files them.
|
|
const stillOwner = getCurrentUser() === owner;
|
|
for (const d of deposits) {
|
|
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);
|
|
// 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(
|
|
targetInboxLike: NuriLike,
|
|
onDeposits: (deposits: Deposit[]) => void,
|
|
_opts?: { intervalMs?: number },
|
|
): () => void {
|
|
// Permissive in, precise out — like every other public entry. It took a bare `Nuri`
|
|
// until 2026-08-10, which contradicted the very reason no type guard is published.
|
|
const targetInbox = toNuri(targetInboxLike, "inbox.watch");
|
|
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();
|
|
};
|
|
}
|