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

96
src/billing/cron.test.ts Normal file
View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockRedisSet, mockRedisEval } = vi.hoisted(() => ({
mockRedisSet: vi.fn(),
mockRedisEval: vi.fn(),
}));
const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() }));
const { mockGenerateMonthlyInvoice } = vi.hoisted(() => ({
mockGenerateMonthlyInvoice: vi.fn(),
}));
vi.mock('../redis.js', () => ({
default: { set: mockRedisSet, eval: mockRedisEval },
}));
vi.mock('../db.js', () => ({
getPool: () => ({ query: mockQuery }),
}));
vi.mock('./invoices.js', () => ({
generateMonthlyInvoice: (...args: any[]) => mockGenerateMonthlyInvoice(...args),
}));
import { runInvoiceCron } from './cron.js';
const TWO_CUSTOMERS = [[{ id: 'cust-a' }, { id: 'cust-b' }]];
beforeEach(() => {
vi.clearAllMocks();
mockRedisEval.mockResolvedValue(1);
mockGenerateMonthlyInvoice.mockResolvedValue(null);
});
describe('runInvoiceCron — Redis lock', () => {
it('runs invoice loop when lock is acquired', async () => {
mockRedisSet.mockResolvedValue('OK');
mockQuery.mockResolvedValue(TWO_CUSTOMERS);
await runInvoiceCron();
expect(mockRedisSet).toHaveBeenCalledWith(
'invoice:cron:lock',
expect.any(String),
{ NX: true, EX: 3600 }
);
expect(mockQuery).toHaveBeenCalled();
});
it('skips invoice loop when lock is already held', async () => {
mockRedisSet.mockResolvedValue(null);
await runInvoiceCron();
expect(mockQuery).not.toHaveBeenCalled();
expect(mockGenerateMonthlyInvoice).not.toHaveBeenCalled();
});
it('releases lock via compare-delete Lua script after success', async () => {
mockRedisSet.mockResolvedValue('OK');
mockQuery.mockResolvedValue([[{ id: 'cust-1' }]]);
await runInvoiceCron();
expect(mockRedisEval).toHaveBeenCalledWith(
expect.stringContaining('redis.call("get"'),
expect.objectContaining({ keys: ['invoice:cron:lock'], arguments: [expect.any(String)] })
);
});
it('does NOT release lock when the loop throws', async () => {
mockRedisSet.mockResolvedValue('OK');
mockQuery.mockRejectedValue(new Error('DB down'));
await expect(runInvoiceCron()).rejects.toThrow('DB down');
expect(mockRedisEval).not.toHaveBeenCalled();
});
});
describe('runInvoiceCron — per-customer error isolation', () => {
it('continues processing remaining customers after one fails', async () => {
mockRedisSet.mockResolvedValue('OK');
mockQuery.mockResolvedValue([[{ id: 'cust-a' }, { id: 'cust-b' }, { id: 'cust-c' }]]);
mockGenerateMonthlyInvoice
.mockResolvedValueOnce({ invoice_number: 'SMCP-1', customer_id: 'cust-a' })
.mockRejectedValueOnce(new Error('Stripe error'))
.mockResolvedValueOnce({ invoice_number: 'SMCP-3', customer_id: 'cust-c' });
await runInvoiceCron();
expect(mockGenerateMonthlyInvoice).toHaveBeenCalledTimes(3);
expect(mockRedisEval).toHaveBeenCalled();
});
});

51
src/billing/cron.ts Normal file
View File

@@ -0,0 +1,51 @@
import { randomUUID } from 'crypto';
import redis from '../redis.js';
import { getPool } from '../db.js';
import { generateMonthlyInvoice } from './invoices.js';
const LOCK_KEY = 'invoice:cron:lock';
const LOCK_TTL_SECONDS = 3600;
// Only releases the lock if the value matches — prevents one replica from
// deleting another's lock after a TTL expiry race.
const COMPARE_DELETE_LUA = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
export async function runInvoiceCron(): Promise<void> {
const lockValue = randomUUID();
const acquired = await redis.set(LOCK_KEY, lockValue, { NX: true, EX: LOCK_TTL_SECONDS });
if (!acquired) {
console.log('[cron] Another process holds the invoice lock — skipping');
return;
}
try {
const [customers] = await getPool().query<any[]>(
"SELECT id FROM customers WHERE active = TRUE AND plan != 'free'"
);
for (const customer of customers) {
try {
const invoice = await generateMonthlyInvoice(customer.id);
if (invoice) {
console.log(`[cron] Generated invoice ${invoice.invoice_number} for customer ${customer.id}`);
}
} catch (err) {
// One customer failing must not abort the rest
console.error(`[cron] Failed for customer ${customer.id}:`, err);
}
}
await redis.eval(COMPARE_DELETE_LUA, { keys: [LOCK_KEY], arguments: [lockValue] });
} catch (err) {
// Leave the lock in place on unexpected failure — TTL releases it after 1 hour
console.error('[cron] Invoice cron fatal error:', err);
throw err;
}
}

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import nodemailer from 'nodemailer';
const mockQuery = vi.fn();
vi.mock('../db.js', () => ({
getPool: () => ({ query: mockQuery }),
}));
vi.mock('nodemailer', () => ({
default: { createTransport: vi.fn() },
}));
import {
createInvoice,
generateMonthlyInvoice,
getInvoiceByNumber,
emailInvoice,
type Invoice,
} from './invoices.js';
const baseInvoice: Invoice = {
id: 1,
customer_id: 'cust-1',
invoice_number: 'SMCP-20260501-aabb1122',
amount: 25.0,
currency: 'USD',
status: 'draft',
period_start: '2026-04-01',
period_end: '2026-04-30',
line_items: [{ description: 'email actions', quantity: 500, unit_price: 0.05, amount: 25.0 }],
sent_at: null,
paid_at: null,
created_at: '2026-05-01T00:00:00Z',
constructor: { name: 'RowDataPacket' } as any,
};
beforeEach(() => {
vi.clearAllMocks();
});
// ── generateInvoiceNumber format ─────────────────────────────────────────────
describe('generateMonthlyInvoice — billing period', () => {
it('queries previous month, not current', async () => {
mockQuery.mockResolvedValueOnce([[]]); // usage query returns empty
await generateMonthlyInvoice('cust-1');
const sql: string = mockQuery.mock.calls[0][0];
// Must reference DATE_SUB for the start of the previous month
expect(sql).toContain('DATE_SUB');
// Must NOT use a plain NOW() as the lower bound (that would be current month)
expect(sql).not.toMatch(/>=\s*DATE_FORMAT\(NOW\(\)/);
});
it('returns null when customer has zero usage', async () => {
mockQuery.mockResolvedValueOnce([[]]); // no usage rows
const result = await generateMonthlyInvoice('cust-1');
expect(result).toBeNull();
});
it('creates invoice for previous month period', async () => {
const now = new Date();
const expectedStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
.toISOString().slice(0, 10);
mockQuery
.mockResolvedValueOnce([[{ platform: 'email', count: 100 }]]) // usage
.mockResolvedValueOnce([[]]) // insert
.mockResolvedValueOnce([[{ ...baseInvoice, period_start: expectedStart }]]); // select after insert
const invoice = await generateMonthlyInvoice('cust-1');
expect(invoice?.period_start).toBe(expectedStart);
});
});
// ── Invoice number format ─────────────────────────────────────────────────────
describe('createInvoice — invoice number', () => {
it('generates hex suffix, not decimal padded', async () => {
mockQuery
.mockResolvedValueOnce([[]]) // insert
.mockResolvedValueOnce([[baseInvoice]]); // select
await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30');
const insertSql: string = mockQuery.mock.calls[0][0];
const invoiceNumber: string = mockQuery.mock.calls[0][1][1];
// Should be SMCP-YYYYMMDD-<8 hex chars>
expect(invoiceNumber).toMatch(/^SMCP-\d{8}-[0-9a-f]{8}$/);
});
});
// ── Idempotency ───────────────────────────────────────────────────────────────
describe('createInvoice — idempotency', () => {
it('returns existing invoice on uq_customer_period duplicate', async () => {
const dupError = Object.assign(new Error("Duplicate entry 'cust-1-2026-04-01' for key 'invoices.uq_customer_period'"), { errno: 1062 });
mockQuery
.mockRejectedValueOnce(dupError) // INSERT throws 1062
.mockResolvedValueOnce([[baseInvoice]]); // SELECT existing
const invoice = await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30');
expect(invoice.invoice_number).toBe(baseInvoice.invoice_number);
expect(invoice.customer_id).toBe('cust-1');
});
it('re-throws 1062 for other constraints (e.g. duplicate invoice_number)', async () => {
const dupError = Object.assign(new Error("Duplicate entry 'SMCP-...' for key 'invoices.invoice_number'"), { errno: 1062 });
mockQuery.mockRejectedValueOnce(dupError);
await expect(createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30'))
.rejects.toThrow('invoice_number');
});
});
// ── line_items JSON normalization ─────────────────────────────────────────────
describe('getInvoiceByNumber — line_items normalization', () => {
it('parses line_items when returned as JSON string', async () => {
const rawInvoice = {
...baseInvoice,
line_items: JSON.stringify(baseInvoice.line_items) as any,
};
mockQuery.mockResolvedValueOnce([[rawInvoice]]);
const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122');
expect(Array.isArray(invoice?.line_items)).toBe(true);
expect(invoice?.line_items[0].description).toBe('email actions');
});
it('leaves line_items unchanged when already an array', async () => {
mockQuery.mockResolvedValueOnce([[baseInvoice]]);
const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122');
expect(Array.isArray(invoice?.line_items)).toBe(true);
});
});
// ── emailInvoice ─────────────────────────────────────────────────────────────
describe('emailInvoice', () => {
it('sends mail with invoice_number in subject and HTML body', async () => {
const sendMail = vi.fn().mockResolvedValue({ messageId: '<abc@mail>' });
vi.mocked(nodemailer.createTransport).mockReturnValue({ sendMail } as any);
await emailInvoice(baseInvoice, 'customer@example.com');
expect(sendMail).toHaveBeenCalledOnce();
const mailArgs = sendMail.mock.calls[0][0];
expect(mailArgs.to).toBe('customer@example.com');
expect(mailArgs.subject).toContain(baseInvoice.invoice_number);
expect(mailArgs.html).toContain(baseInvoice.invoice_number);
});
});

View File

@@ -1,3 +1,5 @@
import { randomBytes } from 'crypto';
import nodemailer from 'nodemailer';
import { getPool } from '../db.js';
import type { RowDataPacket } from 'mysql2';
@@ -24,10 +26,16 @@ export interface Invoice extends RowDataPacket {
}
function generateInvoiceNumber(): string {
const prefix = 'SMCP';
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `${prefix}-${date}-${random}`;
const suffix = randomBytes(4).toString('hex');
return `SMCP-${date}-${suffix}`;
}
function normalizeInvoice(invoice: Invoice): Invoice {
if (typeof invoice.line_items === 'string') {
invoice.line_items = JSON.parse(invoice.line_items);
}
return invoice;
}
export async function createInvoice(
@@ -38,16 +46,27 @@ export async function createInvoice(
periodEnd: string
): Promise<Invoice> {
const invoiceNumber = generateInvoiceNumber();
await getPool().query(
`INSERT INTO invoices (customer_id, invoice_number, amount, line_items, period_start, period_end)
VALUES (?, ?, ?, ?, ?, ?)`,
[customerId, invoiceNumber, amount, JSON.stringify(lineItems), periodStart, periodEnd]
);
try {
await getPool().query(
`INSERT INTO invoices (customer_id, invoice_number, amount, line_items, period_start, period_end)
VALUES (?, ?, ?, ?, ?, ?)`,
[customerId, invoiceNumber, amount, JSON.stringify(lineItems), periodStart, periodEnd]
);
} catch (err: any) {
if (err.errno === 1062 && err.message.includes('uq_customer_period')) {
const [rows] = await getPool().query<Invoice[]>(
'SELECT * FROM invoices WHERE customer_id = ? AND period_start = ?',
[customerId, periodStart]
);
return normalizeInvoice(rows[0]);
}
throw err;
}
const [rows] = await getPool().query<Invoice[]>(
'SELECT * FROM invoices WHERE invoice_number = ?',
[invoiceNumber]
);
return rows[0];
return normalizeInvoice(rows[0]);
}
export async function getCustomerInvoices(customerId: string): Promise<Invoice[]> {
@@ -55,7 +74,7 @@ export async function getCustomerInvoices(customerId: string): Promise<Invoice[]
'SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at DESC',
[customerId]
);
return rows;
return rows.map(normalizeInvoice);
}
export async function getInvoiceByNumber(invoiceNumber: string): Promise<Invoice | null> {
@@ -63,7 +82,7 @@ export async function getInvoiceByNumber(invoiceNumber: string): Promise<Invoice
'SELECT * FROM invoices WHERE invoice_number = ?',
[invoiceNumber]
);
return rows[0] ?? null;
return rows[0] ? normalizeInvoice(rows[0]) : null;
}
export async function markInvoiceSent(invoiceNumber: string): Promise<void> {
@@ -80,12 +99,45 @@ export async function markInvoicePaid(invoiceNumber: string): Promise<void> {
);
}
export async function emailInvoice(invoice: Invoice, toEmail: string): Promise<void> {
const transport = nodemailer.createTransport({
host: process.env.FETCHERPAY_SMTP_HOST ?? 'mail.fetcherpay.com',
port: parseInt(process.env.FETCHERPAY_SMTP_PORT ?? '30587', 10),
secure: false,
auth: {
user: process.env.BILLING_EMAIL ?? process.env.FETCHERPAY_EMAIL,
pass: process.env.BILLING_PASSWORD ?? process.env.FETCHERPAY_PASSWORD,
},
tls: { rejectUnauthorized: false },
});
await transport.sendMail({
from: process.env.BILLING_FROM ?? 'billing@squaremcp.com',
to: toEmail,
subject: `Invoice ${invoice.invoice_number} from SquareMCP`,
html: `
<h1>Invoice ${invoice.invoice_number}</h1>
<p>Billing period: ${invoice.period_start} ${invoice.period_end}</p>
<p>Amount due: $${invoice.amount} ${invoice.currency}</p>
<table>
<tr><th>Description</th><th>Qty</th><th>Unit price</th><th>Amount</th></tr>
${invoice.line_items.map((li) =>
`<tr><td>${li.description}</td><td>${li.quantity}</td><td>$${li.unit_price}</td><td>$${li.amount}</td></tr>`
).join('')}
</table>
`,
});
}
export async function generateMonthlyInvoice(customerId: string): Promise<Invoice | null> {
const now = new Date();
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
const [usageRows] = await getPool().query<any[]>(
`SELECT platform, COUNT(*) as count FROM usage_logs
WHERE customer_id = ?
AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01')
AND created_at < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
AND created_at >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
AND created_at < DATE_FORMAT(NOW(), '%Y-%m-01')
GROUP BY platform`,
[customerId]
);
@@ -97,7 +149,7 @@ export async function generateMonthlyInvoice(customerId: string): Promise<Invoic
for (const row of usageRows) {
const qty = row.count;
const unitPrice = 0.05; // $0.05 per action
const unitPrice = 0.05;
const amount = qty * unitPrice;
total += amount;
lineItems.push({
@@ -108,15 +160,11 @@ export async function generateMonthlyInvoice(customerId: string): Promise<Invoic
});
}
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return createInvoice(
customerId,
parseFloat(total.toFixed(2)),
lineItems,
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
prevMonthStart.toISOString().slice(0, 10),
prevMonthEnd.toISOString().slice(0, 10)
);
}

View File

@@ -1,13 +1,10 @@
import { createClient } from 'redis';
import { RowDataPacket } from 'mysql2';
import { getPool } from '../db.js';
import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js';
import type { PlanKey } from './plans.js';
import type { Request, Response, NextFunction } from 'express';
import { verifyJWT } from '../auth.js';
const redis = createClient({ url: process.env.REDIS_URL });
redis.connect().catch((err) => console.error('[billing] Redis connect error:', err));
import redis from '../redis.js';
export interface Customer {
id: string;

View File

@@ -20,11 +20,11 @@ export const PLANS: Record<PlanKey, Plan> = {
growth: {
name: 'Growth',
monthlyCallLimit: 10000,
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'],
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter', 'tiktok', 'facebook', 'snapchat'],
},
enterprise: {
name: 'Enterprise',
monthlyCallLimit: -1,
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'],
platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter', 'tiktok', 'facebook', 'snapchat'],
},
};

47
src/billing/usage.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { checkLimit } from './usage.js';
vi.mock('../db.js', () => ({
getPool: vi.fn(() => ({
query: vi.fn().mockResolvedValue([[{ count: 50 }]]),
})),
}));
describe('checkLimit', () => {
it('always allows enterprise plan regardless of usage', async () => {
const result = await checkLimit('cust-1', 'enterprise');
expect(result).toEqual({ allowed: true, limit: -1, used: 0 });
});
it('allows when usage is under limit', async () => {
const { getPool } = await import('../db.js');
vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 50 }]]) } as any);
const result = await checkLimit('cust-1', 'growth');
expect(result.allowed).toBe(true);
expect(result.used).toBe(50);
expect(result.limit).toBe(10000);
});
it('blocks when usage equals limit', async () => {
const { getPool } = await import('../db.js');
vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 1000 }]]) } as any);
const result = await checkLimit('cust-1', 'starter');
expect(result.allowed).toBe(false);
expect(result.used).toBe(1000);
});
it('blocks when usage exceeds limit', async () => {
const { getPool } = await import('../db.js');
vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 1001 }]]) } as any);
const result = await checkLimit('cust-1', 'free');
expect(result.allowed).toBe(false);
});
it('does not query DB for enterprise plan', async () => {
const { getPool } = await import('../db.js');
const querySpy = vi.fn();
vi.mocked(getPool).mockReturnValue({ query: querySpy } as any);
await checkLimit('cust-1', 'enterprise');
expect(querySpy).not.toHaveBeenCalled();
});
});