feat(saas): full SquareMCP SaaS platform v1
- JWT auth with bcrypt password hashing, cookie sessions, forgot/reset password - Per-user encrypted credential storage (Redis + AES-256-GCM) for all 9 platforms - Usage tracking with monthly limits per plan (free/starter/growth/enterprise) - Invoice generation and retrieval (admin + user views) - Admin panel with customer listing (role-based access) - Web app UI at app.squaremcp.com — login, dashboard, connections, usage, invoices - Unified auth middleware: API key, OAuth Bearer, and JWT cookie support - Facebook Graph API fixes: published_posts endpoint, photo/video post support - TikTok sandbox compliance: SELF_ONLY privacy for unaudited apps - URL verification files for TikTok app review
This commit is contained in:
98
src/auth.ts
Normal file
98
src/auth.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import bcryptjs from 'bcryptjs';
|
||||
const { hash, compare } = bcryptjs;
|
||||
import jwt from 'jsonwebtoken';
|
||||
const { sign, verify } = jwt;
|
||||
import { getPool } from './db.js';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET ?? process.env.CREDENTIAL_ENCRYPTION_KEY ?? 'dev-secret-change-me';
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
export interface JWTPayload {
|
||||
sub: string; // customer id
|
||||
email: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return compare(password, hash);
|
||||
}
|
||||
|
||||
export function signJWT(payload: JWTPayload): string {
|
||||
return sign(payload, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
export function verifyJWT(token: string): JWTPayload {
|
||||
return verify(token, JWT_SECRET) as JWTPayload;
|
||||
}
|
||||
|
||||
interface CustomerRow extends RowDataPacket {
|
||||
id: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
active: boolean;
|
||||
api_key: string;
|
||||
password_hash: string | null;
|
||||
}
|
||||
|
||||
export async function findCustomerByEmail(email: string): Promise<CustomerRow | null> {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findCustomerById(id: string): Promise<CustomerRow | null> {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createCustomer(
|
||||
id: string,
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
'INSERT INTO customers (id, email, password_hash, api_key, plan, active) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[id, email, passwordHash, apiKey, 'free', true]
|
||||
);
|
||||
}
|
||||
|
||||
export async function setResetToken(email: string, token: string): Promise<boolean> {
|
||||
const [result] = await getPool().query<any>(
|
||||
'UPDATE customers SET reset_token = ?, reset_expires_at = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE email = ?',
|
||||
[token, email]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
export async function findCustomerByResetToken(token: string) {
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE reset_token = ? AND reset_expires_at > NOW()',
|
||||
[token]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function clearResetToken(customerId: string): Promise<void> {
|
||||
await getPool().query(
|
||||
'UPDATE customers SET reset_token = NULL, reset_expires_at = NULL WHERE id = ?',
|
||||
[customerId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePassword(customerId: string, passwordHash: string): Promise<void> {
|
||||
await getPool().query(
|
||||
'UPDATE customers SET password_hash = ? WHERE id = ?',
|
||||
[passwordHash, customerId]
|
||||
);
|
||||
}
|
||||
122
src/billing/invoices.ts
Normal file
122
src/billing/invoices.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { getPool } from '../db.js';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
|
||||
export interface InvoiceLineItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface Invoice extends RowDataPacket {
|
||||
id: number;
|
||||
customer_id: string;
|
||||
invoice_number: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'draft' | 'sent' | 'paid' | 'overdue';
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
line_items: InvoiceLineItem[];
|
||||
sent_at: string | null;
|
||||
paid_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function generateInvoiceNumber(): string {
|
||||
const prefix = 'SMCP';
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `${prefix}-${date}-${random}`;
|
||||
}
|
||||
|
||||
export async function createInvoice(
|
||||
customerId: string,
|
||||
amount: number,
|
||||
lineItems: InvoiceLineItem[],
|
||||
periodStart: string,
|
||||
periodEnd: string
|
||||
): Promise<Invoice> {
|
||||
const invoiceNumber = generateInvoiceNumber();
|
||||
await getPool().query(
|
||||
`INSERT INTO invoices (customer_id, invoice_number, amount, line_items, period_start, period_end)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[customerId, invoiceNumber, amount, JSON.stringify(lineItems), periodStart, periodEnd]
|
||||
);
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE invoice_number = ?',
|
||||
[invoiceNumber]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getCustomerInvoices(customerId: string): Promise<Invoice[]> {
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at DESC',
|
||||
[customerId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getInvoiceByNumber(invoiceNumber: string): Promise<Invoice | null> {
|
||||
const [rows] = await getPool().query<Invoice[]>(
|
||||
'SELECT * FROM invoices WHERE invoice_number = ?',
|
||||
[invoiceNumber]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function markInvoiceSent(invoiceNumber: string): Promise<void> {
|
||||
await getPool().query(
|
||||
"UPDATE invoices SET status = 'sent', sent_at = NOW() WHERE invoice_number = ?",
|
||||
[invoiceNumber]
|
||||
);
|
||||
}
|
||||
|
||||
export async function markInvoicePaid(invoiceNumber: string): Promise<void> {
|
||||
await getPool().query(
|
||||
"UPDATE invoices SET status = 'paid', paid_at = NOW() WHERE invoice_number = ?",
|
||||
[invoiceNumber]
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMonthlyInvoice(customerId: string): Promise<Invoice | null> {
|
||||
const [usageRows] = await getPool().query<any[]>(
|
||||
`SELECT platform, COUNT(*) as count FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
AND created_at < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
||||
GROUP BY platform`,
|
||||
[customerId]
|
||||
);
|
||||
|
||||
if (!usageRows.length) return null;
|
||||
|
||||
const lineItems: InvoiceLineItem[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const row of usageRows) {
|
||||
const qty = row.count;
|
||||
const unitPrice = 0.05; // $0.05 per action
|
||||
const amount = qty * unitPrice;
|
||||
total += amount;
|
||||
lineItems.push({
|
||||
description: `${row.platform} actions`,
|
||||
quantity: qty,
|
||||
unit_price: unitPrice,
|
||||
amount: parseFloat(amount.toFixed(2)),
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
return createInvoice(
|
||||
customerId,
|
||||
parseFloat(total.toFixed(2)),
|
||||
lineItems,
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getPool } from '../db.js';
|
||||
import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js';
|
||||
import type { PlanKey } from './plans.js';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { verifyJWT } from '../auth.js';
|
||||
|
||||
const redis = createClient({ url: process.env.REDIS_URL });
|
||||
redis.connect().catch((err) => console.error('[billing] Redis connect error:', err));
|
||||
@@ -24,16 +25,22 @@ interface CustomerRow extends RowDataPacket {
|
||||
email: string;
|
||||
}
|
||||
|
||||
async function resolveCustomer(apiKey: string): Promise<Customer | null> {
|
||||
function buildCustomer(row: CustomerRow): Customer {
|
||||
return {
|
||||
id: row.id,
|
||||
plan: row.plan,
|
||||
active: Boolean(row.active),
|
||||
email: row.email,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(row.id, platform),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCustomerByApiKey(apiKey: string): Promise<Customer | null> {
|
||||
const cached = await redis.get(`customer:apikey:${apiKey}`);
|
||||
if (cached) {
|
||||
const base = JSON.parse(cached) as Omit<Customer, 'getCredential'>;
|
||||
// Re-attach the credential loader (functions can't be cached)
|
||||
return {
|
||||
...base,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(base.id, platform),
|
||||
};
|
||||
const base = JSON.parse(cached) as Omit<CustomerRow, 'getCredential'>;
|
||||
return buildCustomer(base as CustomerRow);
|
||||
}
|
||||
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
@@ -42,50 +49,80 @@ async function resolveCustomer(apiKey: string): Promise<Customer | null> {
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
|
||||
const { id, plan, active, email } = rows[0];
|
||||
const customer: Customer = {
|
||||
id,
|
||||
plan,
|
||||
active: Boolean(active),
|
||||
email,
|
||||
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
|
||||
getCredential<T>(id, platform),
|
||||
};
|
||||
|
||||
// Cache only the serialisable fields (not the function)
|
||||
await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify({ id, plan, active, email }));
|
||||
return customer;
|
||||
const row = rows[0];
|
||||
await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify(row));
|
||||
return buildCustomer(row);
|
||||
}
|
||||
|
||||
// Express middleware: resolve API key → Customer and attach to req.customer
|
||||
export async function resolveCustomerById(id: string): Promise<Customer | null> {
|
||||
const cached = await redis.get(`customer:id:${id}`);
|
||||
if (cached) {
|
||||
const base = JSON.parse(cached) as CustomerRow;
|
||||
return buildCustomer(base);
|
||||
}
|
||||
|
||||
const [rows] = await getPool().query<CustomerRow[]>(
|
||||
'SELECT id, plan, active, email FROM customers WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
|
||||
const row = rows[0];
|
||||
await redis.setEx(`customer:id:${id}`, 60, JSON.stringify(row));
|
||||
return buildCustomer(row);
|
||||
}
|
||||
|
||||
// Express middleware: resolve API key OR JWT cookie → Customer and attach to req.customer
|
||||
export async function meterMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. Try API key
|
||||
const apiKey =
|
||||
(req.headers['x-api-key'] as string | undefined) ||
|
||||
(req.query.key as string | undefined);
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: 'Missing API key' });
|
||||
if (apiKey) {
|
||||
const customer = await resolveCustomerByApiKey(apiKey);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid API key' });
|
||||
return;
|
||||
}
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await resolveCustomer(apiKey);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid API key' });
|
||||
return;
|
||||
// 2. Try JWT session cookie
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (jwtCookie) {
|
||||
try {
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
const customer = await resolveCustomerById(payload.sub);
|
||||
if (!customer) {
|
||||
res.status(401).json({ error: 'Invalid session' });
|
||||
return;
|
||||
}
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
return;
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired session' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customer.active) {
|
||||
res.status(403).json({ error: 'Account suspended' });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as Request & { customer: Customer }).customer = customer;
|
||||
next();
|
||||
res.status(401).json({ error: 'Missing API key or session' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
48
src/billing/usage.ts
Normal file
48
src/billing/usage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getPool } from '../db.js';
|
||||
import { PLANS, type PlanKey } from './plans.js';
|
||||
|
||||
export async function recordUsage(
|
||||
customerId: string,
|
||||
platform: string,
|
||||
action: string
|
||||
): Promise<void> {
|
||||
await getPool().query(
|
||||
'INSERT INTO usage_logs (customer_id, platform, action) VALUES (?, ?, ?)',
|
||||
[customerId, platform, action]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMonthlyUsage(customerId: string): Promise<number> {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
`SELECT COUNT(*) as count FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
AND created_at < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')`,
|
||||
[customerId]
|
||||
);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function getUsageBreakdown(customerId: string): Promise<Record<string, number>> {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
`SELECT platform, COUNT(*) as count FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
GROUP BY platform`,
|
||||
[customerId]
|
||||
);
|
||||
const breakdown: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
breakdown[row.platform] = row.count;
|
||||
}
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
export async function checkLimit(customerId: string, plan: PlanKey): Promise<{ allowed: boolean; limit: number; used: number }> {
|
||||
const planConfig = PLANS[plan];
|
||||
if (planConfig.monthlyCallLimit === -1) {
|
||||
return { allowed: true, limit: -1, used: 0 };
|
||||
}
|
||||
const used = await getMonthlyUsage(customerId);
|
||||
return { allowed: used < planConfig.monthlyCallLimit, limit: planConfig.monthlyCallLimit, used };
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export async function getPosts(
|
||||
const { accessToken, pageId } = await resolveCreds(args, customer);
|
||||
const limit = args.limit ?? 10;
|
||||
const data = await fbRequest(
|
||||
`/${pageId}/feed?fields=id,message,story,created_time,permalink_url&limit=${limit}`,
|
||||
`/${pageId}/published_posts?fields=id,message,story,created_time,permalink_url&limit=${limit}`,
|
||||
accessToken
|
||||
);
|
||||
return (data.data ?? []).map((p: Record<string, unknown>) => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ const TIKTOK_API_BASE = 'https://open.tiktokapis.com/v2';
|
||||
|
||||
function getEnvToken(account: string): string {
|
||||
const envKey = `TIKTOK_${account.toUpperCase()}_ACCESS_TOKEN`;
|
||||
return process.env[envKey] ?? '';
|
||||
return process.env[envKey] ?? process.env.TIKTOK_DEFAULT_ACCESS_TOKEN ?? '';
|
||||
}
|
||||
|
||||
async function resolveToken(args: { account?: string }, customer?: Customer): Promise<string> {
|
||||
@@ -122,9 +122,13 @@ export async function createVideo(
|
||||
|
||||
// Step 1: query creator info to get valid privacy levels (sandbox may not support PUBLIC_TO_EVERYONE)
|
||||
const creatorInfo = await getCreatorInfo(args, customer);
|
||||
const privacyLevel = creatorInfo.privacy_level_options.includes('PUBLIC_TO_EVERYONE')
|
||||
const options = creatorInfo.privacy_level_options;
|
||||
// Unaudited apps MUST post SELF_ONLY; prefer that when public isn't available
|
||||
const privacyLevel = options.includes('PUBLIC_TO_EVERYONE')
|
||||
? 'PUBLIC_TO_EVERYONE'
|
||||
: creatorInfo.privacy_level_options[0] ?? 'SELF_ONLY';
|
||||
: options.includes('SELF_ONLY')
|
||||
? 'SELF_ONLY'
|
||||
: options[0] ?? 'SELF_ONLY';
|
||||
|
||||
// Step 2: initialise upload
|
||||
const init = await tiktokRequest('/post/publish/video/init/', accessToken, 'POST', {
|
||||
|
||||
44
src/db.ts
44
src/db.ts
@@ -93,6 +93,7 @@ export async function initDatabase(): Promise<void> {
|
||||
await ensureColumn(db, 'oauth_auth_codes', 'scope', 'TEXT NULL');
|
||||
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge', 'TEXT NULL');
|
||||
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge_method', 'VARCHAR(20) NULL');
|
||||
await ensureColumn(db, 'customers', 'password_hash', 'VARCHAR(255) NULL');
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
@@ -112,10 +113,51 @@ export async function initDatabase(): Promise<void> {
|
||||
plan ENUM('free', 'starter', 'growth', 'enterprise') DEFAULT 'free',
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NULL,
|
||||
role ENUM('user', 'admin') DEFAULT 'user',
|
||||
reset_token VARCHAR(255) NULL,
|
||||
reset_expires_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_api_key (api_key)
|
||||
INDEX idx_api_key (api_key),
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_reset_token (reset_token)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS usage_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_id VARCHAR(255) NOT NULL,
|
||||
platform VARCHAR(32) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_customer_time (customer_id, created_at)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_id VARCHAR(255) NOT NULL,
|
||||
invoice_number VARCHAR(64) NOT NULL UNIQUE,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
status ENUM('draft', 'sent', 'paid', 'overdue') DEFAULT 'draft',
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
line_items JSON,
|
||||
sent_at TIMESTAMP NULL,
|
||||
paid_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_customer (customer_id),
|
||||
INDEX idx_status (status)
|
||||
)
|
||||
`);
|
||||
|
||||
// Ensure new columns on existing customers table
|
||||
await ensureColumn(db, 'customers', 'role', "ENUM('user','admin') DEFAULT 'user'");
|
||||
await ensureColumn(db, 'customers', 'reset_token', 'VARCHAR(255) NULL');
|
||||
await ensureColumn(db, 'customers', 'reset_expires_at', 'TIMESTAMP NULL');
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
|
||||
386
src/index.ts
386
src/index.ts
@@ -1,6 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
@@ -14,7 +15,7 @@ import { tools, handleToolCall } from './tools.js';
|
||||
import { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial } from './manifest.js';
|
||||
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
|
||||
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
|
||||
import { meterMiddleware, type Customer } from './billing/middleware.js';
|
||||
import { meterMiddleware, resolveCustomerByApiKey, resolveCustomerById, type Customer } from './billing/middleware.js';
|
||||
import {
|
||||
registerClient,
|
||||
getClient,
|
||||
@@ -23,9 +24,13 @@ import {
|
||||
validateAccessToken,
|
||||
getAuthorizeHtml,
|
||||
} from './oauth.js';
|
||||
import { initDatabase } from './db.js';
|
||||
import { initDatabase, getPool } from './db.js';
|
||||
import { hashPassword, verifyPassword, signJWT, verifyJWT, findCustomerByEmail, createCustomer, setResetToken, findCustomerByResetToken, clearResetToken, updatePassword } from './auth.js';
|
||||
import { recordUsage, getMonthlyUsage, getUsageBreakdown, checkLimit } from './billing/usage.js';
|
||||
import { getCustomerInvoices, getInvoiceByNumber, markInvoiceSent, markInvoicePaid, generateMonthlyInvoice } from './billing/invoices.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
@@ -152,14 +157,14 @@ async function appendPilotRequestToVault(requestId: string, body: PilotRequestBo
|
||||
const content = formatPilotRequestMarkdown(requestId, body, req);
|
||||
const dailyNotePath = `Daily Notes/${getEasternDateString()}.md`;
|
||||
|
||||
await handleToolCall('obsidian_append_to_note', {
|
||||
await callTool(req, 'obsidian_append_to_note', {
|
||||
path: 'SquareMCP/Pilot Requests.md',
|
||||
header: 'Pilot Requests',
|
||||
content,
|
||||
create_if_missing: true,
|
||||
});
|
||||
|
||||
await handleToolCall('obsidian_append_to_note', {
|
||||
await callTool(req, 'obsidian_append_to_note', {
|
||||
path: dailyNotePath,
|
||||
header: 'SquareMCP Pilot Requests',
|
||||
content,
|
||||
@@ -302,19 +307,48 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex
|
||||
// No API key configured = open access
|
||||
if (!API_KEY) return next();
|
||||
|
||||
// 1. Check x-api-key header or query param (backward compatibility)
|
||||
// 1. Check x-api-key header or query param (backward compatibility — global key)
|
||||
const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined);
|
||||
if (apiKeyProvided === API_KEY) return next();
|
||||
|
||||
// 2. Check OAuth Bearer token
|
||||
// 2. Check customer API key (per-user SaaS auth)
|
||||
if (apiKeyProvided) {
|
||||
const customer = await resolveCustomerByApiKey(apiKeyProvided);
|
||||
if (customer && customer.active) {
|
||||
(req as express.Request & { customer?: Customer }).customer = customer;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check OAuth Bearer token
|
||||
const bearerToken = extractBearerToken(req);
|
||||
if (bearerToken && await validateAccessToken(bearerToken)) return next();
|
||||
|
||||
// 4. Check JWT session cookie (web app auth)
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (jwtCookie) {
|
||||
try {
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
const customer = await resolveCustomerById(payload.sub);
|
||||
if (customer && customer.active) {
|
||||
(req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string } }).customer = customer;
|
||||
(req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
plan: payload.plan,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
// invalid JWT, fall through to 401
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
'WWW-Authenticate',
|
||||
`Bearer realm="hermes", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`
|
||||
);
|
||||
res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, or Bearer token' });
|
||||
res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, Bearer token, or session cookie' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -717,7 +751,7 @@ app.post('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) =>
|
||||
const args = (req.body ?? {}) as Record<string, unknown>;
|
||||
console.log(`[chatgpt-mcp] ${toolName}`, JSON.stringify(args).substring(0, 200));
|
||||
try {
|
||||
const result = await handleToolCall(toolName, args);
|
||||
const result = await callTool(req, toolName, args);
|
||||
const text = result.content[0].text;
|
||||
if (text.startsWith('Error:')) {
|
||||
res.status(400).json({ error: text.slice(7).trim() });
|
||||
@@ -734,7 +768,7 @@ app.get('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => {
|
||||
const args = req.query as Record<string, unknown>;
|
||||
console.log(`[chatgpt-mcp] GET ${toolName}`, JSON.stringify(args).substring(0, 200));
|
||||
try {
|
||||
const result = await handleToolCall(toolName, args);
|
||||
const result = await callTool(req, toolName, args);
|
||||
const text = result.content[0].text;
|
||||
if (text.startsWith('Error:')) {
|
||||
res.status(400).json({ error: text.slice(7).trim() });
|
||||
@@ -799,7 +833,7 @@ app.get('/api/obsidian/search', requireAuth, async (req, res) => {
|
||||
const tagsRaw = req.query.tags as string | undefined;
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : undefined;
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_search_notes', { query, limit, path_filter, tags });
|
||||
const result = await callTool(req, 'obsidian_search_notes', { query, limit, path_filter, tags });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -810,7 +844,7 @@ app.get('/api/obsidian/note', requireAuth, async (req, res) => {
|
||||
const path = req.query.path as string | undefined;
|
||||
if (!path) { res.status(400).json({ error: 'path is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_read_note', { path });
|
||||
const result = await callTool(req, 'obsidian_read_note', { path });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
@@ -822,7 +856,7 @@ app.post('/api/obsidian/note/append', requireAuth, async (req, res) => {
|
||||
const { path, content, header, create_if_missing } = req.body as Record<string, unknown>;
|
||||
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_append_to_note', { path, content, header, create_if_missing });
|
||||
const result = await callTool(req, 'obsidian_append_to_note', { path, content, header, create_if_missing });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -833,16 +867,16 @@ app.put('/api/obsidian/note', requireAuth, async (req, res) => {
|
||||
const { path, content } = req.body as Record<string, unknown>;
|
||||
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_update_note', { path, content });
|
||||
const result = await callTool(req, 'obsidian_update_note', { path, content });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/obsidian/sync', requireAuth, async (_req, res) => {
|
||||
app.get('/api/obsidian/sync', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_sync_status', {});
|
||||
const result = await callTool(req, 'obsidian_sync_status', {});
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -854,7 +888,7 @@ app.post('/api/whatsapp/send', requireAuth, async (req, res) => {
|
||||
const { to, message, account } = req.body as Record<string, unknown>;
|
||||
if (!to || !message) { res.status(400).json({ error: 'to and message are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('whatsapp_send_message', { to, message, account });
|
||||
const result = await callTool(req, 'whatsapp_send_message', { to, message, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -865,7 +899,7 @@ app.post('/api/whatsapp/template', requireAuth, async (req, res) => {
|
||||
const { to, template_name, language, components, account } = req.body as Record<string, unknown>;
|
||||
if (!to || !template_name) { res.status(400).json({ error: 'to and template_name are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('whatsapp_send_template', { to, template_name, language, components, account });
|
||||
const result = await callTool(req, 'whatsapp_send_template', { to, template_name, language, components, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -875,7 +909,7 @@ app.post('/api/whatsapp/template', requireAuth, async (req, res) => {
|
||||
app.get('/api/whatsapp/templates', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('whatsapp_list_templates', { account });
|
||||
const result = await callTool(req, 'whatsapp_list_templates', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -916,6 +950,93 @@ app.post('/webhook/whatsapp', express.json(), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auth endpoints ──────────────────────────────────────────────
|
||||
|
||||
app.post('/api/auth/signup', express.json(), async (req, res) => {
|
||||
const { email, password } = req.body as Record<string, string>;
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password required' });
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await findCustomerByEmail(email);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'Email already registered' });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const apiKey = crypto.randomUUID().replace(/-/g, '');
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
try {
|
||||
// Check if this is the first user — make them admin
|
||||
const [countRows] = await getPool().query<any[]>('SELECT COUNT(*) as c FROM customers');
|
||||
const isFirstUser = countRows[0]?.c === 0;
|
||||
await createCustomer(id, email, passwordHash, apiKey);
|
||||
if (isFirstUser) {
|
||||
await getPool().query("UPDATE customers SET role = 'admin', plan = 'enterprise' WHERE id = ?", [id]);
|
||||
}
|
||||
const token = signJWT({ sub: id, email, plan: isFirstUser ? 'enterprise' : 'free' });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
res.status(201).json({ id, email, plan: 'free', api_key: apiKey });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to create account' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', express.json(), async (req, res) => {
|
||||
const { email, password } = req.body as Record<string, string>;
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await findCustomerByEmail(email);
|
||||
if (!customer || !customer.password_hash) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, customer.password_hash);
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
res.json({ id: customer.id, email: customer.email, plan: customer.plan, api_key: customer.api_key });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (_req, res) => {
|
||||
res.clearCookie('session');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
||||
const jwtUser = (req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser;
|
||||
if (jwtUser) {
|
||||
res.json(jwtUser);
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
});
|
||||
|
||||
// ── Customer onboarding endpoints ───────────────────────────────
|
||||
|
||||
// Connect WhatsApp — called after customer enters their Meta credentials
|
||||
@@ -1013,11 +1134,174 @@ app.get('/api/connections', meterMiddleware, async (req, res) => {
|
||||
res.json({ customerId: customer.id, connections: status });
|
||||
});
|
||||
|
||||
// ── Usage & Limits ──────────────────────────────────────────────
|
||||
|
||||
app.get('/api/usage', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const used = await getMonthlyUsage(customer.id);
|
||||
const breakdown = await getUsageBreakdown(customer.id);
|
||||
const limitCheck = await checkLimit(customer.id, customer.plan);
|
||||
res.json({
|
||||
plan: customer.plan,
|
||||
monthlyLimit: limitCheck.limit,
|
||||
used,
|
||||
remaining: limitCheck.limit === -1 ? -1 : Math.max(0, limitCheck.limit - used),
|
||||
breakdown,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Invoices ────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/invoices', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const invoices = await getCustomerInvoices(customer.id);
|
||||
res.json({ invoices });
|
||||
});
|
||||
|
||||
app.get('/api/invoices/:number', meterMiddleware, async (req, res) => {
|
||||
const invoice = await getInvoiceByNumber(req.params.number);
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: 'Invoice not found' });
|
||||
return;
|
||||
}
|
||||
res.json(invoice);
|
||||
});
|
||||
|
||||
// ── Password Reset ──────────────────────────────────────────────
|
||||
|
||||
app.post('/api/auth/forgot-password', express.json(), async (req, res) => {
|
||||
const { email } = req.body as Record<string, string>;
|
||||
if (!email) {
|
||||
res.status(400).json({ error: 'Email required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomUUID().replace(/-/g, '');
|
||||
const success = await setResetToken(email, token);
|
||||
|
||||
if (!success) {
|
||||
// Don't reveal if email exists
|
||||
res.json({ message: 'If an account exists, a reset link has been sent.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// In production, send email here. For now, return the token in dev mode.
|
||||
const resetUrl = `https://app.squaremcp.com/reset-password?token=${token}`;
|
||||
res.json({
|
||||
message: 'Password reset link generated.',
|
||||
resetUrl,
|
||||
token,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset-password', express.json(), async (req, res) => {
|
||||
const { token, password } = req.body as Record<string, string>;
|
||||
if (!token || !password) {
|
||||
res.status(400).json({ error: 'Token and password required' });
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await findCustomerByResetToken(token);
|
||||
if (!customer) {
|
||||
res.status(400).json({ error: 'Invalid or expired reset token' });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await updatePassword(customer.id, passwordHash);
|
||||
await clearResetToken(customer.id);
|
||||
|
||||
res.json({ message: 'Password updated successfully.' });
|
||||
});
|
||||
|
||||
// ── Admin Endpoints ─────────────────────────────────────────────
|
||||
|
||||
function callTool(req: express.Request, name: string, args: Record<string, unknown>) {
|
||||
const customer = (req as express.Request & { customer?: Customer }).customer;
|
||||
return handleToolCall(name, args, customer);
|
||||
}
|
||||
|
||||
async function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
// Global API key = superadmin access
|
||||
const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined);
|
||||
if (apiKeyProvided === API_KEY) return next();
|
||||
|
||||
// Check JWT first
|
||||
const jwtCookie = req.cookies?.session;
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (jwtCookie) {
|
||||
try {
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
customerId = payload.sub;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Check customer API key
|
||||
if (!customerId && apiKeyProvided) {
|
||||
const customer = await resolveCustomerByApiKey(apiKeyProvided);
|
||||
if (customer) customerId = customer.id;
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await getPool().query<any[]>('SELECT role FROM customers WHERE id = ?', [customerId]);
|
||||
if (!rows.length || rows[0].role !== 'admin') {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
app.get('/api/admin/customers', requireAdmin, async (req, res) => {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
'SELECT id, email, plan, active, role, created_at FROM customers ORDER BY created_at DESC'
|
||||
);
|
||||
res.json({ customers: rows });
|
||||
});
|
||||
|
||||
app.get('/api/admin/customers/:id/usage', requireAdmin, async (req, res) => {
|
||||
const customerId = req.params.id;
|
||||
const used = await getMonthlyUsage(customerId);
|
||||
const breakdown = await getUsageBreakdown(customerId);
|
||||
res.json({ customerId, used, breakdown });
|
||||
});
|
||||
|
||||
app.post('/api/admin/customers/:id/invoice', requireAdmin, async (req, res) => {
|
||||
const customerId = req.params.id;
|
||||
const invoice = await generateMonthlyInvoice(customerId);
|
||||
if (!invoice) {
|
||||
res.status(400).json({ error: 'No usage to invoice' });
|
||||
return;
|
||||
}
|
||||
res.json(invoice);
|
||||
});
|
||||
|
||||
app.post('/api/admin/invoices/:number/send', requireAdmin, async (req, res) => {
|
||||
await markInvoiceSent(req.params.number);
|
||||
res.json({ sent: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/invoices/:number/pay', requireAdmin, async (req, res) => {
|
||||
await markInvoicePaid(req.params.number);
|
||||
res.json({ paid: true });
|
||||
});
|
||||
|
||||
// ── LinkedIn REST endpoints ─────────────────────────────────────
|
||||
app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('linkedin_get_profile', { account });
|
||||
const result = await callTool(req, 'linkedin_get_profile', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1028,7 +1312,7 @@ app.post('/api/linkedin/post', requireAuth, async (req, res) => {
|
||||
const { text, visibility, account } = req.body as Record<string, unknown>;
|
||||
if (!text) { res.status(400).json({ error: 'text is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('linkedin_create_post', { text, visibility, account });
|
||||
const result = await callTool(req, 'linkedin_create_post', { text, visibility, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1038,7 +1322,7 @@ app.post('/api/linkedin/post', requireAuth, async (req, res) => {
|
||||
app.post('/api/linkedin/video', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { video_url, text, visibility, account } = req.body as Record<string, unknown>;
|
||||
const result = await handleToolCall('linkedin_upload_video', { video_url, text, visibility, account });
|
||||
const result = await callTool(req, 'linkedin_upload_video', { video_url, text, visibility, account });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
@@ -1048,7 +1332,7 @@ app.post('/api/linkedin/video', requireAuth, async (req, res) => {
|
||||
app.post('/api/linkedin/search-connections', requireAuth, async (req, res) => {
|
||||
const { keywords, account } = req.body as Record<string, unknown>;
|
||||
try {
|
||||
const result = await handleToolCall('linkedin_search_connections', { keywords, account });
|
||||
const result = await callTool(req, 'linkedin_search_connections', { keywords, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1059,7 +1343,7 @@ app.post('/api/linkedin/message', requireAuth, async (req, res) => {
|
||||
const { recipient_id, message, account } = req.body as Record<string, unknown>;
|
||||
if (!recipient_id || !message) { res.status(400).json({ error: 'recipient_id and message are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('linkedin_send_message', { recipient_id, message, account });
|
||||
const result = await callTool(req, 'linkedin_send_message', { recipient_id, message, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1070,7 +1354,7 @@ app.post('/api/linkedin/message', requireAuth, async (req, res) => {
|
||||
app.get('/api/telegram/me', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('telegram_get_me', { account });
|
||||
const result = await callTool(req, 'telegram_get_me', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1081,7 +1365,7 @@ app.post('/api/telegram/message', requireAuth, async (req, res) => {
|
||||
const { chat_id, text, parse_mode, account } = req.body as Record<string, unknown>;
|
||||
if (!chat_id || !text) { res.status(400).json({ error: 'chat_id and text are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('telegram_send_message', { chat_id, text, parse_mode, account });
|
||||
const result = await callTool(req, 'telegram_send_message', { chat_id, text, parse_mode, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1092,7 +1376,7 @@ app.post('/api/telegram/photo', requireAuth, async (req, res) => {
|
||||
const { chat_id, photo, caption, account } = req.body as Record<string, unknown>;
|
||||
if (!chat_id || !photo) { res.status(400).json({ error: 'chat_id and photo are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('telegram_send_photo', { chat_id, photo, caption, account });
|
||||
const result = await callTool(req, 'telegram_send_photo', { chat_id, photo, caption, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1103,7 +1387,7 @@ app.get('/api/telegram/updates', requireAuth, async (req, res) => {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('telegram_get_updates', { limit, account });
|
||||
const result = await callTool(req, 'telegram_get_updates', { limit, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1115,7 +1399,7 @@ app.get('/api/telegram/chat', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!chat_id) { res.status(400).json({ error: 'chat_id is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('telegram_get_chat', { chat_id, account });
|
||||
const result = await callTool(req, 'telegram_get_chat', { chat_id, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1126,7 +1410,7 @@ app.get('/api/telegram/chat', requireAuth, async (req, res) => {
|
||||
app.get('/api/discord/me', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('discord_get_me', { account });
|
||||
const result = await callTool(req, 'discord_get_me', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1136,7 +1420,7 @@ app.get('/api/discord/me', requireAuth, async (req, res) => {
|
||||
app.get('/api/discord/guilds', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('discord_get_guilds', { account });
|
||||
const result = await callTool(req, 'discord_get_guilds', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1148,7 +1432,7 @@ app.get('/api/discord/channels', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!guild_id) { res.status(400).json({ error: 'guild_id is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('discord_get_channels', { guild_id, account });
|
||||
const result = await callTool(req, 'discord_get_channels', { guild_id, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1159,7 +1443,7 @@ app.post('/api/discord/message', requireAuth, async (req, res) => {
|
||||
const { channel_id, content, account } = req.body as Record<string, unknown>;
|
||||
if (!channel_id || !content) { res.status(400).json({ error: 'channel_id and content are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('discord_send_message', { channel_id, content, account });
|
||||
const result = await callTool(req, 'discord_send_message', { channel_id, content, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1172,7 +1456,7 @@ app.get('/api/discord/messages', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!channel_id) { res.status(400).json({ error: 'channel_id is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('discord_get_messages', { channel_id, limit, account });
|
||||
const result = await callTool(req, 'discord_get_messages', { channel_id, limit, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1183,7 +1467,7 @@ app.get('/api/discord/messages', requireAuth, async (req, res) => {
|
||||
app.get('/api/instagram/profile', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('instagram_get_profile', { account });
|
||||
const result = await callTool(req, 'instagram_get_profile', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1194,7 +1478,7 @@ app.get('/api/instagram/media', requireAuth, async (req, res) => {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('instagram_get_media', { limit, account });
|
||||
const result = await callTool(req, 'instagram_get_media', { limit, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1205,7 +1489,7 @@ app.post('/api/instagram/post', requireAuth, async (req, res) => {
|
||||
const { image_url, caption, account } = req.body as Record<string, unknown>;
|
||||
if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('instagram_create_post', { image_url, caption, account });
|
||||
const result = await callTool(req, 'instagram_create_post', { image_url, caption, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1216,7 +1500,7 @@ app.post('/api/instagram/reel', requireAuth, async (req, res) => {
|
||||
const { video_url, caption, account } = req.body as Record<string, unknown>;
|
||||
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('instagram_create_reel', { video_url, caption, account });
|
||||
const result = await callTool(req, 'instagram_create_reel', { video_url, caption, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1230,7 +1514,7 @@ app.get('/api/twitter/search', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!query) { res.status(400).json({ error: 'query is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_search_tweets', { query, max_results, account });
|
||||
const result = await callTool(req, 'twitter_search_tweets', { query, max_results, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1242,7 +1526,7 @@ app.get('/api/twitter/user', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!username) { res.status(400).json({ error: 'username is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_get_user_profile', { username, account });
|
||||
const result = await callTool(req, 'twitter_get_user_profile', { username, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1255,7 +1539,7 @@ app.get('/api/twitter/tweets', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
if (!username) { res.status(400).json({ error: 'username is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_get_user_tweets', { username, max_results, account });
|
||||
const result = await callTool(req, 'twitter_get_user_tweets', { username, max_results, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1266,7 +1550,7 @@ app.post('/api/twitter/tweet', requireAuth, async (req, res) => {
|
||||
const { text, account } = req.body as Record<string, unknown>;
|
||||
if (!text) { res.status(400).json({ error: 'text is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_create_tweet', { text, account });
|
||||
const result = await callTool(req, 'twitter_create_tweet', { text, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1277,7 +1561,7 @@ app.post('/api/twitter/video', requireAuth, async (req, res) => {
|
||||
const { video_url, text, account } = req.body as Record<string, unknown>;
|
||||
if (!video_url || !text) { res.status(400).json({ error: 'video_url and text are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('twitter_upload_video', { video_url, text, account });
|
||||
const result = await callTool(req, 'twitter_upload_video', { video_url, text, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1288,7 +1572,7 @@ app.post('/api/twitter/video', requireAuth, async (req, res) => {
|
||||
app.get('/api/facebook/page', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('facebook_get_page', { account });
|
||||
const result = await callTool(req, 'facebook_get_page', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1299,7 +1583,7 @@ app.get('/api/facebook/posts', requireAuth, async (req, res) => {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('facebook_get_posts', { limit, account });
|
||||
const result = await callTool(req, 'facebook_get_posts', { limit, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1310,7 +1594,7 @@ app.post('/api/facebook/post', requireAuth, async (req, res) => {
|
||||
const { message, link, account } = req.body as Record<string, unknown>;
|
||||
if (!message) { res.status(400).json({ error: 'message is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('facebook_create_post', { message, link, account });
|
||||
const result = await callTool(req, 'facebook_create_post', { message, link, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1321,7 +1605,7 @@ app.post('/api/facebook/photo', requireAuth, async (req, res) => {
|
||||
const { image_url, caption, account } = req.body as Record<string, unknown>;
|
||||
if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('facebook_create_photo_post', { image_url, caption, account });
|
||||
const result = await callTool(req, 'facebook_create_photo_post', { image_url, caption, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1332,7 +1616,7 @@ app.post('/api/facebook/video', requireAuth, async (req, res) => {
|
||||
const { video_url, description, account } = req.body as Record<string, unknown>;
|
||||
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('facebook_create_video_post', { video_url, description, account });
|
||||
const result = await callTool(req, 'facebook_create_video_post', { video_url, description, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1343,7 +1627,7 @@ app.post('/api/facebook/video', requireAuth, async (req, res) => {
|
||||
app.get('/api/tiktok/profile', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('tiktok_get_profile', { account });
|
||||
const result = await callTool(req, 'tiktok_get_profile', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1353,7 +1637,7 @@ app.get('/api/tiktok/profile', requireAuth, async (req, res) => {
|
||||
app.get('/api/tiktok/creator-info', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
try {
|
||||
const result = await handleToolCall('tiktok_get_creator_info', { account });
|
||||
const result = await callTool(req, 'tiktok_get_creator_info', { account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1364,7 +1648,7 @@ app.post('/api/tiktok/video', requireAuth, async (req, res) => {
|
||||
const { video_url, title, description, account } = req.body as Record<string, unknown>;
|
||||
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('tiktok_create_video', { video_url, title, description, account });
|
||||
const result = await callTool(req, 'tiktok_create_video', { video_url, title, description, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
@@ -1375,7 +1659,7 @@ app.post('/api/tiktok/video/status', requireAuth, async (req, res) => {
|
||||
const { publish_id, account } = req.body as Record<string, unknown>;
|
||||
if (!publish_id) { res.status(400).json({ error: 'publish_id is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('tiktok_get_video_status', { publish_id, account });
|
||||
const result = await callTool(req, 'tiktok_get_video_status', { publish_id, account });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Customer } from './billing/middleware.js';
|
||||
import { recordUsage } from './billing/usage.js';
|
||||
import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js';
|
||||
import { sendEmail, createDraft } from './smtp.js';
|
||||
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
|
||||
@@ -1124,6 +1125,10 @@ export async function handleToolCall(
|
||||
}
|
||||
|
||||
console.log(`[tool] ${name} OK (${Date.now() - t0}ms)`);
|
||||
if (customer) {
|
||||
const platform = name.split('_')[0];
|
||||
recordUsage(customer.id, platform, name).catch(() => {});
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user