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
JavaScript
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