clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / scripts / webmcp.test.mjs
webmcp.test.mjs 23.7K
1import assert from "node:assert/strict";2import { readFile } from "node:fs/promises";3import test from "node:test";4import vm from "node:vm";56const script = await readFile(new URL("../tinyclient/webmcp.js", import.meta.url), "utf8");78async function browser({path = "/tools", failRegistration = "", fetch, signedFetch} = {}) {9 const tools = new Map();10 const sandbox = {11 document: {modelContext: {registerTool(tool) {12 if (tool.name === failRegistration) return Promise.reject(new Error("Registration denied"));13 tools.set(tool.name, tool);14 }}},15 navigator: {}, URL, URLSearchParams, CustomEvent,16 location: {pathname: path, href: "https://tiny.example" + path,17 assign(url) { sandbox.opened = url; }},18 dispatchEvent() {}, fetch,19 tinySignedFetch: signedFetch20 };21 sandbox.window = sandbox;22 const root = (path.match(/^\/r\/[^/]+/) || [""])[0];23 sandbox.tiny = {signing: {publish: async (unsigned, {signal} = {}) => {24 if (!sandbox.nostr?.signEvent) throw new Error("Connect a Nostr signer first.");25 if (typeof sandbox.NostrSigner?.verifyEvent !== "function") throw new Error("The signer verifier is still loading. Try again.");26 const expected = JSON.stringify(unsigned);27 const event = await sandbox.nostr.signEvent(JSON.parse(expected));28 const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});29 if (actual !== expected || sandbox.NostrSigner.verifyEvent(event) !== true) throw new Error("The signer returned an invalid or changed event.");30 if (signal?.aborted) throw new Error("Sending canceled.");31 if (!signedFetch) throw new Error("The signer is still loading. Try again.");32 const response = await signedFetch(root + "/events", "POST", JSON.stringify(event), {contentType: "application/json", signal});33 const value = JSON.parse(await response.text());34 if (value.accepted !== true) throw new Error(value.message || "The relay rejected the message.");35 return event;36 }}};37 vm.runInNewContext(script, sandbox);38 await sandbox.tinyWebMCP.ready;39 return {tools, sandbox};40}4142test("read tools keep tenant scope and return backend data", async () => {43 let request;44 const {tools} = await browser({path: "/r/work/tools", fetch: async (url, options) => {45 request = {url, options};46 return new Response(JSON.stringify({items: [{identifier: "notes"}]}));47 }});48 const result = await tools.get("tiny.list_repositories").execute({q: "notes"});49 const url = new URL(request.url, "https://tiny.example");50 assert.equal(url.pathname, "/r/work/webmcp/query");51 assert.equal(request.options.method, "GET");52 assert.equal(url.searchParams.get("method"), "browserepos");53 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{q: "notes"}]);54 assert.equal(result.structuredContent.result.items[0].identifier, "notes");55});5657test("backup and policy controls await signed JSON RPC and preserve parameter types", async () => {58 const requests = [];59 let finish;60 const completed = new Promise(resolve => { finish = resolve; });61 const {tools, sandbox} = await browser({signedFetch: async (url, method, body, options) => {62 requests.push({url, method, body: JSON.parse(body), options});63 await completed;64 return new Response(JSON.stringify({result: {id: "backup-1"}}));65 }});66 const pending = tools.get("tiny.backup_now").execute({});67 assert.equal(sandbox.tinyWebMCP.lastOperation, "tiny.backup_now: running");68 finish();69 const result = await pending;70 assert.equal(result.structuredContent.result.id, "backup-1");71 assert.deepEqual(requests[0].body, {method: "backupnow", params: []});72 assert.equal(requests[0].options.contentType, "application/json");73 await tools.get("tiny.set_policy").execute({patch: {delivery: {enabled: true}}});74 assert.deepEqual(requests[1].body.params, [{delivery: {enabled: true}}]);75 await tools.get("tiny.set_connections").execute({connections: []});76 assert.deepEqual(requests[2].body.params, [[]]);77});7879test("server refusal fails the tool and is visible in operation status", async () => {80 const {tools, sandbox} = await browser({signedFetch: async () =>81 new Response(JSON.stringify({error: "owner required"}), {status: 403})});82 await assert.rejects(tools.get("tiny.set_policy").execute({patch: {writes: "owner"}}), /owner required/);83 assert.match(sandbox.tinyWebMCP.lastOperation, /owner required/);84});8586test("repository storage reads pass the selected owner and repository", async () => {87 let request;88 const {tools} = await browser({signedFetch: async (_, __, body) => {89 request = JSON.parse(body);90 return new Response(JSON.stringify({result: {bytes: 123}}));91 }});92 const read = tools.get("tiny.read_management");93 await assert.rejects(read.execute({method: "gitstorage"}), /repository owner and name/);94 await read.execute({method: "gitstorage", owner: "a".repeat(64), repo: "notes"});95 assert.deepEqual(request.params, ["a".repeat(64), "notes"]);96});9798test("navigation actually opens the selected repository without changing tenant", async () => {99 const {tools, sandbox} = await browser({path: "/r/work/tools"});100 const open = tools.get("tiny.open_repository");101 const owner = "a".repeat(64);102 await open.execute({owner, repo: "notes", path: "src/hello world.go", view: "file", ref: "refs/heads/main"});103 const url = new URL(sandbox.opened);104 assert.equal(url.pathname, "/r/work/repos/" + owner + "/notes/file/src/hello%20world.go");105 assert.equal(url.searchParams.get("ref"), "refs/heads/main");106 const opened = async input => { await open.execute({owner, repo: "my notes", ...input}); return new URL(sandbox.opened).pathname + new URL(sandbox.opened).search; };107 assert.equal(await opened({}), "/r/work/repos/" + owner + "/my%20notes");108 assert.equal(await opened({view: "tree"}), "/r/work/repos/" + owner + "/my%20notes/tree");109 assert.equal(await opened({view: "tree", path: "docs/caf\u00e9"}), "/r/work/repos/" + owner + "/my%20notes/tree/docs/caf%C3%A9");110 assert.equal(await opened({view: "history"}), "/r/work/repos/" + owner + "/my%20notes/history");111 assert.equal(await opened({view: "commit", id: "b".repeat(40)}), "/r/work/repos/" + owner + "/my%20notes/commit/" + "b".repeat(40));112 assert.equal(await opened({view: "activity"}), "/r/work/repos/" + owner + "/my%20notes/activity");113 assert.equal(await opened({view: "issues"}), "/r/work/repos/" + owner + "/my%20notes/issues");114 assert.equal(await opened({view: "prs"}), "/r/work/repos/" + owner + "/my%20notes/prs");115 assert.equal(await opened({view: "issue", id: "c".repeat(64)}), "/r/work/repos/" + owner + "/my%20notes/issues/" + "c".repeat(64));116 assert.equal(await opened({view: "pr", id: "d".repeat(64)}), "/r/work/repos/" + owner + "/my%20notes/prs/" + "d".repeat(64));117 await tools.get("tiny.open_file").execute({hash: "e".repeat(64)});118 assert.equal(new URL(sandbox.opened).pathname, "/r/work/file/" + "e".repeat(64));119});120121test("registration failures stay visible while other tools remain available", async () => {122 const {sandbox} = await browser({failRegistration: "tiny.backup_now"});123 assert.equal(sandbox.tinyWebMCP.errors.length, 1);124 assert.match(sandbox.tinyWebMCP.errors[0], /tiny.backup_now: Registration denied/);125 assert.ok(sandbox.tinyWebMCP.registered.includes("tiny.read_status"));126 assert.ok(!sandbox.tinyWebMCP.registered.includes("tiny.backup_now"));127});128129test("cancellation reaches the fetch request", async () => {130 const controller = new AbortController();131 let received;132 const {tools} = await browser({fetch: async (_, options) => {133 received = options.signal;134 return new Response("{}");135 }});136 await tools.get("tiny.list_files").execute({}, {signal: controller.signal});137 assert.equal(received, controller.signal);138});139140test("collaboration tools preserve repository, event and filter scope", async () => {141 const requests = [];142 const {tools} = await browser({path: "/r/work/tools", fetch: async url => {143 requests.push(new URL(url, "https://tiny.example"));144 return new Response(JSON.stringify({items: [], item: {title: "Issue"}}));145 }});146 const repo = {owner: "a".repeat(64), repo: "notes"};147 for (const [name, method, extra] of [148 ["tiny.list_issues", "browseissues", {q: "bug", state: "resolved", limit: 5}],149 ["tiny.read_issue", "browseissue", {event: "b".repeat(64)}],150 ["tiny.list_pull_requests", "browsepulls", {state: "merged"}],151 ["tiny.read_pull_request", "browsepull", {event: "c".repeat(64)}]152 ]) {153 const tool = tools.get(name);154 assert.equal(tool.annotations.readOnlyHint, true);155 await tool.execute({...repo, ...extra});156 const url = requests.at(-1);157 assert.equal(url.pathname, "/r/work/webmcp/query");158 assert.equal(url.searchParams.get("method"), method);159 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{...repo, ...extra}]);160 }161});162163test("approval tools list, read and open requests for a decision", async () => {164 const requests = [];165 const {tools, sandbox} = await browser({path: "/r/work/approvals", fetch: async url => {166 requests.push(new URL(url, "https://tiny.example"));167 return new Response(JSON.stringify({items: [{id: "a".repeat(64), state: "open"}], counts: {open: 1}}));168 }});169 const list = tools.get("tiny.list_approvals");170 assert.equal(list.annotations.readOnlyHint, true);171 const listed = await list.execute({state: "open", limit: 5});172 assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");173 assert.equal(requests.at(-1).searchParams.get("method"), "browseapprovals");174 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", limit: 5}]);175 assert.equal(listed.structuredContent.result.counts.open, 1);176 assert.equal(list.inputSchema.properties.state.enum.join(","), "open,answered,expired,all");177 const read = tools.get("tiny.read_approval");178 assert.equal(read.annotations.readOnlyHint, true);179 assert.equal(read.inputSchema.required.join(","), "id");180 await read.execute({id: "b".repeat(64)});181 assert.equal(requests.at(-1).searchParams.get("method"), "browseapproval");182 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);183 await tools.get("tiny.open_approvals").execute({});184 assert.equal(new URL(sandbox.opened).pathname, "/r/work/approvals");185 await tools.get("tiny.open_approvals").execute({id: "c".repeat(64)});186 const opened = new URL(sandbox.opened);187 assert.equal(opened.pathname, "/r/work/approvals/" + "c".repeat(64));188 assert.equal(opened.search, "");189});190191test("job tools list and read long tasks through the session", async () => {192 const requests = [];193 const {tools} = await browser({path: "/r/work/tools", fetch: async url => {194 requests.push(new URL(url, "https://tiny.example"));195 return new Response(JSON.stringify({items: [{id: "a".repeat(64), kind: 5001, state: "open", status: "processing"}], next_cursor: "",196 item: {id: "b".repeat(64), kind: 5002, state: "done"}, feedback: [{status: "processing"}], results: [{kind: 6002}]}));197 }});198 const list = tools.get("tiny.list_jobs");199 assert.equal(list.annotations.readOnlyHint, true);200 assert.equal(list.inputSchema.properties.state.enum.join(","), "open,done,all");201 assert.equal(list.inputSchema.properties.mine.type, "boolean");202 const listed = await list.execute({state: "open", mine: true, limit: 5});203 assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");204 assert.equal(requests.at(-1).searchParams.get("method"), "browsejobs");205 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", mine: true, limit: 5}]);206 assert.equal(listed.structuredContent.result.items[0].status, "processing");207 const read = tools.get("tiny.read_job");208 assert.equal(read.annotations.readOnlyHint, true);209 assert.deepEqual([...read.inputSchema.required], ["id"]);210 const detail = await read.execute({id: "b".repeat(64)});211 assert.equal(requests.at(-1).searchParams.get("method"), "browsejob");212 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);213 assert.equal(detail.structuredContent.result.item.state, "done");214 assert.equal(detail.structuredContent.result.results[0].kind, 6002);215});216217test("link, files and notification tools reach the new surfaces", async () => {218 const calls = [];219 const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (url, method, body) => {220 calls.push({url, method, body: JSON.parse(body)});221 return new Response(JSON.stringify({result: true}), {status: 200});222 }});223 sandbox.localStorage = {getItem: key => ({"tiny.push": "https://push.example/x", "tiny.push.categories": "replies,relay"})[key] ?? null};224 await tools.get("tiny.open_link").execute({target: "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2"});225 const opened = new URL(sandbox.opened);226 assert.equal(opened.pathname, "/r/work/open");227 assert.equal(opened.searchParams.get("target"), "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2");228 await tools.get("tiny.open_files").execute({});229 assert.equal(new URL(sandbox.opened).pathname, "/r/work/files");230 const status = await tools.get("tiny.read_notifications").execute({});231 assert.equal(status.structuredContent.result.enabled, true);232 assert.equal(status.structuredContent.result.categories.join(","), "replies,relay");233 assert.equal(status.structuredContent.result.permission, "unsupported");234 await tools.get("tiny.send_test_notification").execute({});235 assert.equal(calls[0].url, "/r/work/manage/rpc");236 assert.equal(calls[0].body.method, "notifytest");237});238239test("agent tools list through the signer, read through the session and control through management", async () => {240 const calls = [], queries = [];241 const {tools} = await browser({path: "/r/work/manage/agents", fetch: async url => {242 queries.push(new URL(url, "https://tiny.example"));243 return new Response(JSON.stringify({agent: {name: "hermes"}, events: []}));244 }, signedFetch: async (url, method, body) => {245 calls.push({url, method, body: JSON.parse(body)});246 return new Response(JSON.stringify({result: [{name: "hermes", paused: false}]}));247 }});248 const agent = "a".repeat(64);249 const list = tools.get("tiny.list_agents");250 assert.equal(list.annotations.readOnlyHint, true);251 const listed = await list.execute({});252 assert.equal(listed.structuredContent.result[0].name, "hermes");253 assert.deepEqual(calls[0].body, {method: "listagents", params: []});254 assert.equal(calls[0].url, "/r/work/manage/rpc");255 const read = await tools.get("tiny.read_agent").execute({agent});256 assert.equal(queries[0].pathname, "/r/work/webmcp/query");257 assert.equal(queries[0].searchParams.get("method"), "browseagent");258 assert.deepEqual(JSON.parse(queries[0].searchParams.get("params")), [{agent}]);259 assert.equal(read.structuredContent.result.agent.name, "hermes");260 for (const [name, method] of [["tiny.pause_agent", "pauseagent"], ["tiny.resume_agent", "resumeagent"], ["tiny.revoke_agent", "revokeagent"]]) {261 const tool = tools.get(name);262 assert.equal(tool.annotations.consequentialHint, true);263 assert.deepEqual([...tool.inputSchema.required], ["agent"]);264 await tool.execute({agent});265 assert.deepEqual(calls.at(-1).body, {method, params: [agent]});266 }267});268269test("access request tools list through the signer and decide by pubkey", async () => {270 const calls = [];271 const asker = "b".repeat(64);272 const {tools} = await browser({path: "/r/work/manage/people", signedFetch: async (url, method, body) => {273 calls.push({url, method, body: JSON.parse(body)});274 const request = JSON.parse(body);275 if (request.method === "listjoinrequests") {276 return new Response(JSON.stringify({result: [{pubkey: asker, reason: "Met you at the meetup", requested_at: 1700000000, status: "pending", decided_by: "", decided_at: 0}]}));277 }278 return new Response(JSON.stringify({result: {pubkey: asker, status: request.method === "approvejoin" ? "approved" : "denied"}}));279 }});280 const list = tools.get("tiny.list_join_requests");281 assert.equal(list.annotations.readOnlyHint, true);282 const listed = await list.execute({});283 assert.equal(listed.structuredContent.result[0].pubkey, asker);284 assert.equal(listed.structuredContent.result[0].status, "pending");285 assert.equal(calls[0].url, "/r/work/manage/rpc");286 assert.equal(calls[0].method, "POST");287 assert.deepEqual(calls[0].body, {method: "listjoinrequests", params: []});288 for (const [name, method, status] of [["tiny.approve_join", "approvejoin", "approved"], ["tiny.deny_join", "denyjoin", "denied"]]) {289 const tool = tools.get(name);290 assert.equal(tool.annotations.consequentialHint, true);291 assert.deepEqual([...tool.inputSchema.required], ["pubkey"]);292 assert.equal(tool.inputSchema.properties.pubkey.pattern, "^[0-9a-f]{64}$");293 const result = await tool.execute({pubkey: asker});294 assert.deepEqual(calls.at(-1).body, {method, params: [asker]});295 assert.equal(result.structuredContent.result.status, status);296 }297});298299test("callback tools list through the signer and control by id", async () => {300 const calls = [];301 const {tools} = await browser({path: "/r/work/manage/agents", signedFetch: async (url, method, body) => {302 calls.push({url, method, body: JSON.parse(body)});303 return new Response(JSON.stringify({result: [{id: "cb-1", host: "hooks.example", paused: false}]}));304 }});305 const list = tools.get("tiny.list_callbacks");306 assert.equal(list.annotations.readOnlyHint, true);307 const listed = await list.execute({});308 assert.equal(listed.structuredContent.result[0].host, "hooks.example");309 assert.equal(calls[0].url, "/r/work/manage/rpc");310 assert.deepEqual(calls[0].body, {method: "listcallbacks", params: []});311 assert.equal(tools.get("tiny.add_callback"), undefined);312 for (const [name, method] of [["tiny.pause_callback", "pausecallback"], ["tiny.resume_callback", "resumecallback"], ["tiny.remove_callback", "removecallback"]]) {313 const tool = tools.get(name);314 assert.equal(tool.annotations.consequentialHint, true);315 assert.deepEqual([...tool.inputSchema.required], ["id"]);316 await tool.execute({id: "cb-1"});317 assert.deepEqual(calls.at(-1).body, {method, params: ["cb-1"]});318 }319});320321test("wiki tools read pages and merge requests and open pages in the tenant", async () => {322 const requests = [];323 const {tools, sandbox} = await browser({path: "/r/work/wiki", fetch: async url => {324 requests.push(new URL(url, "https://tiny.example"));325 return new Response(JSON.stringify({items: [{d: "release-notes-1-4"}], d: "release-notes-1-4", merge: {status: "open"}}));326 }});327 for (const [name, method, input] of [328 ["tiny.list_wiki", "browsewiki", {q: "release", limit: 10}],329 ["tiny.read_wiki_page", "browsewikipage", {d: "Release Notes 1.4", version: "b".repeat(64)}],330 ["tiny.read_merge_request", "browsewikimerge", {id: "c".repeat(64)}]331 ]) {332 const tool = tools.get(name);333 assert.equal(tool.annotations.readOnlyHint, true);334 const result = await tool.execute(input);335 const url = requests.at(-1);336 assert.equal(url.pathname, "/r/work/webmcp/query");337 assert.equal(url.searchParams.get("method"), method);338 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [input]);339 assert.equal(result.structuredContent.result.d, "release-notes-1-4");340 }341 const open = tools.get("tiny.open_wiki_page");342 assert.equal(open.annotations.readOnlyHint, undefined);343 await open.execute({});344 assert.equal(new URL(sandbox.opened).pathname, "/r/work/wiki");345 await open.execute({d: "日本語 Article", merge: "c".repeat(64)});346 let opened = new URL(sandbox.opened);347 assert.equal(opened.pathname, "/r/work/wiki/" + encodeURIComponent("日本語 Article") + "/proposals/" + "c".repeat(64));348 assert.equal(opened.search, "");349 await open.execute({d: "release-notes-1-4", edit: true, version: "b".repeat(64)});350 opened = new URL(sandbox.opened);351 assert.equal(opened.pathname, "/r/work/wiki/release-notes-1-4");352 assert.equal(opened.searchParams.get("edit"), "1");353 assert.equal(opened.searchParams.get("version"), "b".repeat(64));354});355356test("room tools read rooms and threads, open rooms and post signed messages", async () => {357 const {finalizeEvent, generateSecretKey, verifyEvent} = await import("nostr-tools/pure");358 const requests = [], calls = [];359 const {tools, sandbox} = await browser({path: "/r/work/tools", fetch: async url => {360 requests.push(new URL(url, "https://tiny.example"));361 return new Response(JSON.stringify({items: [{id: "build"}]}));362 }, signedFetch: async (url, method, body, options) => {363 calls.push({url, method, body: JSON.parse(body), options});364 return new Response(JSON.stringify(calls.length > 1 ? {accepted: false, message: "restricted: members only"} : {accepted: true}));365 }});366 const secret = generateSecretKey();367 sandbox.nostr = {signEvent: async event => finalizeEvent(JSON.parse(JSON.stringify(event)), secret)};368 sandbox.NostrSigner = {verifyEvent};369 for (const [name, method, input] of [370 ["tiny.list_rooms", "browserooms", {limit: 5}],371 ["tiny.read_room", "browseroom", {id: "build", cursor: "c1"}],372 ["tiny.read_thread", "browsethread", {id: "build", event: "a".repeat(64)}]373 ]) {374 const tool = tools.get(name);375 assert.equal(tool.annotations.readOnlyHint, true);376 await tool.execute(input);377 const url = requests.at(-1);378 assert.equal(url.pathname, "/r/work/webmcp/query");379 assert.equal(url.searchParams.get("method"), method);380 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [input]);381 }382 await tools.get("tiny.open_room").execute({id: "build"});383 assert.equal(new URL(sandbox.opened).pathname, "/r/work/rooms/build");384 await tools.get("tiny.open_room").execute({});385 assert.equal(new URL(sandbox.opened).pathname, "/r/work/rooms");386 const post = tools.get("tiny.post_message");387 assert.equal(post.annotations.consequentialHint, true);388 const result = await post.execute({room: "build", text: "hello", mentions: ["b".repeat(64), "b".repeat(64)]});389 assert.equal(calls[0].url, "/r/work/events");390 assert.equal(calls[0].method, "POST");391 assert.equal(calls[0].options.contentType, "application/json");392 assert.equal(calls[0].body.kind, 9);393 assert.deepEqual(calls[0].body.tags, [["h", "build"], ["p", "b".repeat(64)]]);394 assert.equal(verifyEvent(calls[0].body), true);395 assert.equal(result.structuredContent.result.id, calls[0].body.id);396 await assert.rejects(post.execute({room: "build", text: "again"}), /members only/);397 await assert.rejects(post.execute({room: "build", text: " "}), /Enter a message/);398});399400test("the profile tool reads through the session and defaults to the signed-in key", async () => {401 const requests = [];402 const {tools} = await browser({path: "/r/work/profile", fetch: async url => { requests.push(new URL(url, "https://tiny.example")); return new Response(JSON.stringify({pubkey: "a".repeat(64), profile: {name: "dami"}, relays: []})); }});403 const tool = tools.get("tiny.read_profile");404 assert.equal(tool.annotations.readOnlyHint, true);405 const own = await tool.execute({});406 assert.equal(requests[0].searchParams.get("method"), "browseprofile");407 assert.deepEqual(JSON.parse(requests[0].searchParams.get("params")), [{}]);408 assert.equal(own.structuredContent.result.profile.name, "dami");409 await tool.execute({pubkey: "b".repeat(64)});410 assert.deepEqual(JSON.parse(requests[1].searchParams.get("params")), [{pubkey: "b".repeat(64)}]);411});412413test("post_message fails closed when the signer verifier is unavailable", async () => {414 const calls = [];415 const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (...args) => {416 calls.push(args);417 return new Response(JSON.stringify({accepted: true}));418 }});419 const {finalizeEvent, generateSecretKey} = await import("nostr-tools/pure");420 sandbox.nostr = {signEvent: async event => finalizeEvent(JSON.parse(JSON.stringify(event)), generateSecretKey())};421 await assert.rejects(tools.get("tiny.post_message").execute({room: "build", text: "hello"}), /verifier is still loading/);422 assert.equal(calls.length, 0);423});