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:
161
src/webhooks/delivery.test.ts
Normal file
161
src/webhooks/delivery.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { mockQuery, mockRPush, mockExpire } = vi.hoisted(() => ({
|
||||
mockQuery: vi.fn(),
|
||||
mockRPush: vi.fn(),
|
||||
mockExpire: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../db.js', () => ({ getPool: vi.fn(() => ({ query: mockQuery })) }));
|
||||
vi.mock('../redis.js', () => ({
|
||||
default: { rPush: mockRPush, expire: mockExpire },
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
import { deliverWebhook, isValidWebhookUrl } from './delivery.js';
|
||||
|
||||
// ── URL validation ──────────────────────────────────────────────
|
||||
|
||||
describe('isValidWebhookUrl', () => {
|
||||
it('accepts https:// with public hostname', () => {
|
||||
expect(isValidWebhookUrl('https://my-server.example.com/hook')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects ftp:// and other non-http schemes', () => {
|
||||
expect(isValidWebhookUrl('ftp://example.com/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects 127.x loopback', () => {
|
||||
expect(isValidWebhookUrl('http://127.0.0.1/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects 10.x private range', () => {
|
||||
expect(isValidWebhookUrl('https://10.0.0.1/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects 192.168.x private range', () => {
|
||||
expect(isValidWebhookUrl('https://192.168.1.1/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects 172.16-31.x private range', () => {
|
||||
expect(isValidWebhookUrl('https://172.16.0.1/hook')).toBe(false);
|
||||
expect(isValidWebhookUrl('https://172.31.255.255/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects localhost hostname', () => {
|
||||
expect(isValidWebhookUrl('http://localhost/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects .local domains', () => {
|
||||
expect(isValidWebhookUrl('http://myserver.local/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid URLs', () => {
|
||||
expect(isValidWebhookUrl('not-a-url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deliverWebhook ──────────────────────────────────────────────
|
||||
|
||||
describe('deliverWebhook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockRPush.mockResolvedValue(1);
|
||||
mockExpire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does nothing when customer has no webhook_url', async () => {
|
||||
mockQuery.mockResolvedValue([[{ webhook_url: null, webhook_secret: null }]]);
|
||||
await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1234' });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when customer not found', async () => {
|
||||
mockQuery.mockResolvedValue([[]]);
|
||||
await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1234' });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POSTs to webhook_url with correct headers on success', async () => {
|
||||
mockQuery.mockResolvedValue([[{
|
||||
webhook_url: 'https://example.com/hook',
|
||||
webhook_secret: 'secret123',
|
||||
}]]);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
|
||||
|
||||
await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1234', text: 'hi' });
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://example.com/hook');
|
||||
expect((opts.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||
expect((opts.headers as Record<string, string>)['X-SquareMCP-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('sends correct HMAC signature', async () => {
|
||||
const secret = 'mysecret';
|
||||
mockQuery.mockResolvedValue([[{ webhook_url: 'https://example.com/hook', webhook_secret: secret }]]);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
|
||||
|
||||
await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1' });
|
||||
|
||||
const [, opts] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
const sig = (opts.headers as Record<string, string>)['X-SquareMCP-Signature'];
|
||||
const body = opts.body as string;
|
||||
|
||||
// Verify the signature independently
|
||||
const { createHmac } = await import('crypto');
|
||||
const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
|
||||
expect(sig).toBe(expected);
|
||||
});
|
||||
|
||||
it('retries on failure and pushes to DLQ after all attempts', async () => {
|
||||
mockQuery.mockResolvedValue([[{ webhook_url: 'https://example.com/hook', webhook_secret: 'sec' }]]);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
const deliverPromise = deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1' });
|
||||
|
||||
// Advance timers through all retry delays: 1s, 4s, 16s
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
await vi.advanceTimersByTimeAsync(16000);
|
||||
await deliverPromise;
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
||||
expect(mockRPush).toHaveBeenCalledWith(
|
||||
'webhook:dlq:cust-1',
|
||||
expect.stringContaining('"customerId":"cust-1"')
|
||||
);
|
||||
expect(mockExpire).toHaveBeenCalledWith('webhook:dlq:cust-1', 604800);
|
||||
});
|
||||
|
||||
it('does not push to DLQ on first-attempt success', async () => {
|
||||
mockQuery.mockResolvedValue([[{ webhook_url: 'https://example.com/hook', webhook_secret: 'sec' }]]);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
|
||||
|
||||
await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1' });
|
||||
|
||||
expect(mockRPush).not.toHaveBeenCalled();
|
||||
expect(mockExpire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('succeeds on second attempt (one initial failure)', async () => {
|
||||
mockQuery.mockResolvedValue([[{ webhook_url: 'https://example.com/hook', webhook_secret: 'sec' }]]);
|
||||
(fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: false, status: 503 })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const deliverPromise = deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1' });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await deliverPromise;
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockRPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
84
src/webhooks/delivery.ts
Normal file
84
src/webhooks/delivery.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import crypto from 'crypto';
|
||||
import redis from '../redis.js';
|
||||
import { getPool } from '../db.js';
|
||||
|
||||
const RETRY_DELAYS_MS = [1000, 4000, 16000];
|
||||
const DLQ_TTL_SECONDS = 604800; // 7 days
|
||||
|
||||
export interface WebhookPayload {
|
||||
customerId: string;
|
||||
platform: string;
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isValidWebhookUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
const host = parsed.hostname;
|
||||
if (host === 'localhost' || host === '0.0.0.0' || host.endsWith('.local')) return false;
|
||||
// Block RFC-1918 private ranges and loopback
|
||||
if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function signPayload(secret: string, payload: string): string {
|
||||
return `sha256=${crypto.createHmac('sha256', secret).update(payload).digest('hex')}`;
|
||||
}
|
||||
|
||||
async function postWithRetry(url: string, payload: string, signature: string): Promise<boolean> {
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAYS_MS[attempt - 1]));
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-SquareMCP-Signature': signature },
|
||||
body: payload,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (res.ok) return true;
|
||||
console.warn(`[webhook] attempt ${attempt + 1} HTTP ${res.status} → ${url}`);
|
||||
} catch (err) {
|
||||
console.warn(`[webhook] attempt ${attempt + 1} error:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function deliverWebhook(
|
||||
customerId: string,
|
||||
platform: string,
|
||||
event: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const [rows] = await getPool().query<any[]>(
|
||||
'SELECT webhook_url, webhook_secret FROM customers WHERE id = ?',
|
||||
[customerId]
|
||||
);
|
||||
if (!rows.length || !rows[0].webhook_url || !rows[0].webhook_secret) return;
|
||||
|
||||
const { webhook_url, webhook_secret } = rows[0] as { webhook_url: string; webhook_secret: string };
|
||||
const payload: WebhookPayload = { customerId, platform, event, data };
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const signature = signPayload(webhook_secret, payloadStr);
|
||||
|
||||
const delivered = await postWithRetry(webhook_url, payloadStr, signature);
|
||||
|
||||
if (!delivered) {
|
||||
console.error(`[webhook] all attempts failed for customer ${customerId}, pushing to DLQ`);
|
||||
const dlqKey = `webhook:dlq:${customerId}`;
|
||||
const entry = JSON.stringify({
|
||||
payload,
|
||||
failedAt: new Date().toISOString(),
|
||||
attempts: RETRY_DELAYS_MS.length + 1,
|
||||
});
|
||||
await redis.rPush(dlqKey, entry);
|
||||
await redis.expire(dlqKey, DLQ_TTL_SECONDS);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user