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

@@ -21,6 +21,7 @@ interface AuthCode {
code_challenge?: string;
code_challenge_method?: string;
expires_at: number;
customer_id?: string;
}
interface Token {
@@ -72,6 +73,23 @@ function verifyPkce(codeVerifier: string, storedChallenge: string, method?: stri
return false;
}
export async function ensureOAuthAppRegistered(
clientId: string,
clientSecret: string,
redirectUris: string[]
): Promise<void> {
const pool = getPool();
await pool.execute(
`INSERT INTO oauth_clients (client_id, client_secret, client_name, redirect_urls, grant_types)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
client_secret = VALUES(client_secret),
redirect_urls = VALUES(redirect_urls)`,
[clientId, clientSecret, 'SquareMCP App', JSON.stringify(redirectUris), JSON.stringify(['authorization_code'])]
);
console.log(`[oauth] Pre-registered app ${clientId} ensured`);
}
export async function registerClient(body: {
client_name?: string;
redirect_uris?: string[];
@@ -139,7 +157,8 @@ export async function createAuthCode(
redirectUri: string,
scope?: string,
codeChallenge?: string,
codeChallengeMethod?: string
codeChallengeMethod?: string,
customerId?: string
): Promise<AuthCode> {
const code: AuthCode = {
code: generateAuthCode(),
@@ -149,18 +168,27 @@ export async function createAuthCode(
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
expires_at: Date.now() + AUTH_CODE_EXPIRY_MS,
customer_id: customerId,
};
const pool = getPool();
await pool.execute(
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at)]
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at, customer_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at), customerId || null]
);
console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`);
return code;
}
export function isValidRedirectUri(uri: string, registeredUris: string[]): boolean {
for (const registered of registeredUris) {
if (registered === uri) return true;
if (registered === 'http://localhost:*' && /^http:\/\/localhost:\d+(\/|$)/.test(uri)) return true;
}
return false;
}
export async function exchangeCodeForToken(
clientId: string,
clientSecret: string | undefined,
@@ -192,19 +220,27 @@ export async function exchangeCodeForToken(
}
const pool = getPool();
const db = await pool.getConnection();
try {
const [rows] = await db.execute<RowDataPacket[]>(
'SELECT * FROM oauth_auth_codes WHERE code = ? AND used = FALSE AND expires_at > NOW()',
// Atomic consume: only one concurrent request can win this UPDATE
const [updateResult] = await pool.execute<import('mysql2').ResultSetHeader>(
'UPDATE oauth_auth_codes SET used = TRUE WHERE code = ? AND used = FALSE AND expires_at > NOW()',
[code]
);
if (!Array.isArray(rows) || rows.length === 0) {
console.log('[oauth] Auth code not found or expired');
if (updateResult.affectedRows === 0) {
console.log('[oauth] Auth code not found, expired, or already used');
return null;
}
// Fetch the row now that it is consumed
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT * FROM oauth_auth_codes WHERE code = ?',
[code]
);
if (!Array.isArray(rows) || rows.length === 0) {
return null;
}
const authCode = rows[0];
if (authCode.client_id !== clientId || authCode.redirect_uri !== redirectUri) {
console.log('[oauth] Auth code client/redirect mismatch');
return null;
@@ -215,16 +251,12 @@ export async function exchangeCodeForToken(
console.log('[oauth] Missing code_verifier for PKCE exchange');
return null;
}
if (!verifyPkce(codeVerifier, authCode.code_challenge, authCode.code_challenge_method || undefined)) {
console.log('[oauth] PKCE verification failed');
return null;
}
}
// Mark auth code as used
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
const token: Token = {
access_token: generateAccessToken(),
token_type: 'Bearer',
@@ -233,9 +265,9 @@ export async function exchangeCodeForToken(
expires_at: Date.now() + TOKEN_EXPIRY_MS,
};
await db.execute(
'INSERT INTO oauth_tokens (token, client_id, token_type, expires_at) VALUES (?, ?, ?, ?)',
[token.access_token, clientId, 'access', new Date(token.expires_at)]
await pool.execute(
'INSERT INTO oauth_tokens (token, client_id, token_type, expires_at, customer_id) VALUES (?, ?, ?, ?, ?)',
[token.access_token, clientId, 'access', new Date(token.expires_at), authCode.customer_id || null]
);
console.log(`[oauth] Issued token ${token.access_token.slice(0, 8)}... for client ${clientId}`);
@@ -243,8 +275,6 @@ export async function exchangeCodeForToken(
} catch (err) {
console.error('[oauth] exchangeCodeForToken error:', err);
return null;
} finally {
db.release();
}
}
@@ -252,21 +282,31 @@ export async function validateAccessToken(tokenValue: string): Promise<boolean>
try {
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > NOW()',
'SELECT token FROM oauth_tokens WHERE token = ? AND expires_at > NOW()',
[tokenValue]
);
if (!Array.isArray(rows) || rows.length === 0) {
return false;
}
return true;
return Array.isArray(rows) && rows.length > 0;
} catch (err) {
console.error('[oauth] validateAccessToken error:', err);
return false;
}
}
export async function getTokenCustomer(tokenValue: string): Promise<{ customerId: string } | null> {
try {
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT customer_id FROM oauth_tokens WHERE token = ? AND expires_at > NOW()',
[tokenValue]
);
if (!Array.isArray(rows) || rows.length === 0 || !rows[0].customer_id) return null;
return { customerId: rows[0].customer_id as string };
} catch (err) {
console.error('[oauth] getTokenCustomer error:', err);
return null;
}
}
export function getAuthorizeHtml(params: {
client_id: string;
redirect_uri: string;