fix: une connexion qui échoue ne se résout plus comme une réussite
connectedUser() restaure les capacités reçues et draine les inbox. Aucun de ses chemins ne pouvait échouer : un broker injoignable rendait exactement la même promesse qu'un succès complet. L'application affichait alors des listes vides, et rien nulle part ne disait que la restauration n'avait pas eu lieu. L'énumération m'avait échappé sur deux points, l'agent les a établis. resolveAccount attrapait tout et rendait null : une lecture qui ÉCHOUAIT ressortait donc comme « ce compte n'existe pas ». L'échec était déguisé en absence — c'est la racine du partage cassé trouvé ce matin, dont on n'avait traité que le déclencheur. lookupAccount le remplace : le silence n'est plus possible que sur une absence VÉRIFIÉE. Et readLinks comme readInboxCapPairs avalaient leur propre erreur en rendant un tableau vide, un étage sous le catch de connect. Une panne n'y parvenait même pas. Elles relèvent désormais. La règle est simple : tout échec remonte, seul « il n'y avait rien à faire » se résout en silence. Ce qui reste silencieux — aucun détenteur, compte réellement absent, identité changée en route — l'est parce que c'est la vérité. Le piège consigné hier est fermé par là même : une exécution qui ne peut pas répondre rejette, et ceux qui la rejoignent en héritent. Sa feuille est supprimée, la question qu'elle laissait ouverte étant tranchée. Trois fixtures de test utilisaient un ng vide — une forme qu'aucune plateforme ne présente, dont le TypeError était mangé par le catch. Remplacées par un broker au portefeuille vide. Aucune assertion modifiée.
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
# Doc-debt — app-contract
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED packages/polyfill/src/surface/lifecycle.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
- TOUCHED docs/api-contract.md @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
@@ -1,10 +0,0 @@
|
||||
# Doc-debt — sign-in
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED packages/polyfill/src/shared-wallet/bootstrap.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
- TOUCHED packages/polyfill/src/shared-wallet/access-gate.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
- TOUCHED packages/polyfill/src/surface/lifecycle.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
- TOUCHED packages/polyfill/src/shared-wallet/session.ts @2026-08-13 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
@@ -29,4 +29,3 @@ The whole thing is scaffolding: upstream, a person opens **their** wallet, it co
|
||||
|
||||
- `knowledge_how-a-user-gets-in` — the flow end to end, and which layer owns each step.
|
||||
- `knowledge_settling-is-not-connecting` — the split, and the cycle that forces it.
|
||||
- `caveat_connect-memoizes-an-abandoned-run` — a live trap, unfixed.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: A connection attempt that finds no account returns silently and leaves its abandoned run joinable, so a later legitimate attempt does nothing
|
||||
last_checked: 2026-08-11
|
||||
---
|
||||
|
||||
# A connection run that gives up stays joinable
|
||||
|
||||
In `emulated-verifier/connect.ts`, a connection attempt that finds no account for the acting identity **returns silently** — no restore, no drain — and it has already registered itself as the in-flight run for that identity. Any concurrent caller joins that run and resolves having done nothing.
|
||||
|
||||
That is exactly how sharing broke once (see `knowledge_settling-is-not-connecting`): the trigger was fixed, this mechanism was not.
|
||||
|
||||
**It is still live.** Any future caller that starts connecting before its session can answer will reproduce the same silence: work skipped, promise resolved, no error anywhere. The trap is documented on the setter that fires the connection, but a comment is not a mechanism — nothing prevents it.
|
||||
|
||||
Two things make it nasty: giving up is indistinguishable from succeeding at the call site, and the memoization spreads the damage to callers that did nothing wrong.
|
||||
|
||||
To validate the state of this: look at what the connection routine does when the account lookup answers nothing, and at whether the in-flight registration happens before or after that decision.
|
||||
|
||||
Fixing it properly means deciding what an attempt that cannot answer should be — a failure, a retry, or something never memoized — and that decision has its own blast radius, which is why it was deliberately left out of the fix that closed the symptom.
|
||||
@@ -294,6 +294,14 @@ function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
|
||||
return `${doc} ${inbox}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The (document, inbox) pairs this user may read — the emulated `AddInboxCap` records.
|
||||
*
|
||||
* **Propagates a failed read**, for the reason spelled out on {@link readLinks}: this is
|
||||
* half of the drain list `connect.connectedUser` works from, and an empty answer would
|
||||
* make a document's queue silently un-drained — a share that was delivered and never
|
||||
* applied, with nothing to see anywhere.
|
||||
*/
|
||||
export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null) return [];
|
||||
@@ -318,6 +326,7 @@ export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nur
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " readInboxCaps failed:", error);
|
||||
throw error;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -383,6 +392,14 @@ export async function addLink(cap: ReadCap): Promise<void> {
|
||||
/**
|
||||
* The caps this user has received and applied — the User branch read back. Called
|
||||
* at connection to restore what was shared with them, without touching any inbox.
|
||||
*
|
||||
* **Propagates a failed read** rather than answering `[]`. Empty and unreadable are the
|
||||
* same value here and could not be more different: "nobody has shared anything with me"
|
||||
* against "everything shared with me is invisible and nothing said so". The caller that
|
||||
* matters is `connect.connectedUser`, whose whole contract is that it either did the
|
||||
* 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`.
|
||||
*/
|
||||
export async function readLinks(): Promise<ReadCap[]> {
|
||||
const holder = getCurrentUser();
|
||||
@@ -407,6 +424,7 @@ export async function readLinks(): Promise<ReadCap[]> {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " readLinks failed:", error);
|
||||
throw error;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
*/
|
||||
|
||||
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { resolveAccount } from "../shared-wallet/account-registry";
|
||||
import { lookupAccount } from "../shared-wallet/account-registry";
|
||||
import { accessLogPrefix } from "../shared-wallet/access-log";
|
||||
import { myInboxes, readLinks } from "./branch-registers";
|
||||
import { processInbox } from "../surface/inbox";
|
||||
|
||||
@@ -45,10 +46,32 @@ const inFlight = new Map<string, Promise<void>>();
|
||||
/**
|
||||
* Restore and drain for the connected user. Idempotent per user while in flight.
|
||||
*
|
||||
* Tolerant by construction: it runs on every `setCurrentUser`, including in
|
||||
* contexts where the store registry was never configured (unit tests, an app
|
||||
* setting the identity before the session resolves). Those simply have nothing to
|
||||
* restore, and a failure here must never break connecting.
|
||||
* ── It either DID THE WORK or SAYS IT DID NOT ─────────────────────────────
|
||||
* `ensureIdentity()` awaits this, and the contract it publishes is that the call
|
||||
* *completes the connection work it starts*. So the one thing this must never do is
|
||||
* resolve after failing: an application then renders, shows empty lists, and nothing
|
||||
* anywhere says the restore never happened. What a person sees is "the app works but the
|
||||
* documents shared with me never appear" — which reads like a permission decision and is
|
||||
* a swallowed error. It swallowed **every** failure until 2026-08-13, offline broker
|
||||
* included, and that is what this shape replaces.
|
||||
*
|
||||
* The rule, one line: **any failure rejects; only "there was nothing to do" resolves
|
||||
* quietly.** Exactly three cases are nothing to do, and none of them is a failure:
|
||||
*
|
||||
* - **no identity connected** — anonymous holds nothing and owns no inbox;
|
||||
* - **the identity has no account yet** — nothing to restore, no queue to drain, and
|
||||
* connecting must not create one (see the note in `run` below). A GENUINE absence:
|
||||
* `lookupAccount` is used precisely so a lookup that could not ANSWER throws instead
|
||||
* of borrowing that silence;
|
||||
* - **the identity changed under the run** — abandoning is correct, and the next
|
||||
* connection picks the work up (see `stillConnected`).
|
||||
*
|
||||
* A restore that did not happen makes every shared document invisible, and an inbox that
|
||||
* was not drained leaves a share unapplied. Both are the same event for the person using
|
||||
* the application, so both are failures — there is no third category here.
|
||||
*
|
||||
* Not fixed with a retry, a timeout or a flag on purpose: deciding *what to do* about a
|
||||
* broker that cannot answer belongs to the caller, and it can only decide if it is told.
|
||||
*/
|
||||
export async function connectedUser(): Promise<void> {
|
||||
const holder = getCurrentUser();
|
||||
@@ -75,13 +98,20 @@ export async function connectedUser(): Promise<void> {
|
||||
const holderKey = getCaps().holderKey();
|
||||
|
||||
const run = (async (): Promise<void> => {
|
||||
try {
|
||||
// Connecting must not PROVISION. `ensureAccount` would create the user on
|
||||
// first sight, so connecting an identity that does not exist yet would
|
||||
// silently mint its stores and their caps — arming the whole emulation as a
|
||||
// background side effect, at a moment nothing controls. An account that does
|
||||
// not exist has nothing to restore and no inbox to drain.
|
||||
if ((await resolveAccount(holder)) === null) return;
|
||||
//
|
||||
// `lookupAccount`, not `resolveAccount`: the tolerant form answers `null` for a read
|
||||
// that FAILED exactly as for one that found nothing, so an unreachable broker looked
|
||||
// like "this identity has no account" — the one absence connecting is entitled to
|
||||
// pass over in silence. The whole restore was skipped and the promise resolved like a
|
||||
// success. That is how sharing broke once (`.project/concepts/sign-in/`
|
||||
// `knowledge_settling-is-not-connecting`), and conflating absence with ignorance is
|
||||
// the same fault `inbox.share` was fixed for on 2026-08-10.
|
||||
if ((await lookupAccount(holder)) === null) return;
|
||||
if (!stillConnected()) return;
|
||||
// 1. Durable first: what this user has already applied.
|
||||
const links = await readLinks();
|
||||
@@ -93,16 +123,16 @@ export async function connectedUser(): Promise<void> {
|
||||
// Sequential rather than parallel: each `processInbox` writes what it
|
||||
// applies to the SAME private store, and interleaving those writes buys
|
||||
// nothing on a queue that is nearly always empty.
|
||||
//
|
||||
// A queue that cannot be read stops the loop, so the inboxes after it are left
|
||||
// undrained — as before. What changed is that stopping is now audible: the caller
|
||||
// is told the connection did not complete instead of being handed a resolved
|
||||
// promise over a half-drained wallet.
|
||||
const inboxes = await myInboxes();
|
||||
for (const inbox of inboxes) {
|
||||
if (!stillConnected()) return;
|
||||
await processInbox(inbox);
|
||||
}
|
||||
} catch {
|
||||
// Not configured yet, or offline. Nothing to restore, and connecting must
|
||||
// not fail because a queue could not be reached — the next connection, or
|
||||
// an explicit `connectedUser()`, picks it up.
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.set(holder, run);
|
||||
@@ -113,7 +143,20 @@ export async function connectedUser(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the connection work without awaiting it. Called by `setCurrentUser`. */
|
||||
/**
|
||||
* Fire the connection work without awaiting it. Called by `setCurrentUser`.
|
||||
*
|
||||
* There is no caller to reject at here — that is what fire-and-forget means — so this is
|
||||
* the one place a failure cannot surface as a rejection. It is logged instead, and NOT
|
||||
* left to become an unhandled rejection: that would take down whatever runtime the
|
||||
* consumer is in, over a connection the consumer never awaited.
|
||||
*
|
||||
* A caller that needs the outcome awaits `connectedUser()` — it joins this very run and
|
||||
* inherits it, failure included. That is what `ensureIdentity()` does, and it is the path
|
||||
* on which the guarantee is published.
|
||||
*/
|
||||
export function startConnect(): void {
|
||||
void connectedUser();
|
||||
void connectedUser().catch((error: unknown) => {
|
||||
console.error(accessLogPrefix() + " connect failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,9 +71,31 @@ afterEach(() => {
|
||||
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
|
||||
});
|
||||
|
||||
/**
|
||||
* A broker over an EMPTY wallet: every read answers zero bindings, every write lands
|
||||
* nowhere, and `doc_create` mints a distinct NURI.
|
||||
*
|
||||
* These tests are about the gate, not about storage — but `ensureIdentity()` goes on to
|
||||
* connect the identity it settled, and connecting reaches the broker. It was handed `{}`
|
||||
* here, which no real platform ever presents: `sparql_query` was simply missing, and the
|
||||
* `TypeError` that produced was swallowed by the connection work. Now that a failed
|
||||
* connection is reported (`emulated-verifier/connect.ts`), a fake that cannot answer would
|
||||
* fail every test in this file for a reason none of them is about. An empty wallet — a page
|
||||
* where nobody has signed in yet — is a state the real system produces constantly, and it
|
||||
* changes nothing this file asserts: connecting finds no account and returns.
|
||||
*/
|
||||
function emptyWallet() {
|
||||
let created = 0;
|
||||
return {
|
||||
sparql_query: async (): Promise<unknown> => ({ results: { bindings: [] } }),
|
||||
sparql_update: async (): Promise<void> => undefined,
|
||||
doc_create: async (): Promise<string> => `did:ng:o:gate${++created}`,
|
||||
};
|
||||
}
|
||||
|
||||
function configured(injectedInit?: (...args: unknown[]) => unknown) {
|
||||
configure({
|
||||
ng: {} as never,
|
||||
ng: emptyWallet() as never,
|
||||
useShape: (() => {}) as never,
|
||||
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
|
||||
// Only where a test needs a hand-over to come back FROM: the delegation is what the
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Connecting a user either DID THE WORK or SAYS IT DID NOT — the whole case space.
|
||||
*
|
||||
* `connectedUser()` restores the caps a person was given (the Links already applied on
|
||||
* their User branch) and drains their inboxes. `ensureIdentity()` awaits it, and the
|
||||
* published contract says that call "completes the connection work it starts". So the one
|
||||
* thing it must never do is resolve after failing: an application then renders, shows empty
|
||||
* lists, and nothing anywhere says the restore never happened. The symptom a consumer sees
|
||||
* is *"the app works but the documents shared with me never appear"* — the worst kind,
|
||||
* because it looks like a permission decision and is a swallowed error.
|
||||
*
|
||||
* The rule this file pins, one line: **any failure must surface; only "there was nothing to
|
||||
* do" may resolve quietly.** Nothing to do means exactly two things — no identity is
|
||||
* connected, or the identity has no account yet (connecting must never PROVISION one, see
|
||||
* `connect.ts`) — plus abandoning when the identity changed under the run, which is not a
|
||||
* failure either: the next connection picks it up.
|
||||
*
|
||||
* ── Why every branch is here, not just the interesting ones ───────────────
|
||||
* A swallowed failure is invisible by construction, so a suite that covers "the happy path
|
||||
* and one error" leaves exactly the places a bug hides. Every path through the function is
|
||||
* asserted below: no holder, no account, a lookup that could not answer, each of the three
|
||||
* identity checkpoints, each of the three collaborators failing, joining a run in flight
|
||||
* (both outcomes), the fire-and-forget entry, and success.
|
||||
*
|
||||
* ── The faults are the broker's, not the test's ───────────────────────────
|
||||
* Every failure below is injected at the `ng` boundary — a read that throws `RepoNotFound`
|
||||
* (what the engine hard-errors when a repo is not in `self.repos`,
|
||||
* `engine/verifier/src/verifier.rs`, and what `cold-start-anchor.test.ts` models too), or a
|
||||
* `doc_create` that cannot reach the broker. Nothing here reaches into the library to make
|
||||
* one of its functions reject artificially: a fake that fabricates a state the real system
|
||||
* never produces goes green while leaving the real state untested.
|
||||
*/
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import { configure } from "../src/index";
|
||||
import {
|
||||
adoptCurrentUser,
|
||||
configureStoreRegistry,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
resetConfig,
|
||||
resetStoreRegistry,
|
||||
setCurrentUser,
|
||||
} from "../src/shared-wallet/bootstrap";
|
||||
import {
|
||||
createEntityDoc,
|
||||
ensureAccount,
|
||||
resetRegistryCache,
|
||||
userInbox,
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { connectedUser } from "../src/emulated-verifier/connect";
|
||||
import { share } from "../src/surface/inbox";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-connect", privateStoreId: "PRIV-CONNECT" };
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
const SECRET = "urn:connect-test:secret";
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/**
|
||||
* What the broker refuses to do, and when. Every field arms a REAL failure of the
|
||||
* corresponding platform call; `null` means "answer normally".
|
||||
*/
|
||||
interface Faults {
|
||||
/** The doc-shim read that answers "does this account exist" throws. */
|
||||
accountLookup: boolean;
|
||||
/** The User-branch read that answers "which Links has this user applied" throws. */
|
||||
linksRead: boolean;
|
||||
/** `doc_create` throws — the broker cannot mint the document an inbox needs. */
|
||||
docCreate: boolean;
|
||||
/** The User-branch read that answers "which document inboxes may I read" throws. */
|
||||
inboxCapRead: boolean;
|
||||
/** Reading THIS inbox throws (a repo the session cannot resolve). */
|
||||
inboxRead: Nuri | null;
|
||||
/** Called before every anchored read — the seam the identity-switch cases use. */
|
||||
onQuery: ((query: string, anchor: string | undefined) => void) | null;
|
||||
}
|
||||
|
||||
function noFaults(): Faults {
|
||||
return {
|
||||
accountLookup: false,
|
||||
linksRead: false,
|
||||
docCreate: false,
|
||||
inboxCapRead: false,
|
||||
inboxRead: null,
|
||||
onQuery: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reverse of the lib's `escapeLiteral`: one left-to-right pass over `\x`. */
|
||||
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` over an in-memory quad store — the shim SPARQL, the User-branch
|
||||
* registers, the inbox SPARQL and the anchored per-doc read. Same shape as the one
|
||||
* `cross-user-access.test.ts` drives the model with, plus the fault switches above.
|
||||
*
|
||||
* No `doc_subscribe`: `ensureRepoOpen` is then a no-op by design (`open-repo.ts`), which
|
||||
* keeps every failure below attributable to the call that was armed.
|
||||
*/
|
||||
function makeFakeNg(faults: Faults) {
|
||||
const quads: Quad[] = [];
|
||||
/** The anchors an inbox READ was issued against, in order — what "was it drained" reads. */
|
||||
const inboxReads: string[] = [];
|
||||
/** How many times the Links register was read — what "the work ran once" reads. */
|
||||
let linksReads = 0;
|
||||
let docCounter = 0;
|
||||
|
||||
const doc_create = mock(async () => {
|
||||
if (faults.docCreate) throw new Error("BrokerError: cannot create document");
|
||||
return `did:ng:o:cdoc${++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;
|
||||
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses;
|
||||
// everything else writes the anchored default graph.
|
||||
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 after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
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 rows = (anchor: string | undefined, pred: string, name: string) => ({
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === anchor && q.p === pred)
|
||||
.map((q) => ({ [name]: { value: q.o } })),
|
||||
},
|
||||
});
|
||||
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
faults.onQuery?.(query, anchor);
|
||||
// Store-root pointer → the doc-shim.
|
||||
if (query.includes(`<${SHIM}:shimDoc>`)) return rows(anchor, `${SHIM}:shimDoc`, "shimDoc");
|
||||
// The account record — the read `lookupAccount` issues.
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
if (faults.accountLookup) throw new Error("RepoNotFound");
|
||||
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 ?? "" },
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
// An inbox READ — the deposits queued for its owner.
|
||||
if (query.includes(`<${INBOX}:payload>`)) {
|
||||
inboxReads.push(anchor ?? "");
|
||||
if (faults.inboxRead !== null && anchor === faults.inboxRead) 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;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
// The User branch: the applied Links, and the inbox caps.
|
||||
if (query.includes(`<${SHIM}:link>`)) {
|
||||
linksReads += 1;
|
||||
if (faults.linksRead) throw new Error("RepoNotFound");
|
||||
return rows(anchor, `${SHIM}:link`, "c");
|
||||
}
|
||||
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
||||
if (faults.inboxCapRead) throw new Error("RepoNotFound");
|
||||
return rows(anchor, `${SHIM}:inboxCap`, "c");
|
||||
}
|
||||
if (query.includes(`<${SHIM}:inboxAddress>`)) return rows(anchor, `${SHIM}:inboxAddress`, "a");
|
||||
if (query.includes(`<${SHIM}:readCap>`)) return rows(anchor, `${SHIM}:readCap`, "c");
|
||||
if (query.includes(`<${SHIM}:exposedReadCap>`)) return rows(anchor, `${SHIM}:exposedReadCap`, "c");
|
||||
if (query.includes(`<${SHIM}:contains>`)) return rows(anchor, `${SHIM}:contains`, "e");
|
||||
if (query.includes(`${SHIM}:isInbox`)) return rows(anchor, `${SHIM}:isInbox`, "i");
|
||||
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 } })),
|
||||
},
|
||||
};
|
||||
}
|
||||
// Anchored per-doc read (`SELECT ?s ?p ?o`).
|
||||
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_query,
|
||||
sparql_update,
|
||||
inboxReads,
|
||||
linksReadCount: (): number => linksReads,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wire a clean world: fresh fake broker, fresh caches, nobody connected. */
|
||||
function inject(faults: Faults) {
|
||||
const ng = makeFakeNg(faults);
|
||||
configure({ ng: ng as never, useShape: (() => {}) as never });
|
||||
configureStoreRegistry({
|
||||
getSession: async (): Promise<RegistrySession> => SESSION,
|
||||
normalizeId: (id: string) => id.trim().toLowerCase(),
|
||||
});
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentUser(null);
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
/** Write one triple into `doc`, as a 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Alice owns a protected note and shares it with Bob — the ordinary way a cap reaches
|
||||
* someone. Bob's account and inbox come into existence through the system (he signs in
|
||||
* once), never through a value the test hands across the identity boundary.
|
||||
*
|
||||
* Returns the note Alice shared, so a later assertion can ask "does Bob hold it".
|
||||
*/
|
||||
async function aliceSharesANoteWithBob(): Promise<Nuri> {
|
||||
adoptCurrentUser("bob");
|
||||
await ensureAccount("bob"); // Bob has signed in before: he exists, so he can be shared with.
|
||||
|
||||
adoptCurrentUser("alice");
|
||||
const note = await createEntityDoc("alice", "protected");
|
||||
await write(note, SECRET, "the-protected-content");
|
||||
await share(note, "bob");
|
||||
|
||||
adoptCurrentUser(null);
|
||||
return note;
|
||||
}
|
||||
|
||||
/** Does the connected identity hold this document's cap? */
|
||||
function holds(doc: Nuri): boolean {
|
||||
return getCaps().capFor(doc) !== undefined;
|
||||
}
|
||||
|
||||
// --- nothing to do: the only two silences that are legitimate ---------------
|
||||
|
||||
test("no identity connected: nothing to restore, and the call resolves", async () => {
|
||||
// Anonymous holds nothing and owns no inbox, so there is genuinely no work. This is the
|
||||
// one branch `ensureIdentity` can never reach — it has settled an identity by then — and
|
||||
// it exists for the internal callers (`startConnect` is gated on a non-null id; a test or
|
||||
// the e2e harness may call in before signing in).
|
||||
const ng = inject(noFaults());
|
||||
|
||||
await expect(connectedUser()).resolves.toBeUndefined();
|
||||
|
||||
expect(ng.linksReadCount()).toBe(0);
|
||||
expect(ng.inboxReads).toEqual([]);
|
||||
});
|
||||
|
||||
test("an identity with no account yet: nothing to restore, and the call resolves", async () => {
|
||||
// Connecting must not PROVISION (see `connect.ts`): an account that does not exist has no
|
||||
// Links to restore and no inbox to drain. A genuine absence is therefore silence, and the
|
||||
// ONLY silence a failed read may not borrow — see the next test.
|
||||
const ng = inject(noFaults());
|
||||
adoptCurrentUser("nobody-has-signed-in-as-this");
|
||||
|
||||
await expect(connectedUser()).resolves.toBeUndefined();
|
||||
|
||||
expect(ng.linksReadCount()).toBe(0);
|
||||
expect(ng.inboxReads).toEqual([]);
|
||||
});
|
||||
|
||||
// --- the failures, one per collaborator -------------------------------------
|
||||
|
||||
test("an account lookup that could not answer is not an absent account: the call rejects", async () => {
|
||||
// The shipped shape of this defect. The tolerant resolver answers `null` for a read that
|
||||
// FAILED exactly as for one that found nothing, so a broker that cannot answer looks like
|
||||
// "you have no account" — which connecting is entitled to pass over in silence. The whole
|
||||
// restore is then skipped and the promise resolves like a success.
|
||||
const faults = noFaults();
|
||||
inject(faults);
|
||||
await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
resetRegistryCache(); // a fresh session: nothing is answered from a warm cache
|
||||
faults.accountLookup = true;
|
||||
|
||||
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
||||
});
|
||||
|
||||
test("a Links register that cannot be read fails the connection", async () => {
|
||||
// The restore step. Its failure is the one with no other symptom at all: every document
|
||||
// ever shared with this person stays invisible, and an application has no way to tell
|
||||
// that from "nobody has shared anything with me".
|
||||
const faults = noFaults();
|
||||
inject(faults);
|
||||
const note = await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
await connectedUser(); // a first, healthy connection: the cap is applied durably
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
faults.linksRead = true;
|
||||
|
||||
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
||||
expect(holds(note)).toBe(false); // and it really did not restore
|
||||
});
|
||||
|
||||
test("an inbox list that cannot be built fails the connection", async () => {
|
||||
// Enumerating the inboxes resolves the user's own two, minting the document on first
|
||||
// ask. A broker that cannot create it leaves the drain list unknowable — not empty.
|
||||
const faults = noFaults();
|
||||
inject(faults);
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
await ensureAccount("bob"); // exists, but has never opened an inbox
|
||||
faults.docCreate = true;
|
||||
|
||||
await expect(connectedUser()).rejects.toThrow(/cannot create document/);
|
||||
});
|
||||
|
||||
test("a document-inbox list that cannot be read fails the connection", async () => {
|
||||
// The other half of the drain list: the inboxes this user opened on its own documents.
|
||||
// Unreadable is not empty — a queue skipped for want of knowing it exists holds a share
|
||||
// that was delivered and will never be applied, with nothing to see anywhere.
|
||||
const faults = noFaults();
|
||||
inject(faults);
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
await userInbox("bob", "public"); // so the OWN inboxes resolve and the fault lands later
|
||||
await userInbox("bob", "protected");
|
||||
faults.inboxCapRead = true;
|
||||
|
||||
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
||||
});
|
||||
|
||||
test("an inbox that cannot be drained fails the connection, and the rest stay undrained", async () => {
|
||||
// Draining is what APPLIES a share. An inbox that could not be read may hold the cap this
|
||||
// very reconnection was for, so a silent skip loses it with no trace — and it took the
|
||||
// remaining inboxes down with it, silently too. It still stops at the first failure; the
|
||||
// difference is that stopping is now audible.
|
||||
const faults = noFaults();
|
||||
const ng = inject(faults);
|
||||
await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
const publicInbox = await userInbox("bob", "public"); // the first of the two drained
|
||||
faults.inboxRead = publicInbox;
|
||||
|
||||
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
||||
expect(ng.inboxReads).toEqual([publicInbox]); // the protected one was never reached
|
||||
});
|
||||
|
||||
// --- abandoning on an identity switch: not a failure ------------------------
|
||||
|
||||
test("the identity changes right after the account resolved: abandons, and resolves", async () => {
|
||||
// Everything below the checkpoint resolves the CURRENT holder when it reads a register,
|
||||
// so after a switch it would read the WRONG user's. Abandoning loses nothing: the new
|
||||
// identity's own connection does its own work.
|
||||
const faults = noFaults();
|
||||
const ng = inject(faults);
|
||||
await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
resetRegistryCache();
|
||||
faults.onQuery = (query) => {
|
||||
if (query.includes(`<${SHIM}:id>`)) adoptCurrentUser("carol");
|
||||
};
|
||||
|
||||
await expect(connectedUser()).resolves.toBeUndefined();
|
||||
expect(ng.linksReadCount()).toBe(0);
|
||||
expect(ng.inboxReads).toEqual([]);
|
||||
});
|
||||
|
||||
test("the identity changes while the Links are being read: abandons, and resolves", async () => {
|
||||
const faults = noFaults();
|
||||
const ng = inject(faults);
|
||||
const note = await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
await connectedUser(); // apply the cap durably, so there IS something to restore
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
const drainedBefore = ng.inboxReads.length; // the healthy connection above drained them
|
||||
faults.onQuery = (query) => {
|
||||
if (query.includes(`<${SHIM}:link>`)) adoptCurrentUser("carol");
|
||||
};
|
||||
|
||||
await expect(connectedUser()).resolves.toBeUndefined();
|
||||
expect(holds(note)).toBe(false); // nothing was filed under the wrong holder
|
||||
expect(ng.inboxReads.length).toBe(drainedBefore); // and it never reached the queues
|
||||
});
|
||||
|
||||
test("the identity changes between two inboxes: abandons, and resolves", async () => {
|
||||
const faults = noFaults();
|
||||
const ng = inject(faults);
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
const publicInbox = await userInbox("bob", "public");
|
||||
const protectedInbox = await userInbox("bob", "protected");
|
||||
expect(protectedInbox).not.toBe(publicInbox);
|
||||
faults.onQuery = (query, anchor) => {
|
||||
if (query.includes(`<${INBOX}:payload>`) && anchor === publicInbox) adoptCurrentUser("carol");
|
||||
};
|
||||
|
||||
await expect(connectedUser()).resolves.toBeUndefined();
|
||||
expect(ng.inboxReads).toEqual([publicInbox]); // the checkpoint stopped the loop
|
||||
});
|
||||
|
||||
// --- joining a run already in flight ----------------------------------------
|
||||
|
||||
test("a concurrent caller joins the run in flight and inherits its success", async () => {
|
||||
const ng = inject(noFaults());
|
||||
await aliceSharesANoteWithBob();
|
||||
adoptCurrentUser("bob");
|
||||
const publicInbox = await userInbox("bob", "public");
|
||||
const protectedInbox = await userInbox("bob", "protected");
|
||||
resetRegistryCache();
|
||||
|
||||
const first = connectedUser();
|
||||
const second = connectedUser();
|
||||
|
||||
await expect(first).resolves.toBeUndefined();
|
||||
await expect(second).resolves.toBeUndefined();
|
||||
// ONE run, two callers: each inbox was drained exactly once. Two independent runs
|
||||
// would show each anchor twice — the memoisation is what the joiner depends on.
|
||||
expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]);
|
||||
});
|
||||
|
||||
test("a concurrent caller joins the run in flight and inherits its FAILURE", async () => {
|
||||
// The sibling defect: a run that gives up registers itself as the run in flight FIRST, so
|
||||
// a caller that did nothing wrong joins it and resolves having done nothing. Inheriting
|
||||
// the outcome — failure included — is what makes joining safe.
|
||||
const faults = noFaults();
|
||||
inject(faults);
|
||||
await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
resetRegistryCache();
|
||||
faults.accountLookup = true;
|
||||
|
||||
// Both handlers attached in the same tick, as two real concurrent callers would: awaiting
|
||||
// one and only THEN the other leaves the second rejection momentarily unobserved, which
|
||||
// the runtime reports as an unhandled rejection rather than as the outcome under test.
|
||||
const outcomes = await Promise.allSettled([connectedUser(), connectedUser()]);
|
||||
|
||||
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected"]);
|
||||
const reasons = outcomes.flatMap((o) => (o.status === "rejected" ? [String(o.reason)] : []));
|
||||
expect(reasons).toEqual([expect.stringContaining("RepoNotFound"), expect.stringContaining("RepoNotFound")]);
|
||||
});
|
||||
|
||||
// --- the fire-and-forget entry ----------------------------------------------
|
||||
|
||||
test("the fire-and-forget entry reports the failure instead of dropping it", async () => {
|
||||
// `setCurrentUser` fires the work un-awaited, so there is no caller to reject at. The
|
||||
// failure must still leave a trace rather than vanish — and it must not surface as an
|
||||
// unhandled rejection, which would take down whatever runtime the consumer is in.
|
||||
const faults = noFaults();
|
||||
inject(faults);
|
||||
await aliceSharesANoteWithBob();
|
||||
resetRegistryCache();
|
||||
faults.accountLookup = true;
|
||||
|
||||
const logged: string[] = [];
|
||||
const original = console.error;
|
||||
console.error = (...args: unknown[]): void => void logged.push(args.map(String).join(" "));
|
||||
try {
|
||||
setCurrentUser("bob");
|
||||
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
|
||||
expect(logged.some((line) => /connect(ing)? failed/i.test(line))).toBe(true);
|
||||
});
|
||||
|
||||
// --- success ----------------------------------------------------------------
|
||||
|
||||
test("a healthy connection restores the applied Links and drains every inbox", async () => {
|
||||
// The normal case, and the reason all of the above matters: this is what an application
|
||||
// is entitled to assume happened when `ensureIdentity()` came back.
|
||||
const faults = noFaults();
|
||||
const ng = inject(faults);
|
||||
const note = await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
const publicInbox = await userInbox("bob", "public");
|
||||
const protectedInbox = await userInbox("bob", "protected");
|
||||
|
||||
// First connection: the cap is in the inbox, draining APPLIES it (durably, as a Link).
|
||||
await expect(connectedUser()).resolves.toBeUndefined();
|
||||
expect(holds(note)).toBe(true);
|
||||
expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]);
|
||||
|
||||
// Second connection, as a later session would: nothing left in the queue, and the cap
|
||||
// comes back from the durable register alone.
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
await expect(connectedUser()).resolves.toBeUndefined();
|
||||
expect(holds(note)).toBe(true);
|
||||
expect(ng.linksReadCount()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
@@ -170,9 +170,29 @@ function consumerWiring(session: Record<string, unknown> = BROKER_SESSION) {
|
||||
return { sessionReady, calls, injectedInit, returned, getSession: () => sessionReady };
|
||||
}
|
||||
|
||||
/**
|
||||
* A broker over an EMPTY wallet — nobody has signed in yet.
|
||||
*
|
||||
* This file is about the lifecycle, not about storage, but `ensureIdentity()` goes on to
|
||||
* connect the identity it settled and connecting reaches the broker. It was handed `{}`,
|
||||
* a shape no real platform presents: the `TypeError` that produced was swallowed by the
|
||||
* connection work, so the tests below were passing over a run that had died of the
|
||||
* fixture. A failed connection is reported now (`emulated-verifier/connect.ts`), so the
|
||||
* fake answers like a broker with nothing in it — which is what a first sign-in meets, and
|
||||
* which changes nothing any test here asserts: connecting finds no account and returns.
|
||||
*/
|
||||
function emptyWallet() {
|
||||
let created = 0;
|
||||
return {
|
||||
sparql_query: async (): Promise<unknown> => ({ results: { bindings: [] } }),
|
||||
sparql_update: async (): Promise<void> => undefined,
|
||||
doc_create: async (): Promise<string> => `did:ng:o:life${++created}`,
|
||||
};
|
||||
}
|
||||
|
||||
function configured(wiring: ReturnType<typeof consumerWiring>, opts: { sharedWallet?: boolean } = {}) {
|
||||
configure({
|
||||
ng: {} as never,
|
||||
ng: emptyWallet() as never,
|
||||
useShape: (() => {}) as never,
|
||||
init: wiring.injectedInit,
|
||||
...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }),
|
||||
@@ -350,7 +370,7 @@ function flush(): Promise<void> {
|
||||
function anApplicationThatConfigured() {
|
||||
let deliver: ((event: unknown) => void) | null = null;
|
||||
configure({
|
||||
ng: {} as never,
|
||||
ng: emptyWallet() as never,
|
||||
useShape: (() => {}) as never,
|
||||
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
|
||||
init: (...args: unknown[]): Promise<void> => {
|
||||
|
||||
@@ -80,6 +80,16 @@ function inBrokerIframe(url: string): void {
|
||||
|
||||
const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const;
|
||||
|
||||
/** A broker over an empty wallet: reads answer nothing, `doc_create` mints a NURI. */
|
||||
function emptyWallet() {
|
||||
let created = 0;
|
||||
return {
|
||||
sparql_query: async (): Promise<unknown> => ({ results: { bindings: [] } }),
|
||||
sparql_update: async (): Promise<void> => undefined,
|
||||
doc_create: async (): Promise<string> => `did:ng:o:signin${++created}`,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentUser(null);
|
||||
resetConfig();
|
||||
@@ -109,7 +119,14 @@ function bootTheApplication(identifier: string) {
|
||||
const thunk = { asked: 0, answered: 0, refused: 0 };
|
||||
|
||||
configure({
|
||||
ng: {} as never, // no store behind it: the assertions are about what is REACHED
|
||||
// A broker over an EMPTY wallet — nobody has signed in yet. The assertions below are
|
||||
// about what is REACHED on the way to the session, not about anything stored; what
|
||||
// this must NOT be is `{}`, a shape no real platform presents. The connection work
|
||||
// called into it, got a `TypeError`, and swallowed it — so the counters were read off
|
||||
// a run that had already died of the fixture. Now that a failed connection is reported
|
||||
// (`emulated-verifier/connect.ts`), the fake has to answer like a broker with nothing
|
||||
// in it, which is exactly the state a first sign-in meets.
|
||||
ng: emptyWallet() as never,
|
||||
useShape: (() => {}) as never,
|
||||
init: (...args: unknown[]): Promise<string> => {
|
||||
const callback = args[0];
|
||||
|
||||
Reference in New Issue
Block a user