diff --git a/.env.example b/.env.example index d385feb..470a3dd 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ DATA_PATH=./data # the port to use -PORT=3000 +PORT=9272 # the address to the tor SOCKS5 proxy to enable connections to .onion addresses # TOR_PROXY="127.0.0.1:9050" diff --git a/package.json b/package.json index 5004466..ab68bab 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "scripts": { "prepack": "tsc", "start": "node .", - "dev": "nodemon --loader @swc-node/register/esm src/index.ts", - "mcp": "mcp-inspector node . --mcp --port 8080", - "mcp-debug": "mcp-inspector node --inspect-brk . --mcp --port 8080", + "dev": "DATA_PATH=./data nodemon --loader @swc-node/register/esm src/index.ts", + "mcp": "mcp-inspector node . --mcp", + "mcp-debug": "mcp-inspector node --inspect-brk . --mcp", "build": "tsc", "test": "vitest run", "format": "prettier -w .", @@ -60,6 +60,7 @@ "nostr-tools": "^2.11.0", "pac-proxy-agent": "^7.2.0", "process-streams": "^1.0.3", + "qrcode-terminal": "^0.12.0", "rx-nostr": "^3.5.0", "rxjs": "^7.8.2", "streamx": "^2.22.0", @@ -81,6 +82,7 @@ "@types/hash-sum": "^1.0.2", "@types/lodash.throttle": "^4.1.9", "@types/node": "^22.13.14", + "@types/qrcode-terminal": "^0.12.2", "@types/web-push": "^3.6.4", "@types/ws": "^8.18.0", "nodemon": "^3.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87a0889..bc51cf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: process-streams: specifier: ^1.0.3 version: 1.0.3 + qrcode-terminal: + specifier: ^0.12.0 + version: 0.12.0 rx-nostr: specifier: ^3.5.0 version: 3.5.0 @@ -159,6 +162,9 @@ importers: '@types/node': specifier: ^22.13.14 version: 22.13.14 + '@types/qrcode-terminal': + specifier: ^0.12.2 + version: 0.12.2 '@types/web-push': specifier: ^3.6.4 version: 3.6.4 @@ -1204,6 +1210,9 @@ packages: '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + '@types/qrcode-terminal@0.12.2': + resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} + '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} @@ -2663,6 +2672,10 @@ packages: pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + qrcode-terminal@0.12.0: + resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} + hasBin: true + qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} @@ -4406,6 +4419,8 @@ snapshots: '@types/prismjs@1.26.5': {} + '@types/qrcode-terminal@0.12.2': {} + '@types/qs@6.9.18': {} '@types/range-parser@1.2.7': {} @@ -6176,6 +6191,8 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 + qrcode-terminal@0.12.0: {} + qs@6.13.0: dependencies: side-channel: 1.1.0 diff --git a/src/app/index.ts b/src/app/index.ts index a512e14..ac26183 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -14,7 +14,6 @@ import Database from "./database.js"; import { NIP_11_SOFTWARE_URL, SENSITIVE_KINDS } from "../const.js"; import { OWNER_PUBKEY, PORT } from "../env.js"; -import ConfigManager from "../modules/config/config-manager.js"; import ControlApi from "../modules/control/control-api.js"; import ConfigActions from "../modules/control/config-actions.js"; import ReceiverActions from "../modules/control/receiver-actions.js"; @@ -43,7 +42,7 @@ import Switchboard from "../modules/switchboard/switchboard.js"; import Gossip from "../modules/gossip.js"; import database from "../services/database.js"; import secrets from "../services/secrets.js"; -import config from "../services/config.js"; +import bakeryConfig from "../services/config.js"; import logStore from "../services/log-store.js"; import stateManager from "../services/state.js"; import eventCache from "../services/event-cache.js"; @@ -63,7 +62,7 @@ type EventMap = { export default class App extends EventEmitter { running = false; - config: ConfigManager; + config: typeof bakeryConfig; secrets: SecretsManager; state: ApplicationStateManager; signer: SimpleSigner; @@ -95,18 +94,12 @@ export default class App extends EventEmitter { constructor() { super(); - this.config = config; + this.config = bakeryConfig; this.secrets = secrets; this.signer = bakerySigner; - // copy the vapid public key over to config so the web ui can access it - // TODO: this should be moved to another place - this.secrets.on("updated", () => { - this.config.data.vapidPublicKey = this.secrets.get("vapidPublicKey"); - }); - // set owner pubkey from env variable if (!this.config.data.owner && OWNER_PUBKEY) { this.config.setField("owner", OWNER_PUBKEY); diff --git a/src/classes/json-file.ts b/src/classes/json-file.ts index 9da4bf1..c4342a5 100644 --- a/src/classes/json-file.ts +++ b/src/classes/json-file.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "events"; -import { Adapter, Low, LowSync, SyncAdapter } from "lowdb"; +import { LowSync, SyncAdapter } from "lowdb"; +import { JSONFileSync } from "lowdb/node"; type EventMap = { /** fires when file is loaded */ @@ -11,61 +12,17 @@ type EventMap = { saved: [T]; }; -export class ReactiveJsonFile extends EventEmitter> implements Low { - protected db: Low; - adapter: Adapter; - - data: T; - - constructor(adapter: Adapter, defaultData: T) { - super(); - - this.adapter = adapter; - this.db = new Low(adapter, defaultData); - - this.data = this.createProxy(); - } - - private createProxy() { - return (this.data = new Proxy(this.db.data, { - get(target, prop, receiver) { - return Reflect.get(target, prop, receiver); - }, - set: (target, p, newValue, receiver) => { - Reflect.set(target, p, newValue, receiver); - this.emit("changed", target as T, String(p), newValue); - this.emit("updated", target as T); - return true; - }, - })); - } - - async read() { - await this.db.read(); - this.emit("loaded", this.db.data); - this.emit("updated", this.db.data); - this.createProxy(); - } - async write() { - await this.db.write(); - this.emit("saved", this.db.data); - } - update(fn: (data: T) => unknown) { - return this.db.update(fn); - } -} - -export class ReactiveJsonFileSync extends EventEmitter> implements LowSync { +export class ReactiveJsonFile extends EventEmitter> implements LowSync { protected db: LowSync; adapter: SyncAdapter; data: T; - constructor(adapter: SyncAdapter, defaultData: T) { + constructor(path: string, defaultData: T) { super(); - this.adapter = adapter; - this.db = new LowSync(adapter, defaultData); + this.adapter = new JSONFileSync(path); + this.db = new LowSync(this.adapter, defaultData); this.data = this.createProxy(); } @@ -97,4 +54,18 @@ export class ReactiveJsonFileSync extends EventEmitter unknown) { return this.db.update(fn); } + + setDefaults(defaults: Partial) { + // explicitly set default values if fields are not set + for (const [key, value] of Object.entries(defaults)) { + // @ts-expect-error + if (this.data[key] === undefined) this.data[key] = value; + } + this.write(); + } + + setField(field: keyof T, value: T[keyof T]) { + this.data[field] = value; + this.write(); + } } diff --git a/src/const.ts b/src/const.ts index 7998374..bdf06e3 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,7 +1,10 @@ import { EncryptedDirectMessage } from "nostr-tools/kinds"; +export const DEFAULT_PORT = 9272; export const SENSITIVE_KINDS = [EncryptedDirectMessage]; export const NIP_11_SOFTWARE_URL = "git+https://github.com/hzrd149/bakery.git"; export const OUTBOUND_PROXY_TYPES = ["SOCKS5", "HTTP"]; + +export const DEFAULT_NOSTR_CONNECT_RELAYS = ["wss://relay.nsec.app"]; diff --git a/src/env.ts b/src/env.ts index b948a82..5293b51 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,17 +1,19 @@ import "dotenv/config"; import { mkdirp } from "mkdirp"; import { normalizeURL } from "applesauce-core/helpers"; +import { homedir } from "os"; +import { join } from "path"; -import { OUTBOUND_PROXY_TYPES } from "./const.js"; +import { DEFAULT_PORT, OUTBOUND_PROXY_TYPES } from "./const.js"; import { normalizeToHexPubkey } from "./helpers/nip19.js"; import args from "./args.js"; export const OWNER_PUBKEY = process.env.OWNER_PUBKEY ? normalizeToHexPubkey(process.env.OWNER_PUBKEY) : undefined; export const PUBLIC_ADDRESS = process.env.PUBLIC_ADDRESS; -export const DATA_PATH = process.env.DATA_PATH || "./data"; +export const DATA_PATH = process.env.DATA_PATH || join(homedir(), ".bakery"); await mkdirp(DATA_PATH); -export const PORT = parseInt(args.values.port ?? process.env.PORT ?? "") || 3000; +export const PORT = parseInt(args.values.port ?? process.env.PORT ?? "") || DEFAULT_PORT; // I2P config export const I2P_PROXY = process.env.I2P_PROXY; diff --git a/src/index.ts b/src/index.ts index 8676822..bd94e7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,12 +57,14 @@ app.express.get("*", (req, res) => { res.sendFile(path.resolve(appDir, "index.html")); }); -// log uncaught errors +// catch unhandled errors +process.on("uncaughtException", (error) => { + if (!IS_MCP) console.error("Uncaught Exception:", error); +}); + +// Catch unhandled promise rejections process.on("unhandledRejection", (reason, promise) => { - if (reason instanceof Error) { - console.log("Unhandled Rejection"); - console.log(reason); - } else console.log("Unhandled Rejection at:", promise, "reason:", reason); + if (!IS_MCP) console.error("Unhandled Promise Rejection:", reason); }); // start the app @@ -87,13 +89,3 @@ async function shutdown() { } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - -// catch unhandled errors -process.on("uncaughtException", (error) => { - if (!IS_MCP) console.error("Uncaught Exception:", error); -}); - -// 2. Catch unhandled promise rejections -process.on("unhandledRejection", (reason, promise) => { - if (!IS_MCP) console.error("Unhandled Promise Rejection:", reason); -}); diff --git a/src/modules/config/config-manager.ts b/src/modules/config/config-manager.ts deleted file mode 100644 index e81e870..0000000 --- a/src/modules/config/config-manager.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { JSONFileSync } from "lowdb/node"; -import _throttle from "lodash.throttle"; -import { uniqueNamesGenerator, adjectives, colors, animals } from "unique-names-generator"; -import { PrivateNodeConfig } from "@satellite-earth/core/types/private-node-config.js"; - -import { logger } from "../../logger.js"; -import { ReactiveJsonFileSync } from "../../classes/json-file.js"; - -export const defaultConfig: PrivateNodeConfig = { - name: uniqueNamesGenerator({ - dictionaries: [colors, adjectives, animals], - }), - description: "", - - autoListen: false, - runReceiverOnBoot: true, - runScrapperOnBoot: false, - logsEnabled: true, - requireReadAuth: false, - publicAddresses: [], - - hyperEnabled: false, - - enableTorConnections: true, - enableI2PConnections: true, - enableHyperConnections: false, - routeAllTrafficThroughTor: false, - - gossipEnabled: false, - gossipInterval: 10 * 60_000, - gossipBroadcastRelays: [], -}; - -export default class ConfigManager extends ReactiveJsonFileSync { - log = logger.extend("ConfigManager"); - - constructor(path: string) { - super(new JSONFileSync(path), defaultConfig); - - this.on("loaded", (config) => { - // explicitly set default values if fields are not set - for (const [key, value] of Object.entries(defaultConfig)) { - // @ts-expect-error - if (config[key] === undefined) { - // @ts-expect-error - config[key] = value; - } - } - - this.write(); - }); - } - - setField(field: keyof PrivateNodeConfig, value: any) { - this.log(`Setting ${field} to ${value}`); - // @ts-expect-error - this.data[field] = value; - - this.write(); - } -} diff --git a/src/modules/control/config-actions.ts b/src/modules/control/config-actions.ts index 8e38a56..e5f858d 100644 --- a/src/modules/control/config-actions.ts +++ b/src/modules/control/config-actions.ts @@ -35,6 +35,7 @@ export default class ConfigActions implements ControlMessageHandler { const field = message[3]; const value = message[4]; + // @ts-expect-error this.app.config.setField(field, value); return true; diff --git a/src/modules/network/inbound/index.ts b/src/modules/network/inbound/index.ts index f9cba2e..1d584cb 100644 --- a/src/modules/network/inbound/index.ts +++ b/src/modules/network/inbound/index.ts @@ -1,8 +1,7 @@ import { Server } from "http"; import { logger } from "../../../logger.js"; import { getIPAddresses } from "../../../helpers/ip.js"; -import ConfigManager from "../../config/config-manager.js"; -import config from "../../../services/config.js"; +import bakeryConfig from "../../../services/config.js"; import TorInbound from "./tor.js"; import I2PInbound from "./i2p.js"; @@ -29,7 +28,7 @@ export default class InboundNetworkManager { this.tor = new TorInbound(); this.i2p = new I2PInbound(); - this.listenToAppConfig(config); + this.listenToAppConfig(bakeryConfig); } private getAddress() { @@ -41,7 +40,7 @@ export default class InboundNetworkManager { return address; } - private update(cfg = config.data) { + private update(cfg = bakeryConfig.data) { if (!this.running) return; const address = this.getAddress(); @@ -60,7 +59,7 @@ export default class InboundNetworkManager { } /** A helper method to make the manager run off of the app config */ - listenToAppConfig(config: ConfigManager) { + listenToAppConfig(config: typeof bakeryConfig) { config.on("updated", this.update.bind(this)); } diff --git a/src/modules/network/outbound/index.ts b/src/modules/network/outbound/index.ts index 9aa29e2..95c4b08 100644 --- a/src/modules/network/outbound/index.ts +++ b/src/modules/network/outbound/index.ts @@ -2,10 +2,10 @@ import { PacProxyAgent } from "pac-proxy-agent"; import _throttle from "lodash.throttle"; import { logger } from "../../../logger.js"; -import ConfigManager from "../../config/config-manager.js"; import HyperOutbound from "./hyper.js"; import TorOutbound from "./tor.js"; import I2POutbound from "./i2p.js"; +import bakeryConfig from "../../../services/config.js"; export default class OutboundNetworkManager { log = logger.extend("Network:Outbound"); @@ -95,7 +95,7 @@ function FindProxyForURL(url, host) updateAgentThrottle: () => void = _throttle(this.updateAgent.bind(this), 100); /** A helper method to make the manager run off of the app config */ - listenToAppConfig(config: ConfigManager) { + listenToAppConfig(config: typeof bakeryConfig) { config.on("updated", (c) => { this.enableHyperConnections = c.hyperEnabled && c.enableHyperConnections; this.enableTorConnections = c.enableTorConnections; diff --git a/src/modules/notifications/notifications-manager.ts b/src/modules/notifications/notifications-manager.ts index 5305532..eb6c2c5 100644 --- a/src/modules/notifications/notifications-manager.ts +++ b/src/modules/notifications/notifications-manager.ts @@ -8,7 +8,7 @@ import webPush from "web-push"; import { logger } from "../../logger.js"; import App from "../../app/index.js"; import stateManager from "../../services/state.js"; -import config from "../../services/config.js"; +import bakeryConfig from "../../services/config.js"; import { getDMRecipient, getDMSender } from "../../helpers/direct-messages.js"; export type NotificationsManagerState = { @@ -73,7 +73,7 @@ export default class NotificationsManager extends EventEmitter { /** Whether a notification should be sent */ shouldNotify(event: NostrEvent) { if (event.kind !== kinds.EncryptedDirectMessage) return; - if (getDMRecipient(event) !== config.data.owner) return; + if (getDMRecipient(event) !== bakeryConfig.data.owner) return; if (event.created_at > this.lastRead) return true; } diff --git a/src/modules/queries/queries/config.ts b/src/modules/queries/queries/config.ts index 7f6bd62..6b96b01 100644 --- a/src/modules/queries/queries/config.ts +++ b/src/modules/queries/queries/config.ts @@ -1,13 +1,12 @@ import { Observable } from "rxjs"; -import { PrivateNodeConfig } from "@satellite-earth/core/types"; import { Query } from "../types.js"; -import config from "../../../services/config.js"; +import bakeryConfig, { BakeryConfig } from "../../../services/config.js"; -export const ConfigQuery: Query = () => +export const ConfigQuery: Query = () => new Observable((observer) => { - observer.next(config.data); - const listener = (c: PrivateNodeConfig) => observer.next(c); - config.on("updated", listener); - return () => config.off("updated", listener); + observer.next(bakeryConfig.data); + const listener = (c: BakeryConfig) => observer.next(c); + bakeryConfig.on("updated", listener); + return () => bakeryConfig.off("updated", listener); }); diff --git a/src/modules/receiver/index.ts b/src/modules/receiver/index.ts index 1bffbf1..82d19df 100644 --- a/src/modules/receiver/index.ts +++ b/src/modules/receiver/index.ts @@ -100,7 +100,6 @@ export default class Receiver extends EventEmitter { // if the user does not have any mailboxes try to get the relays stored in the contact list if (relays.length === 0) { - this.log(`Failed to find mailboxes for ${person.pubkey}`); const contacts = await this.app.contactBook.loadContactsEvent( person.pubkey, arrayFallback(ownerMailboxes?.inboxes, BOOTSTRAP_RELAYS), diff --git a/src/services/config.ts b/src/services/config.ts index db53205..32a61c5 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -1,10 +1,59 @@ +import { animals, colors, adjectives, uniqueNamesGenerator } from "unique-names-generator"; import path from "node:path"; +import { z } from "zod"; -import ConfigManager from "../modules/config/config-manager.js"; import { DATA_PATH } from "../env.js"; +import { ReactiveJsonFile } from "../classes/json-file.js"; -const config = new ConfigManager(path.join(DATA_PATH, "node.json")); +export const bakeryConfigSchema = z.object({ + name: z.string().default(""), + description: z.string().default(""), -config.read(); + owner: z.string().optional(), + public_address: z.string().url().optional(), -export default config; + // legacy config (unused) + requireReadAuth: z.boolean().default(false), + publicAddresses: z.array(z.string().url()).default([]), + autoListen: z.boolean().default(false), + logsEnabled: z.boolean().default(false), + + // scrapper config + runReceiverOnBoot: z.boolean().default(true), + runScrapperOnBoot: z.boolean().default(false), + + // nostr network config + bootstrap_relays: z.array(z.string().url()).optional(), + lookup_relays: z.array(z.string().url()).optional(), + + // hyper config + hyperEnabled: z.boolean().default(false), + + // tor config + enableTorConnections: z.boolean().default(true), + enableI2PConnections: z.boolean().default(true), + enableHyperConnections: z.boolean().default(false), + routeAllTrafficThroughTor: z.boolean().default(false), + + // gossip config + gossipEnabled: z.boolean().default(false), + gossipInterval: z.number().default(10 * 60_000), + gossipBroadcastRelays: z.array(z.string().url()).default([]), +}); + +export type BakeryConfig = z.infer; + +const defaultConfig: BakeryConfig = bakeryConfigSchema.parse({}); + +const bakeryConfig = new ReactiveJsonFile(path.join(DATA_PATH, "node.json"), defaultConfig); +bakeryConfig.read(); + +// explicitly set the default values +bakeryConfig.setDefaults(defaultConfig); + +// set a new name if it is not set +if (!bakeryConfig.data.name) { + bakeryConfig.data.name = uniqueNamesGenerator({ dictionaries: [colors, adjectives, animals] }); +} + +export default bakeryConfig; diff --git a/src/services/mcp/resources.ts b/src/services/mcp/resources.ts index 512e08c..5edf2bf 100644 --- a/src/services/mcp/resources.ts +++ b/src/services/mcp/resources.ts @@ -1,7 +1,7 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import server from "./server.js"; -import config from "../config.js"; +import bakeryConfig from "../config.js"; import { normalizeToHexPubkey } from "../../helpers/nip19.js"; import { requestLoader } from "../loaders.js"; import { kinds } from "nostr-tools"; @@ -10,7 +10,7 @@ server.resource("owner_pubkey", "pubkey://owner", async (uri) => ({ contents: [ { uri: uri.href, - text: config.data.owner ?? "undefined", + text: bakeryConfig.data.owner ?? "undefined", }, ], })); @@ -19,7 +19,7 @@ server.resource("config", "config://app", async (uri) => ({ contents: [ { uri: uri.href, - text: JSON.stringify(config.data), + text: JSON.stringify(bakeryConfig.data), }, ], })); diff --git a/src/services/mcp/tools/config.ts b/src/services/mcp/tools/config.ts index e196977..d0c8b49 100644 --- a/src/services/mcp/tools/config.ts +++ b/src/services/mcp/tools/config.ts @@ -1,37 +1,20 @@ -import { z } from "zod"; - import server from "../server.js"; -import { ownerAccount$ } from "../../owner.js"; -import { NostrConnectSigner } from "applesauce-signers"; -import { NostrConnectAccount } from "applesauce-accounts/accounts"; -import { normalizeToHexPubkey } from "../../../helpers/nip19.js"; -import config from "../../config.js"; +import bakeryConfig, { bakeryConfigSchema } from "../../config.js"; -server.tool( - "set_owner_nostr_connect_uri", - "Sets the nostr connect URI that should be used to request signatures from the owners pubkey", - { uri: z.string().startsWith("bunker://") }, - async ({ uri }) => { - try { - const signer = await NostrConnectSigner.fromBunkerURI(uri, {}); - const pubkey = await signer.getPublicKey(); - const account = new NostrConnectAccount(pubkey, signer); - ownerAccount$.next(account); +server.tool("get_bakery_config", "Gets the current configuration for the bakery", {}, async () => { + return { content: [{ type: "text", text: JSON.stringify(bakeryConfig.data) }] }; +}); - return { content: [{ type: "text", text: "Connected to the nostr signer" }] }; - } catch (error: any) { - return { content: [{ type: "text", text: "Error connecting to the nostr signer: " + error.message }] }; - } - }, -); - -server.tool( - "set_owner_pubkey", - "Sets the owner of the bakery", - { pubkey: z.string().transform((hex) => normalizeToHexPubkey(hex, true)) }, - async ({ pubkey }) => { - config.setField("owner", pubkey); - - return { content: [{ type: "text", text: "Owner set" }] }; +server.tool( + "update_bakery_config", + "Updates the bakery config with the provided config", + // @ts-expect-error + bakeryConfigSchema.partial(), + async (config) => { + bakeryConfig.update((data) => { + return { ...data, ...config }; + }); + + return { content: [{ type: "text", text: "Updated config" }] }; }, ); diff --git a/src/services/mcp/tools/events.ts b/src/services/mcp/tools/events.ts index 9970c9b..71aa8aa 100644 --- a/src/services/mcp/tools/events.ts +++ b/src/services/mcp/tools/events.ts @@ -6,7 +6,7 @@ import z from "zod"; import server from "../server.js"; import { ownerFactory, ownerPublish } from "../../owner.js"; import { requestLoader } from "../../loaders.js"; -import config from "../../config.js"; +import bakeryConfig from "../../config.js"; import eventCache from "../../event-cache.js"; import { normalizeToHexPubkey } from "../../../helpers/nip19.js"; @@ -52,9 +52,9 @@ server.tool( relays: z.array(z.string().url()).optional().describe("An array of relays to publish to"), }, async ({ event, relays }) => { - if (!config.data.owner) throw new Error("Owner not set"); + if (!bakeryConfig.data.owner) throw new Error("Owner not set"); - relays = relays || (await requestLoader.mailboxes({ pubkey: config.data.owner })).outboxes; + relays = relays || (await requestLoader.mailboxes({ pubkey: bakeryConfig.data.owner })).outboxes; const results = await ownerPublish(event, relays); if (!results) throw new Error("Failed to publish event to relays"); diff --git a/src/services/mcp/tools/index.ts b/src/services/mcp/tools/index.ts index b422980..f232617 100644 --- a/src/services/mcp/tools/index.ts +++ b/src/services/mcp/tools/index.ts @@ -3,3 +3,4 @@ import "./config.js"; import "./connection.js"; import "./database.js"; import "./events.js"; +import "./signer.js"; diff --git a/src/services/mcp/tools/signer.ts b/src/services/mcp/tools/signer.ts new file mode 100644 index 0000000..bf263bb --- /dev/null +++ b/src/services/mcp/tools/signer.ts @@ -0,0 +1,88 @@ +import { NostrConnectSigner } from "applesauce-signers/signers/nostr-connect-signer"; +import { NostrConnectAccount } from "applesauce-accounts/accounts/nostr-connect-account"; +import { z } from "zod"; + +import server from "../server.js"; +import { ownerAccount$, setupSigner$, startSignerSetup, stopSignerSetup } from "../../owner.js"; +import { DEFAULT_NOSTR_CONNECT_RELAYS } from "../../../const.js"; + +const qrcode = require("qrcode-terminal"); + +server.tool( + "connect_nostr_signer", + "Connects remote signer using a bunker:// URI", + { uri: z.string().startsWith("bunker://") }, + async ({ uri }) => { + if (ownerAccount$.value) return { content: [{ type: "text", text: "The owner already has a signer connected" }] }; + + const signer = await NostrConnectSigner.fromBunkerURI(uri, {}); + const pubkey = await signer.getPublicKey(); + const account = new NostrConnectAccount(pubkey, signer); + ownerAccount$.next(account); + + return { content: [{ type: "text", text: "Connected to the signer" }] }; + }, +); + +server.tool("disconnect_nostr_signer", "Disconnects and forgets the current signer", {}, async () => { + if (!ownerAccount$.value) return { content: [{ type: "text", text: "No signer connected" }] }; + ownerAccount$.next(undefined); + + return { content: [{ type: "text", text: "Disconnected from the signer" }] }; +}); + +server.tool("nostr_signer_status", "Gets the status of the current signer", {}, async () => { + const account = ownerAccount$.getValue(); + if (!account) return { content: [{ type: "text", text: "No signer connected" }] }; + + if (setupSigner$.value) { + return { content: [{ type: "text", text: "Signer setup in progress, waiting for the owner to connect" }] }; + } + + return { + content: [ + { + type: "text", + text: [ + `Pubkey: ${await account.getPublicKey()}`, + `Connected: ${account.signer.isConnected}`, + `Relays: ${account.signer.relays.join(", ")}`, + ].join("\n"), + }, + ], + }; +}); + +// signer setup tools +server.tool( + "create_signer_setup_link", + "Creates a nostrconnect:// URI for the owner to setup their signer", + { relays: z.array(z.string().url()).default(DEFAULT_NOSTR_CONNECT_RELAYS) }, + async ({ relays }) => { + const account = ownerAccount$.getValue(); + if (account) return { content: [{ type: "text", text: "A signer is already connected" }] }; + + // Create a new signer if there isn't one already + if (!setupSigner$.value) startSignerSetup(relays); + + const uri = setupSigner$.value!.getNostrConnectURI(); + + // Generate QR code + const qr = await new Promise((resolve) => { + qrcode.generate(uri, { small: true }, (qr: string) => resolve(qr)); + }); + + return { + content: [ + { type: "text", text: qr }, + { type: "text", text: `Nostr Connect URI: ${uri}` }, + ], + }; + }, +); + +server.tool("abort_nostr_signer_setup", "Aborts the signer setup process", {}, async () => { + await stopSignerSetup(); + + return { content: [{ type: "text", text: "signer setup aborted" }] }; +}); diff --git a/src/services/owner.ts b/src/services/owner.ts index 6fae84a..74e4c3c 100644 --- a/src/services/owner.ts +++ b/src/services/owner.ts @@ -10,16 +10,45 @@ import { nostrConnectPublish, nostrConnectSubscription } from "../helpers/apples import { NostrEvent } from "nostr-tools"; import eventCache from "./event-cache.js"; import { requestLoader } from "./loaders.js"; -import config from "./config.js"; +import bakeryConfig from "./config.js"; import { rxNostr } from "./rx-nostr.js"; import { logger } from "../logger.js"; import { NostrConnectAccount } from "applesauce-accounts/accounts"; +import { DEFAULT_NOSTR_CONNECT_RELAYS } from "../const.js"; NostrConnectSigner.subscriptionMethod = nostrConnectSubscription; NostrConnectSigner.publishMethod = nostrConnectPublish; const log = logger.extend("Owner"); +/** A temp signer while the owner is setting up their signer */ +export const setupSigner$ = new BehaviorSubject(undefined); + +export function startSignerSetup(relays = DEFAULT_NOSTR_CONNECT_RELAYS) { + if (setupSigner$.value) return setupSigner$.value; + + const signer = new NostrConnectSigner({ relays }); + setupSigner$.next(signer); + + // async setup process + const p = signer.waitForSigner().then(async () => { + const pubkey = await signer.getPublicKey(); + ownerAccount$.next(new NostrConnectAccount(pubkey, signer)); + setupSigner$.next(undefined); + }); + + return p; +} + +export async function stopSignerSetup() { + const signer = setupSigner$.getValue(); + if (signer) { + signer.close(); + setupSigner$.next(undefined); + } +} + +/** The owner's account */ export const ownerAccount$ = new BehaviorSubject | undefined>(undefined); // Update account when secrets change @@ -71,9 +100,9 @@ export async function ownerPublish(event: NostrEvent, relays?: string[]) { eventCache.addEvent(event); // publish event to owners outboxes - if (config.data.owner) { + if (bakeryConfig.data.owner) { try { - relays = relays || (await requestLoader.mailboxes({ pubkey: config.data.owner })).outboxes; + relays = relays || (await requestLoader.mailboxes({ pubkey: bakeryConfig.data.owner })).outboxes; return await lastValueFrom(rxNostr.send(event, { on: { relays } }).pipe(toArray())); } catch (error) { // Failed to publish to outboxes, ignore error for now