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

192
src/oauth.test.ts Normal file
View File

@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ResultSetHeader } from 'mysql2';
// ── DB mock ────────────────────────────────────────────────────────────────
const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() }));
vi.mock('./db.js', () => ({
getPool: vi.fn(() => ({ execute: mockExecute })),
isPoolReady: vi.fn(() => true),
}));
import {
createAuthCode,
exchangeCodeForToken,
validateAccessToken,
getTokenCustomer,
isValidRedirectUri,
} from './oauth.js';
// Helper: build a minimal ResultSetHeader-shaped object
function affected(n: number): [ResultSetHeader, unknown[]] {
return [{ affectedRows: n } as ResultSetHeader, []];
}
// Helper: build a row-result
function rows(data: object[]): [object[], unknown[]] {
return [data, []];
}
describe('isValidRedirectUri', () => {
it('exact match is valid', () => {
expect(isValidRedirectUri('https://app.example.com/callback', ['https://app.example.com/callback'])).toBe(true);
});
it('http://localhost:* wildcard matches any localhost port', () => {
expect(isValidRedirectUri('http://localhost:3000', ['http://localhost:*'])).toBe(true);
expect(isValidRedirectUri('http://localhost:9876/callback', ['http://localhost:*'])).toBe(true);
});
it('http://localhost:* does not match non-localhost', () => {
expect(isValidRedirectUri('http://evil.com', ['http://localhost:*'])).toBe(false);
expect(isValidRedirectUri('https://localhost:3000', ['http://localhost:*'])).toBe(false);
});
it('unregistered URI is rejected', () => {
expect(isValidRedirectUri('https://attacker.com', ['https://app.example.com/callback'])).toBe(false);
});
it('other wildcards are not supported', () => {
expect(isValidRedirectUri('https://any.example.com', ['https://*.example.com'])).toBe(false);
});
it('empty registered list rejects everything', () => {
expect(isValidRedirectUri('https://app.example.com/callback', [])).toBe(false);
});
});
describe('createAuthCode', () => {
beforeEach(() => vi.clearAllMocks());
it('inserts with customer_id when provided', async () => {
mockExecute
// getClient SELECT (called by nothing here; we call createAuthCode directly)
.mockResolvedValueOnce([[], []]); // INSERT returns ResultSetHeader but we ignore it
// The first execute is the INSERT
mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]);
const code = await createAuthCode('client1', 'http://localhost:3000', 'read', undefined, undefined, 'cust-42');
expect(code.customer_id).toBe('cust-42');
const [sql, params] = mockExecute.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('customer_id');
expect(params).toContain('cust-42');
});
it('inserts NULL customer_id when not provided', async () => {
mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]);
const code = await createAuthCode('client1', 'http://localhost:3000');
expect(code.customer_id).toBeUndefined();
const [, params] = mockExecute.mock.calls[0] as [string, unknown[]];
// Last param should be null
expect(params[params.length - 1]).toBeNull();
});
});
describe('exchangeCodeForToken — replay protection', () => {
beforeEach(() => vi.clearAllMocks());
const fakeClient = {
client_id: 'c1',
client_secret: 'secret',
client_name: 'Test',
redirect_uris: ['http://localhost:3000'],
created_at: Date.now(),
};
const fakeAuthCode = {
code: 'abc',
client_id: 'c1',
redirect_uri: 'http://localhost:3000',
scope: 'read',
code_challenge: null,
code_challenge_method: null,
customer_id: 'cust-1',
expires_at: new Date(Date.now() + 60_000),
used: true,
};
it('returns null when UPDATE affectedRows = 0 (already used)', async () => {
// getClient SELECT
mockExecute.mockResolvedValueOnce(rows([fakeClient]));
// UPDATE oauth_clients last_used
mockExecute.mockResolvedValueOnce(affected(1));
// Atomic UPDATE auth code — 0 rows (already used)
mockExecute.mockResolvedValueOnce(affected(0));
const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
expect(result).toBeNull();
});
it('issues token and threads customer_id on success', async () => {
// getClient SELECT
mockExecute.mockResolvedValueOnce(rows([fakeClient]));
// getClient UPDATE last_used
mockExecute.mockResolvedValueOnce(affected(1));
// Atomic UPDATE auth code — 1 row consumed
mockExecute.mockResolvedValueOnce(affected(1));
// SELECT auth code data
mockExecute.mockResolvedValueOnce(rows([fakeAuthCode]));
// INSERT token
mockExecute.mockResolvedValueOnce(affected(1));
const token = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
expect(token).not.toBeNull();
expect(token!.access_token).toBeTruthy();
// Verify INSERT includes customer_id = 'cust-1'
const insertCall = mockExecute.mock.calls.find((c) => (c[0] as string).includes('INSERT INTO oauth_tokens'));
expect(insertCall).toBeDefined();
const insertParams = insertCall![1] as unknown[];
expect(insertParams).toContain('cust-1');
});
it('returns null when client/redirect mismatch', async () => {
mockExecute.mockResolvedValueOnce(rows([fakeClient]));
mockExecute.mockResolvedValueOnce(affected(1));
mockExecute.mockResolvedValueOnce(affected(1));
// SELECT returns code with different client_id
mockExecute.mockResolvedValueOnce(rows([{ ...fakeAuthCode, client_id: 'other' }]));
const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
expect(result).toBeNull();
});
});
describe('getTokenCustomer', () => {
beforeEach(() => vi.clearAllMocks());
it('returns customerId when token has one', async () => {
mockExecute.mockResolvedValue(rows([{ customer_id: 'cust-99' }]));
const result = await getTokenCustomer('tok-abc');
expect(result).toEqual({ customerId: 'cust-99' });
});
it('returns null when token not found', async () => {
mockExecute.mockResolvedValue(rows([]));
const result = await getTokenCustomer('bad-token');
expect(result).toBeNull();
});
it('returns null when token has no customer_id', async () => {
mockExecute.mockResolvedValue(rows([{ customer_id: null }]));
const result = await getTokenCustomer('tok-legacy');
expect(result).toBeNull();
});
});
describe('validateAccessToken', () => {
beforeEach(() => vi.clearAllMocks());
it('returns true for valid token', async () => {
mockExecute.mockResolvedValue(rows([{ token: 'tok' }]));
expect(await validateAccessToken('tok')).toBe(true);
});
it('returns false for expired/missing token', async () => {
mockExecute.mockResolvedValue(rows([]));
expect(await validateAccessToken('bad')).toBe(false);
});
});