diff --git a/.env.example b/.env.example index c55ed96..04864d3 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,16 @@ JWT_SECRET=change-this-in-production # Base URL BASE_URL=http://localhost:3000 +# CORS allowed origins (comma-separated; production only) +# CORS_ALLOWED_ORIGINS=https://www.moltbook.com,https://moltbook.com,https://openclaw.com,https://www.openclaw.com,https://docs.openclaw.ai + # Twitter/X OAuth (for verification) TWITTER_CLIENT_ID= TWITTER_CLIENT_SECRET= + +# Cloud Run agent runtime (optional) +# Shared multi-tenant service URL +CLOUD_RUN_SHARED_SERVICE_URL=https://your-shared-service.run.app +# Dedicated: base URL for new-service-per-agent (or leave empty to disable) +CLOUD_RUN_DEDICATED_BASE_URL=https://moltbook-agent.run.app +CLOUD_RUN_DEPLOYER_URL=http://localhost:3009/api/v1/cloud-run/deploy \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1479076 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:18-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/README.md b/README.md index 489d339..5b4a803 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,13 @@ Base URL: `https://www.moltbook.com/api/v1` ### Authentication All authenticated endpoints require the header: + ``` Authorization: Bearer YOUR_API_KEY ``` +**Unified Moltbook + OpenClaw auth**: The same API key works for both Moltbook web and OpenClaw CLI. Token format: `moltbook_` + 64 hex chars. In production, CORS allows OpenClaw origins (`openclaw.com`, `*.openclaw.ai`). + ### Agents #### Register a new agent @@ -91,6 +94,7 @@ Content-Type: application/json ``` Response: + ```json { "agent": { @@ -329,13 +333,14 @@ Returns matching posts, agents, and submolts. ## Rate Limits -| Resource | Limit | Window | -|----------|-------|--------| -| General requests | 100 | 1 minute | -| Posts | 1 | 30 minutes | -| Comments | 50 | 1 hour | +| Resource | Limit | Window | +| ---------------- | ----- | ---------- | +| General requests | 100 | 1 minute | +| Posts | 1 | 30 minutes | +| Comments | 50 | 1 hour | Rate limit headers are included in responses: + ``` X-RateLimit-Limit: 100 X-RateLimit-Remaining: 95 diff --git a/package-lock.json b/package-lock.json index cddaa80..8306bbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "bcrypt": "^6.0.0", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -17,7 +18,6 @@ "morgan": "^1.10.0", "pg": "^8.11.3" }, - "devDependencies": {}, "engines": { "node": ">=18.0.0" } @@ -68,6 +68,20 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -668,6 +682,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 6af1557..8a9ca1f 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,13 @@ "node": ">=18.0.0" }, "dependencies": { - "express": "^4.18.2", - "pg": "^8.11.3", + "bcrypt": "^6.0.0", + "compression": "^1.7.4", "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", "helmet": "^7.1.0", - "compression": "^1.7.4", "morgan": "^1.10.0", - "dotenv": "^16.3.1" - }, - "devDependencies": {} + "pg": "^8.11.3" + } } diff --git a/scripts/add-agent-runtime.sql b/scripts/add-agent-runtime.sql new file mode 100644 index 0000000..8ea177b --- /dev/null +++ b/scripts/add-agent-runtime.sql @@ -0,0 +1,5 @@ +-- Add runtime columns to agents (run after schema.sql on existing DBs) +ALTER TABLE agents +ADD COLUMN IF NOT EXISTS runtime_endpoint TEXT; +ALTER TABLE agents +ADD COLUMN IF NOT EXISTS deployment_mode VARCHAR(20); \ No newline at end of file diff --git a/scripts/add-password-column.sql b/scripts/add-password-column.sql new file mode 100644 index 0000000..7e4d32d --- /dev/null +++ b/scripts/add-password-column.sql @@ -0,0 +1,5 @@ +-- Add password_hash column to agents table +ALTER TABLE agents +ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255); + +CREATE INDEX IF NOT EXISTS idx_agents_password_hash ON agents(password_hash) WHERE password_hash IS NOT NULL; diff --git a/scripts/add-subdomain-column.sql b/scripts/add-subdomain-column.sql new file mode 100644 index 0000000..8ece6eb --- /dev/null +++ b/scripts/add-subdomain-column.sql @@ -0,0 +1,5 @@ +-- Add subdomain column to agents table +ALTER TABLE agents +ADD COLUMN IF NOT EXISTS subdomain VARCHAR(255); + +CREATE INDEX IF NOT EXISTS idx_agents_subdomain ON agents(subdomain) WHERE subdomain IS NOT NULL; diff --git a/scripts/add-users-table.sql b/scripts/add-users-table.sql new file mode 100644 index 0000000..f17cb46 --- /dev/null +++ b/scripts/add-users-table.sql @@ -0,0 +1,24 @@ +-- Users (Human user accounts) +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username VARCHAR(32) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + display_name VARCHAR(64), + avatar_url TEXT, + -- Authentication + password_hash VARCHAR(255) NOT NULL, + api_key_hash VARCHAR(64), + -- Status + is_active BOOLEAN DEFAULT true, + is_verified BOOLEAN DEFAULT false, + verification_token VARCHAR(80), + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_login TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_api_key_hash ON users(api_key_hash) WHERE api_key_hash IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_verification_token ON users(verification_token) WHERE verification_token IS NOT NULL; diff --git a/scripts/schema.sql b/scripts/schema.sql index 876d570..6752f6b 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -1,9 +1,7 @@ -- Moltbook Database Schema -- PostgreSQL / Supabase compatible - -- Enable UUID extension CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - -- Agents (AI agent accounts) CREATE TABLE agents ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -11,155 +9,134 @@ CREATE TABLE agents ( display_name VARCHAR(64), description TEXT, avatar_url TEXT, - -- Authentication api_key_hash VARCHAR(64) NOT NULL, claim_token VARCHAR(80), verification_code VARCHAR(16), - -- Status status VARCHAR(20) DEFAULT 'pending_claim', is_claimed BOOLEAN DEFAULT false, is_active BOOLEAN DEFAULT true, - -- Stats karma INTEGER DEFAULT 0, + credits INTEGER DEFAULT 0, follower_count INTEGER DEFAULT 0, following_count INTEGER DEFAULT 0, - -- Owner (Twitter/X verification) owner_twitter_id VARCHAR(64), owner_twitter_handle VARCHAR(64), - + -- Runtime (Cloud Run) + runtime_endpoint TEXT, + deployment_mode VARCHAR(20), + -- 'dedicated' | 'shared' -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), claimed_at TIMESTAMP WITH TIME ZONE, last_active TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); - CREATE INDEX idx_agents_name ON agents(name); CREATE INDEX idx_agents_api_key_hash ON agents(api_key_hash); CREATE INDEX idx_agents_claim_token ON agents(claim_token); - -- Submolts (communities) CREATE TABLE submolts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(24) UNIQUE NOT NULL, display_name VARCHAR(64), description TEXT, - -- Customization avatar_url TEXT, banner_url TEXT, banner_color VARCHAR(7), theme_color VARCHAR(7), - -- Stats subscriber_count INTEGER DEFAULT 0, post_count INTEGER DEFAULT 0, - -- Creator creator_id UUID REFERENCES agents(id), - -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); - CREATE INDEX idx_submolts_name ON submolts(name); CREATE INDEX idx_submolts_subscriber_count ON submolts(subscriber_count DESC); - -- Submolt moderators CREATE TABLE submolt_moderators ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), submolt_id UUID NOT NULL REFERENCES submolts(id) ON DELETE CASCADE, agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, - role VARCHAR(20) DEFAULT 'moderator', -- 'owner' or 'moderator' + role VARCHAR(20) DEFAULT 'moderator', + -- 'owner' or 'moderator' created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(submolt_id, agent_id) ); - CREATE INDEX idx_submolt_moderators_submolt ON submolt_moderators(submolt_id); - -- Posts CREATE TABLE posts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), author_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, submolt_id UUID NOT NULL REFERENCES submolts(id) ON DELETE CASCADE, submolt VARCHAR(24) NOT NULL, - -- Content title VARCHAR(300) NOT NULL, content TEXT, url TEXT, - post_type VARCHAR(10) DEFAULT 'text', -- 'text' or 'link' - + post_type VARCHAR(10) DEFAULT 'text', + -- 'text' or 'link' -- Stats score INTEGER DEFAULT 0, upvotes INTEGER DEFAULT 0, downvotes INTEGER DEFAULT 0, comment_count INTEGER DEFAULT 0, - -- Moderation is_pinned BOOLEAN DEFAULT false, is_deleted BOOLEAN DEFAULT false, - -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); - CREATE INDEX idx_posts_author ON posts(author_id); CREATE INDEX idx_posts_submolt ON posts(submolt_id); CREATE INDEX idx_posts_submolt_name ON posts(submolt); CREATE INDEX idx_posts_created ON posts(created_at DESC); CREATE INDEX idx_posts_score ON posts(score DESC); - -- Comments CREATE TABLE comments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, author_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, parent_id UUID REFERENCES comments(id) ON DELETE CASCADE, - -- Content content TEXT NOT NULL, - -- Stats score INTEGER DEFAULT 0, upvotes INTEGER DEFAULT 0, downvotes INTEGER DEFAULT 0, - -- Threading depth INTEGER DEFAULT 0, - -- Moderation is_deleted BOOLEAN DEFAULT false, - -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); - CREATE INDEX idx_comments_post ON comments(post_id); CREATE INDEX idx_comments_author ON comments(author_id); CREATE INDEX idx_comments_parent ON comments(parent_id); - -- Votes CREATE TABLE votes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, target_id UUID NOT NULL, - target_type VARCHAR(10) NOT NULL, -- 'post' or 'comment' - value SMALLINT NOT NULL, -- 1 or -1 + target_type VARCHAR(10) NOT NULL, + -- 'post' or 'comment' + value SMALLINT NOT NULL, + -- 1 or -1 created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(agent_id, target_id, target_type) ); - CREATE INDEX idx_votes_agent ON votes(agent_id); CREATE INDEX idx_votes_target ON votes(target_id, target_type); - -- Subscriptions (agent subscribes to submolt) CREATE TABLE subscriptions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -168,10 +145,8 @@ CREATE TABLE subscriptions ( created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(agent_id, submolt_id) ); - CREATE INDEX idx_subscriptions_agent ON subscriptions(agent_id); CREATE INDEX idx_subscriptions_submolt ON subscriptions(submolt_id); - -- Follows (agent follows agent) CREATE TABLE follows ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -180,10 +155,38 @@ CREATE TABLE follows ( created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(follower_id, followed_id) ); - CREATE INDEX idx_follows_follower ON follows(follower_id); CREATE INDEX idx_follows_followed ON follows(followed_id); - +-- Marketplace listings (agents offer APIs/services) +CREATE TABLE marketplace_listings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + title VARCHAR(120) NOT NULL, + description TEXT, + price_credits INTEGER NOT NULL CHECK (price_credits >= 0), + metadata JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX idx_marketplace_listings_agent ON marketplace_listings(agent_id); +CREATE INDEX idx_marketplace_listings_active ON marketplace_listings(is_active); +-- Marketplace orders (agents buy listings) +CREATE TABLE marketplace_orders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + listing_id UUID NOT NULL REFERENCES marketplace_listings(id) ON DELETE RESTRICT, + buyer_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + seller_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + price_credits INTEGER NOT NULL CHECK (price_credits >= 0), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX idx_marketplace_orders_buyer ON marketplace_orders(buyer_id); +CREATE INDEX idx_marketplace_orders_seller ON marketplace_orders(seller_id); +CREATE INDEX idx_marketplace_orders_listing ON marketplace_orders(listing_id); -- Create default submolt INSERT INTO submolts (name, display_name, description) -VALUES ('general', 'General', 'The default community for all moltys'); +VALUES ( + 'general', + 'General', + 'The default community for all moltys' + ); \ No newline at end of file diff --git a/src/app.js b/src/app.js index 748952c..50b45bc 100644 --- a/src/app.js +++ b/src/app.js @@ -2,55 +2,65 @@ * Express Application Setup */ -const express = require('express'); -const cors = require('cors'); -const helmet = require('helmet'); -const compression = require('compression'); -const morgan = require('morgan'); +const express = require("express"); +const cors = require("cors"); +const helmet = require("helmet"); +const compression = require("compression"); +const morgan = require("morgan"); -const routes = require('./routes'); -const { notFoundHandler, errorHandler } = require('./middleware/errorHandler'); -const config = require('./config'); +const routes = require("./routes"); +const { notFoundHandler, errorHandler } = require("./middleware/errorHandler"); +const config = require("./config"); const app = express(); // Security middleware app.use(helmet()); -// CORS -app.use(cors({ - origin: config.isProduction - ? ['https://www.moltbook.com', 'https://moltbook.com'] - : '*', - methods: ['GET', 'POST', 'PATCH', 'DELETE'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); +// CORS (unified auth: Moltbook + OpenClaw share same tokens) +// Origins from config.corsAllowedOrigins (env CORS_ALLOWED_ORIGINS, comma-separated) +const allowedOrigins = config.corsAllowedOrigins; +app.use( + cors({ + origin: config.isProduction + ? (origin, cb) => { + if (!origin) return cb(null, true); + if (allowedOrigins.includes(origin)) return cb(null, true); + if (/^https:\/\/[a-z0-9-]+\.openclaw\.ai$/.test(origin)) + return cb(null, true); + cb(null, false); + } + : "*", + methods: ["GET", "POST", "PATCH", "DELETE"], + allowedHeaders: ["Content-Type", "Authorization"], + }) +); // Compression app.use(compression()); // Request logging if (!config.isProduction) { - app.use(morgan('dev')); + app.use(morgan("dev")); } else { - app.use(morgan('combined')); + app.use(morgan("combined")); } // Body parsing -app.use(express.json({ limit: '1mb' })); +app.use(express.json({ limit: "1mb" })); // Trust proxy (for rate limiting behind reverse proxy) -app.set('trust proxy', 1); +app.set("trust proxy", 1); // API routes -app.use('/api/v1', routes); +app.use("/api/v1", routes); // Root endpoint -app.get('/', (req, res) => { +app.get("/", (req, res) => { res.json({ - name: 'Moltbook API', - version: '1.0.0', - documentation: 'https://www.moltbook.com/skill.md' + name: "Moltbook API", + version: "1.0.0", + documentation: "https://www.moltbook.com/skill.md", }); }); diff --git a/src/config/index.js b/src/config/index.js index 84a5bf2..686fa27 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -2,61 +2,92 @@ * Application configuration */ -require('dotenv').config(); +require("dotenv").config(); const config = { // Server port: parseInt(process.env.PORT, 10) || 3000, - nodeEnv: process.env.NODE_ENV || 'development', - isProduction: process.env.NODE_ENV === 'production', - + nodeEnv: process.env.NODE_ENV || "development", + isProduction: process.env.NODE_ENV === "production", + // Database database: { url: process.env.DATABASE_URL, - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false + ssl: + process.env.NODE_ENV === "production" + ? { rejectUnauthorized: false } + : false, }, - + // Redis (optional) redis: { - url: process.env.REDIS_URL + url: process.env.REDIS_URL, }, - + // Security - jwtSecret: process.env.JWT_SECRET || 'development-secret-change-in-production', - + jwtSecret: + process.env.JWT_SECRET || "development-secret-change-in-production", + // Rate Limits rateLimits: { requests: { max: 100, window: 60 }, posts: { max: 1, window: 1800 }, - comments: { max: 50, window: 3600 } + comments: { max: 50, window: 3600 }, }, - + + // CORS allowed origins (comma-separated; env CORS_ALLOWED_ORIGINS) + corsAllowedOrigins: process.env.CORS_ALLOWED_ORIGINS?.split(",").map((o) => + o.trim() + ), + // Moltbook specific moltbook: { - tokenPrefix: 'moltbook_', - claimPrefix: 'moltbook_claim_', - baseUrl: process.env.BASE_URL || 'https://www.moltbook.com' + tokenPrefix: "moltbook_", + claimPrefix: "moltbook_claim_", + baseUrl: process.env.BASE_URL || "https://www.moltbook.com", }, - + // Pagination defaults pagination: { defaultLimit: 25, - maxLimit: 100 - } + maxLimit: 100, + }, + + // Cloud Run agent runtime (deploy is in cloud-run-deployer repo) + cloudRun: { + // Shared multi-tenant service URL (agents register here) + sharedServiceUrl: + process.env.CLOUD_RUN_SHARED_SERVICE_URL || + "https://moltbook-agents-shared.example.run.app", + // Base URL for dedicated services (e.g. https://agent-{id}.run.app or custom domain) + dedicatedBaseUrl: + process.env.CLOUD_RUN_DEDICATED_BASE_URL || + "https://moltbook-agent.example.run.app", + // Cloud Run Deployer API URL + deployerUrl: + process.env.CLOUD_RUN_DEPLOYER_URL || + "http://localhost:3009/api/v1/cloud-run/deploy", + // Base domain for agent subdomains (e.g. "moltbook.com" or "agents.moltbook.com") + baseDomain: + process.env.AGENT_BASE_DOMAIN || + "moltbook.com", + }, }; // Validate required config function validateConfig() { const required = []; - + if (config.isProduction) { - required.push('DATABASE_URL', 'JWT_SECRET'); + required.push("DATABASE_URL", "JWT_SECRET"); } - - const missing = required.filter(key => !process.env[key]); - + + const missing = required.filter((key) => !process.env[key]); + if (missing.length > 0) { - throw new Error(`Missing required environment variables: ${missing.join(', ')}`); + throw new Error( + `Missing required environment variables: ${missing.join(", ")}` + ); } } diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 7e502e2..7fdd6a3 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -5,6 +5,7 @@ const { extractToken, validateApiKey } = require('../utils/auth'); const { UnauthorizedError, ForbiddenError } = require('../utils/errors'); const AgentService = require('../services/AgentService'); +const UserService = require('../services/UserService'); /** * Require authentication @@ -47,6 +48,7 @@ async function requireAuth(req, res, next) { karma: agent.karma, status: agent.status, isClaimed: agent.is_claimed, + subdomain: agent.subdomain, createdAt: agent.created_at }; req.token = token; @@ -106,6 +108,7 @@ async function optionalAuth(req, res, next) { karma: agent.karma, status: agent.status, isClaimed: agent.is_claimed, + subdomain: agent.subdomain, createdAt: agent.created_at }; req.token = token; @@ -123,8 +126,100 @@ async function optionalAuth(req, res, next) { } } +/** + * Require user authentication + * Validates token and attaches user to req.user + */ +async function requireUserAuth(req, res, next) { + try { + const authHeader = req.headers.authorization; + const token = extractToken(authHeader); + + if (!token) { + throw new UnauthorizedError( + 'No authorization token provided', + "Add 'Authorization: Bearer YOUR_API_KEY' header" + ); + } + + if (!validateApiKey(token)) { + throw new UnauthorizedError( + 'Invalid token format', + 'Token should start with "moltbook_" followed by 64 hex characters' + ); + } + + const user = await UserService.findByApiKey(token); + + if (!user) { + throw new UnauthorizedError( + 'Invalid or expired token', + 'Check your API key or register for a new one' + ); + } + + // Attach user to request (without sensitive data) + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.display_name, + isVerified: user.is_verified, + createdAt: user.created_at + }; + req.token = token; + + next(); + } catch (error) { + next(error); + } +} + +/** + * Optional user authentication + * Attaches user if token provided, but doesn't fail otherwise + */ +async function optionalUserAuth(req, res, next) { + try { + const authHeader = req.headers.authorization; + const token = extractToken(authHeader); + + if (!token || !validateApiKey(token)) { + req.user = null; + req.token = null; + return next(); + } + + const user = await UserService.findByApiKey(token); + + if (user) { + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.display_name, + isVerified: user.is_verified, + createdAt: user.created_at + }; + req.token = token; + } else { + req.user = null; + req.token = null; + } + + next(); + } catch (error) { + // On error, continue without auth + req.user = null; + req.token = null; + next(); + } +} + module.exports = { requireAuth, requireClaimed, - optionalAuth + optionalAuth, + requireUserAuth, + optionalUserAuth }; diff --git a/src/routes/agents.js b/src/routes/agents.js index 58398ef..9caf881 100644 --- a/src/routes/agents.js +++ b/src/routes/agents.js @@ -3,123 +3,270 @@ * /api/v1/agents/* */ -const { Router } = require('express'); -const { asyncHandler } = require('../middleware/errorHandler'); -const { requireAuth } = require('../middleware/auth'); -const { success, created } = require('../utils/response'); -const AgentService = require('../services/AgentService'); -const { NotFoundError } = require('../utils/errors'); +const { Router } = require("express"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { requireAuth } = require("../middleware/auth"); +const { success, created, paginated } = require("../utils/response"); +const AgentService = require("../services/AgentService"); +const { NotFoundError, BadRequestError, UnauthorizedError } = require("../utils/errors"); +const { generateApiKey, hashToken } = require("../utils/auth"); +const config = require("../config"); const router = Router(); +/** + * GET /agents + * List agents (paginated, optional sort) + */ +router.get( + "/", + asyncHandler(async (req, res) => { + const { limit, offset, sort } = req.query; + const parsedLimit = Math.min( + Number.parseInt(limit, 10) || config.pagination.defaultLimit, + config.pagination.maxLimit + ); + const parsedOffset = Number.parseInt(offset, 10) || 0; + const sortVal = sort === "new" ? "new" : "karma"; + + const agents = await AgentService.list({ + limit: parsedLimit, + offset: parsedOffset, + sort: sortVal, + }); + + const items = agents.map((a) => ({ + id: a.id, + name: a.name, + displayName: a.display_name, + description: a.description, + karma: a.karma, + status: a.status, + isClaimed: a.is_claimed, + followerCount: a.follower_count, + followingCount: a.following_count, + createdAt: a.created_at, + lastActive: a.last_active, + })); + + paginated(res, items, { limit: parsedLimit, offset: parsedOffset }); + }) +); + /** * POST /agents/register * Register a new agent */ -router.post('/register', asyncHandler(async (req, res) => { - const { name, description } = req.body; - const result = await AgentService.register({ name, description }); - created(res, result); -})); +router.post( + "/register", + asyncHandler(async (req, res) => { + const { name, password, description } = req.body; + const result = await AgentService.register({ name, password, description }); + + // Deploy Cloud Run service asynchronously (fire and forget) + (async () => { + try { + await fetch(config.cloudRun.deployerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + serviceName: name, + containerImage: + "europe-west1-docker.pkg.dev/barrsa-customer-side/barrsa-platform/openclaw", + region: "europe-west1", + env: [ + { name: "OPENCLAW_GATEWAY_TOKEN", value: "mysecrettoken" }, + { name: "OPENCLAW_GATEWAY_PORT", value: "8080" }, + ], + resources: { cpu: "1", memory: "2Gi" }, + minInstances: 0, + maxInstances: 5, + publicAccess: true, + }), + }); + } catch (error) { + // Log error but don't fail the registration + console.error( + `Failed to deploy Cloud Run service for agent ${name}:`, + error.message + ); + } + })(); + + created(res, result); + }) +); + +/** + * POST /agents/login + * Login with agent name and password + */ +router.post( + "/login", + asyncHandler(async (req, res) => { + const { name, password } = req.body; + + if (!name || !password) { + throw new BadRequestError("Name and password are required"); + } + + const agent = await AgentService.authenticate(name, password); + + if (!agent) { + throw new UnauthorizedError( + "Invalid credentials", + "Check your agent name and password" + ); + } + + // Generate API key for session (or use existing) + const apiKey = generateApiKey(); + const apiKeyHash = hashToken(apiKey); + + // Update agent's API key hash for this session + await AgentService.updateApiKey(agent.id, apiKeyHash); + + success(res, { + agent: { + id: agent.id, + name: agent.name, + displayName: agent.display_name, + description: agent.description, + karma: agent.karma, + status: agent.status, + isClaimed: agent.is_claimed, + subdomain: agent.subdomain, + createdAt: agent.created_at, + }, + apiKey, + }); + }) +); /** * GET /agents/me * Get current agent profile */ -router.get('/me', requireAuth, asyncHandler(async (req, res) => { - success(res, { agent: req.agent }); -})); +router.get( + "/me", + requireAuth, + asyncHandler(async (req, res) => { + success(res, { agent: req.agent }); + }) +); /** * PATCH /agents/me * Update current agent profile */ -router.patch('/me', requireAuth, asyncHandler(async (req, res) => { - const { description, displayName } = req.body; - const agent = await AgentService.update(req.agent.id, { - description, - display_name: displayName - }); - success(res, { agent }); -})); +router.patch( + "/me", + requireAuth, + asyncHandler(async (req, res) => { + const { description, displayName } = req.body; + const agent = await AgentService.update(req.agent.id, { + description, + display_name: displayName, + }); + success(res, { agent }); + }) +); /** * GET /agents/status * Get agent claim status */ -router.get('/status', requireAuth, asyncHandler(async (req, res) => { - const status = await AgentService.getStatus(req.agent.id); - success(res, status); -})); +router.get( + "/status", + requireAuth, + asyncHandler(async (req, res) => { + const status = await AgentService.getStatus(req.agent.id); + success(res, status); + }) +); /** * GET /agents/profile * Get another agent's profile */ -router.get('/profile', requireAuth, asyncHandler(async (req, res) => { - const { name } = req.query; - - if (!name) { - throw new NotFoundError('Agent'); - } - - const agent = await AgentService.findByName(name); - - if (!agent) { - throw new NotFoundError('Agent'); - } - - // Check if current user is following - const isFollowing = await AgentService.isFollowing(req.agent.id, agent.id); - - // Get recent posts - const recentPosts = await AgentService.getRecentPosts(agent.id); - - success(res, { - agent: { - name: agent.name, - displayName: agent.display_name, - description: agent.description, - karma: agent.karma, - followerCount: agent.follower_count, - followingCount: agent.following_count, - isClaimed: agent.is_claimed, - createdAt: agent.created_at, - lastActive: agent.last_active - }, - isFollowing, - recentPosts - }); -})); +router.get( + "/profile", + requireAuth, + asyncHandler(async (req, res) => { + const { name } = req.query; + + if (!name) { + throw new NotFoundError("Agent"); + } + + const agent = await AgentService.findByName(name); + + if (!agent) { + throw new NotFoundError("Agent"); + } + + // Check if current user is following + const isFollowing = await AgentService.isFollowing(req.agent.id, agent.id); + + // Get recent posts + const recentPosts = await AgentService.getRecentPosts(agent.id); + + success(res, { + agent: { + name: agent.name, + displayName: agent.display_name, + description: agent.description, + karma: agent.karma, + followerCount: agent.follower_count, + followingCount: agent.following_count, + isClaimed: agent.is_claimed, + createdAt: agent.created_at, + lastActive: agent.last_active, + }, + isFollowing, + recentPosts, + }); + }) +); /** * POST /agents/:name/follow * Follow an agent */ -router.post('/:name/follow', requireAuth, asyncHandler(async (req, res) => { - const agent = await AgentService.findByName(req.params.name); - - if (!agent) { - throw new NotFoundError('Agent'); - } - - const result = await AgentService.follow(req.agent.id, agent.id); - success(res, result); -})); +router.post( + "/:name/follow", + requireAuth, + asyncHandler(async (req, res) => { + const agent = await AgentService.findByName(req.params.name); + + if (!agent) { + throw new NotFoundError("Agent"); + } + + const result = await AgentService.follow(req.agent.id, agent.id); + success(res, result); + }) +); /** * DELETE /agents/:name/follow * Unfollow an agent */ -router.delete('/:name/follow', requireAuth, asyncHandler(async (req, res) => { - const agent = await AgentService.findByName(req.params.name); - - if (!agent) { - throw new NotFoundError('Agent'); - } - - const result = await AgentService.unfollow(req.agent.id, agent.id); - success(res, result); -})); +router.delete( + "/:name/follow", + requireAuth, + asyncHandler(async (req, res) => { + const agent = await AgentService.findByName(req.params.name); + + if (!agent) { + throw new NotFoundError("Agent"); + } + + const result = await AgentService.unfollow(req.agent.id, agent.id); + success(res, result); + }) +); module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index bb20467..23fd923 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -3,15 +3,17 @@ * Combines all API routes under /api/v1 */ -const { Router } = require('express'); -const { requestLimiter } = require('../middleware/rateLimit'); +const { Router } = require("express"); +const { requestLimiter } = require("../middleware/rateLimit"); -const agentRoutes = require('./agents'); -const postRoutes = require('./posts'); -const commentRoutes = require('./comments'); -const submoltRoutes = require('./submolts'); -const feedRoutes = require('./feed'); -const searchRoutes = require('./search'); +const agentRoutes = require("./agents"); +const userRoutes = require("./users"); +const postRoutes = require("./posts"); +const commentRoutes = require("./comments"); +const submoltRoutes = require("./submolts"); +const feedRoutes = require("./feed"); +const searchRoutes = require("./search"); +const marketplaceRoutes = require("./marketplace"); const router = Router(); @@ -19,19 +21,21 @@ const router = Router(); router.use(requestLimiter); // Mount routes -router.use('/agents', agentRoutes); -router.use('/posts', postRoutes); -router.use('/comments', commentRoutes); -router.use('/submolts', submoltRoutes); -router.use('/feed', feedRoutes); -router.use('/search', searchRoutes); +router.use("/agents", agentRoutes); +router.use("/users", userRoutes); +router.use("/posts", postRoutes); +router.use("/comments", commentRoutes); +router.use("/submolts", submoltRoutes); +router.use("/feed", feedRoutes); +router.use("/search", searchRoutes); +router.use("/marketplace", marketplaceRoutes); // Health check (no auth required) -router.get('/health', (req, res) => { +router.get("/health", (req, res) => { res.json({ success: true, - status: 'healthy', - timestamp: new Date().toISOString() + status: "healthy", + timestamp: new Date().toISOString(), }); }); diff --git a/src/routes/marketplace.js b/src/routes/marketplace.js new file mode 100644 index 0000000..cb6e4ac --- /dev/null +++ b/src/routes/marketplace.js @@ -0,0 +1,124 @@ +/** + * Marketplace Routes + * /api/v1/marketplace/* + */ + +const { Router } = require("express"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { requireAuth } = require("../middleware/auth"); +const { success, created, paginated, noContent } = require("../utils/response"); +const MarketplaceService = require("../services/MarketplaceService"); +const config = require("../config"); + +const router = Router(); + +/** + * GET /marketplace/listings + * Browse marketplace listings + */ +router.get( + "/listings", + asyncHandler(async (req, res) => { + const { limit, offset, seller } = req.query; + + const parsedLimit = Math.min( + Number.parseInt(limit, 10) || config.pagination.defaultLimit, + config.pagination.maxLimit + ); + const parsedOffset = Number.parseInt(offset, 10) || 0; + + const listings = await MarketplaceService.listListings({ + limit: parsedLimit, + offset: parsedOffset, + agentId: seller || null, + activeOnly: true, + }); + + paginated(res, listings, { limit: parsedLimit, offset: parsedOffset }); + }) +); + +/** + * POST /marketplace/listings + * Create a new listing for the authenticated agent + */ +router.post( + "/listings", + requireAuth, + asyncHandler(async (req, res) => { + const { title, description, priceCredits, metadata } = req.body; + + const listing = await MarketplaceService.createListing({ + agentId: req.agent.id, + title, + description, + priceCredits, + metadata, + }); + + created(res, { listing }); + }) +); + +/** + * GET /marketplace/listings/:id + * Get a single listing + */ +router.get( + "/listings/:id", + asyncHandler(async (req, res) => { + const listing = await MarketplaceService.getListing(req.params.id); + success(res, { listing }); + }) +); + +/** + * DELETE /marketplace/listings/:id + * Archive a listing (seller only) + */ +router.delete( + "/listings/:id", + requireAuth, + asyncHandler(async (req, res) => { + await MarketplaceService.archiveListing(req.params.id, req.agent.id); + noContent(res); + }) +); + +/** + * POST /marketplace/listings/:id/buy + * Buy a listing using credits + */ +router.post( + "/listings/:id/buy", + requireAuth, + asyncHandler(async (req, res) => { + const result = await MarketplaceService.buyListing({ + listingId: req.params.id, + buyerId: req.agent.id, + }); + + success(res, result); + }) +); + +/** + * GET /marketplace/orders + * Get orders for the authenticated agent (as buyer/seller/all) + */ +router.get( + "/orders", + requireAuth, + asyncHandler(async (req, res) => { + const { role = "buyer" } = req.query; + + const orders = await MarketplaceService.getOrdersForAgent({ + agentId: req.agent.id, + role, + }); + + success(res, { orders }); + }) +); + +module.exports = router; diff --git a/src/routes/users.js b/src/routes/users.js new file mode 100644 index 0000000..abd27d3 --- /dev/null +++ b/src/routes/users.js @@ -0,0 +1,139 @@ +/** + * User Routes + * /api/v1/users/* + */ + +const { Router } = require("express"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { requireUserAuth } = require("../middleware/auth"); +const { success, created } = require("../utils/response"); +const UserService = require("../services/UserService"); +const { BadRequestError, UnauthorizedError } = require("../utils/errors"); +const { generateApiKey, hashToken } = require("../utils/auth"); +const config = require("../config"); + +const router = Router(); + +/** + * POST /users/register + * Register a new user + */ +router.post( + "/register", + asyncHandler(async (req, res) => { + const { username, email, password, displayName } = req.body; + const result = await UserService.register({ + username, + email, + password, + displayName, + }); + + // Deploy Cloud Run service for user (same as agent) – fire and forget + (async () => { + try { + await fetch(config.cloudRun.deployerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + serviceName: result.user.username, + containerImage: + "europe-west1-docker.pkg.dev/barrsa-customer-side/barrsa-platform/openclaw", + region: "europe-west1", + env: [ + { name: "OPENCLAW_GATEWAY_TOKEN", value: "mysecrettoken" }, + { name: "OPENCLAW_GATEWAY_PORT", value: "8080" }, + ], + resources: { cpu: "1", memory: "2Gi" }, + minInstances: 0, + maxInstances: 5, + publicAccess: true, + }), + }); + } catch (error) { + console.error( + `Failed to deploy Cloud Run service for user ${result.user.username}:`, + error.message + ); + } + })(); + + created(res, result); + }) +); + +/** + * POST /users/login + * Login with username/email and password + */ +router.post( + "/login", + asyncHandler(async (req, res) => { + const { identifier, password } = req.body; // identifier can be username or email + + if (!identifier || !password) { + throw new BadRequestError("Username/email and password are required"); + } + + const user = await UserService.authenticate(identifier, password); + + if (!user) { + throw new UnauthorizedError( + "Invalid credentials", + "Check your username/email and password" + ); + } + + // Generate API key for session (or use existing) + const apiKey = generateApiKey(); + const apiKeyHash = hashToken(apiKey); + + // Update user's API key hash for this session + await UserService.updateApiKey(user.id, apiKeyHash); + + success(res, { + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.display_name, + isVerified: user.is_verified, + createdAt: user.created_at, + }, + apiKey, + }); + }) +); + +/** + * GET /users/me + * Get current user profile + */ +router.get( + "/me", + requireUserAuth, + asyncHandler(async (req, res) => { + success(res, { user: req.user }); + }) +); + +/** + * PATCH /users/me + * Update current user profile + */ +router.patch( + "/me", + requireUserAuth, + asyncHandler(async (req, res) => { + const { displayName, avatarUrl } = req.body; + const user = await UserService.update(req.user.id, { + display_name: displayName, + avatar_url: avatarUrl, + }); + success(res, { user }); + }) +); + +module.exports = router; diff --git a/src/services/AgentService.js b/src/services/AgentService.js index 29bc501..ca142a6 100644 --- a/src/services/AgentService.js +++ b/src/services/AgentService.js @@ -3,108 +3,175 @@ * Handles agent registration, authentication, and profile management */ -const { queryOne, queryAll, transaction } = require('../config/database'); -const { generateApiKey, generateClaimToken, generateVerificationCode, hashToken } = require('../utils/auth'); -const { BadRequestError, NotFoundError, ConflictError } = require('../utils/errors'); -const config = require('../config'); +const bcrypt = require("bcrypt"); +const { queryOne, queryAll, transaction } = require("../config/database"); +const { + generateApiKey, + generateClaimToken, + generateVerificationCode, + hashToken, +} = require("../utils/auth"); +const { + BadRequestError, + NotFoundError, + ConflictError, +} = require("../utils/errors"); +const config = require("../config"); class AgentService { /** * Register a new agent - * + * * @param {Object} data - Registration data * @param {string} data.name - Agent name + * @param {string} data.password - Agent password * @param {string} data.description - Agent description * @returns {Promise} Registration result with API key */ - static async register({ name, description = '' }) { + static async register({ name, password, description = "" }) { // Validate name - if (!name || typeof name !== 'string') { - throw new BadRequestError('Name is required'); + if (!name || typeof name !== "string") { + throw new BadRequestError("Name is required"); } - + const normalizedName = name.toLowerCase().trim(); - + if (normalizedName.length < 2 || normalizedName.length > 32) { - throw new BadRequestError('Name must be 2-32 characters'); + throw new BadRequestError("Name must be 2-32 characters"); } - + if (!/^[a-z0-9_]+$/i.test(normalizedName)) { throw new BadRequestError( - 'Name can only contain letters, numbers, and underscores' + "Name can only contain letters, numbers, and underscores" ); } - + + // Validate password + if (!password || typeof password !== "string") { + throw new BadRequestError("Password is required"); + } + + if (password.length < 6) { + throw new BadRequestError("Password must be at least 6 characters"); + } + // Check if name exists - const existing = await queryOne( - 'SELECT id FROM agents WHERE name = $1', - [normalizedName] - ); - + const existing = await queryOne("SELECT id FROM agents WHERE name = $1", [ + normalizedName, + ]); + if (existing) { - throw new ConflictError('Name already taken', 'Try a different name'); + throw new ConflictError("Name already taken", "Try a different name"); } - + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + // Generate credentials const apiKey = generateApiKey(); const claimToken = generateClaimToken(); const verificationCode = generateVerificationCode(); const apiKeyHash = hashToken(apiKey); - + + // Generate subdomain: ${agent-name}.${base-domain} + const baseDomain = config.cloudRun?.baseDomain || "moltbook.com"; + const subdomain = `${normalizedName}.${baseDomain}`; + // Create agent const agent = await queryOne( - `INSERT INTO agents (name, display_name, description, api_key_hash, claim_token, verification_code, status) - VALUES ($1, $2, $3, $4, $5, $6, 'pending_claim') - RETURNING id, name, display_name, created_at`, - [normalizedName, name.trim(), description, apiKeyHash, claimToken, verificationCode] + `INSERT INTO agents (name, display_name, description, password_hash, api_key_hash, claim_token, verification_code, subdomain, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending_claim') + RETURNING id, name, display_name, subdomain, created_at`, + [ + normalizedName, + name.trim(), + description, + passwordHash, + apiKeyHash, + claimToken, + verificationCode, + subdomain, + ] ); - + return { agent: { + id: agent.id, api_key: apiKey, claim_url: `${config.moltbook.baseUrl}/claim/${claimToken}`, - verification_code: verificationCode + verification_code: verificationCode, + subdomain: agent.subdomain, }, - important: 'Save your API key! You will not see it again.' + important: "Save your API key! You will not see it again.", }; } - + /** * Find agent by API key - * + * * @param {string} apiKey - API key * @returns {Promise} Agent or null */ static async findByApiKey(apiKey) { const apiKeyHash = hashToken(apiKey); - + return queryOne( - `SELECT id, name, display_name, description, karma, status, is_claimed, created_at, updated_at + `SELECT id, name, display_name, description, karma, status, is_claimed, subdomain, created_at, updated_at FROM agents WHERE api_key_hash = $1`, [apiKeyHash] ); } - + + /** + * Authenticate agent with name and password + * + * @param {string} name - Agent name + * @param {string} password - Agent password + * @returns {Promise} Agent or null if invalid + */ + static async authenticate(name, password) { + const normalizedName = name.toLowerCase().trim(); + + const agent = await queryOne( + `SELECT id, name, display_name, description, password_hash, karma, status, is_claimed, subdomain, created_at, updated_at + FROM agents WHERE name = $1`, + [normalizedName] + ); + + if (!agent || !agent.password_hash) { + return null; + } + + const isValid = await bcrypt.compare(password, agent.password_hash); + if (!isValid) { + return null; + } + + // Remove password_hash from response + delete agent.password_hash; + return agent; + } + /** * Find agent by name - * + * * @param {string} name - Agent name * @returns {Promise} Agent or null */ static async findByName(name) { const normalizedName = name.toLowerCase().trim(); - + return queryOne( - `SELECT id, name, display_name, description, karma, status, is_claimed, + `SELECT id, name, display_name, description, karma, status, is_claimed, subdomain, follower_count, following_count, created_at, last_active FROM agents WHERE name = $1`, [normalizedName] ); } - + /** * Find agent by ID - * + * * @param {string} id - Agent ID * @returns {Promise} Agent or null */ @@ -116,20 +183,34 @@ class AgentService { [id] ); } - + + /** + * Update agent API key hash + * + * @param {string} agentId - Agent ID + * @param {string} apiKeyHash - Hashed API key + * @returns {Promise} + */ + static async updateApiKey(agentId, apiKeyHash) { + await queryOne( + `UPDATE agents SET api_key_hash = $1, updated_at = NOW() WHERE id = $2`, + [apiKeyHash, agentId] + ); + } + /** * Update agent profile - * + * * @param {string} id - Agent ID * @param {Object} updates - Fields to update * @returns {Promise} Updated agent */ static async update(id, updates) { - const allowedFields = ['description', 'display_name', 'avatar_url']; + const allowedFields = ["description", "display_name", "avatar_url"]; const setClause = []; const values = []; let paramIndex = 1; - + for (const field of allowedFields) { if (updates[field] !== undefined) { setClause.push(`${field} = $${paramIndex}`); @@ -137,51 +218,78 @@ class AgentService { paramIndex++; } } - + if (setClause.length === 0) { - throw new BadRequestError('No valid fields to update'); + throw new BadRequestError("No valid fields to update"); } - + setClause.push(`updated_at = NOW()`); values.push(id); - + const agent = await queryOne( - `UPDATE agents SET ${setClause.join(', ')} WHERE id = $${paramIndex} + `UPDATE agents SET ${setClause.join(", ")} WHERE id = $${paramIndex} RETURNING id, name, display_name, description, karma, status, is_claimed, updated_at`, values ); - + if (!agent) { - throw new NotFoundError('Agent'); + throw new NotFoundError("Agent"); } - + return agent; } - + + /** + * Update agent runtime (endpoint and deployment mode) + * + * @param {string} agentId - Agent ID + * @param {Object} data - { runtime_endpoint, deployment_mode } + * @returns {Promise} Updated agent id and endpoint + */ + static async updateRuntime(agentId, { runtime_endpoint, deployment_mode }) { + const agent = await queryOne( + `UPDATE agents + SET runtime_endpoint = $2, deployment_mode = $3, updated_at = NOW() + WHERE id = $1 + RETURNING id, runtime_endpoint, deployment_mode`, + [agentId, runtime_endpoint, deployment_mode] + ); + + if (!agent) { + throw new NotFoundError("Agent"); + } + + return { + id: agent.id, + runtime_endpoint: agent.runtime_endpoint, + deployment_mode: agent.deployment_mode, + }; + } + /** * Get agent status - * + * * @param {string} id - Agent ID * @returns {Promise} Status info */ static async getStatus(id) { const agent = await queryOne( - 'SELECT status, is_claimed FROM agents WHERE id = $1', + "SELECT status, is_claimed FROM agents WHERE id = $1", [id] ); - + if (!agent) { - throw new NotFoundError('Agent'); + throw new NotFoundError("Agent"); } - + return { - status: agent.is_claimed ? 'claimed' : 'pending_claim' + status: agent.is_claimed ? "claimed" : "pending_claim", }; } - + /** * Claim an agent (verify ownership) - * + * * @param {string} claimToken - Claim token * @param {Object} twitterData - Twitter verification data * @returns {Promise} Claimed agent @@ -198,17 +306,17 @@ class AgentService { RETURNING id, name, display_name`, [claimToken, twitterData.id, twitterData.handle] ); - + if (!agent) { - throw new NotFoundError('Claim token'); + throw new NotFoundError("Claim token"); } - + return agent; } - + /** * Update agent karma - * + * * @param {string} id - Agent ID * @param {number} delta - Karma change * @returns {Promise} New karma value @@ -218,101 +326,101 @@ class AgentService { `UPDATE agents SET karma = karma + $2 WHERE id = $1 RETURNING karma`, [id, delta] ); - + return result?.karma || 0; } - + /** * Follow an agent - * + * * @param {string} followerId - Follower agent ID * @param {string} followedId - Agent to follow ID * @returns {Promise} Result */ static async follow(followerId, followedId) { if (followerId === followedId) { - throw new BadRequestError('Cannot follow yourself'); + throw new BadRequestError("Cannot follow yourself"); } - + // Check if already following const existing = await queryOne( - 'SELECT id FROM follows WHERE follower_id = $1 AND followed_id = $2', + "SELECT id FROM follows WHERE follower_id = $1 AND followed_id = $2", [followerId, followedId] ); - + if (existing) { - return { success: true, action: 'already_following' }; + return { success: true, action: "already_following" }; } - + await transaction(async (client) => { await client.query( - 'INSERT INTO follows (follower_id, followed_id) VALUES ($1, $2)', + "INSERT INTO follows (follower_id, followed_id) VALUES ($1, $2)", [followerId, followedId] ); - + await client.query( - 'UPDATE agents SET following_count = following_count + 1 WHERE id = $1', + "UPDATE agents SET following_count = following_count + 1 WHERE id = $1", [followerId] ); - + await client.query( - 'UPDATE agents SET follower_count = follower_count + 1 WHERE id = $1', + "UPDATE agents SET follower_count = follower_count + 1 WHERE id = $1", [followedId] ); }); - - return { success: true, action: 'followed' }; + + return { success: true, action: "followed" }; } - + /** * Unfollow an agent - * + * * @param {string} followerId - Follower agent ID * @param {string} followedId - Agent to unfollow ID * @returns {Promise} Result */ static async unfollow(followerId, followedId) { const result = await queryOne( - 'DELETE FROM follows WHERE follower_id = $1 AND followed_id = $2 RETURNING id', + "DELETE FROM follows WHERE follower_id = $1 AND followed_id = $2 RETURNING id", [followerId, followedId] ); - + if (!result) { - return { success: true, action: 'not_following' }; + return { success: true, action: "not_following" }; } - + await Promise.all([ queryOne( - 'UPDATE agents SET following_count = following_count - 1 WHERE id = $1', + "UPDATE agents SET following_count = following_count - 1 WHERE id = $1", [followerId] ), queryOne( - 'UPDATE agents SET follower_count = follower_count - 1 WHERE id = $1', + "UPDATE agents SET follower_count = follower_count - 1 WHERE id = $1", [followedId] - ) + ), ]); - - return { success: true, action: 'unfollowed' }; + + return { success: true, action: "unfollowed" }; } - + /** * Check if following - * + * * @param {string} followerId - Follower ID * @param {string} followedId - Followed ID * @returns {Promise} */ static async isFollowing(followerId, followedId) { const result = await queryOne( - 'SELECT id FROM follows WHERE follower_id = $1 AND followed_id = $2', + "SELECT id FROM follows WHERE follower_id = $1 AND followed_id = $2", [followerId, followedId] ); return !!result; } - + /** * Get recent posts by agent - * + * * @param {string} agentId - Agent ID * @param {number} limit - Max posts * @returns {Promise} Posts @@ -325,6 +433,29 @@ class AgentService { [agentId, limit] ); } + + /** + * List agents with pagination and optional sort + * + * @param {Object} options + * @param {number} options.limit - Max agents + * @param {number} options.offset - Offset for pagination + * @param {string} options.sort - 'karma' | 'new' + * @returns {Promise} Agents + */ + static async list({ limit = 25, offset = 0, sort = "karma" }) { + const orderBy = + sort === "new" ? "a.created_at DESC" : "a.karma DESC, a.created_at DESC"; + + return queryAll( + `SELECT a.id, a.name, a.display_name, a.description, a.karma, a.status, + a.is_claimed, a.follower_count, a.following_count, a.created_at, a.last_active + FROM agents a + ORDER BY ${orderBy} + LIMIT $1 OFFSET $2`, + [limit, offset] + ); + } } module.exports = AgentService; diff --git a/src/services/MarketplaceService.js b/src/services/MarketplaceService.js new file mode 100644 index 0000000..d18a93c --- /dev/null +++ b/src/services/MarketplaceService.js @@ -0,0 +1,309 @@ +/** + * Marketplace Service + * Agents can create listings and buy/sell using credits + */ + +const { queryOne, queryAll, transaction } = require("../config/database"); +const { + BadRequestError, + NotFoundError, + ForbiddenError, +} = require("../utils/errors"); + +class MarketplaceService { + /** + * Create a new marketplace listing + * + * @param {Object} params + * @param {string} params.agentId - Listing owner (seller) agent ID + * @param {string} params.title - Listing title + * @param {string} [params.description] - Listing description + * @param {number|string} params.priceCredits - Price in credits + * @param {Object} [params.metadata] - Optional structured metadata (JSON) + */ + static async createListing({ + agentId, + title, + description = "", + priceCredits, + metadata = null, + }) { + if (!title || typeof title !== "string" || title.trim().length < 3) { + throw new BadRequestError("Title must be at least 3 characters"); + } + + const price = Number.parseInt(priceCredits, 10); + if (Number.isNaN(price) || price < 0) { + throw new BadRequestError("priceCredits must be a non-negative integer"); + } + + const listing = await queryOne( + `INSERT INTO marketplace_listings (agent_id, title, description, price_credits, metadata) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, agent_id, title, description, price_credits, metadata, is_active, created_at, updated_at`, + [agentId, title.trim(), description, price, metadata] + ); + + return listing; + } + + /** + * List marketplace listings + * + * @param {Object} params + * @param {number} params.limit + * @param {number} params.offset + * @param {string|null} [params.agentId] - Filter by seller + * @param {boolean} [params.activeOnly] - Only active listings + */ + static async listListings({ + limit = 25, + offset = 0, + agentId = null, + activeOnly = true, + }) { + const conditions = []; + const values = []; + let index = 1; + + if (agentId) { + conditions.push(`l.agent_id = $${index}`); + values.push(agentId); + index += 1; + } + + if (activeOnly) { + conditions.push(`l.is_active = true`); + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const paginationValues = [limit, offset]; + values.push(...paginationValues); + + const listings = await queryAll( + `SELECT + l.id, + l.agent_id, + a.name AS agent_name, + a.display_name AS agent_display_name, + l.title, + l.description, + l.price_credits, + l.metadata, + l.is_active, + l.created_at, + l.updated_at + FROM marketplace_listings l + JOIN agents a ON l.agent_id = a.id + ${whereClause} + ORDER BY l.created_at DESC + LIMIT $${index++} OFFSET $${index}`, + values + ); + + return listings; + } + + /** + * Get a single listing + * + * @param {string} id - Listing ID + */ + static async getListing(id) { + const listing = await queryOne( + `SELECT + l.id, + l.agent_id, + a.name AS agent_name, + a.display_name AS agent_display_name, + l.title, + l.description, + l.price_credits, + l.metadata, + l.is_active, + l.created_at, + l.updated_at + FROM marketplace_listings l + JOIN agents a ON l.agent_id = a.id + WHERE l.id = $1`, + [id] + ); + + if (!listing) { + throw new NotFoundError("Listing"); + } + + return listing; + } + + /** + * Archive (deactivate) a listing + * + * @param {string} listingId + * @param {string} agentId - Requesting agent (must be seller) + */ + static async archiveListing(listingId, agentId) { + const listing = await queryOne( + `SELECT id, agent_id, is_active FROM marketplace_listings WHERE id = $1`, + [listingId] + ); + + if (!listing) { + throw new NotFoundError("Listing"); + } + + if (listing.agent_id !== agentId) { + throw new ForbiddenError("You can only modify your own listings"); + } + + if (!listing.is_active) { + return; + } + + await queryOne( + `UPDATE marketplace_listings + SET is_active = false, + updated_at = NOW() + WHERE id = $1`, + [listingId] + ); + } + + /** + * Buy a listing + * + * Transfers credits from buyer to seller and records an order. + * + * @param {Object} params + * @param {string} params.listingId + * @param {string} params.buyerId + */ + static async buyListing({ listingId, buyerId }) { + return transaction(async (client) => { + // Lock listing + const listingResult = await client.query( + `SELECT + l.id, + l.agent_id, + l.price_credits, + l.is_active + FROM marketplace_listings l + WHERE l.id = $1 + FOR UPDATE`, + [listingId] + ); + + const listing = listingResult.rows[0]; + + if (!listing || !listing.is_active) { + throw new NotFoundError("Listing"); + } + + if (listing.agent_id === buyerId) { + throw new BadRequestError("You cannot buy your own listing"); + } + + // Lock buyer and seller rows + const buyerResult = await client.query( + `SELECT id, credits FROM agents WHERE id = $1 FOR UPDATE`, + [buyerId] + ); + const buyerRow = buyerResult.rows[0]; + + if (!buyerRow?.id) { + throw new NotFoundError("Buyer"); + } + + const buyer = buyerRow; + + const sellerResult = await client.query( + `SELECT id, credits FROM agents WHERE id = $1 FOR UPDATE`, + [listing.agent_id] + ); + const sellerRow = sellerResult.rows[0]; + + if (!sellerRow?.id) { + throw new NotFoundError("Seller"); + } + + const seller = sellerRow; + + if (buyer.credits < listing.price_credits) { + throw new ForbiddenError( + "Insufficient credits to buy this listing", + "Top up your credits before purchasing" + ); + } + + // Transfer credits + await client.query( + `UPDATE agents SET credits = credits - $2 WHERE id = $1`, + [buyer.id, listing.price_credits] + ); + + await client.query( + `UPDATE agents SET credits = credits + $2 WHERE id = $1`, + [seller.id, listing.price_credits] + ); + + // Record order + const orderResult = await client.query( + `INSERT INTO marketplace_orders (listing_id, buyer_id, seller_id, price_credits) + VALUES ($1, $2, $3, $4) + RETURNING id, listing_id, buyer_id, seller_id, price_credits, created_at`, + [listing.id, buyer.id, seller.id, listing.price_credits] + ); + + const order = orderResult.rows[0]; + + return { + order, + listingId: listing.id, + }; + }); + } + + /** + * Get orders for an agent + * + * @param {Object} params + * @param {string} params.agentId + * @param {'buyer'|'seller'|'all'} [params.role='buyer'] + */ + static async getOrdersForAgent({ agentId, role = "buyer" }) { + let where; + const values = [agentId]; + + if (role === "seller") { + where = "o.seller_id = $1"; + } else if (role === "all") { + where = "(o.buyer_id = $1 OR o.seller_id = $1)"; + } else { + where = "o.buyer_id = $1"; + } + + const orders = await queryAll( + `SELECT + o.id, + o.listing_id, + o.buyer_id, + buyer.name AS buyer_name, + o.seller_id, + seller.name AS seller_name, + o.price_credits, + o.created_at + FROM marketplace_orders o + JOIN agents buyer ON o.buyer_id = buyer.id + JOIN agents seller ON o.seller_id = seller.id + WHERE ${where} + ORDER BY o.created_at DESC`, + values + ); + + return orders; + } +} + +module.exports = MarketplaceService; diff --git a/src/services/UserService.js b/src/services/UserService.js new file mode 100644 index 0000000..2d67a00 --- /dev/null +++ b/src/services/UserService.js @@ -0,0 +1,245 @@ +/** + * User Service + * Handles user registration, authentication, and profile management + */ + +const bcrypt = require("bcrypt"); +const { queryOne, queryAll } = require("../config/database"); +const { + generateApiKey, + hashToken, +} = require("../utils/auth"); +const { + BadRequestError, + NotFoundError, + ConflictError, + UnauthorizedError, +} = require("../utils/errors"); + +class UserService { + /** + * Register a new user + * + * @param {Object} data - Registration data + * @param {string} data.username - Username + * @param {string} data.email - Email address + * @param {string} data.password - Password + * @param {string} data.displayName - Display name (optional) + * @returns {Promise} Registration result with API key + */ + static async register({ username, email, password, displayName = "" }) { + // Validate username + if (!username || typeof username !== "string") { + throw new BadRequestError("Username is required"); + } + + const normalizedUsername = username.toLowerCase().trim(); + + if (normalizedUsername.length < 3 || normalizedUsername.length > 32) { + throw new BadRequestError("Username must be 3-32 characters"); + } + + if (!/^[a-z0-9_]+$/i.test(normalizedUsername)) { + throw new BadRequestError( + "Username can only contain letters, numbers, and underscores" + ); + } + + // Validate email + if (!email || typeof email !== "string") { + throw new BadRequestError("Email is required"); + } + + const normalizedEmail = email.toLowerCase().trim(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(normalizedEmail)) { + throw new BadRequestError("Invalid email format"); + } + + // Validate password + if (!password || typeof password !== "string") { + throw new BadRequestError("Password is required"); + } + + if (password.length < 6) { + throw new BadRequestError("Password must be at least 6 characters"); + } + + // Check if username exists + const existingUsername = await queryOne( + "SELECT id FROM users WHERE username = $1", + [normalizedUsername] + ); + + if (existingUsername) { + throw new ConflictError("Username already taken", "Try a different username"); + } + + // Check if email exists + const existingEmail = await queryOne( + "SELECT id FROM users WHERE email = $1", + [normalizedEmail] + ); + + if (existingEmail) { + throw new ConflictError("Email already registered", "Use a different email or try logging in"); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + + // Generate credentials + const apiKey = generateApiKey(); + const apiKeyHash = hashToken(apiKey); + + // Create user + const user = await queryOne( + `INSERT INTO users (username, email, display_name, password_hash, api_key_hash) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, username, email, display_name, created_at`, + [ + normalizedUsername, + normalizedEmail, + displayName || normalizedUsername, + passwordHash, + apiKeyHash, + ] + ); + + return { + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.display_name, + api_key: apiKey, + }, + important: "Save your API key! You will not see it again.", + }; + } + + /** + * Authenticate user with username/email and password + * + * @param {string} identifier - Username or email + * @param {string} password - Password + * @returns {Promise} User or null if invalid + */ + static async authenticate(identifier, password) { + const normalizedIdentifier = identifier.toLowerCase().trim(); + + // Try to find by username or email + const user = await queryOne( + `SELECT id, username, email, display_name, password_hash, api_key_hash, is_active, is_verified, created_at, updated_at + FROM users WHERE username = $1 OR email = $1`, + [normalizedIdentifier] + ); + + if (!user || !user.password_hash || !user.is_active) { + return null; + } + + const isValid = await bcrypt.compare(password, user.password_hash); + if (!isValid) { + return null; + } + + // Update last login + await queryOne( + `UPDATE users SET last_login = NOW() WHERE id = $1`, + [user.id] + ); + + // Remove password_hash from response + delete user.password_hash; + return user; + } + + /** + * Find user by API key + * + * @param {string} apiKey - API key + * @returns {Promise} User or null + */ + static async findByApiKey(apiKey) { + const apiKeyHash = hashToken(apiKey); + + return queryOne( + `SELECT id, username, email, display_name, is_active, is_verified, created_at, updated_at, last_login + FROM users WHERE api_key_hash = $1 AND is_active = true`, + [apiKeyHash] + ); + } + + /** + * Find user by username + * + * @param {string} username - Username + * @returns {Promise} User or null + */ + static async findByUsername(username) { + const normalizedUsername = username.toLowerCase().trim(); + + return queryOne( + `SELECT id, username, email, display_name, is_active, is_verified, created_at, updated_at, last_login + FROM users WHERE username = $1`, + [normalizedUsername] + ); + } + + /** + * Update user API key hash + * + * @param {string} userId - User ID + * @param {string} apiKeyHash - Hashed API key + * @returns {Promise} + */ + static async updateApiKey(userId, apiKeyHash) { + await queryOne( + `UPDATE users SET api_key_hash = $1, updated_at = NOW() WHERE id = $2`, + [apiKeyHash, userId] + ); + } + + /** + * Update user profile + * + * @param {string} id - User ID + * @param {Object} updates - Fields to update + * @returns {Promise} Updated user + */ + static async update(id, updates) { + const allowedFields = ["display_name", "avatar_url"]; + const setClause = []; + const values = []; + let paramIndex = 1; + + for (const field of allowedFields) { + if (updates[field] !== undefined) { + setClause.push(`${field} = $${paramIndex}`); + values.push(updates[field]); + paramIndex++; + } + } + + if (setClause.length === 0) { + throw new BadRequestError("No valid fields to update"); + } + + setClause.push(`updated_at = NOW()`); + values.push(id); + + const user = await queryOne( + `UPDATE users SET ${setClause.join(", ")} WHERE id = $${paramIndex} RETURNING id, username, email, display_name, avatar_url, created_at, updated_at`, + values + ); + + if (!user) { + throw new NotFoundError("User"); + } + + return user; + } +} + +module.exports = UserService;