diff --git a/apps/api/package.json b/apps/api/package.json index e30082e..a9d9385 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -31,7 +31,9 @@ "ioredis": "^5.4.1", "jose": "^5.9.6", "nats": "^2.28.2", + "otpauth": "^9.3.5", "pino": "^9.5.0", + "qrcode": "^1.5.4", "pino-pretty": "^13.0.0", "zod": "^3.23.8" }, diff --git a/apps/api/src/common/middleware/audit.ts b/apps/api/src/common/middleware/audit.ts new file mode 100644 index 0000000..3f22dda --- /dev/null +++ b/apps/api/src/common/middleware/audit.ts @@ -0,0 +1,221 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; +import { insertAuditLog } from '../../lib/clickhouse'; +import pino from 'pino'; + +const logger = pino({ name: 'audit-middleware' }); + +// Map HTTP methods to actions +const METHOD_ACTION_MAP: Record = { + GET: 'read', + POST: 'create', + PUT: 'update', + PATCH: 'update', + DELETE: 'delete', +}; + +// Routes that should be audited (patterns) +const AUDITED_ROUTES: Array<{ + pattern: RegExp; + resourceType: string; + extractResourceInfo: (url: string, body?: unknown) => { resourceId?: string; resourceName?: string }; +}> = [ + { + pattern: /^\/clusters\/([^/]+)$/, + resourceType: 'cluster', + extractResourceInfo: (url) => { + const match = url.match(/^\/clusters\/([^/]+)$/); + return { resourceId: match?.[1] }; + }, + }, + { + pattern: /^\/clusters\/([^/]+)\/streams\/([^/]+)$/, + resourceType: 'stream', + extractResourceInfo: (url) => { + const match = url.match(/^\/clusters\/([^/]+)\/streams\/([^/]+)$/); + return { resourceId: match?.[1], resourceName: match?.[2] }; + }, + }, + { + pattern: /^\/clusters\/([^/]+)\/streams$/, + resourceType: 'stream', + extractResourceInfo: (_url, body) => { + const b = body as { name?: string } | undefined; + return { resourceName: b?.name }; + }, + }, + { + pattern: /^\/clusters\/([^/]+)\/streams\/([^/]+)\/consumers\/([^/]+)$/, + resourceType: 'consumer', + extractResourceInfo: (url) => { + const match = url.match(/^\/clusters\/([^/]+)\/streams\/([^/]+)\/consumers\/([^/]+)$/); + return { resourceId: match?.[1], resourceName: match?.[3] }; + }, + }, + { + pattern: /^\/clusters\/([^/]+)\/streams\/([^/]+)\/consumers$/, + resourceType: 'consumer', + extractResourceInfo: (_url, body) => { + const b = body as { name?: string } | undefined; + return { resourceName: b?.name }; + }, + }, + { + pattern: /^\/alerts\/rules\/([^/]+)$/, + resourceType: 'alert_rule', + extractResourceInfo: (url) => { + const match = url.match(/^\/alerts\/rules\/([^/]+)$/); + return { resourceId: match?.[1] }; + }, + }, + { + pattern: /^\/alerts\/channels\/([^/]+)$/, + resourceType: 'notification_channel', + extractResourceInfo: (url) => { + const match = url.match(/^\/alerts\/channels\/([^/]+)$/); + return { resourceId: match?.[1] }; + }, + }, + { + pattern: /^\/dashboards\/([^/]+)$/, + resourceType: 'dashboard', + extractResourceInfo: (url) => { + const match = url.match(/^\/dashboards\/([^/]+)$/); + return { resourceId: match?.[1] }; + }, + }, + { + pattern: /^\/organizations\/([^/]+)$/, + resourceType: 'organization', + extractResourceInfo: (url) => { + const match = url.match(/^\/organizations\/([^/]+)$/); + return { resourceId: match?.[1] }; + }, + }, + { + pattern: /^\/teams\/([^/]+)$/, + resourceType: 'team', + extractResourceInfo: (url) => { + const match = url.match(/^\/teams\/([^/]+)$/); + return { resourceId: match?.[1] }; + }, + }, + { + pattern: /^\/invites$/, + resourceType: 'invite', + extractResourceInfo: (_url, body) => { + const b = body as { email?: string } | undefined; + return { resourceName: b?.email }; + }, + }, + { + pattern: /^\/settings\/api-keys\/([^/]+)$/, + resourceType: 'api_key', + extractResourceInfo: (url) => { + const match = url.match(/^\/settings\/api-keys\/([^/]+)$/); + return { resourceId: match?.[1] }; + }, + }, +]; + +// Skip audit for read operations (GET) except for specific sensitive endpoints +const SKIP_READ_AUDIT = true; + +interface AuditInfo { + resourceType: string; + resourceId?: string; + resourceName?: string; + clusterId?: string; +} + +function getAuditInfo(url: string, body?: unknown): AuditInfo | null { + // Remove /api/v1 prefix if present + const path = url.replace(/^\/api\/v1/, ''); + + for (const route of AUDITED_ROUTES) { + if (route.pattern.test(path)) { + const info = route.extractResourceInfo(path, body); + + // Extract cluster ID from URL if present + const clusterMatch = path.match(/^\/clusters\/([^/]+)/); + + return { + resourceType: route.resourceType, + resourceId: info.resourceId, + resourceName: info.resourceName, + clusterId: clusterMatch?.[1], + }; + } + } + + return null; +} + +export async function auditLogger( + request: FastifyRequest, + reply: FastifyReply, + done: () => void +): Promise { + // Skip if no user (unauthenticated request) + if (!request.user) { + done(); + return; + } + + const method = request.method; + const action = METHOD_ACTION_MAP[method] || method.toLowerCase(); + + // Skip read operations if configured + if (SKIP_READ_AUDIT && action === 'read') { + done(); + return; + } + + // Get audit info for this route + const auditInfo = getAuditInfo(request.url, request.body); + if (!auditInfo) { + done(); + return; + } + + // Store start time for latency calculation + const startTime = Date.now(); + + // Add hook to log after response + reply.addHook('onSend', async (_request, _reply, payload) => { + try { + const statusCode = reply.statusCode; + const status = statusCode >= 200 && statusCode < 400 ? 'success' : 'failure'; + + await insertAuditLog({ + orgId: request.user!.orgId, + userId: request.user!.sub, + userEmail: request.user!.email, + timestamp: new Date(), + action, + resourceType: auditInfo.resourceType, + resourceId: auditInfo.resourceId || '', + resourceName: auditInfo.resourceName || '', + clusterId: auditInfo.clusterId || null, + ipAddress: request.ip || '', + userAgent: request.headers['user-agent'] || '', + requestId: request.id, + changes: method !== 'GET' && request.body ? request.body as Record : {}, + status, + errorMessage: status === 'failure' && typeof payload === 'string' + ? JSON.parse(payload)?.error?.message + : undefined, + }); + } catch (err) { + logger.error({ err }, 'Failed to insert audit log'); + } + + return payload; + }); + + done(); +} + +// Fastify plugin for audit logging +export async function auditPlugin(fastify: any): Promise { + fastify.addHook('preHandler', auditLogger); +} diff --git a/apps/api/src/common/middleware/auth.ts b/apps/api/src/common/middleware/auth.ts index 53456c6..0ce3093 100644 --- a/apps/api/src/common/middleware/auth.ts +++ b/apps/api/src/common/middleware/auth.ts @@ -1,21 +1,113 @@ import type { FastifyRequest, FastifyReply } from 'fastify'; -import { verifyToken } from '../../modules/auth/auth.service'; +import * as argon2 from 'argon2'; +import { verifyToken, getPermissionsForRole } from '../../modules/auth/auth.service'; import { getSession, updateSessionActivity } from '../../lib/redis'; +import { prisma } from '../../lib/prisma'; import type { JwtPayload } from '../../../../shared/src/index'; -// Extend FastifyRequest to include user +// Extend FastifyRequest to include user and apiKeyId declare module 'fastify' { interface FastifyRequest { user?: JwtPayload; + apiKeyId?: string; } } +// Check if the token is an API key (starts with nats_) +function isApiKey(token: string): boolean { + return token.startsWith('nats_'); +} + +// Validate API key and return user context +async function validateApiKey( + apiKey: string +): Promise<{ payload: JwtPayload; keyId: string } | null> { + // Extract the actual key (remove nats_ prefix) + const rawKey = apiKey.slice(5); + const prefix = rawKey.substring(0, 8); + + // Find API keys with matching prefix + const candidates = await prisma.apiKey.findMany({ + where: { + prefix, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + include: { + user: { + include: { + organizationMemberships: { + take: 1, + }, + }, + }, + }, + }); + + // Verify the key hash + for (const candidate of candidates) { + try { + const isValid = await argon2.verify(candidate.keyHash, rawKey); + if (isValid) { + // Update last used timestamp + await prisma.apiKey.update({ + where: { id: candidate.id }, + data: { lastUsedAt: new Date() }, + }); + + const membership = candidate.user.organizationMemberships[0]; + if (!membership) { + return null; + } + + // Build JWT-like payload for API key + const permissions = + candidate.permissions.length > 0 + ? candidate.permissions + : getPermissionsForRole(membership.role); + + return { + payload: { + sub: candidate.userId, + email: candidate.user.email, + orgId: membership.orgId, + role: membership.role as 'owner' | 'admin' | 'member' | 'viewer', + permissions, + }, + keyId: candidate.id, + }; + } + } catch { + // Hash verification failed, continue to next candidate + } + } + + return null; +} + export async function authenticate( request: FastifyRequest, reply: FastifyReply ): Promise { const authHeader = request.headers.authorization; + const apiKeyHeader = request.headers['x-api-key'] as string | undefined; + // Check for API key in X-API-Key header first + if (apiKeyHeader && isApiKey(apiKeyHeader)) { + const result = await validateApiKey(apiKeyHeader); + if (result) { + request.user = result.payload; + request.apiKeyId = result.keyId; + return; + } + return reply.status(401).send({ + error: { + code: 'INVALID_API_KEY', + message: 'Invalid or expired API key', + }, + }); + } + + // Check for Bearer token (could be JWT or API key) if (!authHeader?.startsWith('Bearer ')) { return reply.status(401).send({ error: { @@ -27,6 +119,23 @@ export async function authenticate( const token = authHeader.slice(7); + // Check if it's an API key + if (isApiKey(token)) { + const result = await validateApiKey(token); + if (result) { + request.user = result.payload; + request.apiKeyId = result.keyId; + return; + } + return reply.status(401).send({ + error: { + code: 'INVALID_API_KEY', + message: 'Invalid or expired API key', + }, + }); + } + + // Regular JWT token try { const payload = await verifyToken(token); request.user = payload; diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index cec112d..50c2beb 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -35,6 +35,13 @@ const envSchema = z.object({ // Rate limiting (only applies in production, disabled in development) RATE_LIMIT_MAX: z.coerce.number().default(1000), // 1000 requests per window in production RATE_LIMIT_WINDOW: z.coerce.number().default(60000), // 1 minute + + // Email (Resend) + RESEND_API_KEY: z.string().optional(), + EMAIL_FROM: z.string().default('NATS Console '), + + // Frontend URL (for links in emails) + FRONTEND_URL: z.string().default('http://localhost:3000'), }); function loadConfig() { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 66c290d..77f7722 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -24,6 +24,8 @@ import { alertRoutes } from './modules/alerts/alerts.routes'; import { dashboardRoutes } from './modules/dashboards/dashboards.routes'; import { inviteRoutes } from './modules/invites/invites.routes'; import { settingsRoutes } from './modules/settings/settings.routes'; +import { auditRoutes } from './modules/audit/audit.routes'; +import { auditPlugin } from './common/middleware/audit'; const app = Fastify({ logger: { @@ -77,6 +79,9 @@ app.get('/health', async () => { // API version prefix app.register( async (api) => { + // Register audit logging middleware + await api.register(auditPlugin); + // Register all routes await api.register(authRoutes, { prefix: '/auth' }); await api.register(organizationRoutes, { prefix: '/organizations' }); @@ -90,6 +95,7 @@ app.register( await api.register(dashboardRoutes, { prefix: '/dashboards' }); await api.register(inviteRoutes, { prefix: '/invites' }); await api.register(settingsRoutes, { prefix: '/settings' }); + await api.register(auditRoutes, { prefix: '/audit' }); }, { prefix: '/api/v1' } ); diff --git a/apps/api/src/lib/email.ts b/apps/api/src/lib/email.ts new file mode 100644 index 0000000..24c0cbc --- /dev/null +++ b/apps/api/src/lib/email.ts @@ -0,0 +1,219 @@ +import { config } from '../config/index'; +import pino from 'pino'; + +const logger = pino({ name: 'email-service' }); + +interface EmailOptions { + to: string | string[]; + subject: string; + html: string; + text?: string; +} + +export async function sendEmail(options: EmailOptions): Promise { + const apiKey = config.RESEND_API_KEY; + + if (!apiKey) { + logger.warn('RESEND_API_KEY not configured, skipping email'); + // In development, log the email content + if (config.NODE_ENV === 'development') { + logger.info({ ...options }, 'Email would be sent (dev mode)'); + } + return false; + } + + try { + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + from: config.EMAIL_FROM || 'NATS Console ', + to: Array.isArray(options.to) ? options.to : [options.to], + subject: options.subject, + html: options.html, + text: options.text, + }), + }); + + if (!response.ok) { + const error = await response.text(); + logger.error({ error, status: response.status }, 'Failed to send email'); + return false; + } + + logger.info({ to: options.to, subject: options.subject }, 'Email sent successfully'); + return true; + } catch (error) { + logger.error({ error }, 'Error sending email'); + return false; + } +} + +export async function sendInviteEmail( + email: string, + inviteToken: string, + inviterName: string, + organizationName: string +): Promise { + const inviteUrl = `${config.FRONTEND_URL}/invite/${inviteToken}`; + + return sendEmail({ + to: email, + subject: `You've been invited to join ${organizationName} on NATS Console`, + html: ` + + + + + + +
+

You're invited to join ${organizationName}

+

${inviterName} has invited you to join their organization on NATS Console.

+

NATS Console is a management platform for NATS JetStream clusters, providing real-time monitoring, stream and consumer management, and alerting.

+ Accept Invitation +

Or copy and paste this link into your browser:

+

${inviteUrl}

+

This invitation will expire in 7 days.

+ +
+ + + `, + text: ` +You're invited to join ${organizationName} + +${inviterName} has invited you to join their organization on NATS Console. + +Accept the invitation: ${inviteUrl} + +This invitation will expire in 7 days. + +If you didn't expect this invitation, you can safely ignore this email. + `, + }); +} + +export async function sendPasswordResetEmail( + email: string, + resetToken: string, + userName?: string +): Promise { + const resetUrl = `${config.FRONTEND_URL}/reset-password?token=${resetToken}`; + + return sendEmail({ + to: email, + subject: 'Reset your NATS Console password', + html: ` + + + + + + +
+

Reset your password

+

Hi${userName ? ` ${userName}` : ''},

+

We received a request to reset your password for your NATS Console account.

+ Reset Password +

Or copy and paste this link into your browser:

+

${resetUrl}

+

This link will expire in 1 hour.

+ +
+ + + `, + text: ` +Reset your password + +Hi${userName ? ` ${userName}` : ''}, + +We received a request to reset your password for your NATS Console account. + +Reset your password: ${resetUrl} + +This link will expire in 1 hour. + +If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. + `, + }); +} + +export async function sendWelcomeEmail( + email: string, + firstName: string +): Promise { + return sendEmail({ + to: email, + subject: 'Welcome to NATS Console', + html: ` + + + + + + +
+

Welcome to NATS Console!

+

Hi ${firstName},

+

Thank you for signing up for NATS Console. We're excited to have you on board!

+

With NATS Console, you can:

+
    +
  • Manage your NATS JetStream clusters
  • +
  • Monitor streams and consumers in real-time
  • +
  • Set up alerts for critical metrics
  • +
  • Browse and publish messages
  • +
+ Get Started + +
+ + + `, + text: ` +Welcome to NATS Console! + +Hi ${firstName}, + +Thank you for signing up for NATS Console. We're excited to have you on board! + +With NATS Console, you can: +- Manage your NATS JetStream clusters +- Monitor streams and consumers in real-time +- Set up alerts for critical metrics +- Browse and publish messages + +Get started: ${config.FRONTEND_URL}/clusters + +If you have any questions, feel free to reach out to our support team. + `, + }); +} diff --git a/apps/api/src/modules/alerts/alerts.routes.ts b/apps/api/src/modules/alerts/alerts.routes.ts index b7bb4df..e72325b 100644 --- a/apps/api/src/modules/alerts/alerts.routes.ts +++ b/apps/api/src/modules/alerts/alerts.routes.ts @@ -11,6 +11,7 @@ import { import { prisma } from '../../lib/prisma'; import { authenticate } from '../../common/middleware/auth'; import { IncidentStatus } from '@prisma/client'; +import { getClickHouseClient } from '../../lib/clickhouse'; // Incident Schemas const UpdateIncidentSchema = z.object({ @@ -276,8 +277,145 @@ export const alertRoutes: FastifyPluginAsync = async (fastify) => { throw new NotFoundError('Notification channel', request.params.id); } - // TODO: Implement actual test notification - return { success: true, message: 'Test notification sent' }; + const config = channel.config as Record; + const testMessage = { + rule: 'Test Alert Rule', + severity: 'info', + status: 'test', + metricValue: 42, + threshold: 100, + message: 'This is a test notification from NATS Console', + incidentId: 'test-' + Date.now(), + timestamp: new Date().toISOString(), + }; + + try { + switch (channel.type) { + case 'webhook': { + const url = config.url as string; + if (!url) throw new Error('Webhook URL not configured'); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(testMessage), + }); + if (!response.ok) throw new Error(`Webhook returned ${response.status}`); + break; + } + + case 'slack': { + const webhookUrl = config.webhookUrl as string; + if (!webhookUrl) throw new Error('Slack webhook URL not configured'); + const payload = { + attachments: [{ + color: '#3b82f6', + blocks: [ + { type: 'header', text: { type: 'plain_text', text: '🧪 Test Notification', emoji: true } }, + { type: 'section', text: { type: 'mrkdwn', text: 'This is a test notification from NATS Console. Your Slack integration is working correctly!' } }, + ], + }], + }; + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(`Slack returned ${response.status}`); + break; + } + + case 'email': { + const apiKey = (config.apiKey as string) || process.env.RESEND_API_KEY; + const recipients = config.recipients as string[]; + if (!apiKey) throw new Error('Email API key not configured'); + if (!recipients?.length) throw new Error('No email recipients configured'); + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + from: (config.fromEmail as string) || 'alerts@nats-console.local', + to: recipients, + subject: '🧪 Test Notification - NATS Console', + html: '

Test Notification

This is a test notification from NATS Console. Your email integration is working correctly!

', + }), + }); + if (!response.ok) throw new Error(`Email API returned ${response.status}`); + break; + } + + case 'teams': { + const webhookUrl = config.webhookUrl as string; + if (!webhookUrl) throw new Error('Teams webhook URL not configured'); + const payload = { + '@type': 'MessageCard', + '@context': 'http://schema.org/extensions', + themeColor: '3b82f6', + summary: 'Test Notification', + sections: [{ + activityTitle: '🧪 Test Notification', + text: 'This is a test notification from NATS Console. Your Teams integration is working correctly!', + }], + }; + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(`Teams returned ${response.status}`); + break; + } + + case 'pagerduty': { + const routingKey = config.routingKey as string; + if (!routingKey) throw new Error('PagerDuty routing key not configured'); + const payload = { + routing_key: routingKey, + event_action: 'trigger', + dedup_key: `nats-console-test-${Date.now()}`, + payload: { + summary: 'Test notification from NATS Console', + severity: 'info', + source: 'NATS Console', + custom_details: { test: true }, + }, + }; + const response = await fetch('https://events.pagerduty.com/v2/enqueue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(`PagerDuty returned ${response.status}`); + break; + } + + case 'google_chat': { + const webhookUrl = config.webhookUrl as string; + if (!webhookUrl) throw new Error('Google Chat webhook URL not configured'); + const payload = { + cards: [{ + header: { title: '🧪 Test Notification', subtitle: 'NATS Console' }, + sections: [{ + widgets: [{ textParagraph: { text: 'This is a test notification. Your Google Chat integration is working correctly!' } }], + }], + }], + }; + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(`Google Chat returned ${response.status}`); + break; + } + + default: + throw new Error(`Unknown channel type: ${channel.type}`); + } + + return { success: true, message: 'Test notification sent successfully' }; + } catch (error: any) { + return { success: false, message: error.message || 'Failed to send test notification' }; + } }); // ==================== Incidents ==================== @@ -460,14 +598,184 @@ export const alertRoutes: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { ruleId?: string; from?: string; to?: string; limit?: string }; }>('/events', async (request) => { - // TODO: Query from ClickHouse - return { events: [] }; + const { ruleId, from, to, limit = '100' } = request.query; + const ch = getClickHouseClient(); + + const conditions = ['org_id = {orgId:UUID}']; + const params: Record = { orgId: request.user!.orgId }; + + if (ruleId) { + conditions.push('alert_rule_id = {ruleId:UUID}'); + params.ruleId = ruleId; + } + + if (from) { + conditions.push('timestamp >= {from:DateTime64(3)}'); + params.from = new Date(from).toISOString(); + } + + if (to) { + conditions.push('timestamp <= {to:DateTime64(3)}'); + params.to = new Date(to).toISOString(); + } + + const whereClause = conditions.join(' AND '); + + try { + const result = await ch.query({ + query: ` + SELECT + id, + org_id, + alert_rule_id, + cluster_id, + timestamp, + severity, + status, + metric_value, + threshold_value, + message, + notified_at, + resolved_at + FROM alert_events + WHERE ${whereClause} + ORDER BY timestamp DESC + LIMIT ${parseInt(limit)} + `, + query_params: params, + format: 'JSONEachRow', + }); + + const rows = await result.json() as Record[]; + const events = rows.map((row) => ({ + id: row.id, + orgId: row.org_id, + ruleId: row.alert_rule_id, + clusterId: row.cluster_id, + timestamp: row.timestamp, + severity: row.severity, + status: row.status, + metricValue: Number(row.metric_value), + thresholdValue: Number(row.threshold_value), + message: row.message, + notifiedAt: row.notified_at, + resolvedAt: row.resolved_at, + })); + + return { events }; + } catch (err) { + fastify.log.error({ err }, 'Error querying alert events'); + return { events: [] }; + } }); - // POST /alerts/test - Test alert rule - fastify.post('/test', async (request) => { + // POST /alerts/test - Test alert rule evaluation + fastify.post<{ Body: z.infer }>('/test', async (request) => { const body = CreateAlertRuleSchema.parse(request.body); - // TODO: Implement test alert - return { success: true, message: 'Test alert sent' }; + const ch = getClickHouseClient(); + + const { metric, aggregation, window } = body.condition; + const parts = metric.split('.'); + const metricType = parts[0]; + + const windowStart = new Date(Date.now() - window * 1000); + const windowEnd = new Date(); + + try { + let metricValue: number | null = null; + + if (metricType === 'stream' && parts.length >= 3) { + const streamName = parts[1]; + const metricName = parts[2]; + + const result = await ch.query({ + query: ` + SELECT ${aggregation}(${metricName}) as value + FROM stream_metrics + WHERE stream_name = {streamName:String} + ${body.clusterId ? 'AND cluster_id = {clusterId:UUID}' : ''} + AND timestamp >= {from:DateTime64(3)} + AND timestamp <= {to:DateTime64(3)} + `, + query_params: { + streamName, + clusterId: body.clusterId, + from: windowStart.toISOString(), + to: windowEnd.toISOString(), + }, + format: 'JSONEachRow', + }); + + const rows = await result.json() as { value: number }[]; + metricValue = rows[0]?.value ?? null; + } else if (metricType === 'consumer' && parts.length >= 4) { + const streamName = parts[1]; + const consumerName = parts[2]; + const metricName = parts[3]; + + const result = await ch.query({ + query: ` + SELECT ${aggregation}(${metricName}) as value + FROM consumer_metrics + WHERE stream_name = {streamName:String} + AND consumer_name = {consumerName:String} + ${body.clusterId ? 'AND cluster_id = {clusterId:UUID}' : ''} + AND timestamp >= {from:DateTime64(3)} + AND timestamp <= {to:DateTime64(3)} + `, + query_params: { + streamName, + consumerName, + clusterId: body.clusterId, + from: windowStart.toISOString(), + to: windowEnd.toISOString(), + }, + format: 'JSONEachRow', + }); + + const rows = await result.json() as { value: number }[]; + metricValue = rows[0]?.value ?? null; + } + + if (metricValue === null) { + return { + success: true, + wouldFire: false, + message: 'No metric data available for the specified window', + metricValue: null, + threshold: body.threshold.value, + }; + } + + // Check threshold + const { operator } = body.condition; + let wouldFire = false; + switch (operator) { + case 'gt': wouldFire = metricValue > body.threshold.value; break; + case 'lt': wouldFire = metricValue < body.threshold.value; break; + case 'gte': wouldFire = metricValue >= body.threshold.value; break; + case 'lte': wouldFire = metricValue <= body.threshold.value; break; + case 'eq': wouldFire = metricValue === body.threshold.value; break; + case 'neq': wouldFire = metricValue !== body.threshold.value; break; + } + + return { + success: true, + wouldFire, + message: wouldFire + ? `Alert would fire: ${metricValue} ${operator} ${body.threshold.value}` + : `Alert would not fire: ${metricValue} ${operator} ${body.threshold.value}`, + metricValue, + threshold: body.threshold.value, + }; + } catch (err: any) { + return { + success: false, + wouldFire: false, + message: err.message || 'Failed to evaluate alert rule', + metricValue: null, + threshold: body.threshold.value, + }; + } }); }; diff --git a/apps/api/src/modules/analytics/analytics.routes.ts b/apps/api/src/modules/analytics/analytics.routes.ts index 08871d3..ca2800f 100644 --- a/apps/api/src/modules/analytics/analytics.routes.ts +++ b/apps/api/src/modules/analytics/analytics.routes.ts @@ -121,4 +121,205 @@ export const analyticsRoutes: FastifyPluginAsync = async (fastify) => { return queryOverviewMetrics(clusterId, timeRange || '1h'); } ); + + // GET /analytics/charts/throughput - Throughput over time for charts + fastify.get<{ Querystring: { clusterId: string; timeRange?: string } }>( + '/charts/throughput', + async (request) => { + const { clusterId, timeRange = '24h' } = request.query; + + // Parse time range + const rangeHours: Record = { + '1h': 1, + '6h': 6, + '24h': 24, + '7d': 168, + '30d': 720, + }; + const hours = rangeHours[timeRange] || 24; + const interval = hours <= 6 ? '5m' : hours <= 24 ? '15m' : hours <= 168 ? '1h' : '6h'; + + const from = new Date(Date.now() - hours * 60 * 60 * 1000); + const to = new Date(); + + // Get all streams for the cluster + const { prisma } = await import('../../lib/prisma'); + const streams = await prisma.stream.findMany({ + where: { clusterId }, + select: { name: true }, + }); + + if (streams.length === 0) { + return { data: [], interval }; + } + + // Query aggregated throughput for all streams + const { getClickHouseClient } = await import('../../lib/clickhouse'); + const ch = getClickHouseClient(); + + const intervalSeconds = interval === '5m' ? 300 : interval === '15m' ? 900 : interval === '1h' ? 3600 : 21600; + + try { + const result = await ch.query({ + query: ` + SELECT + toStartOfInterval(timestamp, INTERVAL ${intervalSeconds} SECOND) as time, + sum(messages_rate) as messages_per_sec + FROM stream_metrics + WHERE cluster_id = {clusterId:UUID} + AND timestamp >= {from:DateTime64(3)} + AND timestamp <= {to:DateTime64(3)} + GROUP BY time + ORDER BY time ASC + `, + query_params: { + clusterId, + from: from.toISOString(), + to: to.toISOString(), + }, + format: 'JSONEachRow', + }); + + const rows = (await result.json()) as Array<{ time: string; messages_per_sec: string }>; + + return { + data: rows.map((row) => ({ + name: 'Throughput', + value: Math.round(Number(row.messages_per_sec) * 100) / 100, + time: new Date(row.time).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + ...(hours > 24 ? { month: 'short', day: 'numeric' } : {}), + }), + })), + interval, + }; + } catch { + return { data: [], interval }; + } + } + ); + + // GET /analytics/charts/consumer-lag - Consumer lag by consumer + fastify.get<{ Querystring: { clusterId: string; timeRange?: string } }>( + '/charts/consumer-lag', + async (request) => { + const { clusterId, timeRange = '24h' } = request.query; + + const rangeHours: Record = { + '1h': 1, + '6h': 6, + '24h': 24, + '7d': 168, + '30d': 720, + }; + const hours = rangeHours[timeRange] || 24; + + const { getClickHouseClient } = await import('../../lib/clickhouse'); + const ch = getClickHouseClient(); + + try { + const result = await ch.query({ + query: ` + SELECT + consumer_name as name, + avg(pending_count) as pending + FROM consumer_metrics + WHERE cluster_id = {clusterId:UUID} + AND timestamp >= now() - INTERVAL ${hours} HOUR + GROUP BY consumer_name + ORDER BY pending DESC + LIMIT 10 + `, + query_params: { clusterId }, + format: 'JSONEachRow', + }); + + const rows = (await result.json()) as Array<{ name: string; pending: string }>; + + return { + data: rows.map((row) => ({ + name: row.name, + value: Math.round(Number(row.pending)), + })), + }; + } catch { + return { data: [] }; + } + } + ); + + // GET /analytics/charts/stream-activity - Messages by stream over time + fastify.get<{ Querystring: { clusterId: string; timeRange?: string } }>( + '/charts/stream-activity', + async (request) => { + const { clusterId, timeRange = '24h' } = request.query; + + const rangeHours: Record = { + '1h': 1, + '6h': 6, + '24h': 24, + '7d': 168, + '30d': 720, + }; + const hours = rangeHours[timeRange] || 24; + const interval = hours <= 6 ? '5m' : hours <= 24 ? '15m' : hours <= 168 ? '1h' : '6h'; + const intervalSeconds = interval === '5m' ? 300 : interval === '15m' ? 900 : interval === '1h' ? 3600 : 21600; + + const from = new Date(Date.now() - hours * 60 * 60 * 1000); + const to = new Date(); + + const { getClickHouseClient } = await import('../../lib/clickhouse'); + const ch = getClickHouseClient(); + + try { + const result = await ch.query({ + query: ` + SELECT + stream_name, + toStartOfInterval(timestamp, INTERVAL ${intervalSeconds} SECOND) as time, + avg(messages_rate) as messages_rate + FROM stream_metrics + WHERE cluster_id = {clusterId:UUID} + AND timestamp >= {from:DateTime64(3)} + AND timestamp <= {to:DateTime64(3)} + GROUP BY stream_name, time + ORDER BY stream_name, time ASC + `, + query_params: { + clusterId, + from: from.toISOString(), + to: to.toISOString(), + }, + format: 'JSONEachRow', + }); + + const rows = (await result.json()) as Array<{ + stream_name: string; + time: string; + messages_rate: string; + }>; + + // Group by stream + const streams: Record> = {}; + for (const row of rows) { + if (!streams[row.stream_name]) { + streams[row.stream_name] = []; + } + streams[row.stream_name].push({ + time: new Date(row.time).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + ...(hours > 24 ? { month: 'short', day: 'numeric' } : {}), + }), + value: Math.round(Number(row.messages_rate) * 100) / 100, + }); + } + + return { streams, interval }; + } catch { + return { streams: {}, interval }; + } + } + ); }; diff --git a/apps/api/src/modules/audit/audit.routes.ts b/apps/api/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..5606f51 --- /dev/null +++ b/apps/api/src/modules/audit/audit.routes.ts @@ -0,0 +1,135 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import { queryAuditLogs } from '../../lib/clickhouse'; +import { authenticate } from '../../common/middleware/auth'; +import { hasPermission } from '../auth/auth.service'; +import { ForbiddenError } from '../../../../shared/src/index'; + +const QueryLogsSchema = z.object({ + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + action: z.enum(['create', 'update', 'delete', 'read']).optional(), + resourceType: z.string().optional(), + userId: z.string().uuid().optional(), + limit: z.coerce.number().min(1).max(100).optional().default(50), + offset: z.coerce.number().min(0).optional().default(0), +}); + +export const auditRoutes: FastifyPluginAsync = async (fastify) => { + fastify.addHook('preHandler', authenticate); + + // GET /audit/logs - List audit logs + fastify.get<{ + Querystring: z.infer; + }>('/logs', async (request) => { + // Check if user has permission to view audit logs (admin/owner only) + if (!hasPermission(request.user!.permissions, 'settings', 'read')) { + throw new ForbiddenError('You do not have permission to view audit logs'); + } + + const query = QueryLogsSchema.parse(request.query); + + const { logs, total } = await queryAuditLogs(request.user!.orgId, { + from: query.from ? new Date(query.from) : undefined, + to: query.to ? new Date(query.to) : undefined, + action: query.action, + resourceType: query.resourceType, + userId: query.userId, + limit: query.limit, + offset: query.offset, + }); + + return { + logs, + pagination: { + total, + limit: query.limit, + offset: query.offset, + hasMore: query.offset + logs.length < total, + }, + }; + }); + + // GET /audit/logs/actions - Get distinct action types + fastify.get('/logs/actions', async () => { + return { + actions: ['create', 'update', 'delete'], + }; + }); + + // GET /audit/logs/resource-types - Get distinct resource types + fastify.get('/logs/resource-types', async () => { + return { + resourceTypes: [ + 'cluster', + 'stream', + 'consumer', + 'alert_rule', + 'notification_channel', + 'dashboard', + 'organization', + 'team', + 'invite', + 'api_key', + ], + }; + }); + + // GET /audit/logs/export - Export audit logs + fastify.get<{ + Querystring: z.infer & { format?: 'json' | 'csv' }; + }>('/logs/export', async (request, reply) => { + // Check if user has permission to view audit logs + if (!hasPermission(request.user!.permissions, 'settings', 'read')) { + throw new ForbiddenError('You do not have permission to export audit logs'); + } + + const { format = 'json', ...rest } = request.query; + const query = QueryLogsSchema.parse(rest); + + const { logs } = await queryAuditLogs(request.user!.orgId, { + from: query.from ? new Date(query.from) : undefined, + to: query.to ? new Date(query.to) : undefined, + action: query.action, + resourceType: query.resourceType, + userId: query.userId, + limit: 10000, // Max export limit + offset: 0, + }); + + if (format === 'csv') { + const headers = [ + 'timestamp', + 'action', + 'resource_type', + 'resource_name', + 'user_email', + 'ip_address', + 'status', + ]; + const csvRows = [headers.join(',')]; + + for (const log of logs) { + const row = [ + log.timestamp, + log.action, + log.resourceType, + `"${(log.resourceName || '').replace(/"/g, '""')}"`, + log.userEmail, + log.ipAddress, + log.status, + ]; + csvRows.push(row.join(',')); + } + + reply.header('Content-Type', 'text/csv'); + reply.header('Content-Disposition', 'attachment; filename="audit-logs.csv"'); + return csvRows.join('\n'); + } + + // JSON format + reply.header('Content-Type', 'application/json'); + reply.header('Content-Disposition', 'attachment; filename="audit-logs.json"'); + return JSON.stringify(logs, null, 2); + }); +}; diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 5471f66..9428124 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -1,8 +1,11 @@ import * as argon2 from 'argon2'; import { SignJWT, jwtVerify } from 'jose'; +import * as OTPAuth from 'otpauth'; +import * as QRCode from 'qrcode'; import { prisma } from '../../lib/prisma'; import { redis, setSession, getSession, deleteSession } from '../../lib/redis'; import { config } from '../../config/index'; +import { sendPasswordResetEmail } from '../../lib/email'; import { UnauthorizedError, NotFoundError, @@ -13,6 +16,68 @@ import type { User, AuthTokens, JwtPayload } from '../../../../shared/src/index' const JWT_SECRET = new TextEncoder().encode(config.JWT_SECRET); +// ==================== RBAC Permissions ==================== + +// Permission format: resource:action:scope +// Resources: clusters, streams, consumers, alerts, dashboards, settings, users, teams +// Actions: read, write, delete, admin +// Scope: * (all) or specific ID + +const ROLE_PERMISSIONS: Record = { + owner: ['*:*:*'], // Full access to everything + admin: [ + 'clusters:*:*', + 'streams:*:*', + 'consumers:*:*', + 'alerts:*:*', + 'dashboards:*:*', + 'settings:read:*', + 'settings:write:*', + 'users:read:*', + 'users:write:*', + 'teams:*:*', + ], + member: [ + 'clusters:read:*', + 'streams:read:*', + 'streams:write:*', + 'consumers:read:*', + 'consumers:write:*', + 'alerts:read:*', + 'dashboards:read:*', + 'dashboards:write:own', + 'settings:read:*', + ], + viewer: [ + 'clusters:read:*', + 'streams:read:*', + 'consumers:read:*', + 'alerts:read:*', + 'dashboards:read:*', + ], +}; + +export function getPermissionsForRole(role: string): string[] { + return ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS.viewer; +} + +export function hasPermission(permissions: string[], resource: string, action: string, scope: string = '*'): boolean { + return permissions.some((perm) => { + const [permResource, permAction, permScope] = perm.split(':'); + + // Check resource match (wildcard or exact) + const resourceMatch = permResource === '*' || permResource === resource; + + // Check action match (wildcard or exact) + const actionMatch = permAction === '*' || permAction === action; + + // Check scope match (wildcard or exact) + const scopeMatch = permScope === '*' || permScope === scope || permScope === 'own'; + + return resourceMatch && actionMatch && scopeMatch; + }); +} + // ==================== Password Hashing ==================== export async function hashPassword(password: string): Promise { @@ -208,13 +273,16 @@ export async function login( // Generate tokens const tokens = await generateTokens(user.id, user.email, membership.orgId, membership.role); + // Get permissions based on role + const permissions = getPermissionsForRole(membership.role); + // Store session in Redis await setSession(tokens.accessToken.split('.')[2]!, { userId: user.id, orgId: membership.orgId, email: user.email, role: membership.role, - permissions: ['*:*:*'], // TODO: Fetch actual permissions + permissions, ipAddress: ipAddress || '', createdAt: new Date().toISOString(), lastActivity: new Date().toISOString(), @@ -302,21 +370,9 @@ export async function requestPasswordReset(email: string): Promise { // Store in Redis with 1 hour TTL await redis.set(`password_reset:${user.id}`, resetToken, 'EX', 3600); - // Build reset URL - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; - const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`; - - // In dev mode, print the URL to console - if (config.NODE_ENV === 'development') { - console.log('\n========================================'); - console.log('📧 PASSWORD RESET EMAIL (DEV MODE)'); - console.log('========================================'); - console.log(`To: ${email}`); - console.log(`Reset URL: ${resetUrl}`); - console.log('========================================\n'); - } - - // TODO: In production, send email with reset link + // Send password reset email + const userName = user.firstName || undefined; + await sendPasswordResetEmail(email, resetToken, userName); } export async function resetPassword(token: string, newPassword: string): Promise { @@ -346,13 +402,73 @@ export async function resetPassword(token: string, newPassword: string): Promise // ==================== MFA ==================== export async function enableMfa(userId: string): Promise<{ secret: string; qrCode: string }> { - // TODO: Implement TOTP generation with speakeasy - throw new Error('MFA not implemented yet'); + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundError('User', userId); + } + + // Generate a new TOTP secret + const totp = new OTPAuth.TOTP({ + issuer: 'NATS Console', + label: user.email, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromHex(crypto.randomUUID().replace(/-/g, '')), + }); + + const secret = totp.secret.base32; + + // Generate QR code + const otpauthUrl = totp.toString(); + const qrCode = await QRCode.toDataURL(otpauthUrl); + + // Store secret temporarily (not enabled yet until verified) + await prisma.user.update({ + where: { id: userId }, + data: { mfaSecret: secret }, + }); + + return { secret, qrCode }; } export async function verifyMfa(userId: string, code: string): Promise { - // TODO: Implement TOTP verification - throw new Error('MFA not implemented yet'); + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user || !user.mfaSecret) { + throw new ValidationError('MFA not set up for this user'); + } + + const totp = new OTPAuth.TOTP({ + issuer: 'NATS Console', + label: user.email, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(user.mfaSecret), + }); + + // Validate the code (allow 1 period window for clock drift) + const delta = totp.validate({ token: code, window: 1 }); + + if (delta === null) { + return false; + } + + // If MFA was not yet enabled, enable it now (first successful verification) + if (!user.mfaEnabled) { + await prisma.user.update({ + where: { id: userId }, + data: { mfaEnabled: true }, + }); + } + + return true; } export async function disableMfa(userId: string): Promise { @@ -429,8 +545,7 @@ async function generateTokens( orgId: string, role: string ): Promise { - // TODO: Fetch actual permissions from database - const permissions = ['*:*:*']; + const permissions = getPermissionsForRole(role); const accessToken = await generateAccessToken({ sub: userId, diff --git a/apps/api/src/modules/invites/invites.routes.ts b/apps/api/src/modules/invites/invites.routes.ts index c501521..bfc8f88 100644 --- a/apps/api/src/modules/invites/invites.routes.ts +++ b/apps/api/src/modules/invites/invites.routes.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'crypto'; import { prisma } from '../../lib/prisma'; import { authenticate } from '../../common/middleware/auth'; import { config } from '../../config/index'; +import { sendInviteEmail } from '../../lib/email'; import { NotFoundError, ConflictError, ForbiddenError } from '../../../../shared/src/index'; const CreateInviteSchema = z.object({ @@ -111,19 +112,17 @@ export const inviteRoutes: FastifyPluginAsync = async (fastify) => { const inviteLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/invite/${token}`; - // In dev mode, print the URL to console - if (config.NODE_ENV === 'development') { - console.log('\n========================================'); - console.log('📨 TEAM INVITE EMAIL (DEV MODE)'); - console.log('========================================'); - console.log(`To: ${body.email}`); - console.log(`Organization: ${invite.organization.name}`); - console.log(`Role: ${body.role}`); - console.log(`Invite URL: ${inviteLink}`); - console.log('========================================\n'); - } + // Send invite email + const inviterName = invite.inviter + ? `${invite.inviter.firstName || ''} ${invite.inviter.lastName || ''}`.trim() || invite.inviter.email + : 'A team member'; - // TODO: In production, send email with invite link + await sendInviteEmail( + body.email, + token, + inviterName, + invite.organization.name + ); return reply.status(201).send({ invite: { diff --git a/apps/api/src/modules/streams/streams.routes.ts b/apps/api/src/modules/streams/streams.routes.ts index a441160..9e1629f 100644 --- a/apps/api/src/modules/streams/streams.routes.ts +++ b/apps/api/src/modules/streams/streams.routes.ts @@ -147,4 +147,121 @@ export const streamRoutes: FastifyPluginAsync = async (fastify) => { return reply.status(204).send(); } ); + + // GET /clusters/:cid/streams/:name/messages/export - Export messages + fastify.get<{ + Params: { cid: string; name: string }; + Querystring: { + format?: 'json' | 'csv'; + start_seq?: string; + limit?: string; + subject?: string; + }; + }>('/:cid/streams/:name/messages/export', async (request, reply) => { + const { format = 'json', start_seq, limit = '1000', subject } = request.query; + + const messages = await streamService.getMessages( + request.user!.orgId, + request.params.cid, + request.params.name, + { + startSeq: start_seq ? parseInt(start_seq) : undefined, + limit: Math.min(parseInt(limit), 10000), // Max 10k messages per export + subject, + } + ); + + if (format === 'csv') { + // CSV format + const headers = ['sequence', 'subject', 'time', 'data']; + const csvRows = [headers.join(',')]; + + for (const msg of messages) { + const row = [ + msg.sequence, + `"${msg.subject.replace(/"/g, '""')}"`, + msg.time, + `"${String(msg.data).replace(/"/g, '""').replace(/\n/g, '\\n')}"`, + ]; + csvRows.push(row.join(',')); + } + + reply.header('Content-Type', 'text/csv'); + reply.header( + 'Content-Disposition', + `attachment; filename="${request.params.name}-messages.csv"` + ); + return csvRows.join('\n'); + } + + // JSON format (default) + reply.header('Content-Type', 'application/json'); + reply.header( + 'Content-Disposition', + `attachment; filename="${request.params.name}-messages.json"` + ); + return JSON.stringify(messages, null, 2); + }); + + // POST /clusters/:cid/streams/:name/messages/replay - Replay messages to another subject + fastify.post<{ + Params: { cid: string; name: string }; + Body: { + targetSubject: string; + startSeq?: number; + endSeq?: number; + limit?: number; + }; + }>('/:cid/streams/:name/messages/replay', async (request) => { + const { targetSubject, startSeq, endSeq, limit = 100 } = request.body as { + targetSubject: string; + startSeq?: number; + endSeq?: number; + limit?: number; + }; + + // Get messages to replay + const messages = await streamService.getMessages( + request.user!.orgId, + request.params.cid, + request.params.name, + { + startSeq, + limit: Math.min(limit, 1000), // Max 1000 messages per replay + } + ); + + // Filter by end sequence if specified + const filteredMessages = endSeq + ? messages.filter((m) => m.sequence <= endSeq) + : messages; + + // Replay each message + let replayedCount = 0; + const errors: Array<{ sequence: number; error: string }> = []; + + for (const msg of filteredMessages) { + try { + await streamService.publishMessage( + request.user!.orgId, + request.params.cid, + targetSubject, + String(msg.data), + msg.headers + ); + replayedCount++; + } catch (err) { + errors.push({ + sequence: msg.sequence, + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + return { + replayed: replayedCount, + total: filteredMessages.length, + errors: errors.length > 0 ? errors : undefined, + }; + }); }; diff --git a/apps/web/app/(dashboard)/analytics/page.tsx b/apps/web/app/(dashboard)/analytics/page.tsx index db0edcf..be65b52 100644 --- a/apps/web/app/(dashboard)/analytics/page.tsx +++ b/apps/web/app/(dashboard)/analytics/page.tsx @@ -2,9 +2,10 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { BarChart3, TrendingUp, TrendingDown, Activity, ArrowUpRight, ArrowDownRight } from 'lucide-react'; +import { BarChart3, TrendingUp, TrendingDown, Activity, ArrowUpRight, ArrowDownRight, Loader2 } from 'lucide-react'; import { api } from '@/lib/api'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { LineChart, BarChart, MultiLineChart } from '@/components/charts'; import { formatBytes, formatNumber } from '@nats-console/shared'; export default function AnalyticsPage() { @@ -25,6 +26,34 @@ export default function AnalyticsPage() { enabled: !!selectedCluster, }); + // Chart data queries + const { data: throughputData, isLoading: throughputLoading } = useQuery({ + queryKey: ['analytics-throughput', selectedCluster, timeRange], + queryFn: () => + selectedCluster + ? api.analytics.chartThroughput(selectedCluster, timeRange) + : null, + enabled: !!selectedCluster, + }); + + const { data: consumerLagData, isLoading: consumerLagLoading } = useQuery({ + queryKey: ['analytics-consumer-lag', selectedCluster, timeRange], + queryFn: () => + selectedCluster + ? api.analytics.chartConsumerLag(selectedCluster, timeRange) + : null, + enabled: !!selectedCluster, + }); + + const { data: streamActivityData, isLoading: streamActivityLoading } = useQuery({ + queryKey: ['analytics-stream-activity', selectedCluster, timeRange], + queryFn: () => + selectedCluster + ? api.analytics.chartStreamActivity(selectedCluster, timeRange) + : null, + enabled: !!selectedCluster, + }); + // Auto-select first cluster if (clustersData?.clusters?.length && !selectedCluster) { setSelectedCluster(clustersData.clusters[0].id); @@ -185,13 +214,27 @@ export default function AnalyticsPage() { Messages per second over time -
-
- -

Chart visualization coming soon

-

Integrate with your preferred charting library

+ {throughputLoading ? ( +
+
-
+ ) : throughputData?.data && throughputData.data.length > 0 ? ( + + ) : ( +
+
+ +

No throughput data available

+

Start producing messages to see metrics

+
+
+ )} @@ -201,13 +244,27 @@ export default function AnalyticsPage() { Pending messages by consumer -
-
- -

Chart visualization coming soon

-

Integrate with your preferred charting library

+ {consumerLagLoading ? ( +
+
-
+ ) : consumerLagData?.data && consumerLagData.data.length > 0 ? ( + + ) : ( +
+
+ +

No consumer lag data available

+

Create consumers to see lag metrics

+
+
+ )}
@@ -218,13 +275,26 @@ export default function AnalyticsPage() { Messages by stream over time -
-
- -

Chart visualization coming soon

-

Integrate with your preferred charting library

+ {streamActivityLoading ? ( +
+
-
+ ) : streamActivityData?.streams && Object.keys(streamActivityData.streams).length > 0 ? ( + + ) : ( +
+
+ +

No stream activity data available

+

Create streams and produce messages to see activity

+
+
+ )} diff --git a/apps/web/app/(dashboard)/dashboards/page.tsx b/apps/web/app/(dashboard)/dashboards/page.tsx new file mode 100644 index 0000000..9f50048 --- /dev/null +++ b/apps/web/app/(dashboard)/dashboards/page.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + LayoutDashboard, + Plus, + Settings, + Trash2, + Copy, + Share2, + Loader2, + MoreHorizontal, + Clock, + User, +} from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +export default function DashboardsPage() { + const queryClient = useQueryClient(); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newDashboardName, setNewDashboardName] = useState(''); + const [newDashboardDescription, setNewDashboardDescription] = useState(''); + const [deleteDashboardId, setDeleteDashboardId] = useState(null); + + const { data: dashboardsData, isLoading } = useQuery({ + queryKey: ['dashboards'], + queryFn: () => api.dashboards.list(), + }); + + const createMutation = useMutation({ + mutationFn: (data: { name: string; description?: string }) => api.dashboards.create(data), + onSuccess: () => { + setShowCreateDialog(false); + setNewDashboardName(''); + setNewDashboardDescription(''); + queryClient.invalidateQueries({ queryKey: ['dashboards'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.dashboards.delete(id), + onSuccess: () => { + setDeleteDashboardId(null); + queryClient.invalidateQueries({ queryKey: ['dashboards'] }); + }, + }); + + const duplicateMutation = useMutation({ + mutationFn: async (id: string) => { + const original = await api.dashboards.get(id); + return api.dashboards.create({ + name: `${original.dashboard.name} (Copy)`, + description: original.dashboard.description, + config: original.dashboard.config, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['dashboards'] }); + }, + }); + + const handleCreate = () => { + if (!newDashboardName.trim()) return; + createMutation.mutate({ + name: newDashboardName, + description: newDashboardDescription || undefined, + }); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; + + return ( +
+
+
+

Dashboards

+

Create custom dashboards to visualize your NATS metrics

+
+ +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && (!dashboardsData?.dashboards || dashboardsData.dashboards.length === 0) && ( + + + +

No dashboards yet

+

+ Create your first custom dashboard to visualize NATS metrics +

+ +
+
+ )} + + {dashboardsData?.dashboards && dashboardsData.dashboards.length > 0 && ( +
+ {dashboardsData.dashboards.map((dashboard: any) => ( + + + +
+
+
+ +
+
+ {dashboard.name} + + {dashboard.description || 'No description'} + +
+
+
+
+ +
+
+ + Updated {formatDate(dashboard.updatedAt)} +
+
+ + {dashboard.isPublic ? 'Public' : 'Private'} +
+
+
+ +
+ + e.preventDefault()}> + + + + + + + Edit + + + { + e.preventDefault(); + duplicateMutation.mutate(dashboard.id); + }} + > + + Duplicate + + + + Share + + + { + e.preventDefault(); + setDeleteDashboardId(dashboard.id); + }} + > + + Delete + + + +
+
+ ))} +
+ )} + + {/* Create Dashboard Dialog */} + + + + Create New Dashboard + + Create a custom dashboard to visualize your NATS metrics + + +
+
+ + setNewDashboardName(e.target.value)} + /> +
+
+ + setNewDashboardDescription(e.target.value)} + /> +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + setDeleteDashboardId(null)}> + + + Delete Dashboard? + + This action cannot be undone. This will permanently delete the dashboard and all its widgets. + + + + Cancel + deleteDashboardId && deleteMutation.mutate(deleteDashboardId)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteMutation.isPending ? ( + + ) : ( + + )} + Delete + + + + +
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 59e1fb8..cfc77d6 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -113,6 +113,13 @@ function SettingsPageContent() { const [showApiKey, setShowApiKey] = useState(false); const [deleteApiKeyId, setDeleteApiKeyId] = useState(null); + // 2FA state + const [show2faDialog, setShow2faDialog] = useState(false); + const [mfaSetupData, setMfaSetupData] = useState<{ secret: string; qrCode: string } | null>(null); + const [mfaVerifyCode, setMfaVerifyCode] = useState(''); + const [mfaError, setMfaError] = useState(''); + const [showDisable2faDialog, setShowDisable2faDialog] = useState(false); + // Initialize form with user data useEffect(() => { if (user) { @@ -204,6 +211,56 @@ function SettingsPageContent() { }, }); + // 2FA mutations + const enable2faMutation = useMutation({ + mutationFn: () => api.mfa.enable(), + onSuccess: (data) => { + setMfaSetupData(data); + setShow2faDialog(true); + }, + onError: (err: any) => { + setMfaError(err.message || 'Failed to enable 2FA'); + }, + }); + + const verify2faMutation = useMutation({ + mutationFn: (code: string) => api.mfa.verify(code), + onSuccess: (data) => { + if (data.valid) { + setShow2faDialog(false); + setMfaSetupData(null); + setMfaVerifyCode(''); + setMfaError(''); + // Refresh user data to get updated mfaEnabled status + queryClient.invalidateQueries({ queryKey: ['user'] }); + // Update local user state + if (user) { + setUser({ ...user, mfaEnabled: true }); + } + } else { + setMfaError('Invalid verification code. Please try again.'); + } + }, + onError: (err: any) => { + setMfaError(err.message || 'Failed to verify code'); + }, + }); + + const disable2faMutation = useMutation({ + mutationFn: () => api.mfa.disable(), + onSuccess: () => { + setShowDisable2faDialog(false); + queryClient.invalidateQueries({ queryKey: ['user'] }); + // Update local user state + if (user) { + setUser({ ...user, mfaEnabled: false }); + } + }, + onError: (err: any) => { + setMfaError(err.message || 'Failed to disable 2FA'); + }, + }); + const handleProfileSubmit = (e: React.FormEvent) => { e.preventDefault(); profileMutation.mutate(profileForm); @@ -631,19 +688,129 @@ function SettingsPageContent() { : 'Two-factor authentication is not enabled'}

- + {user?.mfaEnabled ? ( + + ) : ( + + )}

- 2FA setup coming soon. This will allow you to use an authenticator app for additional security. + Use an authenticator app like Google Authenticator, Authy, or 1Password to generate verification codes.

+ {/* 2FA Setup Dialog */} + + + + Set Up Two-Factor Authentication + + Scan the QR code with your authenticator app, then enter the verification code. + + + {mfaSetupData && ( +
+
+ QR Code +
+
+

Or enter this secret manually:

+ + {mfaSetupData.secret} + +
+
+ + { + setMfaVerifyCode(e.target.value.replace(/\D/g, '')); + setMfaError(''); + }} + className="text-center text-lg tracking-widest" + /> +
+ {mfaError && ( +
+ {mfaError} +
+ )} +
+ )} + + + + +
+
+ + {/* Disable 2FA Confirmation Dialog */} + + + + Disable Two-Factor Authentication? + + This will remove the extra layer of security from your account. You can re-enable it at any time. + + + + Cancel + disable2faMutation.mutate()} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {disable2faMutation.isPending ? ( + + ) : null} + Disable 2FA + + + + + Active Sessions diff --git a/apps/web/app/(dashboard)/streams/[clusterId]/[name]/page.tsx b/apps/web/app/(dashboard)/streams/[clusterId]/[name]/page.tsx index 0b84eae..8888c4b 100644 --- a/apps/web/app/(dashboard)/streams/[clusterId]/[name]/page.tsx +++ b/apps/web/app/(dashboard)/streams/[clusterId]/[name]/page.tsx @@ -24,6 +24,12 @@ import { ChevronsRight, Maximize2, Minimize2, + Search, + Download, + RotateCcw, + Filter, + X, + Loader2, } from 'lucide-react'; import Link from 'next/link'; import { api } from '@/lib/api'; @@ -41,6 +47,21 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { formatBytes, formatNumber, formatDuration } from '@nats-console/shared'; import { LineChart } from '@/components/charts'; import { CreateStreamDialog } from '@/components/forms/create-stream-dialog'; @@ -75,6 +96,23 @@ function StreamDetailContent() { // Metrics time range const [metricsTimeRange, setMetricsTimeRange] = useState('1h'); + // Search/Filter state + const [showFilters, setShowFilters] = useState(false); + const [subjectFilter, setSubjectFilter] = useState(''); + const [activeSubjectFilter, setActiveSubjectFilter] = useState(''); + + // Export dialog state + const [showExportDialog, setShowExportDialog] = useState(false); + const [exportFormat, setExportFormat] = useState<'json' | 'csv'>('json'); + const [exportLimit, setExportLimit] = useState('1000'); + + // Replay dialog state + const [showReplayDialog, setShowReplayDialog] = useState(false); + const [replayTargetSubject, setReplayTargetSubject] = useState(''); + const [replayStartSeq, setReplayStartSeq] = useState(''); + const [replayEndSeq, setReplayEndSeq] = useState(''); + const [replayLimit, setReplayLimit] = useState('100'); + // Helper to format message data - detect JSON and pretty print const formatMessageData = (data: unknown): { formatted: string; isJson: boolean } => { if (typeof data === 'object' && data !== null) { @@ -136,15 +174,65 @@ function StreamDetailContent() { }); const { data: messagesData, refetch: refetchMessages, isFetching: isLoadingMessages } = useQuery({ - queryKey: ['messages', clusterId, streamName, currentPage, pageSize], + queryKey: ['messages', clusterId, streamName, currentPage, pageSize, activeSubjectFilter], queryFn: () => { const firstSeq = streamData?.stream?.state?.first_seq || 1; const startSeq = String(firstSeq + (currentPage - 1) * pageSize); - return api.streams.messages(clusterId, streamName, { start_seq: startSeq, limit: String(pageSize) }); + const params: Record = { start_seq: startSeq, limit: String(pageSize) }; + if (activeSubjectFilter) { + params.subject = activeSubjectFilter; + } + return api.streams.messages(clusterId, streamName, params); }, enabled: activeTab === 'messages' && !!streamData?.stream, }); + // Replay mutation + const replayMutation = useMutation({ + mutationFn: (data: { targetSubject: string; startSeq?: number; endSeq?: number; limit?: number }) => + api.streams.replayMessages(clusterId, streamName, data), + onSuccess: () => { + setShowReplayDialog(false); + setReplayTargetSubject(''); + setReplayStartSeq(''); + setReplayEndSeq(''); + }, + }); + + // Apply subject filter + const applyFilter = () => { + setActiveSubjectFilter(subjectFilter); + setCurrentPage(1); + }; + + // Clear filters + const clearFilters = () => { + setSubjectFilter(''); + setActiveSubjectFilter(''); + setCurrentPage(1); + }; + + // Handle export + const handleExport = () => { + const url = api.streams.exportMessages(clusterId, streamName, exportFormat, { + limit: parseInt(exportLimit), + subject: activeSubjectFilter || undefined, + }); + window.open(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}${url}`, '_blank'); + setShowExportDialog(false); + }; + + // Handle replay + const handleReplay = () => { + if (!replayTargetSubject) return; + replayMutation.mutate({ + targetSubject: replayTargetSubject, + startSeq: replayStartSeq ? parseInt(replayStartSeq) : undefined, + endSeq: replayEndSeq ? parseInt(replayEndSeq) : undefined, + limit: parseInt(replayLimit), + }); + }; + // Metrics data const getTimeRangeParams = () => { const now = new Date(); @@ -316,6 +404,148 @@ function StreamDetailContent() { mode="edit" /> + {/* Export Dialog */} + + + + Export Messages + + Export messages from this stream in JSON or CSV format + + +
+
+ + +
+
+ + +
+ {activeSubjectFilter && ( +
+ Filter applied: + {activeSubjectFilter} +
+ )} +
+ + + + +
+
+ + {/* Replay Dialog */} + + + + Replay Messages + + Replay messages from this stream to another subject + + +
+
+ + setReplayTargetSubject(e.target.value)} + /> +

+ Messages will be published to this subject +

+
+
+
+ + setReplayStartSeq(e.target.value)} + /> +
+
+ + setReplayEndSeq(e.target.value)} + /> +
+
+
+ + +
+ {replayMutation.isSuccess && ( +
+ Successfully replayed {replayMutation.data?.replayed} of {replayMutation.data?.total} messages +
+ )} + {replayMutation.isError && ( +
+ Failed to replay messages: {(replayMutation.error as Error).message} +
+ )} +
+ + + + +
+
+ {/* Tabs */} @@ -451,52 +681,113 @@ function StreamDetailContent() { {/* Message Browser */} - -
- Message Browser - - {totalMessages > 0 ? `${formatNumber(totalMessages)} messages total` : 'Browse messages in this stream'} - -
-
- - -
- - + +
+
+ Message Browser + + {totalMessages > 0 ? `${formatNumber(totalMessages)} messages total` : 'Browse messages in this stream'} + {activeSubjectFilter && ( + + (filtered by: {activeSubjectFilter}) + + )} + +
+
+ + + +
+ + +
+ + +
+ + {/* Filters Panel */} + {showFilters && ( +
+
+ +
+ setSubjectFilter(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && applyFilter()} + /> + + {activeSubjectFilter && ( + + )} +
+
+
+ )} {isLoadingMessages && !messagesData?.messages?.length ? ( diff --git a/apps/web/components/charts/index.tsx b/apps/web/components/charts/index.tsx index 87becde..1726eca 100644 --- a/apps/web/components/charts/index.tsx +++ b/apps/web/components/charts/index.tsx @@ -1,3 +1,4 @@ export { LineChart } from './line-chart'; export { BarChart } from './bar-chart'; export { GaugeChart } from './gauge-chart'; +export { MultiLineChart } from './multi-line-chart'; diff --git a/apps/web/components/charts/multi-line-chart.tsx b/apps/web/components/charts/multi-line-chart.tsx new file mode 100644 index 0000000..0f456e3 --- /dev/null +++ b/apps/web/components/charts/multi-line-chart.tsx @@ -0,0 +1,115 @@ +'use client'; + +import ReactECharts from 'echarts-for-react'; +import type { EChartsOption } from 'echarts'; +import { BarChart3 } from 'lucide-react'; + +interface MultiLineChartProps { + series: Record>; + title?: string; + yAxisLabel?: string; + height?: number; + showArea?: boolean; +} + +const COLORS = [ + '#2563eb', // blue + '#16a34a', // green + '#dc2626', // red + '#ca8a04', // yellow + '#9333ea', // purple + '#0891b2', // cyan + '#ea580c', // orange + '#84cc16', // lime +]; + +export function MultiLineChart({ + series, + title, + yAxisLabel = '', + height = 300, + showArea = false, +}: MultiLineChartProps) { + const seriesNames = Object.keys(series); + + // Get all unique timestamps + const allTimes = new Set(); + for (const data of Object.values(series)) { + for (const item of data) { + allTimes.add(item.time); + } + } + const timeLabels = Array.from(allTimes).sort(); + + const chartSeries = seriesNames.map((name, index) => { + const data = series[name]; + const color = COLORS[index % COLORS.length]; + + // Map values to time labels + const values = timeLabels.map((time) => { + const item = data.find((d) => d.time === time); + return item?.value ?? 0; + }); + + return { + name, + type: 'line' as const, + smooth: true, + symbol: 'none', + sampling: 'lttb' as const, + itemStyle: { color }, + lineStyle: { width: 2 }, + areaStyle: showArea ? { color: `${color}20` } : undefined, + data: values, + }; + }); + + const option: EChartsOption = { + title: title ? { text: title, left: 'center', textStyle: { fontSize: 14 } } : undefined, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + }, + legend: { + data: seriesNames, + bottom: 0, + type: 'scroll', + }, + grid: { + left: '3%', + right: '4%', + bottom: seriesNames.length > 0 ? '15%' : '3%', + containLabel: true, + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: timeLabels, + axisLine: { lineStyle: { color: '#e5e7eb' } }, + axisLabel: { color: '#6b7280', fontSize: 11 }, + }, + yAxis: { + type: 'value', + name: yAxisLabel, + nameTextStyle: { color: '#6b7280' }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: '#f3f4f6' } }, + axisLabel: { color: '#6b7280', fontSize: 11 }, + }, + series: chartSeries, + }; + + if (seriesNames.length === 0) { + return ( +
+
+ +

No data available

+
+
+ ); + } + + return ; +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 7cb065c..c9d4e73 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -196,6 +196,29 @@ export const streams = { method: 'POST', body: data, }), + + exportMessages: ( + clusterId: string, + name: string, + format: 'json' | 'csv' = 'json', + options?: { startSeq?: number; limit?: number; subject?: string } + ) => { + const params = new URLSearchParams({ format }); + if (options?.startSeq) params.set('start_seq', String(options.startSeq)); + if (options?.limit) params.set('limit', String(options.limit)); + if (options?.subject) params.set('subject', options.subject); + return `/clusters/${clusterId}/streams/${name}/messages/export?${params}`; + }, + + replayMessages: ( + clusterId: string, + name: string, + data: { targetSubject: string; startSeq?: number; endSeq?: number; limit?: number } + ) => + request<{ replayed: number; total: number; errors?: Array<{ sequence: number; error: string }> }>( + `/clusters/${clusterId}/streams/${name}/messages/replay`, + { method: 'POST', body: data } + ), }; // Consumers API @@ -267,6 +290,23 @@ export const analytics = { const query = clusterId ? `?clusterId=${clusterId}` : ''; return request(`/analytics/cluster/overview${query}`); }, + + chartThroughput: (clusterId: string, timeRange: string = '24h') => + request<{ + data: Array<{ name: string; value: number; time: string }>; + interval: string; + }>(`/analytics/charts/throughput?clusterId=${clusterId}&timeRange=${timeRange}`), + + chartConsumerLag: (clusterId: string, timeRange: string = '24h') => + request<{ + data: Array<{ name: string; value: number }>; + }>(`/analytics/charts/consumer-lag?clusterId=${clusterId}&timeRange=${timeRange}`), + + chartStreamActivity: (clusterId: string, timeRange: string = '24h') => + request<{ + streams: Record>; + interval: string; + }>(`/analytics/charts/stream-activity?clusterId=${clusterId}&timeRange=${timeRange}`), }; // Alerts API diff --git a/apps/workers/src/index.ts b/apps/workers/src/index.ts index 5ca36a4..cae4d32 100644 --- a/apps/workers/src/index.ts +++ b/apps/workers/src/index.ts @@ -3,6 +3,7 @@ import pino from 'pino'; import cron from 'node-cron'; import { MetricsCollector } from './collectors/metrics'; import { AlertProcessor } from './processors/alerts'; +import { CleanupProcessor } from './processors/cleanup'; import { config } from './config'; const logger = pino({ @@ -22,6 +23,7 @@ const logger = pino({ const metricsCollector = new MetricsCollector(); const alertProcessor = new AlertProcessor(); +const cleanupProcessor = new CleanupProcessor(); // Health check server const healthServer = createServer((req, res) => { @@ -48,6 +50,7 @@ async function shutdown(signal: string): Promise { healthServer.close(); await metricsCollector.stop(); await alertProcessor.stop(); + await cleanupProcessor.close(); logger.info('Shutdown complete'); process.exit(0); @@ -73,12 +76,15 @@ async function start(): Promise { await metricsCollector.refreshConnections(); }); - // Schedule cleanup job daily at 2 AM - cron.schedule('0 2 * * *', async () => { + // Schedule cleanup job every 6 hours + cron.schedule('0 */6 * * *', async () => { logger.info('Running cleanup job...'); - // TODO: Implement cleanup (expired sessions, old data archival) + await cleanupProcessor.runCleanup(); }); + // Run initial cleanup on startup + await cleanupProcessor.runCleanup(); + // Start health server healthServer.listen(config.PORT, () => { logger.info(`Health server listening on http://localhost:${config.PORT}/health`); diff --git a/apps/workers/src/processors/cleanup.ts b/apps/workers/src/processors/cleanup.ts new file mode 100644 index 0000000..8b43c9b --- /dev/null +++ b/apps/workers/src/processors/cleanup.ts @@ -0,0 +1,202 @@ +import Redis from 'ioredis'; +import pino from 'pino'; +import { config } from '../config'; + +const logger = pino({ name: 'cleanup-processor' }); + +// Session cleanup settings +const SESSION_PREFIX = 'session:'; +const SESSION_INACTIVITY_THRESHOLD = 24 * 60 * 60 * 1000; // 24 hours + +// Password reset token prefix +const PASSWORD_RESET_PREFIX = 'password_reset:'; + +// Rate limit prefix +const RATE_LIMIT_PREFIX = 'ratelimit:'; + +export class CleanupProcessor { + private redis: Redis; + private running = false; + + constructor() { + this.redis = new Redis(config.REDIS_URL, { + maxRetriesPerRequest: 3, + retryStrategy(times) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + }); + + this.redis.on('error', (err) => { + logger.error({ err }, 'Redis connection error'); + }); + } + + async runCleanup(): Promise { + if (this.running) { + logger.warn('Cleanup already running, skipping'); + return; + } + + this.running = true; + logger.info('Starting cleanup job...'); + + try { + const results = await Promise.all([ + this.cleanupInactiveSessions(), + this.cleanupExpiredPasswordResets(), + this.cleanupOldRateLimitData(), + ]); + + const [sessions, resets, rateLimits] = results; + + logger.info( + { + sessionsDeleted: sessions, + passwordResetsDeleted: resets, + rateLimitsDeleted: rateLimits, + }, + 'Cleanup job completed' + ); + } catch (err) { + logger.error({ err }, 'Cleanup job failed'); + } finally { + this.running = false; + } + } + + private async cleanupInactiveSessions(): Promise { + const now = Date.now(); + let deletedCount = 0; + let cursor = '0'; + + try { + do { + // Scan for session keys + const [nextCursor, keys] = await this.redis.scan( + cursor, + 'MATCH', + `${SESSION_PREFIX}*`, + 'COUNT', + 100 + ); + cursor = nextCursor; + + for (const key of keys) { + try { + const lastActivity = await this.redis.hget(key, 'lastActivity'); + if (lastActivity) { + const lastActivityTime = new Date(lastActivity).getTime(); + if (now - lastActivityTime > SESSION_INACTIVITY_THRESHOLD) { + await this.redis.del(key); + deletedCount++; + logger.debug({ key }, 'Deleted inactive session'); + } + } + } catch (err) { + logger.error({ err, key }, 'Error processing session key'); + } + } + } while (cursor !== '0'); + + logger.info({ count: deletedCount }, 'Inactive sessions cleaned up'); + return deletedCount; + } catch (err) { + logger.error({ err }, 'Error scanning sessions'); + return deletedCount; + } + } + + private async cleanupExpiredPasswordResets(): Promise { + let deletedCount = 0; + let cursor = '0'; + + try { + do { + // Scan for password reset keys + const [nextCursor, keys] = await this.redis.scan( + cursor, + 'MATCH', + `${PASSWORD_RESET_PREFIX}*`, + 'COUNT', + 100 + ); + cursor = nextCursor; + + for (const key of keys) { + try { + // Check if key still exists (may have been expired) + const ttl = await this.redis.ttl(key); + if (ttl === -2 || ttl === -1) { + // Key doesn't exist or has no TTL, delete it + await this.redis.del(key); + deletedCount++; + logger.debug({ key }, 'Deleted expired password reset token'); + } + } catch (err) { + logger.error({ err, key }, 'Error processing password reset key'); + } + } + } while (cursor !== '0'); + + logger.info({ count: deletedCount }, 'Expired password resets cleaned up'); + return deletedCount; + } catch (err) { + logger.error({ err }, 'Error scanning password resets'); + return deletedCount; + } + } + + private async cleanupOldRateLimitData(): Promise { + let deletedCount = 0; + let cursor = '0'; + const now = Date.now(); + // Rate limit window - clean up entries older than 1 hour + const cleanupThreshold = now - 60 * 60 * 1000; + + try { + do { + // Scan for rate limit keys + const [nextCursor, keys] = await this.redis.scan( + cursor, + 'MATCH', + `${RATE_LIMIT_PREFIX}*`, + 'COUNT', + 100 + ); + cursor = nextCursor; + + for (const key of keys) { + try { + // Remove old entries from sorted set + const removed = await this.redis.zremrangebyscore(key, 0, cleanupThreshold); + if (removed > 0) { + deletedCount += removed; + logger.debug({ key, removed }, 'Removed old rate limit entries'); + } + + // If the sorted set is now empty, delete the key + const count = await this.redis.zcard(key); + if (count === 0) { + await this.redis.del(key); + logger.debug({ key }, 'Deleted empty rate limit key'); + } + } catch (err) { + logger.error({ err, key }, 'Error processing rate limit key'); + } + } + } while (cursor !== '0'); + + logger.info({ count: deletedCount }, 'Old rate limit entries cleaned up'); + return deletedCount; + } catch (err) { + logger.error({ err }, 'Error scanning rate limits'); + return deletedCount; + } + } + + async close(): Promise { + await this.redis.quit(); + logger.info('Cleanup processor closed'); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3798222..91f4498 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,12 +56,18 @@ importers: nats: specifier: ^2.28.2 version: 2.29.3 + otpauth: + specifier: ^9.3.5 + version: 9.4.1 pino: specifier: ^9.5.0 version: 9.14.0 pino-pretty: specifier: ^13.0.0 version: 13.1.3 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 zod: specifier: ^3.23.8 version: 3.25.76 @@ -826,6 +832,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1521,6 +1531,14 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1596,6 +1614,10 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001759: resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} @@ -1624,6 +1646,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1632,6 +1657,13 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -1674,6 +1706,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1710,6 +1746,9 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -1738,6 +1777,9 @@ packages: electron-to-chromium@1.5.264: resolution: {integrity: sha512-1tEf0nLgltC3iy9wtlYDlQDc5Rg9lEKVjEmIHJ21rI9OcqkvD45K1oyNIRA4rR1z3LgJ7KeGzEBojVcV6m4qjA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -1841,6 +1883,10 @@ packages: resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} engines: {node: '>=20'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1856,6 +1902,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -1913,6 +1963,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1952,6 +2006,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2093,6 +2151,25 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + otpauth@9.4.1: + resolution: {integrity: sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -2152,6 +2229,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -2225,6 +2306,11 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2306,10 +2392,17 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2361,6 +2454,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -2407,9 +2503,17 @@ packages: stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -2663,11 +2767,18 @@ packages: jsdom: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2687,6 +2798,17 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3097,6 +3219,8 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.7': optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3755,6 +3879,12 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -3837,6 +3967,8 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001759: {} chai@5.3.3: @@ -3875,10 +4007,22 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} cluster-key-slot@1.1.2: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + colorette@2.0.20: {} commander@4.1.1: {} @@ -3901,6 +4045,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-eql@5.0.2: {} deepmerge-ts@7.1.5: {} @@ -3922,6 +4068,8 @@ snapshots: didyoumean@1.2.2: {} + dijkstrajs@1.0.3: {} + dlv@1.1.3: {} dotenv@16.6.1: {} @@ -3956,6 +4104,8 @@ snapshots: electron-to-chromium@1.5.264: {} + emoji-regex@8.0.0: {} + empathic@2.0.0: {} end-of-stream@1.4.5: @@ -4123,6 +4273,11 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.0.0 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -4132,6 +4287,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-nonce@1.0.1: {} get-tsconfig@4.13.0: @@ -4199,6 +4356,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -4229,6 +4388,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} @@ -4349,6 +4512,22 @@ snapshots: dependencies: wrappy: 1.0.2 + otpauth@9.4.1: + dependencies: + '@noble/hashes': 1.8.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + path-exists@4.0.0: {} + path-parse@1.0.7: {} pathe@1.1.2: {} @@ -4429,6 +4608,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pngjs@5.0.0: {} + postcss-import@15.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -4493,6 +4674,12 @@ snapshots: pure-rand@6.1.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -4564,8 +4751,12 @@ snapshots: dependencies: redis-errors: 1.2.0 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-pkg-maps@1.0.0: {} resolve@1.22.11: @@ -4628,6 +4819,8 @@ snapshots: semver@7.7.3: {} + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} setprototypeof@1.2.0: {} @@ -4694,10 +4887,20 @@ snapshots: stream-shift@1.0.3: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-json-comments@5.0.3: {} styled-jsx@5.1.6(react@19.2.1): @@ -4933,17 +5136,46 @@ snapshots: - supports-color - terser + which-module@2.0.1: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@8.18.3: {} xtend@4.0.2: {} + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + zod@3.25.76: {} zrender@5.6.1: