nyk
feat: phase 1 workspace isolation across auth and core APIs (#112)
4e0867d unverified
Raw
History Blame Contribute Delete
13.1 kB
import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth'
import { getDatabase } from '@/lib/db'
import { mutationLimiter } from '@/lib/rate-limit'
import { createAlertSchema, validateBody } from '@/lib/validation'
interface AlertRule {
id: number
name: string
description: string | null
enabled: number
entity_type: string
condition_field: string
condition_operator: string
condition_value: string
action_type: string
action_config: string
cooldown_minutes: number
last_triggered_at: number | null
trigger_count: number
created_by: string
created_at: number
updated_at: number
}
/**
* GET /api/alerts - List all alert rules
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
try {
const rules = db
.prepare('SELECT * FROM alert_rules WHERE workspace_id = ? ORDER BY created_at DESC')
.all(workspaceId) as AlertRule[]
return NextResponse.json({ rules })
} catch {
return NextResponse.json({ rules: [] })
}
}
/**
* POST /api/alerts - Create a new alert rule or evaluate rules
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
// Check for evaluate action first (peek at body without consuming)
let rawBody: any
try { rawBody = await request.json() } catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
if (rawBody.action === 'evaluate') {
return evaluateRules(db, workspaceId)
}
// Validate for create using schema
const parseResult = createAlertSchema.safeParse(rawBody)
if (!parseResult.success) {
const messages = parseResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
return NextResponse.json({ error: 'Validation failed', details: messages }, { status: 400 })
}
// Create new rule
const { name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes } = parseResult.data
try {
const result = db.prepare(`
INSERT INTO alert_rules (name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes, created_by, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
name,
description || null,
entity_type,
condition_field,
condition_operator,
condition_value,
action_type || 'notification',
JSON.stringify(action_config || {}),
cooldown_minutes || 60,
auth.user?.username || 'system',
workspaceId
)
// Audit log
try {
db.prepare('INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)').run(
'alert_rule_created',
auth.user?.username || 'system',
`Created alert rule: ${name}`
)
} catch { /* audit table might not exist */ }
const rule = db
.prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
.get(result.lastInsertRowid, workspaceId) as AlertRule
return NextResponse.json({ rule }, { status: 201 })
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to create rule' }, { status: 500 })
}
}
/**
* PUT /api/alerts - Update an alert rule
*/
export async function PUT(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const { id, ...updates } = body
if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
const existing = db
.prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
.get(id, workspaceId) as AlertRule | undefined
if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
const allowed = ['name', 'description', 'enabled', 'entity_type', 'condition_field', 'condition_operator', 'condition_value', 'action_type', 'action_config', 'cooldown_minutes']
const sets: string[] = []
const values: any[] = []
for (const key of allowed) {
if (key in updates) {
sets.push(`${key} = ?`)
values.push(key === 'action_config' ? JSON.stringify(updates[key]) : updates[key])
}
}
if (sets.length === 0) return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
sets.push('updated_at = (unixepoch())')
values.push(id, workspaceId)
db.prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...values)
const updated = db
.prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
.get(id, workspaceId) as AlertRule
return NextResponse.json({ rule: updated })
}
/**
* DELETE /api/alerts - Delete an alert rule
*/
export async function DELETE(request: NextRequest) {
const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const { id } = body
if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
const result = db.prepare('DELETE FROM alert_rules WHERE id = ? AND workspace_id = ?').run(id, workspaceId)
try {
db.prepare('INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)').run(
'alert_rule_deleted',
auth.user?.username || 'system',
`Deleted alert rule #${id}`
)
} catch { /* audit table might not exist */ }
return NextResponse.json({ deleted: result.changes > 0 })
}
/**
* Evaluate all enabled alert rules against current data
*/
function evaluateRules(db: ReturnType<typeof getDatabase>, workspaceId: number) {
let rules: AlertRule[]
try {
rules = db.prepare('SELECT * FROM alert_rules WHERE enabled = 1 AND workspace_id = ?').all(workspaceId) as AlertRule[]
} catch {
return NextResponse.json({ evaluated: 0, triggered: 0, results: [] })
}
const now = Math.floor(Date.now() / 1000)
const results: { rule_id: number; rule_name: string; triggered: boolean; reason?: string }[] = []
for (const rule of rules) {
// Check cooldown
if (rule.last_triggered_at && (now - rule.last_triggered_at) < rule.cooldown_minutes * 60) {
results.push({ rule_id: rule.id, rule_name: rule.name, triggered: false, reason: 'In cooldown' })
continue
}
const triggered = evaluateRule(db, rule, now, workspaceId)
results.push({ rule_id: rule.id, rule_name: rule.name, triggered, reason: triggered ? 'Condition met' : 'Condition not met' })
if (triggered) {
// Update trigger tracking
db.prepare('UPDATE alert_rules SET last_triggered_at = ?, trigger_count = trigger_count + 1 WHERE id = ?').run(now, rule.id)
// Create notification
try {
const config = JSON.parse(rule.action_config || '{}')
const recipient = config.recipient || 'system'
db.prepare(`
INSERT INTO notifications (recipient, type, title, message, source_type, source_id, workspace_id)
VALUES (?, 'alert', ?, ?, 'alert_rule', ?, ?)
`).run(recipient, `Alert: ${rule.name}`, rule.description || `Rule "${rule.name}" triggered`, rule.id, workspaceId)
} catch { /* notification creation failed */ }
}
}
const triggered = results.filter(r => r.triggered).length
return NextResponse.json({ evaluated: rules.length, triggered, results })
}
function evaluateRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
try {
switch (rule.entity_type) {
case 'agent': return evaluateAgentRule(db, rule, now, workspaceId)
case 'task': return evaluateTaskRule(db, rule, now, workspaceId)
case 'session': return evaluateSessionRule(db, rule, now, workspaceId)
case 'activity': return evaluateActivityRule(db, rule, now, workspaceId)
default: return false
}
} catch {
return false
}
}
function evaluateAgentRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
const { condition_field, condition_operator, condition_value } = rule
if (condition_operator === 'count_above' || condition_operator === 'count_below') {
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND ${safeColumn('agents', condition_field)} = ?`).get(workspaceId, condition_value) as any)?.c || 0
return condition_operator === 'count_above' ? count > parseInt(condition_value) : count < parseInt(condition_value)
}
if (condition_operator === 'age_minutes_above') {
// Check agents where field value is older than N minutes (e.g., last_seen)
const threshold = now - parseInt(condition_value) * 60
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND status != 'offline' AND ${safeColumn('agents', condition_field)} < ?`).get(workspaceId, threshold) as any)?.c || 0
return count > 0
}
const agents = db.prepare(`SELECT ${safeColumn('agents', condition_field)} as val FROM agents WHERE workspace_id = ? AND status != 'offline'`).all(workspaceId) as any[]
return agents.some(a => compareValue(a.val, condition_operator, condition_value))
}
function evaluateTaskRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number, workspaceId: number): boolean {
const { condition_field, condition_operator, condition_value } = rule
if (condition_operator === 'count_above') {
const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE workspace_id = ? AND ${safeColumn('tasks', condition_field)} = ?`).get(workspaceId, condition_value) as any)?.c || 0
return count > parseInt(condition_value)
}
if (condition_operator === 'count_below') {
const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE workspace_id = ?`).get(workspaceId) as any)?.c || 0
return count < parseInt(condition_value)
}
const tasks = db.prepare(`SELECT ${safeColumn('tasks', condition_field)} as val FROM tasks WHERE workspace_id = ?`).all(workspaceId) as any[]
return tasks.some(t => compareValue(t.val, condition_operator, condition_value))
}
function evaluateSessionRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number, workspaceId: number): boolean {
// Session data comes from the gateway, not the DB, so we check the agents table for session info
const { condition_operator, condition_value } = rule
if (condition_operator === 'count_above') {
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND status = 'busy'`).get(workspaceId) as any)?.c || 0
return count > parseInt(condition_value)
}
return false
}
function evaluateActivityRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
const { condition_field, condition_operator, condition_value } = rule
if (condition_operator === 'count_above') {
// Count activities in the last hour
const hourAgo = now - 3600
const count = (db.prepare(`SELECT COUNT(*) as c FROM activities WHERE workspace_id = ? AND created_at > ? AND ${safeColumn('activities', condition_field)} = ?`).get(workspaceId, hourAgo, condition_value) as any)?.c || 0
return count > parseInt(condition_value)
}
return false
}
function compareValue(actual: any, operator: string, expected: string): boolean {
if (actual == null) return false
const strActual = String(actual)
switch (operator) {
case 'equals': return strActual === expected
case 'not_equals': return strActual !== expected
case 'greater_than': return Number(actual) > Number(expected)
case 'less_than': return Number(actual) < Number(expected)
case 'contains': return strActual.toLowerCase().includes(expected.toLowerCase())
default: return false
}
}
// Whitelist of columns per table to prevent SQL injection
const SAFE_COLUMNS: Record<string, Set<string>> = {
agents: new Set(['status', 'role', 'name', 'last_seen', 'last_activity']),
tasks: new Set(['status', 'priority', 'assigned_to', 'title']),
activities: new Set(['type', 'actor', 'entity_type']),
}
function safeColumn(table: string, column: string): string {
if (SAFE_COLUMNS[table]?.has(column)) return column
return 'id' // fallback to safe column
}