tinyrelayfile
tinyrelay/scripts/agent-grant.test.mjs/

scripts/agent-grant.test.mjs raw
1import test from "node:test";
2import assert from "node:assert/strict";
3import fs from "node:fs";
4import vm from "node:vm";
5import {finalizeEvent, generateSecretKey, getPublicKey, nip19, verifyEvent} from "nostr-tools";
6import {sharedSigning} from "./test-signing.mjs";
7
8const owner = generateSecretKey();
9const ownerPubkey = getPublicKey(owner);
10const repoOwner = "b".repeat(64);
11const source = fs.readFileSync("tinyclient/components.js", "utf8");
12const tinySource = fs.readFileSync("tinyclient/tiny.js", "utf8");
13const grantSource = source.slice(source.indexOf(" class AgentGrant "), source.indexOf(" class NostrKey "));
14const today = new Date();
15const dateAfter = days => new Date(today.getTime() + days * 86400000).toISOString().slice(0, 10);
16
17function setup(values = {}, options = {}) {
18 const sent = [], signed = [], appended = [];
19 let resets = 0, navigated = 0;
20 class FormElement {
21 getAttribute() { return null; }
22 report(message) { this.message = message; }
23 append(...nodes) { appended.push(...nodes); }
24 }
25 const el = (name, text) => ({name, text, children: [], attributes: {}, append(...nodes) { this.children.push(...nodes); }, setAttribute(key, value) { this.attributes[key] = value; }, addEventListener() {}});
26 const tinySandbox = {globalThis: {}, crypto: {}, TextEncoder, URL};
27 vm.runInNewContext(tinySource, tinySandbox);
28 const window = {
29 NostrSigner: {verifyEvent, generateSecretKey, getPublicKey},
30 nostr: {signEvent: async event => {
31 signed.push(structuredClone(event));
32 if (options.change) event.tags.push(["k", "7"]);
33 return finalizeEvent(structuredClone(event), options.signer || owner);
34 }}
35 };
36 const tiny = {
37 util: tinySandbox.globalThis.tiny.util,
38 navigate: async () => { navigated++; },
39 signedFetch: async (path, method, body) => {
40 sent.push({path, method, event: JSON.parse(body)});
41 return Response.json(options.result ?? {accepted: true});
42 }
43 };
44 tiny.signing = sharedSigning(window, tiny);
45 const Constructor = vm.runInNewContext(`${grantSource}\nAgentGrant`, {FormElement, window, tiny, el, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || ""), globalThis: {location: {pathname: "/manage/agents"}}});
46 const component = new Constructor();
47 const form = {elements: Object.fromEntries(Object.entries({key: "generate", name: "helper", expires: dateAfter(90), rooms: "", repos: "", kinds: "", wiki: "", sites: "", rate: "60", pubkey: "", ...values}).map(([key, value]) => [key, {value}])), reset: () => { resets++; }};
48 return {Constructor, component, form, sent, signed, appended, resets: () => resets, navigated: () => navigated};
49}
50
51test("a generated key yields a signed grant, an nsec shown once and no reload", async () => {
52 const s = setup({rooms: "build, agents", repos: `${repoOwner}:tinyrelay:maintain\n${repoOwner}:docs:read`, kinds: "9, 1111, 1621", wiki: "propose", rate: "30"});
53 await s.component.submit(s.form);
54 assert.equal(s.sent.length, 1);
55 const {event} = s.sent[0];
56 assert.equal(s.sent[0].path, "/events");
57 assert.equal(event.kind, 30392);
58 assert.equal(event.pubkey, ownerPubkey);
59 assert.equal(verifyEvent(event), true);
60 const agent = event.tags[0][1];
61 assert.match(agent, /^[0-9a-f]{64}$/);
62 assert.notEqual(agent, ownerPubkey);
63 const expiration = Number(event.tags.find(tag => tag[0] === "expiration")[1]);
64 const now = Math.floor(Date.now() / 1000);
65 assert.ok(expiration > now + 89 * 86400 && expiration <= now + 91 * 86400, "expiration is about 90 days out");
66 assert.deepEqual(event.tags.filter(tag => tag[0] !== "expiration"), [
67 ["d", agent], ["p", agent], ["name", "helper"],
68 ["room", "build"], ["room", "agents"],
69 ["repo", `${repoOwner}:tinyrelay:maintain`], ["repo", `${repoOwner}:docs:read`],
70 ["k", "9"], ["k", "1111"], ["k", "1621"],
71 ["wiki", "propose"], ["rate", "30"]
72 ]);
73 assert.equal(event.content, "");
74 // The secret is shown once, in a read-only field, and matches the agent key.
75 const secretInput = s.appended.flatMap(node => node.children || []).find(node => typeof node.value === "string" && node.value.startsWith("nsec1"));
76 assert.ok(secretInput, "nsec field appended");
77 assert.equal(secretInput.readOnly, true);
78 const decoded = nip19.decode(secretInput.value);
79 assert.equal(decoded.type, "nsec");
80 assert.equal(getPublicKey(decoded.data), agent);
81 assert.ok(s.appended.some(node => node.attributes?.role === "alert"), "warning shown");
82 assert.equal(s.navigated(), 0);
83 assert.equal(s.resets(), 0);
84 assert.equal(s.component.message, "Granted.");
85});
86
87test("a pasted public key signs the grant and reloads the page", async () => {
88 const agent = "c".repeat(64);
89 const s = setup({key: "paste", pubkey: " " + agent.toUpperCase() + " ", name: "", rate: ""});
90 await s.component.submit(s.form);
91 const {event} = s.sent[0];
92 assert.deepEqual(event.tags.map(tag => tag[0]), ["d", "p", "expiration"]);
93 assert.equal(event.tags[0][1], agent);
94 assert.equal(s.appended.length, 0);
95 assert.equal(s.navigated(), 1);
96 assert.equal(s.resets(), 1);
97});
98
99test("invalid fields are refused before anything is signed", async () => {
100 for (const [values, message] of [
101 [{key: "paste", pubkey: "nope"}, /public key/],
102 [{name: "x".repeat(65)}, /64 characters/],
103 [{expires: dateAfter(-1)}, /after today/],
104 [{expires: dateAfter(400)}, /365 days/],
105 [{repos: "tinyrelay:maintain"}, /Repositories are/],
106 [{repos: `${repoOwner}:tinyrelay:admin`}, /Repositories are/],
107 [{kinds: "1, abc"}, /Kinds are/],
108 [{kinds: "70000"}, /Kinds are/],
109 [{wiki: "delete"}, /propose or edit/],
110 [{sites: "not-a-site"}, /Sites are/],
111 [{sites: "* ttl=0"}, /1 to 365/],
112 [{sites: "* ttl=366"}, /1 to 365/],
113 [{sites: "* public"}, /1 to 365/],
114 [{rate: "0"}, /1 to 600/],
115 [{rate: "601"}, /1 to 600/]
116 ]) {
117 const s = setup(values);
118 await assert.rejects(s.component.submit(s.form), message);
119 assert.equal(s.signed.length, 0);
120 assert.equal(s.sent.length, 0);
121 }
122});
123
124test("a changed event, a self grant and a relay refusal keep the form", async () => {
125 const changed = setup({}, {change: true});
126 await assert.rejects(changed.component.submit(changed.form), /invalid or changed/);
127 assert.equal(changed.sent.length, 0);
128 const refused = setup({}, {result: {accepted: false, message: "restricted: moderator required"}});
129 await assert.rejects(refused.component.submit(refused.form), /moderator required/);
130 assert.equal(refused.appended.length, 0);
131 assert.equal(refused.resets(), 0);
132});
133
134test("sites lines become sites tags with their ttl and encrypted flag", async () => {
135 const npub = nip19.npubEncode("e".repeat(64));
136 const named = "0".repeat(50) + "docs";
137 const s = setup({sites: `${npub} ttl=30 encrypted\n ${named}\n* ttl=7\n${npub} ttl=1`});
138 await s.component.submit(s.form);
139 const {event} = s.sent[0];
140 assert.deepEqual(event.tags.filter(tag => tag[0] === "sites"), [
141 ["sites", npub, "ttl=30", "encrypted"],
142 ["sites", named],
143 ["sites", "*", "ttl=7"]
144 ]);
145 const wiki = event.tags.findIndex(tag => tag[0] === "wiki");
146 const first = event.tags.findIndex(tag => tag[0] === "sites");
147 assert.ok(wiki === -1 || first > wiki, "sites follow the wiki tag");
148});
149
150test("tags dedupe rooms, repositories and kinds", () => {
151 const {Constructor} = setup();
152 const agent = "d".repeat(64);
153 const tags = Constructor.tags({name: " bot ", expires: dateAfter(10), rooms: "a a, b", repos: `${repoOwner}:r:read\n${repoOwner}:r:maintain`, kinds: "1 1 2", wiki: "", rate: ""}, agent);
154 assert.deepEqual(JSON.parse(JSON.stringify(tags)).filter(tag => tag[0] !== "expiration"), [["d", agent], ["p", agent], ["name", "bot"], ["room", "a"], ["room", "b"], ["repo", `${repoOwner}:r:read`], ["k", "1"], ["k", "2"]]);
155});
156
157test("a repository line accepts the propose level", async () => {
158 const s = setup({repos: `${repoOwner}:tinyrelay:propose\n${repoOwner}:docs:read`, kinds: "1621, 1111"});
159 await s.component.submit(s.form);
160 const {event} = s.sent[0];
161 assert.deepEqual(event.tags.filter(tag => tag[0] === "repo"), [["repo", `${repoOwner}:tinyrelay:propose`], ["repo", `${repoOwner}:docs:read`]]);
162 const {Constructor} = setup();
163 assert.throws(() => Constructor.tags({name: "bot", expires: dateAfter(10), rooms: "", repos: `${repoOwner}:tinyrelay:proposer`, kinds: "", wiki: "", rate: ""}, "d".repeat(64)), /propose, read or maintain/);
164});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs