feat: add Docker support, session controls, model catalog, API rate limiting

This commit is contained in:
Nyk 2026-02-27 20:56:02 +07:00
parent 4f92c22f32
commit 299faf50e3
34 changed files with 750 additions and 145 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
.git
.data
.next
.env
*.md
.github
ops
scripts

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM node:20-slim AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

12
docker-compose.yml Normal file
View File

@ -0,0 +1,12 @@
services:
mission-control:
build: .
ports:
- "3000:3000"
env_file: .env
volumes:
- mc-data:/app/.data
restart: unless-stopped
volumes:
mc-data:

View File

@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone',
turbopack: {}, turbopack: {},
// Security headers // Security headers
@ -25,6 +26,9 @@ const nextConfig = {
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Content-Security-Policy', value: csp }, { key: 'Content-Security-Policy', value: csp },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
...(process.env.MC_ENABLE_HSTS === '1' ? [
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }
] : []),
], ],
}, },
]; ];

View File

@ -24,6 +24,7 @@
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"next": "^16.1.6", "next": "^16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pino": "^10.3.1",
"postcss": "^8.5.2", "postcss": "^8.5.2",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
@ -33,6 +34,7 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"ws": "^8.19.0", "ws": "^8.19.0",
"zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
@ -47,6 +49,7 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"pino-pretty": "^13.1.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.5" "vitest": "^2.1.5"
}, },

View File

@ -32,6 +32,9 @@ importers:
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pino:
specifier: ^10.3.1
version: 10.3.1
postcss: postcss:
specifier: ^8.5.2 specifier: ^8.5.2
version: 8.5.6 version: 8.5.6
@ -59,6 +62,9 @@ importers:
ws: ws:
specifier: ^8.19.0 specifier: ^8.19.0
version: 8.19.0 version: 8.19.0
zod:
specifier: ^4.3.6
version: 4.3.6
zustand: zustand:
specifier: ^5.0.11 specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.13)(immer@11.1.3)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) version: 5.0.11(@types/react@19.2.13)(immer@11.1.3)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
@ -96,6 +102,9 @@ importers:
jsdom: jsdom:
specifier: ^26.0.0 specifier: ^26.0.0
version: 26.1.0 version: 26.1.0
pino-pretty:
specifier: ^13.1.3
version: 13.1.3
vite-tsconfig-paths: vite-tsconfig-paths:
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.9)) version: 5.1.4(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.9))
@ -677,6 +686,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@playwright/test@1.58.2': '@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1346,6 +1358,10 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
autoprefixer@10.4.24: autoprefixer@10.4.24:
resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -1470,6 +1486,9 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
commander@4.1.1: commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -1584,6 +1603,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
debug@3.2.7: debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies: peerDependencies:
@ -1852,6 +1874,9 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
fast-copy@4.0.2:
resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -1869,6 +1894,9 @@ packages:
fast-levenshtein@2.0.6: fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fastq@1.20.1: fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@ -2013,6 +2041,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
hermes-estree@0.25.1: hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@ -2202,6 +2233,10 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true hasBin: true
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -2425,6 +2460,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@ -2484,6 +2523,20 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
pino-abstract-transport@3.0.0:
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
pino-pretty@13.1.3:
resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
hasBin: true
pino-std-serializers@7.1.0:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
pino@10.3.1:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
pirates@4.0.7: pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -2566,6 +2619,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@ -2579,6 +2635,9 @@ packages:
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
rc@1.2.8: rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true hasBin: true
@ -2631,6 +2690,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
recharts@3.7.0: recharts@3.7.0:
resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2708,6 +2771,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@ -2718,6 +2785,9 @@ packages:
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@ -2776,10 +2846,17 @@ packages:
simple-get@4.0.1: simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@ -2835,6 +2912,10 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'} engines: {node: '>=8'}
strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
styled-jsx@5.1.6: styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@ -2886,6 +2967,10 @@ packages:
thenify@3.3.1: thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
thread-stream@4.0.0:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -3668,6 +3753,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@pinojs/redact@0.4.0': {}
'@playwright/test@1.58.2': '@playwright/test@1.58.2':
dependencies: dependencies:
playwright: 1.58.2 playwright: 1.58.2
@ -4392,6 +4479,8 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
atomic-sleep@1.0.0: {}
autoprefixer@10.4.24(postcss@8.5.6): autoprefixer@10.4.24(postcss@8.5.6):
dependencies: dependencies:
browserslist: 4.28.1 browserslist: 4.28.1
@ -4524,6 +4613,8 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
colorette@2.0.20: {}
commander@4.1.1: {} commander@4.1.1: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
@ -4636,6 +4727,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-data-view: 1.0.2 is-data-view: 1.0.2
dateformat@4.6.3: {}
debug@3.2.7: debug@3.2.7:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@ -5050,6 +5143,8 @@ snapshots:
expect-type@1.3.0: {} expect-type@1.3.0: {}
fast-copy@4.0.2: {}
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-glob@3.3.1: fast-glob@3.3.1:
@ -5072,6 +5167,8 @@ snapshots:
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
fast-safe-stringify@2.1.1: {}
fastq@1.20.1: fastq@1.20.1:
dependencies: dependencies:
reusify: 1.1.0 reusify: 1.1.0
@ -5206,6 +5303,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
help-me@5.0.0: {}
hermes-estree@0.25.1: {} hermes-estree@0.25.1: {}
hermes-parser@0.25.1: hermes-parser@0.25.1:
@ -5398,6 +5497,8 @@ snapshots:
jiti@1.21.7: {} jiti@1.21.7: {}
joycon@3.1.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
@ -5620,6 +5721,8 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
on-exit-leak-free@2.1.2: {}
once@1.4.0: once@1.4.0:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
@ -5673,6 +5776,42 @@ snapshots:
pify@2.3.0: {} pify@2.3.0: {}
pino-abstract-transport@3.0.0:
dependencies:
split2: 4.2.0
pino-pretty@13.1.3:
dependencies:
colorette: 2.0.20
dateformat: 4.6.3
fast-copy: 4.0.2
fast-safe-stringify: 2.1.1
help-me: 5.0.0
joycon: 3.1.1
minimist: 1.2.8
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pump: 3.0.3
secure-json-parse: 4.1.0
sonic-boom: 4.2.1
strip-json-comments: 5.0.3
pino-std-serializers@7.1.0: {}
pino@10.3.1:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pino-std-serializers: 7.1.0
process-warning: 5.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.1
thread-stream: 4.0.0
pirates@4.0.7: {} pirates@4.0.7: {}
playwright-core@1.58.2: {} playwright-core@1.58.2: {}
@ -5751,6 +5890,8 @@ snapshots:
ansi-styles: 5.2.0 ansi-styles: 5.2.0
react-is: 17.0.2 react-is: 17.0.2
process-warning@5.0.0: {}
prop-types@15.8.1: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@ -5766,6 +5907,8 @@ snapshots:
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
rc@1.2.8: rc@1.2.8:
dependencies: dependencies:
deep-extend: 0.6.0 deep-extend: 0.6.0
@ -5823,6 +5966,8 @@ snapshots:
dependencies: dependencies:
picomatch: 2.3.1 picomatch: 2.3.1
real-require@0.2.0: {}
recharts@3.7.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): recharts@3.7.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1):
dependencies: dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.13)(react@19.2.4)(redux@5.0.1))(react@19.2.4) '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.13)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
@ -5952,6 +6097,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-regex: 1.2.1 is-regex: 1.2.1
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
saxes@6.0.0: saxes@6.0.0:
@ -5960,6 +6107,8 @@ snapshots:
scheduler@0.27.0: {} scheduler@0.27.0: {}
secure-json-parse@4.1.0: {}
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.4: {} semver@7.7.4: {}
@ -6062,8 +6211,14 @@ snapshots:
once: 1.4.0 once: 1.4.0
simple-concat: 1.0.1 simple-concat: 1.0.1
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
split2@4.2.0: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
stackback@0.0.2: {} stackback@0.0.2: {}
@ -6139,6 +6294,8 @@ snapshots:
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
strip-json-comments@5.0.3: {}
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
dependencies: dependencies:
client-only: 0.0.1 client-only: 0.0.1
@ -6217,6 +6374,10 @@ snapshots:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
thread-stream@4.0.0:
dependencies:
real-require: 0.2.0
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}

View File

@ -38,7 +38,7 @@ async function handleActivitiesRequest(request: NextRequest) {
const type = searchParams.get('type'); const type = searchParams.get('type');
const actor = searchParams.get('actor'); const actor = searchParams.get('actor');
const entity_type = searchParams.get('entity_type'); const entity_type = searchParams.get('entity_type');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 500);
const offset = parseInt(searchParams.get('offset') || '0'); const offset = parseInt(searchParams.get('offset') || '0');
const since = searchParams.get('since'); // Unix timestamp for real-time updates const since = searchParams.get('since'); // Unix timestamp for real-time updates

View File

@ -5,6 +5,9 @@ import { getTemplate, buildAgentConfig } from '@/lib/agent-templates';
import { writeAgentToConfig } from '@/lib/agent-sync'; import { writeAgentToConfig } from '@/lib/agent-sync';
import { logAuditEvent } from '@/lib/db'; import { logAuditEvent } from '@/lib/db';
import { getUserFromRequest, requireRole } from '@/lib/auth'; import { getUserFromRequest, requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, createAgentSchema } from '@/lib/validation';
/** /**
* GET /api/agents - List all agents with optional filtering * GET /api/agents - List all agents with optional filtering
@ -95,7 +98,7 @@ export async function GET(request: NextRequest) {
limit limit
}); });
} catch (error) { } catch (error) {
console.error('GET /api/agents error:', error); logger.error({ err: error }, 'GET /api/agents error');
return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 }); return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 });
} }
} }
@ -107,9 +110,14 @@ export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator'); const auth = requireRole(request, 'operator');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const body = await request.json(); const validated = await validateBody(request, createAgentSchema);
if ('error' in validated) return validated.error;
const body = validated.data;
const { const {
name, name,
@ -125,16 +133,16 @@ export async function POST(request: NextRequest) {
// Resolve template if specified // Resolve template if specified
let finalRole = role; let finalRole = role;
let finalConfig = config; let finalConfig: Record<string, any> = config as Record<string, any>;
if (template) { if (template) {
const tpl = getTemplate(template); const tpl = getTemplate(template);
if (tpl) { if (tpl) {
const builtConfig = buildAgentConfig(tpl, gateway_config || {}); const builtConfig = buildAgentConfig(tpl, (gateway_config || {}) as any);
finalConfig = { ...builtConfig, ...config }; finalConfig = { ...builtConfig, ...finalConfig };
if (!finalRole) finalRole = tpl.config.identity?.theme || tpl.type; if (!finalRole) finalRole = tpl.config.identity?.theme || tpl.type;
} }
} else if (gateway_config) { } else if (gateway_config) {
finalConfig = { ...config, ...gateway_config }; finalConfig = { ...finalConfig, ...(gateway_config as Record<string, any>) };
} }
if (!name || !finalRole) { if (!name || !finalRole) {
@ -221,7 +229,7 @@ export async function POST(request: NextRequest) {
ip_address: ipAddress, ip_address: ipAddress,
}); });
} catch (gwErr: any) { } catch (gwErr: any) {
console.error('Gateway write-back failed:', gwErr); logger.error({ err: gwErr }, 'Gateway write-back failed');
return NextResponse.json({ return NextResponse.json({
agent: parsedAgent, agent: parsedAgent,
warning: `Agent created in MC but gateway write failed: ${gwErr.message}` warning: `Agent created in MC but gateway write failed: ${gwErr.message}`
@ -231,7 +239,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ agent: parsedAgent }, { status: 201 }); return NextResponse.json({ agent: parsedAgent }, { status: 201 });
} catch (error) { } catch (error) {
console.error('POST /api/agents error:', error); logger.error({ err: error }, 'POST /api/agents error');
return NextResponse.json({ error: 'Failed to create agent' }, { status: 500 }); return NextResponse.json({ error: 'Failed to create agent' }, { status: 500 });
} }
} }
@ -243,6 +251,9 @@ export async function PUT(request: NextRequest) {
const auth = requireRole(request, 'operator'); const auth = requireRole(request, 'operator');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const body = await request.json(); const body = await request.json();
@ -343,7 +354,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Agent name is required' }, { status: 400 }); return NextResponse.json({ error: 'Agent name is required' }, { status: 400 });
} }
} catch (error) { } catch (error) {
console.error('PUT /api/agents error:', error); logger.error({ err: error }, 'PUT /api/agents error');
return NextResponse.json({ error: 'Failed to update agent' }, { status: 500 }); return NextResponse.json({ error: 'Failed to update agent' }, { status: 500 });
} }
} }

View File

@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
import { getDatabase } from '@/lib/db' import { getDatabase } from '@/lib/db'
import { mutationLimiter } from '@/lib/rate-limit'
import { createAlertSchema } from '@/lib/validation'
interface AlertRule { interface AlertRule {
id: number id: number
@ -44,6 +46,9 @@ export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator') const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) 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 db = getDatabase()
const body = await request.json() const body = await request.json()
@ -52,22 +57,15 @@ export async function POST(request: NextRequest) {
return evaluateRules(db) return evaluateRules(db)
} }
// Validate for create
const parseResult = createAlertSchema.safeParse(body)
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 // Create new rule
const { name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes } = body const { name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes } = parseResult.data
if (!name || !entity_type || !condition_field || !condition_operator || !condition_value) {
return NextResponse.json({ error: 'Missing required fields: name, entity_type, condition_field, condition_operator, condition_value' }, { status: 400 })
}
const validEntities = ['agent', 'task', 'session', 'activity']
if (!validEntities.includes(entity_type)) {
return NextResponse.json({ error: `entity_type must be one of: ${validEntities.join(', ')}` }, { status: 400 })
}
const validOperators = ['equals', 'not_equals', 'greater_than', 'less_than', 'contains', 'count_above', 'count_below', 'age_minutes_above']
if (!validOperators.includes(condition_operator)) {
return NextResponse.json({ error: `condition_operator must be one of: ${validOperators.join(', ')}` }, { status: 400 })
}
try { try {
const result = db.prepare(` const result = db.prepare(`
@ -109,6 +107,9 @@ export async function PUT(request: NextRequest) {
const auth = requireRole(request, 'operator') const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) 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 db = getDatabase()
const body = await request.json() const body = await request.json()
const { id, ...updates } = body const { id, ...updates } = body
@ -147,6 +148,9 @@ export async function DELETE(request: NextRequest) {
const auth = requireRole(request, 'admin') const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) 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 db = getDatabase()
const body = await request.json() const body = await request.json()
const { id } = body const { id } = body

View File

@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const action = searchParams.get('action') const action = searchParams.get('action')
const actor = searchParams.get('actor') const actor = searchParams.get('actor')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500) const limit = Math.min(parseInt(searchParams.get('limit') || '1000'), 10000)
const offset = parseInt(searchParams.get('offset') || '0') const offset = parseInt(searchParams.get('offset') || '0')
const since = searchParams.get('since') const since = searchParams.get('since')
const until = searchParams.get('until') const until = searchParams.get('until')

View File

@ -2,27 +2,13 @@ import { NextResponse } from 'next/server'
import { authenticateUser, createSession } from '@/lib/auth' import { authenticateUser, createSession } from '@/lib/auth'
import { logAuditEvent } from '@/lib/db' import { logAuditEvent } from '@/lib/db'
import { getMcSessionCookieOptions } from '@/lib/session-cookie' import { getMcSessionCookieOptions } from '@/lib/session-cookie'
import { loginLimiter } from '@/lib/rate-limit'
// Rate limiting: 5 attempts per minute per IP import { logger } from '@/lib/logger'
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const entry = loginAttempts.get(ip)
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + 60_000 })
return true
}
entry.count++
return entry.count <= 5
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' const rateCheck = loginLimiter(request)
if (!checkRateLimit(ip)) { if (rateCheck) return rateCheck
return NextResponse.json({ error: 'Too many login attempts. Try again in a minute.' }, { status: 429 })
}
const { username, password } = await request.json() const { username, password } = await request.json()
@ -61,7 +47,7 @@ export async function POST(request: Request) {
return response return response
} catch (error) { } catch (error) {
console.error('Login error:', error) logger.error({ err: error }, 'Login error')
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
} }
} }

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
import { getDatabase, logAuditEvent } from '@/lib/db' import { getDatabase, logAuditEvent } from '@/lib/db'
import { heavyLimiter } from '@/lib/rate-limit'
/** /**
* GET /api/export?type=audit|tasks|activities|pipelines&format=csv|json&since=UNIX&until=UNIX * GET /api/export?type=audit|tasks|activities|pipelines&format=csv|json&since=UNIX&until=UNIX
@ -10,6 +11,9 @@ export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin') const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = heavyLimiter(request)
if (rateCheck) return rateCheck
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const type = searchParams.get('type') const type = searchParams.get('type')
const format = searchParams.get('format') || 'csv' const format = searchParams.get('format') || 'csv'
@ -38,31 +42,35 @@ export async function GET(request: NextRequest) {
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const requestedLimit = parseInt(searchParams.get('limit') || '10000')
const maxLimit = 50000
const limit = Math.min(requestedLimit, maxLimit)
let rows: any[] = [] let rows: any[] = []
let headers: string[] = [] let headers: string[] = []
let filename = '' let filename = ''
switch (type) { switch (type) {
case 'audit': { case 'audit': {
rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC`).all(...params) rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
headers = ['id', 'action', 'actor', 'actor_id', 'target_type', 'target_id', 'detail', 'ip_address', 'user_agent', 'created_at'] headers = ['id', 'action', 'actor', 'actor_id', 'target_type', 'target_id', 'detail', 'ip_address', 'user_agent', 'created_at']
filename = 'audit-log' filename = 'audit-log'
break break
} }
case 'tasks': { case 'tasks': {
rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at DESC`).all(...params) rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags'] headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags']
filename = 'tasks' filename = 'tasks'
break break
} }
case 'activities': { case 'activities': {
rows = db.prepare(`SELECT * FROM activities ${where} ORDER BY created_at DESC`).all(...params) rows = db.prepare(`SELECT * FROM activities ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at'] headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at']
filename = 'activities' filename = 'activities'
break break
} }
case 'pipelines': { case 'pipelines': {
rows = db.prepare(`SELECT pr.*, wp.name as pipeline_name FROM pipeline_runs pr LEFT JOIN workflow_pipelines wp ON pr.pipeline_id = wp.id ${where ? where.replace('created_at', 'pr.created_at') : ''} ORDER BY pr.created_at DESC`).all(...params) rows = db.prepare(`SELECT pr.*, wp.name as pipeline_name FROM pipeline_runs pr LEFT JOIN workflow_pipelines wp ON pr.pipeline_id = wp.id ${where ? where.replace('created_at', 'pr.created_at') : ''} ORDER BY pr.created_at DESC LIMIT ?`).all(...params, limit)
headers = ['id', 'pipeline_id', 'pipeline_name', 'status', 'current_step', 'steps_snapshot', 'started_at', 'completed_at', 'triggered_by', 'created_at'] headers = ['id', 'pipeline_id', 'pipeline_name', 'status', 'current_step', 'steps_snapshot', 'started_at', 'completed_at', 'triggered_by', 'created_at']
filename = 'pipeline-runs' filename = 'pipeline-runs'
break break

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getDatabase, Notification } from '@/lib/db'; import { getDatabase, Notification } from '@/lib/db';
import { requireRole } from '@/lib/auth'; import { requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit';
/** /**
* GET /api/notifications - Get notifications for a specific recipient * GET /api/notifications - Get notifications for a specific recipient
@ -18,7 +19,7 @@ export async function GET(request: NextRequest) {
const recipient = searchParams.get('recipient'); const recipient = searchParams.get('recipient');
const unread_only = searchParams.get('unread_only') === 'true'; const unread_only = searchParams.get('unread_only') === 'true';
const type = searchParams.get('type'); const type = searchParams.get('type');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 500);
const offset = parseInt(searchParams.get('offset') || '0'); const offset = parseInt(searchParams.get('offset') || '0');
if (!recipient) { if (!recipient) {
@ -138,6 +139,9 @@ export async function PUT(request: NextRequest) {
const auth = requireRole(request, 'operator'); const auth = requireRole(request, 'operator');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const body = await request.json(); const body = await request.json();
@ -193,6 +197,9 @@ export async function DELETE(request: NextRequest) {
const auth = requireRole(request, 'admin'); const auth = requireRole(request, 'admin');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const body = await request.json(); const body = await request.json();
@ -244,6 +251,9 @@ export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator'); const auth = requireRole(request, 'operator');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const body = await request.json(); const body = await request.json();

View File

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth'
import { runClawdbot } from '@/lib/command'
import { db_helpers } from '@/lib/db'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { id } = await params
const { action } = await request.json()
if (!['monitor', 'pause', 'terminate'].includes(action)) {
return NextResponse.json(
{ error: 'Invalid action. Must be: monitor, pause, terminate' },
{ status: 400 }
)
}
let result
if (action === 'terminate') {
result = await runClawdbot(
['-c', `sessions_kill("${id}")`],
{ timeoutMs: 10000 }
)
} else {
const message = action === 'monitor'
? JSON.stringify({ type: 'control', action: 'monitor' })
: JSON.stringify({ type: 'control', action: 'pause' })
result = await runClawdbot(
['-c', `sessions_send("${id}", ${JSON.stringify(message)})`],
{ timeoutMs: 10000 }
)
}
db_helpers.logActivity(
'session_control',
'session',
0,
auth.user.username,
`Session ${action}: ${id}`,
{ session_key: id, action }
)
return NextResponse.json({
success: true,
action,
session: id,
stdout: result.stdout.trim(),
})
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Session control failed' },
{ status: 500 }
)
}
}

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
import { getDatabase, logAuditEvent } from '@/lib/db' import { getDatabase, logAuditEvent } from '@/lib/db'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import { mutationLimiter } from '@/lib/rate-limit'
interface SettingRow { interface SettingRow {
key: string key: string
@ -101,6 +102,9 @@ export async function PUT(request: NextRequest) {
const auth = requireRole(request, 'admin') const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
const body = await request.json().catch(() => null) const body = await request.json().catch(() => null)
if (!body?.settings || typeof body.settings !== 'object') { if (!body?.settings || typeof body.settings !== 'object') {
return NextResponse.json({ error: 'settings object required' }, { status: 400 }) return NextResponse.json({ error: 'settings object required' }, { status: 400 })
@ -157,6 +161,9 @@ export async function DELETE(request: NextRequest) {
const auth = requireRole(request, 'admin') const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
let body: any let body: any
try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) } try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) }
const key = body.key const key = body.key

View File

@ -4,11 +4,16 @@ import { requireRole } from '@/lib/auth'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import { readdir, readFile, stat } from 'fs/promises' import { readdir, readFile, stat } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import { heavyLimiter } from '@/lib/rate-limit'
import { logger } from '@/lib/logger'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator') const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = heavyLimiter(request)
if (rateCheck) return rateCheck
try { try {
const { task, model, label, timeoutSeconds } = await request.json() const { task, model, label, timeoutSeconds } = await request.json()
@ -57,7 +62,7 @@ export async function POST(request: NextRequest) {
sessionInfo = sessionMatch[1] sessionInfo = sessionMatch[1]
} }
} catch (parseError) { } catch (parseError) {
console.error('Failed to parse session info:', parseError) logger.error({ err: parseError }, 'Failed to parse session info')
} }
return NextResponse.json({ return NextResponse.json({
@ -74,7 +79,7 @@ export async function POST(request: NextRequest) {
}) })
} catch (execError: any) { } catch (execError: any) {
console.error('Spawn execution error:', execError) logger.error({ err: execError }, 'Spawn execution error')
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
@ -89,7 +94,7 @@ export async function POST(request: NextRequest) {
} }
} catch (error) { } catch (error) {
console.error('Spawn API error:', error) logger.error({ err: error }, 'Spawn API error')
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: 'Internal server error' },
{ status: 500 } { status: 500 }
@ -173,7 +178,7 @@ export async function GET(request: NextRequest) {
} }
} catch (error) { } catch (error) {
console.error('Spawn history API error:', error) logger.error({ err: error }, 'Spawn history API error')
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: 'Internal server error' },
{ status: 500 } { status: 500 }

View File

@ -6,6 +6,7 @@ import { config } from '@/lib/config'
import { getDatabase } from '@/lib/db' import { getDatabase } from '@/lib/db'
import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions' import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
import { MODEL_CATALOG } from '@/lib/models'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer') const auth = requireRole(request, 'viewer')
@ -340,17 +341,8 @@ async function getGatewayStatus() {
async function getAvailableModels() { async function getAvailableModels() {
// This would typically query the gateway or config files // This would typically query the gateway or config files
// For now, return the models from AGENTS.md // Model catalog is the single source of truth
const models = [ const models = [...MODEL_CATALOG]
{ alias: 'haiku', name: 'anthropic/claude-3-5-haiku-latest', provider: 'anthropic', description: 'Ultra-cheap, simple tasks', costPer1k: 0.25 },
{ alias: 'sonnet', name: 'anthropic/claude-sonnet-4-20250514', provider: 'anthropic', description: 'Standard workhorse', costPer1k: 3.0 },
{ alias: 'opus', name: 'anthropic/claude-opus-4-5', provider: 'anthropic', description: 'Premium quality', costPer1k: 15.0 },
{ alias: 'deepseek', name: 'ollama/deepseek-r1:14b', provider: 'ollama', description: 'Local reasoning (free)', costPer1k: 0.0 },
{ alias: 'groq-fast', name: 'groq/llama-3.1-8b-instant', provider: 'groq', description: '840 tok/s, ultra fast', costPer1k: 0.05 },
{ alias: 'groq', name: 'groq/llama-3.3-70b-versatile', provider: 'groq', description: 'Fast + quality balance', costPer1k: 0.59 },
{ alias: 'kimi', name: 'moonshot/kimi-k2.5', provider: 'moonshot', description: 'Alternative provider', costPer1k: 1.0 },
{ alias: 'minimax', name: 'minimax/minimax-m2.1', provider: 'minimax', description: 'Cost-effective (1/10th price), strong coding', costPer1k: 0.3 },
]
try { try {
// Check which Ollama models are available locally // Check which Ollama models are available locally

View File

@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { getDatabase, Task, db_helpers } from '@/lib/db'; import { getDatabase, Task, db_helpers } from '@/lib/db';
import { eventBus } from '@/lib/event-bus'; import { eventBus } from '@/lib/event-bus';
import { getUserFromRequest, requireRole } from '@/lib/auth'; import { getUserFromRequest, requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, updateTaskSchema } from '@/lib/validation';
function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean { function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
const review = db.prepare(` const review = db.prepare(`
@ -48,7 +51,7 @@ export async function GET(
return NextResponse.json({ task: taskWithParsedData }); return NextResponse.json({ task: taskWithParsedData });
} catch (error) { } catch (error) {
console.error('GET /api/tasks/[id] error:', error); logger.error({ err: error }, 'GET /api/tasks/[id] error');
return NextResponse.json({ error: 'Failed to fetch task' }, { status: 500 }); return NextResponse.json({ error: 'Failed to fetch task' }, { status: 500 });
} }
} }
@ -63,11 +66,16 @@ export async function PUT(
const auth = requireRole(request, 'operator'); const auth = requireRole(request, 'operator');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const resolvedParams = await params; const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id); const taskId = parseInt(resolvedParams.id);
const body = await request.json(); const validated = await validateBody(request, updateTaskSchema);
if ('error' in validated) return validated.error;
const body = validated.data;
if (isNaN(taskId)) { if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
@ -240,7 +248,7 @@ export async function PUT(
return NextResponse.json({ task: parsedTask }); return NextResponse.json({ task: parsedTask });
} catch (error) { } catch (error) {
console.error('PUT /api/tasks/[id] error:', error); logger.error({ err: error }, 'PUT /api/tasks/[id] error');
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 }); return NextResponse.json({ error: 'Failed to update task' }, { status: 500 });
} }
} }
@ -255,6 +263,9 @@ export async function DELETE(
const auth = requireRole(request, 'operator'); const auth = requireRole(request, 'operator');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const resolvedParams = await params; const resolvedParams = await params;
@ -294,7 +305,7 @@ export async function DELETE(
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('DELETE /api/tasks/[id] error:', error); logger.error({ err: error }, 'DELETE /api/tasks/[id] error');
return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 }); return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 });
} }
} }

View File

@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { getDatabase, Task, db_helpers } from '@/lib/db'; import { getDatabase, Task, db_helpers } from '@/lib/db';
import { eventBus } from '@/lib/event-bus'; import { eventBus } from '@/lib/event-bus';
import { requireRole } from '@/lib/auth'; import { requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, createTaskSchema } from '@/lib/validation';
function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean { function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
const review = db.prepare(` const review = db.prepare(`
@ -83,7 +86,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit }); return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit });
} catch (error) { } catch (error) {
console.error('GET /api/tasks error:', error); logger.error({ err: error }, 'GET /api/tasks error');
return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 }); return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
} }
} }
@ -95,9 +98,14 @@ export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator'); const auth = requireRole(request, 'operator');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const body = await request.json(); const validated = await validateBody(request, createTaskSchema);
if ('error' in validated) return validated.error;
const body = validated.data;
const user = auth.user const user = auth.user
const { const {
@ -113,10 +121,6 @@ export async function POST(request: NextRequest) {
metadata = {} metadata = {}
} = body; } = body;
if (!title) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
// Check for duplicate title // Check for duplicate title
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ?').get(title); const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ?').get(title);
if (existingTask) { if (existingTask) {
@ -187,7 +191,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ task: parsedTask }, { status: 201 }); return NextResponse.json({ task: parsedTask }, { status: 201 });
} catch (error) { } catch (error) {
console.error('POST /api/tasks error:', error); logger.error({ err: error }, 'POST /api/tasks error');
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 }); return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
} }
} }
@ -199,6 +203,9 @@ export async function PUT(request: NextRequest) {
const auth = requireRole(request, 'operator'); const auth = requireRole(request, 'operator');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
const rateCheck = mutationLimiter(request);
if (rateCheck) return rateCheck;
try { try {
const db = getDatabase(); const db = getDatabase();
const { tasks } = await request.json(); const { tasks } = await request.json();
@ -254,7 +261,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ success: true, updated: tasks.length }); return NextResponse.json({ success: true, updated: tasks.length });
} catch (error) { } catch (error) {
console.error('PUT /api/tasks error:', error); logger.error({ err: error }, 'PUT /api/tasks error');
const message = error instanceof Error ? error.message : 'Failed to update tasks' const message = error instanceof Error ? error.message : 'Failed to update tasks'
if (message.includes('Aegis approval required')) { if (message.includes('Aegis approval required')) {
return NextResponse.json({ error: message }, { status: 403 }); return NextResponse.json({ error: message }, { status: 403 });

View File

@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { getDatabase } from '@/lib/db' import { getDatabase } from '@/lib/db'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
import { randomBytes, createHmac } from 'crypto' import { randomBytes, createHmac } from 'crypto'
import { mutationLimiter } from '@/lib/rate-limit'
import { logger } from '@/lib/logger'
import { validateBody, createWebhookSchema } from '@/lib/validation'
/** /**
* GET /api/webhooks - List all webhooks with delivery stats * GET /api/webhooks - List all webhooks with delivery stats
@ -31,7 +34,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ webhooks: result }) return NextResponse.json({ webhooks: result })
} catch (error) { } catch (error) {
console.error('GET /api/webhooks error:', error) logger.error({ err: error }, 'GET /api/webhooks error')
return NextResponse.json({ error: 'Failed to fetch webhooks' }, { status: 500 }) return NextResponse.json({ error: 'Failed to fetch webhooks' }, { status: 500 })
} }
} }
@ -43,32 +46,26 @@ export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin') const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
try { try {
const db = getDatabase() const db = getDatabase()
const body = await request.json() const validated = await validateBody(request, createWebhookSchema)
if ('error' in validated) return validated.error
const body = validated.data
const { name, url, events, generate_secret } = body const { name, url, events, generate_secret } = body
if (!name || !url) {
return NextResponse.json({ error: 'Name and URL are required' }, { status: 400 })
}
// Validate URL
try {
new URL(url)
} catch {
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
}
const secret = generate_secret !== false ? randomBytes(32).toString('hex') : null const secret = generate_secret !== false ? randomBytes(32).toString('hex') : null
const eventsJson = JSON.stringify(events || ['*']) const eventsJson = JSON.stringify(events || ['*'])
const result = db.prepare(` const dbResult = db.prepare(`
INSERT INTO webhooks (name, url, secret, events, created_by) INSERT INTO webhooks (name, url, secret, events, created_by)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(name, url, secret, eventsJson, auth.user.username) `).run(name, url, secret, eventsJson, auth.user.username)
return NextResponse.json({ return NextResponse.json({
id: result.lastInsertRowid, id: dbResult.lastInsertRowid,
name, name,
url, url,
secret, // Show full secret only on creation secret, // Show full secret only on creation
@ -77,7 +74,7 @@ export async function POST(request: NextRequest) {
message: 'Webhook created. Save the secret - it won\'t be shown again in full.', message: 'Webhook created. Save the secret - it won\'t be shown again in full.',
}) })
} catch (error) { } catch (error) {
console.error('POST /api/webhooks error:', error) logger.error({ err: error }, 'POST /api/webhooks error')
return NextResponse.json({ error: 'Failed to create webhook' }, { status: 500 }) return NextResponse.json({ error: 'Failed to create webhook' }, { status: 500 })
} }
} }
@ -89,6 +86,9 @@ export async function PUT(request: NextRequest) {
const auth = requireRole(request, 'admin') const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
try { try {
const db = getDatabase() const db = getDatabase()
const body = await request.json() const body = await request.json()
@ -132,7 +132,7 @@ export async function PUT(request: NextRequest) {
...(newSecret ? { secret: newSecret, message: 'New secret generated. Save it now.' } : {}), ...(newSecret ? { secret: newSecret, message: 'New secret generated. Save it now.' } : {}),
}) })
} catch (error) { } catch (error) {
console.error('PUT /api/webhooks error:', error) logger.error({ err: error }, 'PUT /api/webhooks error')
return NextResponse.json({ error: 'Failed to update webhook' }, { status: 500 }) return NextResponse.json({ error: 'Failed to update webhook' }, { status: 500 })
} }
} }
@ -144,6 +144,9 @@ export async function DELETE(request: NextRequest) {
const auth = requireRole(request, 'admin') const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const rateCheck = mutationLimiter(request)
if (rateCheck) return rateCheck
try { try {
const db = getDatabase() const db = getDatabase()
let body: any let body: any
@ -164,7 +167,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true, deleted: result.changes }) return NextResponse.json({ success: true, deleted: result.changes })
} catch (error) { } catch (error) {
console.error('DELETE /api/webhooks error:', error) logger.error({ err: error }, 'DELETE /api/webhooks error')
return NextResponse.json({ error: 'Failed to delete webhook' }, { status: 500 }) return NextResponse.json({ error: 'Failed to delete webhook' }, { status: 500 })
} }
} }

View File

@ -110,7 +110,7 @@ export default function LoginPage() {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive"> <div role="alert" className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
{error} {error}
</div> </div>
)} )}
@ -127,6 +127,7 @@ export default function LoginPage() {
autoComplete="username" autoComplete="username"
autoFocus autoFocus
required required
aria-required="true"
/> />
</div> </div>
@ -141,6 +142,7 @@ export default function LoginPage() {
placeholder="Enter password" placeholder="Enter password"
autoComplete="current-password" autoComplete="current-password"
required required
aria-required="true"
/> />
</div> </div>

View File

@ -29,6 +29,7 @@ import { AlertRulesPanel } from '@/components/panels/alert-rules-panel'
import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel' import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel'
import { SuperAdminPanel } from '@/components/panels/super-admin-panel' import { SuperAdminPanel } from '@/components/panels/super-admin-panel'
import { ChatPanel } from '@/components/chat/chat-panel' import { ChatPanel } from '@/components/chat/chat-panel'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { useWebSocket } from '@/lib/websocket' import { useWebSocket } from '@/lib/websocket'
import { useServerEvents } from '@/lib/use-server-events' import { useServerEvents } from '@/lib/use-server-events'
import { useMissionControl } from '@/store' import { useMissionControl } from '@/store'
@ -86,8 +87,12 @@ export default function Home() {
{/* Center: Header + Content */} {/* Center: Header + Content */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<HeaderBar /> <HeaderBar />
<main className="flex-1 overflow-auto pb-16 md:pb-0"> <main className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
<div aria-live="polite">
<ErrorBoundary key={activeTab}>
<ContentRouter tab={activeTab} /> <ContentRouter tab={activeTab} />
</ErrorBoundary>
</div>
</main> </main>
</div> </div>

View File

@ -0,0 +1,58 @@
'use client'
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Panel error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
return (
<div className="flex flex-col items-center justify-center h-full min-h-[200px] p-8 text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-destructive" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">Something went wrong</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
{this.state.error?.message || 'An unexpected error occurred in this panel.'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Try again
</button>
</div>
)
}
return this.props.children
}
}

View File

@ -127,7 +127,7 @@ export function HeaderBar() {
} }
return ( return (
<header className="h-12 bg-card/80 backdrop-blur-sm border-b border-border px-4 flex items-center justify-between shrink-0"> <header role="banner" aria-label="Application header" className="h-12 bg-card/80 backdrop-blur-sm border-b border-border px-4 flex items-center justify-between shrink-0">
{/* Left: Page title + breadcrumb */} {/* Left: Page title + breadcrumb */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-sm font-semibold text-foreground"> <h1 className="text-sm font-semibold text-foreground">

View File

@ -84,6 +84,8 @@ export function NavRail() {
<> <>
{/* Desktop: Grouped sidebar */} {/* Desktop: Grouped sidebar */}
<nav <nav
role="navigation"
aria-label="Main navigation"
className={`hidden md:flex flex-col bg-card border-r border-border shrink-0 transition-all duration-200 ease-in-out ${ className={`hidden md:flex flex-col bg-card border-r border-border shrink-0 transition-all duration-200 ease-in-out ${
sidebarExpanded ? 'w-[220px]' : 'w-14' sidebarExpanded ? 'w-[220px]' : 'w-14'
}`} }`}
@ -199,6 +201,7 @@ function NavButton({ item, active, expanded, onClick }: {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
aria-current={active ? 'page' : undefined}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left transition-smooth relative ${ className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left transition-smooth relative ${
active active
? 'bg-primary/15 text-primary' ? 'bg-primary/15 text-primary'
@ -218,6 +221,7 @@ function NavButton({ item, active, expanded, onClick }: {
<button <button
onClick={onClick} onClick={onClick}
title={item.label} title={item.label}
aria-current={active ? 'page' : undefined}
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-smooth group relative ${ className={`w-10 h-10 rounded-lg flex items-center justify-center transition-smooth group relative ${
active active
? 'bg-primary/15 text-primary' ? 'bg-primary/15 text-primary'

View File

@ -26,6 +26,7 @@ export function SessionDetailsPanel() {
useSmartPoll(loadSessions, 60000, { pauseWhenConnected: true }) useSmartPoll(loadSessions, 60000, { pauseWhenConnected: true })
const [controllingSession, setControllingSession] = useState<string | null>(null)
const [sessionFilter, setSessionFilter] = useState<'all' | 'active' | 'idle'>('all') const [sessionFilter, setSessionFilter] = useState<'all' | 'active' | 'idle'>('all')
const [sortBy, setSortBy] = useState<'age' | 'tokens' | 'model'>('age') const [sortBy, setSortBy] = useState<'age' | 'tokens' | 'model'>('age')
const [expandedSession, setExpandedSession] = useState<string | null>(null) const [expandedSession, setExpandedSession] = useState<string | null>(null)
@ -323,36 +324,80 @@ export function SessionDetailsPanel() {
{/* Actions */} {/* Actions */}
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
className="px-3 py-1 text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded hover:bg-blue-500/30 transition-colors" className="px-3 py-1 text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded hover:bg-blue-500/30 transition-colors disabled:opacity-50"
onClick={(e) => { disabled={controllingSession !== null}
onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
// TODO: Implement session monitoring setControllingSession(`monitor-${session.id}`)
console.log('Monitor session:', session.id) try {
}} const res = await fetch(`/api/sessions/${session.id}/control`, {
> method: 'POST',
Monitor headers: { 'Content-Type': 'application/json' },
</button> body: JSON.stringify({ action: 'monitor' }),
<button })
className="px-3 py-1 text-xs bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 rounded hover:bg-yellow-500/30 transition-colors" if (!res.ok) {
onClick={(e) => { const data = await res.json()
e.stopPropagation() alert(data.error || 'Failed to monitor session')
// TODO: Implement session pause }
console.log('Pause session:', session.id) } catch {
}} alert('Failed to monitor session')
> } finally {
Pause setControllingSession(null)
</button>
<button
className="px-3 py-1 text-xs bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors"
onClick={(e) => {
e.stopPropagation()
if (confirm('Are you sure you want to terminate this session?')) {
// TODO: Implement session termination
console.log('Terminate session:', session.id)
} }
}} }}
> >
Terminate {controllingSession === `monitor-${session.id}` ? 'Working...' : 'Monitor'}
</button>
<button
className="px-3 py-1 text-xs bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 rounded hover:bg-yellow-500/30 transition-colors disabled:opacity-50"
disabled={controllingSession !== null}
onClick={async (e) => {
e.stopPropagation()
setControllingSession(`pause-${session.id}`)
try {
const res = await fetch(`/api/sessions/${session.id}/control`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'pause' }),
})
if (!res.ok) {
const data = await res.json()
alert(data.error || 'Failed to pause session')
}
} catch {
alert('Failed to pause session')
} finally {
setControllingSession(null)
}
}}
>
{controllingSession === `pause-${session.id}` ? 'Working...' : 'Pause'}
</button>
<button
className="px-3 py-1 text-xs bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors disabled:opacity-50"
disabled={controllingSession !== null}
onClick={async (e) => {
e.stopPropagation()
if (!window.confirm('Are you sure you want to terminate this session?')) return
setControllingSession(`terminate-${session.id}`)
try {
const res = await fetch(`/api/sessions/${session.id}/control`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'terminate' }),
})
if (!res.ok) {
const data = await res.json()
alert(data.error || 'Failed to terminate session')
}
} catch {
alert('Failed to terminate session')
} finally {
setControllingSession(null)
}
}}
>
{controllingSession === `terminate-${session.id}` ? 'Working...' : 'Terminate'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -4,6 +4,7 @@ import { config, ensureDirExists } from './config';
import { runMigrations } from './migrations'; import { runMigrations } from './migrations';
import { eventBus } from './event-bus'; import { eventBus } from './event-bus';
import { hashPassword } from './password'; import { hashPassword } from './password';
import { logger } from './logger';
// Database file location // Database file location
const DB_PATH = config.dbPath; const DB_PATH = config.dbPath;
@ -63,9 +64,9 @@ function initializeSchema() {
} }
} }
console.log('Database migrations applied successfully'); logger.info('Database migrations applied successfully');
} catch (error) { } catch (error) {
console.error('Failed to apply database migrations:', error); logger.error({ err: error }, 'Failed to apply database migrations');
throw error; throw error;
} }
} }
@ -83,7 +84,7 @@ function seedAdminUserFromEnv(dbConn: Database.Database): void {
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(username, displayName, hashPassword(password), 'admin') `).run(username, displayName, hashPassword(password), 'admin')
console.log(`Seeded admin user: ${username}`) logger.info(`Seeded admin user: ${username}`)
} }
/** /**
@ -460,7 +461,7 @@ if (typeof window === 'undefined') { // Only run on server side
try { try {
getDatabase(); getDatabase();
} catch (error) { } catch (error) {
console.error('Failed to initialize database:', error); logger.error({ err: error }, 'Failed to initialize database');
} }
} }

11
src/lib/logger.ts Normal file
View File

@ -0,0 +1,11 @@
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
...(process.env.NODE_ENV !== 'production' && {
transport: {
target: 'pino-pretty',
options: { colorize: true },
},
}),
})

30
src/lib/models.ts Normal file
View File

@ -0,0 +1,30 @@
export interface ModelConfig {
alias: string
name: string
provider: string
description: string
costPer1k: number
}
export const MODEL_CATALOG: ModelConfig[] = [
{ alias: 'haiku', name: 'anthropic/claude-3-5-haiku-latest', provider: 'anthropic', description: 'Ultra-cheap, simple tasks', costPer1k: 0.25 },
{ alias: 'sonnet', name: 'anthropic/claude-sonnet-4-20250514', provider: 'anthropic', description: 'Standard workhorse', costPer1k: 3.0 },
{ alias: 'opus', name: 'anthropic/claude-opus-4-5', provider: 'anthropic', description: 'Premium quality', costPer1k: 15.0 },
{ alias: 'deepseek', name: 'ollama/deepseek-r1:14b', provider: 'ollama', description: 'Local reasoning (free)', costPer1k: 0.0 },
{ alias: 'groq-fast', name: 'groq/llama-3.1-8b-instant', provider: 'groq', description: '840 tok/s, ultra fast', costPer1k: 0.05 },
{ alias: 'groq', name: 'groq/llama-3.3-70b-versatile', provider: 'groq', description: 'Fast + quality balance', costPer1k: 0.59 },
{ alias: 'kimi', name: 'moonshot/kimi-k2.5', provider: 'moonshot', description: 'Alternative provider', costPer1k: 1.0 },
{ alias: 'minimax', name: 'minimax/minimax-m2.1', provider: 'minimax', description: 'Cost-effective (1/10th price), strong coding', costPer1k: 0.3 },
]
export function getModelByAlias(alias: string): ModelConfig | undefined {
return MODEL_CATALOG.find(m => m.alias === alias)
}
export function getModelByName(name: string): ModelConfig | undefined {
return MODEL_CATALOG.find(m => m.name === name)
}
export function getAllModels(): ModelConfig[] {
return [...MODEL_CATALOG]
}

64
src/lib/rate-limit.ts Normal file
View File

@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
interface RateLimitEntry {
count: number
resetAt: number
}
interface RateLimiterOptions {
windowMs: number
maxRequests: number
message?: string
}
export function createRateLimiter(options: RateLimiterOptions) {
const store = new Map<string, RateLimitEntry>()
// Periodic cleanup every 60s
const cleanupInterval = setInterval(() => {
const now = Date.now()
for (const [key, entry] of store) {
if (now > entry.resetAt) store.delete(key)
}
}, 60_000)
// Don't prevent process exit
if (cleanupInterval.unref) cleanupInterval.unref()
return function checkRateLimit(request: Request): NextResponse | null {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const now = Date.now()
const entry = store.get(ip)
if (!entry || now > entry.resetAt) {
store.set(ip, { count: 1, resetAt: now + options.windowMs })
return null
}
entry.count++
if (entry.count > options.maxRequests) {
return NextResponse.json(
{ error: options.message || 'Too many requests. Please try again later.' },
{ status: 429 }
)
}
return null
}
}
export const loginLimiter = createRateLimiter({
windowMs: 60_000,
maxRequests: 5,
message: 'Too many login attempts. Try again in a minute.',
})
export const mutationLimiter = createRateLimiter({
windowMs: 60_000,
maxRequests: 60,
})
export const heavyLimiter = createRateLimiter({
windowMs: 60_000,
maxRequests: 10,
message: 'Too many requests for this resource. Please try again later.',
})

View File

@ -3,6 +3,7 @@ import { syncAgentsFromConfig } from './agent-sync'
import { config, ensureDirExists } from './config' import { config, ensureDirExists } from './config'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { readdirSync, statSync, unlinkSync } from 'fs' import { readdirSync, statSync, unlinkSync } from 'fs'
import { logger } from './logger'
const BACKUP_DIR = join(dirname(config.dbPath), 'backups') const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
@ -209,7 +210,7 @@ export function initScheduler() {
// Auto-sync agents from openclaw.json on startup // Auto-sync agents from openclaw.json on startup
syncAgentsFromConfig('startup').catch(err => { syncAgentsFromConfig('startup').catch(err => {
console.warn('Agent auto-sync failed:', err.message) logger.warn({ err }, 'Agent auto-sync failed')
}) })
// Register tasks // Register tasks
@ -247,7 +248,7 @@ export function initScheduler() {
// Start the tick loop // Start the tick loop
tickInterval = setInterval(tick, TICK_MS) tickInterval = setInterval(tick, TICK_MS)
console.log('Scheduler initialized - backup at ~3AM, cleanup at ~4AM, heartbeat every 5m') logger.info('Scheduler initialized - backup at ~3AM, cleanup at ~4AM, heartbeat every 5m')
} }
/** Calculate ms until next occurrence of a given hour (UTC) */ /** Calculate ms until next occurrence of a given hour (UTC) */

74
src/lib/validation.ts Normal file
View File

@ -0,0 +1,74 @@
import { NextResponse } from 'next/server'
import { ZodSchema, ZodError } from 'zod'
import { z } from 'zod'
export async function validateBody<T>(
request: Request,
schema: ZodSchema<T>
): Promise<{ data: T } | { error: NextResponse }> {
try {
const body = await request.json()
const data = schema.parse(body)
return { data }
} catch (err) {
if (err instanceof ZodError) {
const messages = err.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
return {
error: NextResponse.json(
{ error: 'Validation failed', details: messages },
{ status: 400 }
),
}
}
return {
error: NextResponse.json({ error: 'Invalid request body' }, { status: 400 }),
}
}
}
export const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required').max(500),
description: z.string().max(5000).optional(),
status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'done', 'blocked']).default('inbox'),
priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
assigned_to: z.string().max(100).optional(),
created_by: z.string().max(100).optional(),
due_date: z.number().optional(),
estimated_hours: z.number().min(0).optional(),
actual_hours: z.number().min(0).optional(),
tags: z.array(z.string()).default([] as string[]),
metadata: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>),
})
export const updateTaskSchema = createTaskSchema.partial()
export const createAgentSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
role: z.string().min(1, 'Role is required').max(100).optional(),
session_key: z.string().max(200).optional(),
soul_content: z.string().max(50000).optional(),
status: z.enum(['online', 'offline', 'busy', 'idle', 'error']).default('offline'),
config: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>),
template: z.string().max(100).optional(),
gateway_config: z.record(z.string(), z.unknown()).optional(),
write_to_gateway: z.boolean().optional(),
})
export const createWebhookSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
url: z.string().url('Invalid URL'),
events: z.array(z.string()).optional(),
generate_secret: z.boolean().optional(),
})
export const createAlertSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
description: z.string().max(1000).optional(),
entity_type: z.enum(['agent', 'task', 'session', 'activity']),
condition_field: z.string().min(1).max(100),
condition_operator: z.enum(['equals', 'not_equals', 'greater_than', 'less_than', 'contains', 'count_above', 'count_below', 'age_minutes_above']),
condition_value: z.string().min(1).max(500),
action_type: z.string().max(100).optional(),
action_config: z.record(z.string(), z.unknown()).optional(),
cooldown_minutes: z.number().min(1).max(10080).optional(),
})

View File

@ -1,5 +1,6 @@
import { createHmac } from 'crypto' import { createHmac } from 'crypto'
import { eventBus, type ServerEvent } from './event-bus' import { eventBus, type ServerEvent } from './event-bus'
import { logger } from './logger'
interface Webhook { interface Webhook {
id: number id: number
@ -46,12 +47,12 @@ export function initWebhookListener() {
const isAgentError = event.type === 'agent.status_changed' && event.data?.status === 'error' const isAgentError = event.type === 'agent.status_changed' && event.data?.status === 'error'
fireWebhooksAsync(webhookEventType, event.data).catch((err) => { fireWebhooksAsync(webhookEventType, event.data).catch((err) => {
console.error('Webhook dispatch error:', err) logger.error({ err }, 'Webhook dispatch error')
}) })
if (isAgentError) { if (isAgentError) {
fireWebhooksAsync('agent.error', event.data).catch((err) => { fireWebhooksAsync('agent.error', event.data).catch((err) => {
console.error('Webhook dispatch error (agent.error):', err) logger.error({ err }, 'Webhook dispatch error')
}) })
} }
}) })
@ -62,7 +63,7 @@ export function initWebhookListener() {
*/ */
export function fireWebhooks(eventType: string, payload: Record<string, any>) { export function fireWebhooks(eventType: string, payload: Record<string, any>) {
fireWebhooksAsync(eventType, payload).catch((err) => { fireWebhooksAsync(eventType, payload).catch((err) => {
console.error('Webhook dispatch error:', err) logger.error({ err }, 'Webhook dispatch error')
}) })
} }

View File

@ -2,6 +2,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware' import { subscribeWithSelector } from 'zustand/middleware'
import { MODEL_CATALOG } from '@/lib/models'
// Enhanced types for Mission Control // Enhanced types for Mission Control
export interface Session { export interface Session {
@ -523,16 +524,7 @@ export const useMissionControl = create<MissionControlStore>()(
}, },
// Model Configuration // Model Configuration
availableModels: [ availableModels: [...MODEL_CATALOG],
{ alias: 'haiku', name: 'anthropic/claude-3-5-haiku-latest', provider: 'anthropic', description: 'Ultra-cheap, simple tasks', costPer1k: 0.25 },
{ alias: 'sonnet', name: 'anthropic/claude-sonnet-4-20250514', provider: 'anthropic', description: 'Standard workhorse', costPer1k: 3.0 },
{ alias: 'opus', name: 'anthropic/claude-opus-4-5', provider: 'anthropic', description: 'Premium quality', costPer1k: 15.0 },
{ alias: 'deepseek', name: 'ollama/deepseek-r1:14b', provider: 'ollama', description: 'Local reasoning (free)', costPer1k: 0.0 },
{ alias: 'groq-fast', name: 'groq/llama-3.1-8b-instant', provider: 'groq', description: '840 tok/s, ultra fast', costPer1k: 0.05 },
{ alias: 'groq', name: 'groq/llama-3.3-70b-versatile', provider: 'groq', description: 'Fast + quality balance', costPer1k: 0.59 },
{ alias: 'kimi', name: 'moonshot/kimi-k2.5', provider: 'moonshot', description: 'Alternative provider', costPer1k: 1.0 },
{ alias: 'minimax', name: 'minimax/minimax-m2.1', provider: 'minimax', description: 'Cost-effective (1/10th price), strong coding', costPer1k: 0.3 },
],
setAvailableModels: (models) => set({ availableModels: models }), setAvailableModels: (models) => set({ availableModels: models }),
// Auth // Auth