This commit is contained in:
southseact-3d
2026-02-21 10:53:56 +00:00
parent 98c3b5f040
commit c52709da7d

View File

@@ -550,7 +550,8 @@ const AUTO_MODEL_TOKEN = 'auto';
const DEFAULT_PROVIDER_FALLBACK = 'opencode';
const DEFAULT_PROVIDER_SEEDS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', DEFAULT_PROVIDER_FALLBACK, 'cohere', 'kilo'];
const PROVIDER_PERSIST_DEBOUNCE_MS = 200;
const TOKEN_ESTIMATION_BUFFER = 400;
const TOKEN_ESTIMATION_BUFFER = 800;
const TOKEN_HISTORY_OVERHEAD = 200;
const BOOST_PACK_SIZE = 500_000;
const BOOST_BASE_PRICE = 15;
const TOKEN_GRACE_RATIO = 0.05;
@@ -1603,6 +1604,7 @@ async function gracefulShutdown(signal) {
// Stop periodic tasks first
stopAutoSave();
stopMemoryCleanup();
stopPendingCleanup();
// Notify all active SSE connections about the restart
for (const [messageId, streams] of activeStreams.entries()) {
@@ -2112,10 +2114,19 @@ function validateCsrfToken(token, userId) {
}
// Security: Honeypot Detection
const HONEYPOT_FIELDS = ['website', 'url', 'homepage', 'web_address', 'site', 'link'];
function checkHoneypot(body) {
return !!(body.website && body.website.length > 0);
if (!body || typeof body !== 'object') return false;
for (const field of HONEYPOT_FIELDS) {
if (body[field] && typeof body[field] === 'string' && body[field].length > 0) {
return true;
}
}
return false;
}
const passwordResetAttempts = new Map();
// Security: Prompt Injection Protection - Comprehensive
function sanitizePromptInput(input, options = {}) {
if (!input || typeof input !== 'string') return '';
@@ -2302,7 +2313,7 @@ function startAdminSession(res) {
'SameSite=Lax',
`Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}`,
];
if (process.env.COOKIE_SECURE === '0') parts.push('Secure');
if (process.env.COOKIE_SECURE !== '0' && process.env.NODE_ENV !== 'development') parts.push('Secure');
res.setHeader('Set-Cookie', parts.join('; '));
return token;
}
@@ -9678,43 +9689,18 @@ async function sendOpenRouterChat({ messages, model }) {
async function sendMistralChat({ messages, model }) {
if (!MISTRAL_API_KEY) {
console.error('[MISTRAL] API key missing');
log('Mistral API key missing, cannot fulfill planning request');
throw new Error('Mistral API key is not configured');
}
const safeMessages = Array.isArray(messages) ? messages : [];
if (!safeMessages.length) {
console.error('[MISTRAL] Empty messages array');
throw new Error('Mistral messages must be a non-empty array');
}
const resolvedModel = model || resolveMistralModel();
const payload = { model: resolvedModel, messages: safeMessages };
console.log('[MISTRAL] Starting API request', {
url: MISTRAL_API_URL,
model: resolvedModel,
messageCount: safeMessages.length,
hasApiKey: !!MISTRAL_API_KEY,
apiKeyPrefix: MISTRAL_API_KEY ? MISTRAL_API_KEY.substring(0, 8) + '...' : 'none'
});
console.log('[MISTRAL] Request payload:', {
model: payload.model,
messagesCount: payload.messages.length,
firstMessage: payload.messages[0] ? {
role: payload.messages[0].role,
contentLength: payload.messages[0].content?.length || 0,
contentPreview: payload.messages[0].content?.substring(0, 100)
} : null,
lastMessage: payload.messages[payload.messages.length - 1] ? {
role: payload.messages[payload.messages.length - 1].role,
contentLength: payload.messages[payload.messages.length - 1].content?.length || 0,
contentPreview: payload.messages[payload.messages.length - 1].content?.substring(0, 100)
} : null
});
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
@@ -9723,85 +9709,29 @@ async function sendMistralChat({ messages, model }) {
try {
const res = await fetch(MISTRAL_API_URL, { method: 'POST', headers, body: JSON.stringify(payload) });
console.log('[MISTRAL] Response received', {
status: res.status,
statusText: res.statusText,
ok: res.ok,
headers: Object.fromEntries(res.headers.entries())
});
if (!res.ok) {
let detail = '';
try {
detail = await res.text();
console.error('[MISTRAL] Error response body:', detail);
} catch (err) {
console.error('[MISTRAL] Failed to read error body', String(err));
log('Mistral error body read failed', { status: res.status, err: String(err) });
}
const err = buildProviderError('Mistral', res.status, detail || res.statusText);
console.error('[MISTRAL] Request failed', { status: res.status, detail: err.detail });
log('Mistral request failed', { status: res.status, detail: err.detail || res.statusText });
throw err;
}
const data = await res.json();
// Log the FULL raw response for debugging
console.log('[MISTRAL] Full API response:', JSON.stringify(data, null, 2));
// Log the response data structure analysis
console.log('[MISTRAL] Response data structure:', {
hasChoices: !!data?.choices,
choicesLength: data?.choices?.length || 0,
firstChoiceKeys: data?.choices?.[0] ? Object.keys(data.choices[0]) : [],
hasMessage: !!data?.choices?.[0]?.message,
messageKeys: data?.choices?.[0]?.message ? Object.keys(data.choices[0].message) : [],
hasContent: !!data?.choices?.[0]?.message?.content,
contentLength: data?.choices?.[0]?.message?.content?.length || 0,
rawDataKeys: Object.keys(data || {})
});
// Log each step of content extraction
console.log('[MISTRAL] Choices array:', data?.choices);
console.log('[MISTRAL] First choice:', data?.choices?.[0]);
console.log('[MISTRAL] Message object:', data?.choices?.[0]?.message);
console.log('[MISTRAL] Content value:', data?.choices?.[0]?.message?.content);
console.log('[MISTRAL] Content type:', typeof data?.choices?.[0]?.message?.content);
const reply = data?.choices?.[0]?.message?.content || '';
console.log('[MISTRAL] Extracted reply:', {
reply: reply,
replyType: typeof reply,
replyLength: reply?.length || 0,
isEmpty: reply === '',
isNull: reply === null,
isUndefined: reply === undefined,
isFalsy: !reply
});
if (!reply) {
console.error('[MISTRAL] No content in response!', {
fullData: JSON.stringify(data, null, 2),
extractedReply: reply,
replyType: typeof reply
});
} else {
console.log('[MISTRAL] Successfully extracted reply', {
replyLength: reply.length,
replyPreview: reply.substring(0, 200)
});
log('Mistral returned empty response', { model: resolvedModel });
}
log('Mistral request succeeded', { model: resolvedModel, replyLength: reply.length });
return { reply: reply ? String(reply).trim() : '', model: resolvedModel, raw: data };
} catch (fetchErr) {
console.error('[MISTRAL] Fetch error:', {
error: String(fetchErr),
message: fetchErr.message,
stack: fetchErr.stack
});
log('Mistral fetch error', { error: String(fetchErr) });
throw fetchErr;
}
}
@@ -10066,8 +9996,17 @@ async function handlePlanMessage(req, res, userId) {
stripMarkdownFromDisplay(sanitizePromptInput(body.displayContent)) :
stripMarkdownFromDisplay(content);
if (!content) return sendJson(res, 400, { error: 'Message is required' });
const historyMessages = (session.messages || []).filter((m) => m.phase === 'plan');
let historyTokens = 0;
historyMessages.forEach((m) => {
if (m.content) historyTokens += estimateTokensFromText(m.content);
if (m.reply) historyTokens += estimateTokensFromText(m.reply);
});
const totalEstimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER + (historyMessages.length * TOKEN_HISTORY_OVERHEAD) + historyTokens;
const userPlan = resolveUserPlan(session.userId);
const allowance = canConsumeTokens(session.userId, userPlan, estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER);
const allowance = canConsumeTokens(session.userId, userPlan, totalEstimatedTokens);
if (!allowance.allowed) {
return sendJson(res, 402, { error: 'You have reached your token allowance. Upgrade or add a boost.', allowance });
}
@@ -10083,7 +10022,6 @@ async function handlePlanMessage(req, res, userId) {
const safePluginName = session.pluginName || `Plugin Compass ${session.title || 'Plugin'}`;
let finalSystemPrompt = (systemPrompt || '').replace('{{PLUGIN_SLUG}}', safePluginSlug);
finalSystemPrompt = finalSystemPrompt.replace('{{PLUGIN_NAME}}', safePluginName);
const historyMessages = (session.messages || []).filter((m) => m.phase === 'plan');
const messages = [{ role: 'system', content: finalSystemPrompt }];
historyMessages.forEach((m) => {
if (m.content) messages.push({ role: 'user', content: sanitizePromptInput(m.content) });
@@ -14172,6 +14110,11 @@ async function handleTopupCheckout(req, res) {
const user = findUserById(session.userId);
if (!user) return sendJson(res, 404, { error: 'User not found' });
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) {
return sendJson(res, 403, { error: 'Invalid CSRF token' });
}
try {
const body = await parseJsonBody(req).catch(() => ({}));
@@ -14615,6 +14558,11 @@ async function handlePaygCheckout(req, res) {
if (!PAYG_ENABLED) return sendJson(res, 503, { error: 'Pay-as-you-go billing is not enabled' });
if (!isPaidPlan(plan)) return sendJson(res, 400, { error: 'Pay-as-you-go is only available on paid plans' });
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) {
return sendJson(res, 403, { error: 'Invalid CSRF token' });
}
const payg = computePaygSummary(user.id, plan);
if (payg.billableTokens <= 0 || payg.amount <= 0) {
@@ -14813,6 +14761,11 @@ async function handleSubscriptionCheckout(req, res) {
const user = findUserById(session.userId);
if (!user) return sendJson(res, 404, { error: 'User not found' });
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) {
return sendJson(res, 403, { error: 'Invalid CSRF token' });
}
try {
const body = await parseJsonBody(req);
@@ -15248,6 +15201,106 @@ async function handleSubscriptionCancel(req, res) {
});
}
const webhookProcessingLocks = new Map();
const PENDING_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
class AsyncMutex {
constructor() {
this._locked = false;
this._queue = [];
}
acquire() {
return new Promise(resolve => {
if (!this._locked) {
this._locked = true;
resolve(() => this._release());
} else {
this._queue.push(resolve);
}
});
}
_release() {
const next = this._queue.shift();
if (next) {
next(() => this._release());
} else {
this._locked = false;
}
}
}
const paymentMutex = new Map();
async function withPaymentLock(paymentId, fn) {
if (!paymentMutex.has(paymentId)) {
paymentMutex.set(paymentId, new AsyncMutex());
}
const mutex = paymentMutex.get(paymentId);
const release = await mutex.acquire();
try {
return await fn();
} finally {
release();
}
}
async function cleanupStalePendingPayments() {
const now = Date.now();
let cleanedTopups = 0;
let cleanedPayg = 0;
let cleanedSubscriptions = 0;
for (const [key, pending] of Object.entries(pendingTopups)) {
if (pending?.createdAt) {
const age = now - new Date(pending.createdAt).getTime();
if (age > PENDING_MAX_AGE_MS) {
delete pendingTopups[key];
cleanedTopups++;
}
}
}
for (const [key, pending] of Object.entries(pendingPayg)) {
if (pending?.createdAt) {
const age = now - new Date(pending.createdAt).getTime();
if (age > PENDING_MAX_AGE_MS) {
delete pendingPayg[key];
cleanedPayg++;
}
}
}
for (const [key, pending] of Object.entries(pendingSubscriptions)) {
if (pending?.createdAt) {
const age = now - new Date(pending.createdAt).getTime();
if (age > PENDING_MAX_AGE_MS) {
delete pendingSubscriptions[key];
cleanedSubscriptions++;
}
}
}
if (cleanedTopups > 0 || cleanedPayg > 0 || cleanedSubscriptions > 0) {
log('Cleaned up stale pending payments', { cleanedTopups, cleanedPayg, cleanedSubscriptions });
await Promise.all([persistPendingTopups(), persistPendingPayg(), persistPendingSubscriptions()]);
}
}
let pendingCleanupTimer = null;
function startPendingCleanup() {
if (pendingCleanupTimer) return;
pendingCleanupTimer = setInterval(cleanupStalePendingPayments, PENDING_CLEANUP_INTERVAL_MS);
}
function stopPendingCleanup() {
if (pendingCleanupTimer) {
clearInterval(pendingCleanupTimer);
pendingCleanupTimer = null;
}
}
async function handleDodoWebhook(req, res) {
try {
const DODO_WEBHOOK_KEY = process.env.DODO_PAYMENTS_WEBHOOK_KEY || '';
@@ -15260,17 +15313,25 @@ async function handleDodoWebhook(req, res) {
const signature = req.headers['dodo-signature'] || '';
if (DODO_WEBHOOK_KEY && signature) {
const isProduction = process.env.NODE_ENV === 'production' || DODO_ENVIRONMENT.includes('live');
if (!DODO_WEBHOOK_KEY) {
if (isProduction) {
log('CRITICAL: Dodo webhook received but DODO_PAYMENTS_WEBHOOK_KEY not set in production - rejecting');
return sendJson(res, 500, { error: 'Webhook processing not configured' });
}
log('WARNING: Dodo webhook received without webhook key configured (development mode)');
} else if (!signature) {
log('Dodo webhook missing signature', { hasKey: !!DODO_WEBHOOK_KEY });
return sendJson(res, 401, { error: 'Missing signature' });
} else {
const expectedSignature = `sha256=${require('crypto').createHmac('sha256', DODO_WEBHOOK_KEY).update(rawBody).digest('hex')}`;
const sigBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (sigBuffer.length !== expectedBuffer.length || !require('crypto').timingSafeEqual(sigBuffer, expectedBuffer)) {
log('Dodo webhook signature verification failed', { signature });
log('Dodo webhook signature verification failed', { signature: signature.substring(0, 20) + '...' });
return sendJson(res, 401, { error: 'Invalid signature' });
}
} else if (DODO_WEBHOOK_KEY) {
log('Dodo webhook missing signature', { hasKey: !!DODO_WEBHOOK_KEY });
return sendJson(res, 401, { error: 'Missing signature' });
}
const event = JSON.parse(rawBody);
@@ -15417,68 +15478,71 @@ async function handlePaymentSucceeded(event) {
if (inferredType === 'topup') {
const pendingKey = (checkoutId && pendingTopups?.[checkoutId] ? checkoutId : '') || findKeyByOrderId(pendingTopups, orderId);
const pending = pendingKey ? pendingTopups[pendingKey] : null;
const lockKey = pendingKey || paymentId || orderId;
const tokens = Number(metadata.tokens || pending?.tokens || 0);
if (!tokens) {
log('payment_succeeded: top-up missing tokens', { userId, eventId: event.id, checkoutId, orderId });
return;
}
await withPaymentLock(lockKey, async () => {
const tokens = Number(metadata.tokens || pending?.tokens || 0);
if (!tokens) {
log('payment_succeeded: top-up missing tokens', { userId, eventId: event.id, checkoutId, orderId });
return;
}
const amount = parseAmount(metadata.amount || pending?.amount || data.amount || data.amount_total || data.total_amount);
const currency = String(metadata.currency || pending?.currency || data.currency || '').toLowerCase() || null;
const tier = metadata.tier || pending?.tier || null;
const amount = parseAmount(metadata.amount || pending?.amount || data.amount || data.amount_total || data.total_amount);
const currency = String(metadata.currency || pending?.currency || data.currency || '').toLowerCase() || null;
const tier = metadata.tier || pending?.tier || null;
if (pendingKey && processedTopups[pendingKey]) {
await ensureInvoice('topup', {
tokens,
amount: processedTopups[pendingKey].amount ?? amount,
currency: processedTopups[pendingKey].currency ?? currency,
tier: processedTopups[pendingKey].tier ?? tier,
source: {
provider: 'dodo',
checkoutId: pendingKey,
orderId: processedTopups[pendingKey].orderId || orderId,
paymentId: processedTopups[pendingKey].paymentId || paymentId,
eventId: event.id,
},
});
return;
}
const bucket = ensureTokenUsageBucket(userId);
bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + tokens);
await persistTokenUsage();
if (pendingKey) {
processedTopups[pendingKey] = {
userId: user.id,
orderId: (pending?.orderId || orderId) || null,
paymentId: paymentId || null,
tokens,
tier: tier || null,
amount,
currency,
completedAt: new Date().toISOString(),
};
delete pendingTopups[pendingKey];
await Promise.all([persistTopupSessions(), persistPendingTopups()]);
}
if (pendingKey && processedTopups[pendingKey]) {
await ensureInvoice('topup', {
tokens,
amount: processedTopups[pendingKey].amount ?? amount,
currency: processedTopups[pendingKey].currency ?? currency,
tier: processedTopups[pendingKey].tier ?? tier,
amount,
currency,
tier,
source: {
provider: 'dodo',
checkoutId: pendingKey,
orderId: processedTopups[pendingKey].orderId || orderId,
paymentId: processedTopups[pendingKey].paymentId || paymentId,
checkoutId: pendingKey || checkoutId,
orderId,
paymentId,
eventId: event.id,
},
});
return;
}
const bucket = ensureTokenUsageBucket(userId);
bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + tokens);
await persistTokenUsage();
if (pendingKey) {
processedTopups[pendingKey] = {
userId: user.id,
orderId: (pending?.orderId || orderId) || null,
paymentId: paymentId || null,
tokens,
tier: tier || null,
amount,
currency,
completedAt: new Date().toISOString(),
};
delete pendingTopups[pendingKey];
await Promise.all([persistTopupSessions(), persistPendingTopups()]);
}
await ensureInvoice('topup', {
tokens,
amount,
currency,
tier,
source: {
provider: 'dodo',
checkoutId: pendingKey || checkoutId,
orderId,
paymentId,
eventId: event.id,
},
log('payment_succeeded: top-up processed via webhook', { userId, tokens, eventId: event.id, checkoutId: pendingKey || checkoutId });
});
log('payment_succeeded: top-up processed via webhook', { userId, tokens, eventId: event.id, checkoutId: pendingKey || checkoutId });
return;
}
@@ -16273,6 +16337,16 @@ async function handleVerifyEmailApi(req, res, url) {
async function handlePasswordResetRequest(req, res) {
try {
const clientIp = req.socket?.remoteAddress || 'unknown';
const rateLimit = checkLoginRateLimit(clientIp, 5, passwordResetAttempts);
if (rateLimit.blocked) {
log('password reset rate limited', { ip: clientIp, retryAfter: rateLimit.retryAfter });
return sendJson(res, 429, {
error: 'Too many password reset attempts. Please try again later.',
retryAfter: rateLimit.retryAfter || 60
});
}
const body = await parseJsonBody(req);
const email = (body.email || '').trim().toLowerCase();
if (!email) return sendJson(res, 400, { error: 'Email is required' });
@@ -16280,11 +16354,14 @@ async function handlePasswordResetRequest(req, res) {
const user = findUserByEmail(email);
if (!user) {
await new Promise((resolve) => setTimeout(resolve, 250));
passwordResetAttempts.delete(clientIp);
return sendJson(res, 200, { ok: true, message: 'If an account exists, a reset link has been sent.' });
}
assignPasswordResetToken(user);
await persistUsersDb();
passwordResetAttempts.delete(clientIp);
// Send reset email in the background
sendPasswordResetEmail(user, resolveBaseUrl(req)).catch(err => {
@@ -18483,7 +18560,15 @@ async function handleNewMessage(req, res, sessionId, userId) {
}
}
const model = resolvePlanModel(userPlan, body.model || session.model);
const estimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER; // include headroom for reply
const sessionHistory = (session.messages || []).slice(-20);
let historyTokens = 0;
sessionHistory.forEach((m) => {
if (m.content) historyTokens += estimateTokensFromText(m.content);
if (m.reply) historyTokens += estimateTokensFromText(m.reply);
});
const estimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER + (sessionHistory.length * TOKEN_HISTORY_OVERHEAD) + historyTokens;
const allowance = canConsumeTokens(session.userId, userPlan, estimatedTokens);
if (!allowance.allowed) {
const friendlyRemaining = allowance.remaining > 0 ? `${allowance.remaining.toLocaleString()} remaining` : 'no remaining balance';
@@ -20263,8 +20348,15 @@ async function routeInternal(req, res, url, pathname) {
if (req.method === 'GET' && pathname === '/api/verify-email') return handleVerifyEmailApi(req, res, url);
if (req.method === 'POST' && pathname === '/api/password/forgot') return handlePasswordResetRequest(req, res);
// Dev helper: preview branded email templates without sending
// Dev helper: preview branded email templates without sending (admin only)
if (req.method === 'GET' && pathname === '/debug/email/preview') {
const adminSession = requireAdminAuth(req, res);
if (!adminSession) return;
if (process.env.NODE_ENV === 'production') {
return sendJson(res, 403, { error: 'Debug endpoints disabled in production' });
}
const type = url.searchParams.get('type') || 'verification';
const email = url.searchParams.get('email') || 'user@example.com';
const token = url.searchParams.get('token') || 'sample-token';
@@ -20901,6 +20993,9 @@ async function bootstrap() {
// Start memory cleanup scheduler
startMemoryCleanup();
// Start pending payment cleanup scheduler
startPendingCleanup();
// Start periodic resource monitoring for analytics
startPeriodicMonitoring();