Nyk commited on
Commit
b608851
·
1 Parent(s): 7606386

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

Browse files
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .git
3
+ .data
4
+ .next
5
+ .env
6
+ *.md
7
+ .github
8
+ ops
9
+ scripts
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim AS base
2
+ RUN corepack enable && corepack prepare pnpm@latest --activate
3
+ WORKDIR /app
4
+
5
+ FROM base AS deps
6
+ COPY package.json pnpm-lock.yaml ./
7
+ RUN pnpm install --frozen-lockfile
8
+
9
+ FROM base AS build
10
+ COPY --from=deps /app/node_modules ./node_modules
11
+ COPY . .
12
+ RUN pnpm build
13
+
14
+ FROM node:20-slim AS runtime
15
+ WORKDIR /app
16
+ ENV NODE_ENV=production
17
+ RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
18
+ COPY --from=build /app/.next/standalone ./
19
+ COPY --from=build /app/.next/static ./.next/static
20
+ COPY --from=build /app/public ./public
21
+ USER nextjs
22
+ EXPOSE 3000
23
+ CMD ["node", "server.js"]
docker-compose.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ mission-control:
3
+ build: .
4
+ ports:
5
+ - "3000:3000"
6
+ env_file: .env
7
+ volumes:
8
+ - mc-data:/app/.data
9
+ restart: unless-stopped
10
+
11
+ volumes:
12
+ mc-data:
next.config.js CHANGED
@@ -1,5 +1,6 @@
1
  /** @type {import('next').NextConfig} */
2
  const nextConfig = {
 
3
  turbopack: {},
4
 
5
  // Security headers
@@ -25,6 +26,9 @@ const nextConfig = {
25
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
26
  { key: 'Content-Security-Policy', value: csp },
27
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
 
 
 
28
  ],
29
  },
30
  ];
 
1
  /** @type {import('next').NextConfig} */
2
  const nextConfig = {
3
+ output: 'standalone',
4
  turbopack: {},
5
 
6
  // Security headers
 
26
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
27
  { key: 'Content-Security-Policy', value: csp },
28
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
29
+ ...(process.env.MC_ENABLE_HSTS === '1' ? [
30
+ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }
31
+ ] : []),
32
  ],
33
  },
34
  ];
package.json CHANGED
@@ -24,6 +24,7 @@
24
  "eslint-config-next": "^16.1.6",
25
  "next": "^16.1.6",
26
  "next-themes": "^0.4.6",
 
27
  "postcss": "^8.5.2",
28
  "react": "^19.0.1",
29
  "react-dom": "^19.0.1",
@@ -33,6 +34,7 @@
33
  "tailwindcss": "^3.4.17",
34
  "typescript": "^5.7.2",
35
  "ws": "^8.19.0",
 
36
  "zustand": "^5.0.11"
37
  },
38
  "devDependencies": {
@@ -47,6 +49,7 @@
47
  "@types/ws": "^8.18.1",
48
  "@vitejs/plugin-react": "^4.3.4",
49
  "jsdom": "^26.0.0",
 
50
  "vite-tsconfig-paths": "^5.1.4",
51
  "vitest": "^2.1.5"
52
  },
 
24
  "eslint-config-next": "^16.1.6",
25
  "next": "^16.1.6",
26
  "next-themes": "^0.4.6",
27
+ "pino": "^10.3.1",
28
  "postcss": "^8.5.2",
29
  "react": "^19.0.1",
30
  "react-dom": "^19.0.1",
 
34
  "tailwindcss": "^3.4.17",
35
  "typescript": "^5.7.2",
36
  "ws": "^8.19.0",
37
+ "zod": "^4.3.6",
38
  "zustand": "^5.0.11"
39
  },
40
  "devDependencies": {
 
49
  "@types/ws": "^8.18.1",
50
  "@vitejs/plugin-react": "^4.3.4",
51
  "jsdom": "^26.0.0",
52
+ "pino-pretty": "^13.1.3",
53
  "vite-tsconfig-paths": "^5.1.4",
54
  "vitest": "^2.1.5"
55
  },
pnpm-lock.yaml CHANGED
@@ -32,6 +32,9 @@ importers:
32
  next-themes:
33
  specifier: ^0.4.6
34
  version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
 
 
 
35
  postcss:
36
  specifier: ^8.5.2
37
  version: 8.5.6
@@ -59,6 +62,9 @@ importers:
59
  ws:
60
  specifier: ^8.19.0
61
  version: 8.19.0
 
 
 
62
  zustand:
63
  specifier: ^5.0.11
64
  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:
96
  jsdom:
97
  specifier: ^26.0.0
98
  version: 26.1.0
 
 
 
99
  vite-tsconfig-paths:
100
  specifier: ^5.1.4
101
  version: 5.1.4(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.9))
@@ -677,6 +686,9 @@ packages:
677
  resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
678
  engines: {node: '>=12.4.0'}
679
 
 
 
 
680
  '@playwright/test@1.58.2':
681
  resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
682
  engines: {node: '>=18'}
@@ -1346,6 +1358,10 @@ packages:
1346
  resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
1347
  engines: {node: '>= 0.4'}
1348
 
 
 
 
 
1349
  autoprefixer@10.4.24:
1350
  resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==}
1351
  engines: {node: ^10 || ^12 || >=14}
@@ -1470,6 +1486,9 @@ packages:
1470
  color-name@1.1.4:
1471
  resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
1472
 
 
 
 
1473
  commander@4.1.1:
1474
  resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
1475
  engines: {node: '>= 6'}
@@ -1584,6 +1603,9 @@ packages:
1584
  resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
1585
  engines: {node: '>= 0.4'}
1586
 
 
 
 
1587
  debug@3.2.7:
1588
  resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
1589
  peerDependencies:
@@ -1852,6 +1874,9 @@ packages:
1852
  resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
1853
  engines: {node: '>=12.0.0'}
1854
 
 
 
 
1855
  fast-deep-equal@3.1.3:
1856
  resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1857
 
@@ -1869,6 +1894,9 @@ packages:
1869
  fast-levenshtein@2.0.6:
1870
  resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
1871
 
 
 
 
1872
  fastq@1.20.1:
1873
  resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
1874
 
@@ -2013,6 +2041,9 @@ packages:
2013
  resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
2014
  engines: {node: '>= 0.4'}
2015
 
 
 
 
2016
  hermes-estree@0.25.1:
2017
  resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
2018
 
@@ -2202,6 +2233,10 @@ packages:
2202
  resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
2203
  hasBin: true
2204
 
 
 
 
 
2205
  js-tokens@4.0.0:
2206
  resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
2207
 
@@ -2425,6 +2460,10 @@ packages:
2425
  resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
2426
  engines: {node: '>= 0.4'}
2427
 
 
 
 
 
2428
  once@1.4.0:
2429
  resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
2430
 
@@ -2484,6 +2523,20 @@ packages:
2484
  resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
2485
  engines: {node: '>=0.10.0'}
2486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2487
  pirates@4.0.7:
2488
  resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
2489
  engines: {node: '>= 6'}
@@ -2566,6 +2619,9 @@ packages:
2566
  resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
2567
  engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
2568
 
 
 
 
2569
  prop-types@15.8.1:
2570
  resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
2571
 
@@ -2579,6 +2635,9 @@ packages:
2579
  queue-microtask@1.2.3:
2580
  resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
2581
 
 
 
 
2582
  rc@1.2.8:
2583
  resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
2584
  hasBin: true
@@ -2631,6 +2690,10 @@ packages:
2631
  resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
2632
  engines: {node: '>=8.10.0'}
2633
 
 
 
 
 
2634
  recharts@3.7.0:
2635
  resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
2636
  engines: {node: '>=18'}
@@ -2708,6 +2771,10 @@ packages:
2708
  resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
2709
  engines: {node: '>= 0.4'}
2710
 
 
 
 
 
2711
  safer-buffer@2.1.2:
2712
  resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
2713
 
@@ -2718,6 +2785,9 @@ packages:
2718
  scheduler@0.27.0:
2719
  resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
2720
 
 
 
 
2721
  semver@6.3.1:
2722
  resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
2723
  hasBin: true
@@ -2776,10 +2846,17 @@ packages:
2776
  simple-get@4.0.1:
2777
  resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
2778
 
 
 
 
2779
  source-map-js@1.2.1:
2780
  resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
2781
  engines: {node: '>=0.10.0'}
2782
 
 
 
 
 
2783
  stable-hash@0.0.5:
2784
  resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
2785
 
@@ -2835,6 +2912,10 @@ packages:
2835
  resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
2836
  engines: {node: '>=8'}
2837
 
 
 
 
 
2838
  styled-jsx@5.1.6:
2839
  resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
2840
  engines: {node: '>= 12.0.0'}
@@ -2886,6 +2967,10 @@ packages:
2886
  thenify@3.3.1:
2887
  resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
2888
 
 
 
 
 
2889
  tiny-invariant@1.3.3:
2890
  resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
2891
 
@@ -3668,6 +3753,8 @@ snapshots:
3668
 
3669
  '@nolyfill/is-core-module@1.0.39': {}
3670
 
 
 
3671
  '@playwright/test@1.58.2':
3672
  dependencies:
3673
  playwright: 1.58.2
@@ -4392,6 +4479,8 @@ snapshots:
4392
 
4393
  async-function@1.0.0: {}
4394
 
 
 
4395
  autoprefixer@10.4.24(postcss@8.5.6):
4396
  dependencies:
4397
  browserslist: 4.28.1
@@ -4524,6 +4613,8 @@ snapshots:
4524
 
4525
  color-name@1.1.4: {}
4526
 
 
 
4527
  commander@4.1.1: {}
4528
 
4529
  concat-map@0.0.1: {}
@@ -4636,6 +4727,8 @@ snapshots:
4636
  es-errors: 1.3.0
4637
  is-data-view: 1.0.2
4638
 
 
 
4639
  debug@3.2.7:
4640
  dependencies:
4641
  ms: 2.1.3
@@ -5050,6 +5143,8 @@ snapshots:
5050
 
5051
  expect-type@1.3.0: {}
5052
 
 
 
5053
  fast-deep-equal@3.1.3: {}
5054
 
5055
  fast-glob@3.3.1:
@@ -5072,6 +5167,8 @@ snapshots:
5072
 
5073
  fast-levenshtein@2.0.6: {}
5074
 
 
 
5075
  fastq@1.20.1:
5076
  dependencies:
5077
  reusify: 1.1.0
@@ -5206,6 +5303,8 @@ snapshots:
5206
  dependencies:
5207
  function-bind: 1.1.2
5208
 
 
 
5209
  hermes-estree@0.25.1: {}
5210
 
5211
  hermes-parser@0.25.1:
@@ -5398,6 +5497,8 @@ snapshots:
5398
 
5399
  jiti@1.21.7: {}
5400
 
 
 
5401
  js-tokens@4.0.0: {}
5402
 
5403
  js-yaml@4.1.1:
@@ -5620,6 +5721,8 @@ snapshots:
5620
  define-properties: 1.2.1
5621
  es-object-atoms: 1.1.1
5622
 
 
 
5623
  once@1.4.0:
5624
  dependencies:
5625
  wrappy: 1.0.2
@@ -5673,6 +5776,42 @@ snapshots:
5673
 
5674
  pify@2.3.0: {}
5675
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5676
  pirates@4.0.7: {}
5677
 
5678
  playwright-core@1.58.2: {}
@@ -5751,6 +5890,8 @@ snapshots:
5751
  ansi-styles: 5.2.0
5752
  react-is: 17.0.2
5753
 
 
 
5754
  prop-types@15.8.1:
5755
  dependencies:
5756
  loose-envify: 1.4.0
@@ -5766,6 +5907,8 @@ snapshots:
5766
 
5767
  queue-microtask@1.2.3: {}
5768
 
 
 
5769
  rc@1.2.8:
5770
  dependencies:
5771
  deep-extend: 0.6.0
@@ -5823,6 +5966,8 @@ snapshots:
5823
  dependencies:
5824
  picomatch: 2.3.1
5825
 
 
 
5826
  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):
5827
  dependencies:
5828
  '@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:
5952
  es-errors: 1.3.0
5953
  is-regex: 1.2.1
5954
 
 
 
5955
  safer-buffer@2.1.2: {}
5956
 
5957
  saxes@6.0.0:
@@ -5960,6 +6107,8 @@ snapshots:
5960
 
5961
  scheduler@0.27.0: {}
5962
 
 
 
5963
  semver@6.3.1: {}
5964
 
5965
  semver@7.7.4: {}
@@ -6062,8 +6211,14 @@ snapshots:
6062
  once: 1.4.0
6063
  simple-concat: 1.0.1
6064
 
 
 
 
 
6065
  source-map-js@1.2.1: {}
6066
 
 
 
6067
  stable-hash@0.0.5: {}
6068
 
6069
  stackback@0.0.2: {}
@@ -6139,6 +6294,8 @@ snapshots:
6139
 
6140
  strip-json-comments@3.1.1: {}
6141
 
 
 
6142
  styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
6143
  dependencies:
6144
  client-only: 0.0.1
@@ -6217,6 +6374,10 @@ snapshots:
6217
  dependencies:
6218
  any-promise: 1.3.0
6219
 
 
 
 
 
6220
  tiny-invariant@1.3.3: {}
6221
 
6222
  tinybench@2.9.0: {}
 
32
  next-themes:
33
  specifier: ^0.4.6
34
  version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
35
+ pino:
36
+ specifier: ^10.3.1
37
+ version: 10.3.1
38
  postcss:
39
  specifier: ^8.5.2
40
  version: 8.5.6
 
62
  ws:
63
  specifier: ^8.19.0
64
  version: 8.19.0
65
+ zod:
66
+ specifier: ^4.3.6
67
+ version: 4.3.6
68
  zustand:
69
  specifier: ^5.0.11
70
  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))
 
102
  jsdom:
103
  specifier: ^26.0.0
104
  version: 26.1.0
105
+ pino-pretty:
106
+ specifier: ^13.1.3
107
+ version: 13.1.3
108
  vite-tsconfig-paths:
109
  specifier: ^5.1.4
110
  version: 5.1.4(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.9))
 
686
  resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
687
  engines: {node: '>=12.4.0'}
688
 
689
+ '@pinojs/redact@0.4.0':
690
+ resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
691
+
692
  '@playwright/test@1.58.2':
693
  resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
694
  engines: {node: '>=18'}
 
1358
  resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
1359
  engines: {node: '>= 0.4'}
1360
 
1361
+ atomic-sleep@1.0.0:
1362
+ resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
1363
+ engines: {node: '>=8.0.0'}
1364
+
1365
  autoprefixer@10.4.24:
1366
  resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==}
1367
  engines: {node: ^10 || ^12 || >=14}
 
1486
  color-name@1.1.4:
1487
  resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
1488
 
1489
+ colorette@2.0.20:
1490
+ resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
1491
+
1492
  commander@4.1.1:
1493
  resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
1494
  engines: {node: '>= 6'}
 
1603
  resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
1604
  engines: {node: '>= 0.4'}
1605
 
1606
+ dateformat@4.6.3:
1607
+ resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
1608
+
1609
  debug@3.2.7:
1610
  resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
1611
  peerDependencies:
 
1874
  resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
1875
  engines: {node: '>=12.0.0'}
1876
 
1877
+ fast-copy@4.0.2:
1878
+ resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==}
1879
+
1880
  fast-deep-equal@3.1.3:
1881
  resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1882
 
 
1894
  fast-levenshtein@2.0.6:
1895
  resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
1896
 
1897
+ fast-safe-stringify@2.1.1:
1898
+ resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
1899
+
1900
  fastq@1.20.1:
1901
  resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
1902
 
 
2041
  resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
2042
  engines: {node: '>= 0.4'}
2043
 
2044
+ help-me@5.0.0:
2045
+ resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
2046
+
2047
  hermes-estree@0.25.1:
2048
  resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
2049
 
 
2233
  resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
2234
  hasBin: true
2235
 
2236
+ joycon@3.1.1:
2237
+ resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
2238
+ engines: {node: '>=10'}
2239
+
2240
  js-tokens@4.0.0:
2241
  resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
2242
 
 
2460
  resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
2461
  engines: {node: '>= 0.4'}
2462
 
2463
+ on-exit-leak-free@2.1.2:
2464
+ resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
2465
+ engines: {node: '>=14.0.0'}
2466
+
2467
  once@1.4.0:
2468
  resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
2469
 
 
2523
  resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
2524
  engines: {node: '>=0.10.0'}
2525
 
2526
+ pino-abstract-transport@3.0.0:
2527
+ resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
2528
+
2529
+ pino-pretty@13.1.3:
2530
+ resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
2531
+ hasBin: true
2532
+
2533
+ pino-std-serializers@7.1.0:
2534
+ resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
2535
+
2536
+ pino@10.3.1:
2537
+ resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
2538
+ hasBin: true
2539
+
2540
  pirates@4.0.7:
2541
  resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
2542
  engines: {node: '>= 6'}
 
2619
  resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
2620
  engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
2621
 
2622
+ process-warning@5.0.0:
2623
+ resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
2624
+
2625
  prop-types@15.8.1:
2626
  resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
2627
 
 
2635
  queue-microtask@1.2.3:
2636
  resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
2637
 
2638
+ quick-format-unescaped@4.0.4:
2639
+ resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
2640
+
2641
  rc@1.2.8:
2642
  resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
2643
  hasBin: true
 
2690
  resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
2691
  engines: {node: '>=8.10.0'}
2692
 
2693
+ real-require@0.2.0:
2694
+ resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
2695
+ engines: {node: '>= 12.13.0'}
2696
+
2697
  recharts@3.7.0:
2698
  resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
2699
  engines: {node: '>=18'}
 
2771
  resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
2772
  engines: {node: '>= 0.4'}
2773
 
2774
+ safe-stable-stringify@2.5.0:
2775
+ resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
2776
+ engines: {node: '>=10'}
2777
+
2778
  safer-buffer@2.1.2:
2779
  resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
2780
 
 
2785
  scheduler@0.27.0:
2786
  resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
2787
 
2788
+ secure-json-parse@4.1.0:
2789
+ resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
2790
+
2791
  semver@6.3.1:
2792
  resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
2793
  hasBin: true
 
2846
  simple-get@4.0.1:
2847
  resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
2848
 
2849
+ sonic-boom@4.2.1:
2850
+ resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
2851
+
2852
  source-map-js@1.2.1:
2853
  resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
2854
  engines: {node: '>=0.10.0'}
2855
 
2856
+ split2@4.2.0:
2857
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
2858
+ engines: {node: '>= 10.x'}
2859
+
2860
  stable-hash@0.0.5:
2861
  resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
2862
 
 
2912
  resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
2913
  engines: {node: '>=8'}
2914
 
2915
+ strip-json-comments@5.0.3:
2916
+ resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
2917
+ engines: {node: '>=14.16'}
2918
+
2919
  styled-jsx@5.1.6:
2920
  resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
2921
  engines: {node: '>= 12.0.0'}
 
2967
  thenify@3.3.1:
2968
  resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
2969
 
2970
+ thread-stream@4.0.0:
2971
+ resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
2972
+ engines: {node: '>=20'}
2973
+
2974
  tiny-invariant@1.3.3:
2975
  resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
2976
 
 
3753
 
3754
  '@nolyfill/is-core-module@1.0.39': {}
3755
 
3756
+ '@pinojs/redact@0.4.0': {}
3757
+
3758
  '@playwright/test@1.58.2':
3759
  dependencies:
3760
  playwright: 1.58.2
 
4479
 
4480
  async-function@1.0.0: {}
4481
 
4482
+ atomic-sleep@1.0.0: {}
4483
+
4484
  autoprefixer@10.4.24(postcss@8.5.6):
4485
  dependencies:
4486
  browserslist: 4.28.1
 
4613
 
4614
  color-name@1.1.4: {}
4615
 
4616
+ colorette@2.0.20: {}
4617
+
4618
  commander@4.1.1: {}
4619
 
4620
  concat-map@0.0.1: {}
 
4727
  es-errors: 1.3.0
4728
  is-data-view: 1.0.2
4729
 
4730
+ dateformat@4.6.3: {}
4731
+
4732
  debug@3.2.7:
4733
  dependencies:
4734
  ms: 2.1.3
 
5143
 
5144
  expect-type@1.3.0: {}
5145
 
5146
+ fast-copy@4.0.2: {}
5147
+
5148
  fast-deep-equal@3.1.3: {}
5149
 
5150
  fast-glob@3.3.1:
 
5167
 
5168
  fast-levenshtein@2.0.6: {}
5169
 
5170
+ fast-safe-stringify@2.1.1: {}
5171
+
5172
  fastq@1.20.1:
5173
  dependencies:
5174
  reusify: 1.1.0
 
5303
  dependencies:
5304
  function-bind: 1.1.2
5305
 
5306
+ help-me@5.0.0: {}
5307
+
5308
  hermes-estree@0.25.1: {}
5309
 
5310
  hermes-parser@0.25.1:
 
5497
 
5498
  jiti@1.21.7: {}
5499
 
5500
+ joycon@3.1.1: {}
5501
+
5502
  js-tokens@4.0.0: {}
5503
 
5504
  js-yaml@4.1.1:
 
5721
  define-properties: 1.2.1
5722
  es-object-atoms: 1.1.1
5723
 
5724
+ on-exit-leak-free@2.1.2: {}
5725
+
5726
  once@1.4.0:
5727
  dependencies:
5728
  wrappy: 1.0.2
 
5776
 
5777
  pify@2.3.0: {}
5778
 
5779
+ pino-abstract-transport@3.0.0:
5780
+ dependencies:
5781
+ split2: 4.2.0
5782
+
5783
+ pino-pretty@13.1.3:
5784
+ dependencies:
5785
+ colorette: 2.0.20
5786
+ dateformat: 4.6.3
5787
+ fast-copy: 4.0.2
5788
+ fast-safe-stringify: 2.1.1
5789
+ help-me: 5.0.0
5790
+ joycon: 3.1.1
5791
+ minimist: 1.2.8
5792
+ on-exit-leak-free: 2.1.2
5793
+ pino-abstract-transport: 3.0.0
5794
+ pump: 3.0.3
5795
+ secure-json-parse: 4.1.0
5796
+ sonic-boom: 4.2.1
5797
+ strip-json-comments: 5.0.3
5798
+
5799
+ pino-std-serializers@7.1.0: {}
5800
+
5801
+ pino@10.3.1:
5802
+ dependencies:
5803
+ '@pinojs/redact': 0.4.0
5804
+ atomic-sleep: 1.0.0
5805
+ on-exit-leak-free: 2.1.2
5806
+ pino-abstract-transport: 3.0.0
5807
+ pino-std-serializers: 7.1.0
5808
+ process-warning: 5.0.0
5809
+ quick-format-unescaped: 4.0.4
5810
+ real-require: 0.2.0
5811
+ safe-stable-stringify: 2.5.0
5812
+ sonic-boom: 4.2.1
5813
+ thread-stream: 4.0.0
5814
+
5815
  pirates@4.0.7: {}
5816
 
5817
  playwright-core@1.58.2: {}
 
5890
  ansi-styles: 5.2.0
5891
  react-is: 17.0.2
5892
 
5893
+ process-warning@5.0.0: {}
5894
+
5895
  prop-types@15.8.1:
5896
  dependencies:
5897
  loose-envify: 1.4.0
 
5907
 
5908
  queue-microtask@1.2.3: {}
5909
 
5910
+ quick-format-unescaped@4.0.4: {}
5911
+
5912
  rc@1.2.8:
5913
  dependencies:
5914
  deep-extend: 0.6.0
 
5966
  dependencies:
5967
  picomatch: 2.3.1
5968
 
5969
+ real-require@0.2.0: {}
5970
+
5971
  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):
5972
  dependencies:
5973
  '@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)
 
6097
  es-errors: 1.3.0
6098
  is-regex: 1.2.1
6099
 
6100
+ safe-stable-stringify@2.5.0: {}
6101
+
6102
  safer-buffer@2.1.2: {}
6103
 
6104
  saxes@6.0.0:
 
6107
 
6108
  scheduler@0.27.0: {}
6109
 
6110
+ secure-json-parse@4.1.0: {}
6111
+
6112
  semver@6.3.1: {}
6113
 
6114
  semver@7.7.4: {}
 
6211
  once: 1.4.0
6212
  simple-concat: 1.0.1
6213
 
6214
+ sonic-boom@4.2.1:
6215
+ dependencies:
6216
+ atomic-sleep: 1.0.0
6217
+
6218
  source-map-js@1.2.1: {}
6219
 
6220
+ split2@4.2.0: {}
6221
+
6222
  stable-hash@0.0.5: {}
6223
 
6224
  stackback@0.0.2: {}
 
6294
 
6295
  strip-json-comments@3.1.1: {}
6296
 
6297
+ strip-json-comments@5.0.3: {}
6298
+
6299
  styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
6300
  dependencies:
6301
  client-only: 0.0.1
 
6374
  dependencies:
6375
  any-promise: 1.3.0
6376
 
6377
+ thread-stream@4.0.0:
6378
+ dependencies:
6379
+ real-require: 0.2.0
6380
+
6381
  tiny-invariant@1.3.3: {}
6382
 
6383
  tinybench@2.9.0: {}
src/app/api/activities/route.ts CHANGED
@@ -38,7 +38,7 @@ async function handleActivitiesRequest(request: NextRequest) {
38
  const type = searchParams.get('type');
39
  const actor = searchParams.get('actor');
40
  const entity_type = searchParams.get('entity_type');
41
- const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
42
  const offset = parseInt(searchParams.get('offset') || '0');
43
  const since = searchParams.get('since'); // Unix timestamp for real-time updates
44
 
 
38
  const type = searchParams.get('type');
39
  const actor = searchParams.get('actor');
40
  const entity_type = searchParams.get('entity_type');
41
+ const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 500);
42
  const offset = parseInt(searchParams.get('offset') || '0');
43
  const since = searchParams.get('since'); // Unix timestamp for real-time updates
44
 
src/app/api/agents/route.ts CHANGED
@@ -5,6 +5,9 @@ import { getTemplate, buildAgentConfig } from '@/lib/agent-templates';
5
  import { writeAgentToConfig } from '@/lib/agent-sync';
6
  import { logAuditEvent } from '@/lib/db';
7
  import { getUserFromRequest, requireRole } from '@/lib/auth';
 
 
 
8
 
9
  /**
10
  * GET /api/agents - List all agents with optional filtering
@@ -95,7 +98,7 @@ export async function GET(request: NextRequest) {
95
  limit
96
  });
97
  } catch (error) {
98
- console.error('GET /api/agents error:', error);
99
  return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 });
100
  }
101
  }
@@ -107,9 +110,14 @@ export async function POST(request: NextRequest) {
107
  const auth = requireRole(request, 'operator');
108
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
109
 
 
 
 
110
  try {
111
  const db = getDatabase();
112
- const body = await request.json();
 
 
113
 
114
  const {
115
  name,
@@ -125,16 +133,16 @@ export async function POST(request: NextRequest) {
125
 
126
  // Resolve template if specified
127
  let finalRole = role;
128
- let finalConfig = config;
129
  if (template) {
130
  const tpl = getTemplate(template);
131
  if (tpl) {
132
- const builtConfig = buildAgentConfig(tpl, gateway_config || {});
133
- finalConfig = { ...builtConfig, ...config };
134
  if (!finalRole) finalRole = tpl.config.identity?.theme || tpl.type;
135
  }
136
  } else if (gateway_config) {
137
- finalConfig = { ...config, ...gateway_config };
138
  }
139
 
140
  if (!name || !finalRole) {
@@ -221,7 +229,7 @@ export async function POST(request: NextRequest) {
221
  ip_address: ipAddress,
222
  });
223
  } catch (gwErr: any) {
224
- console.error('Gateway write-back failed:', gwErr);
225
  return NextResponse.json({
226
  agent: parsedAgent,
227
  warning: `Agent created in MC but gateway write failed: ${gwErr.message}`
@@ -231,7 +239,7 @@ export async function POST(request: NextRequest) {
231
 
232
  return NextResponse.json({ agent: parsedAgent }, { status: 201 });
233
  } catch (error) {
234
- console.error('POST /api/agents error:', error);
235
  return NextResponse.json({ error: 'Failed to create agent' }, { status: 500 });
236
  }
237
  }
@@ -243,10 +251,13 @@ export async function PUT(request: NextRequest) {
243
  const auth = requireRole(request, 'operator');
244
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
245
 
 
 
 
246
  try {
247
  const db = getDatabase();
248
  const body = await request.json();
249
-
250
  // Handle single agent update or bulk updates
251
  if (body.name) {
252
  // Single agent update
@@ -343,7 +354,7 @@ export async function PUT(request: NextRequest) {
343
  return NextResponse.json({ error: 'Agent name is required' }, { status: 400 });
344
  }
345
  } catch (error) {
346
- console.error('PUT /api/agents error:', error);
347
  return NextResponse.json({ error: 'Failed to update agent' }, { status: 500 });
348
  }
349
  }
 
5
  import { writeAgentToConfig } from '@/lib/agent-sync';
6
  import { logAuditEvent } from '@/lib/db';
7
  import { getUserFromRequest, requireRole } from '@/lib/auth';
8
+ import { mutationLimiter } from '@/lib/rate-limit';
9
+ import { logger } from '@/lib/logger';
10
+ import { validateBody, createAgentSchema } from '@/lib/validation';
11
 
12
  /**
13
  * GET /api/agents - List all agents with optional filtering
 
98
  limit
99
  });
100
  } catch (error) {
101
+ logger.error({ err: error }, 'GET /api/agents error');
102
  return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 });
103
  }
104
  }
 
110
  const auth = requireRole(request, 'operator');
111
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
112
 
113
+ const rateCheck = mutationLimiter(request);
114
+ if (rateCheck) return rateCheck;
115
+
116
  try {
117
  const db = getDatabase();
118
+ const validated = await validateBody(request, createAgentSchema);
119
+ if ('error' in validated) return validated.error;
120
+ const body = validated.data;
121
 
122
  const {
123
  name,
 
133
 
134
  // Resolve template if specified
135
  let finalRole = role;
136
+ let finalConfig: Record<string, any> = config as Record<string, any>;
137
  if (template) {
138
  const tpl = getTemplate(template);
139
  if (tpl) {
140
+ const builtConfig = buildAgentConfig(tpl, (gateway_config || {}) as any);
141
+ finalConfig = { ...builtConfig, ...finalConfig };
142
  if (!finalRole) finalRole = tpl.config.identity?.theme || tpl.type;
143
  }
144
  } else if (gateway_config) {
145
+ finalConfig = { ...finalConfig, ...(gateway_config as Record<string, any>) };
146
  }
147
 
148
  if (!name || !finalRole) {
 
229
  ip_address: ipAddress,
230
  });
231
  } catch (gwErr: any) {
232
+ logger.error({ err: gwErr }, 'Gateway write-back failed');
233
  return NextResponse.json({
234
  agent: parsedAgent,
235
  warning: `Agent created in MC but gateway write failed: ${gwErr.message}`
 
239
 
240
  return NextResponse.json({ agent: parsedAgent }, { status: 201 });
241
  } catch (error) {
242
+ logger.error({ err: error }, 'POST /api/agents error');
243
  return NextResponse.json({ error: 'Failed to create agent' }, { status: 500 });
244
  }
245
  }
 
251
  const auth = requireRole(request, 'operator');
252
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
253
 
254
+ const rateCheck = mutationLimiter(request);
255
+ if (rateCheck) return rateCheck;
256
+
257
  try {
258
  const db = getDatabase();
259
  const body = await request.json();
260
+
261
  // Handle single agent update or bulk updates
262
  if (body.name) {
263
  // Single agent update
 
354
  return NextResponse.json({ error: 'Agent name is required' }, { status: 400 });
355
  }
356
  } catch (error) {
357
+ logger.error({ err: error }, 'PUT /api/agents error');
358
  return NextResponse.json({ error: 'Failed to update agent' }, { status: 500 });
359
  }
360
  }
src/app/api/alerts/route.ts CHANGED
@@ -1,6 +1,8 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { getDatabase } from '@/lib/db'
 
 
4
 
5
  interface AlertRule {
6
  id: number
@@ -44,6 +46,9 @@ export async function POST(request: NextRequest) {
44
  const auth = requireRole(request, 'operator')
45
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
46
 
 
 
 
47
  const db = getDatabase()
48
  const body = await request.json()
49
 
@@ -52,22 +57,15 @@ export async function POST(request: NextRequest) {
52
  return evaluateRules(db)
53
  }
54
 
55
- // Create new rule
56
- const { name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes } = body
57
-
58
- if (!name || !entity_type || !condition_field || !condition_operator || !condition_value) {
59
- return NextResponse.json({ error: 'Missing required fields: name, entity_type, condition_field, condition_operator, condition_value' }, { status: 400 })
60
- }
61
-
62
- const validEntities = ['agent', 'task', 'session', 'activity']
63
- if (!validEntities.includes(entity_type)) {
64
- return NextResponse.json({ error: `entity_type must be one of: ${validEntities.join(', ')}` }, { status: 400 })
65
  }
66
 
67
- const validOperators = ['equals', 'not_equals', 'greater_than', 'less_than', 'contains', 'count_above', 'count_below', 'age_minutes_above']
68
- if (!validOperators.includes(condition_operator)) {
69
- return NextResponse.json({ error: `condition_operator must be one of: ${validOperators.join(', ')}` }, { status: 400 })
70
- }
71
 
72
  try {
73
  const result = db.prepare(`
@@ -109,6 +107,9 @@ export async function PUT(request: NextRequest) {
109
  const auth = requireRole(request, 'operator')
110
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
111
 
 
 
 
112
  const db = getDatabase()
113
  const body = await request.json()
114
  const { id, ...updates } = body
@@ -147,6 +148,9 @@ export async function DELETE(request: NextRequest) {
147
  const auth = requireRole(request, 'admin')
148
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
149
 
 
 
 
150
  const db = getDatabase()
151
  const body = await request.json()
152
  const { id } = body
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { getDatabase } from '@/lib/db'
4
+ import { mutationLimiter } from '@/lib/rate-limit'
5
+ import { createAlertSchema } from '@/lib/validation'
6
 
7
  interface AlertRule {
8
  id: number
 
46
  const auth = requireRole(request, 'operator')
47
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
48
 
49
+ const rateCheck = mutationLimiter(request)
50
+ if (rateCheck) return rateCheck
51
+
52
  const db = getDatabase()
53
  const body = await request.json()
54
 
 
57
  return evaluateRules(db)
58
  }
59
 
60
+ // Validate for create
61
+ const parseResult = createAlertSchema.safeParse(body)
62
+ if (!parseResult.success) {
63
+ const messages = parseResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
64
+ return NextResponse.json({ error: 'Validation failed', details: messages }, { status: 400 })
 
 
 
 
 
65
  }
66
 
67
+ // Create new rule
68
+ const { name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes } = parseResult.data
 
 
69
 
70
  try {
71
  const result = db.prepare(`
 
107
  const auth = requireRole(request, 'operator')
108
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
109
 
110
+ const rateCheck = mutationLimiter(request)
111
+ if (rateCheck) return rateCheck
112
+
113
  const db = getDatabase()
114
  const body = await request.json()
115
  const { id, ...updates } = body
 
148
  const auth = requireRole(request, 'admin')
149
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
150
 
151
+ const rateCheck = mutationLimiter(request)
152
+ if (rateCheck) return rateCheck
153
+
154
  const db = getDatabase()
155
  const body = await request.json()
156
  const { id } = body
src/app/api/audit/route.ts CHANGED
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
17
  const { searchParams } = new URL(request.url)
18
  const action = searchParams.get('action')
19
  const actor = searchParams.get('actor')
20
- const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
21
  const offset = parseInt(searchParams.get('offset') || '0')
22
  const since = searchParams.get('since')
23
  const until = searchParams.get('until')
 
17
  const { searchParams } = new URL(request.url)
18
  const action = searchParams.get('action')
19
  const actor = searchParams.get('actor')
20
+ const limit = Math.min(parseInt(searchParams.get('limit') || '1000'), 10000)
21
  const offset = parseInt(searchParams.get('offset') || '0')
22
  const since = searchParams.get('since')
23
  const until = searchParams.get('until')
src/app/api/auth/login/route.ts CHANGED
@@ -2,27 +2,13 @@ import { NextResponse } from 'next/server'
2
  import { authenticateUser, createSession } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { getMcSessionCookieOptions } from '@/lib/session-cookie'
5
-
6
- // Rate limiting: 5 attempts per minute per IP
7
- const loginAttempts = new Map<string, { count: number; resetAt: number }>()
8
-
9
- function checkRateLimit(ip: string): boolean {
10
- const now = Date.now()
11
- const entry = loginAttempts.get(ip)
12
- if (!entry || now > entry.resetAt) {
13
- loginAttempts.set(ip, { count: 1, resetAt: now + 60_000 })
14
- return true
15
- }
16
- entry.count++
17
- return entry.count <= 5
18
- }
19
 
20
  export async function POST(request: Request) {
21
  try {
22
- const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
23
- if (!checkRateLimit(ip)) {
24
- return NextResponse.json({ error: 'Too many login attempts. Try again in a minute.' }, { status: 429 })
25
- }
26
 
27
  const { username, password } = await request.json()
28
 
@@ -61,7 +47,7 @@ export async function POST(request: Request) {
61
 
62
  return response
63
  } catch (error) {
64
- console.error('Login error:', error)
65
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
66
  }
67
  }
 
2
  import { authenticateUser, createSession } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { getMcSessionCookieOptions } from '@/lib/session-cookie'
5
+ import { loginLimiter } from '@/lib/rate-limit'
6
+ import { logger } from '@/lib/logger'
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  export async function POST(request: Request) {
9
  try {
10
+ const rateCheck = loginLimiter(request)
11
+ if (rateCheck) return rateCheck
 
 
12
 
13
  const { username, password } = await request.json()
14
 
 
47
 
48
  return response
49
  } catch (error) {
50
+ logger.error({ err: error }, 'Login error')
51
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
52
  }
53
  }
src/app/api/export/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { getDatabase, logAuditEvent } from '@/lib/db'
 
4
 
5
  /**
6
  * 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) {
10
  const auth = requireRole(request, 'admin')
11
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
12
 
 
 
 
13
  const { searchParams } = new URL(request.url)
14
  const type = searchParams.get('type')
15
  const format = searchParams.get('format') || 'csv'
@@ -38,31 +42,35 @@ export async function GET(request: NextRequest) {
38
 
39
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
40
 
 
 
 
 
41
  let rows: any[] = []
42
  let headers: string[] = []
43
  let filename = ''
44
 
45
  switch (type) {
46
  case 'audit': {
47
- rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC`).all(...params)
48
  headers = ['id', 'action', 'actor', 'actor_id', 'target_type', 'target_id', 'detail', 'ip_address', 'user_agent', 'created_at']
49
  filename = 'audit-log'
50
  break
51
  }
52
  case 'tasks': {
53
- rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at DESC`).all(...params)
54
  headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags']
55
  filename = 'tasks'
56
  break
57
  }
58
  case 'activities': {
59
- rows = db.prepare(`SELECT * FROM activities ${where} ORDER BY created_at DESC`).all(...params)
60
  headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at']
61
  filename = 'activities'
62
  break
63
  }
64
  case 'pipelines': {
65
- 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)
66
  headers = ['id', 'pipeline_id', 'pipeline_name', 'status', 'current_step', 'steps_snapshot', 'started_at', 'completed_at', 'triggered_by', 'created_at']
67
  filename = 'pipeline-runs'
68
  break
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { getDatabase, logAuditEvent } from '@/lib/db'
4
+ import { heavyLimiter } from '@/lib/rate-limit'
5
 
6
  /**
7
  * GET /api/export?type=audit|tasks|activities|pipelines&format=csv|json&since=UNIX&until=UNIX
 
11
  const auth = requireRole(request, 'admin')
12
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
13
 
14
+ const rateCheck = heavyLimiter(request)
15
+ if (rateCheck) return rateCheck
16
+
17
  const { searchParams } = new URL(request.url)
18
  const type = searchParams.get('type')
19
  const format = searchParams.get('format') || 'csv'
 
42
 
43
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
44
 
45
+ const requestedLimit = parseInt(searchParams.get('limit') || '10000')
46
+ const maxLimit = 50000
47
+ const limit = Math.min(requestedLimit, maxLimit)
48
+
49
  let rows: any[] = []
50
  let headers: string[] = []
51
  let filename = ''
52
 
53
  switch (type) {
54
  case 'audit': {
55
+ rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
56
  headers = ['id', 'action', 'actor', 'actor_id', 'target_type', 'target_id', 'detail', 'ip_address', 'user_agent', 'created_at']
57
  filename = 'audit-log'
58
  break
59
  }
60
  case 'tasks': {
61
+ rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
62
  headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags']
63
  filename = 'tasks'
64
  break
65
  }
66
  case 'activities': {
67
+ rows = db.prepare(`SELECT * FROM activities ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
68
  headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at']
69
  filename = 'activities'
70
  break
71
  }
72
  case 'pipelines': {
73
+ 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)
74
  headers = ['id', 'pipeline_id', 'pipeline_name', 'status', 'current_step', 'steps_snapshot', 'started_at', 'completed_at', 'triggered_by', 'created_at']
75
  filename = 'pipeline-runs'
76
  break
src/app/api/notifications/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Notification } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
 
4
 
5
  /**
6
  * GET /api/notifications - Get notifications for a specific recipient
@@ -18,7 +19,7 @@ export async function GET(request: NextRequest) {
18
  const recipient = searchParams.get('recipient');
19
  const unread_only = searchParams.get('unread_only') === 'true';
20
  const type = searchParams.get('type');
21
- const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
22
  const offset = parseInt(searchParams.get('offset') || '0');
23
 
24
  if (!recipient) {
@@ -138,6 +139,9 @@ export async function PUT(request: NextRequest) {
138
  const auth = requireRole(request, 'operator');
139
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
140
 
 
 
 
141
  try {
142
  const db = getDatabase();
143
  const body = await request.json();
@@ -193,6 +197,9 @@ export async function DELETE(request: NextRequest) {
193
  const auth = requireRole(request, 'admin');
194
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
195
 
 
 
 
196
  try {
197
  const db = getDatabase();
198
  const body = await request.json();
@@ -244,6 +251,9 @@ export async function POST(request: NextRequest) {
244
  const auth = requireRole(request, 'operator');
245
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
246
 
 
 
 
247
  try {
248
  const db = getDatabase();
249
  const body = await request.json();
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Notification } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
4
+ import { mutationLimiter } from '@/lib/rate-limit';
5
 
6
  /**
7
  * GET /api/notifications - Get notifications for a specific recipient
 
19
  const recipient = searchParams.get('recipient');
20
  const unread_only = searchParams.get('unread_only') === 'true';
21
  const type = searchParams.get('type');
22
+ const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 500);
23
  const offset = parseInt(searchParams.get('offset') || '0');
24
 
25
  if (!recipient) {
 
139
  const auth = requireRole(request, 'operator');
140
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
141
 
142
+ const rateCheck = mutationLimiter(request);
143
+ if (rateCheck) return rateCheck;
144
+
145
  try {
146
  const db = getDatabase();
147
  const body = await request.json();
 
197
  const auth = requireRole(request, 'admin');
198
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
199
 
200
+ const rateCheck = mutationLimiter(request);
201
+ if (rateCheck) return rateCheck;
202
+
203
  try {
204
  const db = getDatabase();
205
  const body = await request.json();
 
251
  const auth = requireRole(request, 'operator');
252
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
253
 
254
+ const rateCheck = mutationLimiter(request);
255
+ if (rateCheck) return rateCheck;
256
+
257
  try {
258
  const db = getDatabase();
259
  const body = await request.json();
src/app/api/sessions/[id]/control/route.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { requireRole } from '@/lib/auth'
3
+ import { runClawdbot } from '@/lib/command'
4
+ import { db_helpers } from '@/lib/db'
5
+
6
+ export async function POST(
7
+ request: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ const auth = requireRole(request, 'operator')
11
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
12
+
13
+ try {
14
+ const { id } = await params
15
+ const { action } = await request.json()
16
+
17
+ if (!['monitor', 'pause', 'terminate'].includes(action)) {
18
+ return NextResponse.json(
19
+ { error: 'Invalid action. Must be: monitor, pause, terminate' },
20
+ { status: 400 }
21
+ )
22
+ }
23
+
24
+ let result
25
+ if (action === 'terminate') {
26
+ result = await runClawdbot(
27
+ ['-c', `sessions_kill("${id}")`],
28
+ { timeoutMs: 10000 }
29
+ )
30
+ } else {
31
+ const message = action === 'monitor'
32
+ ? JSON.stringify({ type: 'control', action: 'monitor' })
33
+ : JSON.stringify({ type: 'control', action: 'pause' })
34
+ result = await runClawdbot(
35
+ ['-c', `sessions_send("${id}", ${JSON.stringify(message)})`],
36
+ { timeoutMs: 10000 }
37
+ )
38
+ }
39
+
40
+ db_helpers.logActivity(
41
+ 'session_control',
42
+ 'session',
43
+ 0,
44
+ auth.user.username,
45
+ `Session ${action}: ${id}`,
46
+ { session_key: id, action }
47
+ )
48
+
49
+ return NextResponse.json({
50
+ success: true,
51
+ action,
52
+ session: id,
53
+ stdout: result.stdout.trim(),
54
+ })
55
+ } catch (error: any) {
56
+ return NextResponse.json(
57
+ { error: error.message || 'Session control failed' },
58
+ { status: 500 }
59
+ )
60
+ }
61
+ }
src/app/api/settings/route.ts CHANGED
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { getDatabase, logAuditEvent } from '@/lib/db'
4
  import { config } from '@/lib/config'
 
5
 
6
  interface SettingRow {
7
  key: string
@@ -101,6 +102,9 @@ export async function PUT(request: NextRequest) {
101
  const auth = requireRole(request, 'admin')
102
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
103
 
 
 
 
104
  const body = await request.json().catch(() => null)
105
  if (!body?.settings || typeof body.settings !== 'object') {
106
  return NextResponse.json({ error: 'settings object required' }, { status: 400 })
@@ -157,6 +161,9 @@ export async function DELETE(request: NextRequest) {
157
  const auth = requireRole(request, 'admin')
158
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
159
 
 
 
 
160
  let body: any
161
  try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) }
162
  const key = body.key
 
2
  import { requireRole } from '@/lib/auth'
3
  import { getDatabase, logAuditEvent } from '@/lib/db'
4
  import { config } from '@/lib/config'
5
+ import { mutationLimiter } from '@/lib/rate-limit'
6
 
7
  interface SettingRow {
8
  key: string
 
102
  const auth = requireRole(request, 'admin')
103
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
104
 
105
+ const rateCheck = mutationLimiter(request)
106
+ if (rateCheck) return rateCheck
107
+
108
  const body = await request.json().catch(() => null)
109
  if (!body?.settings || typeof body.settings !== 'object') {
110
  return NextResponse.json({ error: 'settings object required' }, { status: 400 })
 
161
  const auth = requireRole(request, 'admin')
162
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
163
 
164
+ const rateCheck = mutationLimiter(request)
165
+ if (rateCheck) return rateCheck
166
+
167
  let body: any
168
  try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) }
169
  const key = body.key
src/app/api/spawn/route.ts CHANGED
@@ -4,11 +4,16 @@ import { requireRole } from '@/lib/auth'
4
  import { config } from '@/lib/config'
5
  import { readdir, readFile, stat } from 'fs/promises'
6
  import { join } from 'path'
 
 
7
 
8
  export async function POST(request: NextRequest) {
9
  const auth = requireRole(request, 'operator')
10
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
11
 
 
 
 
12
  try {
13
  const { task, model, label, timeoutSeconds } = await request.json()
14
 
@@ -57,7 +62,7 @@ export async function POST(request: NextRequest) {
57
  sessionInfo = sessionMatch[1]
58
  }
59
  } catch (parseError) {
60
- console.error('Failed to parse session info:', parseError)
61
  }
62
 
63
  return NextResponse.json({
@@ -74,7 +79,7 @@ export async function POST(request: NextRequest) {
74
  })
75
 
76
  } catch (execError: any) {
77
- console.error('Spawn execution error:', execError)
78
 
79
  return NextResponse.json({
80
  success: false,
@@ -89,7 +94,7 @@ export async function POST(request: NextRequest) {
89
  }
90
 
91
  } catch (error) {
92
- console.error('Spawn API error:', error)
93
  return NextResponse.json(
94
  { error: 'Internal server error' },
95
  { status: 500 }
@@ -173,7 +178,7 @@ export async function GET(request: NextRequest) {
173
  }
174
 
175
  } catch (error) {
176
- console.error('Spawn history API error:', error)
177
  return NextResponse.json(
178
  { error: 'Internal server error' },
179
  { status: 500 }
 
4
  import { config } from '@/lib/config'
5
  import { readdir, readFile, stat } from 'fs/promises'
6
  import { join } from 'path'
7
+ import { heavyLimiter } from '@/lib/rate-limit'
8
+ import { logger } from '@/lib/logger'
9
 
10
  export async function POST(request: NextRequest) {
11
  const auth = requireRole(request, 'operator')
12
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
13
 
14
+ const rateCheck = heavyLimiter(request)
15
+ if (rateCheck) return rateCheck
16
+
17
  try {
18
  const { task, model, label, timeoutSeconds } = await request.json()
19
 
 
62
  sessionInfo = sessionMatch[1]
63
  }
64
  } catch (parseError) {
65
+ logger.error({ err: parseError }, 'Failed to parse session info')
66
  }
67
 
68
  return NextResponse.json({
 
79
  })
80
 
81
  } catch (execError: any) {
82
+ logger.error({ err: execError }, 'Spawn execution error')
83
 
84
  return NextResponse.json({
85
  success: false,
 
94
  }
95
 
96
  } catch (error) {
97
+ logger.error({ err: error }, 'Spawn API error')
98
  return NextResponse.json(
99
  { error: 'Internal server error' },
100
  { status: 500 }
 
178
  }
179
 
180
  } catch (error) {
181
+ logger.error({ err: error }, 'Spawn history API error')
182
  return NextResponse.json(
183
  { error: 'Internal server error' },
184
  { status: 500 }
src/app/api/status/route.ts CHANGED
@@ -6,6 +6,7 @@ import { config } from '@/lib/config'
6
  import { getDatabase } from '@/lib/db'
7
  import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions'
8
  import { requireRole } from '@/lib/auth'
 
9
 
10
  export async function GET(request: NextRequest) {
11
  const auth = requireRole(request, 'viewer')
@@ -340,17 +341,8 @@ async function getGatewayStatus() {
340
 
341
  async function getAvailableModels() {
342
  // This would typically query the gateway or config files
343
- // For now, return the models from AGENTS.md
344
- const models = [
345
- { alias: 'haiku', name: 'anthropic/claude-3-5-haiku-latest', provider: 'anthropic', description: 'Ultra-cheap, simple tasks', costPer1k: 0.25 },
346
- { alias: 'sonnet', name: 'anthropic/claude-sonnet-4-20250514', provider: 'anthropic', description: 'Standard workhorse', costPer1k: 3.0 },
347
- { alias: 'opus', name: 'anthropic/claude-opus-4-5', provider: 'anthropic', description: 'Premium quality', costPer1k: 15.0 },
348
- { alias: 'deepseek', name: 'ollama/deepseek-r1:14b', provider: 'ollama', description: 'Local reasoning (free)', costPer1k: 0.0 },
349
- { alias: 'groq-fast', name: 'groq/llama-3.1-8b-instant', provider: 'groq', description: '840 tok/s, ultra fast', costPer1k: 0.05 },
350
- { alias: 'groq', name: 'groq/llama-3.3-70b-versatile', provider: 'groq', description: 'Fast + quality balance', costPer1k: 0.59 },
351
- { alias: 'kimi', name: 'moonshot/kimi-k2.5', provider: 'moonshot', description: 'Alternative provider', costPer1k: 1.0 },
352
- { alias: 'minimax', name: 'minimax/minimax-m2.1', provider: 'minimax', description: 'Cost-effective (1/10th price), strong coding', costPer1k: 0.3 },
353
- ]
354
 
355
  try {
356
  // Check which Ollama models are available locally
 
6
  import { getDatabase } from '@/lib/db'
7
  import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions'
8
  import { requireRole } from '@/lib/auth'
9
+ import { MODEL_CATALOG } from '@/lib/models'
10
 
11
  export async function GET(request: NextRequest) {
12
  const auth = requireRole(request, 'viewer')
 
341
 
342
  async function getAvailableModels() {
343
  // This would typically query the gateway or config files
344
+ // Model catalog is the single source of truth
345
+ const models = [...MODEL_CATALOG]
 
 
 
 
 
 
 
 
 
346
 
347
  try {
348
  // Check which Ollama models are available locally
src/app/api/tasks/[id]/route.ts CHANGED
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Task, db_helpers } from '@/lib/db';
3
  import { eventBus } from '@/lib/event-bus';
4
  import { getUserFromRequest, requireRole } from '@/lib/auth';
 
 
 
5
 
6
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
7
  const review = db.prepare(`
@@ -48,7 +51,7 @@ export async function GET(
48
 
49
  return NextResponse.json({ task: taskWithParsedData });
50
  } catch (error) {
51
- console.error('GET /api/tasks/[id] error:', error);
52
  return NextResponse.json({ error: 'Failed to fetch task' }, { status: 500 });
53
  }
54
  }
@@ -63,11 +66,16 @@ export async function PUT(
63
  const auth = requireRole(request, 'operator');
64
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
65
 
 
 
 
66
  try {
67
  const db = getDatabase();
68
  const resolvedParams = await params;
69
  const taskId = parseInt(resolvedParams.id);
70
- const body = await request.json();
 
 
71
 
72
  if (isNaN(taskId)) {
73
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
@@ -240,7 +248,7 @@ export async function PUT(
240
 
241
  return NextResponse.json({ task: parsedTask });
242
  } catch (error) {
243
- console.error('PUT /api/tasks/[id] error:', error);
244
  return NextResponse.json({ error: 'Failed to update task' }, { status: 500 });
245
  }
246
  }
@@ -255,6 +263,9 @@ export async function DELETE(
255
  const auth = requireRole(request, 'operator');
256
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
257
 
 
 
 
258
  try {
259
  const db = getDatabase();
260
  const resolvedParams = await params;
@@ -294,7 +305,7 @@ export async function DELETE(
294
 
295
  return NextResponse.json({ success: true });
296
  } catch (error) {
297
- console.error('DELETE /api/tasks/[id] error:', error);
298
  return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 });
299
  }
300
  }
 
2
  import { getDatabase, Task, db_helpers } from '@/lib/db';
3
  import { eventBus } from '@/lib/event-bus';
4
  import { getUserFromRequest, requireRole } from '@/lib/auth';
5
+ import { mutationLimiter } from '@/lib/rate-limit';
6
+ import { logger } from '@/lib/logger';
7
+ import { validateBody, updateTaskSchema } from '@/lib/validation';
8
 
9
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
10
  const review = db.prepare(`
 
51
 
52
  return NextResponse.json({ task: taskWithParsedData });
53
  } catch (error) {
54
+ logger.error({ err: error }, 'GET /api/tasks/[id] error');
55
  return NextResponse.json({ error: 'Failed to fetch task' }, { status: 500 });
56
  }
57
  }
 
66
  const auth = requireRole(request, 'operator');
67
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
68
 
69
+ const rateCheck = mutationLimiter(request);
70
+ if (rateCheck) return rateCheck;
71
+
72
  try {
73
  const db = getDatabase();
74
  const resolvedParams = await params;
75
  const taskId = parseInt(resolvedParams.id);
76
+ const validated = await validateBody(request, updateTaskSchema);
77
+ if ('error' in validated) return validated.error;
78
+ const body = validated.data;
79
 
80
  if (isNaN(taskId)) {
81
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
 
248
 
249
  return NextResponse.json({ task: parsedTask });
250
  } catch (error) {
251
+ logger.error({ err: error }, 'PUT /api/tasks/[id] error');
252
  return NextResponse.json({ error: 'Failed to update task' }, { status: 500 });
253
  }
254
  }
 
263
  const auth = requireRole(request, 'operator');
264
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
265
 
266
+ const rateCheck = mutationLimiter(request);
267
+ if (rateCheck) return rateCheck;
268
+
269
  try {
270
  const db = getDatabase();
271
  const resolvedParams = await params;
 
305
 
306
  return NextResponse.json({ success: true });
307
  } catch (error) {
308
+ logger.error({ err: error }, 'DELETE /api/tasks/[id] error');
309
  return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 });
310
  }
311
  }
src/app/api/tasks/route.ts CHANGED
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Task, db_helpers } from '@/lib/db';
3
  import { eventBus } from '@/lib/event-bus';
4
  import { requireRole } from '@/lib/auth';
 
 
 
5
 
6
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
7
  const review = db.prepare(`
@@ -83,7 +86,7 @@ export async function GET(request: NextRequest) {
83
 
84
  return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit });
85
  } catch (error) {
86
- console.error('GET /api/tasks error:', error);
87
  return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
88
  }
89
  }
@@ -95,9 +98,14 @@ export async function POST(request: NextRequest) {
95
  const auth = requireRole(request, 'operator');
96
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
97
 
 
 
 
98
  try {
99
  const db = getDatabase();
100
- const body = await request.json();
 
 
101
 
102
  const user = auth.user
103
  const {
@@ -113,10 +121,6 @@ export async function POST(request: NextRequest) {
113
  metadata = {}
114
  } = body;
115
 
116
- if (!title) {
117
- return NextResponse.json({ error: 'Title is required' }, { status: 400 });
118
- }
119
-
120
  // Check for duplicate title
121
  const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ?').get(title);
122
  if (existingTask) {
@@ -187,7 +191,7 @@ export async function POST(request: NextRequest) {
187
 
188
  return NextResponse.json({ task: parsedTask }, { status: 201 });
189
  } catch (error) {
190
- console.error('POST /api/tasks error:', error);
191
  return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
192
  }
193
  }
@@ -199,6 +203,9 @@ export async function PUT(request: NextRequest) {
199
  const auth = requireRole(request, 'operator');
200
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
201
 
 
 
 
202
  try {
203
  const db = getDatabase();
204
  const { tasks } = await request.json();
@@ -254,7 +261,7 @@ export async function PUT(request: NextRequest) {
254
 
255
  return NextResponse.json({ success: true, updated: tasks.length });
256
  } catch (error) {
257
- console.error('PUT /api/tasks error:', error);
258
  const message = error instanceof Error ? error.message : 'Failed to update tasks'
259
  if (message.includes('Aegis approval required')) {
260
  return NextResponse.json({ error: message }, { status: 403 });
 
2
  import { getDatabase, Task, db_helpers } from '@/lib/db';
3
  import { eventBus } from '@/lib/event-bus';
4
  import { requireRole } from '@/lib/auth';
5
+ import { mutationLimiter } from '@/lib/rate-limit';
6
+ import { logger } from '@/lib/logger';
7
+ import { validateBody, createTaskSchema } from '@/lib/validation';
8
 
9
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
10
  const review = db.prepare(`
 
86
 
87
  return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit });
88
  } catch (error) {
89
+ logger.error({ err: error }, 'GET /api/tasks error');
90
  return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
91
  }
92
  }
 
98
  const auth = requireRole(request, 'operator');
99
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
100
 
101
+ const rateCheck = mutationLimiter(request);
102
+ if (rateCheck) return rateCheck;
103
+
104
  try {
105
  const db = getDatabase();
106
+ const validated = await validateBody(request, createTaskSchema);
107
+ if ('error' in validated) return validated.error;
108
+ const body = validated.data;
109
 
110
  const user = auth.user
111
  const {
 
121
  metadata = {}
122
  } = body;
123
 
 
 
 
 
124
  // Check for duplicate title
125
  const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ?').get(title);
126
  if (existingTask) {
 
191
 
192
  return NextResponse.json({ task: parsedTask }, { status: 201 });
193
  } catch (error) {
194
+ logger.error({ err: error }, 'POST /api/tasks error');
195
  return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
196
  }
197
  }
 
203
  const auth = requireRole(request, 'operator');
204
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
205
 
206
+ const rateCheck = mutationLimiter(request);
207
+ if (rateCheck) return rateCheck;
208
+
209
  try {
210
  const db = getDatabase();
211
  const { tasks } = await request.json();
 
261
 
262
  return NextResponse.json({ success: true, updated: tasks.length });
263
  } catch (error) {
264
+ logger.error({ err: error }, 'PUT /api/tasks error');
265
  const message = error instanceof Error ? error.message : 'Failed to update tasks'
266
  if (message.includes('Aegis approval required')) {
267
  return NextResponse.json({ error: message }, { status: 403 });
src/app/api/webhooks/route.ts CHANGED
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
  import { randomBytes, createHmac } from 'crypto'
 
 
 
5
 
6
  /**
7
  * GET /api/webhooks - List all webhooks with delivery stats
@@ -31,7 +34,7 @@ export async function GET(request: NextRequest) {
31
 
32
  return NextResponse.json({ webhooks: result })
33
  } catch (error) {
34
- console.error('GET /api/webhooks error:', error)
35
  return NextResponse.json({ error: 'Failed to fetch webhooks' }, { status: 500 })
36
  }
37
  }
@@ -43,32 +46,26 @@ export async function POST(request: NextRequest) {
43
  const auth = requireRole(request, 'admin')
44
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
45
 
 
 
 
46
  try {
47
  const db = getDatabase()
48
- const body = await request.json()
 
 
49
  const { name, url, events, generate_secret } = body
50
 
51
- if (!name || !url) {
52
- return NextResponse.json({ error: 'Name and URL are required' }, { status: 400 })
53
- }
54
-
55
- // Validate URL
56
- try {
57
- new URL(url)
58
- } catch {
59
- return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
60
- }
61
-
62
  const secret = generate_secret !== false ? randomBytes(32).toString('hex') : null
63
  const eventsJson = JSON.stringify(events || ['*'])
64
 
65
- const result = db.prepare(`
66
  INSERT INTO webhooks (name, url, secret, events, created_by)
67
  VALUES (?, ?, ?, ?, ?)
68
  `).run(name, url, secret, eventsJson, auth.user.username)
69
 
70
  return NextResponse.json({
71
- id: result.lastInsertRowid,
72
  name,
73
  url,
74
  secret, // Show full secret only on creation
@@ -77,7 +74,7 @@ export async function POST(request: NextRequest) {
77
  message: 'Webhook created. Save the secret - it won\'t be shown again in full.',
78
  })
79
  } catch (error) {
80
- console.error('POST /api/webhooks error:', error)
81
  return NextResponse.json({ error: 'Failed to create webhook' }, { status: 500 })
82
  }
83
  }
@@ -89,6 +86,9 @@ export async function PUT(request: NextRequest) {
89
  const auth = requireRole(request, 'admin')
90
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
91
 
 
 
 
92
  try {
93
  const db = getDatabase()
94
  const body = await request.json()
@@ -132,7 +132,7 @@ export async function PUT(request: NextRequest) {
132
  ...(newSecret ? { secret: newSecret, message: 'New secret generated. Save it now.' } : {}),
133
  })
134
  } catch (error) {
135
- console.error('PUT /api/webhooks error:', error)
136
  return NextResponse.json({ error: 'Failed to update webhook' }, { status: 500 })
137
  }
138
  }
@@ -144,6 +144,9 @@ export async function DELETE(request: NextRequest) {
144
  const auth = requireRole(request, 'admin')
145
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
146
 
 
 
 
147
  try {
148
  const db = getDatabase()
149
  let body: any
@@ -164,7 +167,7 @@ export async function DELETE(request: NextRequest) {
164
 
165
  return NextResponse.json({ success: true, deleted: result.changes })
166
  } catch (error) {
167
- console.error('DELETE /api/webhooks error:', error)
168
  return NextResponse.json({ error: 'Failed to delete webhook' }, { status: 500 })
169
  }
170
  }
 
2
  import { getDatabase } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
  import { randomBytes, createHmac } from 'crypto'
5
+ import { mutationLimiter } from '@/lib/rate-limit'
6
+ import { logger } from '@/lib/logger'
7
+ import { validateBody, createWebhookSchema } from '@/lib/validation'
8
 
9
  /**
10
  * GET /api/webhooks - List all webhooks with delivery stats
 
34
 
35
  return NextResponse.json({ webhooks: result })
36
  } catch (error) {
37
+ logger.error({ err: error }, 'GET /api/webhooks error')
38
  return NextResponse.json({ error: 'Failed to fetch webhooks' }, { status: 500 })
39
  }
40
  }
 
46
  const auth = requireRole(request, 'admin')
47
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
48
 
49
+ const rateCheck = mutationLimiter(request)
50
+ if (rateCheck) return rateCheck
51
+
52
  try {
53
  const db = getDatabase()
54
+ const validated = await validateBody(request, createWebhookSchema)
55
+ if ('error' in validated) return validated.error
56
+ const body = validated.data
57
  const { name, url, events, generate_secret } = body
58
 
 
 
 
 
 
 
 
 
 
 
 
59
  const secret = generate_secret !== false ? randomBytes(32).toString('hex') : null
60
  const eventsJson = JSON.stringify(events || ['*'])
61
 
62
+ const dbResult = db.prepare(`
63
  INSERT INTO webhooks (name, url, secret, events, created_by)
64
  VALUES (?, ?, ?, ?, ?)
65
  `).run(name, url, secret, eventsJson, auth.user.username)
66
 
67
  return NextResponse.json({
68
+ id: dbResult.lastInsertRowid,
69
  name,
70
  url,
71
  secret, // Show full secret only on creation
 
74
  message: 'Webhook created. Save the secret - it won\'t be shown again in full.',
75
  })
76
  } catch (error) {
77
+ logger.error({ err: error }, 'POST /api/webhooks error')
78
  return NextResponse.json({ error: 'Failed to create webhook' }, { status: 500 })
79
  }
80
  }
 
86
  const auth = requireRole(request, 'admin')
87
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
88
 
89
+ const rateCheck = mutationLimiter(request)
90
+ if (rateCheck) return rateCheck
91
+
92
  try {
93
  const db = getDatabase()
94
  const body = await request.json()
 
132
  ...(newSecret ? { secret: newSecret, message: 'New secret generated. Save it now.' } : {}),
133
  })
134
  } catch (error) {
135
+ logger.error({ err: error }, 'PUT /api/webhooks error')
136
  return NextResponse.json({ error: 'Failed to update webhook' }, { status: 500 })
137
  }
138
  }
 
144
  const auth = requireRole(request, 'admin')
145
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
146
 
147
+ const rateCheck = mutationLimiter(request)
148
+ if (rateCheck) return rateCheck
149
+
150
  try {
151
  const db = getDatabase()
152
  let body: any
 
167
 
168
  return NextResponse.json({ success: true, deleted: result.changes })
169
  } catch (error) {
170
+ logger.error({ err: error }, 'DELETE /api/webhooks error')
171
  return NextResponse.json({ error: 'Failed to delete webhook' }, { status: 500 })
172
  }
173
  }
src/app/login/page.tsx CHANGED
@@ -110,7 +110,7 @@ export default function LoginPage() {
110
 
111
  <form onSubmit={handleSubmit} className="space-y-4">
112
  {error && (
113
- <div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
114
  {error}
115
  </div>
116
  )}
@@ -127,6 +127,7 @@ export default function LoginPage() {
127
  autoComplete="username"
128
  autoFocus
129
  required
 
130
  />
131
  </div>
132
 
@@ -141,6 +142,7 @@ export default function LoginPage() {
141
  placeholder="Enter password"
142
  autoComplete="current-password"
143
  required
 
144
  />
145
  </div>
146
 
 
110
 
111
  <form onSubmit={handleSubmit} className="space-y-4">
112
  {error && (
113
+ <div role="alert" className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
114
  {error}
115
  </div>
116
  )}
 
127
  autoComplete="username"
128
  autoFocus
129
  required
130
+ aria-required="true"
131
  />
132
  </div>
133
 
 
142
  placeholder="Enter password"
143
  autoComplete="current-password"
144
  required
145
+ aria-required="true"
146
  />
147
  </div>
148
 
src/app/page.tsx CHANGED
@@ -29,6 +29,7 @@ import { AlertRulesPanel } from '@/components/panels/alert-rules-panel'
29
  import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel'
30
  import { SuperAdminPanel } from '@/components/panels/super-admin-panel'
31
  import { ChatPanel } from '@/components/chat/chat-panel'
 
32
  import { useWebSocket } from '@/lib/websocket'
33
  import { useServerEvents } from '@/lib/use-server-events'
34
  import { useMissionControl } from '@/store'
@@ -86,8 +87,12 @@ export default function Home() {
86
  {/* Center: Header + Content */}
87
  <div className="flex-1 flex flex-col min-w-0">
88
  <HeaderBar />
89
- <main className="flex-1 overflow-auto pb-16 md:pb-0">
90
- <ContentRouter tab={activeTab} />
 
 
 
 
91
  </main>
92
  </div>
93
 
 
29
  import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel'
30
  import { SuperAdminPanel } from '@/components/panels/super-admin-panel'
31
  import { ChatPanel } from '@/components/chat/chat-panel'
32
+ import { ErrorBoundary } from '@/components/ErrorBoundary'
33
  import { useWebSocket } from '@/lib/websocket'
34
  import { useServerEvents } from '@/lib/use-server-events'
35
  import { useMissionControl } from '@/store'
 
87
  {/* Center: Header + Content */}
88
  <div className="flex-1 flex flex-col min-w-0">
89
  <HeaderBar />
90
+ <main className="flex-1 overflow-auto pb-16 md:pb-0" role="main">
91
+ <div aria-live="polite">
92
+ <ErrorBoundary key={activeTab}>
93
+ <ContentRouter tab={activeTab} />
94
+ </ErrorBoundary>
95
+ </div>
96
  </main>
97
  </div>
98
 
src/components/ErrorBoundary.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { Component, type ErrorInfo, type ReactNode } from 'react'
4
+
5
+ interface Props {
6
+ children: ReactNode
7
+ fallback?: ReactNode
8
+ }
9
+
10
+ interface State {
11
+ hasError: boolean
12
+ error: Error | null
13
+ }
14
+
15
+ export class ErrorBoundary extends Component<Props, State> {
16
+ constructor(props: Props) {
17
+ super(props)
18
+ this.state = { hasError: false, error: null }
19
+ }
20
+
21
+ static getDerivedStateFromError(error: Error): State {
22
+ return { hasError: true, error }
23
+ }
24
+
25
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
26
+ console.error('Panel error:', error, errorInfo)
27
+ }
28
+
29
+ render() {
30
+ if (this.state.hasError) {
31
+ if (this.props.fallback) return this.props.fallback
32
+
33
+ return (
34
+ <div className="flex flex-col items-center justify-center h-full min-h-[200px] p-8 text-center">
35
+ <div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
36
+ <svg className="w-6 h-6 text-destructive" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
37
+ <circle cx="12" cy="12" r="10" />
38
+ <line x1="12" y1="8" x2="12" y2="12" />
39
+ <line x1="12" y1="16" x2="12.01" y2="16" />
40
+ </svg>
41
+ </div>
42
+ <h3 className="text-lg font-semibold text-foreground mb-2">Something went wrong</h3>
43
+ <p className="text-sm text-muted-foreground mb-4 max-w-md">
44
+ {this.state.error?.message || 'An unexpected error occurred in this panel.'}
45
+ </p>
46
+ <button
47
+ onClick={() => this.setState({ hasError: false, error: null })}
48
+ className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
49
+ >
50
+ Try again
51
+ </button>
52
+ </div>
53
+ )
54
+ }
55
+
56
+ return this.props.children
57
+ }
58
+ }
src/components/layout/header-bar.tsx CHANGED
@@ -127,7 +127,7 @@ export function HeaderBar() {
127
  }
128
 
129
  return (
130
- <header className="h-12 bg-card/80 backdrop-blur-sm border-b border-border px-4 flex items-center justify-between shrink-0">
131
  {/* Left: Page title + breadcrumb */}
132
  <div className="flex items-center gap-3">
133
  <h1 className="text-sm font-semibold text-foreground">
 
127
  }
128
 
129
  return (
130
+ <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">
131
  {/* Left: Page title + breadcrumb */}
132
  <div className="flex items-center gap-3">
133
  <h1 className="text-sm font-semibold text-foreground">
src/components/layout/nav-rail.tsx CHANGED
@@ -84,6 +84,8 @@ export function NavRail() {
84
  <>
85
  {/* Desktop: Grouped sidebar */}
86
  <nav
 
 
87
  className={`hidden md:flex flex-col bg-card border-r border-border shrink-0 transition-all duration-200 ease-in-out ${
88
  sidebarExpanded ? 'w-[220px]' : 'w-14'
89
  }`}
@@ -199,6 +201,7 @@ function NavButton({ item, active, expanded, onClick }: {
199
  return (
200
  <button
201
  onClick={onClick}
 
202
  className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left transition-smooth relative ${
203
  active
204
  ? 'bg-primary/15 text-primary'
@@ -218,6 +221,7 @@ function NavButton({ item, active, expanded, onClick }: {
218
  <button
219
  onClick={onClick}
220
  title={item.label}
 
221
  className={`w-10 h-10 rounded-lg flex items-center justify-center transition-smooth group relative ${
222
  active
223
  ? 'bg-primary/15 text-primary'
 
84
  <>
85
  {/* Desktop: Grouped sidebar */}
86
  <nav
87
+ role="navigation"
88
+ aria-label="Main navigation"
89
  className={`hidden md:flex flex-col bg-card border-r border-border shrink-0 transition-all duration-200 ease-in-out ${
90
  sidebarExpanded ? 'w-[220px]' : 'w-14'
91
  }`}
 
201
  return (
202
  <button
203
  onClick={onClick}
204
+ aria-current={active ? 'page' : undefined}
205
  className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left transition-smooth relative ${
206
  active
207
  ? 'bg-primary/15 text-primary'
 
221
  <button
222
  onClick={onClick}
223
  title={item.label}
224
+ aria-current={active ? 'page' : undefined}
225
  className={`w-10 h-10 rounded-lg flex items-center justify-center transition-smooth group relative ${
226
  active
227
  ? 'bg-primary/15 text-primary'
src/components/panels/session-details-panel.tsx CHANGED
@@ -26,6 +26,7 @@ export function SessionDetailsPanel() {
26
 
27
  useSmartPoll(loadSessions, 60000, { pauseWhenConnected: true })
28
 
 
29
  const [sessionFilter, setSessionFilter] = useState<'all' | 'active' | 'idle'>('all')
30
  const [sortBy, setSortBy] = useState<'age' | 'tokens' | 'model'>('age')
31
  const [expandedSession, setExpandedSession] = useState<string | null>(null)
@@ -323,36 +324,80 @@ export function SessionDetailsPanel() {
323
  {/* Actions */}
324
  <div className="flex space-x-2">
325
  <button
326
- 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"
327
- onClick={(e) => {
 
328
  e.stopPropagation()
329
- // TODO: Implement session monitoring
330
- console.log('Monitor session:', session.id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  }}
332
  >
333
- Monitor
334
  </button>
335
  <button
336
- 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"
337
- onClick={(e) => {
 
338
  e.stopPropagation()
339
- // TODO: Implement session pause
340
- console.log('Pause session:', session.id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  }}
342
  >
343
- Pause
344
  </button>
345
  <button
346
- 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"
347
- onClick={(e) => {
 
348
  e.stopPropagation()
349
- if (confirm('Are you sure you want to terminate this session?')) {
350
- // TODO: Implement session termination
351
- console.log('Terminate session:', session.id)
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  }
353
  }}
354
  >
355
- Terminate
356
  </button>
357
  </div>
358
  </div>
 
26
 
27
  useSmartPoll(loadSessions, 60000, { pauseWhenConnected: true })
28
 
29
+ const [controllingSession, setControllingSession] = useState<string | null>(null)
30
  const [sessionFilter, setSessionFilter] = useState<'all' | 'active' | 'idle'>('all')
31
  const [sortBy, setSortBy] = useState<'age' | 'tokens' | 'model'>('age')
32
  const [expandedSession, setExpandedSession] = useState<string | null>(null)
 
324
  {/* Actions */}
325
  <div className="flex space-x-2">
326
  <button
327
+ 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"
328
+ disabled={controllingSession !== null}
329
+ onClick={async (e) => {
330
  e.stopPropagation()
331
+ setControllingSession(`monitor-${session.id}`)
332
+ try {
333
+ const res = await fetch(`/api/sessions/${session.id}/control`, {
334
+ method: 'POST',
335
+ headers: { 'Content-Type': 'application/json' },
336
+ body: JSON.stringify({ action: 'monitor' }),
337
+ })
338
+ if (!res.ok) {
339
+ const data = await res.json()
340
+ alert(data.error || 'Failed to monitor session')
341
+ }
342
+ } catch {
343
+ alert('Failed to monitor session')
344
+ } finally {
345
+ setControllingSession(null)
346
+ }
347
  }}
348
  >
349
+ {controllingSession === `monitor-${session.id}` ? 'Working...' : 'Monitor'}
350
  </button>
351
  <button
352
+ 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"
353
+ disabled={controllingSession !== null}
354
+ onClick={async (e) => {
355
  e.stopPropagation()
356
+ setControllingSession(`pause-${session.id}`)
357
+ try {
358
+ const res = await fetch(`/api/sessions/${session.id}/control`, {
359
+ method: 'POST',
360
+ headers: { 'Content-Type': 'application/json' },
361
+ body: JSON.stringify({ action: 'pause' }),
362
+ })
363
+ if (!res.ok) {
364
+ const data = await res.json()
365
+ alert(data.error || 'Failed to pause session')
366
+ }
367
+ } catch {
368
+ alert('Failed to pause session')
369
+ } finally {
370
+ setControllingSession(null)
371
+ }
372
  }}
373
  >
374
+ {controllingSession === `pause-${session.id}` ? 'Working...' : 'Pause'}
375
  </button>
376
  <button
377
+ 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"
378
+ disabled={controllingSession !== null}
379
+ onClick={async (e) => {
380
  e.stopPropagation()
381
+ if (!window.confirm('Are you sure you want to terminate this session?')) return
382
+ setControllingSession(`terminate-${session.id}`)
383
+ try {
384
+ const res = await fetch(`/api/sessions/${session.id}/control`, {
385
+ method: 'POST',
386
+ headers: { 'Content-Type': 'application/json' },
387
+ body: JSON.stringify({ action: 'terminate' }),
388
+ })
389
+ if (!res.ok) {
390
+ const data = await res.json()
391
+ alert(data.error || 'Failed to terminate session')
392
+ }
393
+ } catch {
394
+ alert('Failed to terminate session')
395
+ } finally {
396
+ setControllingSession(null)
397
  }
398
  }}
399
  >
400
+ {controllingSession === `terminate-${session.id}` ? 'Working...' : 'Terminate'}
401
  </button>
402
  </div>
403
  </div>
src/lib/db.ts CHANGED
@@ -4,6 +4,7 @@ import { config, ensureDirExists } from './config';
4
  import { runMigrations } from './migrations';
5
  import { eventBus } from './event-bus';
6
  import { hashPassword } from './password';
 
7
 
8
  // Database file location
9
  const DB_PATH = config.dbPath;
@@ -63,9 +64,9 @@ function initializeSchema() {
63
  }
64
  }
65
 
66
- console.log('Database migrations applied successfully');
67
  } catch (error) {
68
- console.error('Failed to apply database migrations:', error);
69
  throw error;
70
  }
71
  }
@@ -83,7 +84,7 @@ function seedAdminUserFromEnv(dbConn: Database.Database): void {
83
  VALUES (?, ?, ?, ?)
84
  `).run(username, displayName, hashPassword(password), 'admin')
85
 
86
- console.log(`Seeded admin user: ${username}`)
87
  }
88
 
89
  /**
@@ -460,7 +461,7 @@ if (typeof window === 'undefined') { // Only run on server side
460
  try {
461
  getDatabase();
462
  } catch (error) {
463
- console.error('Failed to initialize database:', error);
464
  }
465
  }
466
 
 
4
  import { runMigrations } from './migrations';
5
  import { eventBus } from './event-bus';
6
  import { hashPassword } from './password';
7
+ import { logger } from './logger';
8
 
9
  // Database file location
10
  const DB_PATH = config.dbPath;
 
64
  }
65
  }
66
 
67
+ logger.info('Database migrations applied successfully');
68
  } catch (error) {
69
+ logger.error({ err: error }, 'Failed to apply database migrations');
70
  throw error;
71
  }
72
  }
 
84
  VALUES (?, ?, ?, ?)
85
  `).run(username, displayName, hashPassword(password), 'admin')
86
 
87
+ logger.info(`Seeded admin user: ${username}`)
88
  }
89
 
90
  /**
 
461
  try {
462
  getDatabase();
463
  } catch (error) {
464
+ logger.error({ err: error }, 'Failed to initialize database');
465
  }
466
  }
467
 
src/lib/logger.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pino from 'pino'
2
+
3
+ export const logger = pino({
4
+ level: process.env.LOG_LEVEL || 'info',
5
+ ...(process.env.NODE_ENV !== 'production' && {
6
+ transport: {
7
+ target: 'pino-pretty',
8
+ options: { colorize: true },
9
+ },
10
+ }),
11
+ })
src/lib/models.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface ModelConfig {
2
+ alias: string
3
+ name: string
4
+ provider: string
5
+ description: string
6
+ costPer1k: number
7
+ }
8
+
9
+ export const MODEL_CATALOG: ModelConfig[] = [
10
+ { alias: 'haiku', name: 'anthropic/claude-3-5-haiku-latest', provider: 'anthropic', description: 'Ultra-cheap, simple tasks', costPer1k: 0.25 },
11
+ { alias: 'sonnet', name: 'anthropic/claude-sonnet-4-20250514', provider: 'anthropic', description: 'Standard workhorse', costPer1k: 3.0 },
12
+ { alias: 'opus', name: 'anthropic/claude-opus-4-5', provider: 'anthropic', description: 'Premium quality', costPer1k: 15.0 },
13
+ { alias: 'deepseek', name: 'ollama/deepseek-r1:14b', provider: 'ollama', description: 'Local reasoning (free)', costPer1k: 0.0 },
14
+ { alias: 'groq-fast', name: 'groq/llama-3.1-8b-instant', provider: 'groq', description: '840 tok/s, ultra fast', costPer1k: 0.05 },
15
+ { alias: 'groq', name: 'groq/llama-3.3-70b-versatile', provider: 'groq', description: 'Fast + quality balance', costPer1k: 0.59 },
16
+ { alias: 'kimi', name: 'moonshot/kimi-k2.5', provider: 'moonshot', description: 'Alternative provider', costPer1k: 1.0 },
17
+ { alias: 'minimax', name: 'minimax/minimax-m2.1', provider: 'minimax', description: 'Cost-effective (1/10th price), strong coding', costPer1k: 0.3 },
18
+ ]
19
+
20
+ export function getModelByAlias(alias: string): ModelConfig | undefined {
21
+ return MODEL_CATALOG.find(m => m.alias === alias)
22
+ }
23
+
24
+ export function getModelByName(name: string): ModelConfig | undefined {
25
+ return MODEL_CATALOG.find(m => m.name === name)
26
+ }
27
+
28
+ export function getAllModels(): ModelConfig[] {
29
+ return [...MODEL_CATALOG]
30
+ }
src/lib/rate-limit.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server'
2
+
3
+ interface RateLimitEntry {
4
+ count: number
5
+ resetAt: number
6
+ }
7
+
8
+ interface RateLimiterOptions {
9
+ windowMs: number
10
+ maxRequests: number
11
+ message?: string
12
+ }
13
+
14
+ export function createRateLimiter(options: RateLimiterOptions) {
15
+ const store = new Map<string, RateLimitEntry>()
16
+
17
+ // Periodic cleanup every 60s
18
+ const cleanupInterval = setInterval(() => {
19
+ const now = Date.now()
20
+ for (const [key, entry] of store) {
21
+ if (now > entry.resetAt) store.delete(key)
22
+ }
23
+ }, 60_000)
24
+ // Don't prevent process exit
25
+ if (cleanupInterval.unref) cleanupInterval.unref()
26
+
27
+ return function checkRateLimit(request: Request): NextResponse | null {
28
+ const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
29
+ const now = Date.now()
30
+ const entry = store.get(ip)
31
+
32
+ if (!entry || now > entry.resetAt) {
33
+ store.set(ip, { count: 1, resetAt: now + options.windowMs })
34
+ return null
35
+ }
36
+
37
+ entry.count++
38
+ if (entry.count > options.maxRequests) {
39
+ return NextResponse.json(
40
+ { error: options.message || 'Too many requests. Please try again later.' },
41
+ { status: 429 }
42
+ )
43
+ }
44
+
45
+ return null
46
+ }
47
+ }
48
+
49
+ export const loginLimiter = createRateLimiter({
50
+ windowMs: 60_000,
51
+ maxRequests: 5,
52
+ message: 'Too many login attempts. Try again in a minute.',
53
+ })
54
+
55
+ export const mutationLimiter = createRateLimiter({
56
+ windowMs: 60_000,
57
+ maxRequests: 60,
58
+ })
59
+
60
+ export const heavyLimiter = createRateLimiter({
61
+ windowMs: 60_000,
62
+ maxRequests: 10,
63
+ message: 'Too many requests for this resource. Please try again later.',
64
+ })
src/lib/scheduler.ts CHANGED
@@ -3,6 +3,7 @@ import { syncAgentsFromConfig } from './agent-sync'
3
  import { config, ensureDirExists } from './config'
4
  import { join, dirname } from 'path'
5
  import { readdirSync, statSync, unlinkSync } from 'fs'
 
6
 
7
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
8
 
@@ -209,7 +210,7 @@ export function initScheduler() {
209
 
210
  // Auto-sync agents from openclaw.json on startup
211
  syncAgentsFromConfig('startup').catch(err => {
212
- console.warn('Agent auto-sync failed:', err.message)
213
  })
214
 
215
  // Register tasks
@@ -247,7 +248,7 @@ export function initScheduler() {
247
 
248
  // Start the tick loop
249
  tickInterval = setInterval(tick, TICK_MS)
250
- console.log('Scheduler initialized - backup at ~3AM, cleanup at ~4AM, heartbeat every 5m')
251
  }
252
 
253
  /** Calculate ms until next occurrence of a given hour (UTC) */
 
3
  import { config, ensureDirExists } from './config'
4
  import { join, dirname } from 'path'
5
  import { readdirSync, statSync, unlinkSync } from 'fs'
6
+ import { logger } from './logger'
7
 
8
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
9
 
 
210
 
211
  // Auto-sync agents from openclaw.json on startup
212
  syncAgentsFromConfig('startup').catch(err => {
213
+ logger.warn({ err }, 'Agent auto-sync failed')
214
  })
215
 
216
  // Register tasks
 
248
 
249
  // Start the tick loop
250
  tickInterval = setInterval(tick, TICK_MS)
251
+ logger.info('Scheduler initialized - backup at ~3AM, cleanup at ~4AM, heartbeat every 5m')
252
  }
253
 
254
  /** Calculate ms until next occurrence of a given hour (UTC) */
src/lib/validation.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server'
2
+ import { ZodSchema, ZodError } from 'zod'
3
+ import { z } from 'zod'
4
+
5
+ export async function validateBody<T>(
6
+ request: Request,
7
+ schema: ZodSchema<T>
8
+ ): Promise<{ data: T } | { error: NextResponse }> {
9
+ try {
10
+ const body = await request.json()
11
+ const data = schema.parse(body)
12
+ return { data }
13
+ } catch (err) {
14
+ if (err instanceof ZodError) {
15
+ const messages = err.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
16
+ return {
17
+ error: NextResponse.json(
18
+ { error: 'Validation failed', details: messages },
19
+ { status: 400 }
20
+ ),
21
+ }
22
+ }
23
+ return {
24
+ error: NextResponse.json({ error: 'Invalid request body' }, { status: 400 }),
25
+ }
26
+ }
27
+ }
28
+
29
+ export const createTaskSchema = z.object({
30
+ title: z.string().min(1, 'Title is required').max(500),
31
+ description: z.string().max(5000).optional(),
32
+ status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'done', 'blocked']).default('inbox'),
33
+ priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
34
+ assigned_to: z.string().max(100).optional(),
35
+ created_by: z.string().max(100).optional(),
36
+ due_date: z.number().optional(),
37
+ estimated_hours: z.number().min(0).optional(),
38
+ actual_hours: z.number().min(0).optional(),
39
+ tags: z.array(z.string()).default([] as string[]),
40
+ metadata: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>),
41
+ })
42
+
43
+ export const updateTaskSchema = createTaskSchema.partial()
44
+
45
+ export const createAgentSchema = z.object({
46
+ name: z.string().min(1, 'Name is required').max(100),
47
+ role: z.string().min(1, 'Role is required').max(100).optional(),
48
+ session_key: z.string().max(200).optional(),
49
+ soul_content: z.string().max(50000).optional(),
50
+ status: z.enum(['online', 'offline', 'busy', 'idle', 'error']).default('offline'),
51
+ config: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>),
52
+ template: z.string().max(100).optional(),
53
+ gateway_config: z.record(z.string(), z.unknown()).optional(),
54
+ write_to_gateway: z.boolean().optional(),
55
+ })
56
+
57
+ export const createWebhookSchema = z.object({
58
+ name: z.string().min(1, 'Name is required').max(200),
59
+ url: z.string().url('Invalid URL'),
60
+ events: z.array(z.string()).optional(),
61
+ generate_secret: z.boolean().optional(),
62
+ })
63
+
64
+ export const createAlertSchema = z.object({
65
+ name: z.string().min(1, 'Name is required').max(200),
66
+ description: z.string().max(1000).optional(),
67
+ entity_type: z.enum(['agent', 'task', 'session', 'activity']),
68
+ condition_field: z.string().min(1).max(100),
69
+ condition_operator: z.enum(['equals', 'not_equals', 'greater_than', 'less_than', 'contains', 'count_above', 'count_below', 'age_minutes_above']),
70
+ condition_value: z.string().min(1).max(500),
71
+ action_type: z.string().max(100).optional(),
72
+ action_config: z.record(z.string(), z.unknown()).optional(),
73
+ cooldown_minutes: z.number().min(1).max(10080).optional(),
74
+ })
src/lib/webhooks.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { createHmac } from 'crypto'
2
  import { eventBus, type ServerEvent } from './event-bus'
 
3
 
4
  interface Webhook {
5
  id: number
@@ -46,12 +47,12 @@ export function initWebhookListener() {
46
  const isAgentError = event.type === 'agent.status_changed' && event.data?.status === 'error'
47
 
48
  fireWebhooksAsync(webhookEventType, event.data).catch((err) => {
49
- console.error('Webhook dispatch error:', err)
50
  })
51
 
52
  if (isAgentError) {
53
  fireWebhooksAsync('agent.error', event.data).catch((err) => {
54
- console.error('Webhook dispatch error (agent.error):', err)
55
  })
56
  }
57
  })
@@ -62,7 +63,7 @@ export function initWebhookListener() {
62
  */
63
  export function fireWebhooks(eventType: string, payload: Record<string, any>) {
64
  fireWebhooksAsync(eventType, payload).catch((err) => {
65
- console.error('Webhook dispatch error:', err)
66
  })
67
  }
68
 
 
1
  import { createHmac } from 'crypto'
2
  import { eventBus, type ServerEvent } from './event-bus'
3
+ import { logger } from './logger'
4
 
5
  interface Webhook {
6
  id: number
 
47
  const isAgentError = event.type === 'agent.status_changed' && event.data?.status === 'error'
48
 
49
  fireWebhooksAsync(webhookEventType, event.data).catch((err) => {
50
+ logger.error({ err }, 'Webhook dispatch error')
51
  })
52
 
53
  if (isAgentError) {
54
  fireWebhooksAsync('agent.error', event.data).catch((err) => {
55
+ logger.error({ err }, 'Webhook dispatch error')
56
  })
57
  }
58
  })
 
63
  */
64
  export function fireWebhooks(eventType: string, payload: Record<string, any>) {
65
  fireWebhooksAsync(eventType, payload).catch((err) => {
66
+ logger.error({ err }, 'Webhook dispatch error')
67
  })
68
  }
69
 
src/store/index.ts CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { create } from 'zustand'
4
  import { subscribeWithSelector } from 'zustand/middleware'
 
5
 
6
  // Enhanced types for Mission Control
7
  export interface Session {
@@ -523,16 +524,7 @@ export const useMissionControl = create<MissionControlStore>()(
523
  },
524
 
525
  // Model Configuration
526
- availableModels: [
527
- { alias: 'haiku', name: 'anthropic/claude-3-5-haiku-latest', provider: 'anthropic', description: 'Ultra-cheap, simple tasks', costPer1k: 0.25 },
528
- { alias: 'sonnet', name: 'anthropic/claude-sonnet-4-20250514', provider: 'anthropic', description: 'Standard workhorse', costPer1k: 3.0 },
529
- { alias: 'opus', name: 'anthropic/claude-opus-4-5', provider: 'anthropic', description: 'Premium quality', costPer1k: 15.0 },
530
- { alias: 'deepseek', name: 'ollama/deepseek-r1:14b', provider: 'ollama', description: 'Local reasoning (free)', costPer1k: 0.0 },
531
- { alias: 'groq-fast', name: 'groq/llama-3.1-8b-instant', provider: 'groq', description: '840 tok/s, ultra fast', costPer1k: 0.05 },
532
- { alias: 'groq', name: 'groq/llama-3.3-70b-versatile', provider: 'groq', description: 'Fast + quality balance', costPer1k: 0.59 },
533
- { alias: 'kimi', name: 'moonshot/kimi-k2.5', provider: 'moonshot', description: 'Alternative provider', costPer1k: 1.0 },
534
- { alias: 'minimax', name: 'minimax/minimax-m2.1', provider: 'minimax', description: 'Cost-effective (1/10th price), strong coding', costPer1k: 0.3 },
535
- ],
536
  setAvailableModels: (models) => set({ availableModels: models }),
537
 
538
  // Auth
 
2
 
3
  import { create } from 'zustand'
4
  import { subscribeWithSelector } from 'zustand/middleware'
5
+ import { MODEL_CATALOG } from '@/lib/models'
6
 
7
  // Enhanced types for Mission Control
8
  export interface Session {
 
524
  },
525
 
526
  // Model Configuration
527
+ availableModels: [...MODEL_CATALOG],
 
 
 
 
 
 
 
 
 
528
  setAvailableModels: (models) => set({ availableModels: models }),
529
 
530
  // Auth