| 1 | import test from "node:test" ; |
| 2 | import assert from "node:assert/strict" ; |
| 3 | import fs from "node:fs" ; |
| 4 | import vm from "node:vm" ; |
| 5 | import {finalizeEvent, generateSecretKey, getPublicKey, nip19, verifyEvent} from "nostr-tools" ; |
| 6 | import {sharedSigning} from "./test-signing.mjs" ; |
| 7 | |
| 8 | const owner = generateSecretKey(); |
| 9 | const ownerPubkey = getPublicKey(owner); |
| 10 | const repoOwner = "b" .repeat(64); |
| 11 | const source = fs.readFileSync("tinyclient/components.js" , "utf8" ); |
| 12 | const tinySource = fs.readFileSync("tinyclient/tiny.js" , "utf8" ); |
| 13 | const grantSource = source.slice(source.indexOf(" class AgentGrant " ), source.indexOf(" class NostrKey " )); |
| 14 | const today = new Date(); |
| 15 | const dateAfter = days => new Date(today.getTime() + days * 86400000).toISOString().slice(0, 10); |
| 16 | |
| 17 | function 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 | |
| 51 | test("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 | |
| 87 | test("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 | |
| 99 | test("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 | |
| 124 | test("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 | |
| 134 | test("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 | |
| 150 | test("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 | |
| 157 | test("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 | }); |