feat: global API rate limiting + webhook dispatcher wired to key events
Rate limiting (middleware-level): - 120 requests/minute per IP for all /api/* routes - Returns 429 with retry-after header when exceeded - In-memory store (per-container, sufficient for single-instance) Webhook dispatcher wired to events: - license.created → fires when a new license is issued - ticket.created → fires when a support ticket is created - instance.provisioned → fires when a tenant instance goes live - Each event includes relevant payload (key, product, URL, etc.) - Fire-and-forget (doesn't block the response) - HMAC-SHA256 signed (existing webhook.ts implementation) Also: - Ticket creation now sends email notification (sendTicketCreatedEmail)
This commit is contained in:
1 parent
985137b802
commit
db8e365932
4 files changed
+83
No files matched your search
@@ -321,6 +321,18 @@ export async function POST(req: Request) {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch webhook event
|
||||||
|
const { dispatchWebhook } = await import('@/lib/webhook')
|
||||||
|
dispatchWebhook('instance.provisioned', {
|
||||||
|
instanceId: instance.id,
|
||||||
|
subdomain,
|
||||||
|
url: result.url,
|
||||||
|
product,
|
||||||
|
companyId: license.companyId,
|
||||||
|
status: isDemo ? 'demo_active' : 'active',
|
||||||
|
demoExpiresAt: instance.demoExpiresAt,
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
instanceId: instance.id,
|
instanceId: instance.id,
|
||||||
subdomain,
|
subdomain,
|
||||||
|
|||||||
@@ -316,6 +316,20 @@ export async function POST(req: Request) {
|
|||||||
}).catch(() => {}) // fire-and-forget, don't block response
|
}).catch(() => {}) // fire-and-forget, don't block response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch webhook event
|
||||||
|
const { dispatchWebhook } = await import('@/lib/webhook')
|
||||||
|
dispatchWebhook('license.created', {
|
||||||
|
licenseId: license.id,
|
||||||
|
key,
|
||||||
|
product,
|
||||||
|
plan,
|
||||||
|
companyId: license.companyId,
|
||||||
|
companyName: company.name,
|
||||||
|
environment,
|
||||||
|
billingCycle,
|
||||||
|
netPriceCents: pricing.netPriceCents,
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
return NextResponse.json({ license, pricing: pricing.breakdown }, { status: 201 })
|
return NextResponse.json({ license, pricing: pricing.breakdown }, { status: 201 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
|||||||
@@ -159,6 +159,28 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Send ticket created email
|
||||||
|
const { sendTicketCreatedEmail } = await import('@/lib/email')
|
||||||
|
if (company.email) {
|
||||||
|
sendTicketCreatedEmail({
|
||||||
|
to: company.email,
|
||||||
|
ticketNumber: ticket.number,
|
||||||
|
subject: ticket.subject,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch webhook event
|
||||||
|
const { dispatchWebhook } = await import('@/lib/webhook')
|
||||||
|
dispatchWebhook('ticket.created', {
|
||||||
|
ticketId: ticket.id,
|
||||||
|
number: ticket.number,
|
||||||
|
subject: ticket.subject,
|
||||||
|
companyId,
|
||||||
|
priority,
|
||||||
|
routedToAgency,
|
||||||
|
agencyId,
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
return NextResponse.json({ ticket }, { status: 201 })
|
return NextResponse.json({ ticket }, { status: 201 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
|||||||
@@ -3,6 +3,30 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
// Middleware: protects all routes except public ones.
|
// Middleware: protects all routes except public ones.
|
||||||
// Uses a simple session cookie (HMAC-signed) for auth.
|
// Uses a simple session cookie (HMAC-signed) for auth.
|
||||||
// Password is set via ADMIN_PASSWORD env var.
|
// Password is set via ADMIN_PASSWORD env var.
|
||||||
|
// Also applies global rate limiting to API routes.
|
||||||
|
|
||||||
|
// --- Simple in-memory rate limiter (per-IP, per-minute) ---
|
||||||
|
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
|
||||||
|
const RATE_LIMIT_MAX = 120 // 120 requests per minute per IP
|
||||||
|
const RATE_LIMIT_WINDOW = 60_000 // 1 minute
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = rateLimitStore.get(ip)
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
rateLimitStore.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (entry.count >= RATE_LIMIT_MAX) return false
|
||||||
|
entry.count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientIp(req: NextRequest): string {
|
||||||
|
return req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||||
|
?? req.headers.get('x-real-ip')
|
||||||
|
?? 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
const PUBLIC_PATHS = [
|
const PUBLIC_PATHS = [
|
||||||
'/api/health',
|
'/api/health',
|
||||||
@@ -91,6 +115,17 @@ async function verifySession(token: string | undefined): Promise<boolean> {
|
|||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl
|
const { pathname } = req.nextUrl
|
||||||
|
|
||||||
|
// --- Global rate limiting for API routes ---
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'rate_limit_exceeded', message: 'Too many requests. Try again in a minute.' },
|
||||||
|
{ status: 429, headers: { 'retry-after': '60' } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Allow public paths
|
// Allow public paths
|
||||||
if (isPublic(pathname)) {
|
if (isPublic(pathname)) {
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
|
|||||||
Reference in new issue
Block a user