diff --git a/NexusAI/.env.example b/NexusAI/.env.example
index 9f28979..1c0e0ad 100644
--- a/NexusAI/.env.example
+++ b/NexusAI/.env.example
@@ -41,5 +41,9 @@ NEXUS_STT_COMPUTE=auto # auto | int8 | float16
# HuggingFace mirror (for restricted networks)
# HF_ENDPOINT=https://hf-mirror.com
+# --- Admin web UI (packages/admin) ---
+NEXUS_ADMIN_HOST=127.0.0.1
+NEXUS_ADMIN_PORT=4123
+
# --- Proxy (optional) ---
# NEXUS_HTTP_PROXY=http://proxy:8080
diff --git a/NexusAI/README.md b/NexusAI/README.md
index 9a4b1a3..18f2ce2 100644
--- a/NexusAI/README.md
+++ b/NexusAI/README.md
@@ -16,8 +16,9 @@
```
NexusAI/
├── packages/
-│ ├── core/ # провайдеры, media-storage, journal, типы, registry
-│ └── mcp-server/ # stdio MCP-сервер (10 инструментов)
+│ ├── core/ # провайдеры, media-storage, journal, ключи, типы, registry
+│ ├── mcp-server/ # stdio MCP-сервер (10 инструментов)
+│ └── admin/ # веб-админка (Fastify + лёгкий фронт)
├── sidecar/ # faster-whisper STT (PEP 723, запуск через uv)
├── .env.example
└── .opencode/opencode.json
@@ -148,6 +149,27 @@ faster-whisper скачивает модель с HuggingFace. Если `hugging
- либо предзагрузить модель и указать путь: `NEXUS_STT_MODEL=/abs/model-dir` + `NEXUS_STT_LOCAL_ONLY=1`
- либо кэш-директорию: `NEXUS_STT_DOWNLOAD_ROOT=./models`
+## Веб-админка
+
+Отдельный пакет `@nexusai/admin` (Fastify + одностраничный фронт) на общем `core`. Читает тот же SQLite и media-каталог.
+
+```bash
+npm run build
+NEXUS_MASTER_KEY=$(openssl rand -hex 32) \
+NEXUS_MEDIA_DIR=./media-output \
+npm run admin
+# → http://127.0.0.1:4123
+```
+
+Возможности:
+- **Потребление** — дашборд трат: суммарно, по провайдерам, по возможностям, по дням (из журнала).
+- **Галерея** — просмотр сгенерированного контента (изображения/аудио/видео), фильтр по возможности.
+- **Провайдеры** — добавить/включить/выключить, назначить default, ввести ключ (шифруется), **проверить ключ** реальным вызовом. Ключи показываются только маской.
+
+Переменные: `NEXUS_ADMIN_HOST` (default `127.0.0.1`), `NEXUS_ADMIN_PORT` (default `4123`), `NEXUS_MASTER_KEY` (для ключей).
+
+> Важно: изменения провайдеров/ключей в админке подхватываются MCP-сервером **при рестарте** (hot-reload — в планах). По умолчанию админка слушает только localhost.
+
## Расширение: новый провайдер
1. Реализовать `MediaProvider` / `SearchProvider` / `SttProvider` из `@nexusai/core`.
diff --git a/NexusAI/package-lock.json b/NexusAI/package-lock.json
index c8e11f5..0393b35 100644
--- a/NexusAI/package-lock.json
+++ b/NexusAI/package-lock.json
@@ -10,9 +10,129 @@
"license": "MIT",
"workspaces": [
"packages/core",
- "packages/mcp-server"
+ "packages/mcp-server",
+ "packages/admin"
]
},
+ "node_modules/@fastify/accept-negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
+ "integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@fastify/ajv-compiler": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
+ "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==",
+ "dependencies": {
+ "ajv": "^8.11.0",
+ "ajv-formats": "^2.1.1",
+ "fast-uri": "^2.0.0"
+ }
+ },
+ "node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
+ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.4.tgz",
+ "integrity": "sha512-GntYZbd2KSiFfoZI3Y02rXKihfsPwdWfiHrwKVLuU1i810D0SYw7fCarLxaRO2VvneTrbzCxSz3GnvEfUiApug=="
+ },
+ "node_modules/@fastify/error": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
+ "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ=="
+ },
+ "node_modules/@fastify/fast-json-stringify-compiler": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz",
+ "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==",
+ "dependencies": {
+ "fast-json-stringify": "^5.7.0"
+ }
+ },
+ "node_modules/@fastify/merge-json-schemas": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz",
+ "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3"
+ }
+ },
+ "node_modules/@fastify/send": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz",
+ "integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.1",
+ "escape-html": "~1.0.3",
+ "fast-decode-uri-component": "^1.0.1",
+ "http-errors": "2.0.0",
+ "mime": "^3.0.0"
+ }
+ },
+ "node_modules/@fastify/send/node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/@fastify/send/node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/@fastify/static": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz",
+ "integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==",
+ "dependencies": {
+ "@fastify/accept-negotiator": "^1.0.0",
+ "@fastify/send": "^2.0.0",
+ "content-disposition": "^0.5.3",
+ "fastify-plugin": "^4.0.0",
+ "fastq": "^1.17.0",
+ "glob": "^10.3.4"
+ }
+ },
+ "node_modules/@fastify/static/node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/@hono/node-server": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
@@ -24,6 +144,30 @@
"hono": "^4"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@lukeed/ms": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
+ "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
@@ -63,6 +207,10 @@
}
}
},
+ "node_modules/@nexusai/admin": {
+ "resolved": "packages/admin",
+ "link": true
+ },
"node_modules/@nexusai/core": {
"resolved": "packages/core",
"link": true
@@ -71,6 +219,20 @@
"resolved": "packages/mcp-server",
"link": true
},
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -89,6 +251,11 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/abstract-logging": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
+ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -132,6 +299,50 @@
}
}
},
+ "node_modules/ansi-regex": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
+ "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/avvio": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz",
+ "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==",
+ "dependencies": {
+ "@fastify/error": "^3.3.0",
+ "fastq": "^1.17.1"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -214,6 +425,14 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/brace-expansion": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -277,6 +496,22 @@
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
},
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+ },
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -409,11 +644,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -557,11 +802,48 @@
"express": ">= 4.11"
}
},
+ "node_modules/fast-content-type-parse": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
+ "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ=="
+ },
+ "node_modules/fast-decode-uri-component": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
+ "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
+ "node_modules/fast-json-stringify": {
+ "version": "5.16.1",
+ "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz",
+ "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==",
+ "dependencies": {
+ "@fastify/merge-json-schemas": "^0.1.0",
+ "ajv": "^8.10.0",
+ "ajv-formats": "^3.0.1",
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^2.1.0",
+ "json-schema-ref-resolver": "^1.0.1",
+ "rfdc": "^1.2.0"
+ }
+ },
+ "node_modules/fast-json-stringify/node_modules/fast-uri": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.4.tgz",
+ "integrity": "sha512-GntYZbd2KSiFfoZI3Y02rXKihfsPwdWfiHrwKVLuU1i810D0SYw7fCarLxaRO2VvneTrbzCxSz3GnvEfUiApug=="
+ },
+ "node_modules/fast-querystring": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
+ "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
+ "dependencies": {
+ "fast-decode-uri-component": "^1.0.1"
+ }
+ },
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
@@ -577,6 +859,52 @@
}
]
},
+ "node_modules/fastify": {
+ "version": "4.29.1",
+ "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz",
+ "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "dependencies": {
+ "@fastify/ajv-compiler": "^3.5.0",
+ "@fastify/error": "^3.4.0",
+ "@fastify/fast-json-stringify-compiler": "^4.3.0",
+ "abstract-logging": "^2.0.1",
+ "avvio": "^8.3.0",
+ "fast-content-type-parse": "^1.1.0",
+ "fast-json-stringify": "^5.8.0",
+ "find-my-way": "^8.0.0",
+ "light-my-request": "^5.11.0",
+ "pino": "^9.0.0",
+ "process-warning": "^3.0.0",
+ "proxy-addr": "^2.0.7",
+ "rfdc": "^1.3.0",
+ "secure-json-parse": "^2.7.0",
+ "semver": "^7.5.4",
+ "toad-cache": "^3.3.0"
+ }
+ },
+ "node_modules/fastify-plugin": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
+ "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ=="
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -602,6 +930,34 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/find-my-way": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
+ "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-querystring": "^1.0.0",
+ "safe-regex2": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -671,6 +1027,26 @@
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -791,6 +1167,14 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -801,6 +1185,20 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/jose": {
"version": "6.2.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
@@ -809,6 +1207,14 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/json-schema-ref-resolver": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
+ "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -819,6 +1225,21 @@
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="
},
+ "node_modules/light-my-request": {
+ "version": "5.14.0",
+ "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
+ "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==",
+ "dependencies": {
+ "cookie": "^0.7.0",
+ "process-warning": "^3.0.0",
+ "set-cookie-parser": "^2.4.1"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -850,6 +1271,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/mime": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
+ "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -884,6 +1316,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -892,6 +1338,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -945,6 +1399,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -964,6 +1426,11 @@
"wrappy": "1"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -980,6 +1447,21 @@
"node": ">=8"
}
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -989,6 +1471,55 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/pino": {
+ "version": "9.14.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
+ "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^2.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^3.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
+ "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
+ },
+ "node_modules/pino/node_modules/process-warning": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
+ "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ]
+ },
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -1023,6 +1554,11 @@
"node": ">=10"
}
},
+ "node_modules/process-warning": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
+ "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1059,6 +1595,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
+ },
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@@ -1112,6 +1653,14 @@
"node": ">= 6"
}
},
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1120,6 +1669,28 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ret": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz",
+ "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -1154,11 +1725,32 @@
}
]
},
+ "node_modules/safe-regex2": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
+ "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==",
+ "dependencies": {
+ "ret": "~0.4.0"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
+ "node_modules/secure-json-parse": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
+ "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="
+ },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -1213,6 +1805,11 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1305,6 +1902,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -1348,6 +1956,22 @@
"simple-concat": "^1.0.0"
}
},
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1364,6 +1988,94 @@
"safe-buffer": "~5.2.0"
}
},
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -1398,6 +2110,22 @@
"node": ">=6"
}
},
+ "node_modules/thread-stream": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
+ "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ }
+ },
+ "node_modules/toad-cache": {
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
+ "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1508,6 +2236,90 @@
"node": ">= 8"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1529,6 +2341,21 @@
"zod": "^3.25.28 || ^4"
}
},
+ "packages/admin": {
+ "version": "0.1.0",
+ "dependencies": {
+ "@fastify/static": "^7.0.0",
+ "@nexusai/core": "*",
+ "fastify": "^4.28.0"
+ },
+ "bin": {
+ "nexusai-admin": "dist/server.js"
+ },
+ "devDependencies": {
+ "@types/node": "^22.15.0",
+ "typescript": "^5.8.0"
+ }
+ },
"packages/core": {
"name": "@nexusai/core",
"version": "0.1.0",
@@ -1544,6 +2371,7 @@
}
},
"packages/mcp-server": {
+ "name": "@nexusai/mcp-server",
"version": "0.1.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
diff --git a/NexusAI/package.json b/NexusAI/package.json
index b0a41a7..62bf8aa 100644
--- a/NexusAI/package.json
+++ b/NexusAI/package.json
@@ -5,13 +5,16 @@
"description": "Universal MCP service for agents: TTS, STT, image/video/music generation and web search",
"workspaces": [
"packages/core",
- "packages/mcp-server"
+ "packages/mcp-server",
+ "packages/admin"
],
"scripts": {
- "build": "npm run build --workspace @nexusai/core && npm run build --workspace @nexusai/mcp-server",
+ "build": "npm run build --workspace @nexusai/core && npm run build --workspace @nexusai/mcp-server && npm run build --workspace @nexusai/admin",
"build:core": "npm run build --workspace @nexusai/core",
"build:mcp": "npm run build --workspace @nexusai/mcp-server",
+ "build:admin": "npm run build --workspace @nexusai/admin",
"start": "node packages/mcp-server/dist/index.js",
+ "admin": "node packages/admin/dist/server.js",
"dev": "npm run build && node packages/mcp-server/dist/index.js"
},
"license": "MIT"
diff --git a/NexusAI/packages/admin/package.json b/NexusAI/packages/admin/package.json
new file mode 100644
index 0000000..3e82a0f
--- /dev/null
+++ b/NexusAI/packages/admin/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@nexusai/admin",
+ "version": "0.1.0",
+ "description": "NexusAI admin: web dashboard for content gallery, spend tracking and provider/key management",
+ "main": "dist/server.js",
+ "type": "commonjs",
+ "bin": {
+ "nexusai-admin": "dist/server.js"
+ },
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "start": "node dist/server.js"
+ },
+ "dependencies": {
+ "@fastify/static": "^7.0.0",
+ "@nexusai/core": "*",
+ "fastify": "^4.28.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.15.0",
+ "typescript": "^5.8.0"
+ }
+}
diff --git a/NexusAI/packages/admin/public/app.js b/NexusAI/packages/admin/public/app.js
new file mode 100644
index 0000000..32a48fb
--- /dev/null
+++ b/NexusAI/packages/admin/public/app.js
@@ -0,0 +1,169 @@
+'use strict';
+
+const $ = (sel) => document.querySelector(sel);
+const $$ = (sel) => Array.from(document.querySelectorAll(sel));
+
+function toast(msg) {
+ const el = document.createElement('div');
+ el.className = 'toast';
+ el.textContent = msg;
+ document.body.appendChild(el);
+ setTimeout(() => el.remove(), 2600);
+}
+
+async function api(path, opts) {
+ const res = await fetch(path, {
+ headers: { 'Content-Type': 'application/json' },
+ ...opts,
+ });
+ const text = await res.text();
+ let data = null;
+ try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
+ if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
+ return data;
+}
+
+function usd(n) { return (n == null ? 0 : n).toFixed(4); }
+
+// --- Tabs ---
+$$('nav button').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ $$('nav button').forEach((b) => b.classList.remove('active'));
+ btn.classList.add('active');
+ $$('main section').forEach((s) => s.classList.add('hidden'));
+ $(`#tab-${btn.dataset.tab}`).classList.remove('hidden');
+ if (btn.dataset.tab === 'usage') loadStats();
+ if (btn.dataset.tab === 'gallery') loadGallery();
+ if (btn.dataset.tab === 'providers') loadProviders();
+ });
+});
+
+// --- Usage ---
+async function loadStats() {
+ const range = $('#statsRange').value;
+ const q = range ? `?sinceDays=${range}` : '';
+ const s = await api(`/api/stats${q}`);
+ $('#statCards').innerHTML = `
+
$${usd(s.totalCostUsd)}
Всего потрачено
+ ${s.totalCount}
Всего операций
+ ${s.byProvider.length}
Провайдеров
+ ${s.byCapability.length}
Типов операций
`;
+ const fill = (id, rows, key) => {
+ $(`#${id} tbody`).innerHTML = rows.map((r) =>
+ `| ${r[key]} | ${r.count} | $${usd(r.costUsd)} |
`).join('')
+ || '| Нет данных |
';
+ };
+ fill('byProvider', s.byProvider, 'provider');
+ fill('byCapability', s.byCapability, 'capability');
+ fill('byDay', s.byDay, 'day');
+}
+$('#statsRange').addEventListener('change', loadStats);
+
+// --- Gallery ---
+async function loadGallery() {
+ const cap = $('#galleryFilter').value;
+ const q = cap ? `?capability=${cap}&limit=60` : '?limit=60';
+ const { items } = await api(`/api/generations${q}`);
+ $('#gallery').innerHTML = items.map(renderTile).join('')
+ || 'Пока нет сгенерированного контента.
';
+}
+function renderTile(it) {
+ const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
+ let media = '';
+ if (it.mediaUrl) {
+ if (it.capability === 'image') media = `
`;
+ else if (it.capability === 'tts' || it.capability === 'music') media = ``;
+ else if (it.capability === 'video') media = ``;
+ else media = `Открыть файл`;
+ } else {
+ const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
+ media = `${status}
`;
+ }
+ return ``;
+}
+$('#galleryReload').addEventListener('click', loadGallery);
+$('#galleryFilter').addEventListener('change', loadGallery);
+
+// --- Providers ---
+let CRYPTO_ENABLED = true;
+async function loadProviders() {
+ const { providers, knownTypes, cryptoEnabled } = await api('/api/providers');
+ CRYPTO_ENABLED = cryptoEnabled;
+ $('#cryptoBadge').textContent = cryptoEnabled
+ ? 'Шифрование ключей: включено'
+ : '⚠ NEXUS_MASTER_KEY не задан — ключи из БД недоступны';
+ const typeSel = $('#pType');
+ if (!typeSel.options.length) {
+ typeSel.innerHTML = knownTypes.map((t) => ``).join('');
+ }
+ $('#providersTable tbody').innerHTML = providers.map((p) => {
+ const status = [
+ p.isDefault ? 'default' : '',
+ p.enabled ? 'включён' : 'выключен',
+ ].join(' ');
+ return `
+ | ${p.name} |
+ ${p.type} |
+ ${p.hasKey ? `${p.keyHint || '••••'}` : 'нет'} |
+ ${status} |
+
+
+
+
+
+ |
+
`;
+ }).join('') || '| Нет провайдеров. Добавьте выше. |
';
+
+ $$('[data-check]').forEach((b) => b.onclick = () => checkProvider(b.dataset.check));
+ $$('[data-toggle]').forEach((b) => b.onclick = () => toggleProvider(b.dataset.toggle, b.dataset.en !== 'true'));
+ $$('[data-default]').forEach((b) => b.onclick = () => setDefault(b.dataset.default));
+ $$('[data-del]').forEach((b) => b.onclick = () => delProvider(b.dataset.del));
+}
+
+$('#pSave').addEventListener('click', async () => {
+ try {
+ const body = {
+ name: $('#pName').value.trim(),
+ type: $('#pType').value,
+ baseUrl: $('#pBaseUrl').value.trim() || undefined,
+ apiKey: $('#pKey').value.trim() || undefined,
+ isDefault: $('#pDefault').value === 'true',
+ enabled: $('#pEnabled').value === 'true',
+ };
+ if (!body.name) return toast('Укажите имя');
+ if (body.apiKey && !CRYPTO_ENABLED) return toast('Нельзя сохранить ключ без NEXUS_MASTER_KEY');
+ await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
+ $('#pKey').value = '';
+ toast('Сохранено');
+ loadProviders();
+ } catch (e) { toast('Ошибка: ' + e.message); }
+});
+
+async function checkProvider(name) {
+ try {
+ toast('Проверяю ключ...');
+ const r = await api(`/api/providers/${encodeURIComponent(name)}/check`, { method: 'POST', body: '{}' });
+ toast(r.ok ? `OK · моделей: ${r.modelsCount}` : `Ошибка ключа: ${r.error}`);
+ } catch (e) { toast('Ошибка: ' + e.message); }
+}
+async function toggleProvider(name, enabled) {
+ try { await api(`/api/providers/${encodeURIComponent(name)}/enabled`, { method: 'POST', body: JSON.stringify({ enabled }) }); loadProviders(); }
+ catch (e) { toast('Ошибка: ' + e.message); }
+}
+async function setDefault(name) {
+ try { await api(`/api/providers/${encodeURIComponent(name)}/default`, { method: 'POST', body: '{}' }); loadProviders(); }
+ catch (e) { toast('Ошибка: ' + e.message); }
+}
+async function delProvider(name) {
+ if (!confirm(`Удалить провайдера "${name}"?`)) return;
+ try { await api(`/api/providers/${encodeURIComponent(name)}`, { method: 'DELETE' }); loadProviders(); }
+ catch (e) { toast('Ошибка: ' + e.message); }
+}
+
+// initial
+loadStats();
diff --git a/NexusAI/packages/admin/public/index.html b/NexusAI/packages/admin/public/index.html
new file mode 100644
index 0000000..77dca9b
--- /dev/null
+++ b/NexusAI/packages/admin/public/index.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+ NexusAI Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
По провайдерам
+
| Провайдер | Кол-во | Стоимость, $ |
|---|
+
+
+
По возможностям
+
| Возможность | Кол-во | Стоимость, $ |
|---|
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Добавить / обновить провайдера
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Провайдеры
+
+ | Имя | Тип | Ключ | Статус | Действия |
+
+
+
+
+
+
+
+
diff --git a/NexusAI/packages/admin/src/context.ts b/NexusAI/packages/admin/src/context.ts
new file mode 100644
index 0000000..fea2e1d
--- /dev/null
+++ b/NexusAI/packages/admin/src/context.ts
@@ -0,0 +1,29 @@
+import {
+ Journal,
+ KeyCipher,
+ ProvidersStore,
+ loadConfig,
+ createLogger,
+ type NexusConfig,
+} from '@nexusai/core';
+
+const log = createLogger('admin:context');
+
+export interface AdminContext {
+ config: NexusConfig;
+ journal: Journal;
+ store: ProvidersStore;
+ cipher: KeyCipher;
+}
+
+export function buildAdminContext(): AdminContext {
+ const config = loadConfig();
+ const journal = new Journal(config.dbPath);
+ const cipher = new KeyCipher(config.masterKey);
+ const store = new ProvidersStore(config.providersDbPath, cipher);
+
+ if (!cipher.enabled) {
+ log.warn('NEXUS_MASTER_KEY not set — provider keys cannot be added/decrypted from the admin');
+ }
+ return { config, journal, store, cipher };
+}
diff --git a/NexusAI/packages/admin/src/routes.ts b/NexusAI/packages/admin/src/routes.ts
new file mode 100644
index 0000000..9aaf684
--- /dev/null
+++ b/NexusAI/packages/admin/src/routes.ts
@@ -0,0 +1,165 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import type { FastifyInstance } from 'fastify';
+import {
+ NexusError,
+ NordRouterClient,
+ NordRouterMediaProvider,
+ MediaStorage,
+ createLogger,
+} from '@nexusai/core';
+import type { AdminContext } from './context.js';
+
+const log = createLogger('admin:api');
+
+const KNOWN_TYPES = ['nordrouter'];
+
+function errorReply(err: unknown): { statusCode: number; body: { error: string; code: string } } {
+ const e = NexusError.from(err);
+ const statusCode =
+ e.code === 'NOT_FOUND' ? 404 : e.code === 'CONFIG' || e.code === 'BAD_REQUEST' ? 400 : 500;
+ return { statusCode, body: { error: e.message, code: e.code } };
+}
+
+export function registerRoutes(app: FastifyInstance, ctx: AdminContext): void {
+ // --- Providers ---
+ app.get('/api/providers', async () => {
+ return { providers: ctx.store.listViews(), knownTypes: KNOWN_TYPES, cryptoEnabled: ctx.cipher.enabled };
+ });
+
+ app.post('/api/providers', async (req, reply) => {
+ try {
+ const body = req.body as {
+ name?: string;
+ type?: string;
+ baseUrl?: string;
+ apiKey?: string;
+ enabled?: boolean;
+ isDefault?: boolean;
+ };
+ if (!body?.name || !body?.type) {
+ throw new NexusError('BAD_REQUEST', 'name and type are required');
+ }
+ if (body.apiKey && !ctx.cipher.enabled) {
+ throw new NexusError('CONFIG', 'NEXUS_MASTER_KEY must be set to store an API key');
+ }
+ ctx.store.upsert({
+ name: body.name,
+ type: body.type,
+ baseUrl: body.baseUrl ?? null,
+ apiKey: body.apiKey,
+ enabled: body.enabled,
+ isDefault: body.isDefault,
+ });
+ return { ok: true, provider: ctx.store.get(body.name) };
+ } catch (err) {
+ const { statusCode, body } = errorReply(err);
+ return reply.code(statusCode).send(body);
+ }
+ });
+
+ app.post('/api/providers/:name/enabled', async (req, reply) => {
+ try {
+ const { name } = req.params as { name: string };
+ const { enabled } = req.body as { enabled: boolean };
+ ctx.store.setEnabled(name, enabled);
+ return { ok: true };
+ } catch (err) {
+ const { statusCode, body } = errorReply(err);
+ return reply.code(statusCode).send(body);
+ }
+ });
+
+ app.post('/api/providers/:name/default', async (req, reply) => {
+ try {
+ const { name } = req.params as { name: string };
+ ctx.store.setDefault(name);
+ return { ok: true };
+ } catch (err) {
+ const { statusCode, body } = errorReply(err);
+ return reply.code(statusCode).send(body);
+ }
+ });
+
+ app.delete('/api/providers/:name', async (req, reply) => {
+ try {
+ const { name } = req.params as { name: string };
+ ctx.store.remove(name);
+ return { ok: true };
+ } catch (err) {
+ const { statusCode, body } = errorReply(err);
+ return reply.code(statusCode).send(body);
+ }
+ });
+
+ // Validate a key: uses provided key, or the stored one, or env fallback.
+ app.post('/api/providers/:name/check', async (req, reply) => {
+ try {
+ const { name } = req.params as { name: string };
+ const body = (req.body ?? {}) as { apiKey?: string; baseUrl?: string };
+ const row = ctx.store.get(name);
+ const type = row?.type ?? name;
+ if (!KNOWN_TYPES.includes(type)) {
+ throw new NexusError('UNSUPPORTED', `Key check not implemented for type "${type}"`);
+ }
+ const apiKey =
+ body.apiKey ||
+ ctx.store.getApiKey(name) ||
+ process.env[`${name.toUpperCase()}_API_KEY`];
+ if (!apiKey) throw new NexusError('CONFIG', 'No API key available to check');
+ const baseUrl = body.baseUrl || row?.baseUrl || 'https://nordrouter.com';
+
+ const client = new NordRouterClient(apiKey, baseUrl);
+ const provider = new NordRouterMediaProvider(client, { save: false });
+ const result = await provider.checkKey();
+ return result;
+ } catch (err) {
+ const { statusCode, body } = errorReply(err);
+ return reply.code(statusCode).send(body);
+ }
+ });
+
+ // --- Usage / stats ---
+ app.get('/api/stats', async (req) => {
+ const q = req.query as { sinceDays?: string };
+ const sinceDays = q.sinceDays ? Number.parseInt(q.sinceDays, 10) : undefined;
+ return ctx.journal.stats(Number.isFinite(sinceDays as number) ? sinceDays : undefined);
+ });
+
+ // --- Generations (gallery) ---
+ app.get('/api/generations', async (req) => {
+ const q = req.query as { limit?: string; offset?: string; capability?: string };
+ const limit = Math.min(200, q.limit ? Number.parseInt(q.limit, 10) : 50);
+ const offset = q.offset ? Number.parseInt(q.offset, 10) : 0;
+ const items = ctx.journal.list(limit, offset, q.capability);
+ // Add a media URL for locally-saved files that still exist.
+ const withUrls = items.map((it) => ({
+ ...it,
+ mediaUrl: it.filePath && fs.existsSync(it.filePath) ? `/media/${path.basename(it.filePath)}` : null,
+ }));
+ return { items: withUrls, limit, offset };
+ });
+
+ app.get('/api/generations/:id', async (req, reply) => {
+ const { id } = req.params as { id: string };
+ const rec = ctx.journal.getById(id);
+ if (!rec) return reply.code(404).send({ error: 'not found', code: 'NOT_FOUND' });
+ return {
+ ...rec,
+ mediaUrl: rec.filePath && fs.existsSync(rec.filePath) ? `/media/${path.basename(rec.filePath)}` : null,
+ };
+ });
+
+ log.info('routes registered');
+}
+
+/** Guard: only serve files that live inside the media dir. */
+export function isInsideMediaDir(mediaDir: string, requested: string): boolean {
+ const resolved = path.resolve(mediaDir, requested);
+ const rel = path.relative(path.resolve(mediaDir), resolved);
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
+}
+
+export function ensureStorage(mediaDir: string): MediaStorage {
+ return new MediaStorage(mediaDir);
+}
diff --git a/NexusAI/packages/admin/src/server.ts b/NexusAI/packages/admin/src/server.ts
new file mode 100644
index 0000000..43afb14
--- /dev/null
+++ b/NexusAI/packages/admin/src/server.ts
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+import * as fs from 'fs';
+import * as path from 'path';
+import Fastify from 'fastify';
+import fastifyStatic from '@fastify/static';
+import { createLogger } from '@nexusai/core';
+import { buildAdminContext } from './context.js';
+import { registerRoutes } from './routes.js';
+
+const log = createLogger('admin');
+
+async function main(): Promise {
+ const ctx = buildAdminContext();
+ const app = Fastify({ logger: false });
+
+ registerRoutes(app, ctx);
+
+ // Serve generated media files from the configured media dir.
+ const mediaDir = path.resolve(ctx.config.mediaDir);
+ app.get('/media/:file', async (req, reply) => {
+ const { file } = req.params as { file: string };
+ // Prevent path traversal: only a bare filename is allowed.
+ if (file.includes('/') || file.includes('..') || path.isAbsolute(file)) {
+ return reply.code(400).send({ error: 'invalid file name' });
+ }
+ const full = path.join(mediaDir, file);
+ if (!full.startsWith(mediaDir + path.sep) || !fs.existsSync(full)) {
+ return reply.code(404).send({ error: 'not found' });
+ }
+ const stream = fs.createReadStream(full);
+ reply.header('Cache-Control', 'private, max-age=60');
+ return reply.send(stream);
+ });
+
+ // Serve the static frontend (built into ./public next to dist).
+ const publicDir = path.resolve(__dirname, '../public');
+ if (fs.existsSync(publicDir)) {
+ await app.register(fastifyStatic, { root: publicDir, prefix: '/' });
+ } else {
+ log.warn(`public dir not found at ${publicDir}; UI will not be served`);
+ }
+
+ const port = Number.parseInt(process.env.NEXUS_ADMIN_PORT || '4123', 10);
+ const host = process.env.NEXUS_ADMIN_HOST || '127.0.0.1';
+
+ try {
+ await app.listen({ port, host });
+ log.info(`NexusAI admin running at http://${host}:${port}`);
+ log.info(`media dir: ${mediaDir}`);
+ if (!ctx.cipher.enabled) {
+ log.warn('crypto disabled (NEXUS_MASTER_KEY unset): cannot add/decrypt provider keys');
+ }
+ } catch (err) {
+ log.error('failed to start admin', err instanceof Error ? err.message : String(err));
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/NexusAI/packages/admin/tsconfig.json b/NexusAI/packages/admin/tsconfig.json
new file mode 100644
index 0000000..3869c10
--- /dev/null
+++ b/NexusAI/packages/admin/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "moduleResolution": "node",
+ "declaration": false,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/NexusAI/packages/core/src/db/journal.ts b/NexusAI/packages/core/src/db/journal.ts
index 8f24886..0f0ee36 100644
--- a/NexusAI/packages/core/src/db/journal.ts
+++ b/NexusAI/packages/core/src/db/journal.ts
@@ -79,6 +79,18 @@ export class Journal {
}
}
+ getById(id: string): GenerationRecord | undefined {
+ const row = this.db
+ .prepare(
+ `SELECT id, provider, capability, model, input_summary as inputSummary, status,
+ cost_usd as costUsd, file_path as filePath, result_url as resultUrl,
+ error, duration_ms as durationMs, created_at as createdAt
+ FROM generations WHERE id = ?`
+ )
+ .get(id);
+ return row as GenerationRecord | undefined;
+ }
+
list(limit = 50, offset = 0, capability?: string): GenerationRecord[] {
const where = capability ? 'WHERE capability = ?' : '';
const params = capability ? [capability, limit, offset] : [limit, offset];
diff --git a/NexusAI/packages/core/src/providers/nordrouter/media.ts b/NexusAI/packages/core/src/providers/nordrouter/media.ts
index a318cd9..805b8a9 100644
--- a/NexusAI/packages/core/src/providers/nordrouter/media.ts
+++ b/NexusAI/packages/core/src/providers/nordrouter/media.ts
@@ -147,6 +147,16 @@ export class NordRouterMediaProvider implements MediaProvider {
return { ref: res, url: res.url };
}
+ /** Validate the API key with a zero-cost authenticated call. */
+ async checkKey(): Promise<{ ok: boolean; modelsCount?: number; error?: string }> {
+ try {
+ const models = await this.listModels();
+ return { ok: true, modelsCount: models.length };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ }
+
async listModels(): Promise {
const now = Date.now();
if (this.modelsCache && now - this.modelsCache.at < this.modelsTtlMs) {