UNPKG

askexperts

Version:

AskExperts SDK: build and use AI experts - ask them questions and pay with bitcoin on an open protocol

823 lines 29.6 kB
import express from "express"; import cors from "cors"; import { debugServer, debugError } from "../common/debug.js"; import { parseAuthToken } from "../common/auth.js"; import { getDB } from "./utils.js"; import { bytesToHex, hexToBytes } from "nostr-tools/utils"; import { getPublicKey } from "nostr-tools"; import { createWallet } from "nwc-enclaved-utils"; import { generateRandomKeyPair } from "../common/crypto.js"; /** * DBServer class that provides an HTTP API for DBInterface operations */ export class DBServer { /** * Creates a new DBServer instance * * @param options - Configuration options */ constructor(options) { this.stopped = true; this.db = getDB(); this.port = options.port; this.basePath = options.basePath ? options.basePath.startsWith("/") ? options.basePath : `/${options.basePath}` : ""; this.perms = options.perms; this.serverOrigin = options.origin || `http://localhost:${this.port}`; // Create the Express app this.app = express(); // Configure middleware this.app.use(cors()); this.app.use(express.json({ limit: "1mb", verify: (req, res, buf) => { req.rawBody = buf; }, })); // Add authentication middleware if perms is provided if (this.perms) { this.app.use(this.authMiddleware.bind(this)); } // Set up routes this.setupRoutes(); } getApp() { return this.app; } getDB() { return this.db; } /** * Authentication middleware * Parses the auth token and checks permissions */ async authMiddleware(req, res, next) { try { // Convert the request to AuthRequest const authReq = { headers: req.headers, method: req.method, originalUrl: req.originalUrl, cookies: req.cookies, rawBody: req.rawBody, req, }; // Parse the auth token const pubkey = this.perms ? await this.perms.parseAuthToken(this.serverOrigin, authReq) : await parseAuthToken(this.serverOrigin, authReq); // If pubkey is empty, authentication failed if (!pubkey) { debugError("Authentication failed: Invalid or missing token"); res.status(401).json({ error: "Unauthorized", message: "Invalid or missing authentication token", }); return; } // Store the pubkey in the request for later use req.pubkey = pubkey; // Check if this is the signup endpoint - skip permission check for it const isSignupEndpoint = req.path.endsWith("/signup"); // Check permissions if perms is provided if (this.perms && !isSignupEndpoint) { try { // Get user_id and store it in the request const user_id = await this.perms.getUserId(pubkey); req.user_id = user_id; debugServer(`Request by user ${user_id} to ${req.path}`); const permsResult = await this.perms.checkPerms(user_id, req); // Store the perms result in the request for later use req.perms = permsResult; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Permission denied for this operation"; debugError(`Permission check error: ${errorMessage}`); res.status(403).json({ error: "Forbidden", message: errorMessage }); return; } } // Authentication and authorization successful, proceed to the route handler next(); } catch (error) { debugError("Authentication error:", error); res.status(500).json({ error: "Internal Server Error", message: "Authentication error", }); } } /** * Sets up the API routes * @private */ setupRoutes() { // Add a leading slash if basePath is not empty const path = this.basePath ? this.basePath.endsWith("/") ? this.basePath : `${this.basePath}/` : "/"; // Signup endpoint this.app.post(`${path}signup`, this.handleSignup.bind(this)); // Health check endpoint this.app.get(`${path}health`, (req, res) => { if (this.stopped) res.status(503).json({ error: "Service unavailable" }); else res.status(200).json({ status: "ok" }); }); // Whoami endpoint - maps to getUserId() this.app.get(`${path}whoami`, this.handleWhoami.bind(this)); // Wallet endpoints this.app.get(`${path}wallets`, this.handleListWallets.bind(this)); this.app.get(`${path}wallets/default`, this.handleGetDefaultWallet.bind(this)); this.app.get(`${path}wallets/:id`, this.handleGetWallet.bind(this)); this.app.get(`${path}wallets/name/:name`, this.handleGetWalletByName.bind(this)); this.app.post(`${path}wallets`, this.handleInsertWallet.bind(this)); this.app.put(`${path}wallets/:id`, this.handleUpdateWallet.bind(this)); this.app.delete(`${path}wallets/:id`, this.handleDeleteWallet.bind(this)); // Expert endpoints this.app.get(`${path}experts`, this.handleListExperts.bind(this)); this.app.get(`${path}experts/:pubkey`, this.handleGetExpert.bind(this)); this.app.post(`${path}experts`, this.handleInsertExpert.bind(this)); this.app.put(`${path}experts/:pubkey`, this.handleUpdateExpert.bind(this)); this.app.put(`${path}experts/:pubkey/disabled`, this.handleSetExpertDisabled.bind(this)); this.app.delete(`${path}experts/:pubkey`, this.handleDeleteExpert.bind(this)); } /** * Handles requests to get the current user ID * * @param req - Express request object * @param res - Express response object * @private */ async handleWhoami(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { res.status(200).json({ user_id: req.user_id }); } catch (error) { debugError("Error handling whoami request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to list all wallets * * @param req - Express request object * @param res - Express response object * @private */ async handleListWallets(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { let wallets; const user_id = req.user_id; // Check if we have listIds in the perms object if (req.perms?.listIds !== undefined) { // Use the listWalletsByIds method with the provided string IDs wallets = await this.db.listWalletsByIds(req.perms.listIds); } else { // Use the regular listWallets method with user_id if available wallets = await this.db.listWallets(user_id); } res.status(200).json(wallets); } catch (error) { debugError("Error handling list wallets request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to get a wallet by ID * * @param req - Express request object * @param res - Express response object * @private */ async handleGetWallet(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const id = req.params.id; if (!id) { res.status(400).json({ error: "Invalid wallet ID" }); return; } const user_id = req.user_id; const wallet = await this.db.getWallet(id, user_id); if (!wallet) { res.status(404).json({ error: "Wallet not found" }); return; } res.status(200).json(wallet); } catch (error) { debugError("Error handling get wallet request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to get a wallet by name * * @param req - Express request object * @param res - Express response object * @private */ async handleGetWalletByName(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const name = req.params.name; if (!name) { res.status(400).json({ error: "Missing wallet name" }); return; } const user_id = req.user_id; const wallet = await this.db.getWalletByName(name, user_id); if (!wallet) { res.status(404).json({ error: "Wallet not found" }); return; } res.status(200).json(wallet); } catch (error) { debugError("Error handling get wallet by name request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to get the default wallet * * @param req - Express request object * @param res - Express response object * @private */ async handleGetDefaultWallet(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const user_id = req.user_id; const wallet = await this.db.getDefaultWallet(user_id); if (!wallet) { res.status(404).json({ error: "Default wallet not found" }); return; } res.status(200).json(wallet); } catch (error) { debugError("Error handling get default wallet request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to insert a new wallet * * @param req - Express request object * @param res - Express response object * @private */ async handleInsertWallet(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const wallet = req.body; if (!wallet || !wallet.name || !wallet.nwc) { res.status(400).json({ error: "Invalid wallet data" }); return; } // Use user_id from the request object if available if (req.user_id) { wallet.user_id = req.user_id; } const id = await this.db.insertWallet(wallet); res.status(201).json({ id }); } catch (error) { debugError("Error handling insert wallet request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to update an existing wallet * * @param req - Express request object * @param res - Express response object * @private */ async handleUpdateWallet(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const id = req.params.id; if (!id) { res.status(400).json({ error: "Invalid wallet ID" }); return; } const wallet = req.body; if (!wallet) { res.status(400).json({ error: "Invalid wallet data" }); return; } // Ensure the ID in the URL matches the ID in the body if (wallet.id !== id) { res.status(400).json({ error: "ID mismatch" }); return; } // Use user_id from the request object if available if (req.user_id) { wallet.user_id = req.user_id; } const success = await this.db.updateWallet(wallet); if (success) { res.status(200).json({ success: true }); } else { res.status(404).json({ error: "Wallet not found" }); } } catch (error) { debugError("Error handling update wallet request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to delete a wallet * * @param req - Express request object * @param res - Express response object * @private */ async handleDeleteWallet(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const id = req.params.id; if (!id) { res.status(400).json({ error: "Invalid wallet ID" }); return; } const user_id = req.user_id; const success = await this.db.deleteWallet(id, user_id); if (success) { res.status(200).json({ success: true }); } else { res.status(404).json({ error: "Wallet not found" }); } } catch (error) { debugError("Error handling delete wallet request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to list all experts * * @param req - Express request object * @param res - Express response object * @private */ async handleListExperts(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { let experts; const user_id = req.user_id; // Check if we have listIds in the perms object if (req.perms?.listIds !== undefined) { // Use the listExpertsByIds method with the provided string IDs experts = await this.db.listExpertsByIds(req.perms.listIds); } else { // Use the regular listExperts method with user_id if available experts = await this.db.listExperts(user_id); } res.status(200).json(experts); } catch (error) { debugError("Error handling list experts request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to get an expert by pubkey * * @param req - Express request object * @param res - Express response object * @private */ async handleGetExpert(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const pubkey = req.params.pubkey; if (!pubkey) { res.status(400).json({ error: "Invalid expert pubkey" }); return; } const user_id = req.user_id; const expert = await this.db.getExpert(pubkey, user_id); if (!expert) { res.status(404).json({ error: "Expert not found" }); return; } res.status(200).json(expert); } catch (error) { debugError("Error handling get expert request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to insert a new expert * * @param req - Express request object * @param res - Express response object * @private */ async handleInsertExpert(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const expert = req.body; if (!expert || !expert.pubkey || !expert.type || !expert.nickname) { res.status(400).json({ error: "Invalid expert data" }); return; } // Use user_id from the request object if available if (req.user_id) { expert.user_id = req.user_id; } // If wallet_id is empty, create a new wallet for this expert if (!expert.wallet_id) { try { // Create a new wallet const { nwcString } = await createWallet(); // Insert the wallet with a name based on the expert's name const walletName = `Wallet for expert ${expert.nickname}`; const wallet_id = await this.db.insertWallet({ user_id: expert.user_id, name: walletName, nwc: nwcString, default: false, }); // Use the new wallet_id for the expert expert.wallet_id = wallet_id; debugServer(`Created new wallet ${wallet_id} for expert ${expert.nickname}`); } catch (e) { debugError("Failed to create wallet for expert", e); res.status(500).json({ error: "Failed to create wallet for expert" }); return; } } const success = await this.db.insertExpert(expert); if (success) { res.status(201).json({ success: true }); } else { res.status(500).json({ error: "Failed to insert expert" }); } } catch (error) { debugError("Error handling insert expert request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to update an existing expert * * @param req - Express request object * @param res - Express response object * @private */ async handleUpdateExpert(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const pubkey = req.params.pubkey; if (!pubkey) { res.status(400).json({ error: "Invalid expert pubkey" }); return; } const expert = req.body; if (!expert) { res.status(400).json({ error: "Invalid expert data" }); return; } // Ensure the pubkey in the URL matches the pubkey in the body if (expert.pubkey !== pubkey) { res.status(400).json({ error: "Pubkey mismatch" }); return; } // Use user_id from the request object if available if (req.user_id) { expert.user_id = req.user_id; } const success = await this.db.updateExpert(expert); if (success) { res.status(200).json({ success: true }); } else { res.status(404).json({ error: "Expert not found" }); } } catch (error) { debugError("Error handling update expert request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to set the disabled status of an expert * * @param req - Express request object * @param res - Express response object * @private */ async handleSetExpertDisabled(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const pubkey = req.params.pubkey; if (!pubkey) { res.status(400).json({ error: "Invalid expert pubkey" }); return; } const { disabled } = req.body; if (disabled === undefined) { res.status(400).json({ error: "Missing disabled status" }); return; } const user_id = req.user_id; const success = await this.db.setExpertDisabled(pubkey, !!disabled, user_id); if (success) { res.status(200).json({ success: true }); } else { res.status(404).json({ error: "Expert not found" }); } } catch (error) { debugError("Error handling set expert disabled request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Handles requests to delete an expert * * @param req - Express request object * @param res - Express response object * @private */ async handleDeleteExpert(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const pubkey = req.params.pubkey; if (!pubkey) { res.status(400).json({ error: "Invalid expert pubkey" }); return; } const user_id = req.user_id; const success = await this.db.deleteExpert(pubkey, user_id); if (success) { res.status(200).json({ success: true }); } else { res.status(404).json({ error: "Expert not found" }); } } catch (error) { debugError("Error handling delete expert request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } async ensureExternalUser(user_id_ext) { // FIXME make it all a tx!!! const user = await this.db.getUserByExtId(user_id_ext); debugServer(`External user request with external ID ${user_id_ext}, pubkey ${user?.pubkey}`); if (user) return user.pubkey; const { privateKey, publicKey } = generateRandomKeyPair(); const { nwcString } = await createWallet(); await this.addUser(publicKey, nwcString, bytesToHex(privateKey), user_id_ext); debugServer(`Created external user with external ID ${user_id_ext} pubkey ${publicKey}`); return publicKey; } async addUser(pubkey, nwc, privkey, user_id_ext) { // FIXME create user and wallet as one tx const newUser = { pubkey, privkey: privkey || "", user_id_ext, }; const user_id = await this.db.insertUser(newUser); debugServer(`Created user ${user_id} pubkey ${pubkey}${user_id_ext ? ` with external ID ${user_id_ext}` : ""}`); // Create a default wallet named 'main' await this.db.insertWallet({ user_id, name: "main", nwc, default: true, }); debugServer(`Created default wallet 'main' for new user ${user_id}`); return user_id; } /** * Handles signup requests * Gets user ID by pubkey or creates a new user if it doesn't exist * * @param req - Express request object * @param res - Express response object * @private */ async handleSignup(req, res) { if (this.stopped) { res.status(503).json({ error: "Service unavailable" }); return; } try { const pubkey = req.pubkey; if (!pubkey) { res.status(400).json({ error: "Missing pubkey" }); return; } if (req.body.privkey) { const privkey = hexToBytes(req.body.privkey); if (pubkey !== getPublicKey(privkey)) { res.status(400).json({ error: "Wrong privkey" }); return; } } let user_id; // Try to get user by pubkey // If no perms interface is provided, check directly in the database const user = await this.db.getUserByPubkey(pubkey); if (user) { user_id = user.id; } else { // User doesn't exist, create a new one // Create wallet first let nwc; try { const { nwcString } = await createWallet(); nwc = nwcString; } catch (e) { debugError("Failed to create wallet", e); res.status(500).json({ error: "Failed to create user wallet" }); return; } user_id = await this.addUser(pubkey, nwc, req.body.privkey, req.body.user_id_ext); } res.status(200).json({ user_id }); } catch (error) { debugError("Error handling signup request:", error); const message = error instanceof Error ? error.message : String(error); res.status(500).json({ error: "Internal server error", message: message, }); } } /** * Starts the server * * @returns Promise that resolves when the server is started */ async start() { if (this.server) throw new Error("Already started"); this.stopped = false; this.server = this.app.listen(this.port); debugServer(`DB Server running at http://localhost:${this.port}${this.basePath}`); } /** * Stops the server * * @returns Promise that resolves when the server is stopped */ stop() { return new Promise(async (resolve) => { // Mark as stopped this.stopped = true; if (!this.server) { resolve(); return; } debugError("Server stopping..."); // Stop accepting new connections const closePromise = new Promise((ok) => this.server.close(ok)); // Wait until all connections are closed with timeout let to; await Promise.race([ closePromise, new Promise((ok) => (to = setTimeout(ok, 5000))), ]); clearTimeout(to); debugError("Server stopped"); resolve(); }); } } //# sourceMappingURL=DBServer.js.map