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

@@ -1,7 +1,4 @@
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
redis.connect().catch((err) => console.error('[audit-log] Redis connect error:', err));
import redis from '../redis.js';
export interface AuditEntry {
customerId: string;

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockRedisGet, mockRedisSet, mockRedisDel, mockRedisKeys } = vi.hoisted(() => ({
mockRedisGet: vi.fn(),
mockRedisSet: vi.fn(),
mockRedisDel: vi.fn(),
mockRedisKeys: vi.fn(),
}));
vi.mock('../redis.js', () => ({
default: {
get: mockRedisGet,
set: mockRedisSet,
del: mockRedisDel,
keys: mockRedisKeys,
},
}));
const mockTryRefreshToken = vi.hoisted(() => vi.fn());
vi.mock('./token-refresh.js', () => ({ tryRefreshToken: mockTryRefreshToken }));
// Use a real 32-byte key so AES-256-GCM doesn't throw
process.env.CREDENTIAL_ENCRYPTION_KEY = '0'.repeat(64);
import { getCredential, storeCredential } from './credential-store.js';
function encryptCreds(creds: object): string {
// We can't call encrypt() directly (not exported), so we'll round-trip through storeCredential
// For tests, we'll use a helper approach: just test behavior using storeCredential to set up state.
return JSON.stringify(creds); // placeholder — see note below
}
describe('getCredential', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRedisSet.mockResolvedValue('OK');
});
it('returns null when no credential stored', async () => {
mockRedisGet.mockResolvedValue(null);
const result = await getCredential('cust1', 'linkedin');
expect(result).toBeNull();
});
it('returns credential when not expired', async () => {
// Store then retrieve using real encryption
const creds = { accessToken: 'tok', expiresAt: Date.now() + 3_600_000 };
await storeCredential('cust1', 'linkedin', creds);
const stored = mockRedisSet.mock.calls[0][1] as string;
mockRedisGet.mockResolvedValue(stored);
const result = await getCredential('cust1', 'linkedin');
expect(result).toMatchObject({ accessToken: 'tok' });
expect(mockTryRefreshToken).not.toHaveBeenCalled();
});
it('attempts refresh when token is within 60s of expiry', async () => {
const creds = { accessToken: 'old', refreshToken: 'ref', expiresAt: Date.now() + 30_000 };
await storeCredential('cust1', 'linkedin', creds);
const stored = mockRedisSet.mock.calls[0][1] as string;
mockRedisGet.mockResolvedValue(stored);
mockTryRefreshToken.mockResolvedValue({ accessToken: 'new', expiresAt: Date.now() + 3_600_000 });
const result = await getCredential<{ accessToken: string }>('cust1', 'linkedin');
expect(mockTryRefreshToken).toHaveBeenCalledWith('cust1', 'linkedin', expect.objectContaining({ accessToken: 'old' }));
expect(result?.accessToken).toBe('new');
});
it('returns null when token expired and no refresh token', async () => {
const creds = { accessToken: 'old', expiresAt: Date.now() - 1000 };
await storeCredential('cust1', 'linkedin', creds);
const stored = mockRedisSet.mock.calls[0][1] as string;
mockRedisGet.mockResolvedValue(stored);
const result = await getCredential('cust1', 'linkedin');
expect(result).toBeNull();
expect(mockTryRefreshToken).not.toHaveBeenCalled();
});
it('returns null when refresh fails', async () => {
const creds = { accessToken: 'old', refreshToken: 'ref', expiresAt: Date.now() - 1000 };
await storeCredential('cust1', 'linkedin', creds);
const stored = mockRedisSet.mock.calls[0][1] as string;
mockRedisGet.mockResolvedValue(stored);
mockTryRefreshToken.mockResolvedValue(null);
const result = await getCredential('cust1', 'linkedin');
expect(result).toBeNull();
});
it('returns non-OAuth credentials without expiry check', async () => {
const creds = { host: 'imap.gmail.com', port: 993, user: 'u', password: 'p' };
await storeCredential('cust1', 'email', creds);
const stored = mockRedisSet.mock.calls[0][1] as string;
mockRedisGet.mockResolvedValue(stored);
const result = await getCredential('cust1', 'email');
expect(result).toMatchObject({ host: 'imap.gmail.com' });
expect(mockTryRefreshToken).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,5 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
redis.connect().catch((err) => console.error('[credential-store] Redis connect error:', err));
import redis from '../redis.js';
const ENCRYPTION_KEY = Buffer.from(process.env.CREDENTIAL_ENCRYPTION_KEY ?? '0'.repeat(64), 'hex');
// CREDENTIAL_ENCRYPTION_KEY must be a 64-char hex string (32 bytes)
@@ -70,7 +67,21 @@ export async function getCredential<T extends PlatformCredentials>(
const key = `creds:${customerId}:${platform}`;
const encrypted = await redis.get(key);
if (!encrypted) return null;
return JSON.parse(decrypt(encrypted)) as T;
const creds = JSON.parse(decrypt(encrypted)) as T;
const oauth = creds as OAuthCredentials;
if (typeof oauth.accessToken === 'string' && typeof oauth.expiresAt === 'number') {
if (oauth.expiresAt < Date.now() + 60_000) {
if (oauth.refreshToken) {
const { tryRefreshToken } = await import('./token-refresh.js');
const refreshed = await tryRefreshToken(customerId, platform, oauth);
if (refreshed) return refreshed as T;
}
return null;
}
}
return creds;
}
export async function revokeCredential(customerId: string, platform: Platform): Promise<void> {

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockRedisGet, mockRedisSetEx } = vi.hoisted(() => ({
mockRedisGet: vi.fn(),
mockRedisSetEx: vi.fn(),
}));
vi.mock('../redis.js', () => ({
default: {
get: mockRedisGet,
setEx: mockRedisSetEx,
},
}));
const mockGetCredential = vi.hoisted(() => vi.fn());
vi.mock('./credential-store.js', () => ({ getCredential: mockGetCredential }));
global.fetch = vi.fn();
import { getAllPlatformHealth } from './platform-health.js';
describe('getAllPlatformHealth', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRedisGet.mockResolvedValue(null);
mockRedisSetEx.mockResolvedValue('OK');
});
it('returns cached status without hitting API', async () => {
mockRedisGet.mockImplementation((key: string) =>
key.includes('linkedin') ? Promise.resolve('healthy') : Promise.resolve(null)
);
mockGetCredential.mockResolvedValue(null);
const results = await getAllPlatformHealth('cust1');
const linkedin = results.find(r => r.platform === 'linkedin');
expect(linkedin?.status).toBe('healthy');
expect(fetch).not.toHaveBeenCalled();
});
it('returns disconnected when no credential stored', async () => {
mockGetCredential.mockResolvedValue(null);
// Second redis.get (raw key check) also returns null
mockRedisGet.mockResolvedValue(null);
const results = await getAllPlatformHealth('cust1');
results.forEach(r => {
expect(r.status).toBe('disconnected');
});
});
it('returns healthy when OAuth probe succeeds', async () => {
mockGetCredential.mockImplementation((id: string, platform: string) =>
platform === 'linkedin' ? Promise.resolve({ accessToken: 'tok' }) : Promise.resolve(null)
);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
mockRedisGet.mockImplementation((key: string) =>
key.startsWith('creds:') ? Promise.resolve(null) : Promise.resolve(null)
);
const results = await getAllPlatformHealth('cust1');
const linkedin = results.find(r => r.platform === 'linkedin');
expect(linkedin?.status).toBe('healthy');
});
it('returns expired when OAuth probe returns non-ok', async () => {
mockGetCredential.mockImplementation((_id: string, platform: string) =>
platform === 'twitter' ? Promise.resolve({ accessToken: 'tok' }) : Promise.resolve(null)
);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false, status: 401 });
mockRedisGet.mockResolvedValue(null);
const results = await getAllPlatformHealth('cust1');
const twitter = results.find(r => r.platform === 'twitter');
expect(twitter?.status).toBe('expired');
});
it('returns unknown when fetch throws', async () => {
mockGetCredential.mockImplementation((_id: string, platform: string) =>
platform === 'tiktok' ? Promise.resolve({ accessToken: 'tok' }) : Promise.resolve(null)
);
(fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('network error'));
mockRedisGet.mockResolvedValue(null);
const results = await getAllPlatformHealth('cust1');
const tiktok = results.find(r => r.platform === 'tiktok');
expect(tiktok?.status).toBe('unknown');
});
it('caches the result in Redis', async () => {
mockGetCredential.mockResolvedValue(null);
mockRedisGet.mockResolvedValue(null);
await getAllPlatformHealth('cust1');
expect(mockRedisSetEx).toHaveBeenCalledWith(
expect.stringContaining('health:cust1:'),
600,
expect.any(String)
);
});
it('returns healthy for non-OAuth platforms when credential exists', async () => {
mockGetCredential.mockImplementation((_id: string, platform: string) =>
platform === 'email' ? Promise.resolve({ host: 'smtp.example.com', port: 587, user: 'u', password: 'p' }) : Promise.resolve(null)
);
mockRedisGet.mockResolvedValue(null);
const results = await getAllPlatformHealth('cust1');
const email = results.find(r => r.platform === 'email');
expect(email?.status).toBe('healthy');
expect(fetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,72 @@
import redis from '../redis.js';
import { getCredential, type Platform } from './credential-store.js';
const HEALTH_TTL = 600; // 10 minutes
type HealthStatus = 'healthy' | 'expired' | 'disconnected' | 'unknown';
interface PlatformHealth {
platform: Platform;
status: HealthStatus;
}
const OAUTH_PLATFORMS: Platform[] = ['linkedin', 'twitter', 'tiktok', 'instagram', 'facebook', 'snapchat'];
const ALL_PLATFORMS: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook', 'obsidian'];
async function checkPlatformHealth(customerId: string, platform: Platform): Promise<HealthStatus> {
const cacheKey = `health:${customerId}:${platform}`;
const cached = await redis.get(cacheKey);
if (cached) return cached as HealthStatus;
const cred = await getCredential(customerId, platform);
let status: HealthStatus;
if (!cred) {
// getCredential returns null for both "not stored" and "expired with no refresh"
// Check if there was a stored (but expired) credential by looking at the raw key
const rawKey = `creds:${customerId}:${platform}`;
const raw = await redis.get(rawKey);
status = raw ? 'expired' : 'disconnected';
} else if (OAUTH_PLATFORMS.includes(platform)) {
// Credential exists and is not expired — probe the API
status = await probeOAuthPlatform(platform, cred as { accessToken: string });
} else {
status = 'healthy';
}
await redis.setEx(cacheKey, HEALTH_TTL, status);
return status;
}
async function probeOAuthPlatform(platform: Platform, cred: { accessToken: string }): Promise<HealthStatus> {
const probeUrls: Partial<Record<Platform, string>> = {
linkedin: 'https://api.linkedin.com/v2/userinfo',
twitter: 'https://api.twitter.com/2/users/me',
tiktok: 'https://open.tiktokapis.com/v2/user/info/?fields=open_id',
instagram: 'https://graph.instagram.com/me?fields=id',
facebook: 'https://graph.facebook.com/me?fields=id',
snapchat: 'https://adsapi.snapchat.com/v1/me',
};
const url = probeUrls[platform];
if (!url) return 'unknown';
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${cred.accessToken}` },
signal: AbortSignal.timeout(8000),
});
return res.ok ? 'healthy' : 'expired';
} catch {
return 'unknown';
}
}
export async function getAllPlatformHealth(customerId: string): Promise<PlatformHealth[]> {
return Promise.all(
ALL_PLATFORMS.map(async (platform) => ({
platform,
status: await checkPlatformHealth(customerId, platform),
}))
);
}

View File

@@ -0,0 +1,102 @@
import { storeCredential, type OAuthCredentials, type Platform } from './credential-store.js';
interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in?: number;
}
async function postRefresh(url: string, body: URLSearchParams): Promise<TokenResponse | null> {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
console.warn(`[token-refresh] HTTP ${res.status} from ${url}`);
return null;
}
return await res.json() as TokenResponse;
} catch (err) {
console.error(`[token-refresh] request failed:`, err);
return null;
}
}
export async function tryRefreshToken(
customerId: string,
platform: Platform,
creds: OAuthCredentials
): Promise<OAuthCredentials | null> {
let data: TokenResponse | null = null;
if (platform === 'linkedin') {
data = await postRefresh('https://www.linkedin.com/oauth/v2/accessToken', new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: creds.refreshToken!,
client_id: process.env.LINKEDIN_CLIENT_ID ?? '',
client_secret: process.env.LINKEDIN_CLIENT_SECRET ?? '',
}));
} else if (platform === 'twitter') {
const credentials = Buffer.from(
`${process.env.TWITTER_CLIENT_ID ?? ''}:${process.env.TWITTER_CLIENT_SECRET ?? ''}`
).toString('base64');
try {
const res = await fetch('https://api.twitter.com/2/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${credentials}`,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: creds.refreshToken!,
}).toString(),
signal: AbortSignal.timeout(15000),
});
if (res.ok) data = await res.json() as TokenResponse;
else console.warn(`[token-refresh] twitter HTTP ${res.status}`);
} catch (err) {
console.error(`[token-refresh] twitter error:`, err);
}
} else if (platform === 'tiktok') {
data = await postRefresh('https://open.tiktokapis.com/v2/oauth/token/', new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: creds.refreshToken!,
client_key: process.env.TIKTOK_CLIENT_KEY ?? '',
client_secret: process.env.TIKTOK_CLIENT_SECRET ?? '',
}));
} else if (platform === 'instagram' || platform === 'facebook') {
// Facebook long-lived token exchange uses the current access token, not refresh token
try {
const url = new URL('https://graph.facebook.com/oauth/access_token');
url.searchParams.set('grant_type', 'fb_exchange_token');
url.searchParams.set('client_id', process.env.FACEBOOK_APP_ID ?? '');
url.searchParams.set('client_secret', process.env.FACEBOOK_APP_SECRET ?? '');
url.searchParams.set('fb_exchange_token', creds.accessToken);
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(15000) });
if (res.ok) data = await res.json() as TokenResponse;
else console.warn(`[token-refresh] ${platform} HTTP ${res.status}`);
} catch (err) {
console.error(`[token-refresh] ${platform} error:`, err);
}
}
if (!data?.access_token) return null;
const refreshed: OAuthCredentials = {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? creds.refreshToken,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
scope: creds.scope,
};
await storeCredential(customerId, platform, refreshed);
console.log(`[token-refresh] ${platform} refreshed for customer ${customerId}`);
return refreshed;
}

View File

@@ -1,9 +1,6 @@
import { createClient } from 'redis';
import redis from '../redis.js';
import { getCredential, WhatsAppCredentials } from './credential-store.js';
const redis = createClient({ url: process.env.REDIS_URL });
redis.connect().catch((err) => console.error('[webhook-router] Redis connect error:', err));
// Call this at customer onboarding when they connect their WhatsApp Business number
export async function registerWhatsAppNumber(
customerId: string,