tinyrelayfile
tinyrelay/scripts/webmcp.test.mjs/

scripts/webmcp.test.mjs raw
1import assert from "node:assert/strict";
2import { readFile } from "node:fs/promises";
3import test from "node:test";
4import vm from "node:vm";
5
6const script = await readFile(new URL("../tinyclient/webmcp.js", import.meta.url), "utf8");
7
8async 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: signedFetch
20 };
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}
41
42test("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});
56
57test("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});
78
79test("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});
85
86test("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});
97
98test("navigation actually opens the selected repository without changing tenant", async () => {
99 const {tools, sandbox} = await browser({path: "/r/work/tools"});
100 await tools.get("tiny.open_repository").execute({owner: "a".repeat(64), repo: "notes", path: "hello world.go", view: "file"});
101 const url = new URL(sandbox.opened);
102 assert.equal(url.pathname, "/r/work/repo");
103 assert.equal(url.searchParams.get("path"), "hello world.go");
104 assert.equal(url.searchParams.get("view"), "file");
105});
106
107test("registration failures stay visible while other tools remain available", async () => {
108 const {sandbox} = await browser({failRegistration: "tiny.backup_now"});
109 assert.equal(sandbox.tinyWebMCP.errors.length, 1);
110 assert.match(sandbox.tinyWebMCP.errors[0], /tiny.backup_now: Registration denied/);
111 assert.ok(sandbox.tinyWebMCP.registered.includes("tiny.read_status"));
112 assert.ok(!sandbox.tinyWebMCP.registered.includes("tiny.backup_now"));
113});
114
115test("cancellation reaches the fetch request", async () => {
116 const controller = new AbortController();
117 let received;
118 const {tools} = await browser({fetch: async (_, options) => {
119 received = options.signal;
120 return new Response("{}");
121 }});
122 await tools.get("tiny.list_files").execute({}, {signal: controller.signal});
123 assert.equal(received, controller.signal);
124});
125
126test("collaboration tools preserve repository, event and filter scope", async () => {
127 const requests = [];
128 const {tools} = await browser({path: "/r/work/tools", fetch: async url => {
129 requests.push(new URL(url, "https://tiny.example"));
130 return new Response(JSON.stringify({items: [], item: {title: "Issue"}}));
131 }});
132 const repo = {owner: "a".repeat(64), repo: "notes"};
133 for (const [name, method, extra] of [
134 ["tiny.list_issues", "browseissues", {q: "bug", state: "resolved", limit: 5}],
135 ["tiny.read_issue", "browseissue", {event: "b".repeat(64)}],
136 ["tiny.list_pull_requests", "browsepulls", {state: "merged"}],
137 ["tiny.read_pull_request", "browsepull", {event: "c".repeat(64)}]
138 ]) {
139 const tool = tools.get(name);
140 assert.equal(tool.annotations.readOnlyHint, true);
141 await tool.execute({...repo, ...extra});
142 const url = requests.at(-1);
143 assert.equal(url.pathname, "/r/work/webmcp/query");
144 assert.equal(url.searchParams.get("method"), method);
145 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{...repo, ...extra}]);
146 }
147});
148
149test("approval tools list, read and open requests for a decision", async () => {
150 const requests = [];
151 const {tools, sandbox} = await browser({path: "/r/work/approvals", fetch: async url => {
152 requests.push(new URL(url, "https://tiny.example"));
153 return new Response(JSON.stringify({items: [{id: "a".repeat(64), state: "open"}], counts: {open: 1}}));
154 }});
155 const list = tools.get("tiny.list_approvals");
156 assert.equal(list.annotations.readOnlyHint, true);
157 const listed = await list.execute({state: "open", limit: 5});
158 assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");
159 assert.equal(requests.at(-1).searchParams.get("method"), "browseapprovals");
160 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", limit: 5}]);
161 assert.equal(listed.structuredContent.result.counts.open, 1);
162 assert.equal(list.inputSchema.properties.state.enum.join(","), "open,answered,expired,all");
163 const read = tools.get("tiny.read_approval");
164 assert.equal(read.annotations.readOnlyHint, true);
165 assert.equal(read.inputSchema.required.join(","), "id");
166 await read.execute({id: "b".repeat(64)});
167 assert.equal(requests.at(-1).searchParams.get("method"), "browseapproval");
168 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);
169 await tools.get("tiny.open_approvals").execute({});
170 assert.equal(new URL(sandbox.opened).pathname, "/r/work/approvals");
171 await tools.get("tiny.open_approvals").execute({id: "c".repeat(64)});
172 const opened = new URL(sandbox.opened);
173 assert.equal(opened.pathname, "/r/work/approvals");
174 assert.equal(opened.searchParams.get("id"), "c".repeat(64));
175 assert.equal(opened.searchParams.get("answer"), null);
176});
177
178test("job tools list and read long tasks through the session", async () => {
179 const requests = [];
180 const {tools} = await browser({path: "/r/work/tools", fetch: async url => {
181 requests.push(new URL(url, "https://tiny.example"));
182 return new Response(JSON.stringify({items: [{id: "a".repeat(64), kind: 5001, state: "open", status: "processing"}], next_cursor: "",
183 item: {id: "b".repeat(64), kind: 5002, state: "done"}, feedback: [{status: "processing"}], results: [{kind: 6002}]}));
184 }});
185 const list = tools.get("tiny.list_jobs");
186 assert.equal(list.annotations.readOnlyHint, true);
187 assert.equal(list.inputSchema.properties.state.enum.join(","), "open,done,all");
188 assert.equal(list.inputSchema.properties.mine.type, "boolean");
189 const listed = await list.execute({state: "open", mine: true, limit: 5});
190 assert.equal(requests.at(-1).pathname, "/r/work/webmcp/query");
191 assert.equal(requests.at(-1).searchParams.get("method"), "browsejobs");
192 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{state: "open", mine: true, limit: 5}]);
193 assert.equal(listed.structuredContent.result.items[0].status, "processing");
194 const read = tools.get("tiny.read_job");
195 assert.equal(read.annotations.readOnlyHint, true);
196 assert.deepEqual([...read.inputSchema.required], ["id"]);
197 const detail = await read.execute({id: "b".repeat(64)});
198 assert.equal(requests.at(-1).searchParams.get("method"), "browsejob");
199 assert.deepEqual(JSON.parse(requests.at(-1).searchParams.get("params")), [{id: "b".repeat(64)}]);
200 assert.equal(detail.structuredContent.result.item.state, "done");
201 assert.equal(detail.structuredContent.result.results[0].kind, 6002);
202});
203
204test("link, files and notification tools reach the new surfaces", async () => {
205 const calls = [];
206 const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (url, method, body) => {
207 calls.push({url, method, body: JSON.parse(body)});
208 return new Response(JSON.stringify({result: true}), {status: 200});
209 }});
210 sandbox.localStorage = {getItem: key => ({"tiny.push": "https://push.example/x", "tiny.push.categories": "replies,relay"})[key] ?? null};
211 await tools.get("tiny.open_link").execute({target: "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2"});
212 const opened = new URL(sandbox.opened);
213 assert.equal(opened.pathname, "/r/work/open");
214 assert.equal(opened.searchParams.get("target"), "web+nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2");
215 await tools.get("tiny.open_files").execute({});
216 assert.equal(new URL(sandbox.opened).pathname, "/r/work/files");
217 const status = await tools.get("tiny.read_notifications").execute({});
218 assert.equal(status.structuredContent.result.enabled, true);
219 assert.equal(status.structuredContent.result.categories.join(","), "replies,relay");
220 assert.equal(status.structuredContent.result.permission, "unsupported");
221 await tools.get("tiny.send_test_notification").execute({});
222 assert.equal(calls[0].url, "/r/work/manage/rpc");
223 assert.equal(calls[0].body.method, "notifytest");
224});
225
226test("agent tools list through the signer, read through the session and control through management", async () => {
227 const calls = [], queries = [];
228 const {tools} = await browser({path: "/r/work/manage/agents", fetch: async url => {
229 queries.push(new URL(url, "https://tiny.example"));
230 return new Response(JSON.stringify({agent: {name: "hermes"}, events: []}));
231 }, signedFetch: async (url, method, body) => {
232 calls.push({url, method, body: JSON.parse(body)});
233 return new Response(JSON.stringify({result: [{name: "hermes", paused: false}]}));
234 }});
235 const agent = "a".repeat(64);
236 const list = tools.get("tiny.list_agents");
237 assert.equal(list.annotations.readOnlyHint, true);
238 const listed = await list.execute({});
239 assert.equal(listed.structuredContent.result[0].name, "hermes");
240 assert.deepEqual(calls[0].body, {method: "listagents", params: []});
241 assert.equal(calls[0].url, "/r/work/manage/rpc");
242 const read = await tools.get("tiny.read_agent").execute({agent});
243 assert.equal(queries[0].pathname, "/r/work/webmcp/query");
244 assert.equal(queries[0].searchParams.get("method"), "browseagent");
245 assert.deepEqual(JSON.parse(queries[0].searchParams.get("params")), [{agent}]);
246 assert.equal(read.structuredContent.result.agent.name, "hermes");
247 for (const [name, method] of [["tiny.pause_agent", "pauseagent"], ["tiny.resume_agent", "resumeagent"], ["tiny.revoke_agent", "revokeagent"]]) {
248 const tool = tools.get(name);
249 assert.equal(tool.annotations.consequentialHint, true);
250 assert.deepEqual([...tool.inputSchema.required], ["agent"]);
251 await tool.execute({agent});
252 assert.deepEqual(calls.at(-1).body, {method, params: [agent]});
253 }
254});
255
256test("access request tools list through the signer and decide by pubkey", async () => {
257 const calls = [];
258 const asker = "b".repeat(64);
259 const {tools} = await browser({path: "/r/work/manage/people", signedFetch: async (url, method, body) => {
260 calls.push({url, method, body: JSON.parse(body)});
261 const request = JSON.parse(body);
262 if (request.method === "listjoinrequests") {
263 return new Response(JSON.stringify({result: [{pubkey: asker, reason: "Met you at the meetup", requested_at: 1700000000, status: "pending", decided_by: "", decided_at: 0}]}));
264 }
265 return new Response(JSON.stringify({result: {pubkey: asker, status: request.method === "approvejoin" ? "approved" : "denied"}}));
266 }});
267 const list = tools.get("tiny.list_join_requests");
268 assert.equal(list.annotations.readOnlyHint, true);
269 const listed = await list.execute({});
270 assert.equal(listed.structuredContent.result[0].pubkey, asker);
271 assert.equal(listed.structuredContent.result[0].status, "pending");
272 assert.equal(calls[0].url, "/r/work/manage/rpc");
273 assert.equal(calls[0].method, "POST");
274 assert.deepEqual(calls[0].body, {method: "listjoinrequests", params: []});
275 for (const [name, method, status] of [["tiny.approve_join", "approvejoin", "approved"], ["tiny.deny_join", "denyjoin", "denied"]]) {
276 const tool = tools.get(name);
277 assert.equal(tool.annotations.consequentialHint, true);
278 assert.deepEqual([...tool.inputSchema.required], ["pubkey"]);
279 assert.equal(tool.inputSchema.properties.pubkey.pattern, "^[0-9a-f]{64}$");
280 const result = await tool.execute({pubkey: asker});
281 assert.deepEqual(calls.at(-1).body, {method, params: [asker]});
282 assert.equal(result.structuredContent.result.status, status);
283 }
284});
285
286test("callback tools list through the signer and control by id", async () => {
287 const calls = [];
288 const {tools} = await browser({path: "/r/work/manage/agents", signedFetch: async (url, method, body) => {
289 calls.push({url, method, body: JSON.parse(body)});
290 return new Response(JSON.stringify({result: [{id: "cb-1", host: "hooks.example", paused: false}]}));
291 }});
292 const list = tools.get("tiny.list_callbacks");
293 assert.equal(list.annotations.readOnlyHint, true);
294 const listed = await list.execute({});
295 assert.equal(listed.structuredContent.result[0].host, "hooks.example");
296 assert.equal(calls[0].url, "/r/work/manage/rpc");
297 assert.deepEqual(calls[0].body, {method: "listcallbacks", params: []});
298 assert.equal(tools.get("tiny.add_callback"), undefined);
299 for (const [name, method] of [["tiny.pause_callback", "pausecallback"], ["tiny.resume_callback", "resumecallback"], ["tiny.remove_callback", "removecallback"]]) {
300 const tool = tools.get(name);
301 assert.equal(tool.annotations.consequentialHint, true);
302 assert.deepEqual([...tool.inputSchema.required], ["id"]);
303 await tool.execute({id: "cb-1"});
304 assert.deepEqual(calls.at(-1).body, {method, params: ["cb-1"]});
305 }
306});
307
308test("wiki tools read pages and merge requests and open pages in the tenant", async () => {
309 const requests = [];
310 const {tools, sandbox} = await browser({path: "/r/work/wiki", fetch: async url => {
311 requests.push(new URL(url, "https://tiny.example"));
312 return new Response(JSON.stringify({items: [{d: "release-notes-1-4"}], d: "release-notes-1-4", merge: {status: "open"}}));
313 }});
314 for (const [name, method, input] of [
315 ["tiny.list_wiki", "browsewiki", {q: "release", limit: 10}],
316 ["tiny.read_wiki_page", "browsewikipage", {d: "Release Notes 1.4", version: "b".repeat(64)}],
317 ["tiny.read_merge_request", "browsewikimerge", {id: "c".repeat(64)}]
318 ]) {
319 const tool = tools.get(name);
320 assert.equal(tool.annotations.readOnlyHint, true);
321 const result = await tool.execute(input);
322 const url = requests.at(-1);
323 assert.equal(url.pathname, "/r/work/webmcp/query");
324 assert.equal(url.searchParams.get("method"), method);
325 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [input]);
326 assert.equal(result.structuredContent.result.d, "release-notes-1-4");
327 }
328 const open = tools.get("tiny.open_wiki_page");
329 assert.equal(open.annotations.readOnlyHint, undefined);
330 await open.execute({});
331 assert.equal(new URL(sandbox.opened).pathname, "/r/work/wiki");
332 await open.execute({d: "日本語 Article", merge: "c".repeat(64)});
333 let opened = new URL(sandbox.opened);
334 assert.equal(opened.pathname, "/r/work/wiki/" + encodeURIComponent("日本語 Article"));
335 assert.equal(opened.searchParams.get("merge"), "c".repeat(64));
336 await open.execute({d: "release-notes-1-4", edit: true, version: "b".repeat(64)});
337 opened = new URL(sandbox.opened);
338 assert.equal(opened.pathname, "/r/work/wiki/release-notes-1-4");
339 assert.equal(opened.searchParams.get("edit"), "1");
340 assert.equal(opened.searchParams.get("version"), "b".repeat(64));
341});
342
343test("room tools read rooms and threads, open rooms and post signed messages", async () => {
344 const {finalizeEvent, generateSecretKey, verifyEvent} = await import("nostr-tools/pure");
345 const requests = [], calls = [];
346 const {tools, sandbox} = await browser({path: "/r/work/tools", fetch: async url => {
347 requests.push(new URL(url, "https://tiny.example"));
348 return new Response(JSON.stringify({items: [{id: "build"}]}));
349 }, signedFetch: async (url, method, body, options) => {
350 calls.push({url, method, body: JSON.parse(body), options});
351 return new Response(JSON.stringify(calls.length > 1 ? {accepted: false, message: "restricted: members only"} : {accepted: true}));
352 }});
353 const secret = generateSecretKey();
354 sandbox.nostr = {signEvent: async event => finalizeEvent(JSON.parse(JSON.stringify(event)), secret)};
355 sandbox.NostrSigner = {verifyEvent};
356 for (const [name, method, input] of [
357 ["tiny.list_rooms", "browserooms", {limit: 5}],
358 ["tiny.read_room", "browseroom", {id: "build", cursor: "c1"}],
359 ["tiny.read_thread", "browsethread", {id: "build", event: "a".repeat(64)}]
360 ]) {
361 const tool = tools.get(name);
362 assert.equal(tool.annotations.readOnlyHint, true);
363 await tool.execute(input);
364 const url = requests.at(-1);
365 assert.equal(url.pathname, "/r/work/webmcp/query");
366 assert.equal(url.searchParams.get("method"), method);
367 assert.deepEqual(JSON.parse(url.searchParams.get("params")), [input]);
368 }
369 await tools.get("tiny.open_room").execute({id: "build"});
370 assert.equal(new URL(sandbox.opened).pathname, "/r/work/rooms/build");
371 await tools.get("tiny.open_room").execute({});
372 assert.equal(new URL(sandbox.opened).pathname, "/r/work/rooms");
373 const post = tools.get("tiny.post_message");
374 assert.equal(post.annotations.consequentialHint, true);
375 const result = await post.execute({room: "build", text: "hello", mentions: ["b".repeat(64), "b".repeat(64)]});
376 assert.equal(calls[0].url, "/r/work/events");
377 assert.equal(calls[0].method, "POST");
378 assert.equal(calls[0].options.contentType, "application/json");
379 assert.equal(calls[0].body.kind, 9);
380 assert.deepEqual(calls[0].body.tags, [["h", "build"], ["p", "b".repeat(64)]]);
381 assert.equal(verifyEvent(calls[0].body), true);
382 assert.equal(result.structuredContent.result.id, calls[0].body.id);
383 await assert.rejects(post.execute({room: "build", text: "again"}), /members only/);
384 await assert.rejects(post.execute({room: "build", text: " "}), /Enter a message/);
385});
386
387test("the profile tool reads through the session and defaults to the signed-in key", async () => {
388 const requests = [];
389 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: []})); }});
390 const tool = tools.get("tiny.read_profile");
391 assert.equal(tool.annotations.readOnlyHint, true);
392 const own = await tool.execute({});
393 assert.equal(requests[0].searchParams.get("method"), "browseprofile");
394 assert.deepEqual(JSON.parse(requests[0].searchParams.get("params")), [{}]);
395 assert.equal(own.structuredContent.result.profile.name, "dami");
396 await tool.execute({pubkey: "b".repeat(64)});
397 assert.deepEqual(JSON.parse(requests[1].searchParams.get("params")), [{pubkey: "b".repeat(64)}]);
398});
399
400test("post_message fails closed when the signer verifier is unavailable", async () => {
401 const calls = [];
402 const {tools, sandbox} = await browser({path: "/r/work/tools", signedFetch: async (...args) => {
403 calls.push(args);
404 return new Response(JSON.stringify({accepted: true}));
405 }});
406 const {finalizeEvent, generateSecretKey} = await import("nostr-tools/pure");
407 sandbox.nostr = {signEvent: async event => finalizeEvent(JSON.parse(JSON.stringify(event)), generateSecretKey())};
408 await assert.rejects(tools.get("tiny.post_message").execute({room: "build", text: "hello"}), /verifier is still loading/);
409 assert.equal(calls.length, 0);
410});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs