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:
Garfield
2026-05-13 23:43:35 -04:00
parent d4bc899b31
commit 61dab40585
38 changed files with 5042 additions and 238 deletions

View 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
View 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);
}
}