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:
Z User committed 2026-10-29 10:46:49 +00:00
1 parent 985137b802
commit db8e365932
4 files changed
+83

No files matched your search

+12
View File
@@ -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,
+14
View File
@@ -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'
+22
View File
@@ -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'
+35
View File
@@ -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()