From 679e56424b222a2b5818c437e321984aec98f6dc Mon Sep 17 00:00:00 2001 From: OpenCode Date: Mon, 17 Aug 2026 17:22:07 +0700 Subject: [PATCH] Add admin web UI (Phase 2): spend dashboard, media gallery, provider management Fastify + single-page frontend on shared @nexusai/core. Reads the same SQLite journal and media dir. Features: usage stats (by provider/capability/day), content gallery (image/audio/video with local file serving + path-traversal guard), provider CRUD with encrypted keys (masked in UI), enable/disable, set-default, and real key validation via zero-cost /media/models call. Adds Journal.getById and NordRouterMediaProvider.checkKey to core. --- NexusAI/.env.example | 4 + NexusAI/README.md | 26 +- NexusAI/package-lock.json | 830 +++++++++++++++++- NexusAI/package.json | 7 +- NexusAI/packages/admin/package.json | 24 + NexusAI/packages/admin/public/app.js | 169 ++++ NexusAI/packages/admin/public/index.html | 148 ++++ NexusAI/packages/admin/src/context.ts | 29 + NexusAI/packages/admin/src/routes.ts | 165 ++++ NexusAI/packages/admin/src/server.ts | 59 ++ NexusAI/packages/admin/tsconfig.json | 17 + NexusAI/packages/core/src/db/journal.ts | 12 + .../core/src/providers/nordrouter/media.ts | 10 + 13 files changed, 1495 insertions(+), 5 deletions(-) create mode 100644 NexusAI/packages/admin/package.json create mode 100644 NexusAI/packages/admin/public/app.js create mode 100644 NexusAI/packages/admin/public/index.html create mode 100644 NexusAI/packages/admin/src/context.ts create mode 100644 NexusAI/packages/admin/src/routes.ts create mode 100644 NexusAI/packages/admin/src/server.ts create mode 100644 NexusAI/packages/admin/tsconfig.json 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 `
${media}
+
${it.capability} · ${cost}
+
${it.model}
+
${new Date(it.createdAt).toLocaleString()}
+
`; +} +$('#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 + + + +
+

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) {