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:
@@ -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;
|
||||
|
||||
101
src/multitenancy/credential-store.test.ts
Normal file
101
src/multitenancy/credential-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
113
src/multitenancy/platform-health.test.ts
Normal file
113
src/multitenancy/platform-health.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
src/multitenancy/platform-health.ts
Normal file
72
src/multitenancy/platform-health.ts
Normal 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),
|
||||
}))
|
||||
);
|
||||
}
|
||||
102
src/multitenancy/token-refresh.ts
Normal file
102
src/multitenancy/token-refresh.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user