feat(saas): SquareMCP v2 — multi-tenant MCP platform complete
Steps 0–10 of the v2 plan, 194 tests passing. Core infrastructure - Shared Redis client (src/redis.ts); all four Redis consumers migrated - Vitest test harness with vitest.config.ts and npm test/test:watch scripts Billing & invoicing (Steps 1–2) - Monthly invoice generation with idempotency (MySQL uq_customer_period unique key) - Cron job with Redis distributed lock (Lua compare-delete, 1-hr TTL) - Invoice emailer via nodemailer (FETCHERPAY SMTP) - Billing middleware: checkLimit gate in handleToolCall; platform attribution fix Email multi-tenancy (Step 3) - EmailCtx = Account | EmailCredentials; imap.ts + smtp.ts accept both - resolveEmailCtx helper in tools.ts; all email tools use customer credentials Analytics + platform health (Steps 4–5) - Chart.js bar charts for platform breakdown and daily activity - Token expiry check in getCredential with dynamic import refresh - platform-health.ts: per-platform health probe with 10-min Redis cache - GET /api/health/platforms; "Token expired" amber badge in dashboard Tool schema filtering (Step 6) - stripAccountParam deep-clones tool schemas; multi-tenant sessions never see the internal account enum OAuth hardening (Step 7) - Atomic auth code consumption: UPDATE SET used=TRUE, check affectedRows - customer_id threaded through oauth_auth_codes → oauth_tokens - getTokenCustomer(); requireAuth resolves req.customer from Bearer token - Consent page requires authenticated session; redirect_uri validated against registered URIs; http://localhost:* loopback wildcard DCR browser flow (Step 8) - ensureOAuthAppRegistered() upserts pre-registered SquareMCP OAuth app on startup with redirect URIs for mcp-callback, localhost:*, claude-desktop, opencode - GET /oauth/connect-mcp → server-side redirect (client_id off frontend) - GET /oauth/mcp-callback → exchanges code, renders config snippet page with copy buttons for Claude Desktop and Codex CLI Webhooks (Step 9) - webhook_url + webhook_secret columns on customers - deliverWebhook(): HMAC-SHA256 signing, 3× exponential retry (1s/4s/16s), Redis DLQ with 7-day TTL on total failure - isValidWebhookUrl(): SSRF protection (blocks RFC-1918, localhost, .local) - POST /api/webhooks/config (secret returned once), GET, DELETE - GET /api/admin/webhooks/dlq/:customerId - WhatsApp POST route uses express.raw() for raw body preservation - Dashboard Webhooks tab with secret-once display and copy button Developer docs (Step 10) - docs/ static HTML site (GitHub Pages, no build pipeline) - index.html: landing page with client + platform overview - getting-started.html: tabbed MCP config for Claude Desktop, Codex CLI, opencode - platforms.html: LinkedIn, TikTok, WhatsApp, Instagram, Twitter, Telegram guides - agent-tutorial.html: complete Node.js agent (Anthropic SDK + MCP SDK), LinkedIn posting loop, extensions for multi-platform + inbound webhook reaction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
308
src/index.ts
308
src/index.ts
@@ -1,4 +1,5 @@
|
||||
import 'dotenv/config';
|
||||
import crypto from 'crypto';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
ListResourcesRequestSchema,
|
||||
isInitializeRequest,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { tools, handleToolCall } from './tools.js';
|
||||
import { tools, handleToolCall, stripAccountParam } 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';
|
||||
@@ -22,12 +23,18 @@ import {
|
||||
createAuthCode,
|
||||
exchangeCodeForToken,
|
||||
validateAccessToken,
|
||||
getTokenCustomer,
|
||||
getAuthorizeHtml,
|
||||
isValidRedirectUri,
|
||||
ensureOAuthAppRegistered,
|
||||
} from './oauth.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';
|
||||
import { getAllPlatformHealth } from './multitenancy/platform-health.js';
|
||||
import { deliverWebhook, isValidWebhookUrl } from './webhooks/delivery.js';
|
||||
import redis from './redis.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cookieParser());
|
||||
@@ -320,9 +327,20 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check OAuth Bearer token
|
||||
// 3. Check OAuth Bearer token — resolve to customer when token has customer_id binding
|
||||
const bearerToken = extractBearerToken(req);
|
||||
if (bearerToken && await validateAccessToken(bearerToken)) return next();
|
||||
if (bearerToken) {
|
||||
const tokenInfo = await getTokenCustomer(bearerToken);
|
||||
if (tokenInfo) {
|
||||
const customer = await resolveCustomerById(tokenInfo.customerId);
|
||||
if (customer && customer.active) {
|
||||
(req as express.Request & { customer?: Customer }).customer = customer;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// Fall back to legacy tokens (no customer_id) — validate presence only
|
||||
if (await validateAccessToken(bearerToken)) return next();
|
||||
}
|
||||
|
||||
// 4. Check JWT session cookie (web app auth)
|
||||
const jwtCookie = req.cookies?.session;
|
||||
@@ -364,17 +382,19 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
function createMcpServer() {
|
||||
function createMcpServer(customer?: Customer) {
|
||||
const server = new Server(
|
||||
{ name: 'hermes', version: '1.0.0' },
|
||||
{ capabilities: { tools: {}, resources: {} } }
|
||||
);
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
||||
const visibleTools = customer ? tools.map(stripAccountParam) : tools;
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: visibleTools }));
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
return handleToolCall(
|
||||
request.params.name,
|
||||
(request.params.arguments ?? {}) as Record<string, unknown>
|
||||
(request.params.arguments ?? {}) as Record<string, unknown>,
|
||||
customer
|
||||
);
|
||||
});
|
||||
return server;
|
||||
@@ -421,6 +441,26 @@ app.get('/oauth/authorize', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidRedirectUri(redirectUri, client.redirect_uris)) {
|
||||
res.status(400).send('redirect_uri not registered for this client');
|
||||
return;
|
||||
}
|
||||
|
||||
// Require authenticated SquareMCP session to show the consent page
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (!jwtCookie) {
|
||||
const returnTo = encodeURIComponent(req.originalUrl);
|
||||
res.redirect(`/login?return_to=${returnTo}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
verifyJWT(jwtCookie);
|
||||
} catch {
|
||||
const returnTo = encodeURIComponent(req.originalUrl);
|
||||
res.redirect(`/login?return_to=${returnTo}`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(getAuthorizeHtml({
|
||||
client_id: clientId,
|
||||
@@ -452,6 +492,11 @@ app.post('/oauth/authorize', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidRedirectUri(redirectUri, client.redirect_uris)) {
|
||||
res.status(400).send('redirect_uri not registered for this client');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action !== 'allow') {
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
@@ -461,7 +506,19 @@ app.post('/oauth/authorize', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod);
|
||||
// Bind the auth code to the authenticated customer if present
|
||||
let customerId: string | undefined;
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (jwtCookie) {
|
||||
try {
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
customerId = payload.sub;
|
||||
} catch {
|
||||
// no binding — legacy flow
|
||||
}
|
||||
}
|
||||
|
||||
const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod, customerId);
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('code', code.code);
|
||||
if (state) url.searchParams.set('state', state);
|
||||
@@ -502,6 +559,121 @@ app.post('/oauth/token', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── DCR browser flow ────────────────────────────────────────────────────────
|
||||
|
||||
// Kick off the "Connect MCP Client" browser flow — server redirects to consent page
|
||||
// so the client_id never needs to be exposed in the frontend JS.
|
||||
app.get('/oauth/connect-mcp', (req, res) => {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
if (!clientId) {
|
||||
res.status(503).send('MCP OAuth app not configured (OAUTH_CLIENT_ID missing)');
|
||||
return;
|
||||
}
|
||||
const callbackUrl = `${SERVER_URL}/oauth/mcp-callback`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: callbackUrl,
|
||||
response_type: 'code',
|
||||
scope: 'mcp',
|
||||
});
|
||||
res.redirect(`/oauth/authorize?${params}`);
|
||||
});
|
||||
|
||||
// Callback — exchange code for token and render the config snippet page
|
||||
app.get('/oauth/mcp-callback', async (req, res) => {
|
||||
const code = req.query.code as string | undefined;
|
||||
const error = req.query.error as string | undefined;
|
||||
|
||||
if (error || !code) {
|
||||
res.status(400).send(renderMcpCallbackHtml({ error: error || 'Missing authorization code' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
res.status(503).send(renderMcpCallbackHtml({ error: 'Server misconfiguration — OAUTH_CLIENT_ID/SECRET missing' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackUrl = `${SERVER_URL}/oauth/mcp-callback`;
|
||||
const token = await exchangeCodeForToken(clientId, clientSecret, code, callbackUrl);
|
||||
if (!token) {
|
||||
res.status(400).send(renderMcpCallbackHtml({ error: 'Token exchange failed — code may be expired or already used' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(renderMcpCallbackHtml({ token: token.access_token, serverUrl: SERVER_URL }));
|
||||
});
|
||||
|
||||
function renderMcpCallbackHtml(opts: { token?: string; serverUrl?: string; error?: string }): string {
|
||||
if (opts.error) {
|
||||
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Connection failed</title>
|
||||
<style>body{font-family:system-ui,sans-serif;background:#0f0f10;color:#e5e5e5;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}
|
||||
.card{background:#1a1a1b;border:1px solid #2a2a2b;border-radius:12px;padding:32px;max-width:520px;width:100%}
|
||||
h1{color:#dc2626;margin:0 0 12px}p{color:#888;margin:0}</style></head>
|
||||
<body><div class="card"><h1>Connection failed</h1><p>${opts.error}</p></div></body></html>`;
|
||||
}
|
||||
|
||||
const { token, serverUrl } = opts;
|
||||
const claudeConfig = JSON.stringify({
|
||||
mcpServers: { 'hermes-mcp': { type: 'http', url: `${serverUrl}/mcp`, headers: { Authorization: `Bearer ${token}` } } }
|
||||
}, null, 2);
|
||||
const codexConfig = JSON.stringify({
|
||||
mcpServers: { 'hermes-mcp': { type: 'http', url: `${serverUrl}/mcp`, headers: { Authorization: `Bearer ${token}` } } }
|
||||
}, null, 2);
|
||||
|
||||
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MCP Client Connected — SquareMCP</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;background:#0f0f10;color:#e5e5e5;margin:0;padding:24px}
|
||||
.card{background:#1a1a1b;border:1px solid #2a2a2b;border-radius:12px;padding:32px;max-width:680px;margin:0 auto}
|
||||
h1{font-size:22px;margin:0 0 8px;color:#10a37f}
|
||||
.subtitle{color:#888;margin:0 0 28px;font-size:14px}
|
||||
h2{font-size:14px;font-weight:600;color:#888;text-transform:uppercase;letter-spacing:.05em;margin:20px 0 8px}
|
||||
pre{background:#0f0f10;border:1px solid #2a2a2b;border-radius:8px;padding:16px;font-size:12px;overflow-x:auto;position:relative}
|
||||
.copy-btn{position:absolute;top:8px;right:8px;background:#2a2a2b;border:none;color:#888;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:11px}
|
||||
.copy-btn:hover{color:#e5e5e5}
|
||||
.token-box{background:#0f0f10;border:1px solid #2a2a2b;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:13px;word-break:break-all;margin-bottom:8px}
|
||||
.warn{color:#888;font-size:12px;margin:4px 0 20px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>MCP Client Connected!</h1>
|
||||
<p class="subtitle">Copy your access token and the config for your MCP client below.</p>
|
||||
|
||||
<h2>Your Access Token</h2>
|
||||
<div class="token-box">${esc(token!)}</div>
|
||||
<p class="warn">Store this securely — it won't be shown again.</p>
|
||||
|
||||
<h2>Claude Desktop <code>claude_desktop_config.json</code></h2>
|
||||
<pre id="claude-cfg">${esc(claudeConfig)}<button class="copy-btn" onclick="copy('claude-cfg')">Copy</button></pre>
|
||||
|
||||
<h2>Codex CLI / opencode config</h2>
|
||||
<pre id="codex-cfg">${esc(codexConfig)}<button class="copy-btn" onclick="copy('codex-cfg')">Copy</button></pre>
|
||||
</div>
|
||||
<script>
|
||||
function copy(id) {
|
||||
const pre = document.getElementById(id);
|
||||
const text = pre.innerText.replace(/Copy$/, '').trim();
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = pre.querySelector('.copy-btn');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── TikTok Login Kit + Content Posting auth flow ───────────────────────────
|
||||
app.get('/auth/tiktok/start', async (req, res) => {
|
||||
if (!TIKTOK_CLIENT_KEY) {
|
||||
@@ -650,22 +822,25 @@ app.get('/auth/tiktok/callback', async (req, res) => {
|
||||
|
||||
// ── Streamable HTTP transport (MCP 1.x standard) ────────────────────────────
|
||||
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
|
||||
const sessionCustomers = new Map<string, Customer>();
|
||||
|
||||
async function createSession(): Promise<StreamableHTTPServerTransport> {
|
||||
async function createSession(customer?: Customer): Promise<StreamableHTTPServerTransport> {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
console.log(`[mcp] Session initialized: ${id}`);
|
||||
httpTransports.set(id, transport);
|
||||
if (customer) sessionCustomers.set(id, customer);
|
||||
},
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
console.log(`[mcp] Session closed: ${transport.sessionId}`);
|
||||
httpTransports.delete(transport.sessionId);
|
||||
sessionCustomers.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
const server = createMcpServer();
|
||||
const server = createMcpServer(customer);
|
||||
await server.connect(transport);
|
||||
return transport;
|
||||
}
|
||||
@@ -674,6 +849,7 @@ app.post('/mcp', requireAuth, async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
console.log(`[mcp] POST sessionId=${sessionId ?? 'none'}, isInit=${isInitializeRequest(req.body)}`);
|
||||
|
||||
const reqCustomer = (req as express.Request & { customer?: Customer }).customer;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && httpTransports.has(sessionId)) {
|
||||
@@ -684,12 +860,12 @@ app.post('/mcp', requireAuth, async (req, res) => {
|
||||
console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`);
|
||||
}
|
||||
console.log(`[mcp] Creating new session`);
|
||||
transport = await createSession();
|
||||
transport = await createSession(reqCustomer);
|
||||
} else {
|
||||
// Stale session ID from a pod restart — transparently create a new session
|
||||
// and handle the request. Our tools are stateless so no context is lost.
|
||||
console.warn(`[mcp] Unknown session ${sessionId ?? '(none)'} — auto-recovering with new session`);
|
||||
transport = await createSession();
|
||||
transport = await createSession(reqCustomer);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -725,7 +901,8 @@ app.get('/sse', requireAuth, async (req, res) => {
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
sseTransports.set(transport.sessionId, transport);
|
||||
res.on('close', () => sseTransports.delete(transport.sessionId));
|
||||
const server = createMcpServer();
|
||||
const sseCustomer = (req as express.Request & { customer?: Customer }).customer;
|
||||
const server = createMcpServer(sseCustomer);
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
@@ -919,7 +1096,12 @@ app.get('/api/whatsapp/templates', requireAuth, async (req, res) => {
|
||||
// ── WhatsApp webhook (multi-tenant) ─────────────────────────────
|
||||
async function handleInboundWhatsAppMessage(event: RoutedWebhookEvent): Promise<void> {
|
||||
console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`);
|
||||
// Future: route to customer's agent or queue for processing
|
||||
// Fire-and-forget — don't block the webhook acknowledgement
|
||||
deliverWebhook(event.customerId, 'whatsapp', 'inbound_message', {
|
||||
from: event.message.from,
|
||||
text: (event.message as unknown as Record<string, unknown>).text ?? null,
|
||||
timestamp: event.message.timestamp,
|
||||
}).catch((err) => console.error('[webhook/whatsapp] delivery error:', err));
|
||||
}
|
||||
|
||||
// WhatsApp webhook verification (GET)
|
||||
@@ -935,13 +1117,14 @@ app.get('/webhook/whatsapp', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// WhatsApp webhook delivery (POST) — multi-tenant routed
|
||||
app.post('/webhook/whatsapp', express.json(), async (req, res) => {
|
||||
// WhatsApp webhook delivery (POST) — raw body preserved for HMAC verification
|
||||
app.post('/webhook/whatsapp', express.raw({ type: '*/*' }), async (req, res) => {
|
||||
// Always acknowledge immediately to prevent Meta retries (20s window)
|
||||
res.status(200).send('EVENT_RECEIVED');
|
||||
|
||||
try {
|
||||
const events = await routeWhatsAppWebhook(req.body as Record<string, unknown>);
|
||||
const body = JSON.parse((req.body as Buffer).toString('utf8')) as Record<string, unknown>;
|
||||
const events = await routeWhatsAppWebhook(body);
|
||||
for (const event of events) {
|
||||
await handleInboundWhatsAppMessage(event);
|
||||
}
|
||||
@@ -1125,15 +1308,62 @@ app.get('/api/connections', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook', 'obsidian'];
|
||||
|
||||
const status: Record<string, boolean> = {};
|
||||
for (const platform of platforms) {
|
||||
const cred = await customer.getCredential(platform);
|
||||
status[platform] = cred !== null;
|
||||
}
|
||||
const results = await Promise.all(platforms.map((p) => customer.getCredential(p)));
|
||||
const status: Record<string, boolean> = Object.fromEntries(
|
||||
platforms.map((p, i) => [p, results[i] !== null])
|
||||
);
|
||||
|
||||
res.json({ customerId: customer.id, connections: status });
|
||||
});
|
||||
|
||||
app.get('/api/health/platforms', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const health = await getAllPlatformHealth(customer.id);
|
||||
res.json({ health });
|
||||
});
|
||||
|
||||
// ── Webhooks ────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/webhooks/config', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
'SELECT webhook_url FROM customers WHERE id = ?',
|
||||
[customer.id]
|
||||
);
|
||||
res.json({ webhookUrl: rows[0]?.webhook_url ?? null });
|
||||
});
|
||||
|
||||
app.post('/api/webhooks/config', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const webhookUrl = (req.body as Record<string, unknown>).webhook_url as string | undefined;
|
||||
|
||||
if (!webhookUrl) {
|
||||
res.status(400).json({ error: 'webhook_url required' });
|
||||
return;
|
||||
}
|
||||
if (!isValidWebhookUrl(webhookUrl)) {
|
||||
res.status(400).json({ error: 'Invalid webhook URL — must be https:// with a public hostname' });
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
await getPool().query(
|
||||
'UPDATE customers SET webhook_url = ?, webhook_secret = ? WHERE id = ?',
|
||||
[webhookUrl, secret, customer.id]
|
||||
);
|
||||
// Secret returned only at creation/rotation; not retrievable afterward
|
||||
res.json({ webhookUrl, webhookSecret: secret });
|
||||
});
|
||||
|
||||
app.delete('/api/webhooks/config', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
await getPool().query(
|
||||
'UPDATE customers SET webhook_url = NULL, webhook_secret = NULL WHERE id = ?',
|
||||
[customer.id]
|
||||
);
|
||||
res.json({ deleted: true });
|
||||
});
|
||||
|
||||
// ── Usage & Limits ──────────────────────────────────────────────
|
||||
|
||||
app.get('/api/usage', meterMiddleware, async (req, res) => {
|
||||
@@ -1150,6 +1380,20 @@ app.get('/api/usage', meterMiddleware, async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/usage/daily', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
`SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM usage_logs
|
||||
WHERE customer_id = ?
|
||||
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC`,
|
||||
[customer.id]
|
||||
);
|
||||
res.json({ daily: rows });
|
||||
});
|
||||
|
||||
// ── Invoices ────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/invoices', meterMiddleware, async (req, res) => {
|
||||
@@ -1159,8 +1403,9 @@ app.get('/api/invoices', meterMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/api/invoices/:number', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as express.Request & { customer?: Customer }).customer;
|
||||
const invoice = await getInvoiceByNumber(req.params.number);
|
||||
if (!invoice) {
|
||||
if (!invoice || invoice.customer_id !== customer?.id) {
|
||||
res.status(404).json({ error: 'Invoice not found' });
|
||||
return;
|
||||
}
|
||||
@@ -1297,6 +1542,13 @@ app.post('/api/admin/invoices/:number/pay', requireAdmin, async (req, res) => {
|
||||
res.json({ paid: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/webhooks/dlq/:customerId', requireAdmin, async (req, res) => {
|
||||
const { customerId } = req.params;
|
||||
const entries = await redis.lRange(`webhook:dlq:${customerId}`, 0, -1);
|
||||
const events = entries.map((e) => JSON.parse(e) as unknown);
|
||||
res.json({ customerId, events, count: events.length });
|
||||
});
|
||||
|
||||
// ── LinkedIn REST endpoints ─────────────────────────────────────
|
||||
app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
@@ -1748,6 +2000,18 @@ app.get('/health', (_req, res) => {
|
||||
async function main() {
|
||||
await initDatabase();
|
||||
|
||||
// Ensure the pre-registered SquareMCP OAuth app exists for the browser DCR flow
|
||||
const oauthClientId = process.env.OAUTH_CLIENT_ID;
|
||||
const oauthClientSecret = process.env.OAUTH_CLIENT_SECRET;
|
||||
if (oauthClientId && oauthClientSecret) {
|
||||
await ensureOAuthAppRegistered(oauthClientId, oauthClientSecret, [
|
||||
`${SERVER_URL}/oauth/mcp-callback`,
|
||||
'http://localhost:*',
|
||||
'claude-desktop://callback',
|
||||
'opencode://callback',
|
||||
]);
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Hermes MCP server running on port ${PORT}`);
|
||||
console.log(` Streamable HTTP: ${SERVER_URL}/mcp`);
|
||||
|
||||
Reference in New Issue
Block a user