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";78const 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);1617function 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}5051test("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});8687test("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});9899test("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});123124test("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});133134test("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});149150test("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});156157test("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});