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:
@@ -34,7 +34,7 @@
|
||||
*/
|
||||
|
||||
import { sparqlQuery } from "../surface/docs";
|
||||
import { registerUpdate } from "./register-write";
|
||||
import { readForHolder, registerUpdate } from "./register-write";
|
||||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { escapeLiteral } from "../surface/sparql";
|
||||
import { hasReadCap, isNuri, toNuri } from "../model/nuri";
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
recordInbox,
|
||||
type VirtualUserRecord,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import type { InboxScope, Nuri, NuriLike, ReadCap, Scope } from "../model/types";
|
||||
import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, Scope } from "../model/types";
|
||||
|
||||
/**
|
||||
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
|
||||
@@ -373,14 +373,21 @@ export async function myInboxes(): Promise<Nuri[]> {
|
||||
*
|
||||
* Idempotent — re-applying the same Link is a no-op, so re-processing an inbox
|
||||
* (a second tab, a reconnect) costs nothing.
|
||||
*
|
||||
* `forHolder` names WHOSE User branch this lands on when it is not the connected
|
||||
* identity's — the emulated inbox processor
|
||||
* (`emulated-verifier/inbox-processor.ts`) applying a Link for an inbox's owner during
|
||||
* someone else's session. Omitted, it is the connected identity, unchanged. Getting this
|
||||
* wrong is not a near-miss: a Link filed under the wrong holder gives one user another's
|
||||
* capability and leaves the real recipient with nothing.
|
||||
*/
|
||||
export async function addLink(cap: ReadCap): Promise<void> {
|
||||
const holder = getCurrentUser();
|
||||
export async function addLink(cap: ReadCap, forHolder?: PrincipalId): Promise<void> {
|
||||
const holder = forHolder ?? getCurrentUser();
|
||||
if (holder === null) return;
|
||||
const record = await ensureAccount(holder);
|
||||
const store = record.docPrivate;
|
||||
if (!store) return;
|
||||
if ((await readLinks()).includes(cap)) return;
|
||||
if ((await readLinks(forHolder)).includes(cap)) return;
|
||||
const s = await session();
|
||||
try {
|
||||
await registerUpdate(
|
||||
@@ -388,12 +395,25 @@ export async function addLink(cap: ReadCap): Promise<void> {
|
||||
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`,
|
||||
store,
|
||||
"addLink",
|
||||
holderRing(forHolder),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " addLink failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cap-registry key of a NAMED holder, or `undefined` for "whoever is connected".
|
||||
*
|
||||
* One rule decides both, and it has to: the registry keys a holder's caps by
|
||||
* `normalizeId` (`shared-wallet/bootstrap.ts` `capsHolder`), and the shim keys its accounts
|
||||
* the same way ({@link accountKey}). A path that named a holder any other way would file
|
||||
* into a ring the owner's own session never looks at.
|
||||
*/
|
||||
function holderRing(forHolder: PrincipalId | undefined): string | undefined {
|
||||
return forHolder === undefined ? undefined : accountKey(forHolder);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -409,24 +429,30 @@ export async function addLink(cap: ReadCap): Promise<void> {
|
||||
* restore or says it did not (2026-08-13) — an empty answer would let it report success
|
||||
* over a restore that never happened. Same reason `lookupAccount` exists beside
|
||||
* `resolveAccount`.
|
||||
*
|
||||
* `forHolder` names whose User branch is read when it is not the connected identity's —
|
||||
* see {@link addLink}, whose idempotence check is the caller that needs it.
|
||||
*/
|
||||
export async function readLinks(): Promise<ReadCap[]> {
|
||||
const holder = getCurrentUser();
|
||||
export async function readLinks(forHolder?: PrincipalId): Promise<ReadCap[]> {
|
||||
const holder = forHolder ?? getCurrentUser();
|
||||
if (holder === null) return [];
|
||||
const record = await ensureAccount(holder);
|
||||
const store = record.docPrivate;
|
||||
if (!store) return [];
|
||||
const s = await session();
|
||||
const out: ReadCap[] = [];
|
||||
await ensureRepoOpen(store);
|
||||
const ring = holderRing(forHolder);
|
||||
await ensureRepoOpen(store, ring);
|
||||
const query = `SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`;
|
||||
try {
|
||||
const res = await sparqlQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`,
|
||||
undefined,
|
||||
store,
|
||||
"readLinks",
|
||||
);
|
||||
// Two doors, one question. The connected identity's own register goes through the
|
||||
// ordinary guarded read; a NAMED holder's goes through the processor's door, which
|
||||
// asks the boundary about THAT holder's possession instead of the session's — see
|
||||
// `register-write.readForHolder`.
|
||||
const res =
|
||||
ring === undefined
|
||||
? await sparqlQuery(s.sessionId, query, undefined, store, "readLinks")
|
||||
: await readForHolder(s.sessionId, query, store, ring, "readLinks");
|
||||
for (const row of readBindings(res)) {
|
||||
const v = bindingValue(row, "c");
|
||||
if (v && hasReadCap(v)) out.push(v);
|
||||
@@ -541,8 +567,10 @@ export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
|
||||
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
|
||||
// …and the shim records that it IS an inbox, so a depositor can find that out without
|
||||
// holding anything of it. See `recordInbox`: upstream a deposit cannot address a plain
|
||||
// document at all, and this is what stands in for that impossibility.
|
||||
await recordInbox(inbox);
|
||||
// document at all, and this is what stands in for that impossibility — and WHOSE it is,
|
||||
// which is the other half of the same routing fact. The owner is `holder`: this branch is
|
||||
// past the ownership guard, so the opener owns the document the inbox belongs to.
|
||||
await recordInbox(inbox, holder);
|
||||
if (store) {
|
||||
try {
|
||||
await registerUpdate(
|
||||
|
||||
@@ -270,6 +270,22 @@ export class CapRegistry {
|
||||
return this.heldCaps().get(targetOf(nuri));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the NAMED holder hold `nuri`'s cap? The reading counterpart of {@link learnFor},
|
||||
* and it exists for the same caller: work decided for one holder that runs while ANOTHER
|
||||
* one is connected — the emulated inbox processor
|
||||
* (`emulated-verifier/inbox-processor.ts`), which drains an inbox on behalf of its owner
|
||||
* during someone else's session. Asking `capFor` there would consult the connected
|
||||
* identity's ring, which is not the ring the question is about.
|
||||
*
|
||||
* Reads without creating a ring, unlike {@link heldCaps}: asking about a holder must not
|
||||
* file one. Still no principal parameter in the model's sense — the question is "does
|
||||
* THIS ring hold the key", never "may principal P read D".
|
||||
*/
|
||||
capForHolder(key: string, nuri: Nuri): ReadCap | undefined {
|
||||
return this.heldByHolder.get(key)?.get(targetOf(nuri));
|
||||
}
|
||||
|
||||
// --- publication (the public store) -------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The inbox-processing SERVICE this deployment has not got — emulated by a timer.
|
||||
*
|
||||
* ── What is missing, and what stands in for it ────────────────────────────
|
||||
* Upstream a deposit does not wait for anybody: the broker routes the sealed message
|
||||
* (`inboxes: PubKey → RepoId`, `engine/verifier/src/verifier.rs:105,1677`) and the
|
||||
* recipient's verifier applies it when it processes the inbox
|
||||
* (`engine/verifier/src/inbox_processor.rs`). A real NextGraph deployment will have
|
||||
* something running continuously that does this; today, in this library, the ONLY thing
|
||||
* that drains an inbox is its owner connecting (`connect.connectedUser` →
|
||||
* `inbox.processInbox`), which can be hours away.
|
||||
*
|
||||
* So: a successful deposit arms a ONE-SHOT timer that processes the TARGET's inbox, twenty
|
||||
* seconds later. Alice deposits into Bob's inbox; twenty seconds later this package drains
|
||||
* Bob's inbox, while the connected session is still Alice's.
|
||||
*
|
||||
* ── That is identity usurpation, and it is deliberate ─────────────────────
|
||||
* It is only possible because one shared wallet holds every virtual identity, and it is
|
||||
* acceptable for exactly one reason: an application learns nothing false from it. What an
|
||||
* application observes is that **deposits converge** — a share becomes readable without the
|
||||
* recipient having to re-open the page — and that stays true when a real service takes over.
|
||||
* What must never leak is the MECHANISM: nothing here is published, there is no way for a
|
||||
* caller to ask for it, to name another user's inbox, or to turn it off. Publishing any of
|
||||
* that would publish a call that processes someone else's inbox, and the confidentiality of
|
||||
* the whole shared-wallet emulation rests on that being unreachable.
|
||||
*
|
||||
* ── Its accepted limit, stated rather than papered over ───────────────────
|
||||
* A timer lives in a page. If the page goes away before it fires, the deposit simply waits
|
||||
* for its owner's next connection — exactly where it waited before. There is deliberately
|
||||
* no persistence, no retry and no longer window to hide that: a stand-in for an absent
|
||||
* service should look like what it is, and the fallback (the owner connects and drains) is
|
||||
* the real path, not a repair.
|
||||
*/
|
||||
|
||||
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* How long a deposit waits before the emulated service picks it up.
|
||||
*
|
||||
* Not configurable, on purpose. A knob would be a published question — *how fast does
|
||||
* delivery converge?* — that the target does not ask a caller, because upstream the answer
|
||||
* belongs to the deployment. An application must be written so that the number does not
|
||||
* matter, and the surest way to keep it that way is to give nobody a way to change it.
|
||||
*/
|
||||
const PROCESSING_DELAY_MS = 20_000;
|
||||
|
||||
/** A drain waiting to happen: the timer, and what it will do. */
|
||||
interface Scheduled {
|
||||
handle: ReturnType<typeof setTimeout>;
|
||||
process: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* At most ONE pending drain per inbox — the coalescing this map exists for.
|
||||
*
|
||||
* Several deposits inside the window must produce ONE drain, not several: processing writes
|
||||
* what it applies (`branch-registers.addLink`, on the owner's User branch), so two runs over
|
||||
* the same inbox race each other or apply the same Link twice. The FIRST deposit arms the
|
||||
* window and later ones join it, rather than each pushing it further out — a busy inbox must
|
||||
* still converge, not be starved by its own traffic.
|
||||
*/
|
||||
const scheduled = new Map<Nuri, Scheduled>();
|
||||
|
||||
/**
|
||||
* Drains in flight, per inbox — so a run that starts while another is still going CHAINS
|
||||
* behind it instead of interleaving with it. Same reason `connect.connectedUser` drains its
|
||||
* inboxes sequentially: two concurrent passes over one queue apply the same records twice.
|
||||
*/
|
||||
const running = new Map<Nuri, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Keep a pending drain from holding a runtime open.
|
||||
*
|
||||
* A browser's `setTimeout` answers a number and has no `unref`; a Node/Bun runtime answers a
|
||||
* timer object that does, and without it a process would sit for twenty seconds after its
|
||||
* last deposit waiting on work nobody asked for. Probed rather than assumed, because the two
|
||||
* platforms genuinely differ and this library runs on both.
|
||||
*/
|
||||
function releaseFromTheEventLoop(handle: ReturnType<typeof setTimeout>): void {
|
||||
const maybe: { unref?: unknown } = handle as unknown as { unref?: unknown };
|
||||
if (typeof maybe.unref === "function") (maybe.unref as () => void).call(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a deferred drain's failure goes.
|
||||
*
|
||||
* It cannot throw: nothing awaits it — the application never asked for this work, and an
|
||||
* unhandled rejection would take down whatever runtime it is in over a drain it did not
|
||||
* request. But "nowhere" is not the alternative. A drain that swallows its failure is
|
||||
* indistinguishable from one that succeeded, which is precisely the defect family removed
|
||||
* from this package on 2026-08-13. So it goes to the package's own log stream — the same
|
||||
* `console.error` with the same `[<identity>][polyfill]` prefix that `startConnect` and
|
||||
* `inbox.watch` use for their un-awaitable failures, and it is NOT gated by the access-log
|
||||
* flag: a diagnostic may be opt-in, a failure may not.
|
||||
*/
|
||||
function reportFailure(inbox: Nuri, error: unknown): void {
|
||||
console.error(
|
||||
accessLogPrefix() + " deferred inbox processing failed for " + shortNuri(inbox) + ":",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
/** Run the drain of `inbox`, behind any run of the same inbox still in flight. */
|
||||
function fire(inbox: Nuri, process: () => Promise<void>): void {
|
||||
scheduled.delete(inbox);
|
||||
const previous = running.get(inbox) ?? Promise.resolve();
|
||||
const next = previous
|
||||
// A previous run that FAILED must not cancel this one: its failure was reported where
|
||||
// failures go, and the deposits it did not apply are exactly what this run is for.
|
||||
.catch(() => undefined)
|
||||
.then(process)
|
||||
.catch((error: unknown) => reportFailure(inbox, error))
|
||||
.then(() => {
|
||||
if (running.get(inbox) === next) running.delete(inbox);
|
||||
});
|
||||
running.set(inbox, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the emulated service for `inbox` — called by a deposit that LANDED, and by nothing
|
||||
* else. A write that failed has nothing to converge.
|
||||
*
|
||||
* `process` is what to do when the window closes; the scheduler itself knows nothing about
|
||||
* inboxes' contents, so the drain stays where the inbox's own vocabulary lives.
|
||||
*
|
||||
* Idempotent inside the window: a second deposit into the same inbox joins the pending run.
|
||||
*/
|
||||
export function scheduleInboxProcessing(inbox: Nuri, process: () => Promise<void>): void {
|
||||
if (scheduled.has(inbox)) return;
|
||||
const handle = setTimeout(() => fire(inbox, process), PROCESSING_DELAY_MS);
|
||||
releaseFromTheEventLoop(handle);
|
||||
scheduled.set(inbox, { handle, process });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every open window NOW and wait for the drains to finish.
|
||||
*
|
||||
* **The emulation's clock, not a door.** It cannot name an inbox, so it can only bring
|
||||
* forward work a deposit has already armed — the same runs, at the same holder, with the
|
||||
* same effects, minus the twenty seconds. That is what makes it usable by a suite that has
|
||||
* to observe convergence without waiting for it, and useless as a way to reach anyone's
|
||||
* inbox. Never exported from the package.
|
||||
*
|
||||
* Resolves rather than rejects, like the timer path it stands in for: a drain's failure is
|
||||
* reported where failures go, and a caller of this is not the party that asked for the work.
|
||||
*/
|
||||
export async function runScheduledInboxProcessingNow(): Promise<void> {
|
||||
for (const [inbox, entry] of [...scheduled]) {
|
||||
clearTimeout(entry.handle);
|
||||
fire(inbox, entry.process);
|
||||
}
|
||||
await Promise.all([...running.values()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every pending drain without running it — what an un-configured library must do.
|
||||
*
|
||||
* Called by `resetConfig`: once the injected `ng` is gone, a drain cannot read or write
|
||||
* anything, so leaving armed timers behind would produce a burst of failures about a
|
||||
* session that no longer exists. In-flight runs are not cancelled (nothing can un-issue a
|
||||
* write already sent); they end where they end.
|
||||
*/
|
||||
export function cancelScheduledInboxProcessing(): void {
|
||||
for (const entry of scheduled.values()) clearTimeout(entry.handle);
|
||||
scheduled.clear();
|
||||
}
|
||||
|
||||
/** Trace one completed drain — diagnostics, so gated by the access-log flag. */
|
||||
export function traceProcessed(inbox: Nuri, holder: string, applied: number): void {
|
||||
logStage(
|
||||
"DEFERRED PROCESS " + shortNuri(inbox) + " for " + holder + " → " + applied + " link(s) applied",
|
||||
);
|
||||
}
|
||||
@@ -176,19 +176,26 @@ async function syncSession(): Promise<void> {
|
||||
* Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g.
|
||||
* the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged.
|
||||
* Never throws; a failed open just leaves the read to behave as it did before.
|
||||
*
|
||||
* `holderKey` names WHOSE possession decides, when it is not the connected identity's —
|
||||
* the emulated inbox processor opening an inbox repo for its owner during someone else's
|
||||
* session (`emulated-verifier/inbox-processor.ts`). The public-store fetch is then skipped
|
||||
* on purpose: it files what it obtains for whoever is CONNECTED, so asking on behalf of
|
||||
* another holder would put a cap in the wrong ring, and a caller on that path has already
|
||||
* established what its holder holds.
|
||||
*/
|
||||
export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
||||
export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise<void> {
|
||||
if (!nuri) return;
|
||||
// A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on
|
||||
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
|
||||
// before deciding whether we may touch it, or the answer would be "no" purely for
|
||||
// want of asking, and a bare reference to a public document would never suffice.
|
||||
// Memoised and inert once the cap is held (see public-store.ts).
|
||||
await fetchReadCap(nuri);
|
||||
if (holderKey === undefined) await fetchReadCap(nuri);
|
||||
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
|
||||
// pulls its state. A user that holds no cap for it has no business asking.
|
||||
// (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.)
|
||||
if (mustNotAttempt(nuri)) return;
|
||||
if (mustNotAttempt(nuri, holderKey)) return;
|
||||
return openRepoUnguarded(nuri);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,12 +60,24 @@ import type { Nuri } from "../model/types";
|
||||
* Inert until the first cap exists (`caps.isEnforcing()`), so a consumer that never
|
||||
* touches caps keeps working. Once ANY cap has been issued the boundary applies to
|
||||
* every user, including one holding nothing: that is the isolation.
|
||||
*
|
||||
* ── Whose possession, when it is not the connected identity's ─────────────
|
||||
* `holderKey` names the ring to consult instead of the connected one. Exactly one caller
|
||||
* passes it: the emulated inbox processor (`inbox-processor.ts`), which drains an inbox on
|
||||
* behalf of its OWNER while another identity holds the session. Left out — which is every
|
||||
* other call site — the question is asked of whoever is connected, unchanged.
|
||||
*
|
||||
* It does not WIDEN the boundary: the named holder must hold the cap just the same. What it
|
||||
* fixes is which ring the question is about, so that work decided for one identity is not
|
||||
* judged against another's possession.
|
||||
*/
|
||||
export function mayReach(nuri: Nuri): boolean {
|
||||
export function mayReach(nuri: Nuri, holderKey?: string): boolean {
|
||||
const caps = getCaps();
|
||||
if (!caps.isEnforcing()) return true;
|
||||
const target = targetOf(nuri);
|
||||
return caps.capFor(target) !== undefined;
|
||||
const held =
|
||||
holderKey === undefined ? caps.capFor(target) : caps.capForHolder(holderKey, target);
|
||||
return held !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,11 +89,16 @@ export function mayReach(nuri: Nuri): boolean {
|
||||
*
|
||||
* Deliberately duplicated with rule 2 below — see {@link mustNotAttempt}. Two rules,
|
||||
* two places, one criterion: a lapse in either is caught by the other.
|
||||
*
|
||||
* `holderKey` asks the question of a NAMED ring rather than the connected one — see
|
||||
* {@link mayReach}. The refusal names it too, or a processor's refusal would read as the
|
||||
* connected identity's, which is the one thing nobody would then go and check.
|
||||
*/
|
||||
export function assertMayReach(nuri: Nuri, op: string): void {
|
||||
if (mayReach(nuri)) return;
|
||||
export function assertMayReach(nuri: Nuri, op: string, holderKey?: string): void {
|
||||
if (mayReach(nuri, holderKey)) return;
|
||||
const who = holderKey === undefined ? "the connected user" : `holder ${JSON.stringify(holderKey)}`;
|
||||
throw new Error(
|
||||
`[ng-eventually] ${op}: refused — the connected user does not hold this document's ` +
|
||||
`[ng-eventually] ${op}: refused — ${who} does not hold this document's ` +
|
||||
"cap. Naming a document does not grant access to it: a cap is looked up in what " +
|
||||
`you hold, or it was delivered to you. ${JSON.stringify(nuri)}`,
|
||||
);
|
||||
@@ -155,7 +172,9 @@ export async function assertMayWrite(nuri: Nuri, op: string): Promise<void> {
|
||||
*
|
||||
* Practically it also stops the library from asking the broker for documents it has
|
||||
* no business asking about, which is work, noise, and a leak of intent.
|
||||
*
|
||||
* `holderKey` names the ring the question is about — see {@link mayReach}.
|
||||
*/
|
||||
export function mustNotAttempt(nuri: Nuri): boolean {
|
||||
return !mayReach(nuri);
|
||||
export function mustNotAttempt(nuri: Nuri, holderKey?: string): boolean {
|
||||
return !mayReach(nuri, holderKey);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* The one door for the library's OWN register writes.
|
||||
* The doors the emulated verifier opens ON ITS OWN BEHALF — its register writes, the
|
||||
* cross-user deposit, and the read it makes for a holder other than the connected one.
|
||||
*
|
||||
* All three are acts of the verifier rather than of an application, which is why they are
|
||||
* not in `surface/docs.ts`: each treats the boundary differently from an application call,
|
||||
* and every one of those differences would be a hole if an application could reach it.
|
||||
*
|
||||
* ── Why it is a module of its own, and not a function in `surface/docs.ts` ──
|
||||
* It lived there for about ten minutes on 2026-08-07, and the contract check caught it:
|
||||
@@ -30,19 +35,64 @@ import type { Nuri } from "../model/types";
|
||||
* Still subject to `assertMayReach`: the register of a virtual user is that user's, and
|
||||
* the machinery writes it while connected as them. What this door skips is ownership,
|
||||
* nothing else. Never exported from the package.
|
||||
*
|
||||
* `holderKey` says WHOSE possession authorises the write when it is not the connected
|
||||
* identity's — the emulated inbox processor filing a Link on the inbox owner's User branch
|
||||
* during someone else's session. The guard still fires, against the named ring; see
|
||||
* {@link assertMayReach}.
|
||||
*/
|
||||
export async function registerUpdate(
|
||||
sessionId: string | number,
|
||||
query: string,
|
||||
anchor: Nuri,
|
||||
label = "registerUpdate",
|
||||
holderKey?: string,
|
||||
): Promise<void> {
|
||||
const { ng } = getConfig();
|
||||
assertMayReach(anchor, label);
|
||||
logAccess("WRITE", anchor, label, " (register)");
|
||||
assertMayReach(anchor, label, holderKey);
|
||||
logAccess("WRITE", anchor, label, " (register)" + forHolderSuffix(holderKey));
|
||||
return ng.sparql_update(sessionId, query, anchor);
|
||||
}
|
||||
|
||||
/** The access-log suffix that makes an act done for someone OTHER than the connected
|
||||
* identity visible in the trace — the log's prefix is the connected one, so without this
|
||||
* a drain of Bob's inbox reads as Alice touching it for reasons of her own. */
|
||||
function forHolderSuffix(holderKey: string | undefined): string {
|
||||
return holderKey === undefined ? "" : " (for holder " + holderKey + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* READ a document on behalf of a NAMED holder — the emulated verifier processing an inbox
|
||||
* for its owner while a DIFFERENT identity holds the session
|
||||
* (`emulated-verifier/inbox-processor.ts`).
|
||||
*
|
||||
* Not `docs.sparqlQuery`, and the difference is the whole point: that door resolves the
|
||||
* CONNECTED identity when it consults the boundary, so a drain of Bob's inbox started
|
||||
* during Alice's session would be judged against Alice's possession and refused — for a
|
||||
* document Bob holds. This one asks the same question of Bob's ring instead. It is not a
|
||||
* bypass: the named holder must hold the cap, and a holder that does not is refused exactly
|
||||
* as an application would be.
|
||||
*
|
||||
* No public-store fetch, unlike `docs.sparqlQuery`: that fetch files what it obtains for
|
||||
* whoever is CONNECTED (`public-store.fetchReadCap`), so asking here would put a cap in the
|
||||
* wrong hands. A caller on this door has already established what its holder holds.
|
||||
*
|
||||
* Never exported from the package — it reads a document the connected identity may not.
|
||||
*/
|
||||
export async function readForHolder(
|
||||
sessionId: string | number,
|
||||
query: string,
|
||||
anchor: Nuri,
|
||||
holderKey: string,
|
||||
label = "readForHolder",
|
||||
): Promise<unknown> {
|
||||
const { ng } = getConfig();
|
||||
assertMayReach(anchor, label, holderKey);
|
||||
const result = await ng.sparql_query(sessionId, query, undefined, anchor);
|
||||
logAccess("READ", anchor, label, forHolderSuffix(holderKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit into ANOTHER virtual user's inbox — the one write that legitimately
|
||||
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
|
||||
|
||||
@@ -125,6 +125,7 @@ export const P = {
|
||||
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
|
||||
exposedReadCap: `${SHIM}:exposedReadCap`, // header branch → the cap a PUBLIC store serves to anyone
|
||||
isInbox: `${SHIM}:isInbox`, // shim → this NURI IS an inbox (see `assertIsInbox`)
|
||||
inboxOwner: `${SHIM}:inboxOwner`, // shim → WHOSE inbox this NURI is (see `recordInbox`)
|
||||
} as const;
|
||||
// Fixed subject of the per-(account×scope) index document. The index doc plays
|
||||
// the role of the future store-container: it lists the NURIs of the entity
|
||||
@@ -319,6 +320,8 @@ export function resetRegistryCache(): void {
|
||||
// suite went red on a NURI collision between two files — a memo that outlives its
|
||||
// session is exactly the fault this review found elsewhere in the tests.)
|
||||
knownInboxes.clear();
|
||||
// …and so is who owns which inbox: it is read from the same index, in the same session.
|
||||
inboxOwners.clear();
|
||||
}
|
||||
|
||||
// --- SPARQL result helpers ------------------------------------------------
|
||||
@@ -798,6 +801,20 @@ const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`;
|
||||
* Written through the PHYSICAL door: which NURIs are inboxes is not one virtual user's
|
||||
* business, exactly like the account records beside it.
|
||||
*
|
||||
* ── And WHOSE inbox it is, recorded beside it ─────────────────────────────
|
||||
* Same table, same reason. Upstream the routing entry `inboxes: PubKey → RepoId`
|
||||
* (`engine/verifier/src/verifier.rs:105`) does not merely say "this is an inbox": it names
|
||||
* the REPO, and the repo's own verifier — its owner's — is what unseals and applies the
|
||||
* message (`verifier.rs:1677`). "Which inbox" and "whose" are one fact there, so they are
|
||||
* one record here. Written for BOTH kinds of inbox, and by the same call: a user's own
|
||||
* ({@link userInbox}) and a document's (`branch-registers.openDocumentInbox`), each of
|
||||
* which knows its owner at the moment it mints one.
|
||||
*
|
||||
* Two `INSERT DATA`s rather than one: the fact keyed by the index subject and the fact
|
||||
* keyed by the inbox are two subjects, and one statement carrying two subjects is a shape
|
||||
* this library writes nowhere else. Both must land before the inbox is handed back, for the
|
||||
* reason spelled out below.
|
||||
*
|
||||
* **Propagates a failed write, and only marks the session's index once the write landed.**
|
||||
* This record is what the emulation puts in place of a fact the network knows by
|
||||
* construction, and `inbox.post` refuses to deposit into anything it cannot confirm
|
||||
@@ -807,7 +824,7 @@ const INBOX_INDEX_SUBJECT = `${SHIM}:inboxes`;
|
||||
* from the only session able to see it: the one that opened the inbox believed the record
|
||||
* existed, and every other session did not.
|
||||
*/
|
||||
export async function recordInbox(nuri: Nuri): Promise<void> {
|
||||
export async function recordInbox(nuri: Nuri, ownerId: string): Promise<void> {
|
||||
const s = await session();
|
||||
const shimDoc = await resolveShimDoc();
|
||||
try {
|
||||
@@ -817,15 +834,62 @@ export async function recordInbox(nuri: Nuri): Promise<void> {
|
||||
shimDoc,
|
||||
"recordInbox",
|
||||
);
|
||||
// The inbox NURI is the subject here — the routing entry is keyed by the inbox, exactly
|
||||
// as upstream's table is keyed by its pubkey. It is a NURI (`did:ng:` + base64url and
|
||||
// `:` segments), so it carries nothing that could break out of the `<…>`.
|
||||
await physicalUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${assertNuri(nuri)}> <${P.inboxOwner}> "${escapeLiteral(ownerId)}" }`,
|
||||
shimDoc,
|
||||
"recordInbox:owner",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " recordInbox failed:", error);
|
||||
throw error;
|
||||
}
|
||||
knownInboxes.add(nuri);
|
||||
inboxOwners.set(nuri, ownerId);
|
||||
}
|
||||
|
||||
/** Inboxes this session has already confirmed — the index only ever grows. */
|
||||
const knownInboxes = new Set<Nuri>();
|
||||
/** inbox → the id of the user it belongs to, as this session has resolved it. */
|
||||
const inboxOwners = new Map<Nuri, string>();
|
||||
|
||||
/**
|
||||
* WHOSE inbox is this? The routing question, answered from the shim — see
|
||||
* {@link recordInbox}. `null` when nothing records an owner for it, which for a NURI that
|
||||
* IS an inbox means the record predates this association or its write did not land.
|
||||
*
|
||||
* Asked through the physical door, like {@link isKnownInbox} beside it: routing is not one
|
||||
* virtual user's business, and the caller is precisely the one that is not the owner.
|
||||
*
|
||||
* **Propagates a failed read.** Its caller (`emulated-verifier/inbox-processor.ts`) decides
|
||||
* on the answer whether to drain an inbox and, if so, for whom — so "the broker could not
|
||||
* answer" and "nobody owns this" must not arrive as the same value. Answering `null` on a
|
||||
* failed read would file a deposit under nobody and say nothing.
|
||||
*/
|
||||
export async function inboxOwner(nuri: Nuri): Promise<string | null> {
|
||||
const memo = inboxOwners.get(nuri);
|
||||
if (memo !== undefined) return memo;
|
||||
const s = await session();
|
||||
const shimDoc = await resolveShimDoc();
|
||||
const res = await physicalQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?u WHERE { <${assertNuri(nuri)}> <${P.inboxOwner}> ?u }`,
|
||||
undefined,
|
||||
shimDoc,
|
||||
"inboxOwner",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
const id = bindingValue(row, "u");
|
||||
if (id) {
|
||||
inboxOwners.set(nuri, id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `nuri` an inbox? Asked of the shim, through the physical door — a depositor is not
|
||||
@@ -1031,7 +1095,7 @@ export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
|
||||
// this scope yet. THAT is what mints one — and the three writes it takes all have to
|
||||
// land before anyone is handed the result.
|
||||
const doc = await createDoc();
|
||||
await recordInbox(doc);
|
||||
await recordInbox(doc, id);
|
||||
try {
|
||||
await physicalUpdate(
|
||||
s.sessionId,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { resetPublicStoreFetches } from "../emulated-verifier/public-store";
|
||||
import { setAccessLog } from "./access-log";
|
||||
import { inspectOutbox } from "./outbox-log";
|
||||
import { startConnect } from "../emulated-verifier/connect";
|
||||
import { cancelScheduledInboxProcessing } from "../emulated-verifier/inbox-processor";
|
||||
import { resetSharedWalletSession, sharedWalletSession } from "./session";
|
||||
|
||||
/**
|
||||
@@ -217,6 +218,10 @@ export function getConfig(): EventuallyConfig {
|
||||
export function resetConfig(): void {
|
||||
cfg = null;
|
||||
currentUser = null;
|
||||
// Any deferred inbox drain still waiting goes with it: it would fire into a library that
|
||||
// no longer has an `ng` to read or write with, and report a failure about a session that
|
||||
// no longer exists. See `emulated-verifier/inbox-processor.ts`.
|
||||
cancelScheduledInboxProcessing();
|
||||
// The hand-over goes with it, for the same reason: it is made of the config's injected
|
||||
// `init`, so leaving it behind would let a revived barrier delegate to the PREVIOUS
|
||||
// application's SDK.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* The inbox-processing service this deployment has not got, emulated by a timer.
|
||||
*
|
||||
* A deposit used to sit in an inbox until its owner next connected — hours, on a page
|
||||
* nobody reloads. `emulated-verifier/inbox-processor.ts` arms a one-shot timer on the
|
||||
* TARGET's inbox instead, so the deposit converges while the depositor's session is still
|
||||
* the one in the page. That is identity usurpation, and every test here is about the two
|
||||
* things that make it acceptable: the result lands in the OWNER's space and nowhere else,
|
||||
* and the depositor's session comes out of it exactly as it went in.
|
||||
*
|
||||
* ── What the actors hand each other, and what they do not ─────────────────
|
||||
* Nobody is handed an inbox address. Each depositor calls `inbox.share(doc, toUser)` and
|
||||
* names a document and a person, which is all an application has; the address is resolved
|
||||
* inside. The one NURI the TEST resolves for itself (`userInbox`) is used only to count
|
||||
* reads and to inspect what landed — never given to an actor.
|
||||
*
|
||||
* ── Time is closed, not slept through ────────────────────────────────────
|
||||
* `runScheduledInboxProcessingNow()` fires the windows a deposit has ALREADY armed — the
|
||||
* same runs, the same holder, the same effects, minus the twenty seconds. It cannot name an
|
||||
* inbox, so it fabricates nothing: every state asserted below is one the timer produces on
|
||||
* its own.
|
||||
*/
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import * as polyfill from "../src/index";
|
||||
import { configure } from "../src/index";
|
||||
import {
|
||||
configureStoreRegistry,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
resetConfig,
|
||||
resetStoreRegistry,
|
||||
setCurrentUser,
|
||||
getCurrentUser,
|
||||
} from "../src/shared-wallet/bootstrap";
|
||||
import {
|
||||
createEntityDoc,
|
||||
resetRegistryCache,
|
||||
resolveAccount,
|
||||
resolveWriteGraph,
|
||||
userInbox,
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import { connectedUser } from "../src/emulated-verifier/connect";
|
||||
import {
|
||||
cancelScheduledInboxProcessing,
|
||||
runScheduledInboxProcessingNow,
|
||||
} from "../src/emulated-verifier/inbox-processor";
|
||||
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { share } from "../src/surface/inbox";
|
||||
import { readUnion } from "../src/surface/read-model";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-deferred", privateStoreId: "PRIV-DEFERRED" };
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
const SECRET = "urn:e2e:secret";
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** What the broker refuses to do. Mutable after `inject`, so a test builds a healthy world
|
||||
* first and breaks only the one call it is about — the fault is the broker's, never a
|
||||
* reach into the library to make one of its own functions reject. */
|
||||
interface Faults {
|
||||
/** The anchored read of THIS inbox document's deposits throws. */
|
||||
depositsRead: Nuri | null;
|
||||
}
|
||||
|
||||
function unescapeLiteral(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "\\" && i + 1 < s.length) {
|
||||
const next = s[++i];
|
||||
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
||||
} else out += s[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored per-doc read. */
|
||||
function makeFakeNg(faults: Faults) {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
if (!anchor) return undefined;
|
||||
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
|
||||
if (del) {
|
||||
const [s0, p0] = [del[1]!, del[2]!];
|
||||
for (let i = quads.length - 1; i >= 0; i--) {
|
||||
const q = quads[i]!;
|
||||
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g: anchor, s, p, o });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
const byPred = (pred: string, v: string) => ({
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === anchor && q.p === pred)
|
||||
.map((q) => ({ [v]: { value: q.o } })),
|
||||
},
|
||||
});
|
||||
if (query.includes(`<${SHIM}:shimDoc>`)) return byPred(`${SHIM}:shimDoc`, "shimDoc");
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const only = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (only !== null && q.s !== only) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
||||
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
||||
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
||||
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
return {
|
||||
results: {
|
||||
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.includes(`<${INBOX}:payload>`)) {
|
||||
// The one failure this fake can inject, at the platform boundary: the anchored read
|
||||
// of an inbox document throws, exactly as the engine hard-errors for a repo it
|
||||
// cannot resolve (`RepoNotFound`, `engine/verifier/src/request_processor.rs:264`).
|
||||
if (faults.depositsRead !== null && faults.depositsRead === anchor) {
|
||||
throw new Error("RepoNotFound");
|
||||
}
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
||||
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
||||
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
return {
|
||||
results: {
|
||||
bindings: [...bySubject.values()]
|
||||
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
||||
.map((r) => {
|
||||
const row: Record<string, { value: string }> = {
|
||||
payload: { value: r.payload! },
|
||||
ts: { value: r.ts! },
|
||||
};
|
||||
if (r.from !== undefined) row.from = { value: r.from };
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.includes(`<${SHIM}:inboxCap>`)) return byPred(`${SHIM}:inboxCap`, "c");
|
||||
if (query.includes(`<${SHIM}:inboxAddress>`)) return byPred(`${SHIM}:inboxAddress`, "a");
|
||||
if (query.includes(`<${SHIM}:readCap>`)) return byPred(`${SHIM}:readCap`, "c");
|
||||
if (query.includes(`<${SHIM}:link>`)) return byPred(`${SHIM}:link`, "c");
|
||||
if (query.includes(`${SHIM}:isInbox`)) return byPred(`${SHIM}:isInbox`, "i");
|
||||
// Shim `inboxOwner` SELECT — WHOSE inbox a NURI is, the emulated routing entry the
|
||||
// deferred service resolves its holder from. Keyed by the INBOX (the subject), as
|
||||
// upstream's `inboxes: PubKey → RepoId` is keyed by the inbox's pubkey.
|
||||
if (query.includes(`<${SHIM}:inboxOwner>`)) {
|
||||
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:inboxOwner>/);
|
||||
const subj = sm ? sm[1]! : null;
|
||||
return {
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxOwner` && q.s === subj)
|
||||
.map((q) => ({ u: { value: q.o } })),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.includes(`${SHIM}:docInbox`)) {
|
||||
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
|
||||
const pred = pm ? pm[1]! : "";
|
||||
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
|
||||
const subj = sm ? sm[1]! : null;
|
||||
return {
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
|
||||
.map((q) => ({ d: { value: q.o } })),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query.includes(`<${SHIM}:exposedReadCap>`)) return byPred(`${SHIM}:exposedReadCap`, "c");
|
||||
if (query.includes(`<${SHIM}:contains>`)) return byPred(`${SHIM}:contains`, "e");
|
||||
return {
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === anchor)
|
||||
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { doc_create, sparql_update, sparql_query, quads };
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof makeFakeNg>;
|
||||
let faults: Faults;
|
||||
|
||||
function inject() {
|
||||
faults = { depositsRead: null };
|
||||
fake = makeFakeNg(faults);
|
||||
configure({ ng: fake as never, useShape: (() => {}) as never });
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cancelScheduledInboxProcessing();
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
/**
|
||||
* Bob has been in the page once — which is what makes him someone a share can NAME
|
||||
* (`inbox.share` refuses a recipient nobody has ever signed in as). Nothing of his crosses
|
||||
* to the depositors afterwards: they name the person, never his address.
|
||||
*/
|
||||
async function bobSignsInOnce(): Promise<void> {
|
||||
setCurrentUser("bob");
|
||||
await resolveWriteGraph("bob", "protected");
|
||||
setCurrentUser(null);
|
||||
}
|
||||
|
||||
/** Write one triple into `doc`, as the consumer's write path would. */
|
||||
async function write(doc: Nuri, p: string, o: string): Promise<void> {
|
||||
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
|
||||
}
|
||||
|
||||
/** The values `p` carries in `docs`, as the CURRENT holder reads them. */
|
||||
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
|
||||
const subjects = await readUnion(docs);
|
||||
return subjects.flatMap((s) => s.props[p] ?? []);
|
||||
}
|
||||
|
||||
/** How many times an inbox document's deposits have been read — one per drain. */
|
||||
function depositReadsOf(inbox: Nuri): number {
|
||||
return fake.sparql_query.mock.calls.filter(
|
||||
(c) => c[3] === inbox && String(c[1]).includes(`${INBOX}:payload`),
|
||||
).length;
|
||||
}
|
||||
|
||||
/** The Links durably filed on a user's User branch — the emulated `AddLink` records. */
|
||||
async function linksFiledFor(id: string): Promise<string[]> {
|
||||
const record = await resolveAccount(id);
|
||||
const store = record?.docPrivate;
|
||||
if (!store) return [];
|
||||
return fake.quads.filter((q) => q.g === store && q.p === `${SHIM}:link`).map((q) => q.o);
|
||||
}
|
||||
|
||||
// --- the drain acts for the OWNER, and touches nothing of the depositor's ---
|
||||
|
||||
test("a deposit is drained for the inbox's OWNER, while the depositor still holds the session", async () => {
|
||||
inject();
|
||||
await bobSignsInOnce();
|
||||
|
||||
// Carol shares one of her documents with Bob. Her deposit is in Bob's inbox, unapplied.
|
||||
setCurrentUser("carol");
|
||||
const carolDoc = await createEntityDoc("carol", "protected");
|
||||
await write(carolDoc, SECRET, "carol's content");
|
||||
await share(carolDoc, "bob");
|
||||
|
||||
// Alice, later, shares one of hers with Bob too. Neither of them is ever handed an
|
||||
// address: `share` names a document and a person.
|
||||
setCurrentUser("alice");
|
||||
const aliceDoc = await createEntityDoc("alice", "protected");
|
||||
await write(aliceDoc, SECRET, "alice's content");
|
||||
await share(aliceDoc, "bob");
|
||||
|
||||
// Before the window closes: nothing has been applied for anyone.
|
||||
const caps = getCaps();
|
||||
expect(caps.capForHolder("bob", carolDoc)).toBeUndefined();
|
||||
expect(caps.capForHolder("bob", aliceDoc)).toBeUndefined();
|
||||
|
||||
// The service picks the deposits up. Alice is the connected identity throughout.
|
||||
await runScheduledInboxProcessingNow();
|
||||
|
||||
// The session is untouched — and this is the assertion that matters, because the drain
|
||||
// read an inbox holding a Link for CAROL's document. Had it filed against whoever was
|
||||
// connected, Alice would now hold a capability nobody gave her.
|
||||
expect(getCurrentUser()).toBe("alice");
|
||||
expect(caps.capForHolder("alice", carolDoc)).toBeUndefined();
|
||||
expect(await readValues([carolDoc], SECRET)).toEqual([]);
|
||||
expect(await linksFiledFor("alice")).toEqual([]);
|
||||
|
||||
// …and both Links landed in Bob's space instead.
|
||||
expect(caps.capForHolder("bob", carolDoc)).toBeDefined();
|
||||
expect(caps.capForHolder("bob", aliceDoc)).toBeDefined();
|
||||
expect((await linksFiledFor("bob")).length).toBe(2);
|
||||
});
|
||||
|
||||
test("what the drain filed for the owner is DURABLE: he restores it with his inbox emptied", async () => {
|
||||
inject();
|
||||
await bobSignsInOnce();
|
||||
|
||||
setCurrentUser("alice");
|
||||
const aliceDoc = await createEntityDoc("alice", "protected");
|
||||
await write(aliceDoc, SECRET, "the-protected-content");
|
||||
await share(aliceDoc, "bob");
|
||||
await runScheduledInboxProcessingNow();
|
||||
|
||||
// EMPTY the inbox, as a consumed queue would be, and drop every in-memory cap — so what
|
||||
// Bob recovers below can only have come from his own User branch, where the drain filed
|
||||
// it. Without that, he would simply be draining the queue himself on connection, which
|
||||
// is the behaviour this service exists to get ahead of.
|
||||
const bobInbox = await userInbox("bob", "protected");
|
||||
for (let k = fake.quads.length - 1; k >= 0; k--) {
|
||||
if (fake.quads[k]!.g === bobInbox) fake.quads.splice(k, 1);
|
||||
}
|
||||
resetCaps();
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private"); // re-arms the emulation: a cap exists again
|
||||
|
||||
setCurrentUser("bob");
|
||||
expect(getCaps().capFor(aliceDoc)).toBeUndefined(); // he holds nothing yet
|
||||
await connectedUser();
|
||||
expect(getCaps().capFor(aliceDoc)).toBeDefined();
|
||||
expect(await readValues([aliceDoc], SECRET)).toEqual(["the-protected-content"]);
|
||||
});
|
||||
|
||||
// --- coalescing --------------------------------------------------------------
|
||||
|
||||
test("several deposits inside the window produce exactly ONE drain, and lose nothing", async () => {
|
||||
inject();
|
||||
await bobSignsInOnce();
|
||||
|
||||
setCurrentUser("alice");
|
||||
const docs: Nuri[] = [];
|
||||
for (const marker of ["one", "two", "three"]) {
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
await write(doc, SECRET, marker);
|
||||
docs.push(doc);
|
||||
}
|
||||
const bobInbox = await userInbox("bob", "protected");
|
||||
const readsBefore = depositReadsOf(bobInbox);
|
||||
|
||||
for (const doc of docs) await share(doc, "bob");
|
||||
// The window is open, not running: depositing processes nothing by itself.
|
||||
expect(depositReadsOf(bobInbox)).toBe(readsBefore);
|
||||
|
||||
await runScheduledInboxProcessingNow();
|
||||
|
||||
// ONE pass over the queue for three deposits. Two passes would race each other on the
|
||||
// very writes the pass makes (`addLink`, on the owner's User branch).
|
||||
expect(depositReadsOf(bobInbox)).toBe(readsBefore + 1);
|
||||
// …and coalescing loses nothing: the one pass reads the whole queue.
|
||||
for (const doc of docs) expect(getCaps().capForHolder("bob", doc)).toBeDefined();
|
||||
expect((await linksFiledFor("bob")).length).toBe(3);
|
||||
});
|
||||
|
||||
test("a deposit after the window has closed arms a NEW one", async () => {
|
||||
inject();
|
||||
await bobSignsInOnce();
|
||||
|
||||
setCurrentUser("alice");
|
||||
const first = await createEntityDoc("alice", "protected");
|
||||
const second = await createEntityDoc("alice", "protected");
|
||||
const bobInbox = await userInbox("bob", "protected");
|
||||
const readsBefore = depositReadsOf(bobInbox);
|
||||
|
||||
await share(first, "bob");
|
||||
await runScheduledInboxProcessingNow();
|
||||
await share(second, "bob");
|
||||
await runScheduledInboxProcessingNow();
|
||||
|
||||
expect(depositReadsOf(bobInbox)).toBe(readsBefore + 2);
|
||||
expect(getCaps().capForHolder("bob", second)).toBeDefined();
|
||||
});
|
||||
|
||||
// --- failure ----------------------------------------------------------------
|
||||
|
||||
test("a drain that fails says so in the package's log, and rejects into nobody", async () => {
|
||||
inject();
|
||||
await bobSignsInOnce();
|
||||
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
await write(doc, SECRET, "content");
|
||||
const bobInbox = await userInbox("bob", "protected");
|
||||
|
||||
// The deposit itself must still succeed — what breaks is the drain that follows it.
|
||||
await share(doc, "bob");
|
||||
faults.depositsRead = bobInbox;
|
||||
|
||||
const errors: string[] = [];
|
||||
const realError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
errors.push(args.map((a) => String(a)).join(" "));
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
// Resolves. The application never asked for this work, so it must not be handed a
|
||||
// rejection for it — and an unhandled one would take the runtime down.
|
||||
await expect(runScheduledInboxProcessingNow()).resolves.toBeUndefined();
|
||||
} finally {
|
||||
console.error = realError;
|
||||
}
|
||||
|
||||
const reported = errors.filter((line) => /deferred inbox processing failed/.test(line));
|
||||
expect(reported.length).toBe(1);
|
||||
// Prefixed by the CONNECTED identity, like every other polyfill-layer line — which is
|
||||
// what makes a drain running under someone else's session legible in a live trace.
|
||||
expect(reported[0]).toContain("[alice][polyfill]");
|
||||
expect(reported[0]).toContain("RepoNotFound");
|
||||
|
||||
// Nothing was applied on a drain that could not read, and nothing was applied for the
|
||||
// depositor either: a failure leaves the deposit where it was, waiting for its owner.
|
||||
expect(getCaps().capForHolder("bob", doc)).toBeUndefined();
|
||||
expect(await linksFiledFor("alice")).toEqual([]);
|
||||
});
|
||||
|
||||
test("an inbox the shim records no owner for is reported, not drained for whoever is connected", async () => {
|
||||
inject();
|
||||
await bobSignsInOnce();
|
||||
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
await write(doc, SECRET, "content");
|
||||
const bobInbox = await userInbox("bob", "protected");
|
||||
await share(doc, "bob");
|
||||
|
||||
// A wallet written before inboxes carried a routing entry — the record simply is not
|
||||
// there. Drained for the connected identity, this is exactly how a capability addressed
|
||||
// to Bob ends up in Alice's hands, so it has to refuse and say so.
|
||||
for (let k = fake.quads.length - 1; k >= 0; k--) {
|
||||
if (fake.quads[k]!.p === `${SHIM}:inboxOwner`) fake.quads.splice(k, 1);
|
||||
}
|
||||
resetRegistryCache(); // …and the session's memo of it goes too
|
||||
|
||||
const errors: string[] = [];
|
||||
const realError = console.error;
|
||||
console.error = ((...args: unknown[]) => {
|
||||
errors.push(args.map((a) => String(a)).join(" "));
|
||||
}) as typeof console.error;
|
||||
try {
|
||||
await runScheduledInboxProcessingNow();
|
||||
} finally {
|
||||
console.error = realError;
|
||||
}
|
||||
|
||||
expect(errors.some((l) => /records no owner for this inbox/.test(l))).toBe(true);
|
||||
expect(getCaps().capForHolder("alice", doc)).toBeDefined(); // hers, she created it
|
||||
expect(await linksFiledFor("alice")).toEqual([]); // and nothing of Bob's was filed for her
|
||||
expect(depositReadsOf(bobInbox)).toBe(0); // the queue was never even read
|
||||
});
|
||||
|
||||
// --- the surface ------------------------------------------------------------
|
||||
|
||||
test("nothing about the deferred service reaches the published surface", () => {
|
||||
// The published entry, as an application really sees it at runtime. A "process now", a
|
||||
// delay knob, or any way to name another user's inbox would show up here — and each of
|
||||
// them would publish a call that processes somebody else's queue, which is the one thing
|
||||
// the shared-wallet emulation cannot survive.
|
||||
expect(Object.keys(polyfill).sort()).toEqual([
|
||||
"configure",
|
||||
"docChangeType",
|
||||
"docs",
|
||||
"ensureIdentity",
|
||||
"inbox",
|
||||
"init",
|
||||
"initNg",
|
||||
"ng",
|
||||
"readUnion",
|
||||
"storeRegistry",
|
||||
"subscribeDoc",
|
||||
"subscribeDocs",
|
||||
"useShape",
|
||||
"watchShape",
|
||||
]);
|
||||
expect(Object.keys(polyfill.inbox).sort()).toEqual([
|
||||
"post",
|
||||
"postToDocument",
|
||||
"processInbox",
|
||||
"read",
|
||||
"readForDocument",
|
||||
"readSynced",
|
||||
"share",
|
||||
"watch",
|
||||
]);
|
||||
expect(Object.keys(polyfill.docs).sort()).toEqual([
|
||||
"docCreate",
|
||||
"sparqlQuery",
|
||||
"sparqlUpdate",
|
||||
]);
|
||||
expect(Object.keys(polyfill.storeRegistry).sort()).toEqual([
|
||||
"createEntityDoc",
|
||||
"listMyEntityDocs",
|
||||
"openDocumentInbox",
|
||||
"resolveScopeGraph",
|
||||
"resolveWriteGraph",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user