diff --git a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md index aa2b1397..e2717039 100644 --- a/BACKEND_OPERATIONS_AUDIT_2026-07-10.md +++ b/BACKEND_OPERATIONS_AUDIT_2026-07-10.md @@ -38,12 +38,17 @@ ## Прогресс исправлений -Обновлено: 2026-07-13. +Обновлено: 2026-07-14. | Задача | Статус | Коммит | Результат | | --- | --- | --- | --- | | OPS-001 | Выполнено | `baa690b` | Preparation failures наблюдаемы в SSE; созданная generation финализируется; пустой assistant scaffolding удаляется при ошибке до создания generation. | | OPS-002 | Выполнено | `3dad07e` | Assistant rewrite сохраняется в main-part; required/optional persistence failures соблюдают policy; UI получает `turn.assistant.canonicalized`. | +| OPS-026 | Выполнено | Текущие изменения | `operation.finished` передаёт stable error code, безопасное сообщение и abort reason; frontend показывает причину и сохраняет её в Run Trace; required barrier перечисляет проблемные operation IDs. | +| OPS-003 | Выполнено | Текущие изменения | Backend слушает loopback по умолчанию; LAN требует opt-in; CORS использует allowlist и отклоняет чужие origins; request payload не переопределяет trusted owner. | +| OPS-004 | Выполнено | Текущие изменения | Profile/block CRUD, export, activation, settings, bundle export и runtime resolution используют обязательный owner scope; cross-owner block refs запрещены. | +| OPS-008 | Выполнено | Текущие изменения | Concurrent operation execution ограничен четырьмя задачами; queued DAG tasks сохраняют прежнюю abort semantics. | +| OPS-009 | Выполнено | Текущие изменения | Добавлены лимиты blocks/operations/dependencies/templates/retries/LLM output, artifact values и history; лимиты проверяются на validation/runtime boundaries. | Проверки после OPS-002: @@ -52,7 +57,31 @@ - `yarn verify:server` и `yarn verify:web` прошли; - `yarn build:server` и `yarn build:web` прошли. -Phase A завершена частично: OPS-001 и OPS-002 закрыты, передача operation errors в SSE остаётся следующей задачей. +Проверки после OPS-026: + +- backend: 101 test files, 516 tests passed; +- frontend: 37 test files, 124 tests passed; +- `yarn verify:server` и `yarn verify:web` прошли; +- `yarn build:server` и `yarn build:web` прошли. + +Phase A завершена: terminal generation state, assistant rewrite persistence и actionable operation errors покрыты regression-тестами. + +Проверки после OPS-003, OPS-004, OPS-008 и OPS-009: + +- backend: 106 test files, 544 tests passed; +- `yarn verify:server` и `yarn build:server` прошли; +- `yarn docs:check` прошёл для RU и EN. + +### Важные незакрытые задачи + +Security и bounded-execution core из Phase B завершены. Наиболее важный остаток: + +1. [ ] **OPS-014 (P1): compile/validate profile при create/update/import.** Activation теперь компилирует профиль заранее, но невалидную композицию всё ещё можно сохранить как неактивную. +2. [ ] **OPS-005–OPS-007 + OPS-020 (P1): transaction/state correctness.** Knowledge mutations, activation counters и effects ещё способны оставить partial state или потерять updates при ошибках и concurrent runs. +3. [ ] **OPS-021 + OPS-022 (P1): atomic import/cutover и optimistic concurrency.** Ошибка multi-write import оставляет orphan/partial records, а параллельное редактирование profile/block молча перетирает изменения. +4. [ ] **OPS-013 (P1): строгая sampler validation.** Общие execution limits уже действуют, но provider samplers всё ещё требуют корректных диапазонов. + +После security и bounded-execution batch следует переходить к полной transaction redesign, а не смешивать её с небольшими contract fixes. ## Как Operations работают сейчас @@ -160,7 +189,9 @@ Operation может закончиться как `done`, effect — как `ap - required rewrite failure завершает run ошибкой; - optional failure сохраняет исходный assistant text. -### OPS-003. Local backend не имеет строгой сетевой границы +### OPS-003. Local backend не имеет строгой сетевой границы — выполнено + +Статус: выполнено 2026-07-19 в текущих изменениях. Код: @@ -186,7 +217,9 @@ Operation может закончиться как `done`, effect — как `ap - неподтверждённые origins отклоняются; - body не может переопределить trusted owner scope. -### OPS-004. Operation repositories не соблюдают owner scope +### OPS-004. Operation repositories не соблюдают owner scope — выполнено + +Статус: выполнено 2026-07-19 в текущих изменениях. Код: @@ -269,7 +302,9 @@ Effects применяются по одному. При ошибке она з - формализовать policy: `atomic_per_operation`, `atomic_per_hook` или `best_effort`; - для required operations по умолчанию использовать atomic semantics. -### OPS-008. `concurrent` запускает неограниченное число операций +### OPS-008. `concurrent` запускает неограниченное число операций — выполнено + +Статус: выполнено 2026-07-19 в текущих изменениях. Код: @@ -294,7 +329,9 @@ Runtime не передаёт `concurrency`, поэтому orchestrator исп - предупреждать или отклонять чрезмерно дорогие profiles; - сохранять abort semantics для queued tasks. -### OPS-009. Нет resource limits для operation profile и artifacts +### OPS-009. Нет resource limits для operation profile и artifacts — выполнено + +Статус: выполнено 2026-07-19 в текущих изменениях. Код: @@ -589,7 +626,9 @@ Update использует read-modify-write и считает `version + 1` в - при необходимости replay сохранять canonical compiled spec; - либо ввести immutable revisions/audit log profile и blocks. -### OPS-026. Operation errors не попадают в SSE completion events +### OPS-026. Operation errors не попадают в SSE completion events — выполнено + +Статус: выполнено 2026-07-14 в текущих изменениях. Код: @@ -604,6 +643,15 @@ Update использует read-modify-write и считает `version + 1` в - перечислять failing operation IDs в required barrier message; - редактировать provider errors и секретные данные. +Результат: + +- terminal details добавлены в typed `orch.task.finished`; +- `operation.finished` получает stable error code, sanitized message и abort reason; +- сообщения ограничены по длине, credential-like values редактируются; +- Run Trace сохраняет error message, а failure toast показывает actionable description; +- required barrier message перечисляет failing operation IDs; +- добавлены backend и frontend regression-тесты. + ### OPS-027. Phase status не отражает реальные operation errors Код: @@ -907,7 +955,7 @@ Config revision и manual reset должны иметь раздельно оп 1. [x] Исправить OPS-001. 2. [x] Добавить preparation failure events и cleanup. 3. [x] Исправить assistant rewrite persistence из OPS-002. -4. [ ] Добавить operation errors в SSE. +4. [x] Добавить operation errors в SSE. 5. [x] Сначала написать regression tests для выполненных задач. Критерии завершения: @@ -919,10 +967,10 @@ Config revision и manual reset должны иметь раздельно оп ### Phase B. Security и bounded execution -1. Исправить owner scope. -2. Bind на loopback и ограничить CORS. -3. Ввести concurrency cap. -4. Добавить profile/artifact resource limits. +1. [x] Исправить owner scope. +2. [x] Bind на loopback и ограничить CORS. +3. [x] Ввести concurrency cap. +4. [x] Добавить profile/artifact resource limits. 5. Усилить sampler validation. Критерии завершения: @@ -985,9 +1033,9 @@ Config revision и manual reset должны иметь раздельно оп 1. [x] Добавить failing tests на preparation errors и assistant rewrite persistence. 2. [x] Изменить `runChatGenerationV3`, чтобы все failure paths завершались наблюдаемо. 3. [x] Persist-ить `turn.assistant.replace_text` через отдельный handler. -4. [ ] Передавать error information в `operation.finished`. -5. [ ] Compile/validate profile до activation. -6. [ ] Добавить conservative concurrency cap. +4. [x] Передавать error information в `operation.finished`. +5. [x] Compile/validate profile до activation. +6. [x] Добавить conservative concurrency cap. Этот batch исправит пользовательскую correctness, не требуя одновременно завершать полную transaction redesign. diff --git a/README.md b/README.md index bb8792e6..0a8da2e6 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ Copy-Item .\env.example .\.env At minimum, you should review these values before regular use: +- `TALESPINNER_ACCESS_MODE` — keep `local` for a trusted machine or use + `public` with the complete hardened configuration; - `TOKENS_MASTER_KEY` — master key used to encrypt stored tokens; - `PORT`, `DATA_DIR`, `DB_PATH` if you need custom runtime paths; - `CHROMA_*` only if you plan to use RAG/ChromaDB. @@ -103,11 +105,12 @@ Vite prints the exact frontend URL in the terminal after startup. ### First working flow 1. Open the app in your browser. -2. Create your first profile. -3. Connect an LLM provider in `LLM Settings`. -4. Select a token and model. -5. Create or open a chat. -6. Configure `World Info` and `Operations` if needed. +2. Create the first user account. In local mode its password may be empty. +3. Create your first character profile. +4. Connect an LLM provider in `LLM Settings`. +5. Select a token and model. +6. Create or open a chat. +7. Configure `World Info` and `Operations` if needed. ## Useful commands @@ -126,6 +129,7 @@ yarn --cwd server test The repository includes separate documentation for users and developers. - User onboarding: `docs/docs/user/getting-started.md` +- Accounts and secure hosting: `docs/docs/user/accounts-and-security.md` - Everyday chat workflow: `docs/docs/user/chat-basics.md` - World Info: `docs/docs/user/world-info.md` - Operations: `docs/docs/user/operations.md` diff --git a/USER_ACCOUNTS_IMPLEMENTATION_PLAN.md b/USER_ACCOUNTS_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..c6748a4a --- /dev/null +++ b/USER_ACCOUNTS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,174 @@ +# Система пользователей и режимы доступа TaleSpinner + +## Цель + +Добавить несколько изолированных аккаунтов без ухудшения локального UX: + +- в режиме `local` разрешить аккаунты без пароля, быстрый выбор и автоматический вход; +- в режиме `public` включать обязательную аутентификацию и полный набор защит; +- всегда определять владельца данных на сервере, не доверяя `ownerId` из HTTP-запроса; +- сохранить существующие данные с владельцем `global` при обновлении приложения. + +## Инварианты + +1. Количество аккаунтов не зависит от режима доступа. +2. Изоляция данных обязательна в `local` и `public`. +3. Режим меняет проверку личности и защиту HTTP, но не правила владения данными. +4. `user_persons` остаются игровыми персонами внутри аккаунта и не заменяют `users`. +5. Клиент не может выбрать произвольный `ownerId`. +6. Публичный режим не запускается при неполной security-конфигурации. +7. Пароли, session tokens, API-ключи и setup secrets не попадают в логи или API DTO. + +## Конфигурация + +Основной переключатель: + +```env +TALESPINNER_ACCESS_MODE=local # local | public +``` + +Планируемые параметры публичного режима: + +```env +TALESPINNER_SESSION_SECRET= +TALESPINNER_SETUP_TOKEN= +TALESPINNER_ALLOW_REGISTRATION=false +TALESPINNER_SESSION_TTL_DAYS=30 +TALESPINNER_TRUST_PROXY=false +``` + +`public` должен активировать согласованный набор защит целиком. Опциональные флаги +не должны позволять по отдельности отключать обязательные secure cookies, CSRF +или rate limiting. + +## Модель данных + +### `users` + +- `id`; +- `username` и нормализованное уникальное значение для входа; +- `display_name`; +- nullable `password_hash` только для локальных passwordless-аккаунтов; +- роль `admin | user`; +- статус `active | disabled`; +- версия credentials для отзыва сессий; +- даты создания, изменения и последнего входа. + +### `auth_sessions` + +- идентификатор сессии; +- ссылка на пользователя; +- только hash session token; +- способ входа `local | password`; +- даты создания, последней активности, истечения и отзыва. + +Первый пользователь принимает существующий scope `global`, чтобы старые чаты и +настройки не потерялись. Новые пользователи получают собственный UUID scope. +Это действие должно быть атомарным и идемпотентным. + +## Этапы реализации + +### 1. Фундамент backend — реализован + +- [x] Зафиксировать архитектурный план. +- [x] Добавить единый resolver политики `local | public`. +- [x] Закрывать запуск `public` при неполной security-конфигурации. +- [x] Добавить таблицы `users` и `auth_sessions`. +- [x] Расширить request context типизированным authenticated actor. +- [x] Добавить репозитории пользователей и сессий без выдачи секретных полей. +- [x] Добавить интеграционные тесты миграции и ограничений уникальности. + +### 2. Credentials и сессии + +- [x] Выбрать и подключить поддерживаемую реализацию Argon2id. +- [x] Хэшировать пароль только на сервере. +- [x] Генерировать криптографически случайные session tokens. +- [x] Хранить в БД только hash токена. +- [x] Реализовать создание, продление, отзыв и очистку истёкших сессий. +- [x] Отзывать сессии при смене пароля или отключении пользователя. + +### 3. Setup и локальный вход + +- [x] Добавить endpoint состояния первоначальной настройки без утечки данных. +- [x] Создавать первого администратора и принимать legacy scope `global`. +- [x] В `local` разрешить пустой пароль. +- [x] Автоматически входить в единственный passwordless-аккаунт. +- [x] Для нескольких аккаунтов поддержать быстрый выбор. Последний аккаунт + восстанавливается серверной session cookie, отдельный неподписанный owner-id не хранится. +- [x] Не позволять local auto-login выбирать аккаунт через неподписанный `ownerId`. + +### 4. Публичный режим + +- [x] Требовать пароль и валидную session-конфигурацию. +- [x] Защитить первоначальный setup токеном из окружения. +- [x] Использовать `HttpOnly`, `Secure`, `SameSite` cookies. +- [x] Добавить CSRF-защиту для изменяющих запросов. +- [x] Добавить rate limiting только неуспешных попыток и безопасные ошибки входа. +- [x] Настроить security headers, proxy trust и проверку HTTPS. +- [x] Добавить безопасное восстановление доступа администратора. + +### 5. Перевод API на trusted owner scope + +- [x] Auth middleware устанавливает actor и owner scope до маршрутизации. +- [x] Игнорировать клиентский `ownerId`; репозитории берут владельца только из + authenticated AsyncLocalStorage scope. +- [x] Все пользовательские get/update/delete выполняются по `id + ownerId` либо + проходят эквивалентную owner-проверку родительского ресурса. +- [x] Проверять одинакового владельца у связанных сущностей. +- [x] Сохранить отдельный явно привилегированный admin API только для управления аккаунтами. + +### 6. Аудит хранилищ + +- [x] Чаты, ветки, сообщения, варианты и bulk-операции. +- [x] Персоны и entity profiles. +- [x] World Info, instructions и operation profiles. +- [x] RAG, Chroma collections и knowledge store. +- [x] LLM presets, provider credentials и runtime state. +- [x] UI settings, темы, фоны, bundles и импорты. +- [x] Загруженные файлы, аватары и защита путей. + +### 7. Frontend + +- [x] Мастер первого запуска. +- [x] Вход, выход и восстановление сессии. +- [x] Локальный account picker и auto-login. +- [x] Базовое создание аккаунтов администратором. +- [x] Переключение аккаунта с полным сбросом Effector state/cache. +- [x] RU/EN локализация добавленных экранов. + +### 8. Миграция и совместимость + +- [x] Автоматически связать legacy `global` data с первым аккаунтом. +- [x] Не создавать публичного администратора без setup token. +- [x] Проверить обновление существующей БД и чистую установку. +- [x] Добавить резервную копию и диагностику перед необратимой миграцией. + +### 9. Проверки готовности + +- [x] Unit-тесты политики доступа, credentials и session lifecycle. +- [x] Интеграционные тесты cross-owner read/write/delete для chats, entries, + operations, LLM, RAG/Chroma, file-backed settings, media и backgrounds. +- [x] API-тесты local/public setup и login/logout. +- [x] Тесты CSRF, cookies, rate limiting и session revocation. +- [x] API-интеграционные сценарии первого запуска и нескольких аккаунтов. +- [x] Аудит отсутствия секретов в auth DTO, публичных ошибках и session storage. +- [x] Обновить RU/EN документацию и `env.example`. + +## Критерий завершения + +Фича готова, когда два одновременно созданных аккаунта не могут получить данные +друг друга ни через UI, ни через прямые HTTP-запросы, локальный пользователь может +работать без пароля, а публичный сервер отказывается запускаться или обслуживать +запросы без полностью настроенной защиты. + +## Текущее состояние + +Последнее обновление: 2026-07-27. Рабочая ветка: `agent/user-access-modes`. + +Реализация и security-review завершены. Итоговая проверка: + +- server: typecheck, lint и production build пройдены; +- server tests: 123 файла, 595 тестов пройдены; +- web: typecheck, lint, 43 файла/139 тестов и production build пройдены; +- E2E: smoke 5, full matrix 9 и black-box 1 сценарий пройдены; +- docs: генерация API, RU/EN parity и production builds пройдены. diff --git a/docs/docs/dev/backend/api-endpoints.md b/docs/docs/dev/backend/api-endpoints.md index 826b0ab1..bd1736a1 100644 --- a/docs/docs/dev/backend/api-endpoints.md +++ b/docs/docs/dev/backend/api-endpoints.md @@ -17,9 +17,25 @@ description: Автогенерируемый инвентарь backend endpoin | Method | Path | Source file | Handler section | | --- | --- | --- | --- | +| GET | /api/app-backgrounds | `server/src/api/app-backgrounds.core.api.ts` | L68 | +| DELETE | /api/app-backgrounds/:id | `server/src/api/app-backgrounds.core.api.ts` | L102 | +| PUT | /api/app-backgrounds/active | `server/src/api/app-backgrounds.core.api.ts` | L92 | +| POST | /api/app-backgrounds/import | `server/src/api/app-backgrounds.core.api.ts` | L76 | | USE | /api/app-settings | `server/src/api/app-settings.api.ts` | L7 | | POST | /api/bundles/export | `server/src/api/bundles.core.api.ts` | L46 | | POST | /api/bundles/import | `server/src/api/bundles.core.api.ts` | L69 | +| GET | /api/chat-knowledge/collections | `server/src/api/chat-knowledge.core.api.ts` | L182 | +| POST | /api/chat-knowledge/collections | `server/src/api/chat-knowledge.core.api.ts` | L196 | +| GET | /api/chat-knowledge/collections/:id | `server/src/api/chat-knowledge.core.api.ts` | L211 | +| GET | /api/chat-knowledge/collections/:id/export | `server/src/api/chat-knowledge.core.api.ts` | L221 | +| POST | /api/chat-knowledge/collections/import | `server/src/api/chat-knowledge.core.api.ts` | L241 | +| GET | /api/chat-knowledge/links | `server/src/api/chat-knowledge.core.api.ts` | L332 | +| POST | /api/chat-knowledge/links | `server/src/api/chat-knowledge.core.api.ts` | L347 | +| GET | /api/chat-knowledge/records | `server/src/api/chat-knowledge.core.api.ts` | L257 | +| POST | /api/chat-knowledge/records | `server/src/api/chat-knowledge.core.api.ts` | L274 | +| GET | /api/chat-knowledge/records/:id | `server/src/api/chat-knowledge.core.api.ts` | L306 | +| POST | /api/chat-knowledge/records/search | `server/src/api/chat-knowledge.core.api.ts` | L316 | +| POST | /api/chat-knowledge/reveal | `server/src/api/chat-knowledge.core.api.ts` | L363 | | DELETE | /api/chats/:id | `server/src/api/chats.core.api.ts` | L91 | | GET | /api/chats/:id | `server/src/api/chats.core.api.ts` | L29 | | PUT | /api/chats/:id | `server/src/api/chats.core.api.ts` | L76 | @@ -28,32 +44,32 @@ description: Автогенерируемый инвентарь backend endpoin | DELETE | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L178 | | PUT | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L159 | | POST | /api/chats/:id/branches/:branchId/activate | `server/src/api/chats.core.api.ts` | L135 | -| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L206 | -| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L230 | -| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L258 | +| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L62 | +| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L86 | +| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L114 | | PUT | /api/chats/:id/instruction | `server/src/api/chats.core.api.ts` | L44 | -| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L335 | -| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L350 | -| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L257 | -| GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L142 | -| POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L150 | -| DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L258 | -| GET | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L167 | -| PUT | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L180 | -| GET | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L278 | -| POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L291 | -| GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L223 | -| POST | /api/entity-profiles/import | `server/src/api/entity-profiles.import.api.ts` | L62 | -| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L421 | -| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L436 | -| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L313 | -| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L507 | -| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L287 | -| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L493 | -| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L365 | -| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L383 | -| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L453 | -| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L480 | +| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L191 | +| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L206 | +| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L288 | +| GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L135 | +| POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L143 | +| DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L251 | +| GET | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L160 | +| PUT | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L173 | +| GET | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L267 | +| POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L280 | +| GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L216 | +| POST | /api/entity-profiles/import | `server/src/api/entity-profiles.import.api.ts` | L38 | +| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L298 | +| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L313 | +| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L169 | +| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L384 | +| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L143 | +| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L370 | +| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L221 | +| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L239 | +| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L330 | +| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L357 | | GET | /api/files/metadata/:filename | `server/src/api/files/routes.ts` | L71 | | POST | /api/files/upload | `server/src/api/files/routes.ts` | L53 | | POST | /api/files/upload-card | `server/src/api/files/routes.ts` | L59 | @@ -72,35 +88,37 @@ description: Автогенерируемый инвентарь backend endpoin | DELETE | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L132 | | PUT | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L108 | | POST | /api/llm-presets/:id/apply | `server/src/api/llm-presets.api.ts` | L150 | -| GET | /api/llm/models | `server/src/api/llm.api.ts` | L216 | -| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L53 | -| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L132 | -| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L103 | -| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L113 | -| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L68 | -| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L86 | -| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L154 | -| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | -| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L207 | -| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L193 | +| GET | /api/llm/models | `server/src/api/llm.api.ts` | L234 | +| GET | /api/llm/openrouter/endpoints | `server/src/api/llm.api.ts` | L274 | +| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L62 | +| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L150 | +| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L121 | +| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L131 | +| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L77 | +| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L95 | +| GET | /api/llm/runtime/provider-state | `server/src/api/llm.api.ts` | L112 | +| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | +| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L190 | +| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L225 | +| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L211 | | GET | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L33 | | POST | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L41 | -| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L79 | +| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L82 | | GET | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L53 | -| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L64 | -| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L93 | -| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L118 | +| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L67 | +| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L96 | +| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L124 | | GET | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L36 | | POST | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L44 | -| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L107 | -| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L81 | -| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L92 | -| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L121 | +| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L115 | +| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L86 | +| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L100 | +| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L132 | | GET | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L58 | -| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L70 | -| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L135 | -| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L530 | -| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L545 | +| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L72 | +| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L151 | +| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L407 | +| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L422 | | GET | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L77 | | POST | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L84 | | DELETE | /api/rag/chroma/collections/:name | `server/src/api/rag-chroma.api.ts` | L96 | @@ -110,24 +128,27 @@ description: Автогенерируемый инвентарь backend endpoin | GET | /api/rag/chroma/health | `server/src/api/rag-chroma.api.ts` | L70 | | POST | /api/rag/chroma/query | `server/src/api/rag-chroma.api.ts` | L145 | | POST | /api/rag/chroma/world-info/reindex | `server/src/api/rag-chroma.api.ts` | L161 | -| POST | /api/rag/embeddings | `server/src/api/rag.api.ts` | L168 | -| GET | /api/rag/models | `server/src/api/rag.api.ts` | L74 | -| GET | /api/rag/presets | `server/src/api/rag.api.ts` | L93 | -| POST | /api/rag/presets | `server/src/api/rag.api.ts` | L98 | -| DELETE | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L116 | -| PUT | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L102 | -| POST | /api/rag/presets/:id/apply | `server/src/api/rag.api.ts` | L145 | -| GET | /api/rag/providers | `server/src/api/rag.api.ts` | L46 | -| GET | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L58 | -| PATCH | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L63 | -| GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L50 | -| PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L54 | -| GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L68 | +| POST | /api/rag/embeddings | `server/src/api/rag.api.ts` | L188 | +| GET | /api/rag/models | `server/src/api/rag.api.ts` | L94 | +| GET | /api/rag/presets | `server/src/api/rag.api.ts` | L113 | +| POST | /api/rag/presets | `server/src/api/rag.api.ts` | L118 | +| DELETE | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L136 | +| PUT | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L122 | +| POST | /api/rag/presets/:id/apply | `server/src/api/rag.api.ts` | L165 | +| GET | /api/rag/providers | `server/src/api/rag.api.ts` | L51 | +| POST | /api/rag/providers/:providerId/check | `server/src/api/rag.api.ts` | L73 | +| GET | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L63 | +| PATCH | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L68 | +| GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L55 | +| PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L59 | +| GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L88 | | USE | /api/settings | `server/src/api/settings.api.ts` | L7 | -| GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L124 | -| POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L129 | +| GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L144 | +| POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L149 | | GET | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L105 | | POST | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L117 | +| POST | /api/sillytavern-import/import | `server/src/api/sillytavern-import.api.ts` | L47 | +| POST | /api/sillytavern-import/scan | `server/src/api/sillytavern-import.api.ts` | L34 | | GET | /api/ui-theme-presets | `server/src/api/ui-theme.core.api.ts` | L39 | | POST | /api/ui-theme-presets | `server/src/api/ui-theme.core.api.ts` | L49 | | DELETE | /api/ui-theme-presets/:id | `server/src/api/ui-theme.core.api.ts` | L88 | @@ -154,7 +175,20 @@ description: Автогенерируемый инвентарь backend endpoin | POST | /api/world-info/resolve | `server/src/api/world-info.core.api.ts` | L372 | | GET | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L320 | | PUT | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L330 | -| USE | /media | `server/src/api/static.api.ts` | L21 | +| USE | /defaults/backgrounds | `server/src/api/static.api.ts` | L36 | +| POST | /login | `server/src/api/auth.api.ts` | L253 | +| POST | /logout | `server/src/api/auth.api.ts` | L345 | +| USE | /media | `server/src/api/static.api.ts` | L22 | +| POST | /password | `server/src/api/auth.api.ts` | L355 | +| POST | /recover | `server/src/api/auth.api.ts` | L322 | +| POST | /register | `server/src/api/auth.api.ts` | L268 | +| POST | /setup | `server/src/api/auth.api.ts` | L234 | +| GET | /status | `server/src/api/auth.api.ts` | L159 | +| POST | /switch | `server/src/api/auth.api.ts` | L305 | +| GET | /users | `server/src/api/auth.api.ts` | L374 | +| POST | /users | `server/src/api/auth.api.ts` | L385 | +| PATCH | /users/:id | `server/src/api/auth.api.ts` | L403 | +| POST | /users/:id/password | `server/src/api/auth.api.ts` | L426 | ## Notes diff --git a/docs/docs/dev/backend/api-overview.md b/docs/docs/dev/backend/api-overview.md index 3495c0dd..50c7d2aa 100644 --- a/docs/docs/dev/backend/api-overview.md +++ b/docs/docs/dev/backend/api-overview.md @@ -22,6 +22,26 @@ app.use('/api', routes) Поэтому endpoint вида `/chats/:id` в router-файле становится `/api/chats/:id`. +## Сетевая граница + +По умолчанию backend слушает только `127.0.0.1`. Значение `TALESPINNER_HOST` +игнорируется, пока явно не включён LAN-режим: + +```bash +TALESPINNER_LAN_MODE=true +TALESPINNER_HOST=0.0.0.0 +TALESPINNER_CORS_ORIGINS=http://192.168.1.20:5173 +``` + +`TALESPINNER_CORS_ORIGINS` — список разрешённых origin через запятую. Без этой +настройки разрешены только dev-origin `http://localhost:5173` и +`http://127.0.0.1:5173`; запросы без `Origin` разрешены для локальных native-клиентов. +Запрос с другим origin отклоняется с `403`. + +LAN-режим открывает API другим устройствам в сети. В текущей локальной модели +полноценной аутентификации нет, поэтому включайте его только в доверенной сети и +защищайте порт системным firewall. + ## Основные группы API - chats / branches diff --git a/docs/docs/user/accounts-and-security.md b/docs/docs/user/accounts-and-security.md new file mode 100644 index 00000000..bacd87d5 --- /dev/null +++ b/docs/docs/user/accounts-and-security.md @@ -0,0 +1,98 @@ +--- +title: Аккаунты и безопасность +sidebar_position: 2 +description: Несколько изолированных аккаунтов, локальный режим и безопасный публичный хостинг. +--- + +# Аккаунты и безопасность + +В TaleSpinner есть два режима доступа: `local` и `public`. В обоих можно создать +сколько угодно аккаунтов, а данные каждого аккаунта изолированы на сервере: +чаты, профили и персоны, World Info, пресеты, настройки LLM/RAG, API-токены и +загруженные файлы принадлежат текущему пользователю. + +## Локальный режим + +`TALESPINNER_ACCESS_MODE=local` подходит для доверенного компьютера. + +- Пароль аккаунта можно оставить пустым. +- Единственный аккаунт без пароля открывается автоматически. +- При нескольких аккаунтах приложение показывает выбор пользователя; аккаунты + без пароля открываются сразу, защищённые запрашивают пароль. +- Новый локальный аккаунт можно создать с экрана выбора по отдельной кнопке. +- Пароль можно добавить позже в менеджере аккаунтов. +- Администратор может создавать, отключать и настраивать другие аккаунты. + +Локальный режим упрощает вход, но не отключает изоляцию данных. Не публикуйте +его порт в интернет: backend по умолчанию слушает только loopback именно по этой +причине. + +## Публичный режим + +Публичный режим включается только полным набором настроек. Пример: + +```env +TALESPINNER_ACCESS_MODE=public +TALESPINNER_SESSION_SECRET=<случайная строка длиной не менее 32 символов> +TALESPINNER_SETUP_TOKEN=<отдельная случайная строка длиной не менее 16 символов> +TALESPINNER_TRUST_PROXY=true +TALESPINNER_LAN_MODE=true +TALESPINNER_CORS_ORIGINS=https://talespinner.example.com +TALESPINNER_ALLOW_REGISTRATION=false +TOKENS_MASTER_KEY=<стойкий отдельный ключ> +``` + +Размещайте backend за reverse proxy, который завершает HTTPS. Передавайте +запросы в TaleSpinner только от этого proxy и не открывайте внутренний порт +напрямую. `TALESPINNER_CORS_ORIGINS` должен содержать точный HTTPS-origin +интерфейса без `*`. + +При первом запуске введите значение `TALESPINNER_SETUP_TOKEN` в поле кода +первоначальной настройки. Этот токен не является паролем администратора: +храните его вне браузера и используйте также для аварийного восстановления +пароля администратора. + +Публичный режим обязательно включает: + +- пароль не короче 10 символов; +- `HttpOnly`, `Secure`, `SameSite=Strict` session cookie; +- проверку HTTPS и доверенного proxy; +- CSRF-защиту изменяющих запросов; +- ограничение неуспешных попыток входа; +- одинаковую ошибку для неизвестного, отключённого и неверно введённого аккаунта. + +Если обязательный secret, setup token или proxy trust не настроен, сервер +завершает запуск с ошибкой. + +## Управление аккаунтами + +Менеджер аккаунтов позволяет: + +- переключиться на другой аккаунт без предварительного выхода; +- создать пользователя или администратора; +- изменить собственный пароль; +- администратору сменить роль, отключить аккаунт или сбросить его пароль. + +Функции разделены по вкладкам «Аккаунт», «Безопасность» и «Управление», поэтому +формы изменения пароля и создания пользователя не занимают экран, пока они не +нужны. + +Отключение аккаунта и смена пароля отзывают его активные сессии. Последнего +активного администратора нельзя отключить или понизить. + +`TALESPINNER_ALLOW_REGISTRATION=true` открывает API +`POST /api/auth/register` для самостоятельной регистрации обычных пользователей +в публичном режиме и показывает кнопку создания аккаунта на приветственном +экране. Оставьте значение `false`, если аккаунты должен создавать только +администратор. + +## Обновление существующей установки + +Первый созданный аккаунт получает legacy scope `global`, поэтому видит данные, +которые существовали до появления системы пользователей. Перед миграцией +TaleSpinner автоматически создаёт согласованную копию SQLite в +`<каталог БД>/backups/db-pre-user-accounts-*.sqlite`. Повторный запуск не создаёт +новую копию после успешной миграции. + +Перед обновлением облачной установки всё равно сохраните отдельный backup +каталога `DATA_DIR`, включая SQLite, media и файловые настройки. diff --git a/docs/docs/user/getting-started.md b/docs/docs/user/getting-started.md index e0de46a2..d96ccddd 100644 --- a/docs/docs/user/getting-started.md +++ b/docs/docs/user/getting-started.md @@ -10,6 +10,7 @@ description: Первый рабочий запуск TaleSpinner за 10 мин После этого гайда у вас будет: +- созданный аккаунт TaleSpinner, - созданный профиль, - подключенный LLM-провайдер, - первый рабочий чат, @@ -38,13 +39,20 @@ yarn dev По умолчанию backend работает на `http://localhost:5000`, frontend подключается к `http://localhost:5000/api`. -## Шаг 3. Создайте первый профиль +## Шаг 3. Создайте первый аккаунт + +При первом открытии TaleSpinner предложит создать администратора. В стандартном +режиме `local` пароль можно оставить пустым: если такой аккаунт один, приложение +будет входить в него автоматически. Аккаунт владеет своими чатами, профилями, +настройками, токенами и загруженными файлами. + +## Шаг 4. Создайте первый профиль 1. Откройте приложение в браузере. 2. На стартовом экране нажмите `Создать новый профиль`. 3. Выберите созданный профиль в левом меню `Профили`. -## Шаг 4. Подключите LLM +## Шаг 5. Подключите LLM 1. Откройте в левом меню `Настройки LLM`. 2. Перейдите в `API Provider`. @@ -52,7 +60,7 @@ yarn dev 4. Добавьте токен. 5. Загрузите и выберите модель. -## Шаг 5. Отправьте первое сообщение +## Шаг 6. Отправьте первое сообщение 1. Откройте или создайте чат. 2. Введите сообщение и нажмите `Отправить`. @@ -65,6 +73,7 @@ yarn dev 3. `Operations` - как автоматизировать шаги до/после генерации. 4. `LLM Setup` - как настроить пресеты под разные задачи. 5. `TaleSpinner vs SillyTavern` - когда TaleSpinner дает преимущество. +6. `Аккаунты и безопасность` - локальный режим, несколько аккаунтов и безопасный хостинг. ## Частые проблемы diff --git a/docs/docs/user/operations.md b/docs/docs/user/operations.md index f2b76319..33846d06 100644 --- a/docs/docs/user/operations.md +++ b/docs/docs/user/operations.md @@ -50,8 +50,23 @@ Operations - это встроенный pipeline вокруг основной - `template` - `llm` +- `guard` +- `knowledge_search` +- `knowledge_reveal` -Остальные kind пока отображаются в UI как draft и пропускаются при исполнении. +Остальные kind пока пропускаются при исполнении. + +Для защиты от чрезмерной нагрузки действуют системные лимиты: + +- до 64 операций в блоке, 16 блоков и 128 скомпилированных операций в профиле; +- не более 4 одновременно исполняемых операций; +- до 100 элементов истории артефакта; +- до 256 КиБ на одно значение артефакта или ответ вспомогательной LLM; +- до 1 МиБ на сериализованную историю артефакта; +- до 100 000 символов в шаблоне или prompt; +- до 3 попыток и 120 секунд на вспомогательный LLM-вызов. + +Конфигурация, превышающая эти лимиты, отклоняется при сохранении или активации. ## Практическое преимущество перед ручной сборкой prompt diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md index e91da75e..6b7ee1ef 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-endpoints.md @@ -17,9 +17,25 @@ Sources: | Method | Path | Source file | Handler section | | --- | --- | --- | --- | +| GET | /api/app-backgrounds | `server/src/api/app-backgrounds.core.api.ts` | L68 | +| DELETE | /api/app-backgrounds/:id | `server/src/api/app-backgrounds.core.api.ts` | L102 | +| PUT | /api/app-backgrounds/active | `server/src/api/app-backgrounds.core.api.ts` | L92 | +| POST | /api/app-backgrounds/import | `server/src/api/app-backgrounds.core.api.ts` | L76 | | USE | /api/app-settings | `server/src/api/app-settings.api.ts` | L7 | | POST | /api/bundles/export | `server/src/api/bundles.core.api.ts` | L46 | | POST | /api/bundles/import | `server/src/api/bundles.core.api.ts` | L69 | +| GET | /api/chat-knowledge/collections | `server/src/api/chat-knowledge.core.api.ts` | L182 | +| POST | /api/chat-knowledge/collections | `server/src/api/chat-knowledge.core.api.ts` | L196 | +| GET | /api/chat-knowledge/collections/:id | `server/src/api/chat-knowledge.core.api.ts` | L211 | +| GET | /api/chat-knowledge/collections/:id/export | `server/src/api/chat-knowledge.core.api.ts` | L221 | +| POST | /api/chat-knowledge/collections/import | `server/src/api/chat-knowledge.core.api.ts` | L241 | +| GET | /api/chat-knowledge/links | `server/src/api/chat-knowledge.core.api.ts` | L332 | +| POST | /api/chat-knowledge/links | `server/src/api/chat-knowledge.core.api.ts` | L347 | +| GET | /api/chat-knowledge/records | `server/src/api/chat-knowledge.core.api.ts` | L257 | +| POST | /api/chat-knowledge/records | `server/src/api/chat-knowledge.core.api.ts` | L274 | +| GET | /api/chat-knowledge/records/:id | `server/src/api/chat-knowledge.core.api.ts` | L306 | +| POST | /api/chat-knowledge/records/search | `server/src/api/chat-knowledge.core.api.ts` | L316 | +| POST | /api/chat-knowledge/reveal | `server/src/api/chat-knowledge.core.api.ts` | L363 | | DELETE | /api/chats/:id | `server/src/api/chats.core.api.ts` | L91 | | GET | /api/chats/:id | `server/src/api/chats.core.api.ts` | L29 | | PUT | /api/chats/:id | `server/src/api/chats.core.api.ts` | L76 | @@ -28,32 +44,32 @@ Sources: | DELETE | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L178 | | PUT | /api/chats/:id/branches/:branchId | `server/src/api/chats.core.api.ts` | L159 | | POST | /api/chats/:id/branches/:branchId/activate | `server/src/api/chats.core.api.ts` | L135 | -| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L206 | -| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L230 | -| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L258 | +| GET | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L62 | +| POST | /api/chats/:id/entries | `server/src/api/chat-entries.api.ts` | L86 | +| POST | /api/chats/:id/entries/continue | `server/src/api/chat-entries.api.ts` | L114 | | PUT | /api/chats/:id/instruction | `server/src/api/chats.core.api.ts` | L44 | -| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L335 | -| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L350 | -| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L257 | -| GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L142 | -| POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L150 | -| DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L258 | -| GET | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L167 | -| PUT | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L180 | -| GET | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L278 | -| POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L291 | -| GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L223 | -| POST | /api/entity-profiles/import | `server/src/api/entity-profiles.import.api.ts` | L62 | -| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L421 | -| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L436 | -| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L313 | -| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L507 | -| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L287 | -| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L493 | -| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L365 | -| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L383 | -| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L453 | -| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L480 | +| GET | /api/chats/:id/operation-runtime-state | `server/src/api/chat-entries.api.ts` | L191 | +| GET | /api/chats/:id/world-info/latest-activations | `server/src/api/chat-entries.api.ts` | L206 | +| ALL | /api/config/openrouter | `server/src/api/llm.api.ts` | L288 | +| GET | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L135 | +| POST | /api/entity-profiles | `server/src/api/entity-profiles.core.api.ts` | L143 | +| DELETE | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L251 | +| GET | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L160 | +| PUT | /api/entity-profiles/:id | `server/src/api/entity-profiles.core.api.ts` | L173 | +| GET | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L267 | +| POST | /api/entity-profiles/:id/chats | `server/src/api/entity-profiles.core.api.ts` | L280 | +| GET | /api/entity-profiles/:id/export | `server/src/api/entity-profiles.core.api.ts` | L216 | +| POST | /api/entity-profiles/import | `server/src/api/entity-profiles.import.api.ts` | L38 | +| POST | /api/entries/:id/manual-edit | `server/src/api/chat-entries.api.ts` | L298 | +| POST | /api/entries/:id/parts/batch-update | `server/src/api/chat-entries.api.ts` | L313 | +| GET | /api/entries/:id/prompt-diagnostics | `server/src/api/chat-entries.api.ts` | L169 | +| POST | /api/entries/:id/prompt-visibility | `server/src/api/chat-entries.api.ts` | L384 | +| POST | /api/entries/:id/regenerate | `server/src/api/chat-entries.api.ts` | L143 | +| POST | /api/entries/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L370 | +| GET | /api/entries/:id/variants | `server/src/api/chat-entries.api.ts` | L221 | +| POST | /api/entries/:id/variants/:variantId/select | `server/src/api/chat-entries.api.ts` | L239 | +| POST | /api/entries/:id/variants/:variantId/soft-delete | `server/src/api/chat-entries.api.ts` | L330 | +| POST | /api/entries/soft-delete-bulk | `server/src/api/chat-entries.api.ts` | L357 | | GET | /api/files/metadata/:filename | `server/src/api/files/routes.ts` | L71 | | POST | /api/files/upload | `server/src/api/files/routes.ts` | L53 | | POST | /api/files/upload-card | `server/src/api/files/routes.ts` | L59 | @@ -72,35 +88,37 @@ Sources: | DELETE | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L132 | | PUT | /api/llm-presets/:id | `server/src/api/llm-presets.api.ts` | L108 | | POST | /api/llm-presets/:id/apply | `server/src/api/llm-presets.api.ts` | L150 | -| GET | /api/llm/models | `server/src/api/llm.api.ts` | L216 | -| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L53 | -| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L132 | -| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L103 | -| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L113 | -| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L68 | -| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L86 | -| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L154 | -| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | -| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L207 | -| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L193 | +| GET | /api/llm/models | `server/src/api/llm.api.ts` | L234 | +| GET | /api/llm/openrouter/endpoints | `server/src/api/llm.api.ts` | L274 | +| GET | /api/llm/providers | `server/src/api/llm.api.ts` | L62 | +| POST | /api/llm/providers/:providerId/check | `server/src/api/llm.api.ts` | L150 | +| GET | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L121 | +| PATCH | /api/llm/providers/:providerId/config | `server/src/api/llm.api.ts` | L131 | +| GET | /api/llm/runtime | `server/src/api/llm.api.ts` | L77 | +| PATCH | /api/llm/runtime | `server/src/api/llm.api.ts` | L95 | +| GET | /api/llm/runtime/provider-state | `server/src/api/llm.api.ts` | L112 | +| GET | /api/llm/tokens | `server/src/api/llm.api.ts` | L172 | +| POST | /api/llm/tokens | `server/src/api/llm.api.ts` | L190 | +| DELETE | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L225 | +| PATCH | /api/llm/tokens/:id | `server/src/api/llm.api.ts` | L211 | | GET | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L33 | | POST | /api/operation-blocks | `server/src/api/operation-blocks.core.api.ts` | L41 | -| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L79 | +| DELETE | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L82 | | GET | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L53 | -| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L64 | -| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L93 | -| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L118 | +| PUT | /api/operation-blocks/:id | `server/src/api/operation-blocks.core.api.ts` | L67 | +| GET | /api/operation-blocks/:id/export | `server/src/api/operation-blocks.core.api.ts` | L96 | +| POST | /api/operation-blocks/import | `server/src/api/operation-blocks.core.api.ts` | L124 | | GET | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L36 | | POST | /api/operation-profiles | `server/src/api/operation-profiles.core.api.ts` | L44 | -| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L107 | -| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L81 | -| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L92 | -| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L121 | +| DELETE | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L115 | +| GET | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L86 | +| PUT | /api/operation-profiles/:id | `server/src/api/operation-profiles.core.api.ts` | L100 | +| GET | /api/operation-profiles/:id/export | `server/src/api/operation-profiles.core.api.ts` | L132 | | GET | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L58 | -| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L70 | -| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L135 | -| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L530 | -| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L545 | +| PUT | /api/operation-profiles/active | `server/src/api/operation-profiles.core.api.ts` | L72 | +| POST | /api/operation-profiles/import | `server/src/api/operation-profiles.core.api.ts` | L151 | +| POST | /api/parts/:id/canonicalization-undo | `server/src/api/chat-entries.api.ts` | L407 | +| POST | /api/parts/:id/soft-delete | `server/src/api/chat-entries.api.ts` | L422 | | GET | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L77 | | POST | /api/rag/chroma/collections | `server/src/api/rag-chroma.api.ts` | L84 | | DELETE | /api/rag/chroma/collections/:name | `server/src/api/rag-chroma.api.ts` | L96 | @@ -110,24 +128,27 @@ Sources: | GET | /api/rag/chroma/health | `server/src/api/rag-chroma.api.ts` | L70 | | POST | /api/rag/chroma/query | `server/src/api/rag-chroma.api.ts` | L145 | | POST | /api/rag/chroma/world-info/reindex | `server/src/api/rag-chroma.api.ts` | L161 | -| POST | /api/rag/embeddings | `server/src/api/rag.api.ts` | L168 | -| GET | /api/rag/models | `server/src/api/rag.api.ts` | L74 | -| GET | /api/rag/presets | `server/src/api/rag.api.ts` | L93 | -| POST | /api/rag/presets | `server/src/api/rag.api.ts` | L98 | -| DELETE | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L116 | -| PUT | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L102 | -| POST | /api/rag/presets/:id/apply | `server/src/api/rag.api.ts` | L145 | -| GET | /api/rag/providers | `server/src/api/rag.api.ts` | L46 | -| GET | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L58 | -| PATCH | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L63 | -| GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L50 | -| PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L54 | -| GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L68 | +| POST | /api/rag/embeddings | `server/src/api/rag.api.ts` | L188 | +| GET | /api/rag/models | `server/src/api/rag.api.ts` | L94 | +| GET | /api/rag/presets | `server/src/api/rag.api.ts` | L113 | +| POST | /api/rag/presets | `server/src/api/rag.api.ts` | L118 | +| DELETE | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L136 | +| PUT | /api/rag/presets/:id | `server/src/api/rag.api.ts` | L122 | +| POST | /api/rag/presets/:id/apply | `server/src/api/rag.api.ts` | L165 | +| GET | /api/rag/providers | `server/src/api/rag.api.ts` | L51 | +| POST | /api/rag/providers/:providerId/check | `server/src/api/rag.api.ts` | L73 | +| GET | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L63 | +| PATCH | /api/rag/providers/:providerId/config | `server/src/api/rag.api.ts` | L68 | +| GET | /api/rag/runtime | `server/src/api/rag.api.ts` | L55 | +| PATCH | /api/rag/runtime | `server/src/api/rag.api.ts` | L59 | +| GET | /api/rag/tokens | `server/src/api/rag.api.ts` | L88 | | USE | /api/settings | `server/src/api/settings.api.ts` | L7 | -| GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L124 | -| POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L129 | +| GET | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L144 | +| POST | /api/settings/rag-presets | `server/src/api/rag.api.ts` | L149 | | GET | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L105 | | POST | /api/settings/user-persons | `server/src/api/user-persons.core.api.ts` | L117 | +| POST | /api/sillytavern-import/import | `server/src/api/sillytavern-import.api.ts` | L47 | +| POST | /api/sillytavern-import/scan | `server/src/api/sillytavern-import.api.ts` | L34 | | GET | /api/ui-theme-presets | `server/src/api/ui-theme.core.api.ts` | L39 | | POST | /api/ui-theme-presets | `server/src/api/ui-theme.core.api.ts` | L49 | | DELETE | /api/ui-theme-presets/:id | `server/src/api/ui-theme.core.api.ts` | L88 | @@ -154,7 +175,20 @@ Sources: | POST | /api/world-info/resolve | `server/src/api/world-info.core.api.ts` | L372 | | GET | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L320 | | PUT | /api/world-info/settings | `server/src/api/world-info.core.api.ts` | L330 | -| USE | /media | `server/src/api/static.api.ts` | L21 | +| USE | /defaults/backgrounds | `server/src/api/static.api.ts` | L36 | +| POST | /login | `server/src/api/auth.api.ts` | L253 | +| POST | /logout | `server/src/api/auth.api.ts` | L345 | +| USE | /media | `server/src/api/static.api.ts` | L22 | +| POST | /password | `server/src/api/auth.api.ts` | L355 | +| POST | /recover | `server/src/api/auth.api.ts` | L322 | +| POST | /register | `server/src/api/auth.api.ts` | L268 | +| POST | /setup | `server/src/api/auth.api.ts` | L234 | +| GET | /status | `server/src/api/auth.api.ts` | L159 | +| POST | /switch | `server/src/api/auth.api.ts` | L305 | +| GET | /users | `server/src/api/auth.api.ts` | L374 | +| POST | /users | `server/src/api/auth.api.ts` | L385 | +| PATCH | /users/:id | `server/src/api/auth.api.ts` | L403 | +| POST | /users/:id/password | `server/src/api/auth.api.ts` | L426 | ## Notes diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-overview.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-overview.md index f6b0555b..47cf9f21 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-overview.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/dev/backend/api-overview.md @@ -22,6 +22,26 @@ app.use('/api', routes) Therefore `/chats/:id` in router files becomes `/api/chats/:id`. +## Network boundary + +By default, the backend binds only to `127.0.0.1`. `TALESPINNER_HOST` is ignored +until LAN mode is explicitly enabled: + +```bash +TALESPINNER_LAN_MODE=true +TALESPINNER_HOST=0.0.0.0 +TALESPINNER_CORS_ORIGINS=http://192.168.1.20:5173 +``` + +`TALESPINNER_CORS_ORIGINS` is a comma-separated origin allowlist. Without this +setting, only the development origins `http://localhost:5173` and +`http://127.0.0.1:5173` are allowed; requests without an `Origin` header remain +available to local native clients. Requests from other origins receive `403`. + +LAN mode exposes the API to other devices on the network. The current local model +does not provide full authentication, so enable it only on a trusted network and +protect the port with the system firewall. + ## Main API groups - chats / branches diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/user/accounts-and-security.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/user/accounts-and-security.md new file mode 100644 index 00000000..5aafae8b --- /dev/null +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/user/accounts-and-security.md @@ -0,0 +1,94 @@ +--- +title: Accounts and Security +sidebar_position: 2 +description: Isolated accounts, frictionless local access, and secure public hosting. +--- + +# Accounts and Security + +TaleSpinner has two access modes: `local` and `public`. Both support any number +of accounts. Every account has a server-enforced data boundary covering chats, +profiles and personas, World Info, presets, LLM/RAG settings, API tokens, and +uploaded files. + +## Local mode + +`TALESPINNER_ACCESS_MODE=local` is intended for a trusted computer. + +- An account may have an empty password. +- A single passwordless account signs in automatically. +- With multiple accounts, the app displays an account picker. Passwordless + accounts open immediately; protected accounts request their password. +- A new local account can be created from the chooser through a separate action. +- A password can be added later in Account Manager. +- An administrator can create, disable, and configure other accounts. + +Local mode removes sign-in friction; it does not disable data isolation. Do not +expose its port to the internet. The backend listens on loopback by default for +this reason. + +## Public mode + +Public mode starts only with its complete security configuration. For example: + +```env +TALESPINNER_ACCESS_MODE=public +TALESPINNER_SESSION_SECRET= +TALESPINNER_SETUP_TOKEN= +TALESPINNER_TRUST_PROXY=true +TALESPINNER_LAN_MODE=true +TALESPINNER_CORS_ORIGINS=https://talespinner.example.com +TALESPINNER_ALLOW_REGISTRATION=false +TOKENS_MASTER_KEY= +``` + +Run the backend behind a reverse proxy that terminates HTTPS. Only allow that +proxy to reach TaleSpinner's internal port. `TALESPINNER_CORS_ORIGINS` must be +the exact HTTPS origin of the web UI and must not use `*`. + +During initial setup, enter `TALESPINNER_SETUP_TOKEN` in the setup-token field. +It is separate from the administrator password. Keep it outside the browser; +the same token authorizes emergency administrator password recovery. + +Public mode always enables: + +- passwords of at least 10 characters; +- `HttpOnly`, `Secure`, `SameSite=Strict` session cookies; +- HTTPS and trusted-proxy checks; +- CSRF validation for mutating requests; +- rate limiting of failed sign-in attempts; +- the same error for unknown, disabled, and incorrectly entered accounts. + +The server fails to start if a required secret, setup token, or proxy trust is +missing. + +## Managing accounts + +Account Manager can: + +- switch to another account without signing out first; +- create a user or administrator; +- change your own password; +- let an administrator change a role, disable an account, or reset its password. + +These actions are separated into Account, Security, and Manage tabs, so password +and user-creation forms stay out of the way until they are needed. + +Disabling an account or changing its password revokes its active sessions. The +last active administrator cannot be disabled or demoted. + +`TALESPINNER_ALLOW_REGISTRATION=true` enables the +`POST /api/auth/register` API for regular-user self-registration in public +mode and displays a create-account action on the welcome screen. Keep it +`false` when only an administrator should create accounts. + +## Upgrading an existing installation + +The first account receives the legacy `global` scope and therefore retains data +created before user accounts existed. Before the migration, TaleSpinner creates +a consistent SQLite backup at +`/backups/db-pre-user-accounts-*.sqlite`. A successful +migration does not create another copy on later starts. + +Before upgrading a hosted installation, still make an independent backup of the +entire `DATA_DIR`, including SQLite, media, and file-backed settings. diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/user/getting-started.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/user/getting-started.md index bc7388ca..b21d7296 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/user/getting-started.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/user/getting-started.md @@ -10,6 +10,7 @@ description: First working TaleSpinner run in 10 minutes. After this guide you will have: +- a TaleSpinner account, - a created profile, - connected LLM provider, - first working chat, @@ -38,13 +39,20 @@ yarn dev By default backend runs on `http://localhost:5000`, frontend targets `http://localhost:5000/api`. -## Step 3. Create your first profile +## Step 3. Create your first account + +On first open, TaleSpinner asks you to create an administrator. In the default +`local` mode the password may be empty. When this is the only passwordless +account, the app signs in automatically. The account owns its chats, profiles, +settings, provider tokens, and uploaded files. + +## Step 4. Create your first profile 1. Open the app in browser. 2. Click `Create new profile` on the initial screen. 3. Select the new profile in the left menu `Profiles`. -## Step 4. Connect LLM +## Step 5. Connect LLM 1. Open `LLM Settings` in the left menu. 2. Go to `API Provider`. @@ -52,7 +60,7 @@ By default backend runs on `http://localhost:5000`, frontend targets `http://loc 4. Add a token. 5. Load and select a model. -## Step 5. Send first message +## Step 6. Send first message 1. Open or create a chat. 2. Type a message and click `Send`. @@ -65,6 +73,7 @@ By default backend runs on `http://localhost:5000`, frontend targets `http://loc 3. `Operations` - generation automation before/after main LLM. 4. `LLM Setup` - presets and provider tuning. 5. `TaleSpinner vs SillyTavern` - where TaleSpinner has practical advantages. +6. `Accounts and Security` - local accounts and secure public hosting. ## Common issues diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md b/docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md index 96dcbd0e..819646dc 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/user/operations.md @@ -50,8 +50,23 @@ Operation kinds currently executed in runtime: - `template` - `llm` +- `guard` +- `knowledge_search` +- `knowledge_reveal` -Other kinds are visible in UI as draft but skipped at execution. +Other kinds are skipped at execution. + +System limits protect the runtime from excessive resource use: + +- up to 64 operations per block, 16 blocks and 128 compiled operations per profile; +- at most 4 operations running concurrently; +- up to 100 artifact history items; +- up to 256 KiB per artifact value or auxiliary LLM response; +- up to 1 MiB of serialized artifact history; +- up to 100,000 characters per template or prompt; +- up to 3 attempts and 120 seconds per auxiliary LLM call. + +Configurations above these limits are rejected during save or activation. ## Practical advantage over manual prompt assembly diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 4479afa0..eb9162f4 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -8,6 +8,7 @@ const sidebars: SidebarsConfig = { label: 'User Guide', items: [ 'user/getting-started', + 'user/accounts-and-security', 'user/chat-basics', 'user/world-info', 'user/operations', diff --git a/env.example b/env.example index 07e0074a..dc0853ef 100644 --- a/env.example +++ b/env.example @@ -4,6 +4,33 @@ # Backend listen port (1-65535). PORT=5000 +# Account access policy: local | public. +# local allows passwordless accounts and automatic login. +# public requires the hardened authentication settings below. +TALESPINNER_ACCESS_MODE=local + +# Required in public mode. Use independent random values. +TALESPINNER_SESSION_SECRET= +TALESPINNER_SETUP_TOKEN= + +# Public deployments are expected to terminate HTTPS at a trusted reverse proxy. +TALESPINNER_TRUST_PROXY=false +TALESPINNER_ALLOW_REGISTRATION=false +TALESPINNER_SESSION_TTL_DAYS=30 + +# Network exposure. Keep LAN mode disabled for a local-only installation. +# A public deployment normally sets LAN mode to true behind an HTTPS proxy and +# lists the exact browser origin (no wildcard), for example: +# TALESPINNER_LAN_MODE=true +# TALESPINNER_CORS_ORIGINS=https://talespinner.example.com +TALESPINNER_LAN_MODE=false +TALESPINNER_HOST=0.0.0.0 +TALESPINNER_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Optional Vite development proxy target. The browser still uses same-origin +# /api, /media and /defaults URLs; Vite forwards them to this backend. +VITE_DEV_PROXY_TARGET=http://127.0.0.1:5000 + # Base directory for backend runtime data. # Absolute path is supported. # Relative path is resolved from monorepo root. diff --git a/server/drizzle/0032_user_accounts.sql b/server/drizzle/0032_user_accounts.sql new file mode 100644 index 00000000..489fff08 --- /dev/null +++ b/server/drizzle/0032_user_accounts.sql @@ -0,0 +1,55 @@ +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `normalized_username` text NOT NULL, + `display_name` text NOT NULL, + `password_hash` text, + `role` text DEFAULT 'user' NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `credential_version` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `last_login_at` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_normalized_username_uq` ON `users` (`normalized_username`); +--> statement-breakpoint +CREATE INDEX `users_status_updated_at_idx` ON `users` (`status`,`updated_at`); +--> statement-breakpoint +CREATE TABLE `auth_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `token_hash` text NOT NULL, + `csrf_token_hash` text NOT NULL, + `auth_method` text NOT NULL, + `credential_version` integer NOT NULL, + `created_at` integer NOT NULL, + `last_seen_at` integer NOT NULL, + `expires_at` integer NOT NULL, + `revoked_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `auth_sessions_token_hash_uq` ON `auth_sessions` (`token_hash`); +--> statement-breakpoint +CREATE INDEX `auth_sessions_user_expires_at_idx` ON `auth_sessions` (`user_id`,`expires_at`); +--> statement-breakpoint +CREATE INDEX `auth_sessions_expires_at_idx` ON `auth_sessions` (`expires_at`); +--> statement-breakpoint +ALTER TABLE `llm_provider_configs` ADD `owner_id` text DEFAULT 'global' NOT NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX `llm_provider_configs_owner_provider_uq` ON `llm_provider_configs` (`owner_id`,`provider_id`); +--> statement-breakpoint +ALTER TABLE `llm_tokens` ADD `owner_id` text DEFAULT 'global' NOT NULL; +--> statement-breakpoint +CREATE INDEX `llm_tokens_owner_provider_idx` ON `llm_tokens` (`owner_id`,`provider_id`); +--> statement-breakpoint +ALTER TABLE `ui_app_backgrounds` ADD `owner_id` text DEFAULT 'global' NOT NULL; +--> statement-breakpoint +CREATE INDEX `ui_app_backgrounds_owner_id_idx` ON `ui_app_backgrounds` (`owner_id`); +--> statement-breakpoint +UPDATE `llm_runtime_settings` +SET `scope_id` = 'global:' || `scope_id`; +--> statement-breakpoint +UPDATE `llm_runtime_provider_state` +SET `scope_id` = 'global:' || `scope_id`; diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 3cde38e9..536c1b94 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1774060000000, "tag": "0031_chat_knowledge_store", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1784560000000, + "tag": "0032_user_accounts", + "breakpoints": true } ] } diff --git a/server/package.json b/server/package.json index a20c1494..1553375a 100644 --- a/server/package.json +++ b/server/package.json @@ -8,6 +8,7 @@ "dependencies": { "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", + "argon2": "0.44.0", "axios": "^1.13.2", "better-sqlite3": "^12.6.0", "chromadb": "^3.3.1", diff --git a/server/src/api/auth-public.api.test.ts b/server/src/api/auth-public.api.test.ts new file mode 100644 index 00000000..01a0150c --- /dev/null +++ b/server/src/api/auth-public.api.test.ts @@ -0,0 +1,411 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { bootstrapApp, createApp } from "../app"; +import { resetDbForTests } from "../db/client"; + +const PUBLIC_ENV = { + TALESPINNER_ACCESS_MODE: "public", + TALESPINNER_SESSION_SECRET: "public-session-secret-value-123456789", + TALESPINNER_SETUP_TOKEN: "public-setup-token", + TALESPINNER_TRUST_PROXY: "true", + TALESPINNER_ALLOW_REGISTRATION: "true", +} as const; + +describe("auth API in public mode", () => { + let tempDir = ""; + let server: ReturnType["listen"]>; + let baseUrl = ""; + const previous = new Map(); + + beforeEach(async () => { + for (const [key, value] of Object.entries(PUBLIC_ENV)) { + previous.set(key, process.env[key]); + process.env[key] = value; + } + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-public-auth-")); + await bootstrapApp({ dbPath: path.join(tempDir, "db.sqlite") }); + const app = createApp(); + server = await new Promise((resolve) => { + const started = app.listen(0, "127.0.0.1", () => resolve(started)); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + baseUrl = `http://127.0.0.1:${address.port}/api`; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + for (const key of Object.keys(PUBLIC_ENV)) { + const value = previous.get(key); + if (typeof value === "string") process.env[key] = value; + else delete process.env[key]; + } + }); + + const secureHeaders = { + "content-type": "application/json", + "x-forwarded-proto": "https", + }; + + test("requires HTTPS and a setup token, then issues hardened cookies", async () => { + const insecure = await fetch(`${baseUrl}/auth/status`); + expect(insecure.status).toBe(426); + expect(insecure.headers.get("x-frame-options")).toBe("DENY"); + + const rejected = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + expect(rejected.status).toBe(403); + + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + expect(setup.status).toBe(201); + expect(setup.headers.get("set-cookie")).toMatch(/HttpOnly.*Secure.*SameSite=Strict/); + await expect(setup.json()).resolves.toMatchObject({ + data: { + user: { role: "admin", hasPassword: true }, + csrfToken: expect.any(String), + }, + }); + }); + + test("requires CSRF for authenticated mutations", async () => { + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + const body = (await setup.json()) as { + data: { csrfToken: string }; + }; + const cookie = setup.headers.get("set-cookie")?.split(";")[0] ?? ""; + + const rejected = await fetch(`${baseUrl}/auth/users`, { + method: "POST", + headers: { ...secureHeaders, cookie }, + body: JSON.stringify({ + username: "bob", + password: "another-strong-password", + }), + }); + expect(rejected.status).toBe(403); + + const accepted = await fetch(`${baseUrl}/auth/users`, { + method: "POST", + headers: { + ...secureHeaders, + cookie, + "x-csrf-token": body.data.csrfToken, + }, + body: JSON.stringify({ + username: "bob", + password: "another-strong-password", + }), + }); + expect(accepted.status).toBe(201); + const created = (await accepted.json()) as { data: { id: string } }; + + const rejectedSwitch = await fetch(`${baseUrl}/auth/switch`, { + method: "POST", + headers: { ...secureHeaders, cookie }, + body: JSON.stringify({ + userId: created.data.id, + password: "another-strong-password", + }), + }); + expect(rejectedSwitch.status).toBe(403); + + const acceptedSwitch = await fetch(`${baseUrl}/auth/switch`, { + method: "POST", + headers: { + ...secureHeaders, + cookie, + "x-csrf-token": body.data.csrfToken, + }, + body: JSON.stringify({ + userId: created.data.id, + password: "another-strong-password", + }), + }); + expect(acceptedSwitch.status).toBe(200); + await expect(acceptedSwitch.json()).resolves.toMatchObject({ + data: { user: { username: "bob" } }, + }); + }); + + test("rate limits failed logins without penalizing successful logins", async () => { + await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + + for (let attempt = 0; attempt < 7; attempt += 1) { + const successful = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + expect(successful.status).toBe(200); + } + + for (let attempt = 0; attempt < 5; attempt += 1) { + const failed = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "wrong-password", + }), + }); + expect(failed.status).toBe(401); + } + + const blocked = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + expect(blocked.status).toBe(429); + expect(blocked.headers.get("retry-after")).toBeTruthy(); + }); + + test("supports public registration after setup and rejects duplicates safely", async () => { + await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + + const registered = await fetch(`${baseUrl}/auth/register`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "bob", + password: "another-strong-password", + }), + }); + expect(registered.status).toBe(201); + expect(registered.headers.get("set-cookie")).toContain("HttpOnly"); + + const duplicate = await fetch(`${baseUrl}/auth/register`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "BOB", + password: "another-strong-password", + }), + }); + expect(duplicate.status).toBe(409); + await expect(duplicate.json()).resolves.toMatchObject({ + error: { code: "USERNAME_TAKEN" }, + }); + }); + + test("protects the last admin, revokes disabled sessions, and supports recovery", async () => { + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + const setupBody = (await setup.json()) as { + data: { user: { id: string }; csrfToken: string }; + }; + const adminCookie = setup.headers.get("set-cookie")?.split(";")[0] ?? ""; + const adminHeaders = { + ...secureHeaders, + cookie: adminCookie, + "x-csrf-token": setupBody.data.csrfToken, + }; + + const rejected = await fetch( + `${baseUrl}/auth/users/${setupBody.data.user.id}`, + { + method: "PATCH", + headers: adminHeaders, + body: JSON.stringify({ status: "disabled" }), + } + ); + expect(rejected.status).toBe(409); + + const secondAdmin = await fetch(`${baseUrl}/auth/users`, { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ + username: "second-admin", + password: "second-admin-password", + role: "admin", + }), + }); + expect(secondAdmin.status).toBe(201); + + const disabled = await fetch( + `${baseUrl}/auth/users/${setupBody.data.user.id}`, + { + method: "PATCH", + headers: adminHeaders, + body: JSON.stringify({ status: "disabled" }), + } + ); + expect(disabled.status).toBe(200); + + const invalidatedStatus = await fetch(`${baseUrl}/auth/status`, { + headers: { ...secureHeaders, cookie: adminCookie }, + }); + await expect(invalidatedStatus.json()).resolves.toMatchObject({ + data: { authenticated: false }, + }); + + const disabledLogin = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + expect(disabledLogin.status).toBe(401); + await expect(disabledLogin.json()).resolves.toMatchObject({ + error: { + code: "INVALID_CREDENTIALS", + message: "Invalid username or password.", + }, + }); + + const recovered = await fetch(`${baseUrl}/auth/recover`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + newPassword: "recovered-strong-password", + }), + }); + expect(recovered.status).toBe(200); + + const login = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "recovered-strong-password", + }), + }); + expect(login.status).toBe(200); + }); + + test("rotates credentials while invalidating the previous session", async () => { + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { + ...secureHeaders, + "x-setup-token": PUBLIC_ENV.TALESPINNER_SETUP_TOKEN, + }, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + const setupBody = (await setup.json()) as { + data: { csrfToken: string }; + }; + const oldCookie = setup.headers.get("set-cookie")?.split(";")[0] ?? ""; + + const changed = await fetch(`${baseUrl}/auth/password`, { + method: "POST", + headers: { + ...secureHeaders, + cookie: oldCookie, + "x-csrf-token": setupBody.data.csrfToken, + }, + body: JSON.stringify({ + currentPassword: "strong-password", + newPassword: "replacement-password", + }), + }); + expect(changed.status).toBe(200); + expect(changed.headers.get("set-cookie")).toContain("HttpOnly"); + + const oldStatus = await fetch(`${baseUrl}/auth/status`, { + headers: { ...secureHeaders, cookie: oldCookie }, + }); + await expect(oldStatus.json()).resolves.toMatchObject({ + data: { authenticated: false }, + }); + + const oldLogin = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "strong-password", + }), + }); + expect(oldLogin.status).toBe(401); + + const newLogin = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: secureHeaders, + body: JSON.stringify({ + username: "admin", + password: "replacement-password", + }), + }); + expect(newLogin.status).toBe(200); + }); +}); diff --git a/server/src/api/auth.api.test.ts b/server/src/api/auth.api.test.ts new file mode 100644 index 00000000..fc04f1ca --- /dev/null +++ b/server/src/api/auth.api.test.ts @@ -0,0 +1,278 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { bootstrapApp, createApp } from "../app"; +import { resetDbForTests } from "../db/client"; + +describe("auth API in local mode", () => { + let tempDir = ""; + let server: ReturnType["listen"]>; + let baseUrl = ""; + let previousMode: string | undefined; + + beforeEach(async () => { + previousMode = process.env.TALESPINNER_ACCESS_MODE; + process.env.TALESPINNER_ACCESS_MODE = "local"; + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-auth-api-")); + await bootstrapApp({ dbPath: path.join(tempDir, "db.sqlite") }); + const app = createApp(); + server = await new Promise((resolve) => { + const started = app.listen(0, "127.0.0.1", () => resolve(started)); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + baseUrl = `http://127.0.0.1:${address.port}/api`; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + if (typeof previousMode === "string") { + process.env.TALESPINNER_ACCESS_MODE = previousMode; + } else { + delete process.env.TALESPINNER_ACCESS_MODE; + } + }); + + function cookieFrom(response: Response): string { + const header = response.headers.get("set-cookie"); + if (!header) throw new Error("Missing session cookie"); + return header.split(";")[0]; + } + + test("requires setup, creates the first admin, and protects app APIs", async () => { + const initialStatus = await fetch(`${baseUrl}/auth/status`); + await expect(initialStatus.json()).resolves.toMatchObject({ + data: { + mode: "local", + registrationAllowed: true, + setupRequired: true, + authenticated: false, + }, + }); + + const denied = await fetch(`${baseUrl}/user-persons`); + expect(denied.status).toBe(401); + + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "alice", password: "" }), + }); + expect(setup.status).toBe(201); + const cookie = cookieFrom(setup); + await expect(setup.json()).resolves.toMatchObject({ + data: { + user: { + id: "global", + username: "alice", + role: "admin", + hasPassword: false, + }, + }, + }); + + const allowed = await fetch(`${baseUrl}/user-persons`, { + headers: { cookie }, + }); + expect(allowed.status).toBe(200); + }); + + test("supports multiple passwordless local accounts", async () => { + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "" }), + }); + const adminCookie = cookieFrom(setup); + const adminPersonaResponse = await fetch(`${baseUrl}/user-persons`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: adminCookie, + }, + body: JSON.stringify({ name: "Admin persona" }), + }); + const adminPersona = (await adminPersonaResponse.json()) as { + data: { id: string }; + }; + + const created = await fetch(`${baseUrl}/auth/users`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: adminCookie, + }, + body: JSON.stringify({ username: "bob", password: "", role: "user" }), + }); + expect(created.status).toBe(201); + const createdBody = (await created.json()) as { + data: { id: string }; + }; + + await fetch(`${baseUrl}/auth/logout`, { + method: "POST", + headers: { cookie: adminCookie }, + }); + const status = await fetch(`${baseUrl}/auth/status`); + await expect(status.json()).resolves.toMatchObject({ + data: { + authenticated: false, + accounts: [ + { username: "admin" }, + { username: "bob" }, + ], + }, + }); + + const login = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + userId: createdBody.data.id, + password: "", + }), + }); + expect(login.status).toBe(200); + const userCookie = cookieFrom(login); + await expect(login.json()).resolves.toMatchObject({ + data: { user: { username: "bob", role: "user" } }, + }); + + const isolatedList = await fetch(`${baseUrl}/user-persons`, { + headers: { cookie: userCookie }, + }); + await expect(isolatedList.json()).resolves.toMatchObject({ data: [] }); + + const directRead = await fetch( + `${baseUrl}/user-persons/${adminPersona.data.id}`, + { headers: { cookie: userCookie } } + ); + expect(directRead.status).toBe(404); + }); + + test("registers a local account from the welcome screen", async () => { + await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "" }), + }); + + const registered = await fetch(`${baseUrl}/auth/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username: "player", + displayName: "Player", + password: "", + }), + }); + + expect(registered.status).toBe(201); + expect(registered.headers.get("set-cookie")).toContain("HttpOnly"); + await expect(registered.json()).resolves.toMatchObject({ + data: { + user: { + username: "player", + displayName: "Player", + role: "user", + hasPassword: false, + }, + }, + }); + }); + + test("switches accounts only after valid credentials", async () => { + const setup = await fetch(`${baseUrl}/auth/setup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "" }), + }); + const adminCookie = cookieFrom(setup); + + const created = await fetch(`${baseUrl}/auth/users`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: adminCookie, + }, + body: JSON.stringify({ + username: "protected-player", + password: "player-password", + role: "user", + }), + }); + const createdBody = (await created.json()) as { data: { id: string } }; + + const authenticatedStatus = await fetch(`${baseUrl}/auth/status`, { + headers: { cookie: adminCookie }, + }); + await expect(authenticatedStatus.json()).resolves.toMatchObject({ + data: { + authenticated: true, + registrationAllowed: true, + accounts: [ + { username: "admin", hasPassword: false }, + { username: "protected-player", hasPassword: true }, + ], + }, + }); + + const rejected = await fetch(`${baseUrl}/auth/switch`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: adminCookie, + }, + body: JSON.stringify({ + userId: createdBody.data.id, + password: "wrong-password", + }), + }); + expect(rejected.status).toBe(401); + + const preserved = await fetch(`${baseUrl}/auth/status`, { + headers: { cookie: adminCookie }, + }); + await expect(preserved.json()).resolves.toMatchObject({ + data: { authenticated: true, user: { username: "admin" } }, + }); + + const switched = await fetch(`${baseUrl}/auth/switch`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: adminCookie, + }, + body: JSON.stringify({ + userId: createdBody.data.id, + password: "player-password", + }), + }); + expect(switched.status).toBe(200); + const playerCookie = cookieFrom(switched); + await expect(switched.json()).resolves.toMatchObject({ + data: { user: { username: "protected-player" } }, + }); + + const oldSession = await fetch(`${baseUrl}/auth/status`, { + headers: { cookie: adminCookie }, + }); + await expect(oldSession.json()).resolves.toMatchObject({ + data: { authenticated: false }, + }); + const newSession = await fetch(`${baseUrl}/auth/status`, { + headers: { cookie: playerCookie }, + }); + await expect(newSession.json()).resolves.toMatchObject({ + data: { authenticated: true, user: { username: "protected-player" } }, + }); + }); +}); diff --git a/server/src/api/auth.api.ts b/server/src/api/auth.api.ts new file mode 100644 index 00000000..f842256e --- /dev/null +++ b/server/src/api/auth.api.ts @@ -0,0 +1,451 @@ +import { timingSafeEqual } from "node:crypto"; + +import express, { type Request, type Response } from "express"; +import { z } from "zod"; + +import { clearSessionCookie, setSessionCookie } from "../core/auth/auth-cookie"; +import { requireAuthenticatedApi } from "../core/auth/auth-middleware"; +import { createLoginRateLimitMiddleware } from "../core/auth/security-middleware"; +import { asyncHandler } from "../core/middleware/async-handler"; +import { HttpError } from "../core/middleware/error-handler"; +import { validate } from "../core/middleware/validate"; +import { + changeOwnPassword, + createAdditionalUser, + loginUser, + recoverAdministrator, + resetUserPassword, + setupInitialUser, + updateUserAdministration, + AuthServiceError, + type AuthResult, +} from "../services/auth/auth-service"; +import { + cleanupAuthSessions, + revokeAuthSession, + rotateSessionCsrfToken, +} from "../services/auth/session-service"; +import { + countUsers, + listActiveUsers, + listUsers, +} from "../services/auth/users-repository"; + +import type { AuthConfig } from "../core/auth/auth-config"; + +const identitySchema = z.string().trim().min(1).max(128); +const passwordSchema = z.string().max(1024); + +const setupBodySchema = z.object({ + username: identitySchema.max(64), + displayName: identitySchema.optional(), + password: passwordSchema.default(""), +}); + +const loginBodySchema = z + .object({ + username: z.string().trim().max(64).optional(), + userId: z.string().trim().min(1).optional(), + password: passwordSchema.default(""), + }) + .refine((value) => Boolean(value.username || value.userId), { + message: "username or userId is required", + }); + +const createUserBodySchema = setupBodySchema.extend({ + role: z.enum(["admin", "user"]).default("user"), +}); + +const changePasswordBodySchema = z.object({ + currentPassword: passwordSchema.default(""), + newPassword: passwordSchema, +}); + +const resetPasswordBodySchema = z.object({ + newPassword: passwordSchema, +}); + +const patchUserBodySchema = z + .object({ + role: z.enum(["admin", "user"]).optional(), + status: z.enum(["active", "disabled"]).optional(), + }) + .refine((value) => value.role !== undefined || value.status !== undefined, { + message: "role or status is required", + }); + +const userIdParamsSchema = z.object({ + id: z.string().trim().min(1), +}); + +const recoveryBodySchema = z.object({ + username: identitySchema.max(64), + newPassword: passwordSchema, +}); + +function safeTokenEquals(actual: string | undefined, expected: string): boolean { + if (!actual) return false; + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return ( + actualBuffer.length === expectedBuffer.length && + timingSafeEqual(actualBuffer, expectedBuffer) + ); +} + +function requirePublicSetupToken(request: Request, config: AuthConfig): void { + if (config.policy.mode !== "public") return; + if ( + !config.setupToken || + !safeTokenEquals(request.header("x-setup-token"), config.setupToken) + ) { + throw new HttpError(403, "Invalid setup token.", "INVALID_SETUP_TOKEN"); + } +} + +function mapAuthError( + error: unknown, + config: AuthConfig, + context: "login" | "default" = "default" +): never { + if (!(error instanceof AuthServiceError)) throw error; + if ( + context === "login" && + config.policy.mode === "public" && + ["INVALID_CREDENTIALS", "ACCOUNT_DISABLED", "PASSWORD_REQUIRED"].includes( + error.code + ) + ) { + throw new HttpError( + 401, + "Invalid username or password.", + "INVALID_CREDENTIALS" + ); + } + const statusByCode: Partial> = { + LAST_ADMIN_REQUIRED: 409, + PASSWORD_POLICY_VIOLATION: 400, + SETUP_COMPLETED: 409, + USERNAME_TAKEN: 409, + USER_NOT_FOUND: 404, + }; + throw new HttpError(statusByCode[error.code] ?? 401, error.message, error.code); +} + +function applyAuthResult( + response: Response, + result: AuthResult, + config: AuthConfig +) { + setSessionCookie({ + response, + token: result.session.token, + expiresAt: result.session.expiresAt, + config, + }); + return { + user: result.user, + csrfToken: result.session.csrfToken, + expiresAt: result.session.expiresAt, + }; +} + +export function createAuthRouter(config: AuthConfig) { + const router = express.Router(); + const authRateLimit = createLoginRateLimitMiddleware(config); + const registrationAllowed = + config.policy.mode === "local" || config.allowRegistration; + + router.get( + "/status", + asyncHandler(async (request: Request, response: Response) => { + await cleanupAuthSessions(); + const userCount = await countUsers(); + if (userCount === 0) { + return { + data: { + mode: config.policy.mode, + registrationAllowed, + setupRequired: true, + authenticated: false, + user: null, + accounts: [], + }, + }; + } + + if (request.auth) { + const accounts = + config.policy.mode === "local" ? await listActiveUsers() : []; + const csrfToken = await rotateSessionCsrfToken( + request.auth.sessionId, + config + ); + return { + data: { + mode: config.policy.mode, + registrationAllowed, + setupRequired: false, + authenticated: true, + user: request.auth.user, + accounts, + csrfToken, + }, + }; + } + + const accounts = + config.policy.mode === "local" ? await listActiveUsers() : []; + if ( + config.policy.automaticLoginAllowed && + accounts.length === 1 && + !accounts[0]?.hasPassword + ) { + const result = await loginUser({ + userId: accounts[0].id, + password: "", + config, + }); + return { + data: { + mode: config.policy.mode, + registrationAllowed, + setupRequired: false, + authenticated: true, + ...applyAuthResult(response, result, config), + accounts: [], + }, + }; + } + + return { + data: { + mode: config.policy.mode, + registrationAllowed, + setupRequired: false, + authenticated: false, + user: null, + accounts, + }, + }; + }) + ); + + router.post( + "/setup", + authRateLimit, + validate({ body: setupBodySchema }), + asyncHandler(async (request: Request, response: Response) => { + requirePublicSetupToken(request, config); + const body = setupBodySchema.parse(request.body); + try { + const result = await setupInitialUser({ ...body, config }); + return { + status: 201, + data: applyAuthResult(response, result, config), + }; + } catch (error) { + mapAuthError(error, config); + } + }) + ); + + router.post( + "/login", + authRateLimit, + validate({ body: loginBodySchema }), + asyncHandler(async (request: Request, response: Response) => { + const body = loginBodySchema.parse(request.body); + try { + const result = await loginUser({ ...body, config }); + return { data: applyAuthResult(response, result, config) }; + } catch (error) { + mapAuthError(error, config, "login"); + } + }) + ); + + router.post( + "/register", + authRateLimit, + validate({ body: setupBodySchema }), + asyncHandler(async (request: Request, response: Response) => { + if (!registrationAllowed) { + throw new HttpError( + 403, + "Public registration is disabled.", + "REGISTRATION_DISABLED" + ); + } + if ((await countUsers()) === 0) { + throw new HttpError( + 409, + "Initial setup must be completed first.", + "SETUP_REQUIRED" + ); + } + const body = setupBodySchema.parse(request.body); + try { + await createAdditionalUser({ ...body, role: "user", config }); + const result = await loginUser({ + username: body.username, + password: body.password, + config, + }); + return { + status: 201, + data: applyAuthResult(response, result, config), + }; + } catch (error) { + mapAuthError(error, config); + } + }) + ); + + router.post( + "/switch", + authRateLimit, + requireAuthenticatedApi, + validate({ body: loginBodySchema }), + asyncHandler(async (request: Request, response: Response) => { + const body = loginBodySchema.parse(request.body); + try { + const result = await loginUser({ ...body, config }); + await revokeAuthSession(request.auth!.sessionId); + return { data: applyAuthResult(response, result, config) }; + } catch (error) { + mapAuthError(error, config, "login"); + } + }) + ); + + router.post( + "/recover", + authRateLimit, + validate({ body: recoveryBodySchema }), + asyncHandler(async (request: Request) => { + if (config.policy.mode !== "public") { + throw new HttpError( + 404, + "Administrator recovery is only available in public mode.", + "NOT_FOUND" + ); + } + requirePublicSetupToken(request, config); + const body = recoveryBodySchema.parse(request.body); + try { + await recoverAdministrator({ ...body, config }); + return { data: { ok: true } }; + } catch (error) { + mapAuthError(error, config, "login"); + } + }) + ); + + router.post( + "/logout", + requireAuthenticatedApi, + asyncHandler(async (request: Request, response: Response) => { + if (request.auth) await revokeAuthSession(request.auth.sessionId); + clearSessionCookie(response, config); + return { data: { ok: true } }; + }) + ); + + router.post( + "/password", + requireAuthenticatedApi, + validate({ body: changePasswordBodySchema }), + asyncHandler(async (request: Request, response: Response) => { + const body = changePasswordBodySchema.parse(request.body); + try { + const result = await changeOwnPassword({ + userId: request.auth!.user.id, + ...body, + config, + }); + return { data: applyAuthResult(response, result, config) }; + } catch (error) { + mapAuthError(error, config, "login"); + } + }) + ); + + router.get( + "/users", + requireAuthenticatedApi, + asyncHandler(async (request: Request) => { + if (request.auth?.user.role !== "admin") { + throw new HttpError(403, "Administrator access is required.", "FORBIDDEN"); + } + return { data: await listUsers() }; + }) + ); + + router.post( + "/users", + requireAuthenticatedApi, + validate({ body: createUserBodySchema }), + asyncHandler(async (request: Request) => { + if (request.auth?.user.role !== "admin") { + throw new HttpError(403, "Administrator access is required.", "FORBIDDEN"); + } + const body = createUserBodySchema.parse(request.body); + try { + const user = await createAdditionalUser({ ...body, config }); + return { status: 201, data: user }; + } catch (error) { + mapAuthError(error, config); + } + }) + ); + + router.patch( + "/users/:id", + requireAuthenticatedApi, + validate({ params: userIdParamsSchema, body: patchUserBodySchema }), + asyncHandler(async (request: Request) => { + if (request.auth?.user.role !== "admin") { + throw new HttpError(403, "Administrator access is required.", "FORBIDDEN"); + } + const params = userIdParamsSchema.parse(request.params); + const body = patchUserBodySchema.parse(request.body); + try { + return { + data: await updateUserAdministration({ + userId: params.id, + ...body, + }), + }; + } catch (error) { + mapAuthError(error, config); + } + }) + ); + + router.post( + "/users/:id/password", + requireAuthenticatedApi, + validate({ params: userIdParamsSchema, body: resetPasswordBodySchema }), + asyncHandler(async (request: Request) => { + if (request.auth?.user.role !== "admin") { + throw new HttpError(403, "Administrator access is required.", "FORBIDDEN"); + } + const params = userIdParamsSchema.parse(request.params); + const body = resetPasswordBodySchema.parse(request.body); + try { + return { + data: await resetUserPassword({ + userId: params.id, + ...body, + config, + }), + }; + } catch (error) { + mapAuthError(error, config); + } + }) + ); + + return router; +} diff --git a/server/src/api/chat-knowledge.core.api.test.ts b/server/src/api/chat-knowledge.core.api.test.ts index f37a72c3..970adfde 100644 --- a/server/src/api/chat-knowledge.core.api.test.ts +++ b/server/src/api/chat-knowledge.core.api.test.ts @@ -40,6 +40,18 @@ async function stopServer(server: Server, tempDir: string) { await fs.rm(tempDir, { recursive: true, force: true }); } +async function setupLocalAccount(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/api/auth/setup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "test-admin", password: "" }), + }); + expect(response.status).toBe(201); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("Auth setup did not return a session cookie"); + return setCookie.split(";")[0]; +} + async function seedChatScope(params: { chatId: string; branchId: string }) { const db = await initDb(); const now = new Date(); @@ -100,12 +112,14 @@ describe("chat knowledge api", () => { test("creates records, searches previews, and reveals through HTTP routes", async () => { const started = await startServer(); try { + const cookie = await setupLocalAccount(started.baseUrl); await seedChatScope({ chatId: "chat-api", branchId: "branch-api" }); const collectionResponse = await fetch(`${started.baseUrl}/api/chat-knowledge/collections`, { method: "POST", headers: { "content-type": "application/json", + cookie, }, body: JSON.stringify({ chatId: "chat-api", @@ -124,6 +138,7 @@ describe("chat knowledge api", () => { method: "POST", headers: { "content-type": "application/json", + cookie, }, body: JSON.stringify({ chatId: "chat-api", @@ -153,6 +168,7 @@ describe("chat knowledge api", () => { method: "POST", headers: { "content-type": "application/json", + cookie, }, body: JSON.stringify({ chatId: "chat-api", @@ -174,6 +190,7 @@ describe("chat knowledge api", () => { method: "POST", headers: { "content-type": "application/json", + cookie, }, body: JSON.stringify({ chatId: "chat-api", @@ -194,6 +211,7 @@ describe("chat knowledge api", () => { method: "POST", headers: { "content-type": "application/json", + cookie, }, body: JSON.stringify({ chatId: "chat-api", diff --git a/server/src/api/entity-profiles.core.api.ts b/server/src/api/entity-profiles.core.api.ts index 80cc35f6..143b1404 100644 --- a/server/src/api/entity-profiles.core.api.ts +++ b/server/src/api/entity-profiles.core.api.ts @@ -17,6 +17,7 @@ import { getRequestOwnerId } from "../core/request-context/request-context"; import { listChatsByEntityProfile, } from "../services/chat-core/chats-repository"; +import { resolveEntityProfileMediaPath } from "../services/chat-core/entity-profile-media"; import { createEntityProfile, deleteEntityProfile, @@ -24,7 +25,6 @@ import { listEntityProfiles, updateEntityProfile, } from "../services/chat-core/entity-profiles-repository"; -import { createDataPath } from "../utils"; const router = express.Router(); @@ -111,15 +111,8 @@ function injectChunkBeforeIEND(basePng: Buffer, chunk: Buffer): Buffer { return Buffer.concat([basePng, chunk]); } -function toProfileMediaPath(avatarAssetId: string | null): string | null { - if (!avatarAssetId) return null; - if (!avatarAssetId.startsWith("/media/")) return null; - return createDataPath(avatarAssetId.replace(/^\/media\//, "media/")); -} - async function buildCharSpecPngBuffer(profile: { name: string; spec: unknown; avatarAssetId: string | null }): Promise { - const relPath = profile.avatarAssetId?.replace(/^\/media\//, "") ?? null; - const filePath = relPath ? createDataPath("media", relPath) : null; + const filePath = resolveEntityProfileMediaPath(profile.avatarAssetId); let basePng = Buffer.from(FALLBACK_PNG_BASE64, "base64"); if (filePath) { @@ -207,8 +200,8 @@ router.put( }); // Best-effort cleanup when avatar is replaced/cleared (avoid orphaned files). - const beforePath = toProfileMediaPath(before?.avatarAssetId ?? null); - const afterPath = toProfileMediaPath(updated?.avatarAssetId ?? null); + const beforePath = resolveEntityProfileMediaPath(before?.avatarAssetId ?? null); + const afterPath = resolveEntityProfileMediaPath(updated?.avatarAssetId ?? null); if (beforePath && beforePath !== afterPath) { await fs.unlink(beforePath).catch(() => undefined); } @@ -264,12 +257,8 @@ router.delete( await deleteEntityProfile(params.id); // Best-effort cleanup for imported avatars (avoid orphaned files). - const avatarPath = profile?.avatarAssetId ?? null; - if (avatarPath && avatarPath.startsWith("/media/images/entity-profiles/")) { - const rel = avatarPath.replace(/^\/media\//, ""); // -> images/entity-profiles/... - const filePath = createDataPath("media", rel); - await fs.unlink(filePath).catch(() => undefined); - } + const filePath = resolveEntityProfileMediaPath(profile?.avatarAssetId ?? null); + if (filePath) await fs.unlink(filePath).catch(() => undefined); return { data: { id: params.id } }; }) diff --git a/server/src/api/entity-profiles.import.api.ts b/server/src/api/entity-profiles.import.api.ts index 3c81adee..985ea20d 100644 --- a/server/src/api/entity-profiles.import.api.ts +++ b/server/src/api/entity-profiles.import.api.ts @@ -1,5 +1,3 @@ -import { randomUUID } from "crypto"; -import fs from "fs/promises"; import path from "path"; import express, { type Request } from "express"; @@ -7,14 +5,15 @@ import multer from "multer"; import { asyncHandler } from "@core/middleware/async-handler"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { normalizeCharSpec } from "../chat-core/charspec/normalize"; import { extractCharSpecFromPngBuffer } from "../chat-core/charspec/png"; +import { saveEntityProfileAvatarPng } from "../services/chat-core/entity-profile-media"; import { createEntityProfile, type EntityProfileDto, } from "../services/chat-core/entity-profiles-repository"; -import { createDataPath } from "../utils"; type ImportFailed = { originalName: string; error: string }; @@ -31,34 +30,11 @@ const upload = multer({ }, }); -function parseOwnerId(req: Request): string { - const raw = (req.body as { ownerId?: unknown } | undefined)?.ownerId; - if (typeof raw === "string" && raw.trim().length > 0) return raw.trim(); - return "global"; -} - function safeJsonParseBuffer(buffer: Buffer): unknown { const text = buffer.toString("utf-8"); return JSON.parse(text) as unknown; } -function getEntityProfileImagesDir(): string { - return createDataPath("media", "images", "entity-profiles"); -} - -async function saveEntityProfilePng( - fileBuffer: Buffer -): Promise<{ urlPath: string; filename: string }> { - const dir = getEntityProfileImagesDir(); - await fs.mkdir(dir, { recursive: true }); - - const filename = `${randomUUID()}.png`; - const filePath = path.join(dir, filename); - await fs.writeFile(filePath, fileBuffer); - - return { filename, urlPath: `/media/images/entity-profiles/${filename}` }; -} - router.post( "/entity-profiles/import", upload.array("files", 10), @@ -67,7 +43,7 @@ router.post( throw new HttpError(400, "Файлы не были загружены", "VALIDATION_ERROR"); } - const ownerId = parseOwnerId(req); + const ownerId = resolveTrustedOwnerId(); const created: EntityProfileDto[] = []; const failed: ImportFailed[] = []; @@ -79,8 +55,7 @@ router.post( let avatarUrlPath: string | null = null; if (ext === ".png") { - const saved = await saveEntityProfilePng(file.buffer); - avatarUrlPath = saved.urlPath; + avatarUrlPath = await saveEntityProfileAvatarPng(file.buffer); rawSpec = await extractCharSpecFromPngBuffer(file.buffer); } else if (ext === ".json") { rawSpec = safeJsonParseBuffer(file.buffer); diff --git a/server/src/api/files/controllers.ts b/server/src/api/files/controllers.ts index b1c3d4e4..48763f0c 100644 --- a/server/src/api/files/controllers.ts +++ b/server/src/api/files/controllers.ts @@ -7,6 +7,7 @@ import sharp from "sharp"; import { resolveSafePath } from "@core/files/safe-path"; import { type AsyncRequestHandler } from "@core/middleware/async-handler"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import fileService from "@services/file-service"; import { createDataPath } from "../../utils"; @@ -23,6 +24,16 @@ function readFilenameOrThrow(raw: unknown): string { if (typeof filename !== "string" || filename.trim().length === 0) { throw new HttpError(400, "filename обязателен", "VALIDATION_ERROR"); } + const ownerId = resolveTrustedOwnerId(); + const scopedSeparator = filename.indexOf("__"); + if ( + (ownerId !== "global" && !filename.startsWith(`${ownerId}__`)) || + (ownerId === "global" && + scopedSeparator >= 0 && + !filename.startsWith("global__")) + ) { + throw new HttpError(404, "Файл не найден", "NOT_FOUND"); + } return filename; } @@ -58,7 +69,7 @@ export const uploadFiles: AsyncRequestHandler = async (req) => { const uploadedFiles = await Promise.all( req.files.map(async (file) => { const fileExtension = path.extname(file.originalname); - const filename = `${uuidv4()}${fileExtension}`; + const filename = `${resolveTrustedOwnerId()}__${uuidv4()}${fileExtension}`; await fileService.saveFile(file.buffer, filename); return { originalName: file.originalname, @@ -138,14 +149,15 @@ export const uploadCards: AsyncRequestHandler = async (req) => { const cardImagesPath = path.join( createDataPath("media", "images"), - "agent-cards" + "agent-cards", + resolveTrustedOwnerId() ); await fs.mkdir(cardImagesPath, { recursive: true }); for (const file of req.files) { try { const fileExtension = path.extname(file.originalname).toLowerCase(); - const filename = `${uuidv4()}${fileExtension}`; + const filename = `${resolveTrustedOwnerId()}__${uuidv4()}${fileExtension}`; const filePath = resolveSafePath(cardImagesPath, filename); if (fileExtension === ".png") { @@ -167,7 +179,7 @@ export const uploadCards: AsyncRequestHandler = async (req) => { processedFiles.push({ originalName: file.originalname, filename, - path: `/media/images/agent-cards/${filename}`, + path: `/media/images/agent-cards/${resolveTrustedOwnerId()}/${filename}`, characterData: [JSON.parse(characterData)], metadata: { ...metadata, @@ -183,7 +195,7 @@ export const uploadCards: AsyncRequestHandler = async (req) => { processedFiles.push({ originalName: file.originalname, filename, - path: `/media/images/agent-cards/${filename}`, + path: `/media/images/agent-cards/${resolveTrustedOwnerId()}/${filename}`, metadata: { width: 0, height: 0, @@ -222,7 +234,8 @@ export const uploadImage: AsyncRequestHandler = async (req) => { const imageFolder = path.join( createDataPath("media", "images"), - sanitizedFolderName + sanitizedFolderName, + resolveTrustedOwnerId() ); // Создаем папку, если она не существует @@ -251,7 +264,7 @@ export const uploadImage: AsyncRequestHandler = async (req) => { return { data: { file: uploadedFile, - path: `/media/images/${sanitizedFolderName}/${filename}`, + path: `/media/images/${sanitizedFolderName}/${resolveTrustedOwnerId()}/${filename}`, message: "Изображение успешно загружено", }, }; diff --git a/server/src/api/legacy-route-wrappers.test.ts b/server/src/api/legacy-route-wrappers.test.ts index cc8bdbbb..20297cc7 100644 --- a/server/src/api/legacy-route-wrappers.test.ts +++ b/server/src/api/legacy-route-wrappers.test.ts @@ -26,7 +26,17 @@ async function requestJson(requestPath: string): Promise { if (!address || typeof address === "string") { throw new Error("Failed to resolve test server address"); } - return await fetch(`http://127.0.0.1:${address.port}${requestPath}`); + const baseUrl = `http://127.0.0.1:${address.port}`; + const setup = await fetch(`${baseUrl}/api/auth/setup`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "test-admin", password: "" }), + }); + const setCookie = setup.headers.get("set-cookie"); + if (!setCookie) throw new Error("Auth setup did not return a session cookie"); + return await fetch(`${baseUrl}${requestPath}`, { + headers: { cookie: setCookie.split(";")[0] }, + }); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); diff --git a/server/src/api/llm.api.ts b/server/src/api/llm.api.ts index 9ccf77b2..1e0ea6d0 100644 --- a/server/src/api/llm.api.ts +++ b/server/src/api/llm.api.ts @@ -15,12 +15,17 @@ import { deleteToken, getProviderConfig, getRuntime, + getRuntimeProviderState, listProviders, listTokens, upsertProviderConfig, updateToken, } from "@services/llm/llm-repository"; -import { checkProviderConnection, getModels } from "@services/llm/llm-service"; +import { + checkProviderConnection, + getModels, + getOpenRouterModelEndpoints, +} from "@services/llm/llm-service"; import { updateLlmRuntime } from "../application/llm/use-cases/update-llm-runtime"; @@ -43,6 +48,10 @@ const runtimePatchSchema = z.object({ activeModel: z.string().min(1).nullable().optional(), }); +const runtimeProviderStateQuerySchema = runtimeQuerySchema.extend({ + providerId: providerIdSchema, +}); + const providerConnectionCheckBodySchema = z.object({ scope: scopeSchema.optional().default("global"), scopeId: z.string().min(1).optional().default("global"), @@ -62,7 +71,7 @@ router.get( })); return { data: { providers } }; - }) + }), ); router.get( @@ -80,7 +89,7 @@ router.get( } return { data: { ...runtime, activeTokenHint } }; - }) + }), ); router.patch( @@ -97,7 +106,16 @@ router.patch( activeModel: body.activeModel, }), }; - }) + }), +); + +router.get( + "/llm/runtime/provider-state", + validate({ query: runtimeProviderStateQuerySchema }), + asyncHandler(async (req: Request) => { + const query = runtimeProviderStateQuerySchema.parse(req.query); + return { data: await getRuntimeProviderState(query) }; + }), ); router.get( @@ -107,7 +125,7 @@ router.get( const providerId = req.params.providerId as LlmProviderId; const config = await getProviderConfig(providerId); return { data: config }; - }) + }), ); router.patch( @@ -126,7 +144,7 @@ router.patch( const saved = await upsertProviderConfig(providerId, parsed); return { data: saved }; - }) + }), ); router.post( @@ -148,7 +166,7 @@ router.post( configOverride: body.config, }), }; - }) + }), ); router.get( @@ -156,11 +174,11 @@ router.get( validate({ query: z.object({ providerId: providerIdSchema }) }), asyncHandler(async (req: Request) => { const providerId = providerIdSchema.parse( - (req.query as unknown as { providerId?: unknown }).providerId + (req.query as unknown as { providerId?: unknown }).providerId, ) as LlmProviderId; const tokens = await listTokens(providerId); return { data: { tokens } }; - }) + }), ); const tokenCreateSchema = z.object({ @@ -180,7 +198,7 @@ router.post( token: body.token, }); return { data: created }; - }) + }), ); const tokenPatchSchema = z @@ -201,7 +219,7 @@ router.patch( const body = req.body as z.infer; await updateToken({ id, name: body.name, token: body.token }); return { data: { success: true } }; - }) + }), ); router.delete( @@ -210,7 +228,7 @@ router.delete( asyncHandler(async (req: Request) => { await deleteToken(String(req.params.id)); return { data: { success: true } }; - }) + }), ); router.get( @@ -250,7 +268,20 @@ router.get( }); return { data: { models } }; - }) + }), +); + +router.get( + "/llm/openrouter/endpoints", + validate({ query: z.object({ modelId: z.string().min(1) }) }), + asyncHandler(async (req: Request) => { + const { modelId } = z + .object({ modelId: z.string().min(1) }) + .parse(req.query); + return { + data: { endpoints: await getOpenRouterModelEndpoints({ modelId }) }, + }; + }), ); // Guardrail: expose only the new endpoints; legacy configs should not be used. @@ -260,9 +291,9 @@ router.all( throw new HttpError( 410, "Legacy endpoint removed", - "LEGACY_ENDPOINT_REMOVED" + "LEGACY_ENDPOINT_REMOVED", ); - }) + }), ); export default router; diff --git a/server/src/api/operation-blocks.core.api.ts b/server/src/api/operation-blocks.core.api.ts index 8cfc6394..4a527028 100644 --- a/server/src/api/operation-blocks.core.api.ts +++ b/server/src/api/operation-blocks.core.api.ts @@ -55,7 +55,10 @@ router.get( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - const item = await getOperationBlockById(params.id); + const item = await getOperationBlockById({ + ownerId: getRequestOwnerId(req), + blockId: params.id, + }); if (!item) throw new HttpError(404, "OperationBlock не найден", "NOT_FOUND"); return { data: item }; }) @@ -95,7 +98,10 @@ router.get( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - const item = await getOperationBlockById(params.id); + const item = await getOperationBlockById({ + ownerId: getRequestOwnerId(req), + blockId: params.id, + }); if (!item) throw new HttpError(404, "OperationBlock не найден", "NOT_FOUND"); return { data: { diff --git a/server/src/api/operation-profiles.core.api.ts b/server/src/api/operation-profiles.core.api.ts index 4eaffcab..855706d3 100644 --- a/server/src/api/operation-profiles.core.api.ts +++ b/server/src/api/operation-profiles.core.api.ts @@ -57,8 +57,10 @@ router.post( router.get( "/operation-profiles/active", - asyncHandler(async () => { - const settings = await getOperationProfileSettings(); + asyncHandler(async (req: Request) => { + const settings = await getOperationProfileSettings({ + ownerId: getRequestOwnerId(req), + }); return { data: settings }; }) ); @@ -73,7 +75,10 @@ router.put( asyncHandler(async (req: Request) => { const body = setActiveBodySchema.parse(req.body); return { - data: await setActiveOperationProfileWithValidation(body.activeProfileId), + data: await setActiveOperationProfileWithValidation({ + ownerId: getRequestOwnerId(req), + activeProfileId: body.activeProfileId, + }), }; }) ); @@ -83,7 +88,10 @@ router.get( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - const item = await getOperationProfileById(params.id); + const item = await getOperationProfileById({ + ownerId: getRequestOwnerId(req), + profileId: params.id, + }); if (!item) throw new HttpError(404, "OperationProfile не найден", "NOT_FOUND"); return { data: item }; }) @@ -109,7 +117,10 @@ router.delete( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - const exists = await getOperationProfileById(params.id); + const exists = await getOperationProfileById({ + ownerId: getRequestOwnerId(req), + profileId: params.id, + }); if (!exists) throw new HttpError(404, "OperationProfile не найден", "NOT_FOUND"); await deleteOperationProfile({ ownerId: getRequestOwnerId(req), profileId: params.id }); return { data: { id: params.id } }; @@ -123,7 +134,12 @@ router.get( validate({ params: idParamsSchema }), asyncHandler(async (req: Request) => { const params = req.params as unknown as { id: string }; - return { data: await exportOperationProfileBundle(params.id) }; + return { + data: await exportOperationProfileBundle({ + ownerId: getRequestOwnerId(req), + profileId: params.id, + }), + }; }) ); diff --git a/server/src/api/rag.api.test.ts b/server/src/api/rag.api.test.ts index 2f9eefb4..7750756a 100644 --- a/server/src/api/rag.api.test.ts +++ b/server/src/api/rag.api.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'; import { ragEmbeddingsBodySchema, ragModelsQuerySchema, + ragProviderConnectionCheckBodySchema, ragPresetCreateBodySchema, ragPresetSettingsPatchBodySchema, ragPresetUpdateBodySchema, @@ -38,6 +39,17 @@ describe('rag route schemas', () => { expect(ragModelsQuerySchema.safeParse({ providerId: 'bad' }).success).toBe(false); }); + test('connection check accepts a draft token and config', () => { + expect(ragProviderConnectionCheckBodySchema.safeParse({ tokenId: null, config: {} }).success).toBe(true); + expect( + ragProviderConnectionCheckBodySchema.safeParse({ + tokenId: 'token-1', + config: { baseUrl: 'http://localhost:11434' }, + }).success, + ).toBe(true); + expect(ragProviderConnectionCheckBodySchema.safeParse({ tokenId: '' }).success).toBe(false); + }); + test('embeddings schema accepts non-empty string or non-empty array of non-empty strings', () => { expect(ragEmbeddingsBodySchema.safeParse({ input: 'hello' }).success).toBe(true); expect(ragEmbeddingsBodySchema.safeParse({ input: ['one', 'two'] }).success).toBe(true); diff --git a/server/src/api/rag.api.ts b/server/src/api/rag.api.ts index a42dd839..4e62f073 100644 --- a/server/src/api/rag.api.ts +++ b/server/src/api/rag.api.ts @@ -5,6 +5,7 @@ import { asyncHandler } from '@core/middleware/async-handler'; import { HttpError } from '@core/middleware/error-handler'; import { validate } from '@core/middleware/validate'; import { + checkRagProviderConnection, ensureRagPresetState, generateRagEmbedding, getRagProviderConfig, @@ -35,6 +36,10 @@ export const ragModelsQuerySchema = z.object({ providerId: ragProviderIdSchema, tokenId: z.string().min(1).optional(), }); +export const ragProviderConnectionCheckBodySchema = z.object({ + tokenId: z.string().min(1).nullable().optional(), + config: z.record(z.string(), z.unknown()).optional(), +}); export const ragEmbeddingsBodySchema = z.object({ input: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]), }); @@ -65,6 +70,21 @@ router.patch('/rag/providers/:providerId/config', validate({ params: ragProvider return { data: { providerId, config: await patchRagProviderConfig(providerId, req.body) } }; })); +router.post('/rag/providers/:providerId/check', validate({ + params: ragProviderParamsSchema, + body: ragProviderConnectionCheckBodySchema, +}), asyncHandler(async (req: Request) => { + const providerId = req.params.providerId as RagProviderId; + const body = ragProviderConnectionCheckBodySchema.parse(req.body); + return { + data: await checkRagProviderConnection({ + providerId, + tokenId: body.tokenId ?? null, + configOverride: body.config, + }), + }; +})); + router.get('/rag/tokens', validate({ query: ragTokensQuerySchema }), asyncHandler(async (req: Request) => { const providerId = ragProviderIdSchema.parse((req.query as { providerId?: string }).providerId); const tokens = await listRagTokens(providerId); diff --git a/server/src/app.ts b/server/src/app.ts index bdb01f9f..5c74eb91 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,11 +5,31 @@ import express, { type Express } from "express"; import morgan from "morgan"; import { routes } from "./api/_routes_"; +import { createAuthRouter } from "./api/auth.api"; import staticRouter from "./api/static.api"; +import { resolveAccessPolicy } from "./core/auth/access-policy"; +import { resolveAuthConfig } from "./core/auth/auth-config"; +import { + createAuthContextMiddleware, + requireAuthenticatedApi, +} from "./core/auth/auth-middleware"; +import { + createCsrfProtectionMiddleware, + createHttpsEnforcementMiddleware, + securityHeadersMiddleware, +} from "./core/auth/security-middleware"; +import { + mediaOwnerMiddleware, + trustedOwnerMiddleware, +} from "./core/auth/trusted-owner-middleware"; import { runBackendBootstrap } from "./core/bootstrap/bootstrap-coordinator"; import { structuredLogger } from "./core/logging/structured-logger"; import { errorHandler } from "./core/middleware/error-handler"; import { requestLifecycleLogger } from "./core/middleware/request-lifecycle-logger"; +import { + rejectDisallowedOrigin, + resolveServerNetworkPolicy, +} from "./core/network/server-network-policy"; import { requestContextMiddleware } from "./core/request-context/request-context"; export type BootstrapAppOptions = { @@ -26,13 +46,33 @@ function shouldUseRequestLogging(): boolean { export function createApp(): Express { const app = express(); + const authConfig = resolveAuthConfig(); + const accessPolicy = resolveAccessPolicy(); + const networkPolicy = resolveServerNetworkPolicy(); + app.locals.accessPolicy = accessPolicy; + app.locals.authConfig = authConfig; + if (authConfig.trustProxy) app.set("trust proxy", 1); + + app.use(securityHeadersMiddleware); + app.use(createHttpsEnforcementMiddleware(authConfig)); if (shouldUseRequestLogging()) { app.use(morgan("dev")); } - app.use(cors()); + app.use(rejectDisallowedOrigin(networkPolicy)); + app.use( + cors({ + origin: (origin, callback) => callback(null, networkPolicy.isOriginAllowed(origin)), + credentials: true, + }) + ); app.use(express.json({ limit: "10mb" })); app.use(requestContextMiddleware); + app.use("/api", createAuthContextMiddleware(authConfig)); + app.use("/media", createAuthContextMiddleware(authConfig)); + app.use("/media", requireAuthenticatedApi, mediaOwnerMiddleware); + app.use("/api", trustedOwnerMiddleware); + app.use("/api", createCsrfProtectionMiddleware(authConfig)); if (shouldUseRequestLogging()) { app.use(requestLifecycleLogger); } @@ -40,7 +80,8 @@ export function createApp(): Express { app.use(express.static("public")); app.use(staticRouter); - app.use("/api", routes); + app.use("/api/auth", createAuthRouter(authConfig)); + app.use("/api", requireAuthenticatedApi, routes); app.use(errorHandler(structuredLogger)); @@ -53,9 +94,10 @@ export async function startAppServer(options: { }): Promise<{ app: Express; server: Server }> { await bootstrapApp({ dbPath: options.dbPath }); const app = createApp(); + const networkPolicy = resolveServerNetworkPolicy(); const server = await new Promise((resolve) => { - const s = app.listen(options.port, () => resolve(s)); + const s = app.listen(options.port, networkPolicy.host, () => resolve(s)); }); return { app, server }; diff --git a/server/src/application/chat-runtime/use-cases/continue-generation.ts b/server/src/application/chat-runtime/use-cases/continue-generation.ts index 21f38d74..22d6cd28 100644 --- a/server/src/application/chat-runtime/use-cases/continue-generation.ts +++ b/server/src/application/chat-runtime/use-cases/continue-generation.ts @@ -1,4 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; @@ -35,7 +36,7 @@ export async function continueGeneration( const chat = await getChatById(params.chatId); if (!chat) throw new HttpError(404, "Chat не найден", "NOT_FOUND"); - const ownerId = params.body.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.body.ownerId); const branchId = params.body.branchId || chat.activeBranchId; if (!branchId) { throw new HttpError(400, "branchId обязателен (нет activeBranchId)", "VALIDATION_ERROR"); diff --git a/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts b/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts index 24dd06b1..ead20c16 100644 --- a/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts +++ b/server/src/application/chat-runtime/use-cases/create-entry-and-start-generation.ts @@ -1,4 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; @@ -38,7 +39,7 @@ export async function createEntryAndStartGeneration( const chat = await getChatById(params.chatId); if (!chat) throw new HttpError(404, "Chat не найден", "NOT_FOUND"); - const ownerId = params.body.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.body.ownerId); const branchId = params.body.branchId || chat.activeBranchId; if (!branchId) { throw new HttpError(400, "branchId обязателен (нет activeBranchId)", "VALIDATION_ERROR"); diff --git a/server/src/application/chat-runtime/use-cases/get-chat-operation-runtime-state.ts b/server/src/application/chat-runtime/use-cases/get-chat-operation-runtime-state.ts index 875e481b..14bf156a 100644 --- a/server/src/application/chat-runtime/use-cases/get-chat-operation-runtime-state.ts +++ b/server/src/application/chat-runtime/use-cases/get-chat-operation-runtime-state.ts @@ -52,10 +52,13 @@ export async function getChatOperationRuntimeState( const empty = buildEmptyState(chat.id, branchId); - const settings = await getOperationProfileSettings(); + const settings = await getOperationProfileSettings({ ownerId: chat.ownerId }); if (!settings.activeProfileId) return empty; - const profile = await getOperationProfileById(settings.activeProfileId); + const profile = await getOperationProfileById({ + ownerId: chat.ownerId, + profileId: settings.activeProfileId, + }); if (!profile || !profile.enabled) return empty; const compiled = await resolveCompiledOperationProfile(profile); diff --git a/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts b/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts index 837d22b4..cf3f4096 100644 --- a/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts +++ b/server/src/application/chat-runtime/use-cases/manual-edit-entry.ts @@ -1,4 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; @@ -53,7 +54,7 @@ export async function manualEditEntry( requestedPartId: params.body.partId, }); - const ownerId = params.body.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.body.ownerId); const chat = await getChatById(entry.chatId); const templateContext = await buildInstructionRenderContext({ ownerId, diff --git a/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts b/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts index 8f02e86c..de61502f 100644 --- a/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts +++ b/server/src/application/chat-runtime/use-cases/regenerate-assistant-variant.ts @@ -1,4 +1,5 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { withDbTransaction } from "../../../db/client"; import { getChatById } from "../../../services/chat-core/chats-repository"; @@ -41,7 +42,7 @@ export async function regenerateAssistantVariant( const chat = await getChatById(entry.chatId); if (!chat) throw new HttpError(404, "Chat не найден", "NOT_FOUND"); - const ownerId = params.body.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.body.ownerId); const currentTurn = await getBranchCurrentTurn({ branchId: entry.branchId }); const userTurnTarget = await resolveRegenerateUserTurnTarget({ chatId: entry.chatId, diff --git a/server/src/application/operations/use-cases/delete-operation-block.ts b/server/src/application/operations/use-cases/delete-operation-block.ts index be6987cb..a2af692b 100644 --- a/server/src/application/operations/use-cases/delete-operation-block.ts +++ b/server/src/application/operations/use-cases/delete-operation-block.ts @@ -10,7 +10,10 @@ export async function deleteOperationBlockWithValidation(params: { ownerId: string; blockId: string; }): Promise<{ id: string }> { - const exists = await getOperationBlockById(params.blockId); + const exists = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: params.blockId, + }); if (!exists) throw new HttpError(404, "OperationBlock не найден", "NOT_FOUND"); const profiles = await listOperationProfiles({ ownerId: params.ownerId }); diff --git a/server/src/application/operations/use-cases/export-operation-profile.ts b/server/src/application/operations/use-cases/export-operation-profile.ts index 7cc862a4..435ffb4f 100644 --- a/server/src/application/operations/use-cases/export-operation-profile.ts +++ b/server/src/application/operations/use-cases/export-operation-profile.ts @@ -3,7 +3,10 @@ import { HttpError } from "@core/middleware/error-handler"; import { getOperationBlockById } from "../../../services/operations/operation-blocks-repository"; import { getOperationProfileById } from "../../../services/operations/operation-profiles-repository"; -export async function exportOperationProfileBundle(profileId: string): Promise<{ +export async function exportOperationProfileBundle(params: { + ownerId: string; + profileId: string; +}): Promise<{ type: "operation_profile_bundle"; version: 2; profile: { @@ -29,12 +32,15 @@ export async function exportOperationProfileBundle(profileId: string): Promise<{ meta?: unknown; }>; }> { - const item = await getOperationProfileById(profileId); + const item = await getOperationProfileById(params); if (!item) throw new HttpError(404, "OperationProfile не найден", "NOT_FOUND"); const blocks = []; for (const ref of item.blockRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: ref.blockId, + }); if (!block) { throw new HttpError(400, "OperationBlock не найден", "VALIDATION_ERROR", { profileId: item.profileId, diff --git a/server/src/application/operations/use-cases/set-active-operation-profile.ts b/server/src/application/operations/use-cases/set-active-operation-profile.ts index 65471d5d..de34ce20 100644 --- a/server/src/application/operations/use-cases/set-active-operation-profile.ts +++ b/server/src/application/operations/use-cases/set-active-operation-profile.ts @@ -1,17 +1,22 @@ import { HttpError } from "@core/middleware/error-handler"; +import { resolveCompiledOperationProfile } from "../../../services/operations/operation-profile-resolver"; import { setActiveOperationProfile } from "../../../services/operations/operation-profile-settings-repository"; import { getOperationProfileById } from "../../../services/operations/operation-profiles-repository"; export async function setActiveOperationProfileWithValidation( - activeProfileId: string | null + params: { ownerId: string; activeProfileId: string | null } ) { - if (activeProfileId !== null) { - const exists = await getOperationProfileById(activeProfileId); - if (!exists) { + if (params.activeProfileId !== null) { + const profile = await getOperationProfileById({ + ownerId: params.ownerId, + profileId: params.activeProfileId, + }); + if (!profile) { throw new HttpError(404, "OperationProfile не найден", "NOT_FOUND"); } + await resolveCompiledOperationProfile(profile); } - return setActiveOperationProfile({ activeProfileId }); + return setActiveOperationProfile(params); } diff --git a/server/src/core/auth/access-policy.test.ts b/server/src/core/auth/access-policy.test.ts new file mode 100644 index 00000000..084cd6c2 --- /dev/null +++ b/server/src/core/auth/access-policy.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; + +import { createApp } from "../../app"; + +import { resolveAccessPolicy } from "./access-policy"; + +describe("access policy", () => { + test("defaults to a frictionless local policy", () => { + expect(resolveAccessPolicy({})).toEqual({ + mode: "local", + passwordRequired: false, + passwordlessLoginAllowed: true, + automaticLoginAllowed: true, + secureCookiesRequired: false, + loginRateLimitRequired: false, + csrfProtectionRequired: false, + }); + }); + + test("enables the complete public security policy", () => { + expect( + resolveAccessPolicy({ TALESPINNER_ACCESS_MODE: "public" }) + ).toEqual({ + mode: "public", + passwordRequired: true, + passwordlessLoginAllowed: false, + automaticLoginAllowed: false, + secureCookiesRequired: true, + loginRateLimitRequired: true, + csrfProtectionRequired: true, + }); + }); + + test("normalizes configured mode and rejects unknown values", () => { + expect( + resolveAccessPolicy({ TALESPINNER_ACCESS_MODE: " LOCAL " }).mode + ).toBe("local"); + + expect(() => + resolveAccessPolicy({ TALESPINNER_ACCESS_MODE: "shared" }) + ).toThrow(/TALESPINNER_ACCESS_MODE/); + }); + + test("exposes the active local policy to server middleware", () => { + const previousMode = process.env.TALESPINNER_ACCESS_MODE; + process.env.TALESPINNER_ACCESS_MODE = "local"; + + try { + expect(createApp().locals.accessPolicy).toMatchObject({ + mode: "local", + passwordRequired: false, + }); + } finally { + if (typeof previousMode === "string") { + process.env.TALESPINNER_ACCESS_MODE = previousMode; + } else { + delete process.env.TALESPINNER_ACCESS_MODE; + } + } + }); +}); diff --git a/server/src/core/auth/access-policy.ts b/server/src/core/auth/access-policy.ts new file mode 100644 index 00000000..d2fa758a --- /dev/null +++ b/server/src/core/auth/access-policy.ts @@ -0,0 +1,40 @@ +export type AccessMode = "local" | "public"; + +export type AccessPolicy = { + mode: AccessMode; + passwordRequired: boolean; + passwordlessLoginAllowed: boolean; + automaticLoginAllowed: boolean; + secureCookiesRequired: boolean; + loginRateLimitRequired: boolean; + csrfProtectionRequired: boolean; +}; + +type AccessEnvironment = Record; + +function resolveAccessMode(environment: AccessEnvironment): AccessMode { + const configured = environment.TALESPINNER_ACCESS_MODE?.trim().toLowerCase(); + if (!configured || configured === "local") return "local"; + if (configured === "public") return "public"; + + throw new Error( + `Invalid TALESPINNER_ACCESS_MODE "${configured}". Expected "local" or "public".` + ); +} + +export function resolveAccessPolicy( + environment: AccessEnvironment = process.env +): AccessPolicy { + const mode = resolveAccessMode(environment); + const isPublic = mode === "public"; + + return { + mode, + passwordRequired: isPublic, + passwordlessLoginAllowed: !isPublic, + automaticLoginAllowed: !isPublic, + secureCookiesRequired: isPublic, + loginRateLimitRequired: isPublic, + csrfProtectionRequired: isPublic, + }; +} diff --git a/server/src/core/auth/auth-config.test.ts b/server/src/core/auth/auth-config.test.ts new file mode 100644 index 00000000..ada3a49a --- /dev/null +++ b/server/src/core/auth/auth-config.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; + +import { resolveAuthConfig } from "./auth-config"; + +describe("auth config", () => { + test("provides local defaults without secrets", () => { + expect(resolveAuthConfig({})).toMatchObject({ + policy: { mode: "local" }, + sessionCookieName: "talespinner_session", + sessionTtlMs: 30 * 24 * 60 * 60 * 1000, + sessionSecret: null, + setupToken: null, + allowRegistration: false, + trustProxy: false, + }); + }); + + test("requires secrets in public mode", () => { + expect(() => + resolveAuthConfig({ + TALESPINNER_ACCESS_MODE: "public", + TALESPINNER_TRUST_PROXY: "true", + }) + ).toThrow(/SESSION_SECRET/); + + expect(() => + resolveAuthConfig({ + TALESPINNER_ACCESS_MODE: "public", + TALESPINNER_TRUST_PROXY: "true", + TALESPINNER_SESSION_SECRET: "s".repeat(32), + }) + ).toThrow(/SETUP_TOKEN/); + }); + + test("parses a complete public configuration", () => { + expect( + resolveAuthConfig({ + TALESPINNER_ACCESS_MODE: "public", + TALESPINNER_SESSION_SECRET: "s".repeat(32), + TALESPINNER_SETUP_TOKEN: "setup-token-value", + TALESPINNER_ALLOW_REGISTRATION: "true", + TALESPINNER_SESSION_TTL_DAYS: "7", + TALESPINNER_TRUST_PROXY: "1", + }) + ).toMatchObject({ + policy: { mode: "public" }, + sessionTtlMs: 7 * 24 * 60 * 60 * 1000, + sessionSecret: "s".repeat(32), + setupToken: "setup-token-value", + allowRegistration: true, + trustProxy: true, + }); + }); + + test("rejects invalid session TTL values", () => { + expect(() => + resolveAuthConfig({ TALESPINNER_SESSION_TTL_DAYS: "0" }) + ).toThrow(/SESSION_TTL_DAYS/); + expect(() => + resolveAuthConfig({ TALESPINNER_SESSION_TTL_DAYS: "abc" }) + ).toThrow(/SESSION_TTL_DAYS/); + }); +}); diff --git a/server/src/core/auth/auth-config.ts b/server/src/core/auth/auth-config.ts new file mode 100644 index 00000000..6231e9ec --- /dev/null +++ b/server/src/core/auth/auth-config.ts @@ -0,0 +1,78 @@ +import { resolveAccessPolicy, type AccessPolicy } from "./access-policy"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_SESSION_TTL_DAYS = 30; +const MIN_PUBLIC_SECRET_LENGTH = 32; +const MIN_SETUP_TOKEN_LENGTH = 16; + +type AuthEnvironment = Record; + +export type AuthConfig = { + policy: AccessPolicy; + sessionCookieName: string; + sessionTtlMs: number; + sessionSecret: string | null; + setupToken: string | null; + allowRegistration: boolean; + trustProxy: boolean; +}; + +function parseBoolean(value: string | undefined): boolean { + return value === "1" || value?.trim().toLowerCase() === "true"; +} + +function parseSessionTtl(value: string | undefined): number { + if (!value?.trim()) return DEFAULT_SESSION_TTL_DAYS * DAY_MS; + const days = Number(value); + if (!Number.isInteger(days) || days < 1 || days > 365) { + throw new Error( + "TALESPINNER_SESSION_TTL_DAYS must be an integer between 1 and 365." + ); + } + return days * DAY_MS; +} + +function optionalSecret(value: string | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +function validatePublicSecrets(config: AuthConfig): void { + if (config.policy.mode !== "public") return; + if (!config.trustProxy) { + throw new Error( + "TALESPINNER_TRUST_PROXY=true is required in public mode." + ); + } + if ( + !config.sessionSecret || + config.sessionSecret.length < MIN_PUBLIC_SECRET_LENGTH + ) { + throw new Error( + `TALESPINNER_SESSION_SECRET must contain at least ${MIN_PUBLIC_SECRET_LENGTH} characters in public mode.` + ); + } + if (!config.setupToken || config.setupToken.length < MIN_SETUP_TOKEN_LENGTH) { + throw new Error( + `TALESPINNER_SETUP_TOKEN must contain at least ${MIN_SETUP_TOKEN_LENGTH} characters in public mode.` + ); + } +} + +export function resolveAuthConfig( + environment: AuthEnvironment = process.env +): AuthConfig { + const config: AuthConfig = { + policy: resolveAccessPolicy(environment), + sessionCookieName: "talespinner_session", + sessionTtlMs: parseSessionTtl(environment.TALESPINNER_SESSION_TTL_DAYS), + sessionSecret: optionalSecret(environment.TALESPINNER_SESSION_SECRET), + setupToken: optionalSecret(environment.TALESPINNER_SETUP_TOKEN), + allowRegistration: parseBoolean( + environment.TALESPINNER_ALLOW_REGISTRATION + ), + trustProxy: parseBoolean(environment.TALESPINNER_TRUST_PROXY), + }; + validatePublicSecrets(config); + return config; +} diff --git a/server/src/core/auth/auth-cookie.ts b/server/src/core/auth/auth-cookie.ts new file mode 100644 index 00000000..46896737 --- /dev/null +++ b/server/src/core/auth/auth-cookie.ts @@ -0,0 +1,59 @@ +import type { AuthConfig } from "./auth-config"; +import type { Response } from "express"; + + +export function readCookie( + cookieHeader: string | undefined, + name: string +): string | null { + if (!cookieHeader) return null; + for (const part of cookieHeader.split(";")) { + const separator = part.indexOf("="); + if (separator < 0) continue; + const key = part.slice(0, separator).trim(); + if (key !== name) continue; + try { + return decodeURIComponent(part.slice(separator + 1)); + } catch { + return null; + } + } + return null; +} + +function cookieSecurity(config: AuthConfig): string { + return config.policy.secureCookiesRequired + ? "; Secure; SameSite=Strict" + : "; SameSite=Lax"; +} + +export function setSessionCookie(params: { + response: Response; + token: string; + expiresAt: Date; + config: AuthConfig; +}): void { + const maxAgeSeconds = Math.max( + 0, + Math.floor((params.expiresAt.getTime() - Date.now()) / 1000) + ); + params.response.append( + "Set-Cookie", + `${params.config.sessionCookieName}=${encodeURIComponent(params.token)}` + + `; Path=/; HttpOnly; Max-Age=${maxAgeSeconds}` + + `; Expires=${params.expiresAt.toUTCString()}` + + cookieSecurity(params.config) + ); +} + +export function clearSessionCookie( + response: Response, + config: AuthConfig +): void { + response.append( + "Set-Cookie", + `${config.sessionCookieName}=; Path=/; HttpOnly; Max-Age=0` + + "; Expires=Thu, 01 Jan 1970 00:00:00 GMT" + + cookieSecurity(config) + ); +} diff --git a/server/src/core/auth/auth-middleware.ts b/server/src/core/auth/auth-middleware.ts new file mode 100644 index 00000000..f0c2a32d --- /dev/null +++ b/server/src/core/auth/auth-middleware.ts @@ -0,0 +1,64 @@ +import { + resolveAuthSession, + type SessionPrincipal, +} from "../../services/auth/session-service"; +import { asyncHandler } from "../middleware/async-handler"; +import { setAuthenticatedUserContext } from "../request-context/request-context"; + +import { clearSessionCookie, readCookie } from "./auth-cookie"; + +import type { AuthConfig } from "./auth-config"; +import type { RequestHandler } from "express"; + + +declare module "express-serve-static-core" { + interface Request { + auth?: SessionPrincipal; + } +} + +export function createAuthContextMiddleware( + config: AuthConfig +): RequestHandler { + return asyncHandler(async (request, response, next) => { + const token = readCookie( + request.header("cookie"), + config.sessionCookieName + ); + if (!token) { + next(); + return; + } + + const principal = await resolveAuthSession({ token, config }); + if (!principal) { + clearSessionCookie(response, config); + next(); + return; + } + + request.auth = principal; + setAuthenticatedUserContext(request, { + userId: principal.user.id, + role: principal.user.role, + }); + next(); + }); +} + +export const requireAuthenticatedApi: RequestHandler = ( + request, + response, + next +) => { + if (request.auth) { + next(); + return; + } + response.status(401).json({ + error: { + code: "AUTH_REQUIRED", + message: "Authentication is required.", + }, + }); +}; diff --git a/server/src/core/auth/security-middleware.test.ts b/server/src/core/auth/security-middleware.test.ts new file mode 100644 index 00000000..36203359 --- /dev/null +++ b/server/src/core/auth/security-middleware.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, vi } from "vitest"; + +import { securityHeadersMiddleware } from "./security-middleware"; + +function readResourcePolicy(path: string): string | undefined { + const headers = new Map(); + const next = vi.fn(); + securityHeadersMiddleware( + { path } as never, + { + setHeader(name: string, value: string) { + headers.set(name, value); + }, + } as never, + next + ); + expect(next).toHaveBeenCalledOnce(); + return headers.get("Cross-Origin-Resource-Policy"); +} + +describe("securityHeadersMiddleware", () => { + test("allows media and default assets to be embedded by the frontend", () => { + expect(readResourcePolicy("/media/images/avatar.png")).toBe("cross-origin"); + expect(readResourcePolicy("/defaults/backgrounds/default-bg.png")).toBe( + "cross-origin" + ); + }); + + test("keeps API and application responses same-origin", () => { + expect(readResourcePolicy("/api/auth/status")).toBe("same-origin"); + expect(readResourcePolicy("/index.html")).toBe("same-origin"); + }); +}); diff --git a/server/src/core/auth/security-middleware.ts b/server/src/core/auth/security-middleware.ts new file mode 100644 index 00000000..9054f5a8 --- /dev/null +++ b/server/src/core/auth/security-middleware.ts @@ -0,0 +1,138 @@ +import { verifySessionCsrfToken } from "../../services/auth/session-service"; + +import type { AuthConfig } from "./auth-config"; +import type { RequestHandler } from "express"; + + + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +const CSRF_EXEMPT_PATHS = new Set([ + "/auth/login", + "/auth/recover", + "/auth/register", + "/auth/setup", +]); + +export const securityHeadersMiddleware: RequestHandler = ( + request, + response, + next +) => { + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + const embeddableResource = + request.path.startsWith("/media/") || + request.path.startsWith("/defaults/"); + response.setHeader( + "Cross-Origin-Resource-Policy", + embeddableResource ? "cross-origin" : "same-origin" + ); + next(); +}; + +export function createHttpsEnforcementMiddleware( + config: AuthConfig +): RequestHandler { + return (request, response, next) => { + if (config.policy.mode !== "public" || request.secure) { + next(); + return; + } + response.status(426).json({ + error: { + code: "HTTPS_REQUIRED", + message: "HTTPS is required in public access mode.", + }, + }); + }; +} + +export function createCsrfProtectionMiddleware( + config: AuthConfig +): RequestHandler { + return (request, response, next) => { + if ( + !config.policy.csrfProtectionRequired || + SAFE_METHODS.has(request.method) || + CSRF_EXEMPT_PATHS.has(request.path) + ) { + next(); + return; + } + const csrfToken = request.header("x-csrf-token"); + if ( + request.auth && + csrfToken && + verifySessionCsrfToken(request.auth, csrfToken, config) + ) { + next(); + return; + } + response.status(403).json({ + error: { + code: "CSRF_TOKEN_INVALID", + message: "A valid CSRF token is required.", + }, + }); + }; +} + +type RateLimitEntry = { + count: number; + resetAt: number; +}; + +export function createLoginRateLimitMiddleware( + config: AuthConfig, + options: { maxAttempts?: number; windowMs?: number } = {} +): RequestHandler { + const entries = new Map(); + const maxAttempts = options.maxAttempts ?? 5; + const windowMs = options.windowMs ?? 15 * 60 * 1000; + + return (request, response, next) => { + if (!config.policy.loginRateLimitRequired) { + next(); + return; + } + const now = Date.now(); + const key = request.ip ?? request.socket.remoteAddress ?? "unknown"; + const current = entries.get(key); + const entry = + !current || current.resetAt <= now + ? { count: 0, resetAt: now + windowMs } + : current; + + if (entries.size > 10_000) { + for (const [entryKey, value] of entries) { + if (value.resetAt <= now) entries.delete(entryKey); + } + } + if (entry.count >= maxAttempts) { + response.setHeader( + "Retry-After", + String(Math.max(1, Math.ceil((entry.resetAt - now) / 1000))) + ); + response.status(429).json({ + error: { + code: "AUTH_RATE_LIMITED", + message: "Too many authentication attempts. Try again later.", + }, + }); + return; + } + + entry.count += 1; + entries.set(key, entry); + response.once("finish", () => { + if (response.statusCode === 401 || response.statusCode === 403) return; + const latest = entries.get(key); + if (!latest || latest.resetAt !== entry.resetAt) return; + latest.count = Math.max(0, latest.count - 1); + if (latest.count === 0) entries.delete(key); + }); + next(); + }; +} diff --git a/server/src/core/auth/trusted-owner-middleware.test.ts b/server/src/core/auth/trusted-owner-middleware.test.ts new file mode 100644 index 00000000..0777edfe --- /dev/null +++ b/server/src/core/auth/trusted-owner-middleware.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; + +import { canAccessMediaPath } from "./trusted-owner-middleware"; + +describe("media owner paths", () => { + const firstOwner = "11111111-1111-4111-8111-111111111111"; + const secondOwner = "22222222-2222-4222-8222-222222222222"; + + test("only serves a namespaced image to its exact owner", () => { + const path = `/images/custom/${firstOwner}/avatar.png`; + expect(canAccessMediaPath(firstOwner, path)).toBe(true); + expect(canAccessMediaPath(secondOwner, path)).toBe(false); + expect(canAccessMediaPath("global", path)).toBe(false); + }); + + test("does not accept an owner id hidden in another path segment", () => { + const path = `/images/${firstOwner}/${secondOwner}/avatar.png`; + expect(canAccessMediaPath(firstOwner, path)).toBe(false); + }); + + test("keeps legacy and explicit global media available only to global", () => { + expect(canAccessMediaPath("global", "/images/legacy/avatar.png")).toBe( + true + ); + expect( + canAccessMediaPath("global", "/images/entity-profiles/global/avatar.png") + ).toBe(true); + expect( + canAccessMediaPath(firstOwner, "/images/legacy/avatar.png") + ).toBe(false); + }); +}); diff --git a/server/src/core/auth/trusted-owner-middleware.ts b/server/src/core/auth/trusted-owner-middleware.ts new file mode 100644 index 00000000..a3db2421 --- /dev/null +++ b/server/src/core/auth/trusted-owner-middleware.ts @@ -0,0 +1,105 @@ + + +import { getChatById } from "../../services/chat-core/chats-repository"; +import { getEntityProfileById } from "../../services/chat-core/entity-profiles-repository"; +import { getGenerationById } from "../../services/chat-core/generations-repository"; +import { getInstructionById } from "../../services/chat-core/instructions-repository"; +import { getUserPersonById } from "../../services/chat-core/user-persons-repository"; +import { getEntryById } from "../../services/chat-entry-parts/entries-repository"; +import { getPartWithVariantContextById } from "../../services/chat-entry-parts/parts-repository"; +import { getWorldInfoBookById } from "../../services/world-info/world-info-repositories"; +import { asyncHandler } from "../middleware/async-handler"; +import { runWithOwnerScope } from "../request-context/owner-scope-storage"; + +import type { RequestHandler } from "express"; + +async function resolveResourceOwner( + path: string, + authenticatedOwnerId: string +): Promise { + const segments = path.split("/").filter(Boolean); + const [resource, id, nested] = segments; + if (!id) return undefined; + + if (resource === "chats") return (await getChatById(id))?.ownerId ?? null; + if (resource === "entity-profiles") { + if (id === "import") return undefined; + return (await getEntityProfileById(id))?.ownerId ?? null; + } + if (resource === "entries") { + if (id === "soft-delete-bulk") return undefined; + return (await getEntryById({ entryId: id })) ? authenticatedOwnerId : null; + } + if (resource === "parts") { + return (await getPartWithVariantContextById({ partId: id }))?.ownerId ?? null; + } + if (resource === "instructions") { + if (id === "default-st-preset" || id === "prerender") return undefined; + return (await getInstructionById(id))?.ownerId ?? null; + } + if (resource === "user-persons") { + return (await getUserPersonById(id))?.ownerId ?? null; + } + if (resource === "generations") { + return (await getGenerationById(id)) ? authenticatedOwnerId : null; + } + if (resource === "world-info" && id === "books" && nested) { + if (nested === "import") return undefined; + return (await getWorldInfoBookById(nested))?.ownerId ?? null; + } + return undefined; +} + +export const trustedOwnerMiddleware: RequestHandler = asyncHandler( + async (request, response, next) => { + const userId = request.auth?.user.id; + if (!userId) { + next(); + return; + } + await runWithOwnerScope(userId, async () => { + const resourceOwner = await resolveResourceOwner(request.path, userId); + if (resourceOwner === undefined || resourceOwner === userId) { + next(); + return; + } + response.status(404).json({ + error: { + code: "NOT_FOUND", + message: "Resource not found.", + }, + }); + }); + } +); + +const UUID_SEGMENT = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function canAccessMediaPath(userId: string, requestPath: string): boolean { + const segments = requestPath.split("/").filter(Boolean); + const isNamespacedImage = segments[0] === "images" && segments.length >= 4; + if (!isNamespacedImage) return userId === "global"; + + const namespaceOwnerId = segments[2] ?? ""; + if (namespaceOwnerId === userId) return true; + if (userId !== "global") return false; + return !UUID_SEGMENT.test(namespaceOwnerId); +} + +export const mediaOwnerMiddleware: RequestHandler = ( + request, + response, + next +) => { + const userId = request.auth?.user.id; + if (!userId) { + response.status(401).end(); + return; + } + if (canAccessMediaPath(userId, request.path)) { + next(); + return; + } + response.status(404).end(); +}; diff --git a/server/src/core/network/server-network-policy.test.ts b/server/src/core/network/server-network-policy.test.ts new file mode 100644 index 00000000..cad7e0c5 --- /dev/null +++ b/server/src/core/network/server-network-policy.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest"; + +import { createApp } from "../../app"; + +import { resolveServerNetworkPolicy } from "./server-network-policy"; + +describe("server network policy", () => { + test("binds to loopback and restricts CORS by default", () => { + const policy = resolveServerNetworkPolicy({}); + + expect(policy.host).toBe("127.0.0.1"); + expect(policy.lanMode).toBe(false); + expect(policy.isOriginAllowed("http://localhost:5173")).toBe(true); + expect(policy.isOriginAllowed("http://127.0.0.1:5173")).toBe(true); + expect(policy.isOriginAllowed("https://attacker.example")).toBe(false); + }); + + test("requires explicit LAN opt-in before honoring a public host", () => { + expect( + resolveServerNetworkPolicy({ TALESPINNER_HOST: "0.0.0.0" }).host + ).toBe("127.0.0.1"); + + const policy = resolveServerNetworkPolicy({ + TALESPINNER_LAN_MODE: "true", + TALESPINNER_HOST: "0.0.0.0", + TALESPINNER_CORS_ORIGINS: "http://192.168.1.20:5173", + }); + expect(policy.host).toBe("0.0.0.0"); + expect(policy.lanMode).toBe(true); + expect(policy.isOriginAllowed("http://192.168.1.20:5173")).toBe(true); + expect(policy.isOriginAllowed("http://192.168.1.21:5173")).toBe(false); + }); + + test("allows requests without an Origin header for local native clients", () => { + expect(resolveServerNetworkPolicy({}).isOriginAllowed(undefined)).toBe(true); + }); + + test("returns 403 before routing requests from a disallowed origin", async () => { + const app = createApp(); + const server = await new Promise>((resolve) => { + const started = app.listen(0, "127.0.0.1", () => resolve(started)); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test address"); + const response = await fetch(`http://127.0.0.1:${address.port}/api/unknown`, { + headers: { origin: "https://attacker.example" }, + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "ORIGIN_NOT_ALLOWED" }, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + test("serves default images to an allowed frontend origin", async () => { + const app = createApp(); + const server = await new Promise>((resolve) => { + const started = app.listen(0, "127.0.0.1", () => resolve(started)); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test address"); + const response = await fetch( + `http://127.0.0.1:${address.port}/defaults/backgrounds/default-bg.png`, + { headers: { origin: "http://localhost:5173" } } + ); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe( + "http://localhost:5173" + ); + expect(response.headers.get("cross-origin-resource-policy")).toBe( + "cross-origin" + ); + expect(response.headers.get("content-type")).toContain("image/png"); + await response.arrayBuffer(); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/server/src/core/network/server-network-policy.ts b/server/src/core/network/server-network-policy.ts new file mode 100644 index 00000000..3ddafe13 --- /dev/null +++ b/server/src/core/network/server-network-policy.ts @@ -0,0 +1,63 @@ +const LOOPBACK_HOST = "127.0.0.1"; +const DEFAULT_LAN_HOST = "0.0.0.0"; +const DEFAULT_CORS_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", +] as const; + +type NetworkEnvironment = Record; + +export type ServerNetworkPolicy = { + host: string; + lanMode: boolean; + allowedOrigins: ReadonlySet; + isOriginAllowed: (origin: string | undefined) => boolean; +}; + +export function rejectDisallowedOrigin( + policy: ServerNetworkPolicy +): RequestHandler { + return (request, response, next) => { + const origin = request.header("origin"); + if (policy.isOriginAllowed(origin)) { + next(); + return; + } + response.status(403).json({ + error: { + code: "ORIGIN_NOT_ALLOWED", + message: "Request origin is not allowed", + }, + }); + }; +} + +function isEnabled(value: string | undefined): boolean { + return value === "1" || value?.toLowerCase() === "true"; +} + +function parseAllowedOrigins(value: string | undefined): Set { + const configured = value + ?.split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + return new Set(configured?.length ? configured : DEFAULT_CORS_ORIGINS); +} + +export function resolveServerNetworkPolicy( + environment: NetworkEnvironment = process.env +): ServerNetworkPolicy { + const lanMode = isEnabled(environment.TALESPINNER_LAN_MODE); + const host = lanMode + ? environment.TALESPINNER_HOST?.trim() || DEFAULT_LAN_HOST + : LOOPBACK_HOST; + const allowedOrigins = parseAllowedOrigins(environment.TALESPINNER_CORS_ORIGINS); + + return { + host, + lanMode, + allowedOrigins, + isOriginAllowed: (origin) => origin === undefined || allowedOrigins.has(origin), + }; +} +import type { RequestHandler } from "express"; diff --git a/server/src/core/operation-orchestrator/executor.ts b/server/src/core/operation-orchestrator/executor.ts index 1c737dda..18f67f30 100644 --- a/server/src/core/operation-orchestrator/executor.ts +++ b/server/src/core/operation-orchestrator/executor.ts @@ -331,7 +331,7 @@ export async function executeOrchestratorPlan(args: ExecutePlanArgs): Promise { + const events: OrchestratorEvent[] = []; + const codedError = new Error("provider request failed") as Error & { code: string }; + codedError.code = "LLM_PROVIDER_ERROR"; + + await runOrchestrator( + { + runId: "run-terminal-details", + hook: "before_main_llm", + trigger: "generate", + executionMode: "sequential", + tasks: [ + { + taskId: "failing-operation", + enabled: true, + required: false, + order: 1, + run: async () => { + throw codedError; + }, + }, + ], + }, + { onEvent: (event) => events.push(event) } + ); + + expect(events).toContainEqual({ + type: "orch.task.finished", + data: { + runId: "run-terminal-details", + taskId: "failing-operation", + status: "error", + error: { code: "LLM_PROVIDER_ERROR", message: "provider request failed" }, + }, + }); +}); + +test("includes the abort reason in task finished events", async () => { + const events: OrchestratorEvent[] = []; + const abortError = new Error("user cancelled operation"); + abortError.name = "AbortError"; + + await runOrchestrator( + { + runId: "run-abort-details", + hook: "after_main_llm", + trigger: "generate", + executionMode: "sequential", + tasks: [ + { + taskId: "cancelled-operation", + enabled: true, + required: false, + order: 1, + run: async () => { + throw abortError; + }, + }, + ], + }, + { onEvent: (event) => events.push(event) } + ); + + expect(events).toContainEqual({ + type: "orch.task.finished", + data: { + runId: "run-abort-details", + taskId: "cancelled-operation", + status: "aborted", + reason: "user cancelled operation", + }, + }); +}); + test("aborted before start skips all plan tasks", async () => { const ac = new AbortController(); ac.abort("user_cancel"); @@ -236,4 +310,3 @@ test("aborted before start skips all plan tasks", async () => { reason: "orchestrator_aborted", }); }); - diff --git a/server/src/core/operation-orchestrator/types.ts b/server/src/core/operation-orchestrator/types.ts index f43d7b74..c70279da 100644 --- a/server/src/core/operation-orchestrator/types.ts +++ b/server/src/core/operation-orchestrator/types.ts @@ -46,7 +46,15 @@ export type OrchestratorEvent = | { type: "orch.task.started"; data: { runId: string; taskId: string } } | { type: "orch.task.finished"; - data: { runId: string; taskId: string; status: TaskStatus }; + data: + | { runId: string; taskId: string; status: "done" | "skipped" } + | { + runId: string; + taskId: string; + status: "error"; + error: { message: string; code?: string }; + } + | { runId: string; taskId: string; status: "aborted"; reason?: string }; } | { type: "orch.task.skipped"; diff --git a/server/src/core/request-context/owner-scope-storage.ts b/server/src/core/request-context/owner-scope-storage.ts new file mode 100644 index 00000000..bf0e2559 --- /dev/null +++ b/server/src/core/request-context/owner-scope-storage.ts @@ -0,0 +1,17 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { GLOBAL_OWNER_ID } from "./request-context"; + +const ownerStorage = new AsyncLocalStorage(); + +export function runWithOwnerScope(ownerId: string, work: () => T): T { + return ownerStorage.run(ownerId, work); +} + +export function getActiveOwnerId(): string | null { + return ownerStorage.getStore() ?? null; +} + +export function resolveTrustedOwnerId(requestedOwnerId?: string | null): string { + return getActiveOwnerId() ?? requestedOwnerId ?? GLOBAL_OWNER_ID; +} diff --git a/server/src/core/request-context/request-context.test.ts b/server/src/core/request-context/request-context.test.ts new file mode 100644 index 00000000..980c4e31 --- /dev/null +++ b/server/src/core/request-context/request-context.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; + +import { + getRequestContext, + getRequestOwnerId, + setAuthenticatedUserContext, +} from "./request-context"; + +import type { Request } from "express"; + +describe("request owner context", () => { + test("does not let request input override the trusted owner scope", () => { + const request = { + context: { + requestId: "request-1", + ownerScope: { ownerId: "trusted-owner", source: "explicit" }, + actor: { type: "system", id: null }, + tenant: { id: null }, + }, + } as Request; + + expect(getRequestOwnerId(request, "attacker-owner")).toBe("trusted-owner"); + }); + + test("sets the authenticated user as the trusted owner", () => { + const request = {} as Request; + + setAuthenticatedUserContext(request, { + userId: "user-1", + role: "admin", + }); + + expect(getRequestContext(request)).toMatchObject({ + ownerScope: { + ownerId: "user-1", + source: "authenticated-user", + }, + actor: { + type: "user", + id: "user-1", + role: "admin", + }, + }); + expect(getRequestOwnerId(request, "attacker-owner")).toBe("user-1"); + }); +}); diff --git a/server/src/core/request-context/request-context.ts b/server/src/core/request-context/request-context.ts index c06c3ed2..80f43831 100644 --- a/server/src/core/request-context/request-context.ts +++ b/server/src/core/request-context/request-context.ts @@ -4,16 +4,26 @@ import type { Request, RequestHandler } from "express"; export const GLOBAL_OWNER_ID = "global"; +export type UserRole = "admin" | "user"; + +export type RequestActor = + | { + type: "system"; + id: null; + } + | { + type: "user"; + id: string; + role: UserRole; + }; + export type RequestContext = { requestId: string; ownerScope: { ownerId: string; - source: "context-default" | "explicit"; - }; - actor: { - type: "system"; - id: null; + source: "context-default" | "explicit" | "authenticated-user"; }; + actor: RequestActor; tenant: { id: null; }; @@ -69,5 +79,27 @@ export function resolveOwnerId( } export function getRequestOwnerId(req: Request, requestedOwnerId?: string | null): string { - return resolveOwnerId(requestedOwnerId, getRequestContext(req).ownerScope.ownerId); + void requestedOwnerId; + return getRequestContext(req).ownerScope.ownerId; +} + +export function setAuthenticatedUserContext( + req: Request, + user: { userId: string; role: UserRole } +): RequestContext { + const context = getRequestContext(req); + const authenticatedContext: RequestContext = { + ...context, + ownerScope: { + ownerId: user.userId, + source: "authenticated-user", + }, + actor: { + type: "user", + id: user.userId, + role: user.role, + }, + }; + req.context = authenticatedContext; + return authenticatedContext; } diff --git a/server/src/core/services/base-service.ts b/server/src/core/services/base-service.ts index f208930c..00a73b7a 100644 --- a/server/src/core/services/base-service.ts +++ b/server/src/core/services/base-service.ts @@ -3,7 +3,9 @@ import { randomUUID as uuidv4 } from "node:crypto"; import path from "path"; +import { resolveSafePath } from "@core/files/safe-path"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { type BaseEntity, type Logger, type ServiceOptions } from "@core/types/common"; import { createDataPath } from "../../utils"; @@ -34,6 +36,7 @@ export class BaseService { protected async ensureReady(): Promise { await this.ready; + await fs.mkdir(this.getOwnerDirectory(), { recursive: true }); } protected createUUID(): string { @@ -41,7 +44,14 @@ export class BaseService { } protected getFilePath(id: string): string { - return path.join(this.dir, `${id}.json`); + return resolveSafePath(this.getOwnerDirectory(), `${id}.json`); + } + + private getOwnerDirectory(): string { + const ownerId = resolveTrustedOwnerId(); + return ownerId === "global" + ? this.dir + : path.join(this.dir, "owners", encodeURIComponent(ownerId)); } protected async readEntity(id: string): Promise { @@ -89,7 +99,7 @@ export class BaseService { async getAll(): Promise { await this.ensureReady(); try { - const files = await fs.readdir(this.dir); + const files = await fs.readdir(this.getOwnerDirectory()); const jsonFiles = files .filter((file) => file.endsWith(".json")) .map((file) => file.replace(".json", "")); diff --git a/server/src/core/services/config-service.ts b/server/src/core/services/config-service.ts index e244c10e..a65bc2ea 100644 --- a/server/src/core/services/config-service.ts +++ b/server/src/core/services/config-service.ts @@ -1,6 +1,7 @@ import fs from "fs/promises"; import path from "path"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { type BaseConfig, type Logger, @@ -11,11 +12,15 @@ import { createDataPath } from "../../utils"; export abstract class ConfigService { protected readonly configPath: string; + private readonly configDir: string; + private readonly fileName: string; protected readonly logger?: Logger; private readonly ready: Promise; constructor(fileName: string, options?: ServiceOptions) { const configDir = options?.dataDir || createDataPath("config"); + this.configDir = configDir; + this.fileName = fileName; this.configPath = path.join(configDir, fileName); this.logger = options?.logger; this.ready = this.ensureConfigDirectory(); @@ -32,6 +37,21 @@ export abstract class ConfigService { protected async ensureReady(): Promise { await this.ready; + await fs.mkdir(path.dirname(this.getOwnerConfigPath()), { + recursive: true, + }); + } + + private getOwnerConfigPath(): string { + const ownerId = resolveTrustedOwnerId(); + return ownerId === "global" + ? this.configPath + : path.join( + this.configDir, + "owners", + encodeURIComponent(ownerId), + this.fileName + ); } protected abstract getDefaultConfig(): T; @@ -39,7 +59,7 @@ export abstract class ConfigService { async getConfig(): Promise { await this.ensureReady(); try { - const content = await fs.readFile(this.configPath, "utf8"); + const content = await fs.readFile(this.getOwnerConfigPath(), "utf8"); return JSON.parse(content); } catch { const defaultConfig = this.getDefaultConfig(); @@ -51,7 +71,10 @@ export abstract class ConfigService { async saveConfig(config: T): Promise { await this.ensureReady(); try { - await fs.writeFile(this.configPath, JSON.stringify(config, null, 2)); + await fs.writeFile( + this.getOwnerConfigPath(), + JSON.stringify(config, null, 2) + ); return config; } catch (error) { this.logger?.error("Failed to save config", { error }); diff --git a/server/src/core/services/owner-aware-file-services.test.ts b/server/src/core/services/owner-aware-file-services.test.ts new file mode 100644 index 00000000..2962a144 --- /dev/null +++ b/server/src/core/services/owner-aware-file-services.test.ts @@ -0,0 +1,102 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runWithOwnerScope } from "../request-context/owner-scope-storage"; + +import { BaseService } from "./base-service"; +import { ConfigService } from "./config-service"; + +type TestEntity = { + id: string; + value: string; + createdAt: string; + updatedAt: string; +}; + +type TestConfig = { + selectedId: string | null; +}; + +class TestEntityService extends BaseService {} + +class TestConfigService extends ConfigService { + protected getDefaultConfig(): TestConfig { + return { selectedId: null }; + } +} + +let tempDir = ""; + +describe("owner-aware file services", () => { + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "owner-files-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("isolates BaseService entities while preserving the global legacy path", async () => { + const service = new TestEntityService("items", { dataDir: tempDir }); + const now = new Date().toISOString(); + await runWithOwnerScope("global", () => + service.create({ id: "global-item", value: "global", createdAt: now, updatedAt: now }) + ); + await runWithOwnerScope("owner-a", () => + service.create({ id: "private-item", value: "private", createdAt: now, updatedAt: now }) + ); + + await expect( + runWithOwnerScope("global", () => service.getAll()) + ).resolves.toMatchObject([{ id: "global-item" }]); + await expect( + runWithOwnerScope("owner-a", () => service.getAll()) + ).resolves.toMatchObject([{ id: "private-item" }]); + await expect( + runWithOwnerScope("owner-b", () => service.getAll()) + ).resolves.toEqual([]); + + await expect( + fs.stat(path.join(tempDir, "global-item.json")) + ).resolves.toBeTruthy(); + await expect( + fs.stat( + path.join(tempDir, "owners", "owner-a", "private-item.json") + ) + ).resolves.toBeTruthy(); + }); + + test("isolates ConfigService values per owner", async () => { + const service = new TestConfigService("settings.json", { + dataDir: tempDir, + }); + await runWithOwnerScope("global", () => + service.saveConfig({ selectedId: "global" }) + ); + await runWithOwnerScope("owner-a", () => + service.saveConfig({ selectedId: "private" }) + ); + + await expect( + runWithOwnerScope("global", () => service.getConfig()) + ).resolves.toEqual({ selectedId: "global" }); + await expect( + runWithOwnerScope("owner-a", () => service.getConfig()) + ).resolves.toEqual({ selectedId: "private" }); + await expect( + runWithOwnerScope("owner-b", () => service.getConfig()) + ).resolves.toEqual({ selectedId: null }); + }); + + test("rejects entity ids that could escape the owner directory", async () => { + const service = new TestEntityService("items", { dataDir: tempDir }); + await expect( + runWithOwnerScope("owner-a", () => + service.getById("../owner-b/private-item") + ) + ).rejects.toMatchObject({ code: "INVALID_FILENAME" }); + }); +}); diff --git a/server/src/db/apply-migrations.ts b/server/src/db/apply-migrations.ts index 22f3531c..cb3eb553 100644 --- a/server/src/db/apply-migrations.ts +++ b/server/src/db/apply-migrations.ts @@ -2,7 +2,13 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "path"; -import { initDb } from "./client"; +import { sql } from "drizzle-orm"; + +import { + backupCurrentDatabase, + getCurrentDbPath, + initDb, +} from "./client"; export function resolveMigrationsFolder(): string { const cwdPath = path.resolve(process.cwd(), "drizzle"); @@ -86,6 +92,29 @@ export async function applyMigrations(): Promise { const db = await initDb(); const migrationsFolder = resolveMigrationsFolder(); const migrations = readMigrationFilesSafe(migrationsFolder); + const tables = await db.all<{ name: string }>( + sql`SELECT name FROM sqlite_master WHERE type = 'table'` + ); + const tableNames = new Set(tables.map((table) => table.name)); + if ( + tableNames.has("__drizzle_migrations") && + !tableNames.has("users") + ) { + const dbPath = getCurrentDbPath(); + if (!dbPath) { + throw new Error("Unable to resolve database path for migration backup."); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupPath = path.join( + path.dirname(dbPath), + "backups", + `db-pre-user-accounts-${timestamp}.sqlite` + ); + await backupCurrentDatabase(backupPath); + console.info("[db] created pre-user-accounts migration backup", { + backupPath, + }); + } // Drizzle internal API. We intentionally bypass the default migrator so we can // sanitize migration chunks and avoid empty-SQL crashes. diff --git a/server/src/db/client.ts b/server/src/db/client.ts index a8f1b1f3..c6bd92da 100644 --- a/server/src/db/client.ts +++ b/server/src/db/client.ts @@ -57,3 +57,17 @@ export function resetDbForTests(): void { } } +export async function backupCurrentDatabase( + destinationPath: string +): Promise { + if (!_sqlite) { + throw new Error("Database must be initialized before creating a backup."); + } + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + await _sqlite.backup(destinationPath); +} + +export function getCurrentDbPath(): string | null { + return _sqlite?.name ? path.resolve(_sqlite.name) : null; +} + diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 06e4836f..2a5eaae2 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -11,4 +11,5 @@ export * from "./schema/ui-theme"; export * from "./schema/user-persons"; export * from "./schema/world-info"; export * from "./schema/chat-knowledge"; +export * from "./schema/auth"; diff --git a/server/src/db/schema/app-backgrounds.ts b/server/src/db/schema/app-backgrounds.ts index b60b1b46..ffb6d612 100644 --- a/server/src/db/schema/app-backgrounds.ts +++ b/server/src/db/schema/app-backgrounds.ts @@ -1,9 +1,16 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -export const uiAppBackgrounds = sqliteTable("ui_app_backgrounds", { - id: text("id").primaryKey(), - name: text("name").notNull(), - fileName: text("file_name").notNull(), - createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), -}); +export const uiAppBackgrounds = sqliteTable( + "ui_app_backgrounds", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + name: text("name").notNull(), + fileName: text("file_name").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => ({ + ownerIdIndex: index("ui_app_backgrounds_owner_id_idx").on(table.ownerId), + }) +); diff --git a/server/src/db/schema/auth.ts b/server/src/db/schema/auth.ts new file mode 100644 index 00000000..4cdccdf1 --- /dev/null +++ b/server/src/db/schema/auth.ts @@ -0,0 +1,63 @@ +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable( + "users", + { + id: text("id").primaryKey(), + username: text("username").notNull(), + normalizedUsername: text("normalized_username").notNull(), + displayName: text("display_name").notNull(), + passwordHash: text("password_hash"), + role: text("role", { enum: ["admin", "user"] }).notNull().default("user"), + status: text("status", { enum: ["active", "disabled"] }) + .notNull() + .default("active"), + credentialVersion: integer("credential_version").notNull().default(0), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + lastLoginAt: integer("last_login_at", { mode: "timestamp_ms" }), + }, + (table) => ({ + normalizedUsernameUnique: uniqueIndex("users_normalized_username_uq").on( + table.normalizedUsername + ), + statusUpdatedAtIndex: index("users_status_updated_at_idx").on( + table.status, + table.updatedAt + ), + }) +); + +export const authSessions = sqliteTable( + "auth_sessions", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + csrfTokenHash: text("csrf_token_hash").notNull(), + authMethod: text("auth_method", { enum: ["local", "password"] }).notNull(), + credentialVersion: integer("credential_version").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }).notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + revokedAt: integer("revoked_at", { mode: "timestamp_ms" }), + }, + (table) => ({ + tokenHashUnique: uniqueIndex("auth_sessions_token_hash_uq").on( + table.tokenHash + ), + userExpiresAtIndex: index("auth_sessions_user_expires_at_idx").on( + table.userId, + table.expiresAt + ), + expiresAtIndex: index("auth_sessions_expires_at_idx").on(table.expiresAt), + }) +); diff --git a/server/src/db/schema/llm.ts b/server/src/db/schema/llm.ts index f8f3fcc9..afea37a5 100644 --- a/server/src/db/schema/llm.ts +++ b/server/src/db/schema/llm.ts @@ -62,29 +62,48 @@ export const llmProviders = sqliteTable("llm_providers", { updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }); -export const llmProviderConfigs = sqliteTable("llm_provider_configs", { - id: text("id").primaryKey(), - providerId: text("provider_id") - .notNull() - .references(() => llmProviders.id, { onDelete: "cascade" }), - // Provider-specific fields are stored in JSON to avoid schema churn. - configJson: text("config_json").notNull(), - createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), -}); +export const llmProviderConfigs = sqliteTable( + "llm_provider_configs", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + providerId: text("provider_id") + .notNull() + .references(() => llmProviders.id, { onDelete: "cascade" }), + configJson: text("config_json").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => ({ + ownerProviderIndex: uniqueIndex("llm_provider_configs_owner_provider_uq").on( + table.ownerId, + table.providerId + ), + }) +); -export const llmTokens = sqliteTable("llm_tokens", { - id: text("id").primaryKey(), - providerId: text("provider_id") - .notNull() - .references(() => llmProviders.id, { onDelete: "cascade" }), - name: text("name").notNull(), - ciphertext: text("ciphertext").notNull(), - tokenHint: text("token_hint").notNull(), - createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), - lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }), -}); +export const llmTokens = sqliteTable( + "llm_tokens", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default("global"), + providerId: text("provider_id") + .notNull() + .references(() => llmProviders.id, { onDelete: "cascade" }), + name: text("name").notNull(), + ciphertext: text("ciphertext").notNull(), + tokenHint: text("token_hint").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }), + }, + (table) => ({ + ownerProviderIndex: index("llm_tokens_owner_provider_idx").on( + table.ownerId, + table.providerId + ), + }) +); export const llmRuntimeSettings = sqliteTable( "llm_runtime_settings", diff --git a/server/src/db/user-accounts-migration.integration.test.ts b/server/src/db/user-accounts-migration.integration.test.ts new file mode 100644 index 00000000..c169e364 --- /dev/null +++ b/server/src/db/user-accounts-migration.integration.test.ts @@ -0,0 +1,148 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations, resolveMigrationsFolder } from "./apply-migrations"; +import { initDb, resetDbForTests } from "./client"; + +type MigrationJournal = { + entries: Array<{ tag: string; when: number }>; +}; + +async function createLegacyDatabase(dbPath: string): Promise { + const migrationsFolder = resolveMigrationsFolder(); + const journal = JSON.parse( + await fs.readFile( + path.join(migrationsFolder, "meta", "_journal.json"), + "utf8" + ) + ) as MigrationJournal; + const sqlite = new Database(dbPath); + sqlite.pragma("foreign_keys = ON"); + sqlite.exec(` + CREATE TABLE __drizzle_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hash text NOT NULL, + created_at numeric + ) + `); + + for (const entry of journal.entries.filter( + (item) => item.tag !== "0032_user_accounts" + )) { + const query = await fs.readFile( + path.join(migrationsFolder, `${entry.tag}.sql`), + "utf8" + ); + sqlite.exec(query); + sqlite + .prepare( + "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)" + ) + .run(createHash("sha256").update(query).digest("hex"), entry.when); + } + + const now = Date.now(); + sqlite + .prepare( + "INSERT INTO llm_providers (id, name, enabled, created_at, updated_at) VALUES (?, ?, 1, ?, ?)" + ) + .run("provider-1", "Provider", now, now); + sqlite + .prepare( + "INSERT INTO llm_provider_configs (id, provider_id, config_json, created_at, updated_at) VALUES (?, ?, '{}', ?, ?)" + ) + .run("config-1", "provider-1", now, now); + sqlite + .prepare( + "INSERT INTO llm_tokens (id, provider_id, name, ciphertext, token_hint, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .run("token-1", "provider-1", "Legacy", "encrypted", "***", now, now); + sqlite + .prepare( + "INSERT INTO llm_runtime_settings (scope, scope_id, active_provider_id, active_token_id, updated_at) VALUES ('global', 'global', ?, ?, ?)" + ) + .run("provider-1", "token-1", now); + sqlite + .prepare( + "INSERT INTO ui_app_backgrounds (id, name, file_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" + ) + .run("background-1", "Legacy", "legacy.png", now, now); + sqlite.close(); +} + +describe("user accounts migration", () => { + let tempDir = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), "user-accounts-migration-") + ); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("backs up and adopts legacy global data before upgrading", async () => { + const dbPath = path.join(tempDir, "db.sqlite"); + await createLegacyDatabase(dbPath); + await initDb({ dbPath }); + await applyMigrations(); + + const backupsDir = path.join(tempDir, "backups"); + const backups = (await fs.readdir(backupsDir)).filter((name) => + name.endsWith(".sqlite") + ); + expect(backups).toHaveLength(1); + + const backup = new Database(path.join(backupsDir, backups[0]), { + readonly: true, + }); + expect( + backup + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'" + ) + .get() + ).toBeUndefined(); + expect( + backup.prepare("SELECT id FROM llm_tokens WHERE id='token-1'").get() + ).toEqual({ id: "token-1" }); + backup.close(); + + const upgraded = new Database(dbPath, { readonly: true }); + expect( + upgraded + .prepare("SELECT owner_id FROM llm_tokens WHERE id='token-1'") + .get() + ).toEqual({ owner_id: "global" }); + expect( + upgraded + .prepare( + "SELECT owner_id FROM ui_app_backgrounds WHERE id='background-1'" + ) + .get() + ).toEqual({ owner_id: "global" }); + expect( + upgraded + .prepare( + "SELECT scope_id FROM llm_runtime_settings WHERE scope='global'" + ) + .get() + ).toEqual({ scope_id: "global:global" }); + upgraded.close(); + + await applyMigrations(); + const finalBackups = (await fs.readdir(backupsDir)).filter((name) => + name.endsWith(".sqlite") + ); + expect(finalBackups).toHaveLength(1); + }); +}); diff --git a/server/src/e2e/full-matrix.e2e.spec.ts b/server/src/e2e/full-matrix.e2e.spec.ts index 1b412ef5..ba44a362 100644 --- a/server/src/e2e/full-matrix.e2e.spec.ts +++ b/server/src/e2e/full-matrix.e2e.spec.ts @@ -3,7 +3,13 @@ import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { configureLlmOpenAiCompatible, createEntityProfileAndChat } from "./helpers/fixtures"; -import { collectSse, requestForm, requestJson, type SseEvent } from "./helpers/http"; +import { + collectSse, + getTestAuthHeaders, + requestForm, + requestJson, + type SseEvent, +} from "./helpers/http"; import { startMockAiServer, type RunningMockAiServer } from "./helpers/mock-ai-server"; import { startInProcessServer, type RunningServer } from "./helpers/test-server"; import { createTempDataDir, removeTempDataDir } from "./helpers/tmp-dir"; @@ -403,7 +409,13 @@ describe("backend e2e full matrix", () => { expect(uploadRes.status).toBe(200); const uploadedFilename = uploadRes.data.data.files[0]?.filename; expect(uploadedFilename).toBeTruthy(); - expect((await fetch(`${baseUrl}/api/files/${uploadedFilename}`)).status).toBe(200); + expect( + ( + await fetch(`${baseUrl}/api/files/${uploadedFilename}`, { + headers: getTestAuthHeaders(baseUrl), + }) + ).status + ).toBe(200); expect((await requestJson({ baseUrl, method: "DELETE", path: `/api/files/${uploadedFilename}` })).status).toBe(200); expect((await requestJson({ baseUrl, method: "GET", path: "/api/models" })).status).toBe(200); diff --git a/server/src/e2e/helpers/http.ts b/server/src/e2e/helpers/http.ts index 49b7a90a..cb1e766c 100644 --- a/server/src/e2e/helpers/http.ts +++ b/server/src/e2e/helpers/http.ts @@ -3,6 +3,38 @@ export type JsonResponse = { data: T; }; +const testAuthHeaders = new Map>(); + +export function getTestAuthHeaders(baseUrl: string): Record { + return testAuthHeaders.get(baseUrl) ?? {}; +} + +export function clearTestAuthHeaders(baseUrl: string): void { + testAuthHeaders.delete(baseUrl); +} + +export async function setupTestAccount(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/api/auth/setup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "e2e-admin", password: "" }), + }); + if (response.status !== 201) { + throw new Error(`Failed to set up E2E account: ${response.status}`); + } + const cookie = response.headers.get("set-cookie")?.split(";")[0]; + const payload = (await response.json()) as { + data?: { csrfToken?: string }; + }; + if (!cookie) throw new Error("E2E auth setup did not return a cookie"); + testAuthHeaders.set(baseUrl, { + Cookie: cookie, + ...(payload.data?.csrfToken + ? { "X-CSRF-Token": payload.data.csrfToken } + : {}), + }); +} + export async function requestJson(params: { baseUrl: string; method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -14,6 +46,7 @@ export async function requestJson(params: { method: params.method, headers: { ...(params.body === undefined ? {} : { "Content-Type": "application/json" }), + ...getTestAuthHeaders(params.baseUrl), ...(params.headers ?? {}), }, body: params.body === undefined ? undefined : JSON.stringify(params.body), @@ -42,6 +75,7 @@ export async function requestForm(params: { const response = await fetch(`${params.baseUrl}${params.path}`, { method: params.method, headers: { + ...getTestAuthHeaders(params.baseUrl), ...(params.headers ?? {}), }, body: params.form, @@ -114,6 +148,7 @@ export async function collectSse(params: { headers: { Accept: "text/event-stream", ...(params.body === undefined ? {} : { "Content-Type": "application/json" }), + ...getTestAuthHeaders(params.baseUrl), ...(params.headers ?? {}), }, body: params.body === undefined ? undefined : JSON.stringify(params.body), diff --git a/server/src/e2e/helpers/test-server.ts b/server/src/e2e/helpers/test-server.ts index 48ec7f69..06b6aa16 100644 --- a/server/src/e2e/helpers/test-server.ts +++ b/server/src/e2e/helpers/test-server.ts @@ -1,10 +1,13 @@ import { once } from "node:events"; import { type Server } from "node:http"; import { type AddressInfo } from "node:net"; +import path from "node:path"; import { resetDbForTests } from "../../db/client"; +import { clearTestAuthHeaders, setupTestAccount } from "./http"; + export type RunningServer = { baseUrl: string; close: () => Promise; @@ -16,7 +19,10 @@ type InProcessOptions = { }; function setTestEnv(options: InProcessOptions): void { + process.env.DATA_DIR = options.dataDir; + process.env.DB_PATH = path.join(options.dataDir, "db.sqlite"); process.env.TALESPINNER_DATA_DIR = options.dataDir; + process.env.TALESPINNER_ACCESS_MODE = "local"; process.env.TOKENS_MASTER_KEY = options.tokensMasterKey; } @@ -37,6 +43,7 @@ export async function startInProcessServer(options: InProcessOptions): Promise { const fileName = uploadRes.data.data.files[0]?.filename; expect(fileName).toBeTruthy(); - const getFileRes = await fetch(`${baseUrl}/api/files/${fileName}`); + const getFileRes = await fetch(`${baseUrl}/api/files/${fileName}`, { + headers: getTestAuthHeaders(baseUrl), + }); expect(getFileRes.status).toBe(200); const delRes = await requestJson>({ diff --git a/server/src/e2e/smoke/blackbox.smoke.e2e.spec.ts b/server/src/e2e/smoke/blackbox.smoke.e2e.spec.ts index 74d88169..bf594c3e 100644 --- a/server/src/e2e/smoke/blackbox.smoke.e2e.spec.ts +++ b/server/src/e2e/smoke/blackbox.smoke.e2e.spec.ts @@ -5,7 +5,12 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { configureLlmOpenAiCompatible, createEntityProfileAndChat } from "../helpers/fixtures"; -import { collectSse, type SseEvent } from "../helpers/http"; +import { + clearTestAuthHeaders, + collectSse, + setupTestAccount, + type SseEvent, +} from "../helpers/http"; import { startMockAiServer, type RunningMockAiServer } from "../helpers/mock-ai-server"; import { createTempDataDir, removeTempDataDir } from "../helpers/tmp-dir"; @@ -38,7 +43,7 @@ async function waitForServer(baseUrl: string): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < 25_000) { try { - const response = await fetch(`${baseUrl}/api/llm/providers`); + const response = await fetch(`${baseUrl}/api/auth/status`); if (response.ok) return; } catch { // retry @@ -69,7 +74,10 @@ describe("backend e2e blackbox smoke", () => { env: { ...process.env, PORT: String(port), + DATA_DIR: dataDir, + DB_PATH: path.join(dataDir, "db.sqlite"), TALESPINNER_DATA_DIR: dataDir, + TALESPINNER_ACCESS_MODE: "local", TOKENS_MASTER_KEY: "blackbox-master-key-012345", }, stdio: "pipe", @@ -81,6 +89,7 @@ describe("backend e2e blackbox smoke", () => { }); await waitForServer(baseUrl); + await setupTestAccount(baseUrl); }); afterAll(async () => { @@ -95,6 +104,7 @@ describe("backend e2e blackbox smoke", () => { }); } await mockAiServer?.close(); + clearTestAuthHeaders(baseUrl); await removeTempDataDir(dataDir); }); diff --git a/server/src/services/app-backgrounds/app-backgrounds-owner.integration.test.ts b/server/src/services/app-backgrounds/app-backgrounds-owner.integration.test.ts new file mode 100644 index 00000000..fad95c3e --- /dev/null +++ b/server/src/services/app-backgrounds/app-backgrounds-owner.integration.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runWithOwnerScope } from "@core/request-context/owner-scope-storage"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; + +import { + deleteAppBackground, + getAppBackgroundCatalog, + importAppBackground, +} from "./app-backgrounds-repository"; + +const FIRST_OWNER = "11111111-1111-4111-8111-111111111111"; +const SECOND_OWNER = "22222222-2222-4222-8222-222222222222"; + +describe("app background owner scope", () => { + let tempDir = ""; + let previousDataDir: string | undefined; + + beforeEach(async () => { + resetDbForTests(); + previousDataDir = process.env.DATA_DIR; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "app-background-owner-")); + process.env.DATA_DIR = tempDir; + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("isolates uploaded backgrounds and their files by active owner", async () => { + const uploaded = await runWithOwnerScope(FIRST_OWNER, () => + importAppBackground({ + fileBuffer: Buffer.from("owner-a"), + originalName: "private.png", + }) + ); + + expect(uploaded.imageUrl).toContain( + `/app-backgrounds/${FIRST_OWNER}/` + ); + await expect( + runWithOwnerScope(SECOND_OWNER, () => getAppBackgroundCatalog()) + ).resolves.not.toMatchObject({ + items: expect.arrayContaining([expect.objectContaining({ id: uploaded.id })]), + }); + await expect( + runWithOwnerScope(SECOND_OWNER, () => + deleteAppBackground({ id: uploaded.id }) + ) + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + const ownerCatalog = await runWithOwnerScope(FIRST_OWNER, () => + getAppBackgroundCatalog() + ); + expect(ownerCatalog.items).toEqual( + expect.arrayContaining([expect.objectContaining({ id: uploaded.id })]) + ); + + const storedPath = path.join( + tempDir, + "media", + "images", + "app-backgrounds", + FIRST_OWNER, + path.basename(uploaded.imageUrl) + ); + await expect(fs.readFile(storedPath, "utf8")).resolves.toBe("owner-a"); + }); +}); diff --git a/server/src/services/app-backgrounds/app-backgrounds-repository.ts b/server/src/services/app-backgrounds/app-backgrounds-repository.ts index fec2db52..8fe00e6a 100644 --- a/server/src/services/app-backgrounds/app-backgrounds-repository.ts +++ b/server/src/services/app-backgrounds/app-backgrounds-repository.ts @@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { resolveSafePath } from "@core/files/safe-path"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { uiAppBackgrounds, uiAppSettings } from "../../db/schema"; @@ -20,8 +21,9 @@ import type { AppBackgroundCatalog, } from "@shared/types/app-background"; -const SETTINGS_ROW_ID = "global"; -const APP_BACKGROUNDS_FOLDER = createDataPath("media", "images", "app-backgrounds"); +function getAppBackgroundsFolder(): string { + return createDataPath("media", "images", "app-backgrounds"); +} type AppBackgroundRow = typeof uiAppBackgrounds.$inferSelect; @@ -30,7 +32,10 @@ function rowToAsset(row: AppBackgroundRow): AppBackgroundAsset { id: row.id, name: row.name, source: "uploaded", - imageUrl: `/media/images/app-backgrounds/${encodeURIComponent(row.fileName)}`, + imageUrl: `/media/images/app-backgrounds/${row.fileName + .split("/") + .map(encodeURIComponent) + .join("/")}`, deletable: true, }; } @@ -40,13 +45,28 @@ function resolveUploadedBackgroundName(originalName: string): string { return baseName.length > 0 ? baseName : "Imported background"; } +function resolveBackgroundFilePath(fileName: string, ownerId: string): string { + const segments = fileName.split("/").filter(Boolean); + if (segments.length === 1 && ownerId === "global") { + return resolveSafePath(getAppBackgroundsFolder(), segments[0]); + } + if (segments.length !== 2 || segments[0] !== ownerId) { + throw new HttpError(404, "App background not found", "NOT_FOUND"); + } + const ownerFolder = resolveSafePath(getAppBackgroundsFolder(), ownerId); + return resolveSafePath(ownerFolder, segments[1]); +} + async function ensureSettingsRow(): Promise { await getAppSettings(); } async function listUploadedBackgrounds(): Promise { const db = await initDb(); - const rows = await db.select().from(uiAppBackgrounds); + const rows = await db + .select() + .from(uiAppBackgrounds) + .where(eq(uiAppBackgrounds.ownerId, resolveTrustedOwnerId())); return rows.map(rowToAsset); } @@ -56,7 +76,7 @@ async function readStoredActiveBackgroundId(): Promise { const rows = await db .select({ activeAppBackgroundId: uiAppSettings.activeAppBackgroundId }) .from(uiAppSettings) - .where(eq(uiAppSettings.id, SETTINGS_ROW_ID)) + .where(eq(uiAppSettings.id, resolveTrustedOwnerId())) .limit(1); return rows[0]?.activeAppBackgroundId ?? null; } @@ -70,7 +90,7 @@ async function persistActiveBackgroundId(activeBackgroundId: string | null): Pro activeAppBackgroundId: activeBackgroundId, updatedAt: new Date(), }) - .where(eq(uiAppSettings.id, SETTINGS_ROW_ID)); + .where(eq(uiAppSettings.id, resolveTrustedOwnerId())); } export function mergeAppBackgroundAssets( @@ -140,11 +160,14 @@ export async function importAppBackground(params: { fileBuffer: Buffer; originalName: string; }): Promise { - await fs.mkdir(APP_BACKGROUNDS_FOLDER, { recursive: true }); + const ownerId = resolveTrustedOwnerId(); + const ownerFolder = resolveSafePath(getAppBackgroundsFolder(), ownerId); + await fs.mkdir(ownerFolder, { recursive: true }); const extension = path.extname(params.originalName).toLowerCase(); - const filename = `${randomUUID()}${extension}`; - const filePath = resolveSafePath(APP_BACKGROUNDS_FOLDER, filename); + const storedFileName = `${randomUUID()}${extension}`; + const filename = `${ownerId}/${storedFileName}`; + const filePath = resolveSafePath(ownerFolder, storedFileName); await fs.writeFile(filePath, params.fileBuffer); const now = new Date(); @@ -152,6 +175,7 @@ export async function importAppBackground(params: { const db = await initDb(); await db.insert(uiAppBackgrounds).values({ id, + ownerId, name: resolveUploadedBackgroundName(params.originalName), fileName: filename, createdAt: now, @@ -160,6 +184,7 @@ export async function importAppBackground(params: { return rowToAsset({ id, + ownerId, name: resolveUploadedBackgroundName(params.originalName), fileName: filename, createdAt: now, @@ -178,15 +203,27 @@ export async function deleteAppBackground(params: { const rows = await db .select() .from(uiAppBackgrounds) - .where(eq(uiAppBackgrounds.id, params.id)) + .where( + and( + eq(uiAppBackgrounds.id, params.id), + eq(uiAppBackgrounds.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const row = rows[0]; if (!row) { throw new HttpError(404, "App background not found", "NOT_FOUND"); } - await db.delete(uiAppBackgrounds).where(eq(uiAppBackgrounds.id, params.id)); - await fs.rm(resolveSafePath(APP_BACKGROUNDS_FOLDER, row.fileName), { + await db + .delete(uiAppBackgrounds) + .where( + and( + eq(uiAppBackgrounds.id, params.id), + eq(uiAppBackgrounds.ownerId, resolveTrustedOwnerId()) + ) + ); + await fs.rm(resolveBackgroundFilePath(row.fileName, resolveTrustedOwnerId()), { force: true, }); diff --git a/server/src/services/app-settings/app-settings-repository.ts b/server/src/services/app-settings/app-settings-repository.ts index 08276c81..90e7f182 100644 --- a/server/src/services/app-settings/app-settings-repository.ts +++ b/server/src/services/app-settings/app-settings-repository.ts @@ -3,12 +3,11 @@ import fs from "node:fs/promises"; import { type AppSettings } from "@shared/types/app-settings"; import { eq } from "drizzle-orm"; - +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { uiAppSettings } from "../../db/schema"; import { createDataPath } from "../../utils"; -const SETTINGS_ROW_ID = "global"; const MAX_LEGACY_DATA_DEPTH = 32; const DEFAULT_APP_SETTINGS: AppSettings = { @@ -109,7 +108,7 @@ async function insertInitialSettings(settings: AppSettings): Promise { await db .insert(uiAppSettings) .values({ - id: SETTINGS_ROW_ID, + id: resolveTrustedOwnerId(), language: settings.language, openLastChat: settings.openLastChat, autoSelectCurrentPersona: settings.autoSelectCurrentPersona, @@ -136,13 +135,13 @@ export async function getAppSettings(): Promise { const rows = await db .select() .from(uiAppSettings) - .where(eq(uiAppSettings.id, SETTINGS_ROW_ID)) + .where(eq(uiAppSettings.id, resolveTrustedOwnerId())) .limit(1); const existing = rows[0]; if (existing) return rowToDto(existing); - const legacy = await tryReadLegacyFile(); + const legacy = resolveTrustedOwnerId() === "global" ? await tryReadLegacyFile() : null; const normalized = normalizeLegacyAppSettings(legacy); await insertInitialSettings(normalized); return normalized; @@ -159,7 +158,7 @@ export async function updateAppSettings( await db .insert(uiAppSettings) .values({ - id: SETTINGS_ROW_ID, + id: resolveTrustedOwnerId(), language: next.language, openLastChat: next.openLastChat, autoSelectCurrentPersona: next.autoSelectCurrentPersona, diff --git a/server/src/services/auth/auth-schema.integration.test.ts b/server/src/services/auth/auth-schema.integration.test.ts new file mode 100644 index 00000000..f4cb37f0 --- /dev/null +++ b/server/src/services/auth/auth-schema.integration.test.ts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { authSessions, users } from "../../db/schema"; + +describe("auth schema", () => { + let tempDir = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-auth-schema-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("stores users and hashed session tokens", async () => { + const db = await initDb(); + const now = new Date(); + + await db.insert(users).values({ + id: "user-1", + username: "Alice", + normalizedUsername: "alice", + displayName: "Alice", + passwordHash: null, + role: "admin", + status: "active", + credentialVersion: 0, + createdAt: now, + updatedAt: now, + }); + await db.insert(authSessions).values({ + id: "session-1", + userId: "user-1", + tokenHash: "hashed-token", + csrfTokenHash: "hashed-csrf-token", + authMethod: "local", + credentialVersion: 0, + createdAt: now, + lastSeenAt: now, + expiresAt: new Date(now.getTime() + 60_000), + }); + + await expect(db.select().from(users)).resolves.toHaveLength(1); + await expect(db.select().from(authSessions)).resolves.toMatchObject([ + { + userId: "user-1", + tokenHash: "hashed-token", + authMethod: "local", + }, + ]); + }); + + test("enforces normalized username and token hash uniqueness", async () => { + const db = await initDb(); + const now = new Date(); + const userValues = { + username: "Alice", + normalizedUsername: "alice", + displayName: "Alice", + role: "user" as const, + status: "active" as const, + credentialVersion: 0, + createdAt: now, + updatedAt: now, + }; + + await db.insert(users).values({ id: "user-1", ...userValues }); + await expect( + db.insert(users).values({ + id: "user-2", + ...userValues, + username: "ALICE", + }) + ).rejects.toThrow(); + + await db.insert(authSessions).values({ + id: "session-1", + userId: "user-1", + tokenHash: "same-hash", + csrfTokenHash: "csrf-1", + authMethod: "local", + credentialVersion: 0, + createdAt: now, + lastSeenAt: now, + expiresAt: new Date(now.getTime() + 60_000), + }); + await expect( + db.insert(authSessions).values({ + id: "session-2", + userId: "user-1", + tokenHash: "same-hash", + csrfTokenHash: "csrf-2", + authMethod: "local", + credentialVersion: 0, + createdAt: now, + lastSeenAt: now, + expiresAt: new Date(now.getTime() + 60_000), + }) + ).rejects.toThrow(); + }); +}); diff --git a/server/src/services/auth/auth-service.ts b/server/src/services/auth/auth-service.ts new file mode 100644 index 00000000..1be1f5e0 --- /dev/null +++ b/server/src/services/auth/auth-service.ts @@ -0,0 +1,273 @@ + +import { createPasswordHash, verifyPassword } from "./password-service"; +import { + createAuthSession, + revokeUserSessions, + type AuthMethod, + type CreatedAuthSession, +} from "./session-service"; +import { + createInitialUser, + createUser, + getUserById, + getUserCredentialsById, + getUserCredentialsByUsername, + patchUserAdministration, + recordUserLogin, + recoverAdminPassword, + updateUserPassword, + type UserDto, + type UserRole, + type UserStatus, +} from "./users-repository"; + +import type { AuthConfig } from "../../core/auth/auth-config"; + +export type AuthResult = { + user: UserDto; + session: CreatedAuthSession; +}; + +export class AuthServiceError extends Error { + constructor( + public readonly code: + | "SETUP_COMPLETED" + | "INVALID_CREDENTIALS" + | "ACCOUNT_DISABLED" + | "PASSWORD_REQUIRED" + | "PASSWORD_POLICY_VIOLATION" + | "USERNAME_TAKEN" + | "USER_NOT_FOUND" + | "LAST_ADMIN_REQUIRED", + message: string + ) { + super(message); + } +} + +async function hashPassword( + password: string, + config: AuthConfig +): Promise { + try { + return await createPasswordHash(password, config.policy); + } catch (error) { + throw new AuthServiceError( + "PASSWORD_POLICY_VIOLATION", + error instanceof Error ? error.message : "Password policy validation failed." + ); + } +} + +function isUniqueConstraintError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + String((error as { code?: unknown }).code).startsWith( + "SQLITE_CONSTRAINT" + ) + ); +} + +async function issueSession(params: { + userId: string; + authMethod: AuthMethod; + config: AuthConfig; +}): Promise { + const user = await getUserCredentialsById(params.userId); + if (!user) { + throw new AuthServiceError( + "INVALID_CREDENTIALS", + "Invalid username or password." + ); + } + const session = await createAuthSession({ + user, + authMethod: params.authMethod, + config: params.config, + }); + await recordUserLogin(user.id); + const safeUser = await getUserById(user.id); + if (!safeUser) { + throw new Error("Authenticated user could not be reloaded."); + } + return { user: safeUser, session }; +} + +export async function setupInitialUser(params: { + username: string; + displayName?: string; + password: string; + config: AuthConfig; +}): Promise { + const passwordHash = await hashPassword(params.password, params.config); + const created = await createInitialUser({ + username: params.username, + displayName: params.displayName ?? params.username, + passwordHash, + }); + if (!created) { + throw new AuthServiceError( + "SETUP_COMPLETED", + "Initial setup has already been completed." + ); + } + return issueSession({ + userId: created.id, + authMethod: passwordHash ? "password" : "local", + config: params.config, + }); +} + +export async function loginUser(params: { + username?: string; + userId?: string; + password: string; + config: AuthConfig; +}): Promise { + const user = params.userId + ? await getUserCredentialsById(params.userId) + : await getUserCredentialsByUsername(params.username ?? ""); + if (!user) { + throw new AuthServiceError( + "INVALID_CREDENTIALS", + "Invalid username or password." + ); + } + if (user.status !== "active") { + throw new AuthServiceError("ACCOUNT_DISABLED", "Account is disabled."); + } + if ( + !user.passwordHash && + !params.config.policy.passwordlessLoginAllowed + ) { + throw new AuthServiceError( + "PASSWORD_REQUIRED", + "Passwordless login is disabled." + ); + } + if (!(await verifyPassword(user.passwordHash, params.password))) { + throw new AuthServiceError( + "INVALID_CREDENTIALS", + "Invalid username or password." + ); + } + return issueSession({ + userId: user.id, + authMethod: user.passwordHash ? "password" : "local", + config: params.config, + }); +} + +export async function createAdditionalUser(params: { + username: string; + displayName?: string; + password: string; + role?: UserRole; + config: AuthConfig; +}): Promise { + const passwordHash = await hashPassword(params.password, params.config); + try { + return await createUser({ + username: params.username, + displayName: params.displayName ?? params.username, + passwordHash, + role: params.role ?? "user", + }); + } catch (error) { + if (isUniqueConstraintError(error)) { + throw new AuthServiceError( + "USERNAME_TAKEN", + "Username is already in use." + ); + } + throw error; + } +} + +export async function changeOwnPassword(params: { + userId: string; + currentPassword: string; + newPassword: string; + config: AuthConfig; +}): Promise { + const current = await getUserCredentialsById(params.userId); + if ( + !current || + current.status !== "active" || + !(await verifyPassword(current.passwordHash, params.currentPassword)) + ) { + throw new AuthServiceError( + "INVALID_CREDENTIALS", + "Current password is invalid." + ); + } + const passwordHash = await hashPassword(params.newPassword, params.config); + await updateUserPassword(current.id, passwordHash); + await revokeUserSessions(current.id); + return issueSession({ + userId: current.id, + authMethod: passwordHash ? "password" : "local", + config: params.config, + }); +} + +export async function updateUserAdministration(params: { + userId: string; + role?: UserRole; + status?: UserStatus; +}): Promise { + const result = await patchUserAdministration(params); + if (result.status === "not_found") { + throw new AuthServiceError("USER_NOT_FOUND", "User not found."); + } + if (result.status === "last_admin") { + throw new AuthServiceError( + "LAST_ADMIN_REQUIRED", + "At least one active administrator is required." + ); + } + await revokeUserSessions(params.userId); + return result.user; +} + +export async function resetUserPassword(params: { + userId: string; + newPassword: string; + config: AuthConfig; +}): Promise { + const current = await getUserById(params.userId); + if (!current) { + throw new AuthServiceError("USER_NOT_FOUND", "User not found."); + } + const passwordHash = await hashPassword(params.newPassword, params.config); + const updated = await updateUserPassword(params.userId, passwordHash); + if (!updated) { + throw new AuthServiceError("USER_NOT_FOUND", "User not found."); + } + await revokeUserSessions(params.userId); + return updated; +} + +export async function recoverAdministrator(params: { + username: string; + newPassword: string; + config: AuthConfig; +}): Promise { + const passwordHash = await hashPassword(params.newPassword, params.config); + if (!passwordHash) { + throw new AuthServiceError("PASSWORD_REQUIRED", "Password is required."); + } + const recovered = await recoverAdminPassword({ + username: params.username, + passwordHash, + }); + if (!recovered) { + throw new AuthServiceError( + "INVALID_CREDENTIALS", + "Administrator recovery failed." + ); + } + await revokeUserSessions(recovered.id); +} diff --git a/server/src/services/auth/password-service.test.ts b/server/src/services/auth/password-service.test.ts new file mode 100644 index 00000000..07d1cdbf --- /dev/null +++ b/server/src/services/auth/password-service.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; + +import { resolveAccessPolicy } from "../../core/auth/access-policy"; + +import { + createPasswordHash, + verifyPassword, +} from "./password-service"; + +describe("password service", () => { + test("allows an empty password only in local mode", async () => { + await expect( + createPasswordHash("", resolveAccessPolicy({})) + ).resolves.toBeNull(); + + await expect( + createPasswordHash( + "", + resolveAccessPolicy({ TALESPINNER_ACCESS_MODE: "public" }) + ) + ).rejects.toThrow(/required/i); + }); + + test("hashes non-empty passwords with Argon2id", async () => { + const hash = await createPasswordHash( + "correct horse battery staple", + resolveAccessPolicy({}) + ); + + expect(hash).toMatch(/^\$argon2id\$/); + await expect( + verifyPassword(hash, "correct horse battery staple") + ).resolves.toBe(true); + await expect(verifyPassword(hash, "wrong password")).resolves.toBe(false); + }); + + test("requires a stronger password in public mode", async () => { + const policy = resolveAccessPolicy({ + TALESPINNER_ACCESS_MODE: "public", + }); + + await expect(createPasswordHash("short", policy)).rejects.toThrow( + /at least 10/ + ); + }); +}); diff --git a/server/src/services/auth/password-service.ts b/server/src/services/auth/password-service.ts new file mode 100644 index 00000000..76b9d94d --- /dev/null +++ b/server/src/services/auth/password-service.ts @@ -0,0 +1,50 @@ +import argon2 from "argon2"; + +import type { AccessPolicy } from "../../core/auth/access-policy"; + +const PUBLIC_PASSWORD_MIN_LENGTH = 10; +const PASSWORD_MAX_LENGTH = 1024; + +function validatePassword(password: string, policy: AccessPolicy): void { + if (password.length > PASSWORD_MAX_LENGTH) { + throw new Error(`Password must not exceed ${PASSWORD_MAX_LENGTH} characters.`); + } + if (policy.passwordRequired && password.length === 0) { + throw new Error("Password is required in public access mode."); + } + if ( + policy.passwordRequired && + password.length < PUBLIC_PASSWORD_MIN_LENGTH + ) { + throw new Error( + `Password must contain at least ${PUBLIC_PASSWORD_MIN_LENGTH} characters.` + ); + } +} + +export async function createPasswordHash( + password: string, + policy: AccessPolicy +): Promise { + validatePassword(password, policy); + if (password.length === 0) return null; + + return argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 19_456, + timeCost: 2, + parallelism: 1, + }); +} + +export async function verifyPassword( + passwordHash: string | null, + password: string +): Promise { + if (!passwordHash) return password.length === 0; + try { + return await argon2.verify(passwordHash, password); + } catch { + return false; + } +} diff --git a/server/src/services/auth/session-service.integration.test.ts b/server/src/services/auth/session-service.integration.test.ts new file mode 100644 index 00000000..8818de81 --- /dev/null +++ b/server/src/services/auth/session-service.integration.test.ts @@ -0,0 +1,150 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { resolveAuthConfig } from "../../core/auth/auth-config"; +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { authSessions } from "../../db/schema"; + +import { + createAuthSession, + cleanupAuthSessions, + resolveAuthSession, + revokeAuthSession, + rotateSessionCsrfToken, + verifySessionCsrfToken, +} from "./session-service"; +import { + createUser, + getUserCredentialsById, +} from "./users-repository"; + +describe("session service", () => { + let tempDir = ""; + const config = resolveAuthConfig({}); + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-session-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function createFixture() { + await createUser({ + id: "user-1", + username: "alice", + displayName: "Alice", + passwordHash: null, + role: "admin", + }); + const user = await getUserCredentialsById("user-1"); + if (!user) throw new Error("Missing fixture user"); + return createAuthSession({ user, authMethod: "local", config }); + } + + test("stores only token hashes and resolves an active principal", async () => { + const created = await createFixture(); + const db = await initDb(); + const stored = await db + .select() + .from(authSessions) + .where(eq(authSessions.id, created.sessionId)); + + expect(stored[0]?.tokenHash).not.toBe(created.token); + expect(stored[0]?.csrfTokenHash).not.toBe(created.csrfToken); + await expect( + resolveAuthSession({ token: created.token, config }) + ).resolves.toMatchObject({ + sessionId: created.sessionId, + user: { id: "user-1", role: "admin" }, + }); + }); + + test("verifies CSRF tokens without storing their plaintext", async () => { + const created = await createFixture(); + const principal = await resolveAuthSession({ + token: created.token, + config, + }); + if (!principal) throw new Error("Missing session principal"); + + expect( + verifySessionCsrfToken(principal, created.csrfToken, config) + ).toBe(true); + expect(verifySessionCsrfToken(principal, "wrong-token", config)).toBe( + false + ); + + const firstRefresh = await rotateSessionCsrfToken( + created.sessionId, + config + ); + const secondRefresh = await rotateSessionCsrfToken( + created.sessionId, + config + ); + expect(firstRefresh).toBe(created.csrfToken); + expect(secondRefresh).toBe(created.csrfToken); + expect( + verifySessionCsrfToken(principal, firstRefresh, config) + ).toBe(true); + }); + + test("does not resolve revoked sessions", async () => { + const created = await createFixture(); + await revokeAuthSession(created.sessionId); + + await expect( + resolveAuthSession({ token: created.token, config }) + ).resolves.toBeNull(); + }); + + test("cleans up expired and old revoked sessions", async () => { + const expired = await createFixture(); + const user = await getUserCredentialsById("user-1"); + if (!user) throw new Error("Missing fixture user"); + const revoked = await createAuthSession({ + user, + authMethod: "local", + config, + }); + const active = await createAuthSession({ + user, + authMethod: "local", + config, + }); + const db = await initDb(); + const now = new Date("2026-07-27T12:00:00.000Z"); + await db + .update(authSessions) + .set({ expiresAt: new Date(now.getTime() - 1) }) + .where(eq(authSessions.id, expired.sessionId)); + await db + .update(authSessions) + .set({ + revokedAt: new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000), + expiresAt: new Date(now.getTime() + 60_000), + }) + .where(eq(authSessions.id, revoked.sessionId)); + await db + .update(authSessions) + .set({ expiresAt: new Date(now.getTime() + 60_000) }) + .where(eq(authSessions.id, active.sessionId)); + + await expect(cleanupAuthSessions({ now })).resolves.toBe(2); + const remaining = await db.select().from(authSessions); + expect(remaining.map((session) => session.id)).toEqual([ + active.sessionId, + ]); + }); +}); diff --git a/server/src/services/auth/session-service.ts b/server/src/services/auth/session-service.ts new file mode 100644 index 00000000..a86416ab --- /dev/null +++ b/server/src/services/auth/session-service.ts @@ -0,0 +1,231 @@ +import { + createHash, + createHmac, + randomBytes, + randomUUID, + timingSafeEqual, +} from "node:crypto"; + +import { and, eq, isNotNull, isNull, lte, or } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { authSessions } from "../../db/schema"; + +import { + getUserCredentialsById, + type UserCredentials, + type UserDto, +} from "./users-repository"; + +import type { AuthConfig } from "../../core/auth/auth-config"; + +export type AuthMethod = "local" | "password"; + +export type SessionPrincipal = { + sessionId: string; + user: UserDto; + csrfTokenHash: string; + expiresAt: Date; +}; + +export type CreatedAuthSession = { + sessionId: string; + token: string; + csrfToken: string; + expiresAt: Date; +}; + +function generateToken(): string { + return randomBytes(32).toString("base64url"); +} + +function hashToken(token: string, secret: string | null): string { + return secret + ? createHmac("sha256", secret).update(token).digest("hex") + : createHash("sha256").update(token).digest("hex"); +} + +function deriveCsrfToken(params: { + sessionId: string; + tokenHash: string; + secret: string | null; +}): string { + return createHmac("sha256", params.secret ?? params.tokenHash) + .update(`csrf:${params.sessionId}:${params.tokenHash}`) + .digest("base64url"); +} + +function safeUser(credentials: UserCredentials): UserDto { + const { + normalizedUsername: _normalizedUsername, + passwordHash: _passwordHash, + credentialVersion: _credentialVersion, + ...user + } = credentials; + return user; +} + +export async function createAuthSession(params: { + user: UserCredentials; + authMethod: AuthMethod; + config: AuthConfig; +}): Promise { + const db = await initDb(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + params.config.sessionTtlMs); + const token = generateToken(); + const sessionId = randomUUID(); + const tokenHash = hashToken(token, params.config.sessionSecret); + const csrfToken = deriveCsrfToken({ + sessionId, + tokenHash, + secret: params.config.sessionSecret, + }); + + await db.insert(authSessions).values({ + id: sessionId, + userId: params.user.id, + tokenHash, + csrfTokenHash: hashToken(csrfToken, params.config.sessionSecret), + authMethod: params.authMethod, + credentialVersion: params.user.credentialVersion, + createdAt: now, + lastSeenAt: now, + expiresAt, + }); + + return { sessionId, token, csrfToken, expiresAt }; +} + +export async function resolveAuthSession(params: { + token: string; + config: AuthConfig; +}): Promise { + const db = await initDb(); + const tokenHash = hashToken(params.token, params.config.sessionSecret); + const rows = await db + .select() + .from(authSessions) + .where( + and( + eq(authSessions.tokenHash, tokenHash), + isNull(authSessions.revokedAt) + ) + ) + .limit(1); + const session = rows[0]; + if (!session || session.expiresAt.getTime() <= Date.now()) return null; + + const user = await getUserCredentialsById(session.userId); + if ( + !user || + user.status !== "active" || + user.credentialVersion !== session.credentialVersion + ) { + return null; + } + + await db + .update(authSessions) + .set({ lastSeenAt: new Date() }) + .where(eq(authSessions.id, session.id)); + + return { + sessionId: session.id, + user: safeUser(user), + csrfTokenHash: session.csrfTokenHash, + expiresAt: session.expiresAt, + }; +} + +export function verifySessionCsrfToken( + principal: SessionPrincipal, + csrfToken: string, + config: AuthConfig +): boolean { + const actual = Buffer.from( + hashToken(csrfToken, config.sessionSecret), + "hex" + ); + const expected = Buffer.from(principal.csrfTokenHash, "hex"); + return ( + actual.length === expected.length && timingSafeEqual(actual, expected) + ); +} + +export async function revokeAuthSession(sessionId: string): Promise { + const db = await initDb(); + await db + .update(authSessions) + .set({ revokedAt: new Date() }) + .where(eq(authSessions.id, sessionId)); +} + +export async function revokeUserSessions(userId: string): Promise { + const db = await initDb(); + await db + .update(authSessions) + .set({ revokedAt: new Date() }) + .where( + and(eq(authSessions.userId, userId), isNull(authSessions.revokedAt)) + ); +} + +export async function rotateSessionCsrfToken( + sessionId: string, + config: AuthConfig +): Promise { + const db = await initDb(); + const rows = await db + .select({ + tokenHash: authSessions.tokenHash, + }) + .from(authSessions) + .where( + and( + eq(authSessions.id, sessionId), + isNull(authSessions.revokedAt) + ) + ) + .limit(1); + const session = rows[0]; + if (!session) { + throw new Error("Active authentication session not found."); + } + const csrfToken = deriveCsrfToken({ + sessionId, + tokenHash: session.tokenHash, + secret: config.sessionSecret, + }); + await db + .update(authSessions) + .set({ + csrfTokenHash: hashToken(csrfToken, config.sessionSecret), + lastSeenAt: new Date(), + }) + .where(eq(authSessions.id, sessionId)); + return csrfToken; +} + +export async function cleanupAuthSessions(params?: { + now?: Date; + revokedRetentionMs?: number; +}): Promise { + const db = await initDb(); + const now = params?.now ?? new Date(); + const revokedCutoff = new Date( + now.getTime() - (params?.revokedRetentionMs ?? 7 * 24 * 60 * 60 * 1000) + ); + const result = await db + .delete(authSessions) + .where( + or( + lte(authSessions.expiresAt, now), + and( + isNotNull(authSessions.revokedAt), + lte(authSessions.revokedAt, revokedCutoff) + ) + ) + ); + return result.changes; +} diff --git a/server/src/services/auth/users-repository.integration.test.ts b/server/src/services/auth/users-repository.integration.test.ts new file mode 100644 index 00000000..672ca38b --- /dev/null +++ b/server/src/services/auth/users-repository.integration.test.ts @@ -0,0 +1,159 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; + +import { + countUsers, + createUser, + getUserCredentialsByUsername, + listActiveUsers, + normalizeUsername, + patchUserAdministration, + updateUserPassword, +} from "./users-repository"; + +describe("users repository", () => { + let tempDir = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-users-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("creates safe user DTOs and keeps credentials internal", async () => { + const user = await createUser({ + id: "global", + username: " Alice ", + displayName: "Alice", + passwordHash: "secret-hash", + role: "admin", + }); + + expect(user).toMatchObject({ + id: "global", + username: "Alice", + displayName: "Alice", + role: "admin", + status: "active", + hasPassword: true, + }); + expect(user).not.toHaveProperty("passwordHash"); + await expect(countUsers()).resolves.toBe(1); + + await expect( + getUserCredentialsByUsername("ALICE") + ).resolves.toMatchObject({ + id: "global", + normalizedUsername: "alice", + passwordHash: "secret-hash", + }); + }); + + test("normalizes Unicode usernames and enforces uniqueness", async () => { + expect(normalizeUsername(" Alice ")).toBe("alice"); + + await createUser({ + username: "Alice", + displayName: "Alice", + passwordHash: null, + role: "user", + }); + await expect( + createUser({ + username: "ALICE", + displayName: "Other Alice", + passwordHash: null, + role: "user", + }) + ).rejects.toThrow(); + }); + + test("lists only active users for local account selection", async () => { + await createUser({ + id: "active-user", + username: "active", + displayName: "Active", + passwordHash: null, + role: "user", + }); + await createUser({ + id: "disabled-user", + username: "disabled", + displayName: "Disabled", + passwordHash: null, + role: "user", + status: "disabled", + }); + + await expect(listActiveUsers()).resolves.toMatchObject([ + { id: "active-user" }, + ]); + }); + + test("protects the last active admin from demotion or disablement", async () => { + await createUser({ + id: "admin-1", + username: "admin-1", + displayName: "Admin 1", + passwordHash: "hash", + role: "admin", + }); + + await expect( + patchUserAdministration({ + userId: "admin-1", + status: "disabled", + }) + ).resolves.toEqual({ status: "last_admin" }); + + await createUser({ + id: "admin-2", + username: "admin-2", + displayName: "Admin 2", + passwordHash: "hash", + role: "admin", + }); + await expect( + patchUserAdministration({ + userId: "admin-1", + status: "disabled", + }) + ).resolves.toMatchObject({ + status: "updated", + user: { id: "admin-1", status: "disabled" }, + }); + }); + + test("updates passwords and increments the credential version", async () => { + await createUser({ + id: "user-1", + username: "user-1", + displayName: "User 1", + passwordHash: null, + role: "user", + }); + const before = await getUserCredentialsByUsername("user-1"); + + await expect(updateUserPassword("user-1", "next-hash")).resolves.toMatchObject({ + id: "user-1", + hasPassword: true, + }); + const after = await getUserCredentialsByUsername("user-1"); + expect(after?.passwordHash).toBe("next-hash"); + expect(after?.credentialVersion).toBe( + (before?.credentialVersion ?? 0) + 1 + ); + }); +}); diff --git a/server/src/services/auth/users-repository.ts b/server/src/services/auth/users-repository.ts new file mode 100644 index 00000000..77369ab7 --- /dev/null +++ b/server/src/services/auth/users-repository.ts @@ -0,0 +1,295 @@ +import { randomUUID } from "node:crypto"; + +import { and, asc, eq, sql } from "drizzle-orm"; + +import { initDb } from "../../db/client"; +import { users } from "../../db/schema"; + +export type UserRole = "admin" | "user"; +export type UserStatus = "active" | "disabled"; + +export type UserDto = { + id: string; + username: string; + displayName: string; + role: UserRole; + status: UserStatus; + hasPassword: boolean; + createdAt: Date; + updatedAt: Date; + lastLoginAt: Date | null; +}; + +export type UserCredentials = UserDto & { + normalizedUsername: string; + passwordHash: string | null; + credentialVersion: number; +}; + +function normalizeIdentity(value: string): string { + return value.normalize("NFKC").trim(); +} + +export function normalizeUsername(value: string): string { + return normalizeIdentity(value).toLocaleLowerCase("en-US"); +} + +function validateIdentity(username: string, displayName: string): void { + if (username.length < 1 || username.length > 64) { + throw new Error("Username must contain between 1 and 64 characters."); + } + if (displayName.length < 1 || displayName.length > 128) { + throw new Error("Display name must contain between 1 and 128 characters."); + } +} + +function rowToDto(row: typeof users.$inferSelect): UserDto { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + role: row.role, + status: row.status, + hasPassword: Boolean(row.passwordHash), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + lastLoginAt: row.lastLoginAt, + }; +} + +function rowToCredentials( + row: typeof users.$inferSelect +): UserCredentials { + return { + ...rowToDto(row), + normalizedUsername: row.normalizedUsername, + passwordHash: row.passwordHash, + credentialVersion: row.credentialVersion, + }; +} + +export async function countUsers(): Promise { + const db = await initDb(); + const rows = await db.select({ id: users.id }).from(users); + return rows.length; +} + +export async function createUser(params: { + id?: string; + username: string; + displayName: string; + passwordHash: string | null; + role: UserRole; + status?: UserStatus; +}): Promise { + const db = await initDb(); + const username = normalizeIdentity(params.username); + const displayName = normalizeIdentity(params.displayName); + validateIdentity(username, displayName); + const now = new Date(); + const id = params.id ?? randomUUID(); + + await db.insert(users).values({ + id, + username, + normalizedUsername: normalizeUsername(username), + displayName, + passwordHash: params.passwordHash, + role: params.role, + status: params.status ?? "active", + credentialVersion: 0, + createdAt: now, + updatedAt: now, + }); + + const created = await getUserById(id); + if (!created) throw new Error("Created user could not be loaded."); + return created; +} + +export async function createInitialUser(params: { + username: string; + displayName: string; + passwordHash: string | null; +}): Promise { + const db = await initDb(); + const username = normalizeIdentity(params.username); + const displayName = normalizeIdentity(params.displayName); + validateIdentity(username, displayName); + const now = new Date(); + + return db.transaction((tx) => { + const existing = tx.select({ id: users.id }).from(users).limit(1).all(); + if (existing.length > 0) return null; + + tx.insert(users) + .values({ + id: "global", + username, + normalizedUsername: normalizeUsername(username), + displayName, + passwordHash: params.passwordHash, + role: "admin", + status: "active", + credentialVersion: 0, + createdAt: now, + updatedAt: now, + }) + .run(); + + return { + id: "global", + username, + displayName, + role: "admin", + status: "active", + hasPassword: Boolean(params.passwordHash), + createdAt: now, + updatedAt: now, + lastLoginAt: null, + }; + }); +} + +export async function getUserById(id: string): Promise { + const db = await initDb(); + const rows = await db.select().from(users).where(eq(users.id, id)).limit(1); + return rows[0] ? rowToDto(rows[0]) : null; +} + +export async function getUserCredentialsById( + id: string +): Promise { + const db = await initDb(); + const rows = await db.select().from(users).where(eq(users.id, id)).limit(1); + return rows[0] ? rowToCredentials(rows[0]) : null; +} + +export async function getUserCredentialsByUsername( + username: string +): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(users) + .where(eq(users.normalizedUsername, normalizeUsername(username))) + .limit(1); + return rows[0] ? rowToCredentials(rows[0]) : null; +} + +export async function listActiveUsers(): Promise { + const db = await initDb(); + const rows = await db + .select() + .from(users) + .where(eq(users.status, "active")) + .orderBy(asc(users.username)); + return rows.map(rowToDto); +} + +export async function listUsers(): Promise { + const db = await initDb(); + const rows = await db.select().from(users).orderBy(asc(users.username)); + return rows.map(rowToDto); +} + +export async function recordUserLogin(id: string): Promise { + const db = await initDb(); + const now = new Date(); + await db + .update(users) + .set({ lastLoginAt: now, updatedAt: now }) + .where(eq(users.id, id)); +} + +export type PatchUserAdministrationResult = + | { status: "updated"; user: UserDto } + | { status: "not_found" } + | { status: "last_admin" }; + +export async function patchUserAdministration(params: { + userId: string; + role?: UserRole; + status?: UserStatus; +}): Promise { + const db = await initDb(); + return db.transaction((tx) => { + const current = tx + .select() + .from(users) + .where(eq(users.id, params.userId)) + .limit(1) + .get(); + if (!current) return { status: "not_found" }; + + const nextRole = params.role ?? current.role; + const nextStatus = params.status ?? current.status; + const removesActiveAdmin = + current.role === "admin" && + current.status === "active" && + (nextRole !== "admin" || nextStatus !== "active"); + if (removesActiveAdmin) { + const activeAdmins = tx + .select({ id: users.id }) + .from(users) + .where( + and(eq(users.role, "admin"), eq(users.status, "active")) + ) + .all(); + if (activeAdmins.length <= 1) return { status: "last_admin" }; + } + + tx.update(users) + .set({ + role: nextRole, + status: nextStatus, + credentialVersion: sql`${users.credentialVersion} + 1`, + updatedAt: new Date(), + }) + .where(eq(users.id, params.userId)) + .run(); + const updated = tx + .select() + .from(users) + .where(eq(users.id, params.userId)) + .limit(1) + .get(); + if (!updated) return { status: "not_found" }; + return { status: "updated", user: rowToDto(updated) }; + }); +} + +export async function updateUserPassword( + userId: string, + passwordHash: string | null +): Promise { + const db = await initDb(); + await db + .update(users) + .set({ + passwordHash, + credentialVersion: sql`${users.credentialVersion} + 1`, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); + return getUserById(userId); +} + +export async function recoverAdminPassword(params: { + username: string; + passwordHash: string; +}): Promise { + const db = await initDb(); + const current = await getUserCredentialsByUsername(params.username); + if (!current || current.role !== "admin") return null; + await db + .update(users) + .set({ + passwordHash: params.passwordHash, + status: "active", + credentialVersion: sql`${users.credentialVersion} + 1`, + updatedAt: new Date(), + }) + .where(and(eq(users.id, current.id), eq(users.role, "admin"))); + return getUserById(current.id); +} diff --git a/server/src/services/bundles/bundle-import-export.test.ts b/server/src/services/bundles/bundle-import-export.test.ts index 20cfb5f3..f4a5303d 100644 --- a/server/src/services/bundles/bundle-import-export.test.ts +++ b/server/src/services/bundles/bundle-import-export.test.ts @@ -82,6 +82,7 @@ describe("bundle import/export", () => { templateText: "{{char.name}}", }); const block = await createOperationBlock({ + ownerId: "global", input: { name: "Scene block", enabled: true, @@ -89,6 +90,7 @@ describe("bundle import/export", () => { }, }); const profile = await createOperationProfile({ + ownerId: "global", input: { name: "Main profile", enabled: true, @@ -121,7 +123,10 @@ describe("bundle import/export", () => { expect(imported.applied.operationProfileId).toBe(imported.created.operationProfiles[0]!.profileId); expect(imported.skippedApply).toEqual([]); - const createdProfile = await getOperationProfileById(imported.created.operationProfiles[0]!.profileId); + const createdProfile = await getOperationProfileById({ + ownerId: "global", + profileId: imported.created.operationProfiles[0]!.profileId, + }); const blocks = await listOperationBlocks({ ownerId: "global" }); expect(createdProfile?.blockRefs).toHaveLength(1); diff --git a/server/src/services/bundles/export-bundle-selection.ts b/server/src/services/bundles/export-bundle-selection.ts index 703733dc..06eff009 100644 --- a/server/src/services/bundles/export-bundle-selection.ts +++ b/server/src/services/bundles/export-bundle-selection.ts @@ -81,7 +81,10 @@ export async function exportBundleSelection(params: { } if (handle.kind === "operation_block") { - const block = await getOperationBlockById(handle.id); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: handle.id, + }); if (!block) throw new Error(`Operation block not found: ${handle.id}`); const resourceId = createBundleResourceId("operation_block", `${block.name}-${block.blockId}`); resources.push({ @@ -103,13 +106,19 @@ export async function exportBundleSelection(params: { } if (handle.kind === "operation_profile") { - const profile = await getOperationProfileById(handle.id); + const profile = await getOperationProfileById({ + ownerId: params.ownerId, + profileId: handle.id, + }); if (!profile) throw new Error(`Operation profile not found: ${handle.id}`); const enabledRefs = profile.blockRefs.filter((ref) => ref.enabled); const exportedBlockResources: Array<{ blockId: string; resourceId: string }> = []; for (const ref of enabledRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: ref.blockId, + }); if (!block || !block.enabled) continue; const resourceId = createBundleResourceId("operation_block", `${block.name}-${block.blockId}`); exportedBlockResources.push({ blockId: block.blockId, resourceId }); diff --git a/server/src/services/chat-core/chats-owner.integration.test.ts b/server/src/services/chat-core/chats-owner.integration.test.ts new file mode 100644 index 00000000..987829af --- /dev/null +++ b/server/src/services/chat-core/chats-owner.integration.test.ts @@ -0,0 +1,155 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runWithOwnerScope } from "@core/request-context/owner-scope-storage"; + +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; + +import { + createAssistantMessageWithVariant, + createChat, + createChatBranch, + getChatById, + listChatBranches, + listChatMessages, + setChatInstruction, + softDeleteChat, + updateAssistantText, + updateChatTitle, +} from "./chats-repository"; +import { createEntityProfile } from "./entity-profiles-repository"; +import { createInstruction } from "./instructions-repository"; + +const FIRST_OWNER = "11111111-1111-4111-8111-111111111111"; +const SECOND_OWNER = "22222222-2222-4222-8222-222222222222"; + +describe("legacy chat repository owner scope", () => { + let tempDir = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "chat-owner-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("prevents cross-owner chat, branch, and message mutations", async () => { + const profile = await runWithOwnerScope(FIRST_OWNER, () => + createEntityProfile({ + name: "Private character", + kind: "CharSpec", + spec: { name: "Private character" }, + }) + ); + const created = await runWithOwnerScope(FIRST_OWNER, () => + createChat({ + entityProfileId: profile.id, + title: "Private chat", + }) + ); + const assistant = await runWithOwnerScope(FIRST_OWNER, () => + createAssistantMessageWithVariant({ + chatId: created.chat.id, + branchId: created.mainBranch.id, + }) + ); + + await runWithOwnerScope(SECOND_OWNER, () => + updateChatTitle({ chatId: created.chat.id, title: "Stolen" }) + ); + await runWithOwnerScope(SECOND_OWNER, () => softDeleteChat(created.chat.id)); + await expect( + runWithOwnerScope(SECOND_OWNER, () => + createChatBranch({ chatId: created.chat.id, title: "Injected" }) + ) + ).rejects.toThrow("Chat не найден"); + await runWithOwnerScope(SECOND_OWNER, () => + updateAssistantText({ + assistantMessageId: assistant.assistantMessageId, + variantId: assistant.variantId, + text: "Stolen", + }) + ); + + await expect( + runWithOwnerScope(SECOND_OWNER, () => getChatById(created.chat.id)) + ).resolves.toBeNull(); + await expect( + runWithOwnerScope(SECOND_OWNER, () => + listChatBranches({ chatId: created.chat.id }) + ) + ).resolves.toEqual([]); + + const original = await runWithOwnerScope(FIRST_OWNER, () => + getChatById(created.chat.id) + ); + expect(original).toMatchObject({ title: "Private chat", status: "active" }); + const messages = await runWithOwnerScope(FIRST_OWNER, () => + listChatMessages({ + chatId: created.chat.id, + branchId: created.mainBranch.id, + limit: 10, + }) + ); + expect(messages[0]).toMatchObject({ promptText: "" }); + }); + + test("does not create a chat for another owner's entity profile", async () => { + const profile = await runWithOwnerScope(FIRST_OWNER, () => + createEntityProfile({ + name: "Private character", + kind: "CharSpec", + spec: { name: "Private character" }, + }) + ); + + await expect( + runWithOwnerScope(SECOND_OWNER, () => + createChat({ entityProfileId: profile.id, title: "Cross owner" }) + ) + ).rejects.toThrow("EntityProfile не найден"); + }); + + test("does not attach another owner's instruction to a chat", async () => { + const profile = await runWithOwnerScope(FIRST_OWNER, () => + createEntityProfile({ + name: "Private character", + kind: "CharSpec", + spec: { name: "Private character" }, + }) + ); + const created = await runWithOwnerScope(FIRST_OWNER, () => + createChat({ entityProfileId: profile.id, title: "Private chat" }) + ); + const foreignInstruction = await runWithOwnerScope(SECOND_OWNER, () => + createInstruction({ + name: "Foreign instruction", + kind: "basic", + templateText: "Do not expose", + }) + ); + + await expect( + runWithOwnerScope(FIRST_OWNER, () => + setChatInstruction({ + chatId: created.chat.id, + instructionId: foreignInstruction.id, + }) + ) + ).rejects.toThrow("Instruction не найдена"); + + const chat = await runWithOwnerScope(FIRST_OWNER, () => + getChatById(created.chat.id) + ); + expect(chat?.instructionId).toBeNull(); + }); +}); diff --git a/server/src/services/chat-core/chats-repository.ts b/server/src/services/chat-core/chats-repository.ts index 5a2b1276..0094052b 100644 --- a/server/src/services/chat-core/chats-repository.ts +++ b/server/src/services/chat-core/chats-repository.ts @@ -3,11 +3,13 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, desc, eq, lt, ne } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { chatBranches, chatMessages, chats, + entityProfiles, messageVariants, instructions, } from "../../db/schema"; @@ -137,7 +139,7 @@ export async function listChatsByEntityProfile(params: { .from(chats) .where( and( - eq(chats.ownerId, params.ownerId ?? "global"), + eq(chats.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(chats.entityProfileId, params.entityProfileId), // Show non-deleted by default in list ne(chats.status, "deleted") @@ -149,7 +151,12 @@ export async function listChatsByEntityProfile(params: { export async function getChatById(id: string): Promise { const db = await initDb(); - const rows = await db.select().from(chats).where(eq(chats.id, id)); + const rows = await db + .select() + .from(chats) + .where( + and(eq(chats.id, id), eq(chats.ownerId, resolveTrustedOwnerId())) + ); return rows[0] ? chatRowToDto(rows[0]) : null; } @@ -160,12 +167,24 @@ export async function createChat(params: { meta?: unknown; }): Promise<{ chat: ChatDto; mainBranch: ChatBranchDto }> { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const ts = new Date(); const chatId = uuidv4(); const mainBranchId = uuidv4(); await db.transaction((tx) => { + const profile = tx + .select({ id: entityProfiles.id }) + .from(entityProfiles) + .where( + and( + eq(entityProfiles.id, params.entityProfileId), + eq(entityProfiles.ownerId, ownerId) + ) + ) + .get(); + if (!profile) throw new Error("EntityProfile не найден"); + const templateRows = tx .select({ id: instructions.id }) .from(instructions) @@ -234,7 +253,20 @@ export async function setChatInstruction(params: { instructionId: string | null; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); + if (params.instructionId) { + const instruction = await db + .select({ id: instructions.id }) + .from(instructions) + .where( + and( + eq(instructions.id, params.instructionId), + eq(instructions.ownerId, ownerId) + ) + ) + .limit(1); + if (!instruction[0]) throw new Error("Instruction не найдена"); + } const ts = new Date(); await db .update(chats) @@ -246,10 +278,11 @@ export async function setChatInstruction(params: { export async function softDeleteChat(id: string): Promise { const db = await initDb(); const ts = new Date(); + const ownerId = resolveTrustedOwnerId(); await db .update(chats) .set({ status: "deleted", updatedAt: ts, version: 0 }) - .where(eq(chats.id, id)); + .where(and(eq(chats.id, id), eq(chats.ownerId, ownerId))); return getChatById(id); } @@ -259,10 +292,11 @@ export async function updateChatTitle(params: { }): Promise { const db = await initDb(); const ts = new Date(); + const ownerId = resolveTrustedOwnerId(); await db .update(chats) .set({ title: params.title, updatedAt: ts }) - .where(eq(chats.id, params.chatId)); + .where(and(eq(chats.id, params.chatId), eq(chats.ownerId, ownerId))); return getChatById(params.chatId); } @@ -270,10 +304,16 @@ export async function listChatBranches(params: { chatId: string; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(); const rows = await db .select() .from(chatBranches) - .where(eq(chatBranches.chatId, params.chatId)) + .where( + and( + eq(chatBranches.chatId, params.chatId), + eq(chatBranches.ownerId, ownerId) + ) + ) .orderBy(desc(chatBranches.createdAt)); return rows.map(branchRowToDto); } @@ -288,10 +328,32 @@ export async function createChatBranch(params: { const db = await initDb(); const ts = new Date(); const id = uuidv4(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + + const chat = await db + .select({ id: chats.id }) + .from(chats) + .where(and(eq(chats.id, params.chatId), eq(chats.ownerId, ownerId))) + .limit(1); + if (!chat[0]) throw new Error("Chat не найден"); + if (params.parentBranchId) { + const parent = await db + .select({ id: chatBranches.id }) + .from(chatBranches) + .where( + and( + eq(chatBranches.id, params.parentBranchId), + eq(chatBranches.chatId, params.chatId), + eq(chatBranches.ownerId, ownerId) + ) + ) + .limit(1); + if (!parent[0]) throw new Error("Parent branch не найден"); + } await db.insert(chatBranches).values({ id, - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, title: params.title ?? null, createdAt: ts, @@ -308,7 +370,7 @@ export async function createChatBranch(params: { const rows = await db .select() .from(chatBranches) - .where(eq(chatBranches.id, id)); + .where(and(eq(chatBranches.id, id), eq(chatBranches.ownerId, ownerId))); if (!rows[0]) throw new Error("Не удалось создать ветку (внутренняя ошибка)."); return branchRowToDto(rows[0]); @@ -321,15 +383,28 @@ export async function updateChatBranchTitle(params: { }): Promise { const db = await initDb(); const ts = new Date(); + const ownerId = resolveTrustedOwnerId(); await db .update(chatBranches) .set({ title: params.title, updatedAt: ts }) - .where(and(eq(chatBranches.id, params.branchId), eq(chatBranches.chatId, params.chatId))); + .where( + and( + eq(chatBranches.id, params.branchId), + eq(chatBranches.chatId, params.chatId), + eq(chatBranches.ownerId, ownerId) + ) + ); const rows = await db .select() .from(chatBranches) - .where(and(eq(chatBranches.id, params.branchId), eq(chatBranches.chatId, params.chatId))) + .where( + and( + eq(chatBranches.id, params.branchId), + eq(chatBranches.chatId, params.chatId), + eq(chatBranches.ownerId, ownerId) + ) + ) .limit(1); return rows[0] ? branchRowToDto(rows[0]) : null; } @@ -358,18 +433,35 @@ export async function deleteChatBranch(params: { const ts = new Date(); const shouldSwitchActive = chat.activeBranchId === params.branchId; - await db.delete(chatBranches).where(eq(chatBranches.id, params.branchId)); + await db + .delete(chatBranches) + .where( + and( + eq(chatBranches.id, params.branchId), + eq(chatBranches.ownerId, resolveTrustedOwnerId()) + ) + ); if (shouldSwitchActive) { await db .update(chats) .set({ activeBranchId: fallback.id, updatedAt: ts }) - .where(eq(chats.id, params.chatId)); + .where( + and( + eq(chats.id, params.chatId), + eq(chats.ownerId, resolveTrustedOwnerId()) + ) + ); } else { await db .update(chats) .set({ updatedAt: ts }) - .where(eq(chats.id, params.chatId)); + .where( + and( + eq(chats.id, params.chatId), + eq(chats.ownerId, resolveTrustedOwnerId()) + ) + ); } const updatedChat = await getChatById(params.chatId); @@ -384,11 +476,24 @@ export async function activateBranch(params: { }): Promise { const db = await initDb(); const ts = new Date(); + const ownerId = resolveTrustedOwnerId(); + const branch = await db + .select({ id: chatBranches.id }) + .from(chatBranches) + .where( + and( + eq(chatBranches.id, params.branchId), + eq(chatBranches.chatId, params.chatId), + eq(chatBranches.ownerId, ownerId) + ) + ) + .limit(1); + if (!branch[0]) return null; await db .update(chats) .set({ activeBranchId: params.branchId, updatedAt: ts }) - .where(eq(chats.id, params.chatId)); + .where(and(eq(chats.id, params.chatId), eq(chats.ownerId, ownerId))); return getChatById(params.chatId); } @@ -403,6 +508,7 @@ export async function listChatMessages(params: { const where = [ eq(chatMessages.chatId, params.chatId), eq(chatMessages.branchId, params.branchId), + eq(chatMessages.ownerId, resolveTrustedOwnerId()), ]; if (typeof params.before === "number") { where.push(lt(chatMessages.createdAt, new Date(params.before))); @@ -435,10 +541,25 @@ export async function createChatMessage(params: { const db = await initDb(); const ts = nowMonotonicDate(); const id = uuidv4(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const branch = await db + .select({ id: chatBranches.id }) + .from(chatBranches) + .innerJoin(chats, eq(chats.id, chatBranches.chatId)) + .where( + and( + eq(chats.id, params.chatId), + eq(chats.ownerId, ownerId), + eq(chatBranches.id, params.branchId), + eq(chatBranches.ownerId, ownerId) + ) + ) + .limit(1); + if (!branch[0]) throw new Error("Chat branch не найден"); await db.insert(chatMessages).values({ id, - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, branchId: params.branchId, role: params.role, @@ -461,12 +582,12 @@ export async function createChatMessage(params: { lastMessagePreview: buildPreview(params.promptText ?? ""), updatedAt: ts, }) - .where(eq(chats.id, params.chatId)); + .where(and(eq(chats.id, params.chatId), eq(chats.ownerId, ownerId))); const rows = await db .select() .from(chatMessages) - .where(eq(chatMessages.id, id)); + .where(and(eq(chatMessages.id, id), eq(chatMessages.ownerId, ownerId))); if (!rows[0]) throw new Error("Не удалось создать сообщение (внутренняя ошибка)."); return messageRowToDto(rows[0]); @@ -483,6 +604,21 @@ export async function createAssistantMessageWithVariant(params: { }> { const db = await initDb(); const ts = nowMonotonicDate(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const branch = await db + .select({ id: chatBranches.id }) + .from(chatBranches) + .innerJoin(chats, eq(chats.id, chatBranches.chatId)) + .where( + and( + eq(chats.id, params.chatId), + eq(chats.ownerId, ownerId), + eq(chatBranches.id, params.branchId), + eq(chatBranches.ownerId, ownerId) + ) + ) + .limit(1); + if (!branch[0]) throw new Error("Chat branch не найден"); const assistantMessageId = uuidv4(); const variantId = uuidv4(); @@ -490,7 +626,7 @@ export async function createAssistantMessageWithVariant(params: { await db.transaction((tx) => { tx.insert(chatMessages).values({ id: assistantMessageId, - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, branchId: params.branchId, role: "assistant", @@ -504,7 +640,7 @@ export async function createAssistantMessageWithVariant(params: { tx.insert(messageVariants).values({ id: variantId, - ownerId: params.ownerId ?? "global", + ownerId, messageId: assistantMessageId, createdAt: ts, kind: "generation", @@ -531,6 +667,21 @@ export async function createImportedAssistantMessage(params: { }> { const db = await initDb(); const ts = nowMonotonicDate(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const branch = await db + .select({ id: chatBranches.id }) + .from(chatBranches) + .innerJoin(chats, eq(chats.id, chatBranches.chatId)) + .where( + and( + eq(chats.id, params.chatId), + eq(chats.ownerId, ownerId), + eq(chatBranches.id, params.branchId), + eq(chatBranches.ownerId, ownerId) + ) + ) + .limit(1); + if (!branch[0]) throw new Error("Chat branch не найден"); const assistantMessageId = uuidv4(); const variantId = uuidv4(); @@ -539,7 +690,7 @@ export async function createImportedAssistantMessage(params: { await db.transaction((tx) => { tx.insert(chatMessages).values({ id: assistantMessageId, - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, branchId: params.branchId, role: "assistant", @@ -556,7 +707,7 @@ export async function createImportedAssistantMessage(params: { tx.insert(messageVariants).values({ id: variantId, - ownerId: params.ownerId ?? "global", + ownerId, messageId: assistantMessageId, createdAt: ts, kind: "import", @@ -573,7 +724,7 @@ export async function createImportedAssistantMessage(params: { lastMessagePreview: buildPreview(text), updatedAt: ts, }) - .where(eq(chats.id, params.chatId)) + .where(and(eq(chats.id, params.chatId), eq(chats.ownerId, ownerId))) .run(); }); @@ -586,15 +737,26 @@ export async function updateAssistantText(params: { text: string; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(); await db .update(messageVariants) .set({ promptText: params.text }) - .where(eq(messageVariants.id, params.variantId)); + .where( + and( + eq(messageVariants.id, params.variantId), + eq(messageVariants.ownerId, ownerId) + ) + ); await db .update(chatMessages) .set({ promptText: params.text, activeVariantId: params.variantId }) - .where(eq(chatMessages.id, params.assistantMessageId)); + .where( + and( + eq(chatMessages.id, params.assistantMessageId), + eq(chatMessages.ownerId, ownerId) + ) + ); } export async function updateMessagePromptText(params: { @@ -602,13 +764,19 @@ export async function updateMessagePromptText(params: { text: string; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(); const rows = await db .select({ id: chatMessages.id, activeVariantId: chatMessages.activeVariantId, }) .from(chatMessages) - .where(eq(chatMessages.id, params.messageId)) + .where( + and( + eq(chatMessages.id, params.messageId), + eq(chatMessages.ownerId, ownerId) + ) + ) .limit(1); const row = rows[0]; @@ -618,13 +786,23 @@ export async function updateMessagePromptText(params: { await db .update(messageVariants) .set({ promptText: params.text }) - .where(eq(messageVariants.id, row.activeVariantId)); + .where( + and( + eq(messageVariants.id, row.activeVariantId), + eq(messageVariants.ownerId, ownerId) + ) + ); } await db .update(chatMessages) .set({ promptText: params.text }) - .where(eq(chatMessages.id, params.messageId)); + .where( + and( + eq(chatMessages.id, params.messageId), + eq(chatMessages.ownerId, ownerId) + ) + ); } export async function updateAssistantBlocks(params: { @@ -633,18 +811,29 @@ export async function updateAssistantBlocks(params: { blocks: unknown[]; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(); const blocksJson = safeJsonStringify(params.blocks ?? [], "[]"); await db .update(messageVariants) .set({ blocksJson }) - .where(eq(messageVariants.id, params.variantId)); + .where( + and( + eq(messageVariants.id, params.variantId), + eq(messageVariants.ownerId, ownerId) + ) + ); // Keep message cache in sync with the selected variant. await db .update(chatMessages) .set({ blocksJson, activeVariantId: params.variantId }) - .where(eq(chatMessages.id, params.assistantMessageId)); + .where( + and( + eq(chatMessages.id, params.assistantMessageId), + eq(chatMessages.ownerId, ownerId) + ) + ); } export async function listMessagesForPrompt(params: { @@ -660,7 +849,8 @@ export async function listMessagesForPrompt(params: { .where( and( eq(chatMessages.chatId, params.chatId), - eq(chatMessages.branchId, params.branchId) + eq(chatMessages.branchId, params.branchId), + eq(chatMessages.ownerId, resolveTrustedOwnerId()) ) ) .orderBy(desc(chatMessages.createdAt), desc(chatMessages.id)) @@ -683,11 +873,17 @@ export async function softDeleteChatMessage(params: { }): Promise<{ id: string }> { const db = await initDb(); const ts = new Date().toISOString(); + const ownerId = resolveTrustedOwnerId(); const rows = await db .select() .from(chatMessages) - .where(eq(chatMessages.id, params.messageId)) + .where( + and( + eq(chatMessages.id, params.messageId), + eq(chatMessages.ownerId, ownerId) + ) + ) .limit(1); const row = rows[0]; if (!row) throw new Error("Message не найден"); @@ -716,7 +912,12 @@ export async function softDeleteChatMessage(params: { blocksJson: "[]", metaJson: safeJsonStringify(nextMeta), }) - .where(eq(chatMessages.id, params.messageId)); + .where( + and( + eq(chatMessages.id, params.messageId), + eq(chatMessages.ownerId, ownerId) + ) + ); return { id: params.messageId }; } diff --git a/server/src/services/chat-core/entity-profile-media.test.ts b/server/src/services/chat-core/entity-profile-media.test.ts new file mode 100644 index 00000000..12e32740 --- /dev/null +++ b/server/src/services/chat-core/entity-profile-media.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runWithOwnerScope } from "@core/request-context/owner-scope-storage"; + +import { + readEntityProfileAvatarFile, + resolveEntityProfileMediaPath, + saveEntityProfileAvatarPng, +} from "./entity-profile-media"; + +const PNG = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, +]); +const FIRST_OWNER = "11111111-1111-4111-8111-111111111111"; +const SECOND_OWNER = "22222222-2222-4222-8222-222222222222"; + +let tempDir = ""; +let previousDataDir: string | undefined; + +describe("entity profile media ownership", () => { + beforeEach(async () => { + previousDataDir = process.env.DATA_DIR; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "entity-media-")); + process.env.DATA_DIR = tempDir; + }); + + afterEach(async () => { + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("stores avatars in the active owner namespace", async () => { + const url = await runWithOwnerScope(FIRST_OWNER, () => + saveEntityProfileAvatarPng(PNG) + ); + + expect(url).toMatch( + new RegExp(`^/media/images/entity-profiles/${FIRST_OWNER}/.+\\.png$`) + ); + await expect( + runWithOwnerScope(FIRST_OWNER, () => readEntityProfileAvatarFile(url)) + ).resolves.toMatchObject({ data: PNG, mediaType: "image/png" }); + }); + + test("does not resolve another owner's avatar or traversal input", async () => { + const url = await runWithOwnerScope(FIRST_OWNER, () => + saveEntityProfileAvatarPng(PNG) + ); + + expect( + runWithOwnerScope(SECOND_OWNER, () => + resolveEntityProfileMediaPath(url) + ) + ).toBeNull(); + expect( + runWithOwnerScope(FIRST_OWNER, () => + resolveEntityProfileMediaPath( + `/media/images/entity-profiles/${FIRST_OWNER}/%2e%2e` + ) + ) + ).toBeNull(); + }); + + test("keeps legacy unnamespaced avatars available only to global", async () => { + const legacyDir = path.join( + tempDir, + "media", + "images", + "entity-profiles" + ); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile(path.join(legacyDir, "legacy.png"), PNG); + const url = "/media/images/entity-profiles/legacy.png"; + + expect( + runWithOwnerScope("global", () => resolveEntityProfileMediaPath(url)) + ).toBe(path.join(legacyDir, "legacy.png")); + expect( + runWithOwnerScope(FIRST_OWNER, () => resolveEntityProfileMediaPath(url)) + ).toBeNull(); + }); +}); diff --git a/server/src/services/chat-core/entity-profile-media.ts b/server/src/services/chat-core/entity-profile-media.ts index 32099691..73a373fd 100644 --- a/server/src/services/chat-core/entity-profile-media.ts +++ b/server/src/services/chat-core/entity-profile-media.ts @@ -2,17 +2,37 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { assertSafeFilenameOrThrow, resolveSafePath } from "@core/files/safe-path"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { createDataPath } from "../../utils"; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -function getEntityProfileImagesDir(): string { +function getEntityProfileImagesRoot(): string { return createDataPath("media", "images", "entity-profiles"); } export function resolveEntityProfileMediaPath(avatarAssetId: string | null): string | null { - if (!avatarAssetId || !avatarAssetId.startsWith("/media/")) return null; - return createDataPath(avatarAssetId.replace(/^\/media\//, "media/")); + const prefix = "/media/images/entity-profiles/"; + if (!avatarAssetId?.startsWith(prefix)) return null; + + try { + const ownerId = resolveTrustedOwnerId(); + const segments = avatarAssetId.slice(prefix.length).split("/").filter(Boolean); + if (segments.length === 1 && ownerId === "global") { + return resolveSafePath(getEntityProfileImagesRoot(), segments[0]); + } + if (segments.length !== 2 || segments[0] !== ownerId) return null; + + const ownerFolder = resolveSafePath( + getEntityProfileImagesRoot(), + assertSafeFilenameOrThrow(ownerId) + ); + return resolveSafePath(ownerFolder, segments[1]); + } catch { + return null; + } } export async function readEntityProfileAvatarFile(avatarAssetId: string | null): Promise<{ @@ -41,10 +61,11 @@ export async function saveEntityProfileAvatarPng(fileBuffer: Buffer): Promise { const db = await initDb(); - await db.delete(entityProfiles).where(eq(entityProfiles.id, id)); + await db + .delete(entityProfiles) + .where( + and( + eq(entityProfiles.id, id), + eq(entityProfiles.ownerId, resolveTrustedOwnerId()) + ) + ); } diff --git a/server/src/services/chat-core/generation-control-repository.ts b/server/src/services/chat-core/generation-control-repository.ts index 67bcf17c..5dc36de9 100644 --- a/server/src/services/chat-core/generation-control-repository.ts +++ b/server/src/services/chat-core/generation-control-repository.ts @@ -1,7 +1,8 @@ import { and, eq } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; -import { generationRuntimeControl } from "../../db/schema"; +import { generationRuntimeControl, llmGenerations } from "../../db/schema"; export type GenerationControlStatus = "active" | "abort_requested"; @@ -31,6 +32,22 @@ function rowToRecord( }; } +function isOwnedGeneration(db: DbExecutor, generationId: string): boolean { + return Boolean( + db + .select({ id: llmGenerations.id }) + .from(llmGenerations) + .where( + and( + eq(llmGenerations.id, generationId), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()) + ) + ) + .limit(1) + .get() + ); +} + type UpsertGenerationControlLeaseParams = { generationId: string; runInstanceId: string; @@ -49,6 +66,7 @@ export function upsertGenerationControlLease( params: UpsertGenerationControlLeaseParams ): Promise | void { const run = (db: DbExecutor): void => { + if (!isOwnedGeneration(db, params.generationId)) return; db.insert(generationRuntimeControl) .values({ generationId: params.generationId, @@ -99,6 +117,7 @@ export function heartbeatGenerationControlLease( params: HeartbeatGenerationControlLeaseParams ): Promise | void { const run = (db: DbExecutor): void => { + if (!isOwnedGeneration(db, params.generationId)) return; db.update(generationRuntimeControl) .set({ heartbeatAt: params.heartbeatAt, @@ -125,6 +144,7 @@ export async function getGenerationControlByGenerationId( generationId: string ): Promise { const db = await initDb(); + if (!isOwnedGeneration(db, generationId)) return null; const rows = await db .select() .from(generationRuntimeControl) @@ -149,6 +169,7 @@ export function markGenerationAbortRequested( params: MarkGenerationAbortRequestedParams ): Promise | void { const run = (db: DbExecutor): void => { + if (!isOwnedGeneration(db, params.generationId)) return; db.update(generationRuntimeControl) .set({ status: "abort_requested", @@ -181,6 +202,7 @@ export function clearGenerationControlLease( params: ClearGenerationControlLeaseParams ): Promise | void { const run = (db: DbExecutor): void => { + if (!isOwnedGeneration(db, params.generationId)) return; db.delete(generationRuntimeControl) .where(eq(generationRuntimeControl.generationId, params.generationId)) .run(); diff --git a/server/src/services/chat-core/generations-repository.ts b/server/src/services/chat-core/generations-repository.ts index ff642d78..f50cab6f 100644 --- a/server/src/services/chat-core/generations-repository.ts +++ b/server/src/services/chat-core/generations-repository.ts @@ -7,8 +7,9 @@ import { safeJsonStringify, safeJsonStringifyForLog, } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; -import { llmGenerations } from "../../db/schema"; +import { chatBranches, chats, llmGenerations } from "../../db/schema"; export type GenerationStatus = "streaming" | "done" | "aborted" | "error"; @@ -30,10 +31,25 @@ export async function createGeneration(params: CreateGenerationParams): Promise< const db = await initDb(); const id = uuidv4(); const ts = new Date(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const branch = await db + .select({ id: chatBranches.id }) + .from(chatBranches) + .innerJoin(chats, eq(chats.id, chatBranches.chatId)) + .where( + and( + eq(chats.id, params.chatId), + eq(chats.ownerId, ownerId), + eq(chatBranches.id, params.branchId), + eq(chatBranches.ownerId, ownerId) + ) + ) + .limit(1); + if (!branch[0]) throw new Error("Chat branch не найден"); await db.insert(llmGenerations).values({ id, - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, branchId: params.branchId, messageId: params.messageId, @@ -103,7 +119,16 @@ function rowToWithDebugDto( export async function getGenerationById(id: string): Promise { const db = await initDb(); - const rows = await db.select().from(llmGenerations).where(eq(llmGenerations.id, id)).limit(1); + const rows = await db + .select() + .from(llmGenerations) + .where( + and( + eq(llmGenerations.id, id), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()) + ) + ) + .limit(1); return rows[0] ? rowToDto(rows[0]) : null; } @@ -119,6 +144,7 @@ export async function getActiveGenerationForChatBranch(params: { and( eq(llmGenerations.chatId, params.chatId), eq(llmGenerations.branchId, params.branchId), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()), eq(llmGenerations.status, "streaming") ) ) @@ -137,6 +163,7 @@ type FinishGenerationParams = { export function finishGeneration(params: FinishGenerationParams & { executor: DbExecutor }): void; export function finishGeneration(params: FinishGenerationParams): Promise; export function finishGeneration(params: FinishGenerationParams): Promise | void { + const ownerId = resolveTrustedOwnerId(); const run = (db: DbExecutor): void => { const finishedAt = new Date(); db @@ -146,7 +173,12 @@ export function finishGeneration(params: FinishGenerationParams): Promise finishedAt, error: params.error ?? null, }) - .where(eq(llmGenerations.id, params.id)) + .where( + and( + eq(llmGenerations.id, params.id), + eq(llmGenerations.ownerId, ownerId) + ) + ) .run(); }; @@ -172,7 +204,15 @@ export async function updateGenerationPromptData(params: { : safeJsonStringifyForLog(params.promptSnapshot, { maxChars: 60_000, fallback: "{}" }); } if (Object.keys(set).length === 0) return; - await db.update(llmGenerations).set(set).where(eq(llmGenerations.id, params.id)); + await db + .update(llmGenerations) + .set(set) + .where( + and( + eq(llmGenerations.id, params.id), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()) + ) + ); } export async function getGenerationByIdWithDebug( @@ -182,7 +222,12 @@ export async function getGenerationByIdWithDebug( const rows = await db .select() .from(llmGenerations) - .where(eq(llmGenerations.id, id)) + .where( + and( + eq(llmGenerations.id, id), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); return rows[0] ? rowToWithDebugDto(rows[0]) : null; } @@ -203,6 +248,7 @@ export function updateGenerationRunReports( export function updateGenerationRunReports( params: UpdateGenerationRunReportsParams ): Promise | void { + const ownerId = resolveTrustedOwnerId(); const run = (db: DbExecutor): void => { const set: Partial = {}; @@ -227,7 +273,16 @@ export function updateGenerationRunReports( } if (Object.keys(set).length === 0) return; - db.update(llmGenerations).set(set).where(eq(llmGenerations.id, params.id)).run(); + db + .update(llmGenerations) + .set(set) + .where( + and( + eq(llmGenerations.id, params.id), + eq(llmGenerations.ownerId, ownerId) + ) + ) + .run(); }; if (params.executor) { @@ -254,7 +309,12 @@ export async function updateGenerationDebugJson(params: { fallback: "{}", }), }) - .where(eq(llmGenerations.id, params.id)); + .where( + and( + eq(llmGenerations.id, params.id), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()) + ) + ); } export async function getLatestGenerationByChatBranchWithDebug(params: { @@ -268,7 +328,8 @@ export async function getLatestGenerationByChatBranchWithDebug(params: { .where( and( eq(llmGenerations.chatId, params.chatId), - eq(llmGenerations.branchId, params.branchId) + eq(llmGenerations.branchId, params.branchId), + eq(llmGenerations.ownerId, resolveTrustedOwnerId()) ) ) .orderBy(desc(llmGenerations.startedAt), desc(llmGenerations.id)) diff --git a/server/src/services/chat-core/instructions-repository.ts b/server/src/services/chat-core/instructions-repository.ts index df13275a..613d6bea 100644 --- a/server/src/services/chat-core/instructions-repository.ts +++ b/server/src/services/chat-core/instructions-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, desc, eq } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { chats, instructions } from "../../db/schema"; @@ -81,7 +82,7 @@ export async function listInstructions(params: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const rows = await db .select() .from(instructions) @@ -97,7 +98,12 @@ export async function getInstructionById( const rows = await db .select() .from(instructions) - .where(eq(instructions.id, id)); + .where( + and( + eq(instructions.id, id), + eq(instructions.ownerId, resolveTrustedOwnerId()) + ) + ); return rows[0] ? rowToDto(rows[0]) : null; } @@ -124,7 +130,7 @@ export async function createInstruction(params: { await db.insert(instructions).values({ id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), name: params.name, kind: params.kind, engine: params.engine ?? "liquidjs", @@ -144,7 +150,7 @@ export async function createInstruction(params: { if (params.kind === "basic") { return { id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), name: params.name, kind: "basic", engine: params.engine ?? "liquidjs", @@ -157,7 +163,7 @@ export async function createInstruction(params: { return { id, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), name: params.name, kind: "st_base", engine: params.engine ?? "liquidjs", @@ -207,13 +213,25 @@ export async function updateInstruction(params: { await db .update(instructions) .set(set) - .where(eq(instructions.id, params.id)); + .where( + and( + eq(instructions.id, params.id), + eq(instructions.ownerId, resolveTrustedOwnerId()) + ) + ); return getInstructionById(params.id); } export async function deleteInstruction(id: string): Promise { const db = await initDb(); - await db.delete(instructions).where(eq(instructions.id, id)); + await db + .delete(instructions) + .where( + and( + eq(instructions.id, id), + eq(instructions.ownerId, resolveTrustedOwnerId()) + ) + ); } export async function pickInstructionForChat(params: { @@ -221,7 +239,7 @@ export async function pickInstructionForChat(params: { chatId: string; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const chatRows = await db .select({ instructionId: chats.instructionId }) diff --git a/server/src/services/chat-core/prompt-template-context.ts b/server/src/services/chat-core/prompt-template-context.ts index 44026801..6b074170 100644 --- a/server/src/services/chat-core/prompt-template-context.ts +++ b/server/src/services/chat-core/prompt-template-context.ts @@ -1,3 +1,4 @@ +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { listProjectedPromptMessages } from "../chat-entry-parts/prompt-history"; import { resolveWorldInfoRuntimeForChat } from "../world-info/world-info-runtime"; @@ -492,7 +493,7 @@ export async function buildInstructionRenderContext(params: { excludeEntryIds?: string[]; worldInfo?: InstructionWorldInfoInput; }): Promise { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); // Default “empty” context: should never break Liquid rendering. const base: InstructionRenderContext = { diff --git a/server/src/services/chat-core/user-persons-repository.ts b/server/src/services/chat-core/user-persons-repository.ts index 4f8d4eaf..ef0cefea 100644 --- a/server/src/services/chat-core/user-persons-repository.ts +++ b/server/src/services/chat-core/user-persons-repository.ts @@ -1,8 +1,9 @@ import { randomUUID as uuidv4 } from "node:crypto"; -import { asc, desc, eq } from "drizzle-orm"; +import { and, asc, desc, eq } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { userPersons, userPersonsSettings } from "../../db/schema"; @@ -68,19 +69,25 @@ export async function listUserPersons(params?: { ownerId?: string; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params?.ownerId); const rows = await db .select() .from(userPersons) - .where(eq(userPersons.ownerId, params?.ownerId ?? "global")) + .where(eq(userPersons.ownerId, ownerId)) .orderBy(asc(userPersons.name), desc(userPersons.updatedAt)); return rows.map(rowToDto); } export async function getUserPersonById( - id: string + id: string, + params?: { ownerId?: string } ): Promise { const db = await initDb(); - const rows = await db.select().from(userPersons).where(eq(userPersons.id, id)); + const ownerId = resolveTrustedOwnerId(params?.ownerId); + const rows = await db + .select() + .from(userPersons) + .where(and(eq(userPersons.id, id), eq(userPersons.ownerId, ownerId))); return rows[0] ? rowToDto(rows[0]) : null; } @@ -97,6 +104,7 @@ export async function createUserPerson(params: { updatedAt?: Date; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params.ownerId); const now = new Date(); const id = typeof params.id === "string" && params.id.length > 0 ? params.id : uuidv4(); @@ -105,7 +113,7 @@ export async function createUserPerson(params: { await db.insert(userPersons).values({ id, - ownerId: params.ownerId ?? "global", + ownerId, name: params.name, prefix: typeof params.prefix === "string" ? params.prefix : null, avatarUrl: typeof params.avatarUrl === "string" ? params.avatarUrl : null, @@ -122,11 +130,11 @@ export async function createUserPerson(params: { updatedAt, }); - const created = await getUserPersonById(id); + const created = await getUserPersonById(id, { ownerId }); if (!created) { return { id, - ownerId: params.ownerId ?? "global", + ownerId, name: params.name, prefix: params.prefix, avatarUrl: params.avatarUrl, @@ -142,6 +150,7 @@ export async function createUserPerson(params: { export async function updateUserPerson(params: { id: string; + ownerId?: string; name?: string; prefix?: string; avatarUrl?: string; @@ -151,7 +160,8 @@ export async function updateUserPerson(params: { updatedAt?: Date; }): Promise { const db = await initDb(); - const current = await getUserPersonById(params.id); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const current = await getUserPersonById(params.id, { ownerId }); if (!current) return null; const set: Partial = { @@ -172,20 +182,29 @@ export async function updateUserPerson(params: { if (typeof params.contentTypeExtended !== "undefined") set.contentTypeExtendedJson = safeJsonStringify(params.contentTypeExtended, "[]"); - await db.update(userPersons).set(set).where(eq(userPersons.id, params.id)); - return getUserPersonById(params.id); + await db + .update(userPersons) + .set(set) + .where(and(eq(userPersons.id, params.id), eq(userPersons.ownerId, ownerId))); + return getUserPersonById(params.id, { ownerId }); } -export async function deleteUserPerson(id: string): Promise { +export async function deleteUserPerson( + id: string, + params?: { ownerId?: string } +): Promise { const db = await initDb(); - await db.delete(userPersons).where(eq(userPersons.id, id)); + const ownerId = resolveTrustedOwnerId(params?.ownerId); + await db + .delete(userPersons) + .where(and(eq(userPersons.id, id), eq(userPersons.ownerId, ownerId))); } export async function getUserPersonsSettings(params?: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params?.ownerId); const rows = await db .select() .from(userPersonsSettings) @@ -231,7 +250,7 @@ export async function updateUserPersonsSettings(params: { sortType?: string | null; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getUserPersonsSettings({ ownerId }); const nextSelectedId = @@ -242,6 +261,12 @@ export async function updateUserPersonsSettings(params: { typeof params.pageSize === "undefined" ? current.pageSize : params.pageSize; const nextSortType = typeof params.sortType === "undefined" ? current.sortType ?? null : params.sortType; + if ( + nextSelectedId && + !(await getUserPersonById(nextSelectedId, { ownerId })) + ) { + throw new Error("User person не найден"); + } await db .update(userPersonsSettings) diff --git a/server/src/services/chat-entry-parts/branch-turn-repository.ts b/server/src/services/chat-entry-parts/branch-turn-repository.ts index 29ce0f9b..d346f4fb 100644 --- a/server/src/services/chat-entry-parts/branch-turn-repository.ts +++ b/server/src/services/chat-entry-parts/branch-turn-repository.ts @@ -1,5 +1,6 @@ -import { eq, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; import { chatBranches } from "../../db/schema"; @@ -12,7 +13,12 @@ function readBranchCurrentTurn(db: DbExecutor, branchId: string): number { const rows = db .select({ currentTurn: chatBranches.currentTurn }) .from(chatBranches) - .where(eq(chatBranches.id, branchId)) + .where( + and( + eq(chatBranches.id, branchId), + eq(chatBranches.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1) .all(); return rows[0]?.currentTurn ?? 0; @@ -35,7 +41,12 @@ export function incrementBranchTurn(params: BranchTurnParams): Promise | db .update(chatBranches) .set({ currentTurn: sql`${chatBranches.currentTurn} + 1` }) - .where(eq(chatBranches.id, params.branchId)) + .where( + and( + eq(chatBranches.id, params.branchId), + eq(chatBranches.ownerId, resolveTrustedOwnerId()) + ) + ) .run(); return readBranchCurrentTurn(db, params.branchId); diff --git a/server/src/services/chat-entry-parts/entries-owner.integration.test.ts b/server/src/services/chat-entry-parts/entries-owner.integration.test.ts new file mode 100644 index 00000000..f65ec8a9 --- /dev/null +++ b/server/src/services/chat-entry-parts/entries-owner.integration.test.ts @@ -0,0 +1,124 @@ +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runWithOwnerScope } from "../../core/request-context/owner-scope-storage"; +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { + chatBranches, + chatEntries, + chats, + entityProfiles, +} from "../../db/schema"; +import { + createTempDataDir, + removeTempDataDir, +} from "../../e2e/helpers/tmp-dir"; + +import { softDeleteEntries } from "./entries-repository"; + +let tempDir = ""; + +async function seedOwnerEntry(ownerId: string): Promise { + const db = await initDb(); + const now = new Date("2026-07-27T10:00:00.000Z"); + const entityId = `entity-${ownerId}`; + const chatId = `chat-${ownerId}`; + const branchId = `branch-${ownerId}`; + const entryId = `entry-${ownerId}`; + + await db.insert(entityProfiles).values({ + id: entityId, + ownerId, + name: ownerId, + kind: "CharSpec", + specJson: "{}", + metaJson: null, + isFavorite: false, + createdAt: now, + updatedAt: now, + avatarAssetId: null, + }); + await db.insert(chats).values({ + id: chatId, + ownerId, + entityProfileId: entityId, + title: ownerId, + activeBranchId: branchId, + instructionId: null, + status: "active", + createdAt: now, + updatedAt: now, + lastMessageAt: null, + lastMessagePreview: null, + version: 0, + metaJson: null, + originChatId: null, + originBranchId: null, + originMessageId: null, + }); + await db.insert(chatBranches).values({ + id: branchId, + ownerId, + chatId, + title: null, + createdAt: now, + updatedAt: now, + parentBranchId: null, + forkedFromMessageId: null, + forkedFromVariantId: null, + metaJson: null, + currentTurn: 0, + }); + await db.insert(chatEntries).values({ + entryId, + ownerId, + chatId, + branchId, + role: "user", + createdAt: now, + activeVariantId: `variant-${ownerId}`, + softDeleted: false, + softDeletedAt: null, + softDeletedBy: null, + metaJson: null, + }); + + return entryId; +} + +describe("chat entry owner isolation", () => { + beforeEach(async () => { + resetDbForTests(); + tempDir = await createTempDataDir("entries-owner-"); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await removeTempDataDir(tempDir); + }); + + test("bulk soft delete only mutates entries owned by the active account", async () => { + const firstEntryId = await seedOwnerEntry("owner-a"); + const secondEntryId = await seedOwnerEntry("owner-b"); + + const changed = await runWithOwnerScope("owner-b", () => + softDeleteEntries({ + entryIds: [firstEntryId, secondEntryId], + by: "user", + }) + ); + + expect(changed).toEqual([secondEntryId]); + const rows = await (await initDb()).select().from(chatEntries); + expect( + Object.fromEntries(rows.map((row) => [row.entryId, row.softDeleted])) + ).toEqual({ + [firstEntryId]: false, + [secondEntryId]: true, + }); + }); +}); diff --git a/server/src/services/chat-entry-parts/entries-repository.ts b/server/src/services/chat-entry-parts/entries-repository.ts index 8e6c66f5..479b4a93 100644 --- a/server/src/services/chat-entry-parts/entries-repository.ts +++ b/server/src/services/chat-entry-parts/entries-repository.ts @@ -3,8 +3,10 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { HttpError } from "../../core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; -import { chatEntries, entryVariants } from "../../db/schema"; +import { chatBranches, chatEntries, chats, entryVariants } from "../../db/schema"; import { listPartsForVariants } from "./parts-repository"; @@ -74,7 +76,24 @@ export function createEntryWithVariant( params: CreateEntryWithVariantParams ): Promise<{ entry: Entry; variant: Variant }> | { entry: Entry; variant: Variant } { const run = (db: DbExecutor): { entry: Entry; variant: Variant } => { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); + const parent = db + .select({ chatId: chats.id }) + .from(chats) + .innerJoin( + chatBranches, + and( + eq(chatBranches.id, params.branchId), + eq(chatBranches.chatId, chats.id), + eq(chatBranches.ownerId, ownerId) + ) + ) + .where(and(eq(chats.id, params.chatId), eq(chats.ownerId, ownerId))) + .limit(1) + .get(); + if (!parent) { + throw new HttpError(404, "Chat branch not found", "NOT_FOUND"); + } const entryId = uuidv4(); const variantId = uuidv4(); @@ -142,7 +161,11 @@ export async function listEntriesPage(params: { includeSoftDeleted?: boolean; }): Promise { const db = await initDb(); - const where = [eq(chatEntries.chatId, params.chatId), eq(chatEntries.branchId, params.branchId)]; + const where = [ + eq(chatEntries.ownerId, resolveTrustedOwnerId()), + eq(chatEntries.chatId, params.chatId), + eq(chatEntries.branchId, params.branchId), + ]; if (!params.includeSoftDeleted) { where.push(eq(chatEntries.softDeleted, false)); } @@ -209,7 +232,16 @@ export async function listEntries(params: { export async function getEntryById(params: { entryId: string }): Promise { const db = await initDb(); - const rows = await db.select().from(chatEntries).where(eq(chatEntries.entryId, params.entryId)).limit(1); + const rows = await db + .select() + .from(chatEntries) + .where( + and( + eq(chatEntries.entryId, params.entryId), + eq(chatEntries.ownerId, resolveTrustedOwnerId()) + ) + ) + .limit(1); const row = rows[0]; return row ? entryRowToDomain(row) : null; } @@ -221,7 +253,12 @@ export async function getActiveVariantWithParts(params: { const rows = await db .select() .from(entryVariants) - .where(eq(entryVariants.variantId, params.entry.activeVariantId)) + .where( + and( + eq(entryVariants.variantId, params.entry.activeVariantId), + eq(entryVariants.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const row = rows[0]; if (!row) return null; @@ -262,7 +299,12 @@ export async function listEntriesWithActiveVariants(params: { const variantRows = await db .select() .from(entryVariants) - .where(inArray(entryVariants.variantId, variantIds)); + .where( + and( + inArray(entryVariants.variantId, variantIds), + eq(entryVariants.ownerId, resolveTrustedOwnerId()) + ) + ); const partsMap = await listPartsForVariants({ variantIds }); @@ -316,7 +358,12 @@ export async function listEntriesWithActiveVariantsPage(params: { const variantRows = await db .select() .from(entryVariants) - .where(inArray(entryVariants.variantId, variantIds)); + .where( + and( + inArray(entryVariants.variantId, variantIds), + eq(entryVariants.ownerId, resolveTrustedOwnerId()) + ) + ); const partsMap = await listPartsForVariants({ variantIds }); const variantById = new Map(); @@ -348,6 +395,7 @@ export async function getLatestSelectedPersonaIdForChatBranch(params: { .from(chatEntries) .where( and( + eq(chatEntries.ownerId, resolveTrustedOwnerId()), eq(chatEntries.chatId, params.chatId), eq(chatEntries.branchId, params.branchId), eq(chatEntries.role, "user"), @@ -373,7 +421,12 @@ export async function softDeleteEntry(params: { entryId: string; by: "user" | "a softDeletedAt: new Date(), softDeletedBy: params.by, }) - .where(eq(chatEntries.entryId, params.entryId)); + .where( + and( + eq(chatEntries.entryId, params.entryId), + eq(chatEntries.ownerId, resolveTrustedOwnerId()) + ) + ); } export async function softDeleteEntries(params: { @@ -387,7 +440,12 @@ export async function softDeleteEntries(params: { const rows = await db .select({ entryId: chatEntries.entryId }) .from(chatEntries) - .where(inArray(chatEntries.entryId, dedupedEntryIds)); + .where( + and( + inArray(chatEntries.entryId, dedupedEntryIds), + eq(chatEntries.ownerId, resolveTrustedOwnerId()) + ) + ); const foundEntryIds = rows.map((row) => row.entryId); if (foundEntryIds.length === 0) return []; @@ -399,7 +457,12 @@ export async function softDeleteEntries(params: { softDeletedAt: new Date(), softDeletedBy: params.by, }) - .where(inArray(chatEntries.entryId, foundEntryIds)); + .where( + and( + inArray(chatEntries.entryId, foundEntryIds), + eq(chatEntries.ownerId, resolveTrustedOwnerId()) + ) + ); return foundEntryIds; } @@ -419,7 +482,12 @@ export function updateEntryMeta(params: UpdateEntryMetaParams): Promise | .set({ metaJson: params.meta === null ? null : safeJsonStringify(params.meta), }) - .where(eq(chatEntries.entryId, params.entryId)) + .where( + and( + eq(chatEntries.entryId, params.entryId), + eq(chatEntries.ownerId, resolveTrustedOwnerId()) + ) + ) .run(); }; @@ -440,6 +508,7 @@ export async function hasActiveUserEntriesInBranch(params: { .from(chatEntries) .where( and( + eq(chatEntries.ownerId, resolveTrustedOwnerId()), eq(chatEntries.chatId, params.chatId), eq(chatEntries.branchId, params.branchId), eq(chatEntries.role, "user"), diff --git a/server/src/services/chat-entry-parts/parts-repository.ts b/server/src/services/chat-entry-parts/parts-repository.ts index a925c167..bd713ac9 100644 --- a/server/src/services/chat-entry-parts/parts-repository.ts +++ b/server/src/services/chat-entry-parts/parts-repository.ts @@ -3,6 +3,8 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, inArray } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { HttpError } from "../../core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; import { entryVariants, variantParts } from "../../db/schema"; @@ -69,7 +71,12 @@ export async function listPartsForVariants(params: { const rows = await db .select() .from(variantParts) - .where(inArray(variantParts.variantId, params.variantIds)); + .where( + and( + inArray(variantParts.variantId, params.variantIds), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ); const map = new Map(); for (const r of rows) { @@ -108,7 +115,21 @@ export function createPart(params: CreatePartParams & { executor: DbExecutor }): export function createPart(params: CreatePartParams): Promise; export function createPart(params: CreatePartParams): Promise | Part { const run = (db: DbExecutor): Part => { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); + const parent = db + .select({ variantId: entryVariants.variantId }) + .from(entryVariants) + .where( + and( + eq(entryVariants.variantId, params.variantId), + eq(entryVariants.ownerId, ownerId) + ) + ) + .limit(1) + .get(); + if (!parent) { + throw new HttpError(404, "Entry variant not found", "NOT_FOUND"); + } const partId = uuidv4(); const payloadJson = safeJsonStringify({ @@ -192,7 +213,12 @@ export async function updatePartPayloadText(params: { const rows = await db .select({ payloadJson: variantParts.payloadJson }) .from(variantParts) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const existing = safeJsonParse(rows[0]?.payloadJson, { format: "text", @@ -207,7 +233,12 @@ export async function updatePartPayloadText(params: { await db .update(variantParts) .set({ payloadJson: safeJsonStringify(payload) }) - .where(eq(variantParts.partId, params.partId)); + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ); } export async function getPartPayloadTextById(params: { @@ -217,7 +248,12 @@ export async function getPartPayloadTextById(params: { const rows = await db .select({ payloadJson: variantParts.payloadJson }) .from(variantParts) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const existing = safeJsonParse(rows[0]?.payloadJson, null); @@ -233,7 +269,12 @@ export async function getPartById(params: { const rows = await db .select() .from(variantParts) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const row = rows[0]; return row ? partRowToDomain(row) : null; @@ -254,7 +295,12 @@ export async function getPartWithVariantContextById(params: { const partRows = await db .select() .from(variantParts) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const partRow = partRows[0]; if (!partRow) return null; @@ -265,7 +311,12 @@ export async function getPartWithVariantContextById(params: { entryId: entryVariants.entryId, }) .from(entryVariants) - .where(eq(entryVariants.variantId, partRow.variantId)) + .where( + and( + eq(entryVariants.variantId, partRow.variantId), + eq(entryVariants.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); const variant = variantRows[0]; if (!variant) return null; @@ -297,7 +348,12 @@ export function applyManualEditToPart( const rows = db .select({ payloadJson: variantParts.payloadJson }) .from(variantParts) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1) .all(); @@ -321,7 +377,12 @@ export function applyManualEditToPart( model: null, requestId: params.requestId ?? null, }) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .run(); }; @@ -349,7 +410,12 @@ export function softDeletePart(params: SoftDeletePartParams): Promise | vo softDeletedAt: new Date(), softDeletedBy: params.by, }) - .where(eq(variantParts.partId, params.partId)) + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .run(); }; @@ -368,7 +434,12 @@ export async function updatePartReplacesPartId(params: { await db .update(variantParts) .set({ replacesPartId: params.replacesPartId }) - .where(eq(variantParts.partId, params.partId)); + .where( + and( + eq(variantParts.partId, params.partId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ); } export type PartMutableBatchPatch = { @@ -407,7 +478,13 @@ export async function applyPartMutableBatchPatches(params: { for (const partId of params.deletePartIds ?? []) { tx .delete(variantParts) - .where(and(eq(variantParts.partId, partId), eq(variantParts.variantId, params.variantId))) + .where( + and( + eq(variantParts.partId, partId), + eq(variantParts.variantId, params.variantId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) + ) + ) .run(); } @@ -435,7 +512,8 @@ export async function applyPartMutableBatchPatches(params: { .where( and( eq(variantParts.partId, patch.partId), - eq(variantParts.variantId, params.variantId) + eq(variantParts.variantId, params.variantId), + eq(variantParts.ownerId, resolveTrustedOwnerId()) ) ) .run(); diff --git a/server/src/services/chat-entry-parts/variants-repository.ts b/server/src/services/chat-entry-parts/variants-repository.ts index 8c614dad..3ec8ab41 100644 --- a/server/src/services/chat-entry-parts/variants-repository.ts +++ b/server/src/services/chat-entry-parts/variants-repository.ts @@ -3,6 +3,8 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, inArray } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { HttpError } from "../../core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { type DbExecutor, initDb } from "../../db/client"; import { chatEntries, entryVariants } from "../../db/schema"; @@ -33,7 +35,21 @@ export function createVariant(params: CreateVariantParams & { executor: DbExecut export function createVariant(params: CreateVariantParams): Promise; export function createVariant(params: CreateVariantParams): Promise | Variant { const run = (db: DbExecutor): Variant => { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); + const parent = db + .select({ entryId: chatEntries.entryId }) + .from(chatEntries) + .where( + and( + eq(chatEntries.entryId, params.entryId), + eq(chatEntries.ownerId, ownerId) + ) + ) + .limit(1) + .get(); + if (!parent) { + throw new HttpError(404, "Chat entry not found", "NOT_FOUND"); + } const variantId = uuidv4(); const createdAtMs = Date.now(); @@ -71,7 +87,12 @@ export async function listVariantsByIds(params: { variantIds: string[] }): Promi const rows = await db .select() .from(entryVariants) - .where(inArray(entryVariants.variantId, params.variantIds)); + .where( + and( + inArray(entryVariants.variantId, params.variantIds), + eq(entryVariants.ownerId, resolveTrustedOwnerId()) + ) + ); const map = new Map(); for (const r of rows) { @@ -90,10 +111,31 @@ export function selectActiveVariant(params: SelectActiveVariantParams & { execut export function selectActiveVariant(params: SelectActiveVariantParams): Promise; export function selectActiveVariant(params: SelectActiveVariantParams): Promise | void { const run = (db: DbExecutor): void => { + const ownerId = resolveTrustedOwnerId(); + const variant = db + .select({ variantId: entryVariants.variantId }) + .from(entryVariants) + .where( + and( + eq(entryVariants.variantId, params.variantId), + eq(entryVariants.entryId, params.entryId), + eq(entryVariants.ownerId, ownerId) + ) + ) + .limit(1) + .get(); + if (!variant) { + throw new HttpError(404, "Entry variant not found", "NOT_FOUND"); + } db .update(chatEntries) .set({ activeVariantId: params.variantId }) - .where(eq(chatEntries.entryId, params.entryId)) + .where( + and( + eq(chatEntries.entryId, params.entryId), + eq(chatEntries.ownerId, ownerId) + ) + ) .run(); }; @@ -106,7 +148,15 @@ export function selectActiveVariant(params: SelectActiveVariantParams): Promise< export async function listEntryVariants(params: { entryId: string }): Promise { const db = await initDb(); - const rows = await db.select().from(entryVariants).where(eq(entryVariants.entryId, params.entryId)); + const rows = await db + .select() + .from(entryVariants) + .where( + and( + eq(entryVariants.entryId, params.entryId), + eq(entryVariants.ownerId, resolveTrustedOwnerId()) + ) + ); const variantIds = rows.map((r) => r.variantId); const partsMap = await listPartsForVariants({ variantIds }); return rows @@ -127,7 +177,12 @@ export function updateVariantDerived(params: UpdateVariantDerivedParams): Promis db .update(entryVariants) .set({ derivedJson: params.derived === null ? null : safeJsonStringify(params.derived) }) - .where(eq(entryVariants.variantId, params.variantId)) + .where( + and( + eq(entryVariants.variantId, params.variantId), + eq(entryVariants.ownerId, resolveTrustedOwnerId()) + ) + ) .run(); }; @@ -143,7 +198,12 @@ export async function getVariantById(params: { variantId: string }): Promise { + const ownerId = resolveTrustedOwnerId(); const entryRows = db .select({ activeVariantId: chatEntries.activeVariantId }) .from(chatEntries) - .where(eq(chatEntries.entryId, params.entryId)) + .where( + and( + eq(chatEntries.entryId, params.entryId), + eq(chatEntries.ownerId, ownerId) + ) + ) .limit(1) .all(); const entryRow = entryRows[0]; @@ -181,7 +247,12 @@ export function deleteVariant( const variantRows = db .select({ variantId: entryVariants.variantId, createdAt: entryVariants.createdAt }) .from(entryVariants) - .where(eq(entryVariants.entryId, params.entryId)) + .where( + and( + eq(entryVariants.entryId, params.entryId), + eq(entryVariants.ownerId, ownerId) + ) + ) .all(); const variantsSorted = variantRows @@ -211,13 +282,24 @@ export function deleteVariant( db .update(chatEntries) .set({ activeVariantId: nextActiveVariantId }) - .where(eq(chatEntries.entryId, params.entryId)) + .where( + and( + eq(chatEntries.entryId, params.entryId), + eq(chatEntries.ownerId, ownerId) + ) + ) .run(); } db .delete(entryVariants) - .where(and(eq(entryVariants.entryId, params.entryId), eq(entryVariants.variantId, params.variantId))) + .where( + and( + eq(entryVariants.entryId, params.entryId), + eq(entryVariants.variantId, params.variantId), + eq(entryVariants.ownerId, ownerId) + ) + ) .run(); return { diff --git a/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.test.ts b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.test.ts new file mode 100644 index 00000000..f513f456 --- /dev/null +++ b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; + +import { ProfileSessionArtifactStore } from "./profile-session-artifact-store"; + +describe("ProfileSessionArtifactStore resource limits", () => { + test("rejects oversized values before touching persistence", async () => { + await expect( + ProfileSessionArtifactStore.upsert({ + ownerId: "global", + sessionKey: "session", + chatId: "chat", + branchId: "branch", + profile: null, + tag: "oversized", + format: "text", + semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + value: "x".repeat(256 * 1024 + 1), + }) + ).rejects.toMatchObject({ code: "ARTIFACT_VALUE_TOO_LARGE" }); + }); +}); diff --git a/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts index 2ae7561d..27e01850 100644 --- a/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts +++ b/server/src/services/chat-generation-v3/artifacts/profile-session-artifact-store.ts @@ -3,8 +3,14 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, inArray } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../../core/request-context/owner-scope-storage"; import { initDb } from "../../../db/client"; import { operationProfileSessionArtifacts } from "../../../db/schema"; +import { + assertArtifactHistoryItemLimit, + assertArtifactHistoryWithinLimits, + assertArtifactValueWithinLimits, +} from "../../operations/operation-resource-limits"; import type { ArtifactValue } from "../contracts"; import type { OperationActivationState } from "../operations/operation-activation-intervals"; @@ -45,12 +51,13 @@ export class ProfileSessionArtifactStore { sessionKey: string; }): Promise> { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params.ownerId); const rows = await db .select() .from(operationProfileSessionArtifacts) .where( and( - eq(operationProfileSessionArtifacts.ownerId, params.ownerId), + eq(operationProfileSessionArtifacts.ownerId, ownerId), eq(operationProfileSessionArtifacts.sessionKey, params.sessionKey) ) ); @@ -82,13 +89,14 @@ export class ProfileSessionArtifactStore { const opIds = Array.from(new Set(params.opIds.filter((opId) => opId.trim().length > 0))); if (opIds.length === 0) return {}; const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params.ownerId); const tagByOpId = new Map(opIds.map((opId) => [opId, buildOperationActivationStateTag(opId)])); const rows = await db .select() .from(operationProfileSessionArtifacts) .where( and( - eq(operationProfileSessionArtifacts.ownerId, params.ownerId), + eq(operationProfileSessionArtifacts.ownerId, ownerId), eq(operationProfileSessionArtifacts.sessionKey, params.sessionKey), inArray(operationProfileSessionArtifacts.tag, Array.from(tagByOpId.values())) ) @@ -120,12 +128,16 @@ export class ProfileSessionArtifactStore { }; value: unknown; }): Promise { + assertArtifactValueWithinLimits(params.value); + assertArtifactHistoryItemLimit(params.history.maxItems); const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params.ownerId); const existingRows = await db .select() .from(operationProfileSessionArtifacts) .where( and( + eq(operationProfileSessionArtifacts.ownerId, ownerId), eq(operationProfileSessionArtifacts.sessionKey, params.sessionKey), eq(operationProfileSessionArtifacts.tag, params.tag) ) @@ -141,6 +153,7 @@ export class ProfileSessionArtifactStore { params.history.maxItems ) : []; + assertArtifactHistoryWithinLimits(history); if (existing) { await db @@ -152,11 +165,16 @@ export class ProfileSessionArtifactStore { historyJson: safeJsonStringify(history, "[]"), updatedAt: now, }) - .where(eq(operationProfileSessionArtifacts.id, existing.id)); + .where( + and( + eq(operationProfileSessionArtifacts.id, existing.id), + eq(operationProfileSessionArtifacts.ownerId, ownerId) + ) + ); } else { await db.insert(operationProfileSessionArtifacts).values({ id: uuidv4(), - ownerId: params.ownerId, + ownerId, sessionKey: params.sessionKey, chatId: params.chatId, branchId: params.branchId, @@ -192,12 +210,14 @@ export class ProfileSessionArtifactStore { state: OperationActivationState; }): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params.ownerId); const tag = buildOperationActivationStateTag(params.opId); const existingRows = await db .select() .from(operationProfileSessionArtifacts) .where( and( + eq(operationProfileSessionArtifacts.ownerId, ownerId), eq(operationProfileSessionArtifacts.sessionKey, params.sessionKey), eq(operationProfileSessionArtifacts.tag, tag) ) @@ -212,13 +232,18 @@ export class ProfileSessionArtifactStore { valueJson: safeJsonStringify(params.state, "{}"), updatedAt: now, }) - .where(eq(operationProfileSessionArtifacts.id, existingRows[0].id)); + .where( + and( + eq(operationProfileSessionArtifacts.id, existingRows[0].id), + eq(operationProfileSessionArtifacts.ownerId, ownerId) + ) + ); return; } await db.insert(operationProfileSessionArtifacts).values({ id: uuidv4(), - ownerId: params.ownerId, + ownerId, sessionKey: params.sessionKey, chatId: params.chatId, branchId: params.branchId, diff --git a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts index 67045e93..f18978a8 100644 --- a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts +++ b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.test.ts @@ -3,6 +3,35 @@ import { describe, expect, test } from "vitest"; import { RunArtifactStore } from "./run-artifact-store"; describe("RunArtifactStore", () => { + test("rejects artifact values larger than 256 KiB", () => { + const store = new RunArtifactStore(); + + expect(() => + store.upsert({ + artifactId: "oversized", + format: "text", + semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 20 }, + value: "x".repeat(256 * 1024 + 1), + }) + ).toThrow(/artifact value exceeds/i); + }); + + test("rejects runtime history limits above 100 items", () => { + const store = new RunArtifactStore(); + expect(() => + store.upsert({ + artifactId: "history", + format: "text", + semantics: "intermediate", + writeMode: "replace", + history: { enabled: true, maxItems: 101 }, + value: "ok", + }) + ).toThrow(/history maxItems/i); + }); + test("returns null for unknown tag", () => { const store = new RunArtifactStore(); expect(store.get("missing")).toBeNull(); diff --git a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts index 203cb241..17fd97fa 100644 --- a/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts +++ b/server/src/services/chat-generation-v3/artifacts/run-artifact-store.ts @@ -1,3 +1,9 @@ +import { + assertArtifactHistoryItemLimit, + assertArtifactHistoryWithinLimits, + assertArtifactValueWithinLimits, +} from "../../operations/operation-resource-limits"; + import type { ArtifactValue } from "../contracts"; import type { ArtifactFormat, @@ -5,7 +11,6 @@ import type { ArtifactWriteMode, } from "@shared/types/operation-profiles"; - export class RunArtifactStore { private readonly byTag = new Map(); @@ -30,6 +35,8 @@ export class RunArtifactStore { }; value: unknown; }): ArtifactValue { + assertArtifactValueWithinLimits(params.value); + assertArtifactHistoryItemLimit(params.history.maxItems); const existing = this.byTag.get(params.artifactId); const nextHistory = existing ? [...existing.history, params.value] @@ -37,6 +44,7 @@ export class RunArtifactStore { const history = params.history.enabled ? nextHistory.slice(-params.history.maxItems) : []; + assertArtifactHistoryWithinLimits(history); if (existing) { const next: ArtifactValue = { diff --git a/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts b/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts index d957e680..e788a3f8 100644 --- a/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts +++ b/server/src/services/chat-generation-v3/operations/execute-operations-phase.test.ts @@ -319,6 +319,104 @@ beforeEach(() => { }); describe("executeOperationsPhase", () => { + test("caps concurrent operation execution at four tasks", async () => { + let active = 0; + let maxActive = 0; + mocks.llmGatewayStream.mockImplementation(() => + (async function* () { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + yield { type: "delta", text: "ok" }; + active -= 1; + yield { type: "done", status: "done" }; + })() + ); + + const operations = Array.from({ length: 10 }, (_, index) => + makeLlmOp({ + opId: `llm-${index}`, + order: index, + prompt: "bounded", + output: artifactOutput(`result_${index}`), + }) + ); + + const out = await executeOperationsPhase({ + runId: "bounded-concurrency", + hook: "before_main_llm", + trigger: "generate", + operations, + executionMode: "concurrent", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out.every((item) => item.status === "done")).toBe(true); + expect(maxActive).toBe(4); + }); + + test("rejects auxiliary LLM output larger than 256 KiB", async () => { + mocks.llmGatewayStream.mockImplementation(() => + streamOf([ + { type: "delta", text: "x".repeat(256 * 1024 + 1) }, + { type: "done", status: "done" }, + ]) + ); + + const out = await executeOperationsPhase({ + runId: "bounded-output", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeLlmOp({ + opId: "llm-large-output", + order: 1, + prompt: "bounded", + output: artifactOutput("large_output"), + }), + ], + executionMode: "concurrent", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out[0]).toMatchObject({ + status: "error", + error: { code: "LLM_OUTPUT_TOO_LARGE" }, + }); + }); + + test("rejects oversized template output before effects are created", async () => { + const out = await executeOperationsPhase({ + runId: "bounded-template-output", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeTemplateOp({ + opId: "large-template-output", + order: 1, + template: "x".repeat(256 * 1024 + 1), + output: artifactOutput("large_template"), + }), + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + }); + + expect(out[0]).toMatchObject({ + status: "error", + error: { code: "ARTIFACT_VALUE_TOO_LARGE" }, + }); + }); + test("returns activation skip with skip details", async () => { const out = await executeOperationsPhase({ runId: "run-eligible-filter", @@ -567,6 +665,11 @@ describe("executeOperationsPhase", () => { }); test("returns error for strictVariables with missing variable", async () => { + const finishedEvents = collectEvents<{ + opId: string; + status: string; + error?: { code: string; message: string }; + }>(); const out = await executeOperationsPhase({ runId: "run-5", hook: "before_main_llm", @@ -585,11 +688,63 @@ describe("executeOperationsPhase", () => { baseArtifacts: makeBaseArtifacts(), assistantText: "", templateContext: makeTemplateContext(), + onOperationFinished: (event) => { + finishedEvents.push({ + opId: event.opId, + status: event.status, + error: event.error, + }); + }, }); expect(out[0]?.status).toBe("error"); expect(out[0]?.effects).toEqual([]); expect(out[0]?.error?.message.length).toBeGreaterThan(0); + expect(finishedEvents.items).toEqual([ + { + opId: "a", + status: "error", + error: { + code: "OPERATION_ERROR", + message: expect.stringContaining("missing"), + }, + }, + ]); + }); + + test("redacts credentials from operation.finished error messages", async () => { + mocks.llmGatewayStream.mockImplementation(() => + streamOf([ + { type: "error", message: "Authorization: Bearer provider-secret" }, + { type: "done", status: "error" }, + ]) + ); + const finishedEvents = collectEvents<{ error?: { code: string; message: string } }>(); + + await executeOperationsPhase({ + runId: "run-redacted-error", + hook: "before_main_llm", + trigger: "generate", + operations: [ + makeLlmOp({ + opId: "redacted-error", + order: 10, + prompt: "summarize", + output: artifactOutput("summary"), + }), + ], + executionMode: "sequential", + baseMessages: makeBaseMessages(), + baseArtifacts: makeBaseArtifacts(), + assistantText: "", + templateContext: makeTemplateContext(), + onOperationFinished: (event) => finishedEvents.push({ error: event.error }), + }); + + expect(finishedEvents.items[0]?.error).toEqual({ + code: "LLM_PROVIDER_ERROR", + message: "Authorization: [REDACTED]", + }); }); test("blocks dependent node when ancestor fails", async () => { diff --git a/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts b/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts index 2d7140d0..a2bdec0e 100644 --- a/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts +++ b/server/src/services/chat-generation-v3/operations/execute-operations-phase.ts @@ -1,6 +1,10 @@ import { createTaskSkip, runOrchestrator } from "@core/operation-orchestrator"; import { renderLiquidTemplate } from "../../chat-core/prompt-template-renderer"; +import { + assertArtifactValueWithinLimits, + OPERATION_RESOURCE_LIMITS, +} from "../../operations/operation-resource-limits"; import { compileArtifactExposureEffect, getArtifactPrimaryEffectType, @@ -17,6 +21,7 @@ import { executeGuardOperation } from "./guard-operation-executor"; import { evaluateGuardRunConditions } from "./guard-run-conditions"; import { executeKnowledgeOperation } from "./knowledge-operation-executor"; import { executeLlmOperation } from "./llm-operation-executor"; +import { toSafeOperationError } from "./operation-error"; import type { TaskResult } from "../../../core/operation-orchestrator/types"; import type { InstructionRenderContext } from "../../chat-core/prompt-template-renderer"; @@ -329,10 +334,11 @@ function mapTaskResult(params: { order: op.config.order, dependsOn: op.config.dependsOn ?? [], effects: [], - error: { - code: task.error.code ?? "OPERATION_ERROR", + error: toSafeOperationError({ + code: task.error.code, message: task.error.message, - }, + fallbackCode: "OPERATION_ERROR", + }), }; } @@ -346,12 +352,10 @@ function mapTaskResult(params: { order: op.config.order, dependsOn: op.config.dependsOn ?? [], effects: [], - error: task.reason - ? { - code: "OPERATION_ABORTED", - message: task.reason, - } - : undefined, + error: toSafeOperationError({ + message: task.reason ?? "Operation aborted", + fallbackCode: "OPERATION_ABORTED", + }), }; } @@ -539,6 +543,7 @@ export async function executeOperationsPhase(params: { hook: params.hook, trigger: params.trigger, executionMode: params.executionMode, + concurrency: OPERATION_RESOURCE_LIMITS.concurrentTasks, signal: params.abortSignal, tasks: executableOps.map((op) => ({ taskId: op.opId, @@ -615,6 +620,7 @@ export async function executeOperationsPhase(params: { return knowledgeResult; } + assertArtifactValueWithinLimits(resolvedRendered); const artifact = op.config.params.artifact; const effects: RuntimeEffect[] = [ { @@ -732,11 +738,25 @@ export async function executeOperationsPhase(params: { } const result = evt.data.status === "done" ? taskResultByOpId.get(op.opId) : undefined; + const error = + evt.data.status === "error" + ? toSafeOperationError({ + code: evt.data.error.code, + message: evt.data.error.message, + fallbackCode: "OPERATION_ERROR", + }) + : evt.data.status === "aborted" + ? toSafeOperationError({ + message: evt.data.reason ?? "Operation aborted", + fallbackCode: "OPERATION_ABORTED", + }) + : undefined; params.onOperationFinished?.({ hook: params.hook, opId: op.opId, name: op.name, status: evt.data.status, + error, result, }); } diff --git a/server/src/services/chat-generation-v3/operations/llm-operation-executor.ts b/server/src/services/chat-generation-v3/operations/llm-operation-executor.ts index 2bfd560b..d323a180 100644 --- a/server/src/services/chat-generation-v3/operations/llm-operation-executor.ts +++ b/server/src/services/chat-generation-v3/operations/llm-operation-executor.ts @@ -6,6 +6,7 @@ import { buildGatewayStreamRequest } from "../../llm/llm-gateway-adapter"; import { getProviderConfig, getTokenPlaintext } from "../../llm/llm-repository"; import { compileLlmJsonSchemaSpec } from "../../operations/llm-json-schema-spec"; import { parseLlmOperationParams } from "../../operations/llm-operation-params"; +import { OPERATION_RESOURCE_LIMITS } from "../../operations/operation-resource-limits"; import type { GenerateMessage } from "@shared/types/generate"; import type { OperationInProfile } from "@shared/types/operation-profiles"; @@ -258,6 +259,12 @@ async function callLlmOnce(params: { for await (const event of llmGateway.stream(request)) { if (event.type === "delta") { text += event.text; + if (Buffer.byteLength(text, "utf8") > OPERATION_RESOURCE_LIMITS.llmOutputBytes) { + throw createCodedError( + "LLM_OUTPUT_TOO_LARGE", + `LLM output exceeds ${OPERATION_RESOURCE_LIMITS.llmOutputBytes} bytes` + ); + } continue; } if (event.type === "error") { @@ -331,6 +338,14 @@ export async function executeLlmOperation(params: { }) ); } + const inputBytes = Buffer.byteLength(renderedPrompt, "utf8") + + Buffer.byteLength(renderedSystem, "utf8"); + if (inputBytes > OPERATION_RESOURCE_LIMITS.llmOutputBytes) { + throw createCodedError( + "LLM_PROMPT_TOO_LARGE", + `Rendered LLM prompt exceeds ${OPERATION_RESOURCE_LIMITS.llmOutputBytes} bytes` + ); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); throw createCodedError("LLM_TEMPLATE_RENDER_ERROR", message); diff --git a/server/src/services/chat-generation-v3/operations/operation-error.ts b/server/src/services/chat-generation-v3/operations/operation-error.ts new file mode 100644 index 00000000..a830f505 --- /dev/null +++ b/server/src/services/chat-generation-v3/operations/operation-error.ts @@ -0,0 +1,27 @@ +const MAX_OPERATION_ERROR_MESSAGE_LENGTH = 500; +const OPERATION_ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{0,63}$/; + +function sanitizeOperationErrorMessage(message: string): string { + const redacted = message + .replace( + /\b(authorization|api[-_ ]?key|token|password|secret)(\s*[:=]\s*)((?:bearer\s+)?[^\s,;]+)/gi, + "$1$2[REDACTED]" + ) + .replace(/(bearer\s+)[^\s,;]+/gi, "$1[REDACTED]"); + if (redacted.length <= MAX_OPERATION_ERROR_MESSAGE_LENGTH) return redacted; + return `${redacted.slice(0, MAX_OPERATION_ERROR_MESSAGE_LENGTH)}...[truncated]`; +} + +export function toSafeOperationError(params: { + code?: string; + message: string; + fallbackCode: "OPERATION_ERROR" | "OPERATION_ABORTED"; +}): { code: string; message: string } { + return { + code: + params.code && OPERATION_ERROR_CODE_RE.test(params.code) + ? params.code + : params.fallbackCode, + message: sanitizeOperationErrorMessage(params.message), + }; +} diff --git a/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts b/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts index 63895b2a..7acb99f9 100644 --- a/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts +++ b/server/src/services/chat-generation-v3/orchestration/run-state-helpers.ts @@ -1,5 +1,7 @@ import type { ArtifactValue, + CommitPhaseReport, + OperationExecutionResult, PromptDraftMessage, RunContext, RunDebugStateSnapshotStage, @@ -57,6 +59,26 @@ export function mergeArtifactsForDebug( return merged; } +export function buildRequiredOperationFailureMessage(params: { + stage: "before" | "after"; + operationResults: OperationExecutionResult[]; + commitReport: CommitPhaseReport; + requiredCommitError: boolean; +}): string { + const operationIds = params.requiredCommitError + ? params.commitReport.effects + .filter((effect) => effect.status === "error") + .map((effect) => effect.opId) + : params.operationResults.map((result) => result.opId); + const uniqueOperationIds = Array.from(new Set(operationIds)).sort(); + const baseMessage = params.requiredCommitError + ? `Required ${params.stage} effect commit failed` + : `Required ${params.stage} operation did not finish with done`; + return uniqueOperationIds.length > 0 + ? `${baseMessage}: ${uniqueOperationIds.join(", ")}` + : baseMessage; +} + export function createInitialRunState( persistedArtifactsSnapshot: RunState["persistedArtifactsSnapshot"] ): RunState { diff --git a/server/src/services/chat-generation-v3/prepare/resolve-run-context.test.ts b/server/src/services/chat-generation-v3/prepare/resolve-run-context.test.ts index e7c851c0..eb7c4d2e 100644 --- a/server/src/services/chat-generation-v3/prepare/resolve-run-context.test.ts +++ b/server/src/services/chat-generation-v3/prepare/resolve-run-context.test.ts @@ -178,7 +178,10 @@ describe("resolveRunContext", () => { const { context, profile } = await resolveRunContext({ request: makeRequest() }); - expect(mocks.getOperationProfileById).toHaveBeenCalledWith("profile-1"); + expect(mocks.getOperationProfileById).toHaveBeenCalledWith({ + ownerId: "owner-1", + profileId: "profile-1", + }); expect(profile).toMatchObject({ profileId: "profile-1", enabled: true, diff --git a/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts b/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts index 88b1a191..35be6bd2 100644 --- a/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts +++ b/server/src/services/chat-generation-v3/prepare/resolve-run-context.ts @@ -1,3 +1,4 @@ +import { resolveTrustedOwnerId } from "../../../core/request-context/owner-scope-storage"; import { createGeneration } from "../../chat-core/generations-repository"; import { resolveGatewayModel, @@ -50,7 +51,7 @@ export async function resolveRunContext(params: { context: RunContext; profile: OperationProfile | null; }> { - const ownerId = params.request.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.request.ownerId); const runtime = await getRuntime("global", ownerId); const providerConfig = await getProviderConfig(runtime.activeProviderId); const model = resolveGatewayModel({ @@ -59,9 +60,12 @@ export async function resolveRunContext(params: { providerConfig: providerConfig.config, }); - const settings = await getOperationProfileSettings(); + const settings = await getOperationProfileSettings({ ownerId }); const activeProfile = settings.activeProfileId - ? await getOperationProfileById(settings.activeProfileId) + ? await getOperationProfileById({ + ownerId, + profileId: settings.activeProfileId, + }) : null; const profile = activeProfile && activeProfile.enabled ? activeProfile : null; const compiledProfile = profile ? await resolveCompiledOperationProfile(profile) : null; diff --git a/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts b/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts index 4415e30e..2a77f411 100644 --- a/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts +++ b/server/src/services/chat-generation-v3/run-chat-generation-v3.test.ts @@ -294,6 +294,9 @@ describe("runChatGenerationV3", () => { const finished = events.find((e) => e.type === "run.finished"); expect(finished?.data.status).toBe("failed"); expect(finished?.data.failedType).toBe("before_barrier"); + expect(finished?.data.message).toBe( + "Required before operation did not finish with done: op-1" + ); }); test("does not fail before barrier for required activation_not_reached skip", async () => { diff --git a/server/src/services/chat-generation-v3/run-chat-generation-v3.ts b/server/src/services/chat-generation-v3/run-chat-generation-v3.ts index fac1bacd..2ebf786a 100644 --- a/server/src/services/chat-generation-v3/run-chat-generation-v3.ts +++ b/server/src/services/chat-generation-v3/run-chat-generation-v3.ts @@ -14,6 +14,7 @@ import { RunEventStream } from "./orchestration/run-event-stream"; import { runOperationHookPhase } from "./orchestration/run-operation-hook-phase"; import { buildRunDebugStateSnapshot, + buildRequiredOperationFailureMessage, buildRunResult, cloneLlmMessages, clonePromptDraftMessages, @@ -306,10 +307,12 @@ export async function* runChatGenerationV3( if (beforeBarrierFailed) { runState.finishedStatus = "failed"; runState.failedType = "before_barrier"; - runState.errorMessage = - commitBefore.requiredError - ? "Required before effect commit failed" - : "Required before operation did not finish with done"; + runState.errorMessage = buildRequiredOperationFailureMessage({ + stage: "before", + operationResults: requiredBeforeNotDone, + commitReport: commitBefore.report, + requiredCommitError: commitBefore.requiredError, + }); markPhase("before_barrier", "failed", barrierStartedAt, runState.errorMessage); } else { markPhase("before_barrier", "done", barrierStartedAt); @@ -452,10 +455,12 @@ export async function* runChatGenerationV3( if (commitAfter.requiredError || requiredAfterNotDone.length > 0) { runState.finishedStatus = "failed"; runState.failedType = "after_main_llm"; - runState.errorMessage = - commitAfter.requiredError - ? "Required after effect commit failed" - : "Required after operation did not finish with done"; + runState.errorMessage = buildRequiredOperationFailureMessage({ + stage: "after", + operationResults: requiredAfterNotDone, + commitReport: commitAfter.report, + requiredCommitError: commitAfter.requiredError, + }); } else { runState.finishedStatus = "done"; } diff --git a/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts b/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts index 53d5ce44..fce9b2bb 100644 --- a/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts +++ b/server/src/services/chat-generation-v3/runtime/chat-runtime-state-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../../core/request-context/owner-scope-storage"; import { initDb } from "../../../db/client"; import { chatRuntimeState } from "../../../db/schema"; import { INITIAL_OPERATION_ACTIVATION_STATE } from "../operations/operation-activation-intervals"; @@ -117,12 +118,13 @@ function rowToRecord(row: typeof chatRuntimeState.$inferSelect): ChatRuntimeStat export class ChatRuntimeStateRepository { static async getByScope(params: ChatRuntimeStateScope): Promise { const db = await initDb(); + const ownerId = resolveTrustedOwnerId(params.ownerId); const rows = await db .select() .from(chatRuntimeState) .where( and( - eq(chatRuntimeState.ownerId, params.ownerId), + eq(chatRuntimeState.ownerId, ownerId), eq(chatRuntimeState.chatId, params.chatId), eq(chatRuntimeState.branchId, params.branchId), eq(chatRuntimeState.profileId, params.profileId), @@ -140,16 +142,18 @@ export class ChatRuntimeStateRepository { }): Promise { const db = await initDb(); const now = new Date(); + const ownerId = resolveTrustedOwnerId(params.scope.ownerId); + const scope = { ...params.scope, ownerId }; await db .insert(chatRuntimeState) .values({ id: uuidv4(), - ownerId: params.scope.ownerId, - chatId: params.scope.chatId, - branchId: params.scope.branchId, - profileId: params.scope.profileId, - operationProfileSessionId: params.scope.operationProfileSessionId, + ownerId: scope.ownerId, + chatId: scope.chatId, + branchId: scope.branchId, + profileId: scope.profileId, + operationProfileSessionId: scope.operationProfileSessionId, stateJson: safeJsonStringify(params.payload, "{}"), updatedAt: now, }) @@ -167,10 +171,10 @@ export class ChatRuntimeStateRepository { }, }); - const reloaded = await this.getByScope(params.scope); + const reloaded = await this.getByScope(scope); if (reloaded) return reloaded; return { - scope: params.scope, + scope, payload: normalizePayload(params.payload), updatedAt: now, }; diff --git a/server/src/services/chat-knowledge/chat-knowledge.integration.test.ts b/server/src/services/chat-knowledge/chat-knowledge.integration.test.ts index 31c8635b..50149d56 100644 --- a/server/src/services/chat-knowledge/chat-knowledge.integration.test.ts +++ b/server/src/services/chat-knowledge/chat-knowledge.integration.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { runWithOwnerScope } from "@core/request-context/owner-scope-storage"; + import { applyMigrations } from "../../db/apply-migrations"; import { initDb, resetDbForTests } from "../../db/client"; import { chatBranches, chats, entityProfiles } from "../../db/schema"; @@ -500,6 +502,44 @@ describe("chat knowledge integration", () => { expect(branchB.hits.some((item) => item.record?.key === "branch_a_note")).toBe(false); }); + test("does not add records to another owner's collection", async () => { + const firstOwner = "11111111-1111-4111-8111-111111111111"; + const secondOwner = "22222222-2222-4222-8222-222222222222"; + await runWithOwnerScope(firstOwner, () => + seedChatScope({ ownerId: firstOwner, chatId: "private-chat" }) + ); + const collection = await runWithOwnerScope(firstOwner, () => + createKnowledgeCollection({ + chatId: "private-chat", + branchId: null, + scope: "chat", + name: "Private collection", + origin: "author", + layer: "baseline", + }) + ); + + await expect( + runWithOwnerScope(secondOwner, () => + upsertKnowledgeRecord({ + ownerId: firstOwner, + chatId: "private-chat", + branchId: null, + collectionId: collection.id, + recordType: "note", + key: "cross_owner", + title: "Cross owner", + aliases: [], + tags: [], + content: { text: "forbidden" }, + accessMode: "public", + layer: "runtime", + origin: "llm", + }) + ) + ).rejects.toThrow("Knowledge collection not found"); + }); + test("export/import support baseline and runtime filtering", async () => { await seedChatScope({ chatId: "chat-export", diff --git a/server/src/services/chat-knowledge/knowledge-access-repository.ts b/server/src/services/chat-knowledge/knowledge-access-repository.ts index d1bba54c..fd760b89 100644 --- a/server/src/services/chat-knowledge/knowledge-access-repository.ts +++ b/server/src/services/chat-knowledge/knowledge-access-repository.ts @@ -2,8 +2,10 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, isNull, or } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; -import { knowledgeRecordAccessState } from "../../db/schema"; +import { knowledgeRecordAccessState, knowledgeRecords } from "../../db/schema"; import { encodeJson, @@ -30,7 +32,10 @@ export async function listKnowledgeRecordAccessState(params: { .from(knowledgeRecordAccessState) .where( and( - eq(knowledgeRecordAccessState.ownerId, params.ownerId ?? "global"), + eq( + knowledgeRecordAccessState.ownerId, + resolveTrustedOwnerId(params.ownerId) + ), eq(knowledgeRecordAccessState.chatId, params.chatId), or( params.branchId === null @@ -55,7 +60,10 @@ export async function getKnowledgeRecordAccessState(params: { .from(knowledgeRecordAccessState) .where( and( - eq(knowledgeRecordAccessState.ownerId, params.ownerId ?? "global"), + eq( + knowledgeRecordAccessState.ownerId, + resolveTrustedOwnerId(params.ownerId) + ), eq(knowledgeRecordAccessState.chatId, params.chatId), params.branchId === null ? isNull(knowledgeRecordAccessState.branchId) @@ -83,11 +91,24 @@ export async function upsertKnowledgeAccessState(params: { }): Promise { const db = await initDb(); const now = new Date(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const record = await db + .select({ id: knowledgeRecords.id }) + .from(knowledgeRecords) + .where( + and( + eq(knowledgeRecords.id, params.recordId), + eq(knowledgeRecords.ownerId, ownerId), + eq(knowledgeRecords.chatId, params.chatId) + ) + ) + .limit(1); + if (!record[0]) throw new Error("Knowledge record not found"); await db .insert(knowledgeRecordAccessState) .values({ id: uuidv4(), - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, branchId: params.branchId, recordId: params.recordId, diff --git a/server/src/services/chat-knowledge/knowledge-collections-repository.ts b/server/src/services/chat-knowledge/knowledge-collections-repository.ts index 99073d78..3afc772c 100644 --- a/server/src/services/chat-knowledge/knowledge-collections-repository.ts +++ b/server/src/services/chat-knowledge/knowledge-collections-repository.ts @@ -2,8 +2,10 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, isNull, or } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; -import { knowledgeCollections } from "../../db/schema"; +import { chatBranches, chats, knowledgeCollections } from "../../db/schema"; import { listKnowledgeRecordAccessState, upsertKnowledgeAccessState } from "./knowledge-access-repository"; import { @@ -48,9 +50,30 @@ export async function createKnowledgeCollection(params: { const db = await initDb(); const id = uuidv4(); const now = new Date(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const chat = await db + .select({ id: chats.id }) + .from(chats) + .where(and(eq(chats.id, params.chatId), eq(chats.ownerId, ownerId))) + .limit(1); + if (!chat[0]) throw new Error("Chat not found"); + if (params.branchId) { + const branch = await db + .select({ id: chatBranches.id }) + .from(chatBranches) + .where( + and( + eq(chatBranches.id, params.branchId), + eq(chatBranches.chatId, params.chatId), + eq(chatBranches.ownerId, ownerId) + ) + ) + .limit(1); + if (!branch[0]) throw new Error("Chat branch not found"); + } await db.insert(knowledgeCollections).values({ id, - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, branchId: params.branchId, scope: params.scope, @@ -76,7 +99,12 @@ export async function getKnowledgeCollectionById( const rows = await db .select() .from(knowledgeCollections) - .where(eq(knowledgeCollections.id, id)) + .where( + and( + eq(knowledgeCollections.id, id), + eq(knowledgeCollections.ownerId, resolveTrustedOwnerId()) + ) + ) .limit(1); return rows[0] ? rowToKnowledgeCollectionDto(rows[0]) : null; } @@ -92,7 +120,7 @@ export async function listKnowledgeCollections(params: { .from(knowledgeCollections) .where( and( - eq(knowledgeCollections.ownerId, params.ownerId ?? "global"), + eq(knowledgeCollections.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeCollections.chatId, params.chatId), params.branchId === null ? isNull(knowledgeCollections.branchId) @@ -113,7 +141,13 @@ export async function exportKnowledgeCollection(params: { mode: KnowledgeExportMode; }): Promise { const collection = await getKnowledgeCollectionById(params.collectionId); - if (!collection) throw new Error("Knowledge collection not found"); + if ( + !collection || + collection.chatId !== params.chatId || + (collection.branchId !== null && collection.branchId !== params.branchId) + ) { + throw new Error("Knowledge collection not found"); + } const records = (await listKnowledgeRecords({ ownerId: params.ownerId, @@ -170,7 +204,9 @@ export async function importKnowledgeCollection(params: { payload: KnowledgeCollectionExportPayload; }): Promise { const collection = await createKnowledgeCollection({ - ownerId: params.ownerId ?? params.payload.collection.ownerId, + ownerId: resolveTrustedOwnerId( + params.ownerId ?? params.payload.collection.ownerId + ), chatId: params.chatId, branchId: params.branchId, scope: params.payload.collection.scope, @@ -187,7 +223,7 @@ export async function importKnowledgeCollection(params: { const importedRecords = []; for (const item of params.payload.records) { const created = await upsertKnowledgeRecord({ - ownerId: params.ownerId ?? item.ownerId, + ownerId: resolveTrustedOwnerId(params.ownerId ?? item.ownerId), chatId: params.chatId, branchId: params.branchId, collectionId: collection.id, @@ -213,7 +249,7 @@ export async function importKnowledgeCollection(params: { } const importedLinks = await createKnowledgeRecordLinksBulk({ - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), chatId: params.chatId, branchId: params.branchId, items: params.payload.links @@ -232,7 +268,7 @@ export async function importKnowledgeCollection(params: { if (!nextRecordId) continue; importedAccessState.push( await upsertKnowledgeAccessState({ - ownerId: params.ownerId ?? item.ownerId, + ownerId: resolveTrustedOwnerId(params.ownerId ?? item.ownerId), chatId: params.chatId, branchId: params.branchId, recordId: nextRecordId, diff --git a/server/src/services/chat-knowledge/knowledge-links-repository.ts b/server/src/services/chat-knowledge/knowledge-links-repository.ts index 1b59fd5d..3f7514f7 100644 --- a/server/src/services/chat-knowledge/knowledge-links-repository.ts +++ b/server/src/services/chat-knowledge/knowledge-links-repository.ts @@ -1,9 +1,11 @@ import { randomUUID as uuidv4 } from "node:crypto"; -import { and, eq, isNull, or } from "drizzle-orm"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; + +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; -import { knowledgeRecordLinks } from "../../db/schema"; +import { knowledgeRecordLinks, knowledgeRecords } from "../../db/schema"; import { encodeJson, rowToKnowledgeRecordLinkDto } from "./knowledge-helpers"; @@ -23,12 +25,31 @@ export async function createKnowledgeRecordLinksBulk(params: { if (params.items.length === 0) return []; const db = await initDb(); const now = new Date(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const referencedIds = [ + ...new Set( + params.items.flatMap((item) => [item.fromRecordId, item.toRecordId]) + ), + ]; + const ownedRecords = await db + .select({ id: knowledgeRecords.id }) + .from(knowledgeRecords) + .where( + and( + eq(knowledgeRecords.ownerId, ownerId), + eq(knowledgeRecords.chatId, params.chatId), + inArray(knowledgeRecords.id, referencedIds) + ) + ); + if (ownedRecords.length !== referencedIds.length) { + throw new Error("Knowledge record not found"); + } await db .insert(knowledgeRecordLinks) .values( params.items.map((item) => ({ id: uuidv4(), - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, branchId: params.branchId, fromRecordId: item.fromRecordId, @@ -58,7 +79,7 @@ export async function listKnowledgeRecordLinks(params: { .from(knowledgeRecordLinks) .where( and( - eq(knowledgeRecordLinks.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecordLinks.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecordLinks.chatId, params.chatId), or( params.branchId === null diff --git a/server/src/services/chat-knowledge/knowledge-records-repository.ts b/server/src/services/chat-knowledge/knowledge-records-repository.ts index cba5669f..504d87b0 100644 --- a/server/src/services/chat-knowledge/knowledge-records-repository.ts +++ b/server/src/services/chat-knowledge/knowledge-records-repository.ts @@ -2,8 +2,10 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; -import { knowledgeRecords } from "../../db/schema"; +import { knowledgeCollections, knowledgeRecords } from "../../db/schema"; import { buildKnowledgeSearchText, @@ -36,14 +38,31 @@ export async function getKnowledgeRecordById( id: string ): Promise { const db = await initDb(); - const rows = await db.select().from(knowledgeRecords).where(eq(knowledgeRecords.id, id)).limit(1); + const rows = await db + .select() + .from(knowledgeRecords) + .where( + and( + eq(knowledgeRecords.id, id), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId()) + ) + ) + .limit(1); return rows[0] ? rowToKnowledgeRecordDto(rows[0]) : null; } export async function getKnowledgeRecordsByIds(ids: string[]): Promise { if (ids.length === 0) return []; const db = await initDb(); - const rows = await db.select().from(knowledgeRecords).where(inArray(knowledgeRecords.id, ids)); + const rows = await db + .select() + .from(knowledgeRecords) + .where( + and( + inArray(knowledgeRecords.id, ids), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId()) + ) + ); return rows.map(rowToKnowledgeRecordDto); } @@ -60,7 +79,7 @@ export async function getScopedKnowledgeRecordsByIds(params: { .from(knowledgeRecords) .where( and( - eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecords.chatId, params.chatId), buildOverlayBranchScope(params.branchId), inArray(knowledgeRecords.id, params.ids) @@ -82,7 +101,7 @@ export async function findKnowledgeRecordsByKeys(params: { .from(knowledgeRecords) .where( and( - eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecords.chatId, params.chatId), buildOverlayBranchScope(params.branchId), inArray(knowledgeRecords.key, params.keys) @@ -100,7 +119,7 @@ export async function listKnowledgeRecords(params: { }): Promise { const db = await initDb(); const where = [ - eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecords.chatId, params.chatId), buildOverlayBranchScope(params.branchId), ]; @@ -134,6 +153,26 @@ export async function upsertKnowledgeRecord(params: { }): Promise { const db = await initDb(); const now = new Date(); + const ownerId = resolveTrustedOwnerId(params.ownerId); + const collection = await db + .select({ id: knowledgeCollections.id }) + .from(knowledgeCollections) + .where( + and( + eq(knowledgeCollections.id, params.collectionId), + eq(knowledgeCollections.ownerId, ownerId), + eq(knowledgeCollections.chatId, params.chatId), + params.branchId === null + ? isNull(knowledgeCollections.branchId) + : or( + isNull(knowledgeCollections.branchId), + eq(knowledgeCollections.branchId, params.branchId) + ) + ) + ) + .limit(1); + if (!collection[0]) throw new Error("Knowledge collection not found"); + const searchText = buildKnowledgeSearchText({ title: params.title, aliases: params.aliases, @@ -147,6 +186,7 @@ export async function upsertKnowledgeRecord(params: { .from(knowledgeRecords) .where( and( + eq(knowledgeRecords.ownerId, ownerId), eq(knowledgeRecords.chatId, params.chatId), buildExactBranchScope(params.branchId), eq(knowledgeRecords.collectionId, params.collectionId), @@ -179,11 +219,16 @@ export async function upsertKnowledgeRecord(params: { metaJson: typeof params.meta === "undefined" ? null : encodeJson(params.meta, "null"), updatedAt: now, }) - .where(eq(knowledgeRecords.id, current.id)); + .where( + and( + eq(knowledgeRecords.id, current.id), + eq(knowledgeRecords.ownerId, ownerId) + ) + ); } else { await db.insert(knowledgeRecords).values({ id: uuidv4(), - ownerId: params.ownerId ?? "global", + ownerId, chatId: params.chatId, branchId: params.branchId, collectionId: params.collectionId, @@ -215,6 +260,7 @@ export async function upsertKnowledgeRecord(params: { .from(knowledgeRecords) .where( and( + eq(knowledgeRecords.ownerId, ownerId), eq(knowledgeRecords.chatId, params.chatId), buildExactBranchScope(params.branchId), eq(knowledgeRecords.collectionId, params.collectionId), diff --git a/server/src/services/chat-knowledge/knowledge-reveal-service.ts b/server/src/services/chat-knowledge/knowledge-reveal-service.ts index 26ad5126..a1b18960 100644 --- a/server/src/services/chat-knowledge/knowledge-reveal-service.ts +++ b/server/src/services/chat-knowledge/knowledge-reveal-service.ts @@ -1,3 +1,5 @@ +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { getKnowledgeRecordAccessState, upsertKnowledgeAccessState } from "./knowledge-access-repository"; import { evaluateKnowledgeGate } from "./knowledge-gate-policy"; import { @@ -62,7 +64,7 @@ export async function revealKnowledgeRecords(params: { branchId: string | null; request: KnowledgeRevealRequest; }): Promise { - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const targetRecords = await resolveTargetRecords(params); const targetIds = new Set(targetRecords.map((item) => item.id)); const missingIds = (params.request.recordIds ?? []).filter((item) => !targetIds.has(item)); diff --git a/server/src/services/chat-knowledge/knowledge-search-service.ts b/server/src/services/chat-knowledge/knowledge-search-service.ts index 9ea133c4..d680f2eb 100644 --- a/server/src/services/chat-knowledge/knowledge-search-service.ts +++ b/server/src/services/chat-knowledge/knowledge-search-service.ts @@ -1,5 +1,7 @@ import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; + import { initDb } from "../../db/client"; import { knowledgeRecords } from "../../db/schema"; @@ -133,7 +135,7 @@ export async function searchKnowledgeRecords(params: { }): Promise { const db = await initDb(); const where = [ - eq(knowledgeRecords.ownerId, params.ownerId ?? "global"), + eq(knowledgeRecords.ownerId, resolveTrustedOwnerId(params.ownerId)), eq(knowledgeRecords.chatId, params.chatId), buildBranchScope(params.branchId), eq(knowledgeRecords.status, "active"), diff --git a/server/src/services/llm/llm-definitions.test.ts b/server/src/services/llm/llm-definitions.test.ts index 829da670..9145ce56 100644 --- a/server/src/services/llm/llm-definitions.test.ts +++ b/server/src/services/llm/llm-definitions.test.ts @@ -9,6 +9,14 @@ describe("llm-definitions.parseProviderConfig", () => { tokenPolicy: { randomize: true, fallbackOnError: true }, anthropicCache: { enabled: true, depth: 2, ttl: "1h" }, messageNormalization: { enabled: false }, + openRouterRouting: { + strategy: "priority", + providerOrder: ["google-ai-studio", "google-vertex/global"], + allowFallbacks: false, + zdr: true, + dataCollection: "deny", + requireParameters: true, + }, custom: "ok", }); @@ -17,6 +25,14 @@ describe("llm-definitions.parseProviderConfig", () => { tokenPolicy: { randomize: true, fallbackOnError: true }, anthropicCache: { enabled: true, depth: 2, ttl: "1h" }, messageNormalization: { enabled: false }, + openRouterRouting: { + strategy: "priority", + providerOrder: ["google-ai-studio", "google-vertex/global"], + allowFallbacks: false, + zdr: true, + dataCollection: "deny", + requireParameters: true, + }, custom: "ok", }); }); @@ -43,12 +59,25 @@ describe("llm-definitions.parseProviderConfig", () => { expect(() => parseProviderConfig("openrouter", { anthropicCache: { enabled: true, depth: -1, ttl: "1h" }, - }) + }), ).toThrow(); expect(() => parseProviderConfig("openrouter", { anthropicCache: { enabled: true, depth: 0, ttl: "2h" }, - }) + }), + ).toThrow(); + }); + + test("throws for invalid OpenRouter routing preferences", () => { + expect(() => + parseProviderConfig("openrouter", { + openRouterRouting: { strategy: "priority", providerOrder: [] }, + }), + ).toThrow(); + expect(() => + parseProviderConfig("openrouter", { + openRouterRouting: { strategy: "unknown" }, + }), ).toThrow(); }); }); diff --git a/server/src/services/llm/llm-definitions.ts b/server/src/services/llm/llm-definitions.ts index bd22efdc..8d888e96 100644 --- a/server/src/services/llm/llm-definitions.ts +++ b/server/src/services/llm/llm-definitions.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import type { LlmOpenRouterRoutingStrategy } from "@shared/types/llm"; + export type LlmProviderId = "openrouter" | "openai_compatible"; export type LlmAnthropicCacheTtl = "5m" | "1h"; @@ -24,6 +26,36 @@ export const messageNormalizationSchema = z }) .strict(); +export const openRouterRoutingSchema = z + .object({ + strategy: z.enum([ + "auto", + "price", + "throughput", + "latency", + "priority", + "only", + ] satisfies LlmOpenRouterRoutingStrategy[]), + providerOrder: z.array(z.string().trim().min(1)).max(20).optional(), + allowFallbacks: z.boolean().optional(), + zdr: z.boolean().optional(), + dataCollection: z.enum(["allow", "deny"]).optional(), + requireParameters: z.boolean().optional(), + }) + .strict() + .superRefine((value, ctx) => { + if ( + (value.strategy === "priority" || value.strategy === "only") && + (!value.providerOrder || value.providerOrder.length === 0) + ) { + ctx.addIssue({ + code: "custom", + path: ["providerOrder"], + message: "Select at least one OpenRouter endpoint provider", + }); + } + }); + export type LlmProviderUiField = | { key: "baseUrl"; @@ -97,6 +129,7 @@ export const openRouterConfigSchema = z tokenPolicy: tokenPolicySchema.optional(), anthropicCache: anthropicCacheSchema.optional(), messageNormalization: messageNormalizationSchema.optional(), + openRouterRouting: openRouterRoutingSchema.optional(), }) .passthrough(); @@ -112,15 +145,16 @@ export const openAiCompatibleConfigSchema = z }) .passthrough(); -export type OpenAiCompatibleConfig = z.infer; +export type OpenAiCompatibleConfig = z.infer< + typeof openAiCompatibleConfigSchema +>; export function parseProviderConfig( providerId: LlmProviderId, - config: unknown + config: unknown, ): OpenRouterConfig | OpenAiCompatibleConfig { if (providerId === "openrouter") { return openRouterConfigSchema.parse(config ?? {}); } return openAiCompatibleConfigSchema.parse(config ?? {}); } - diff --git a/server/src/services/llm/llm-gateway-adapter.test.ts b/server/src/services/llm/llm-gateway-adapter.test.ts index 4bc7f7f5..3a848915 100644 --- a/server/src/services/llm/llm-gateway-adapter.test.ts +++ b/server/src/services/llm/llm-gateway-adapter.test.ts @@ -31,7 +31,10 @@ describe("llm-gateway-adapter", () => { const openAiCompatibleDefault = resolveGatewayModel({ providerId: "openai_compatible", runtimeModel: undefined, - providerConfig: { baseUrl: "http://localhost:1234/v1", defaultModel: "oa-default" }, + providerConfig: { + baseUrl: "http://localhost:1234/v1", + defaultModel: "oa-default", + }, }); const openAiCompatibleBuiltin = resolveGatewayModel({ providerId: "openai_compatible", @@ -40,7 +43,9 @@ describe("llm-gateway-adapter", () => { }); expect(openRouterDefault).toBe("router-default"); - expect(openRouterBuiltin).toBe("google/gemini-2.0-flash-lite-preview-02-05:free"); + expect(openRouterBuiltin).toBe( + "google/gemini-2.0-flash-lite-preview-02-05:free", + ); expect(openAiCompatibleDefault).toBe("oa-default"); expect(openAiCompatibleBuiltin).toBe("gpt-4o-mini"); }); @@ -214,4 +219,47 @@ describe("llm-gateway-adapter", () => { anthropicCache: { enabled: true, depth: 2, ttl: "1h" }, }); }); + + test("buildGatewayStreamRequest maps OpenRouter routing config to provider payload", () => { + const req = buildGatewayStreamRequest({ + providerId: "openrouter", + token: "tok", + providerConfig: { + openRouterRouting: { + strategy: "priority", + providerOrder: ["google-ai-studio", "google-vertex/global"], + allowFallbacks: false, + zdr: true, + dataCollection: "deny", + requireParameters: true, + }, + }, + runtimeModel: "google/gemini-3-flash-preview", + messages: [{ role: "user", content: "hi" }], + settings: {}, + }); + + expect(req.extra).toEqual({ + provider: { + order: ["google-ai-studio", "google-vertex/global"], + allow_fallbacks: false, + zdr: true, + data_collection: "deny", + require_parameters: true, + }, + }); + }); + + test("buildGatewayStreamRequest maps OpenRouter sort strategies", () => { + const req = buildGatewayStreamRequest({ + providerId: "openrouter", + token: "tok", + providerConfig: { openRouterRouting: { strategy: "latency" } }, + runtimeModel: "model-x", + messages: [{ role: "user", content: "hi" }], + settings: {}, + }); + + expect(req.extra).toEqual({ provider: { sort: "latency" } }); + }); }); diff --git a/server/src/services/llm/llm-gateway-adapter.ts b/server/src/services/llm/llm-gateway-adapter.ts index d418ca4c..88c7bc70 100644 --- a/server/src/services/llm/llm-gateway-adapter.ts +++ b/server/src/services/llm/llm-gateway-adapter.ts @@ -5,11 +5,17 @@ import { type LlmProviderId, } from "./llm-definitions"; -import type { LlmGatewayMessage, LlmGatewayRequest, LlmSamplingParams, LlmProviderSpec } from "@core/llm-gateway"; +import type { + LlmGatewayMessage, + LlmGatewayRequest, + LlmSamplingParams, + LlmProviderSpec, +} from "@core/llm-gateway"; import type { GenerateMessage } from "@shared/types/generate"; +import type { LlmOpenRouterRoutingConfig } from "@shared/types/llm"; - -const DEFAULT_OPENROUTER_MODEL = "google/gemini-2.0-flash-lite-preview-02-05:free"; +const DEFAULT_OPENROUTER_MODEL = + "google/gemini-2.0-flash-lite-preview-02-05:free"; const DEFAULT_OPENAI_COMPATIBLE_MODEL = "gpt-4o-mini"; export type MessageNormalizationGatewayFeature = { @@ -41,7 +47,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function pickFirst(settings: Record, keys: string[]): T | undefined { +function pickFirst( + settings: Record, + keys: string[], +): T | undefined { for (const key of keys) { if (Object.prototype.hasOwnProperty.call(settings, key)) { return settings[key] as T; @@ -52,7 +61,9 @@ function pickFirst(settings: Record, keys: string[]): T | un function asStringArray(value: unknown): string[] | null { if (!Array.isArray(value)) return null; - const out = value.filter((v) => typeof v === "string" && v.trim().length > 0) as string[]; + const out = value.filter( + (v) => typeof v === "string" && v.trim().length > 0, + ) as string[]; return out.length > 0 ? out : null; } @@ -77,6 +88,31 @@ function resolveGatewayFeatures(params: { return features; } +function resolveOpenRouterRoutingExtra( + config: unknown, +): Record { + const parsed = openRouterConfigSchema.parse(config ?? {}); + const routing = parsed.openRouterRouting as + | LlmOpenRouterRoutingConfig + | undefined; + if (!routing) return {}; + + const provider: Record = {}; + if (["price", "throughput", "latency"].includes(routing.strategy)) { + provider.sort = routing.strategy; + } else if (routing.strategy === "priority") { + provider.order = routing.providerOrder; + } else if (routing.strategy === "only") { + provider.only = routing.providerOrder; + } + if (typeof routing.allowFallbacks === "boolean") + provider.allow_fallbacks = routing.allowFallbacks; + if (routing.zdr === true) provider.zdr = true; + if (routing.dataCollection) provider.data_collection = routing.dataCollection; + if (routing.requireParameters === true) provider.require_parameters = true; + return Object.keys(provider).length > 0 ? { provider } : {}; +} + export function resolveMessageNormalizationFeature(params: { providerId: LlmProviderId; providerConfig: unknown; @@ -102,11 +138,21 @@ export function resolveGatewayModel(params: { if (params.providerId === "openrouter") { const parsed = openRouterConfigSchema.parse(params.providerConfig ?? {}); - return runtimeModel ?? normalizeNonEmptyString(parsed.defaultModel) ?? DEFAULT_OPENROUTER_MODEL; + return ( + runtimeModel ?? + normalizeNonEmptyString(parsed.defaultModel) ?? + DEFAULT_OPENROUTER_MODEL + ); } - const parsed = openAiCompatibleConfigSchema.parse(params.providerConfig ?? {}); - return runtimeModel ?? normalizeNonEmptyString(parsed.defaultModel) ?? DEFAULT_OPENAI_COMPATIBLE_MODEL; + const parsed = openAiCompatibleConfigSchema.parse( + params.providerConfig ?? {}, + ); + return ( + runtimeModel ?? + normalizeNonEmptyString(parsed.defaultModel) ?? + DEFAULT_OPENAI_COMPATIBLE_MODEL + ); } export function resolveGatewayProviderSpec(params: { @@ -118,7 +164,9 @@ export function resolveGatewayProviderSpec(params: { return { id: "openrouter", token: params.token }; } - const parsed = openAiCompatibleConfigSchema.parse(params.providerConfig ?? {}); + const parsed = openAiCompatibleConfigSchema.parse( + params.providerConfig ?? {}, + ); return { id: "openai_compatible", token: params.token, @@ -149,7 +197,8 @@ export function splitSamplingAndExtra(settings: Record): { if (isFiniteNumber(topA)) extra.top_a = topA; const maxTokens = pickFirst(settings, ["max_tokens", "maxTokens"]); - if (isFiniteNumber(maxTokens) && maxTokens > 0) sampling.max_tokens = maxTokens; + if (isFiniteNumber(maxTokens) && maxTokens > 0) + sampling.max_tokens = maxTokens; const stop = pickFirst(settings, ["stop"]); const stopSequences = pickFirst(settings, ["stopSequences"]); @@ -162,35 +211,58 @@ export function splitSamplingAndExtra(settings: Record): { const seed = pickFirst(settings, ["seed"]); if (isFiniteNumber(seed)) sampling.seed = seed; - const presencePenalty = pickFirst(settings, ["presence_penalty", "presencePenalty"]); - if (isFiniteNumber(presencePenalty)) sampling.presence_penalty = presencePenalty; + const presencePenalty = pickFirst(settings, [ + "presence_penalty", + "presencePenalty", + ]); + if (isFiniteNumber(presencePenalty)) + sampling.presence_penalty = presencePenalty; - const frequencyPenalty = pickFirst(settings, ["frequency_penalty", "frequencyPenalty"]); - if (isFiniteNumber(frequencyPenalty)) sampling.frequency_penalty = frequencyPenalty; + const frequencyPenalty = pickFirst(settings, [ + "frequency_penalty", + "frequencyPenalty", + ]); + if (isFiniteNumber(frequencyPenalty)) + sampling.frequency_penalty = frequencyPenalty; - const repetitionPenalty = pickFirst(settings, ["repetition_penalty", "repetitionPenalty"]); - if (isFiniteNumber(repetitionPenalty)) extra.repetition_penalty = repetitionPenalty; + const repetitionPenalty = pickFirst(settings, [ + "repetition_penalty", + "repetitionPenalty", + ]); + if (isFiniteNumber(repetitionPenalty)) + extra.repetition_penalty = repetitionPenalty; const rawReasoning = pickFirst(settings, ["reasoning"]); if (isRecord(rawReasoning)) { const reasoning: Record = {}; - if (typeof rawReasoning.enabled === "boolean") reasoning.enabled = rawReasoning.enabled; - if (typeof rawReasoning.effort === "string" && rawReasoning.effort.trim().length > 0) { + if (typeof rawReasoning.enabled === "boolean") + reasoning.enabled = rawReasoning.enabled; + if ( + typeof rawReasoning.effort === "string" && + rawReasoning.effort.trim().length > 0 + ) { reasoning.effort = rawReasoning.effort; } - if (isFiniteNumber(rawReasoning.max_tokens) && rawReasoning.max_tokens > 0) { + if ( + isFiniteNumber(rawReasoning.max_tokens) && + rawReasoning.max_tokens > 0 + ) { reasoning.max_tokens = rawReasoning.max_tokens; } if (isFiniteNumber(rawReasoning.maxTokens) && rawReasoning.maxTokens > 0) { reasoning.max_tokens = rawReasoning.maxTokens; } - if (typeof rawReasoning.exclude === "boolean") reasoning.exclude = rawReasoning.exclude; + if (typeof rawReasoning.exclude === "boolean") + reasoning.exclude = rawReasoning.exclude; if (Object.keys(reasoning).length > 0) { extra.reasoning = reasoning; } } - const reasoningEffort = pickFirst(settings, ["reasoning_effort", "reasoningEffort"]); + const reasoningEffort = pickFirst(settings, [ + "reasoning_effort", + "reasoningEffort", + ]); if ( !Object.prototype.hasOwnProperty.call(extra, "reasoning") && typeof reasoningEffort === "string" && @@ -260,7 +332,15 @@ export function buildGatewayStreamRequest(params: { providerConfig: params.providerConfig, }); - const { sampling, extra } = splitSamplingAndExtra(params.settings ?? {}); + const { sampling, extra: settingsExtra } = splitSamplingAndExtra( + params.settings ?? {}, + ); + const extra = { + ...settingsExtra, + ...(params.providerId === "openrouter" + ? resolveOpenRouterRoutingExtra(params.providerConfig) + : {}), + }; const features = resolveGatewayFeatures({ providerId: params.providerId, providerConfig: params.providerConfig, diff --git a/server/src/services/llm/llm-owner.integration.test.ts b/server/src/services/llm/llm-owner.integration.test.ts new file mode 100644 index 00000000..416b07e4 --- /dev/null +++ b/server/src/services/llm/llm-owner.integration.test.ts @@ -0,0 +1,96 @@ +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runWithOwnerScope } from "../../core/request-context/owner-scope-storage"; +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; +import { + createTempDataDir, + removeTempDataDir, +} from "../../e2e/helpers/tmp-dir"; + +import { + createToken, + deleteToken, + ensureDefaultProviders, + getProviderConfig, + getRuntime, + getTokenPlaintext, + listTokens, + upsertProviderConfig, + upsertRuntime, +} from "./llm-repository"; + +let tempDir = ""; +let previousMasterKey: string | undefined; + +describe("LLM owner isolation", () => { + beforeEach(async () => { + previousMasterKey = process.env.TOKENS_MASTER_KEY; + process.env.TOKENS_MASTER_KEY = "test-only-master-key"; + resetDbForTests(); + tempDir = await createTempDataDir("llm-owner-"); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + await ensureDefaultProviders(); + }); + + afterEach(async () => { + resetDbForTests(); + await removeTempDataDir(tempDir); + if (previousMasterKey === undefined) { + delete process.env.TOKENS_MASTER_KEY; + } else { + process.env.TOKENS_MASTER_KEY = previousMasterKey; + } + }); + + test("isolates tokens, provider config, and runtime state", async () => { + const firstToken = await runWithOwnerScope("owner-a", async () => { + const token = await createToken({ + providerId: "openrouter", + name: "first", + token: "sk-owner-a-secret", + }); + await upsertProviderConfig("openrouter", { baseUrl: "https://owner-a" }); + await upsertRuntime({ + scope: "global", + scopeId: "global", + activeProviderId: "openrouter", + activeTokenId: token.id, + activeModel: "model-a", + }); + return token; + }); + + await runWithOwnerScope("owner-b", async () => { + expect(await listTokens("openrouter")).toEqual([]); + expect(await getTokenPlaintext(firstToken.id)).toBeNull(); + expect(await getProviderConfig("openrouter")).toEqual({ + providerId: "openrouter", + config: {}, + }); + expect(await getRuntime("global", "global")).toMatchObject({ + activeTokenId: null, + activeModel: null, + }); + + await deleteToken(firstToken.id); + await upsertProviderConfig("openrouter", { baseUrl: "https://owner-b" }); + }); + + await runWithOwnerScope("owner-a", async () => { + expect(await listTokens("openrouter")).toHaveLength(1); + expect(await getTokenPlaintext(firstToken.id)).toBe("sk-owner-a-secret"); + expect(await getProviderConfig("openrouter")).toEqual({ + providerId: "openrouter", + config: { baseUrl: "https://owner-a" }, + }); + expect(await getRuntime("global", "global")).toMatchObject({ + activeTokenId: firstToken.id, + activeModel: "model-a", + }); + }); + }); +}); diff --git a/server/src/services/llm/llm-presets-repository.ts b/server/src/services/llm/llm-presets-repository.ts index 7d90bc45..63ea0588 100644 --- a/server/src/services/llm/llm-presets-repository.ts +++ b/server/src/services/llm/llm-presets-repository.ts @@ -4,7 +4,7 @@ import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; import { HttpError } from "@core/middleware/error-handler"; - +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; @@ -114,6 +114,7 @@ export function resolveAppliedTokenId(params: { export async function ensureDefaultLlmPresetSettings( ownerId: string = DEFAULT_OWNER_ID ): Promise { + ownerId = resolveTrustedOwnerId(ownerId); const db = await initDb(); const row = await db .select() @@ -139,7 +140,7 @@ export async function ensureDefaultLlmPresetSettings( export async function listLlmPresets(params?: { ownerId?: string; }): Promise { - const ownerId = params?.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params?.ownerId); const db = await initDb(); const rows = await db .select() @@ -153,7 +154,7 @@ export async function getLlmPresetById(params: { presetId: string; ownerId?: string; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); const db = await initDb(); const rows = await db .select() @@ -170,7 +171,7 @@ export async function createLlmPreset(params: { description?: string; payload: LlmPresetPayload; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); const payload = normalizePresetPayload(params.payload); const db = await initDb(); const now = new Date(); @@ -198,7 +199,7 @@ export async function updateLlmPreset(params: { description?: string | null; payload?: LlmPresetPayload; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getLlmPresetById({ presetId: params.presetId, ownerId }); if (!current) throw new HttpError(404, "LLM preset not found", "NOT_FOUND"); if (current.builtIn) { @@ -233,7 +234,7 @@ export async function deleteLlmPreset(params: { ownerId?: string; presetId: string; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getLlmPresetById({ presetId: params.presetId, ownerId }); if (!current) throw new HttpError(404, "LLM preset not found", "NOT_FOUND"); if (current.builtIn) { @@ -254,7 +255,7 @@ export async function deleteLlmPreset(params: { export async function getLlmPresetSettings(params?: { ownerId?: string; }): Promise { - const ownerId = params?.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params?.ownerId); return ensureDefaultLlmPresetSettings(ownerId); } @@ -262,7 +263,7 @@ export async function patchLlmPresetSettings(params: { ownerId?: string; activePresetId?: string | null; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await ensureDefaultLlmPresetSettings(ownerId); const nextActivePresetId = typeof params.activePresetId === "undefined" @@ -299,7 +300,7 @@ export async function applyLlmPreset(params: { scope?: LlmScope; scopeId?: string; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); const scope = params.scope ?? "global"; const scopeId = params.scopeId ?? "global"; const preset = await getLlmPresetById({ presetId: params.presetId, ownerId }); diff --git a/server/src/services/llm/llm-repository.ts b/server/src/services/llm/llm-repository.ts index 3e3f1ca1..a1baece8 100644 --- a/server/src/services/llm/llm-repository.ts +++ b/server/src/services/llm/llm-repository.ts @@ -1,12 +1,14 @@ import { randomUUID as uuidv4 } from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, like } from "drizzle-orm"; import { decryptSecret, encryptSecret, maskToken, } from "@core/crypto/secret-box"; +import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { initDb, type Db } from "../../db/client"; import { @@ -34,6 +36,9 @@ export type LlmTokenListItem = { providerId: LlmProviderId; name: string; tokenHint: string; + createdAt: Date; + updatedAt: Date; + lastUsedAt: Date | null; }; export type ProviderConfigRow = { @@ -62,11 +67,22 @@ function nowDate(): Date { return new Date(); } +function storageScopeId(scopeId: string): string { + const ownerId = resolveTrustedOwnerId(); + return `${ownerId}:${scopeId}`; +} + +function providerConfigId(ownerId: string, providerId: LlmProviderId): string { + return ownerId === "global" ? providerId : `${ownerId}:${providerId}`; +} + function parseConfigJson(raw: string): unknown { try { return JSON.parse(raw) as unknown; } catch (error) { - console.warn("Failed to parse provider config JSON. Falling back to {}", { error }); + console.warn("Failed to parse provider config JSON. Falling back to {}", { + error, + }); return {}; } } @@ -98,21 +114,22 @@ export async function ensureDefaultProviders(): Promise { export async function ensureDefaultRuntimeGlobal(): Promise { const database = await db(); + const persistedScopeId = storageScopeId("global"); const existing = await database .select() .from(llmRuntimeSettings) .where( and( eq(llmRuntimeSettings.scope, "global"), - eq(llmRuntimeSettings.scopeId, "global") - ) + eq(llmRuntimeSettings.scopeId, persistedScopeId), + ), ); if (existing.length > 0) return; await database.insert(llmRuntimeSettings).values({ scope: "global", - scopeId: "global", + scopeId: persistedScopeId, activeProviderId: "openrouter", activeTokenId: null, activeModel: null, @@ -134,17 +151,18 @@ export async function listProviders(): Promise< export async function getRuntime( scope: LlmScope, - scopeId: string + scopeId: string, ): Promise { const database = await db(); + const persistedScopeId = storageScopeId(scopeId); const rows = await database .select() .from(llmRuntimeSettings) .where( and( eq(llmRuntimeSettings.scope, scope), - eq(llmRuntimeSettings.scopeId, scopeId) - ) + eq(llmRuntimeSettings.scopeId, persistedScopeId), + ), ); if (rows[0]) { @@ -168,7 +186,7 @@ export async function getRuntime( await database.insert(llmRuntimeSettings).values({ scope, - scopeId, + scopeId: persistedScopeId, activeProviderId: fallback.activeProviderId, activeTokenId: null, activeModel: null, @@ -179,16 +197,34 @@ export async function getRuntime( } export async function upsertRuntime( - runtime: LlmRuntimeRow + runtime: LlmRuntimeRow, ): Promise { const database = await db(); const ts = nowDate(); + const persistedScopeId = storageScopeId(runtime.scopeId); + const ownerId = resolveTrustedOwnerId(); + + if (runtime.activeTokenId) { + const token = await database + .select({ id: llmTokens.id }) + .from(llmTokens) + .where( + and( + eq(llmTokens.id, runtime.activeTokenId), + eq(llmTokens.ownerId, ownerId) + ) + ) + .limit(1); + if (!token[0]) { + throw new HttpError(404, "LLM token not found", "NOT_FOUND"); + } + } await database .insert(llmRuntimeSettings) .values({ scope: runtime.scope, - scopeId: runtime.scopeId, + scopeId: persistedScopeId, activeProviderId: runtime.activeProviderId, activeTokenId: runtime.activeTokenId, activeModel: runtime.activeModel, @@ -213,15 +249,16 @@ export async function getRuntimeProviderState(params: { providerId: LlmProviderId; }): Promise { const database = await db(); + const persistedScopeId = storageScopeId(params.scopeId); const rows = await database .select() .from(llmRuntimeProviderState) .where( and( eq(llmRuntimeProviderState.scope, params.scope), - eq(llmRuntimeProviderState.scopeId, params.scopeId), - eq(llmRuntimeProviderState.providerId, params.providerId) - ) + eq(llmRuntimeProviderState.scopeId, persistedScopeId), + eq(llmRuntimeProviderState.providerId, params.providerId), + ), ); const row = rows[0]; @@ -236,7 +273,7 @@ export async function getRuntimeProviderState(params: { } return { scope: row.scope, - scopeId: row.scopeId, + scopeId: params.scopeId, providerId: row.providerId as LlmProviderId, lastTokenId: row.lastTokenId ?? null, lastModel: row.lastModel ?? null, @@ -244,15 +281,32 @@ export async function getRuntimeProviderState(params: { } export async function upsertRuntimeProviderState( - params: LlmRuntimeProviderStateRow + params: LlmRuntimeProviderStateRow, ): Promise { const database = await db(); const ts = nowDate(); + const persistedScopeId = storageScopeId(params.scopeId); + const ownerId = resolveTrustedOwnerId(); + if (params.lastTokenId) { + const token = await database + .select({ id: llmTokens.id }) + .from(llmTokens) + .where( + and( + eq(llmTokens.id, params.lastTokenId), + eq(llmTokens.ownerId, ownerId) + ) + ) + .limit(1); + if (!token[0]) { + throw new HttpError(404, "LLM token not found", "NOT_FOUND"); + } + } await database .insert(llmRuntimeProviderState) .values({ scope: params.scope, - scopeId: params.scopeId, + scopeId: persistedScopeId, providerId: params.providerId, lastTokenId: params.lastTokenId, lastModel: params.lastModel, @@ -273,13 +327,19 @@ export async function upsertRuntimeProviderState( } export async function getProviderConfig( - providerId: LlmProviderId + providerId: LlmProviderId, ): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const rows = await database .select() .from(llmProviderConfigs) - .where(eq(llmProviderConfigs.id, providerId)); + .where( + and( + eq(llmProviderConfigs.ownerId, ownerId), + eq(llmProviderConfigs.providerId, providerId) + ) + ); if (!rows[0]) { return { providerId, config: {} }; @@ -291,16 +351,18 @@ export async function getProviderConfig( export async function upsertProviderConfig( providerId: LlmProviderId, - config: unknown + config: unknown, ): Promise { const database = await db(); const ts = nowDate(); const configJson = JSON.stringify(config ?? {}); + const ownerId = resolveTrustedOwnerId(); await database .insert(llmProviderConfigs) .values({ - id: providerId, + id: providerConfigId(ownerId, providerId), + ownerId, providerId, configJson, createdAt: ts, @@ -315,18 +377,27 @@ export async function upsertProviderConfig( } export async function listTokens( - providerId: LlmProviderId + providerId: LlmProviderId, ): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const rows = await database .select() .from(llmTokens) - .where(eq(llmTokens.providerId, providerId)); + .where( + and( + eq(llmTokens.ownerId, ownerId), + eq(llmTokens.providerId, providerId) + ) + ); return rows.map((r) => ({ id: r.id, providerId: r.providerId as LlmProviderId, name: r.name, tokenHint: r.tokenHint, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + lastUsedAt: r.lastUsedAt, })); } @@ -336,6 +407,7 @@ export async function createToken(params: { token: string; }): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const id = uuidv4(); const ts = nowDate(); const ciphertext = encryptSecret(params.token); @@ -343,6 +415,7 @@ export async function createToken(params: { await database.insert(llmTokens).values({ id, + ownerId, providerId: params.providerId, name: params.name, ciphertext, @@ -352,7 +425,15 @@ export async function createToken(params: { lastUsedAt: null, }); - return { id, providerId: params.providerId, name: params.name, tokenHint }; + return { + id, + providerId: params.providerId, + name: params.name, + tokenHint, + createdAt: ts, + updatedAt: ts, + lastUsedAt: null, + }; } export async function updateToken(params: { @@ -361,6 +442,7 @@ export async function updateToken(params: { token?: string; }): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const ts = nowDate(); const set: Partial = { updatedAt: ts }; @@ -372,16 +454,52 @@ export async function updateToken(params: { set.tokenHint = maskToken(params.token.trim()); } - await database.update(llmTokens).set(set).where(eq(llmTokens.id, params.id)); + await database + .update(llmTokens) + .set(set) + .where(and(eq(llmTokens.id, params.id), eq(llmTokens.ownerId, ownerId))); } export async function deleteToken(id: string): Promise { const database = await db(); - await database.delete(llmTokens).where(eq(llmTokens.id, id)); + const ownerId = resolveTrustedOwnerId(); + const ownedToken = await database + .select({ id: llmTokens.id }) + .from(llmTokens) + .where(and(eq(llmTokens.id, id), eq(llmTokens.ownerId, ownerId))) + .limit(1); + if (!ownedToken[0]) return; + + database.transaction((tx) => { + tx + .update(llmRuntimeSettings) + .set({ activeTokenId: null, updatedAt: nowDate() }) + .where( + and( + eq(llmRuntimeSettings.activeTokenId, id), + like(llmRuntimeSettings.scopeId, `${ownerId}:%`) + ) + ) + .run(); + tx + .update(llmRuntimeProviderState) + .set({ lastTokenId: null, updatedAt: nowDate() }) + .where( + and( + eq(llmRuntimeProviderState.lastTokenId, id), + like(llmRuntimeProviderState.scopeId, `${ownerId}:%`) + ) + ) + .run(); + tx + .delete(llmTokens) + .where(and(eq(llmTokens.id, id), eq(llmTokens.ownerId, ownerId))) + .run(); + }); } function classifyDecryptFailure( - error: unknown + error: unknown, ): Extract { const message = error instanceof Error ? error.message : String(error); const normalized = message.trim().toLowerCase(); @@ -399,13 +517,14 @@ function classifyDecryptFailure( } export async function getTokenPlaintextResult( - id: string + id: string, ): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); const rows = await database .select() .from(llmTokens) - .where(eq(llmTokens.id, id)); + .where(and(eq(llmTokens.id, id), eq(llmTokens.ownerId, ownerId))); const row = rows[0]; if (!row) return { status: "missing" }; try { @@ -433,8 +552,9 @@ export async function getTokenPlaintext(id: string): Promise { export async function touchTokenLastUsed(id: string): Promise { const database = await db(); + const ownerId = resolveTrustedOwnerId(); await database .update(llmTokens) .set({ lastUsedAt: nowDate(), updatedAt: nowDate() }) - .where(eq(llmTokens.id, id)); + .where(and(eq(llmTokens.id, id), eq(llmTokens.ownerId, ownerId))); } diff --git a/server/src/services/llm/llm-service.test.ts b/server/src/services/llm/llm-service.test.ts index 408573fb..291d8bbf 100644 --- a/server/src/services/llm/llm-service.test.ts +++ b/server/src/services/llm/llm-service.test.ts @@ -45,6 +45,7 @@ import { __resetTokenTouchThrottleForTests, checkProviderConnection, getModels, + getOpenRouterModelEndpoints, getProvidersForUi, getTokensForUi, streamGlobalChat, @@ -90,10 +91,7 @@ beforeEach(() => { })); mocks.axiosGet.mockResolvedValue({ data: { - data: [ - { id: "m1", name: "Model 1" }, - { id: "m2" }, - ], + data: [{ id: "m1", name: "Model 1" }, { id: "m2" }], }, }); mocks.llmGatewayStream.mockImplementation(async function* () { @@ -136,7 +134,7 @@ describe("llm-service", () => { providerId: "openrouter", scope: "global", scopeId: "global", - }) + }), ).resolves.toEqual([]); }); @@ -148,31 +146,105 @@ describe("llm-service", () => { providerId: "openrouter", scope: "global", scopeId: "global", - }) + }), ).resolves.toEqual([]); }); test("getModels fetches openrouter model list", async () => { + mocks.axiosGet.mockResolvedValueOnce({ + data: { + data: [ + { + id: "m1", + name: "Model 1", + context_length: 128000, + pricing: { prompt: "0.000001", completion: "0.000002" }, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text"], + }, + supported_parameters: ["tools", "reasoning"], + created: 123, + }, + { id: "m2" }, + ], + }, + }); const out = await getModels({ providerId: "openrouter", scope: "global", scopeId: "global", }); - expect(mocks.axiosGet).toHaveBeenCalledWith("https://openrouter.ai/api/v1/models", { - headers: { - "HTTP-Referer": "http://localhost:5000", - "X-Title": "TaleSpinner", - Authorization: "Bearer secret", + expect(mocks.axiosGet).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/models", + { + headers: { + "HTTP-Referer": "http://localhost:5000", + "X-Title": "TaleSpinner", + Authorization: "Bearer secret", + }, + timeout: 7000, }, - timeout: 7000, - }); + ); expect(out).toEqual([ - { id: "m1", name: "Model 1" }, + { + id: "m1", + name: "Model 1", + contextLength: 128000, + pricing: { prompt: "0.000001", completion: "0.000002" }, + inputModalities: ["text", "image"], + outputModalities: ["text"], + supportedParameters: ["tools", "reasoning"], + createdAt: 123, + }, { id: "m2", name: "m2" }, ]); }); + test("getOpenRouterModelEndpoints maps endpoint routing metadata", async () => { + mocks.axiosGet.mockResolvedValueOnce({ + data: { + data: { + endpoints: [ + { + name: "Google AI Studio | model", + provider_name: "Google AI Studio", + tag: "google-ai-studio", + context_length: 1048576, + max_completion_tokens: 65536, + quantization: "unknown", + pricing: { prompt: "0.0000005", completion: "0.000003" }, + supported_parameters: ["tools", "reasoning"], + uptime_last_30m: 99.8, + }, + ], + }, + }, + }); + + await expect( + getOpenRouterModelEndpoints({ modelId: "google/gemini-3-flash-preview" }), + ).resolves.toEqual([ + { + name: "Google AI Studio | model", + providerName: "Google AI Studio", + tag: "google-ai-studio", + contextLength: 1048576, + maxCompletionTokens: 65536, + quantization: "unknown", + pricing: { prompt: "0.0000005", completion: "0.000003" }, + supportedParameters: ["tools", "reasoning"], + uptimeLast30m: 99.8, + }, + ]); + + expect(mocks.axiosGet).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/models/google/gemini-3-flash-preview/endpoints", + { timeout: 7000 }, + ); + }); + test("getModels fetches openai-compatible model list via resolved baseUrl", async () => { const out = await getModels({ providerId: "openai_compatible", @@ -185,12 +257,15 @@ describe("llm-service", () => { token: "secret", providerConfig: {}, }); - expect(mocks.axiosGet).toHaveBeenCalledWith("http://localhost:1234/v1/models", { - headers: { - Authorization: "Bearer secret", + expect(mocks.axiosGet).toHaveBeenCalledWith( + "http://localhost:1234/v1/models", + { + headers: { + Authorization: "Bearer secret", + }, + timeout: 7000, }, - timeout: 7000, - }); + ); expect(out).toEqual([ { id: "m1", name: "Model 1" }, { id: "m2", name: "m2" }, @@ -207,7 +282,7 @@ describe("llm-service", () => { providerId: "openrouter", scope: "global", scopeId: "global", - }) + }), ).resolves.toEqual([]); expect(mocks.axiosGet).toHaveBeenCalledTimes(2); }); @@ -246,7 +321,9 @@ describe("llm-service", () => { checkedUrl: "http://localhost:1234/v1/models", statusCode: 404, }); - expect(result.hints).toContain("For OpenAI-compatible backends the Base URL usually ends with /v1."); + expect(result.hints).toContain( + "For OpenAI-compatible backends the Base URL usually ends with /v1.", + ); }); test("checkProviderConnection returns success payload with model count", async () => { @@ -285,13 +362,16 @@ describe("llm-service", () => { const error = await iter.next().then( () => null, - (err) => err + (err) => err, ); expect(error).toBeInstanceOf(HttpError); expect(error).toMatchObject({ code: "LLM_TOKEN_MISSING", }); - await expect(iter.next()).resolves.toEqual({ value: undefined, done: true }); + await expect(iter.next()).resolves.toEqual({ + value: undefined, + done: true, + }); }); test("streamGlobalChat throws HttpError when active token is not found", async () => { @@ -321,14 +401,20 @@ describe("llm-service", () => { await expect(iter.next()).rejects.toMatchObject({ code: "LLM_TOKEN_NOT_FOUND", - message: "Active token cannot be decrypted with current TOKENS_MASTER_KEY", + message: + "Active token cannot be decrypted with current TOKENS_MASTER_KEY", }); }); test("streamGlobalChat falls back to next token when pre-stream error occurs", async () => { mocks.listTokens.mockResolvedValueOnce([ { id: "tok-1", providerId: "openrouter", name: "main", tokenHint: "***" }, - { id: "tok-2", providerId: "openrouter", name: "backup", tokenHint: "***" }, + { + id: "tok-2", + providerId: "openrouter", + name: "backup", + tokenHint: "***", + }, ]); mocks.getProviderConfig.mockResolvedValueOnce({ providerId: "openrouter", @@ -352,24 +438,31 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, - }) + }), ); - expect(out).toEqual([{ content: "ok-from-second", reasoning: "", error: null }]); + expect(out).toEqual([ + { content: "ok-from-second", reasoning: "", error: null }, + ]); expect(mocks.buildGatewayStreamRequest).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ token: "secret-1" }) + expect.objectContaining({ token: "secret-1" }), ); expect(mocks.buildGatewayStreamRequest).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ token: "secret-2" }) + expect.objectContaining({ token: "secret-2" }), ); }); test("streamGlobalChat does not fallback when error happens after first chunk", async () => { mocks.listTokens.mockResolvedValueOnce([ { id: "tok-1", providerId: "openrouter", name: "main", tokenHint: "***" }, - { id: "tok-2", providerId: "openrouter", name: "backup", tokenHint: "***" }, + { + id: "tok-2", + providerId: "openrouter", + name: "backup", + tokenHint: "***", + }, ]); mocks.getProviderConfig.mockResolvedValueOnce({ providerId: "openrouter", @@ -389,7 +482,7 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, - }) + }), ); expect(out).toEqual([ @@ -403,8 +496,18 @@ describe("llm-service", () => { const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); mocks.listTokens.mockResolvedValueOnce([ { id: "tok-1", providerId: "openrouter", name: "main", tokenHint: "***" }, - { id: "tok-2", providerId: "openrouter", name: "backup-1", tokenHint: "***" }, - { id: "tok-3", providerId: "openrouter", name: "backup-2", tokenHint: "***" }, + { + id: "tok-2", + providerId: "openrouter", + name: "backup-1", + tokenHint: "***", + }, + { + id: "tok-3", + providerId: "openrouter", + name: "backup-2", + tokenHint: "***", + }, ]); mocks.getProviderConfig.mockResolvedValueOnce({ providerId: "openrouter", @@ -422,12 +525,12 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, - }) + }), ); expect(mocks.buildGatewayStreamRequest).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ token: "secret-tok-2" }) + expect.objectContaining({ token: "secret-tok-2" }), ); randomSpy.mockRestore(); }); @@ -441,7 +544,12 @@ describe("llm-service", () => { activeModel: null, }); mocks.listTokens.mockResolvedValueOnce([ - { id: "tok-2", providerId: "openrouter", name: "backup", tokenHint: "***" }, + { + id: "tok-2", + providerId: "openrouter", + name: "backup", + tokenHint: "***", + }, ]); mocks.getTokenPlaintextResult.mockImplementation(async (id: string) => { if (id === "tok-2") return { status: "ok", plaintext: "secret-2" }; @@ -456,10 +564,12 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, - }) + }), ); - expect(out).toEqual([{ content: "from-backup", reasoning: "", error: null }]); + expect(out).toEqual([ + { content: "from-backup", reasoning: "", error: null }, + ]); }); test("streamGlobalChat yields delta/reasoning/error events and stops on error", async () => { @@ -474,7 +584,7 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: { temperature: 0.5 }, - }) + }), ); expect(mocks.touchTokenLastUsed).toHaveBeenCalledWith("tok-1"); @@ -502,7 +612,11 @@ describe("llm-service", () => { }); const ac = new AbortController(); - const received: Array<{ content: string; reasoning: string; error: string | null }> = []; + const received: Array<{ + content: string; + reasoning: string; + error: string | null; + }> = []; for await (const evt of streamGlobalChat({ messages: [{ role: "user", content: "hi" }], settings: {}, @@ -512,7 +626,9 @@ describe("llm-service", () => { ac.abort(); } - expect(received).toEqual([{ content: "first", reasoning: "", error: null }]); + expect(received).toEqual([ + { content: "first", reasoning: "", error: null }, + ]); }); test("streamGlobalChat throttles touchTokenLastUsed for repeated immediate calls", async () => { @@ -524,13 +640,13 @@ describe("llm-service", () => { streamGlobalChat({ messages: [{ role: "user", content: "first" }], settings: {}, - }) + }), ); await collect( streamGlobalChat({ messages: [{ role: "user", content: "second" }], settings: {}, - }) + }), ); expect(mocks.touchTokenLastUsed).toHaveBeenCalledTimes(1); diff --git a/server/src/services/llm/llm-service.ts b/server/src/services/llm/llm-service.ts index 20165bcf..7fc8b7ce 100644 --- a/server/src/services/llm/llm-service.ts +++ b/server/src/services/llm/llm-service.ts @@ -22,13 +22,21 @@ import { type LlmRuntimeRow, type LlmScope, } from "./llm-repository"; +import { + listOpenRouterModelEndpoints, + listOpenRouterModels, +} from "./openrouter-catalog"; import type { GenerateMessage } from "@shared/types/generate"; -import type { LlmProviderConnectionCheckResult } from "@shared/types/llm"; +import type { + LlmModel, + LlmOpenRouterEndpoint, + LlmProviderConnectionCheckResult, +} from "@shared/types/llm"; export async function getRuntimeOrThrow( scope: LlmScope, - scopeId: string + scopeId: string, ): Promise { return getRuntime(scope, scopeId); } @@ -40,7 +48,7 @@ export async function getProvidersForUi(): Promise< } export async function getTokensForUi( - providerId: LlmProviderId + providerId: LlmProviderId, ): Promise> { const tokens = await listTokens(providerId); return tokens.map((t) => ({ @@ -60,7 +68,10 @@ const MODELS_REQUEST_RETRIES = 1; const TOKEN_LAST_USED_TOUCH_INTERVAL_MS = 60_000; const tokenLastTouchedAt = new Map(); -function resolveTokenPolicy(providerId: LlmProviderId, config: unknown): TokenPolicy { +function resolveTokenPolicy( + providerId: LlmProviderId, + config: unknown, +): TokenPolicy { if (providerId === "openrouter") { const parsed = openRouterConfigSchema.safeParse(config ?? {}); const policy = parsed.success ? parsed.data.tokenPolicy : undefined; @@ -124,7 +135,7 @@ function describeTokenLookupFailure(params: { async function fetchModelsWithRetry( url: string, - headers: Record + headers: Record, ): Promise> { let attempt = 0; let lastError: unknown = null; @@ -135,7 +146,10 @@ async function fetchModelsWithRetry( headers, timeout: MODELS_REQUEST_TIMEOUT_MS, }); - return (response.data?.data ?? []) as Array<{ id: string; name?: string }>; + return (response.data?.data ?? []) as Array<{ + id: string; + name?: string; + }>; } catch (error) { lastError = error; if (attempt === MODELS_REQUEST_RETRIES) { @@ -149,7 +163,7 @@ async function fetchModelsWithRetry( } function buildConnectionCheckResult( - params: LlmProviderConnectionCheckResult + params: LlmProviderConnectionCheckResult, ): LlmProviderConnectionCheckResult { return params; } @@ -157,7 +171,8 @@ function buildConnectionCheckResult( function readProviderErrorStatus(error: unknown): number | null { if (!error || typeof error !== "object") return null; const response = (error as { response?: { status?: unknown } }).response; - return typeof response?.status === "number" && Number.isFinite(response.status) + return typeof response?.status === "number" && + Number.isFinite(response.status) ? response.status : null; } @@ -174,7 +189,9 @@ function readProviderErrorMessage(error: unknown): string | null { } if (!error || typeof error !== "object") return null; const message = (error as { message?: unknown }).message; - return typeof message === "string" && message.trim().length > 0 ? message : null; + return typeof message === "string" && message.trim().length > 0 + ? message + : null; } function buildProviderConnectivityFailure(params: { @@ -299,7 +316,7 @@ export async function getModels(params: { scopeId: string; tokenId?: string | null; modelOverride?: string | null; -}): Promise> { +}): Promise { const runtime = await getRuntime(params.scope, params.scopeId); const tokenId = params.tokenId ?? runtime.activeTokenId; if (!tokenId) { @@ -315,14 +332,7 @@ export async function getModels(params: { const config = await getProviderConfig(params.providerId); try { if (params.providerId === "openrouter") { - const raw = await fetchModelsWithRetry("https://openrouter.ai/api/v1/models", { - "HTTP-Referer": "http://localhost:5000", - "X-Title": "TaleSpinner", - Authorization: `Bearer ${token}`, - }); - return raw - .filter((m) => typeof m?.id === "string" && m.id.length > 0) - .map((m) => ({ id: m.id, name: m.name ?? m.id })); + return await listOpenRouterModels(token); } const providerSpec = resolveGatewayProviderSpec({ @@ -349,6 +359,12 @@ export async function getModels(params: { } } +export async function getOpenRouterModelEndpoints(params: { + modelId: string; +}): Promise { + return listOpenRouterModelEndpoints(params.modelId); +} + export async function checkProviderConnection(params: { providerId: LlmProviderId; scope: LlmScope; @@ -366,7 +382,9 @@ export async function checkProviderConnection(params: { resolvedBaseUrl: null, issueCode: "TOKEN_MISSING", message: "Select a token before checking provider connectivity.", - hints: ["Open token manager or choose an existing token in the provider runtime section."], + hints: [ + "Open token manager or choose an existing token in the provider runtime section.", + ], }); } @@ -379,7 +397,9 @@ export async function checkProviderConnection(params: { issueCode: "TOKEN_DECRYPT_FAILED", message: "Selected token cannot be decrypted with the current TOKENS_MASTER_KEY.", - hints: ["Re-save the token with the current backend key or restore the original TOKENS_MASTER_KEY."], + hints: [ + "Re-save the token with the current backend key or restore the original TOKENS_MASTER_KEY.", + ], }); } @@ -390,7 +410,9 @@ export async function checkProviderConnection(params: { resolvedBaseUrl: null, issueCode: "TOKEN_NOT_FOUND", message: "Selected token was not found.", - hints: ["Pick another token or recreate the missing token in token manager."], + hints: [ + "Pick another token or recreate the missing token in token manager.", + ], }); } @@ -472,7 +494,7 @@ export async function checkProviderConnection(params: { ? (response.data.data as Array<{ id?: unknown; name?: unknown }>) : []; const modelCount = rawModels.filter( - (item) => typeof item?.id === "string" && item.id.length > 0 + (item) => typeof item?.id === "string" && item.id.length > 0, ).length; return buildConnectionCheckResult({ @@ -486,7 +508,9 @@ export async function checkProviderConnection(params: { hints: modelCount > 0 ? [] - : ["The provider responded successfully, but no models were returned for this token."], + : [ + "The provider responded successfully, but no models were returned for this token.", + ], checkedUrl, resolvedBaseUrl, statusCode: @@ -510,7 +534,11 @@ export async function* streamGlobalChat(params: { settings: Record; scopeId?: string; abortController?: AbortController; -}): AsyncGenerator<{ content: string; reasoning: string; error: string | null }> { +}): AsyncGenerator<{ + content: string; + reasoning: string; + error: string | null; +}> { const runtime = await getRuntime("global", params.scopeId ?? "global"); const providerId = runtime.activeProviderId; const config = await getProviderConfig(providerId); @@ -527,7 +555,7 @@ export async function* streamGlobalChat(params: { throw new HttpError( 400, "No active token configured for the selected provider", - "LLM_TOKEN_MISSING" + "LLM_TOKEN_MISSING", ); } @@ -624,6 +652,6 @@ export async function* streamGlobalChat(params: { throw new HttpError( 400, "No active token configured for the selected provider", - "LLM_TOKEN_MISSING" + "LLM_TOKEN_MISSING", ); } diff --git a/server/src/services/llm/openrouter-catalog.ts b/server/src/services/llm/openrouter-catalog.ts new file mode 100644 index 00000000..a919c5bb --- /dev/null +++ b/server/src/services/llm/openrouter-catalog.ts @@ -0,0 +1,146 @@ +import axios from "axios"; + +import type { LlmModel, LlmOpenRouterEndpoint } from "@shared/types/llm"; + +const OPENROUTER_API_URL = "https://openrouter.ai/api/v1"; +const REQUEST_TIMEOUT_MS = 7000; +const REQUEST_RETRIES = 1; + +type RawModel = { + id?: unknown; + name?: unknown; + context_length?: unknown; + pricing?: unknown; + architecture?: unknown; + supported_parameters?: unknown; + created?: unknown; +}; + +type RawEndpoint = { + name?: unknown; + provider_name?: unknown; + tag?: unknown; + context_length?: unknown; + max_completion_tokens?: unknown; + quantization?: unknown; + pricing?: unknown; + supported_parameters?: unknown; + uptime_last_30m?: unknown; +}; + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const items = value.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + return items.length > 0 ? items : undefined; +} + +function pricing(value: unknown): LlmModel["pricing"] { + const raw = asRecord(value); + const prompt = optionalString(raw.prompt); + const completion = optionalString(raw.completion); + return prompt || completion ? { prompt, completion } : undefined; +} + +function mapModel(raw: RawModel): LlmModel | null { + const id = optionalString(raw.id); + if (!id) return null; + const architecture = asRecord(raw.architecture); + return { + id, + name: optionalString(raw.name) ?? id, + contextLength: optionalNumber(raw.context_length), + pricing: pricing(raw.pricing), + inputModalities: stringArray(architecture.input_modalities), + outputModalities: stringArray(architecture.output_modalities), + supportedParameters: stringArray(raw.supported_parameters), + createdAt: optionalNumber(raw.created), + }; +} + +function mapEndpoint(raw: RawEndpoint): LlmOpenRouterEndpoint | null { + const tag = optionalString(raw.tag); + if (!tag) return null; + return { + name: optionalString(raw.name) ?? tag, + providerName: optionalString(raw.provider_name) ?? tag, + tag, + contextLength: optionalNumber(raw.context_length), + maxCompletionTokens: optionalNumber(raw.max_completion_tokens), + quantization: optionalString(raw.quantization), + pricing: pricing(raw.pricing), + supportedParameters: stringArray(raw.supported_parameters), + uptimeLast30m: optionalNumber(raw.uptime_last_30m), + }; +} + +async function getWithRetry( + url: string, + headers?: Record, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const response = await axios.get(url, { + ...(headers ? { headers } : {}), + timeout: REQUEST_TIMEOUT_MS, + }); + return response.data; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +export async function listOpenRouterModels(token: string): Promise { + const response = asRecord( + await getWithRetry(`${OPENROUTER_API_URL}/models`, { + "HTTP-Referer": "http://localhost:5000", + "X-Title": "TaleSpinner", + Authorization: `Bearer ${token}`, + }), + ); + const rows = Array.isArray(response.data) + ? (response.data as RawModel[]) + : []; + return rows.map(mapModel).filter((item): item is LlmModel => item !== null); +} + +export async function listOpenRouterModelEndpoints( + modelId: string, +): Promise { + const encodedModel = modelId + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); + const response = asRecord( + await getWithRetry( + `${OPENROUTER_API_URL}/models/${encodedModel}/endpoints`, + ), + ); + const data = asRecord(response.data); + const rows = Array.isArray(data.endpoints) + ? (data.endpoints as RawEndpoint[]) + : []; + return rows + .map(mapEndpoint) + .filter((item): item is LlmOpenRouterEndpoint => item !== null); +} diff --git a/server/src/services/operations/guard-operation-params.ts b/server/src/services/operations/guard-operation-params.ts index 059ba983..9b056bcf 100644 --- a/server/src/services/operations/guard-operation-params.ts +++ b/server/src/services/operations/guard-operation-params.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { OPERATION_RESOURCE_LIMITS } from "./operation-resource-limits"; + import type { GuardAuxLlmParams, GuardLiquidParams, @@ -26,6 +28,7 @@ const guardOutputDefinitionSchema: z.ZodType = z.object({ export const guardOutputContractSchema: z.ZodType = z .array(guardOutputDefinitionSchema) .min(1) + .max(32) .superRefine((items, ctx) => { const seen = new Set(); for (const item of items) { @@ -69,7 +72,7 @@ const samplersSchema: z.ZodType = z const retrySchema: z.ZodType = z .object({ - maxAttempts: z.number().int().min(1).max(10), + maxAttempts: z.number().int().min(1).max(3), backoffMs: z.number().int().min(0).max(120_000).optional(), retryOn: z.array(retryOnSchema).min(1).optional(), }) @@ -79,7 +82,7 @@ export const liquidGuardParamsSchema = z .object({ engine: z.literal("liquid"), outputContract: guardOutputContractSchema, - template: z.string(), + template: z.string().max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), }) .strict(); @@ -91,11 +94,14 @@ export const auxLlmGuardParamsSchema = z providerId: z.enum(["openrouter", "openai_compatible"]), credentialRef: z.string().trim().min(1), model: z.string().trim().min(1).optional(), - system: z.string().optional(), - prompt: z.string().min(1), + system: z.string().max(OPERATION_RESOURCE_LIMITS.templateCharacters).optional(), + prompt: z + .string() + .min(1) + .max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), samplers: samplersSchema.optional(), - timeoutMs: z.number().int().min(1).max(300_000).optional(), + timeoutMs: z.number().int().min(1).max(120_000).optional(), retry: retrySchema.optional(), }) .strict(); diff --git a/server/src/services/operations/llm-operation-params.ts b/server/src/services/operations/llm-operation-params.ts index 4e5d514e..fdf9d807 100644 --- a/server/src/services/operations/llm-operation-params.ts +++ b/server/src/services/operations/llm-operation-params.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { + OPERATION_RESOURCE_LIMITS, + serializedJsonByteLength, +} from "./operation-resource-limits"; + import type { LlmJsonParseMode, LlmOperationParams, @@ -44,7 +49,7 @@ const samplersSchema: z.ZodType = z const retrySchema: z.ZodType = z .object({ - maxAttempts: z.number().int().min(1).max(10), + maxAttempts: z.number().int().min(1).max(3), backoffMs: z.number().int().min(0).max(120_000).optional(), retryOn: z.array(retryOnSchema).min(1).optional(), }) @@ -55,21 +60,45 @@ export const llmOperationParamsSchema: z.ZodType = z providerId: z.enum(["openrouter", "openai_compatible"]), credentialRef: z.string().trim().min(1), model: z.string().trim().min(1).optional(), - system: z.string().optional(), - prompt: z.string().min(1), + llmPresetId: z.string().trim().min(1).optional(), + system: z.string().max(OPERATION_RESOURCE_LIMITS.templateCharacters).optional(), + prompt: z + .string() + .min(1) + .max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), outputMode: z.enum(["text", "json"]).optional(), jsonSchema: z.unknown().optional(), strictSchemaValidation: z.boolean().optional(), jsonParseMode: jsonParseModeSchema.optional(), - jsonCustomPattern: z.string().trim().min(1).optional(), + jsonCustomPattern: z.string().trim().min(1).max(2_000).optional(), jsonCustomFlags: z.string().trim().optional(), samplerPresetId: z.string().trim().min(1).optional(), samplers: samplersSchema.optional(), - timeoutMs: z.number().int().min(1).max(300_000).optional(), + timeoutMs: z.number().int().min(1).max(120_000).optional(), retry: retrySchema.optional(), }) .superRefine((value, ctx) => { + if (typeof value.jsonSchema !== "undefined") { + let schemaBytes = Number.POSITIVE_INFINITY; + try { + schemaBytes = serializedJsonByteLength(value.jsonSchema); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["jsonSchema"], + message: "jsonSchema must be JSON serializable", + }); + } + if (schemaBytes > OPERATION_RESOURCE_LIMITS.jsonSchemaBytes) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["jsonSchema"], + message: `jsonSchema exceeds ${OPERATION_RESOURCE_LIMITS.jsonSchemaBytes} bytes`, + }); + } + } + if (value.outputMode !== "json" && typeof value.jsonParseMode !== "undefined") { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/server/src/services/operations/operation-block-validator.test.ts b/server/src/services/operations/operation-block-validator.test.ts index 3582a909..6c435e26 100644 --- a/server/src/services/operations/operation-block-validator.test.ts +++ b/server/src/services/operations/operation-block-validator.test.ts @@ -5,6 +5,71 @@ import { describe, expect, test } from "vitest"; import { validateOperationBlockUpsertInput } from "./operation-block-validator"; describe("operation block validator", () => { + test("rejects blocks with more than 64 operations", () => { + const operations = Array.from({ length: 65 }, (_, index) => ({ + opId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + name: `operation-${index}`, + kind: "template" as const, + config: { + enabled: true, + required: false, + hooks: ["before_main_llm" as const], + order: index, + params: { template: "ok" }, + }, + })); + + expect(() => + validateOperationBlockUpsertInput({ + name: "oversized", + enabled: true, + operations, + }) + ).toThrow(/Validation error/); + }); + + test("rejects oversized templates and artifact histories", () => { + const base = { + name: "block", + enabled: true, + operations: [ + { + opId: "6ff77029-5037-4d21-8ace-c9836f58a14b", + name: "template-op", + kind: "template" as const, + config: { + enabled: true, + required: false, + hooks: ["before_main_llm" as const], + order: 10, + params: { + template: "x".repeat(100_001), + }, + }, + }, + ], + }; + expect(() => validateOperationBlockUpsertInput(base)).toThrow(/Validation error/); + + const withLargeHistory = structuredClone(base); + withLargeHistory.operations[0]!.config.params.template = "ok"; + Object.assign(withLargeHistory.operations[0]!.config.params, { + artifact: { + artifactId: "artifact:test", + tag: "test", + title: "Test", + format: "text", + persistence: "run_only", + writeMode: "replace", + history: { enabled: true, maxItems: 101 }, + exposures: [], + }, + }); + expect(() => validateOperationBlockUpsertInput(withLargeHistory)).toThrow( + /Validation error/ + ); + }); + test("rejects legacy operation kind", () => { expect(() => validateOperationBlockUpsertInput({ @@ -68,6 +133,40 @@ describe("operation block validator", () => { expect(out.operations[0]?.config.params.artifact.tag).toBe("world_state"); }); + test("preserves the selected LLM preset id", () => { + const out = validateOperationBlockUpsertInput({ + name: "block", + enabled: true, + operations: [ + { + opId: "6ff77029-5037-4d21-8ace-c9836f58a14b", + name: "llm-op", + kind: "llm", + config: { + enabled: true, + required: false, + hooks: ["before_main_llm"], + order: 10, + params: { + params: { + providerId: "openrouter", + credentialRef: "credential-1", + model: "model-1", + llmPresetId: "preset-1", + prompt: "Hello", + }, + }, + }, + }, + ], + }); + + const operation = out.operations[0]; + expect(operation?.kind).toBe("llm"); + if (operation?.kind !== "llm") throw new Error("Expected LLM operation"); + expect(operation.config.params.params.llmPresetId).toBe("preset-1"); + }); + test("rejects dependency to unknown opId", () => { expect(() => validateOperationBlockUpsertInput({ diff --git a/server/src/services/operations/operation-block-validator.ts b/server/src/services/operations/operation-block-validator.ts index 5bf4425f..92ac8867 100644 --- a/server/src/services/operations/operation-block-validator.ts +++ b/server/src/services/operations/operation-block-validator.ts @@ -35,6 +35,7 @@ import { } from "./knowledge-operation-params"; import { compileLlmJsonSchemaSpec } from "./llm-json-schema-spec"; import { llmOperationParamsSchema } from "./llm-operation-params"; +import { OPERATION_RESOURCE_LIMITS } from "./operation-resource-limits"; import type { KnowledgeRevealOperationParams, @@ -135,10 +136,16 @@ const artifactConfigSchema = z.object({ writeMode: artifactWriteModeSchema, history: z.object({ enabled: z.boolean(), - maxItems: z.number().int().min(1), + maxItems: z + .number() + .int() + .min(1) + .max(OPERATION_RESOURCE_LIMITS.artifactHistoryItems), }), semantics: z.string().trim().min(1).optional(), - exposures: z.array(artifactExposureSchema), + exposures: z + .array(artifactExposureSchema) + .max(OPERATION_RESOURCE_LIMITS.exposuresPerArtifact), }); const legacyArtifactWriteTargetSchema = z.object({ @@ -186,7 +193,7 @@ const legacyOperationOutputSchema = z.discriminatedUnion("type", [ ]); const templateParamsSchema = z.object({ - template: z.string(), + template: z.string().max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), artifact: artifactConfigSchema.optional(), output: legacyOperationOutputSchema.optional(), @@ -219,8 +226,14 @@ const operationConfigBaseSchema = z.object({ triggers: z.array(operationTriggerSchema).min(1).optional(), activation: operationActivationSchema.optional(), order: z.number().finite(), - dependsOn: z.array(uuidSchema).optional(), - runConditions: z.array(runConditionSchema).optional(), + dependsOn: z + .array(uuidSchema) + .max(OPERATION_RESOURCE_LIMITS.dependenciesPerOperation) + .optional(), + runConditions: z + .array(runConditionSchema) + .max(OPERATION_RESOURCE_LIMITS.runConditionsPerOperation) + .optional(), }); const operationConfigTemplateSchema = operationConfigBaseSchema.extend({ @@ -234,7 +247,10 @@ const operationConfigOtherSchema = operationConfigBaseSchema.extend({ const knowledgeRequestSourceSchema = z.discriminatedUnion("mode", [ z.object({ mode: z.literal("inline"), - requestTemplate: z.string().min(1), + requestTemplate: z + .string() + .min(1) + .max(OPERATION_RESOURCE_LIMITS.templateCharacters), strictVariables: z.boolean().optional(), }), z.object({ @@ -373,7 +389,9 @@ const upsertInputSchema: z.ZodType = z.object({ name: z.string().trim().min(1), description: z.string().trim().min(1).optional(), enabled: z.boolean(), - operations: z.array(operationInProfileSchema), + operations: z + .array(operationInProfileSchema) + .max(OPERATION_RESOURCE_LIMITS.operationsPerBlock), meta: z.unknown().optional(), }); diff --git a/server/src/services/operations/operation-blocks-repository.ts b/server/src/services/operations/operation-blocks-repository.ts index 7a710dc2..586f469b 100644 --- a/server/src/services/operations/operation-blocks-repository.ts +++ b/server/src/services/operations/operation-blocks-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, asc, eq } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { operationBlocks } from "../../db/schema"; @@ -57,37 +58,43 @@ export function resolveImportedOperationBlockName(input: string, existingNames: return `${base} (imported ${Date.now()})`; } -export async function listOperationBlocks(params?: { - ownerId?: string; +export async function listOperationBlocks(params: { + ownerId: string; }): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; const rows = await db .select() .from(operationBlocks) - .where(eq(operationBlocks.ownerId, ownerId)) + .where(eq(operationBlocks.ownerId, params.ownerId)) .orderBy(asc(operationBlocks.name)); return rows.map(rowToDto); } export async function getOperationBlockById( - id: string + params: { ownerId: string; blockId: string } ): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); const rows = await db .select() .from(operationBlocks) - .where(eq(operationBlocks.id, id)) + .where( + and( + eq(operationBlocks.id, params.blockId), + eq(operationBlocks.ownerId, params.ownerId) + ) + ) .limit(1); return rows[0] ? rowToDto(rows[0]) : null; } export async function createOperationBlock(params: { - ownerId?: string; + ownerId: string; input: OperationBlockUpsertInput; }): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const ownerId = params.ownerId ?? "global"; const ts = new Date(); const blockId = uuidv4(); @@ -95,7 +102,7 @@ export async function createOperationBlock(params: { await db.insert(operationBlocks).values({ id: blockId, - ownerId, + ownerId: params.ownerId, name: validated.name, description: validated.description ?? null, enabled: validated.enabled, @@ -106,11 +113,14 @@ export async function createOperationBlock(params: { updatedAt: ts, }); - const created = await getOperationBlockById(blockId); + const created = await getOperationBlockById({ + ownerId: params.ownerId, + blockId, + }); if (created) return created; return { blockId, - ownerId, + ownerId: params.ownerId, name: validated.name, description: validated.description, enabled: validated.enabled, @@ -123,13 +133,16 @@ export async function createOperationBlock(params: { } export async function updateOperationBlock(params: { - ownerId?: string; + ownerId: string; blockId: string; patch: Partial; }): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const ownerId = params.ownerId ?? "global"; - const current = await getOperationBlockById(params.blockId); + const current = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: params.blockId, + }); if (!current) return null; const nextInput: OperationBlockUpsertInput = { @@ -163,18 +176,33 @@ export async function updateOperationBlock(params: { metaJson: validated.meta === null ? null : safeJsonStringify(validated.meta), updatedAt: ts, }) - .where(and(eq(operationBlocks.id, params.blockId), eq(operationBlocks.ownerId, ownerId))); - - return getOperationBlockById(params.blockId); + .where( + and( + eq(operationBlocks.id, params.blockId), + eq(operationBlocks.ownerId, params.ownerId) + ) + ); + + return getOperationBlockById({ + ownerId: params.ownerId, + blockId: params.blockId, + }); } export async function deleteOperationBlock(params: { - ownerId?: string; + ownerId: string; blockId: string; -}): Promise { +}): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const ownerId = params.ownerId ?? "global"; - await db + const deleted = await db .delete(operationBlocks) - .where(and(eq(operationBlocks.id, params.blockId), eq(operationBlocks.ownerId, ownerId))); + .where( + and( + eq(operationBlocks.id, params.blockId), + eq(operationBlocks.ownerId, params.ownerId) + ) + ) + .returning({ id: operationBlocks.id }); + return deleted.length > 0; } diff --git a/server/src/services/operations/operation-owner-scope.integration.test.ts b/server/src/services/operations/operation-owner-scope.integration.test.ts new file mode 100644 index 00000000..94a4c90e --- /dev/null +++ b/server/src/services/operations/operation-owner-scope.integration.test.ts @@ -0,0 +1,149 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { exportOperationProfileBundle } from "../../application/operations/use-cases/export-operation-profile"; +import { setActiveOperationProfileWithValidation } from "../../application/operations/use-cases/set-active-operation-profile"; +import { applyMigrations } from "../../db/apply-migrations"; +import { initDb, resetDbForTests } from "../../db/client"; + +import { + createOperationBlock, + deleteOperationBlock, + getOperationBlockById, + updateOperationBlock, +} from "./operation-blocks-repository"; +import { + getOperationProfileSettings, + setActiveOperationProfile, +} from "./operation-profile-settings-repository"; +import { + createOperationProfile, + getOperationProfileById, + updateOperationProfile, +} from "./operation-profiles-repository"; + +describe("operation owner scope", () => { + let tempDir = ""; + + beforeEach(async () => { + resetDbForTests(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "talespinner-operation-owner-")); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("blocks cannot be read, updated, or deleted by another owner", async () => { + const block = await createOperationBlock({ + ownerId: "owner-a", + input: { name: "Private block", enabled: true, operations: [] }, + }); + + expect( + await getOperationBlockById({ ownerId: "owner-b", blockId: block.blockId }) + ).toBeNull(); + expect( + await updateOperationBlock({ + ownerId: "owner-b", + blockId: block.blockId, + patch: { name: "Stolen" }, + }) + ).toBeNull(); + expect( + await deleteOperationBlock({ ownerId: "owner-b", blockId: block.blockId }) + ).toBe(false); + + const original = await getOperationBlockById({ + ownerId: "owner-a", + blockId: block.blockId, + }); + expect(original?.name).toBe("Private block"); + }); + + test("profiles cannot reference, read, update, or export another owner's data", async () => { + const block = await createOperationBlock({ + ownerId: "owner-a", + input: { name: "Owner A block", enabled: true, operations: [] }, + }); + const profile = await createOperationProfile({ + ownerId: "owner-a", + input: { + name: "Owner A profile", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "11111111-1111-4111-8111-111111111111", + blockRefs: [{ blockId: block.blockId, enabled: true, order: 0 }], + }, + }); + + await expect( + createOperationProfile({ + ownerId: "owner-b", + input: { + name: "Cross-owner profile", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "22222222-2222-4222-8222-222222222222", + blockRefs: [{ blockId: block.blockId, enabled: true, order: 0 }], + }, + }) + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + expect( + await getOperationProfileById({ + ownerId: "owner-b", + profileId: profile.profileId, + }) + ).toBeNull(); + expect( + await updateOperationProfile({ + ownerId: "owner-b", + profileId: profile.profileId, + patch: { name: "Stolen" }, + }) + ).toBeNull(); + await expect( + exportOperationProfileBundle({ + ownerId: "owner-b", + profileId: profile.profileId, + }) + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + test("active profile settings are isolated by owner", async () => { + const profile = await createOperationProfile({ + ownerId: "owner-a", + input: { + name: "Owner A profile", + enabled: true, + executionMode: "sequential", + operationProfileSessionId: "11111111-1111-4111-8111-111111111111", + blockRefs: [], + }, + }); + + await setActiveOperationProfile({ + ownerId: "owner-a", + activeProfileId: profile.profileId, + }); + + expect(await getOperationProfileSettings({ ownerId: "owner-a" })).toMatchObject({ + activeProfileId: profile.profileId, + }); + expect(await getOperationProfileSettings({ ownerId: "owner-b" })).toMatchObject({ + activeProfileId: null, + }); + await expect( + setActiveOperationProfileWithValidation({ + ownerId: "owner-b", + activeProfileId: profile.profileId, + }) + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); diff --git a/server/src/services/operations/operation-profile-resolver.test.ts b/server/src/services/operations/operation-profile-resolver.test.ts index d1bdd85c..f2828948 100644 --- a/server/src/services/operations/operation-profile-resolver.test.ts +++ b/server/src/services/operations/operation-profile-resolver.test.ts @@ -12,7 +12,10 @@ import { resolveCompiledOperationProfile } from "./operation-profile-resolver"; const blockById = new Map(); vi.mock("./operation-blocks-repository", () => ({ - getOperationBlockById: vi.fn(async (id: string) => blockById.get(id) ?? null), + getOperationBlockById: vi.fn( + async (params: { ownerId: string; blockId: string }) => + blockById.get(params.blockId) ?? null + ), })); function makeBlock(params: { diff --git a/server/src/services/operations/operation-profile-resolver.ts b/server/src/services/operations/operation-profile-resolver.ts index 34b9dc1a..1535d1ce 100644 --- a/server/src/services/operations/operation-profile-resolver.ts +++ b/server/src/services/operations/operation-profile-resolver.ts @@ -4,6 +4,7 @@ import { HttpError } from "@core/middleware/error-handler"; import { validateCompiledProfileArtifactWriters } from "./operation-block-validator"; import { getOperationBlockById } from "./operation-blocks-repository"; +import { OPERATION_RESOURCE_LIMITS } from "./operation-resource-limits"; import type { OperationArtifactConfig, @@ -22,6 +23,19 @@ export type CompiledOperationProfile = { blockVersionFingerprint: string; }; +function assertCompiledOperationCount(profileId: string, operationCount: number): void { + if (operationCount <= OPERATION_RESOURCE_LIMITS.operationsPerProfile) return; + throw new HttpError( + 400, + `Compiled profile exceeds ${OPERATION_RESOURCE_LIMITS.operationsPerProfile} operations`, + "VALIDATION_ERROR", + { + profileId, + operationCount, + } + ); +} + function normalizeOrder(value: number): number { if (!Number.isFinite(value)) return 0; return Math.trunc(value); @@ -82,7 +96,10 @@ async function resolveBlocks( }); const out: Array<{ refOrder: number; block: OperationBlock }> = []; for (const ref of enabledRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: profile.ownerId, + blockId: ref.blockId, + }); if (!block) { throw new HttpError(400, "Operation block not found", "VALIDATION_ERROR", { blockId: ref.blockId, @@ -100,6 +117,7 @@ export async function resolveCompiledOperationProfile( ): Promise { if (!Array.isArray(profile.blockRefs) || profile.blockRefs.length === 0) { const operations = profile.operations ?? []; + assertCompiledOperationCount(profile.profileId, operations.length); validateCompiledProfileArtifactWriters({ ...profile, operations }); return { profile, @@ -126,6 +144,8 @@ export async function resolveCompiledOperationProfile( } }); + assertCompiledOperationCount(profile.profileId, operations.length); + const blockVersionFingerprint = blockVersions .map((item) => `${item.blockId}:${item.version}`) .join("|"); diff --git a/server/src/services/operations/operation-profile-settings-repository.ts b/server/src/services/operations/operation-profile-settings-repository.ts index 70addf61..7dc2c613 100644 --- a/server/src/services/operations/operation-profile-settings-repository.ts +++ b/server/src/services/operations/operation-profile-settings-repository.ts @@ -1,5 +1,6 @@ import { eq } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { operationProfileSettings } from "../../db/schema"; @@ -8,14 +9,13 @@ export type OperationProfileSettingsDto = { updatedAt: Date; }; -const SETTINGS_ROW_ID = "global"; - -async function ensureSettingsRow(): Promise { +async function ensureSettingsRow(ownerId: string): Promise { + ownerId = resolveTrustedOwnerId(ownerId); const db = await initDb(); const rows = await db .select() .from(operationProfileSettings) - .where(eq(operationProfileSettings.id, SETTINGS_ROW_ID)) + .where(eq(operationProfileSettings.id, ownerId)) .limit(1); if (rows[0]) { @@ -27,7 +27,7 @@ async function ensureSettingsRow(): Promise { const now = new Date(); await db.insert(operationProfileSettings).values({ - id: SETTINGS_ROW_ID, + id: ownerId, activeProfileId: null, updatedAt: now, }); @@ -35,15 +35,20 @@ async function ensureSettingsRow(): Promise { return { activeProfileId: null, updatedAt: now }; } -export async function getOperationProfileSettings(): Promise { - return ensureSettingsRow(); +export async function getOperationProfileSettings(params: { + ownerId: string; +}): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; + return ensureSettingsRow(params.ownerId); } export async function setActiveOperationProfile(params: { + ownerId: string; activeProfileId: string | null; }): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const current = await ensureSettingsRow(); + const current = await ensureSettingsRow(params.ownerId); const now = new Date(); await db @@ -52,7 +57,7 @@ export async function setActiveOperationProfile(params: { activeProfileId: params.activeProfileId, updatedAt: now, }) - .where(eq(operationProfileSettings.id, SETTINGS_ROW_ID)); + .where(eq(operationProfileSettings.id, params.ownerId)); return { ...current, activeProfileId: params.activeProfileId, updatedAt: now }; } diff --git a/server/src/services/operations/operation-profile-validator.test.ts b/server/src/services/operations/operation-profile-validator.test.ts index 6b4d8577..e26879eb 100644 --- a/server/src/services/operations/operation-profile-validator.test.ts +++ b/server/src/services/operations/operation-profile-validator.test.ts @@ -6,6 +6,24 @@ import { } from "./operation-profile-validator"; describe("operation profile validator", () => { + test("rejects profiles with more than 16 block references", () => { + const blockRefs = Array.from({ length: 17 }, (_, index) => ({ + blockId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + enabled: true, + order: index, + })); + + expect(() => + validateOperationProfileUpsertInput({ + name: "oversized", + enabled: true, + executionMode: "concurrent", + operationProfileSessionId: "2d9f1f5c-6f38-4f94-9caa-0ea4f36f2db8", + blockRefs, + }) + ).toThrow(/Validation error/); + }); + test("accepts profile with unique block refs", () => { const out = validateOperationProfileUpsertInput({ name: "profile", diff --git a/server/src/services/operations/operation-profile-validator.ts b/server/src/services/operations/operation-profile-validator.ts index 889e5ad0..06f2fd44 100644 --- a/server/src/services/operations/operation-profile-validator.ts +++ b/server/src/services/operations/operation-profile-validator.ts @@ -8,6 +8,7 @@ import { operationInProfileSchema, type ValidatedOperationBlockInput, } from "./operation-block-validator"; +import { OPERATION_RESOURCE_LIMITS } from "./operation-resource-limits"; import type { OperationExecutionMode, @@ -32,7 +33,7 @@ const upsertInputSchema: z.ZodType = z.object({ enabled: z.boolean(), executionMode: executionModeSchema, operationProfileSessionId: uuidSchema, - blockRefs: z.array(blockRefSchema), + blockRefs: z.array(blockRefSchema).max(OPERATION_RESOURCE_LIMITS.blocksPerProfile), meta: z.unknown().optional(), }); @@ -93,7 +94,7 @@ const bundleProfileSchema: z.ZodType = z.object({ enabled: z.boolean(), executionMode: executionModeSchema, operationProfileSessionId: uuidSchema, - blockRefs: z.array(blockRefSchema), + blockRefs: z.array(blockRefSchema).max(OPERATION_RESOURCE_LIMITS.blocksPerProfile), meta: z.unknown().optional(), }); @@ -106,9 +107,11 @@ const bundleImportSchema: z.ZodType = z.object({ name: z.string(), description: z.string().optional(), enabled: z.boolean(), - operations: z.array(operationInProfileSchema), + operations: z + .array(operationInProfileSchema) + .max(OPERATION_RESOURCE_LIMITS.operationsPerBlock), meta: z.unknown().optional(), - })), + })).max(OPERATION_RESOURCE_LIMITS.blocksPerProfile), }); const legacyImportSchema: z.ZodType = z.object({ @@ -118,7 +121,9 @@ const legacyImportSchema: z.ZodType = z.object({ enabled: z.boolean(), executionMode: executionModeSchema, operationProfileSessionId: uuidSchema, - operations: z.array(operationInProfileSchema), + operations: z + .array(operationInProfileSchema) + .max(OPERATION_RESOURCE_LIMITS.operationsPerProfile), meta: z.unknown().optional(), }); diff --git a/server/src/services/operations/operation-profiles-repository.ts b/server/src/services/operations/operation-profiles-repository.ts index c234f9e9..61667880 100644 --- a/server/src/services/operations/operation-profiles-repository.ts +++ b/server/src/services/operations/operation-profiles-repository.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, asc, eq } from "drizzle-orm"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; @@ -45,43 +46,52 @@ function rowToDto(row: typeof operationProfiles.$inferSelect): OperationProfile }; } -export async function listOperationProfiles(params?: { - ownerId?: string; +export async function listOperationProfiles(params: { + ownerId: string; }): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; const rows = await db .select() .from(operationProfiles) - .where(eq(operationProfiles.ownerId, ownerId)) + .where(eq(operationProfiles.ownerId, params.ownerId)) .orderBy(asc(operationProfiles.name)); return rows.map(rowToDto); } export async function getOperationProfileById( - id: string + params: { ownerId: string; profileId: string } ): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); const rows = await db .select() .from(operationProfiles) - .where(eq(operationProfiles.id, id)) + .where( + and( + eq(operationProfiles.id, params.profileId), + eq(operationProfiles.ownerId, params.ownerId) + ) + ) .limit(1); return rows[0] ? rowToDto(rows[0]) : null; } export async function createOperationProfile(params: { - ownerId?: string; + ownerId: string; input: OperationProfileUpsertInput; }): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const ownerId = params.ownerId ?? "global"; const ts = new Date(); const profileId = uuidv4(); const validated = validateOperationProfileUpsertInput(params.input); for (const ref of validated.blockRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: ref.blockId, + }); if (!block) { throw new HttpError(400, "Unknown blockId in profile", "VALIDATION_ERROR", { blockId: ref.blockId, @@ -91,7 +101,7 @@ export async function createOperationProfile(params: { await db.insert(operationProfiles).values({ id: profileId, - ownerId, + ownerId: params.ownerId, name: validated.name, description: validated.description ?? null, enabled: validated.enabled, @@ -104,11 +114,14 @@ export async function createOperationProfile(params: { updatedAt: ts, }); - const created = await getOperationProfileById(profileId); + const created = await getOperationProfileById({ + ownerId: params.ownerId, + profileId, + }); if (created) return created; return { profileId, - ownerId, + ownerId: params.ownerId, name: validated.name, description: validated.description, enabled: validated.enabled, @@ -123,13 +136,16 @@ export async function createOperationProfile(params: { } export async function updateOperationProfile(params: { - ownerId?: string; + ownerId: string; profileId: string; patch: Partial; }): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const ownerId = params.ownerId ?? "global"; - const current = await getOperationProfileById(params.profileId); + const current = await getOperationProfileById({ + ownerId: params.ownerId, + profileId: params.profileId, + }); if (!current) return null; const nextInput: OperationProfileUpsertInput = { @@ -157,7 +173,10 @@ export async function updateOperationProfile(params: { const validated = validateOperationProfileUpsertInput(nextInput); for (const ref of validated.blockRefs) { - const block = await getOperationBlockById(ref.blockId); + const block = await getOperationBlockById({ + ownerId: params.ownerId, + blockId: ref.blockId, + }); if (!block) { throw new HttpError(400, "Unknown blockId in profile", "VALIDATION_ERROR", { blockId: ref.blockId, @@ -181,19 +200,34 @@ export async function updateOperationProfile(params: { metaJson: validated.meta === null ? null : safeJsonStringify(validated.meta), updatedAt: ts, }) - .where(and(eq(operationProfiles.id, params.profileId), eq(operationProfiles.ownerId, ownerId))); - - return getOperationProfileById(params.profileId); + .where( + and( + eq(operationProfiles.id, params.profileId), + eq(operationProfiles.ownerId, params.ownerId) + ) + ); + + return getOperationProfileById({ + ownerId: params.ownerId, + profileId: params.profileId, + }); } export async function deleteOperationProfile(params: { - ownerId?: string; + ownerId: string; profileId: string; -}): Promise { +}): Promise { + params = { ...params, ownerId: resolveTrustedOwnerId(params.ownerId) }; const db = await initDb(); - const ownerId = params.ownerId ?? "global"; - await db + const deleted = await db .delete(operationProfiles) - .where(and(eq(operationProfiles.id, params.profileId), eq(operationProfiles.ownerId, ownerId))); + .where( + and( + eq(operationProfiles.id, params.profileId), + eq(operationProfiles.ownerId, params.ownerId) + ) + ) + .returning({ id: operationProfiles.id }); + return deleted.length > 0; } diff --git a/server/src/services/operations/operation-resource-limits.ts b/server/src/services/operations/operation-resource-limits.ts new file mode 100644 index 00000000..5fb2fbb9 --- /dev/null +++ b/server/src/services/operations/operation-resource-limits.ts @@ -0,0 +1,68 @@ +export const OPERATION_RESOURCE_LIMITS = { + concurrentTasks: 4, + operationsPerBlock: 64, + operationsPerProfile: 128, + blocksPerProfile: 16, + dependenciesPerOperation: 32, + runConditionsPerOperation: 32, + exposuresPerArtifact: 16, + artifactHistoryItems: 100, + templateCharacters: 100_000, + jsonSchemaBytes: 100_000, + llmOutputBytes: 256 * 1024, + artifactValueBytes: 256 * 1024, + artifactHistoryBytes: 1024 * 1024, +} as const; + +type ResourceLimitError = Error & { code: string }; + +function createResourceLimitError(code: string, message: string): ResourceLimitError { + const error = new Error(message) as ResourceLimitError; + error.code = code; + return error; +} + +export function serializedJsonByteLength(value: unknown): number { + const serialized = JSON.stringify(value); + return Buffer.byteLength(serialized ?? "null", "utf8"); +} + +export function assertArtifactValueWithinLimits(value: unknown): void { + let bytes: number; + try { + bytes = serializedJsonByteLength(value); + } catch { + throw createResourceLimitError( + "ARTIFACT_VALUE_NOT_SERIALIZABLE", + "Artifact value must be JSON serializable" + ); + } + if (bytes > OPERATION_RESOURCE_LIMITS.artifactValueBytes) { + throw createResourceLimitError( + "ARTIFACT_VALUE_TOO_LARGE", + `Artifact value exceeds ${OPERATION_RESOURCE_LIMITS.artifactValueBytes} bytes` + ); + } +} + +export function assertArtifactHistoryItemLimit(maxItems: number): void { + if ( + !Number.isInteger(maxItems) || + maxItems < 1 || + maxItems > OPERATION_RESOURCE_LIMITS.artifactHistoryItems + ) { + throw createResourceLimitError( + "ARTIFACT_HISTORY_LIMIT_INVALID", + `Artifact history maxItems must be between 1 and ${OPERATION_RESOURCE_LIMITS.artifactHistoryItems}` + ); + } +} + +export function assertArtifactHistoryWithinLimits(history: unknown[]): void { + if (serializedJsonByteLength(history) > OPERATION_RESOURCE_LIMITS.artifactHistoryBytes) { + throw createResourceLimitError( + "ARTIFACT_HISTORY_TOO_LARGE", + `Artifact history exceeds ${OPERATION_RESOURCE_LIMITS.artifactHistoryBytes} bytes` + ); + } +} diff --git a/server/src/services/rag-owner.integration.test.ts b/server/src/services/rag-owner.integration.test.ts new file mode 100644 index 00000000..940f849b --- /dev/null +++ b/server/src/services/rag-owner.integration.test.ts @@ -0,0 +1,105 @@ +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runWithOwnerScope } from "../core/request-context/owner-scope-storage"; +import { applyMigrations } from "../db/apply-migrations"; +import { initDb, resetDbForTests } from "../db/client"; +import { + createTempDataDir, + removeTempDataDir, +} from "../e2e/helpers/tmp-dir"; + +import { ensureRagPresetState, ragService } from "./rag.service"; + +let tempDir = ""; + +describe("RAG owner isolation", () => { + beforeEach(async () => { + resetDbForTests(); + tempDir = await createTempDataDir("rag-owner-"); + await initDb({ dbPath: path.join(tempDir, "db.sqlite") }); + await applyMigrations(); + }); + + afterEach(async () => { + resetDbForTests(); + await removeTempDataDir(tempDir); + }); + + test("isolates runtime, provider configs, presets, and selected preset", async () => { + const firstState = await runWithOwnerScope("owner-a", async () => { + await ragService.runtime.saveConfig({ + activeProviderId: "ollama", + activeTokenId: null, + activeModel: "embed-a", + activeTokenHint: null, + }); + await ragService.providerConfigs.saveConfig({ + openrouter: { defaultModel: "openrouter-a" }, + ollama: { baseUrl: "http://owner-a:11434" }, + }); + return ensureRagPresetState(); + }); + + await runWithOwnerScope("owner-b", async () => { + expect(await ragService.runtime.getConfig()).toMatchObject({ + activeProviderId: "openrouter", + activeTokenId: null, + activeModel: null, + }); + expect(await ragService.providerConfigs.getConfig()).toMatchObject({ + openrouter: { defaultModel: "text-embedding-3-small" }, + ollama: { baseUrl: "http://localhost:11434" }, + }); + const secondState = await ensureRagPresetState(); + expect(secondState.presets).toHaveLength(1); + expect(secondState.presets[0]?.id).not.toBe(firstState.presets[0]?.id); + }); + + await runWithOwnerScope("owner-a", async () => { + expect(await ragService.runtime.getConfig()).toMatchObject({ + activeProviderId: "ollama", + activeModel: "embed-a", + }); + expect(await ragService.providerConfigs.getConfig()).toMatchObject({ + openrouter: { defaultModel: "openrouter-a" }, + ollama: { baseUrl: "http://owner-a:11434" }, + }); + const restoredState = await ensureRagPresetState(); + expect(restoredState.presets[0]?.id).toBe(firstState.presets[0]?.id); + expect(restoredState.settings.selectedId).toBe( + firstState.settings.selectedId + ); + }); + }); + + test("cannot overwrite another owner's preset by reusing its id", async () => { + const preset = { + id: "shared-preset-id", + name: "Owner A preset", + payload: { + activeProviderId: "ollama" as const, + activeTokenId: null, + activeModel: "model-a", + providerConfigsById: {}, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await runWithOwnerScope("owner-a", () => + ragService.presets.create(preset) + ); + + await expect( + runWithOwnerScope("owner-b", () => + ragService.presets.create({ ...preset, name: "Owner B overwrite" }) + ) + ).rejects.toThrow(); + await expect( + runWithOwnerScope("owner-a", () => ragService.presets.getAll()) + ).resolves.toEqual([ + expect.objectContaining({ id: preset.id, name: "Owner A preset" }), + ]); + }); +}); diff --git a/server/src/services/rag.service.ts b/server/src/services/rag.service.ts index 5fd022bb..84a1b257 100644 --- a/server/src/services/rag.service.ts +++ b/server/src/services/rag.service.ts @@ -5,7 +5,9 @@ import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { getTokenPlaintext, listTokens } from "@services/llm/llm-repository"; +import { probeRagProviderConnection } from "@services/rag/rag-connection-check"; import { safeJsonParse, safeJsonStringify } from "../chat-core/json"; import { initDb } from "../db/client"; @@ -22,6 +24,7 @@ import type { RagPresetPayload, RagPresetSettings, RagProviderConfig, + RagProviderConnectionCheckResult, RagProviderDefinition, RagProviderId, RagRuntime, @@ -29,8 +32,18 @@ import type { const MODELS_REQUEST_TIMEOUT_MS = 7000; const DEFAULT_RAG_PRESET_NAME = "Default RAG preset"; -const DEFAULT_OWNER_ID = "global"; -const RUNTIME_ROW_ID = "global"; +function currentOwnerId(): string { + return resolveTrustedOwnerId(); +} + +function runtimeRowId(): string { + return currentOwnerId(); +} + +function storedRagProviderId(providerId: RagProviderId): string { + const ownerId = currentOwnerId(); + return ownerId === "global" ? providerId : `${ownerId}:${providerId}`; +} const DEFAULT_PROVIDER_CONFIGS: Record = { openrouter: { defaultModel: "text-embedding-3-small", encodingFormat: "float" }, @@ -207,7 +220,7 @@ async function getRagPresetRowById( const rows = await db .select() .from(ragPresets) - .where(and(eq(ragPresets.id, id), eq(ragPresets.ownerId, DEFAULT_OWNER_ID))) + .where(and(eq(ragPresets.id, id), eq(ragPresets.ownerId, currentOwnerId()))) .limit(1); return rows[0] ?? null; } @@ -219,26 +232,32 @@ async function upsertRagPreset(input: RagPreset): Promise { const createdAt = toDate(parsed.createdAt, now); const updatedAt = toDate(parsed.updatedAt, now); - await db - .insert(ragPresets) - .values({ + const existing = await getRagPresetRowById(parsed.id); + if (existing) { + await db + .update(ragPresets) + .set({ + name: parsed.name, + payloadJson: safeJsonStringify(parsed.payload, "{}"), + createdAt, + updatedAt, + }) + .where( + and( + eq(ragPresets.id, parsed.id), + eq(ragPresets.ownerId, currentOwnerId()) + ) + ); + } else { + await db.insert(ragPresets).values({ id: parsed.id, - ownerId: DEFAULT_OWNER_ID, + ownerId: currentOwnerId(), name: parsed.name, payloadJson: safeJsonStringify(parsed.payload, "{}"), createdAt, updatedAt, - }) - .onConflictDoUpdate({ - target: ragPresets.id, - set: { - ownerId: DEFAULT_OWNER_ID, - name: parsed.name, - payloadJson: safeJsonStringify(parsed.payload, "{}"), - createdAt, - updatedAt, - }, }); + } const row = await getRagPresetRowById(parsed.id); if (!row) throw new HttpError(500, "Failed to save RAG preset"); @@ -250,14 +269,14 @@ async function ensureRuntimeRow(): Promise { await db .insert(ragProviderConfigs) .values({ - providerId, + providerId: storedRagProviderId(providerId), configJson: safeJsonStringify(config, "{}"), createdAt: now, updatedAt: now, @@ -395,7 +414,7 @@ export const ragService = { const rows = await db .select() .from(ragPresets) - .where(eq(ragPresets.ownerId, DEFAULT_OWNER_ID)) + .where(eq(ragPresets.ownerId, currentOwnerId())) .orderBy(desc(ragPresets.createdAt)); return rows.map(rowToRagPreset); @@ -416,7 +435,7 @@ export const ragService = { await db .delete(ragPresets) - .where(and(eq(ragPresets.id, id), eq(ragPresets.ownerId, DEFAULT_OWNER_ID))); + .where(and(eq(ragPresets.id, id), eq(ragPresets.ownerId, currentOwnerId()))); return id; }, @@ -434,11 +453,14 @@ export const ragService = { const next = { selectedId: config.selectedId ?? null, }; + if (next.selectedId && !(await getRagPresetRowById(next.selectedId))) { + throw new HttpError(404, "RAG preset not found", "NOT_FOUND"); + } await db .insert(ragPresetSettings) .values({ - ownerId: DEFAULT_OWNER_ID, + ownerId: currentOwnerId(), selectedId: next.selectedId, updatedAt: now, }) @@ -464,11 +486,20 @@ export const ragService = { const db = await initDb(); const parsed = ragRuntimeSchema.parse(config); const now = new Date(); + if (parsed.activeTokenId) { + if (parsed.activeProviderId !== "openrouter") { + throw new HttpError(400, "RAG token is not supported by this provider"); + } + const tokens = await listTokens("openrouter"); + if (!tokens.some((token) => token.id === parsed.activeTokenId)) { + throw new HttpError(404, "RAG token not found", "NOT_FOUND"); + } + } await db .insert(ragRuntimeSettings) .values({ - id: RUNTIME_ROW_ID, + id: runtimeRowId(), activeProviderId: parsed.activeProviderId, activeTokenId: parsed.activeTokenId, activeModel: parsed.activeModel, @@ -496,10 +527,10 @@ export const ragService = { await ensureDefaultProviderConfigs(); const db = await initDb(); const rows = await db.select().from(ragProviderConfigs); - const map = new Map(rows.map((row) => [row.providerId as RagProviderId, row])); + const map = new Map(rows.map((row) => [row.providerId, row])); - const openrouterRaw = map.get("openrouter")?.configJson; - const ollamaRaw = map.get("ollama")?.configJson; + const openrouterRaw = map.get(storedRagProviderId("openrouter"))?.configJson; + const ollamaRaw = map.get(storedRagProviderId("ollama"))?.configJson; return { openrouter: normalizeRagConfig( @@ -530,7 +561,7 @@ export const ragService = { await db .insert(ragProviderConfigs) .values({ - providerId, + providerId: storedRagProviderId(providerId), configJson: safeJsonStringify(next[providerId], "{}"), createdAt: now, updatedAt: now, @@ -549,10 +580,10 @@ export const ragService = { }, }; -let ensureRagPresetStateInFlight: Promise<{ +const ensureRagPresetStateInFlight = new Map | null = null; +}>>(); export function normalizeRagPresetSettings(input: unknown): RagPresetSettings { const parsed = ragPresetSettingsSchema.safeParse(input); @@ -631,15 +662,20 @@ export async function ensureRagPresetState(): Promise<{ presets: RagPreset[]; settings: RagPresetSettings; }> { - if (ensureRagPresetStateInFlight) { - return ensureRagPresetStateInFlight; + const ownerId = currentOwnerId(); + const existing = ensureRagPresetStateInFlight.get(ownerId); + if (existing) { + return existing; } - ensureRagPresetStateInFlight = ensureRagPresetStateUnsafe(); + const pending = ensureRagPresetStateUnsafe(); + ensureRagPresetStateInFlight.set(ownerId, pending); try { - return await ensureRagPresetStateInFlight; + return await pending; } finally { - ensureRagPresetStateInFlight = null; + if (ensureRagPresetStateInFlight.get(ownerId) === pending) { + ensureRagPresetStateInFlight.delete(ownerId); + } } } @@ -700,6 +736,16 @@ export async function listRagModels(params: { } } +export async function checkRagProviderConnection(params: { + providerId: RagProviderId; + tokenId: string | null; + configOverride?: RagProviderConfig; +}): Promise { + const savedConfig = await getRagProviderConfig(params.providerId); + const config = ragConfigSchema.parse({ ...savedConfig, ...(params.configOverride ?? {}) }); + return probeRagProviderConnection({ ...params, config }); +} + export async function getRagRuntime(): Promise { const runtime = ragRuntimeSchema.parse(await ragService.runtime.getConfig()); if (!runtime.activeTokenId || runtime.activeProviderId !== "openrouter") { diff --git a/server/src/services/rag/chroma-rag.service.test.ts b/server/src/services/rag/chroma-rag.service.test.ts index a1842929..307fb875 100644 --- a/server/src/services/rag/chroma-rag.service.test.ts +++ b/server/src/services/rag/chroma-rag.service.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { HttpError } from "../../core/middleware/error-handler"; +import { runWithOwnerScope } from "../../core/request-context/owner-scope-storage"; import { chromaClient } from "./chroma-client"; import { @@ -132,6 +133,42 @@ describe("chroma-rag.service", () => { expect(byWhere.deletedByWhere).toBe(true); }); + test("namespaces collections and only lists the active owner's names", async () => { + const ownerId = "11111111-1111-4111-8111-111111111111"; + const otherOwnerId = "22222222-2222-4222-8222-222222222222"; + const collection = createCollectionStub(); + const getOrCreateCollection = vi.fn(async () => collection); + const deleteCollection = vi.fn(async () => undefined); + const service = createChromaRagService({ + chroma: { + heartbeat: async () => "ok", + listCollections: async () => [ + { name: "world-info" }, + { name: `${ownerId}__world-info` }, + { name: `${otherOwnerId}__world-info` }, + ], + getOrCreateCollection, + deleteCollection, + }, + generateEmbedding: async () => ({ embeddings: [[1]] }), + listBooksForIndexing: async () => [], + }); + + await runWithOwnerScope(ownerId, async () => { + await expect(service.listCollections()).resolves.toEqual([ + { name: "world-info" }, + ]); + await service.createCollection({ name: "notes" }); + await service.deleteCollection("notes"); + }); + + expect(getOrCreateCollection).toHaveBeenCalledWith({ + name: `${ownerId}__notes`, + metadata: {}, + }); + expect(deleteCollection).toHaveBeenCalledWith(`${ownerId}__notes`); + }); + test("propagates mapped chroma errors", async () => { const service = createChromaRagService({ chroma: { diff --git a/server/src/services/rag/chroma-rag.service.ts b/server/src/services/rag/chroma-rag.service.ts index 22bee5d6..d1e25257 100644 --- a/server/src/services/rag/chroma-rag.service.ts +++ b/server/src/services/rag/chroma-rag.service.ts @@ -1,5 +1,6 @@ import { getChromaConfig } from "../../config/chroma-config"; import { HttpError } from "../../core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { generateRagEmbedding } from "../rag.service"; import { normalizeWorldInfoBookEntries } from "../world-info/world-info-normalizer"; import { @@ -70,7 +71,28 @@ function toNonEmptyString(value: unknown): string | null { function resolveCollectionName(collectionName?: string): string { const fallback = getChromaConfig().worldInfoCollection; const normalized = toNonEmptyString(collectionName); - return normalized ?? fallback; + const logicalName = normalized ?? fallback; + const ownerId = resolveTrustedOwnerId(); + if (ownerId === "global") { + return looksOwnerNamespaced(logicalName) ? `global__${logicalName}` : logicalName; + } + return `${ownerId}__${logicalName}`; +} + +function looksOwnerNamespaced(name: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f-]{27}__/i.test(name); +} + +function isVisibleCollectionName(name: string): boolean { + const ownerId = resolveTrustedOwnerId(); + if (ownerId === "global") return !looksOwnerNamespaced(name); + return name.startsWith(`${ownerId}__`); +} + +function toVisibleCollectionName(name: string): string { + const ownerId = resolveTrustedOwnerId(); + if (ownerId === "global") return name; + return name.slice(`${ownerId}__`.length); } function normalizePeekResult(raw: unknown): ChromaPeekItem[] { @@ -242,7 +264,13 @@ export function createChromaRagService( }, async listCollections(): Promise { - return deps.chroma.listCollections(); + const collections = await deps.chroma.listCollections(); + return collections + .filter((collection) => isVisibleCollectionName(collection.name)) + .map((collection) => ({ + ...collection, + name: toVisibleCollectionName(collection.name), + })); }, async createCollection(params: { @@ -253,8 +281,9 @@ export function createChromaRagService( if (!name) { throw new HttpError(400, "Collection name is required", "VALIDATION_ERROR"); } + const storedName = resolveCollectionName(name); await deps.chroma.getOrCreateCollection({ - name, + name: storedName, metadata: toMetadataRecord(params.metadata), }); return { name }; @@ -265,7 +294,7 @@ export function createChromaRagService( if (!normalized) { throw new HttpError(400, "Collection name is required", "VALIDATION_ERROR"); } - await deps.chroma.deleteCollection(normalized); + await deps.chroma.deleteCollection(resolveCollectionName(normalized)); return { name: normalized }; }, @@ -397,7 +426,7 @@ export function createChromaRagService( durationMs: number; }> { const startedAt = Date.now(); - const ownerId = toNonEmptyString(params.ownerId) ?? "global"; + const ownerId = resolveTrustedOwnerId(toNonEmptyString(params.ownerId) ?? undefined); const collectionName = resolveCollectionName(params.collectionName); const books = await deps.listBooksForIndexing({ ownerId }); const docs: ChromaDocInput[] = []; diff --git a/server/src/services/rag/rag-connection-check.test.ts b/server/src/services/rag/rag-connection-check.test.ts new file mode 100644 index 00000000..e504162e --- /dev/null +++ b/server/src/services/rag/rag-connection-check.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + axiosGet: vi.fn(), + getTokenPlaintext: vi.fn(), +})); + +vi.mock("axios", () => ({ + default: { get: mocks.axiosGet }, +})); + +vi.mock("@services/llm/llm-repository", () => ({ + getTokenPlaintext: mocks.getTokenPlaintext, +})); + +import { probeRagProviderConnection } from "./rag-connection-check"; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getTokenPlaintext.mockResolvedValue("secret"); +}); + +describe("RAG provider connection check", () => { + test("requires a token for OpenRouter", async () => { + const result = await probeRagProviderConnection({ + providerId: "openrouter", + tokenId: null, + config: {}, + }); + + expect(result).toMatchObject({ ok: false, issueCode: "TOKEN_MISSING" }); + expect(mocks.axiosGet).not.toHaveBeenCalled(); + }); + + test("reports a missing saved token", async () => { + mocks.getTokenPlaintext.mockResolvedValueOnce(null); + const result = await probeRagProviderConnection({ + providerId: "openrouter", + tokenId: "missing", + config: {}, + }); + expect(result).toMatchObject({ ok: false, issueCode: "TOKEN_NOT_FOUND" }); + }); + + test("checks the OpenRouter embedding catalog", async () => { + mocks.axiosGet.mockResolvedValueOnce({ + status: 200, + data: { data: [{ id: "model-1" }, { id: "model-2" }] }, + }); + const result = await probeRagProviderConnection({ + providerId: "openrouter", + tokenId: "token-1", + config: {}, + }); + + expect(mocks.axiosGet).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/embeddings/models", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer secret" }), + }) + ); + expect(result).toMatchObject({ ok: true, modelCount: 2, statusCode: 200 }); + }); + + test("checks the configured Ollama endpoint", async () => { + mocks.axiosGet.mockResolvedValueOnce({ + status: 200, + data: { models: [{ name: "nomic-embed-text" }] }, + }); + const result = await probeRagProviderConnection({ + providerId: "ollama", + tokenId: null, + config: { baseUrl: "http://127.0.0.1:11434/" }, + }); + + expect(mocks.axiosGet).toHaveBeenCalledWith( + "http://127.0.0.1:11434/api/tags", + { timeout: 7000 } + ); + expect(result).toMatchObject({ ok: true, modelCount: 1 }); + }); + + test("maps provider authentication errors", async () => { + mocks.axiosGet.mockRejectedValueOnce({ + response: { status: 401, data: { error: { message: "Unauthorized" } } }, + }); + const result = await probeRagProviderConnection({ + providerId: "openrouter", + tokenId: "token-1", + config: {}, + }); + + expect(result).toMatchObject({ + ok: false, + issueCode: "AUTH_ERROR", + message: "Unauthorized", + statusCode: 401, + }); + }); +}); diff --git a/server/src/services/rag/rag-connection-check.ts b/server/src/services/rag/rag-connection-check.ts new file mode 100644 index 00000000..6683e32a --- /dev/null +++ b/server/src/services/rag/rag-connection-check.ts @@ -0,0 +1,132 @@ +import axios from "axios"; + +import { getTokenPlaintext } from "@services/llm/llm-repository"; + +import type { + RagProviderConfig, + RagProviderConnectionCheckResult, + RagProviderId, +} from "@shared/types/rag"; + +const REQUEST_TIMEOUT_MS = 7000; + +function getErrorDetails(error: unknown): { + statusCode: number | null; + message: string; +} { + if (!error || typeof error !== "object") { + return { statusCode: null, message: String(error) }; + } + const value = error as { + message?: unknown; + response?: { + status?: unknown; + data?: { error?: { message?: unknown }; message?: unknown }; + }; + }; + const providerMessage = + value.response?.data?.error?.message ?? value.response?.data?.message; + return { + statusCode: + typeof value.response?.status === "number" ? value.response.status : null, + message: + typeof providerMessage === "string" + ? providerMessage + : typeof value.message === "string" + ? value.message + : "Provider request failed", + }; +} + +function failure( + providerId: RagProviderId, + checkedUrl: string, + issueCode: NonNullable, + message: string, + statusCode: number | null = null +): RagProviderConnectionCheckResult { + return { + ok: false, + providerId, + issueCode, + message, + checkedUrl, + statusCode, + modelCount: 0, + }; +} + +export async function probeRagProviderConnection(params: { + providerId: RagProviderId; + tokenId: string | null; + config: RagProviderConfig; +}): Promise { + const checkedUrl = + params.providerId === "openrouter" + ? "https://openrouter.ai/api/v1/embeddings/models" + : `${String(params.config.baseUrl ?? "http://localhost:11434").replace(/\/$/, "")}/api/tags`; + + if (params.providerId === "openrouter" && !params.tokenId) { + return failure( + params.providerId, + checkedUrl, + "TOKEN_MISSING", + "Select an OpenRouter token before checking the connection." + ); + } + const token = params.tokenId ? await getTokenPlaintext(params.tokenId) : null; + if (params.providerId === "openrouter" && !token) { + return failure( + params.providerId, + checkedUrl, + "TOKEN_NOT_FOUND", + "The selected OpenRouter token could not be found." + ); + } + + try { + const response = await axios.get( + checkedUrl, + params.providerId === "openrouter" + ? { + headers: { + "HTTP-Referer": "http://localhost:5000", + "X-Title": "TaleSpinner", + Authorization: `Bearer ${token}`, + }, + timeout: REQUEST_TIMEOUT_MS, + } + : { timeout: REQUEST_TIMEOUT_MS } + ); + const modelCount = + params.providerId === "openrouter" + ? (Array.isArray(response.data?.data) ? response.data.data.length : 0) + : (Array.isArray(response.data?.models) ? response.data.models.length : 0); + return { + ok: true, + providerId: params.providerId, + issueCode: null, + message: `Connection successful. ${modelCount} embedding model${modelCount === 1 ? "" : "s"} available.`, + checkedUrl, + statusCode: typeof response.status === "number" ? response.status : 200, + modelCount, + }; + } catch (error) { + const details = getErrorDetails(error); + const issueCode = + details.statusCode === 401 || details.statusCode === 403 + ? "AUTH_ERROR" + : details.statusCode === 404 + ? "ENDPOINT_NOT_FOUND" + : details.statusCode === null + ? "NETWORK_ERROR" + : "PROVIDER_ERROR"; + return failure( + params.providerId, + checkedUrl, + issueCode, + details.message, + details.statusCode + ); + } +} diff --git a/server/src/services/sidebars/sidebars-repository.ts b/server/src/services/sidebars/sidebars-repository.ts index 008d7903..dbbd340c 100644 --- a/server/src/services/sidebars/sidebars-repository.ts +++ b/server/src/services/sidebars/sidebars-repository.ts @@ -1,11 +1,10 @@ import { eq } from "drizzle-orm"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { uiSidebarsState } from "../../db/schema"; import { type SidebarState } from "../../types"; -const SIDEBARS_STATE_ID = "global"; - function safeParseState(json: string): SidebarState { try { const parsed = JSON.parse(json) as unknown; @@ -21,7 +20,7 @@ export async function getSidebarsState(): Promise { const rows = await db .select() .from(uiSidebarsState) - .where(eq(uiSidebarsState.id, SIDEBARS_STATE_ID)); + .where(eq(uiSidebarsState.id, resolveTrustedOwnerId())); const row = rows[0]; if (!row) return {}; @@ -35,7 +34,7 @@ export async function saveSidebarsState(state: SidebarState): Promise { - if (path.extname(filePath).toLowerCase() !== ".png") return undefined; - const dir = createDataPath("media", "images", "entity-profiles"); +async function saveImportedMedia( + ownerId: string, + kind: "entity-profiles" | "user-persons", + sourcePath: string, + extension: string +): Promise { + const trustedOwnerId = assertSafeFilenameOrThrow(resolveTrustedOwnerId(ownerId)); + const root = createDataPath("media", "images", kind); + const dir = resolveSafePath(root, trustedOwnerId); await fs.mkdir(dir, { recursive: true }); - const filename = `${randomUUID()}.png`; - await fs.copyFile(filePath, path.join(dir, filename)); - return `/media/images/entity-profiles/${filename}`; + const filename = `${randomUUID()}${extension}`; + await fs.copyFile(sourcePath, resolveSafePath(dir, filename)); + return `/media/images/${kind}/${trustedOwnerId}/${filename}`; } -async function savePersonaAvatar(profileRoot: string, avatarFile: string): Promise { +async function saveCharacterAvatar( + ownerId: string, + filePath: string +): Promise { + if (path.extname(filePath).toLowerCase() !== ".png") return undefined; + return saveImportedMedia(ownerId, "entity-profiles", filePath, ".png"); +} + +async function savePersonaAvatar( + ownerId: string, + profileRoot: string, + avatarFile: string +): Promise { const safeName = path.basename(avatarFile); if (!safeName) return undefined; const sourcePath = path.join(profileRoot, "User Avatars", safeName); @@ -122,11 +145,7 @@ async function savePersonaAvatar(profileRoot: string, avatarFile: string): Promi } catch { return undefined; } - const dir = createDataPath("media", "images", "user-persons"); - await fs.mkdir(dir, { recursive: true }); - const filename = `${randomUUID()}${ext}`; - await fs.copyFile(sourcePath, path.join(dir, filename)); - return `/media/images/user-persons/${filename}`; + return saveImportedMedia(ownerId, "user-persons", sourcePath, ext); } async function findExistingEntityId(ownerId: string, source: SillyTavernImportSourceMeta): Promise { @@ -191,7 +210,7 @@ async function importCharacter(ctx: ImportContext, item: SillyTavernImportScanIt const filePath = getFilePath(ctx, item); const { normalized } = await readCharacter(filePath); const name = normalized.name.trim() || item.name; - const avatarAssetId = await saveCharacterAvatar(filePath); + const avatarAssetId = await saveCharacterAvatar(ctx.ownerId, filePath); const profile = await createEntityProfile({ ownerId: ctx.ownerId, name, @@ -313,7 +332,10 @@ async function readChatMessages(filePath: string): Promise<{ meta: Record { const db = await initDb(); - const rows = await db.select({ metaJson: chats.metaJson }).from(chats); + const rows = await db + .select({ metaJson: chats.metaJson }) + .from(chats) + .where(eq(chats.ownerId, resolveTrustedOwnerId(ctx.ownerId))); return rows.some((row) => sourceMatches(safeJsonParse(row.metaJson, null), source)); } @@ -326,12 +348,13 @@ async function addSwipeVariants(params: { if (params.swipes.length <= 1) return; const db = await initDb(); const now = new Date(); + const ownerId = resolveTrustedOwnerId(params.ownerId); const extraVariants = params.swipes .map((text, idx) => ({ text, idx })) .filter((item) => item.idx !== params.selectedIndex) .map((item) => ({ id: randomUUID(), - ownerId: params.ownerId, + ownerId, messageId: params.messageId, createdAt: now, kind: "import" as const, @@ -408,7 +431,7 @@ async function importPersona(ctx: ImportContext, item: SillyTavernImportScanItem const avatarFile = item.relativePath.split("#persona:")[1] ?? ""; if (existingPerson) { if (avatarFile && !existingPerson.avatarUrl?.startsWith("/media/")) { - const avatarUrl = await savePersonaAvatar(profileRoot, avatarFile); + const avatarUrl = await savePersonaAvatar(ctx.ownerId, profileRoot, avatarFile); if (avatarUrl) await updateUserPerson({ id: existingPerson.id, avatarUrl }); } ctx.skipped.push({ kind: item.kind, itemId: item.id, name: item.name, reason: "duplicate" }); @@ -418,7 +441,9 @@ async function importPersona(ctx: ImportContext, item: SillyTavernImportScanItem const powerUser = isRecord(settings) && isRecord(settings.power_user) ? settings.power_user : {}; const descriptions = isRecord(powerUser.persona_descriptions) ? powerUser.persona_descriptions : {}; const rawDescription = isRecord(descriptions[avatarFile]) ? asString(descriptions[avatarFile].description) : ""; - const avatarUrl = avatarFile ? await savePersonaAvatar(profileRoot, avatarFile) : undefined; + const avatarUrl = avatarFile + ? await savePersonaAvatar(ctx.ownerId, profileRoot, avatarFile) + : undefined; const person = await createUserPerson({ ownerId: ctx.ownerId, name: item.name, @@ -465,7 +490,7 @@ export async function importSillyTavernSelection(params: SillyTavernImportReques } const ctx: ImportContext = { rootPath: scan.rootPath, - ownerId: params.ownerId ?? "global", + ownerId: resolveTrustedOwnerId(params.ownerId), selectedIds: new Set(params.selection.itemIds), itemById, profileRootByHandle, diff --git a/server/src/services/ui-theme/ui-theme-repository.ts b/server/src/services/ui-theme/ui-theme-repository.ts index f5e9a385..9f977dfa 100644 --- a/server/src/services/ui-theme/ui-theme-repository.ts +++ b/server/src/services/ui-theme/ui-theme-repository.ts @@ -15,6 +15,7 @@ import { import { and, desc, eq, or } from "drizzle-orm"; import { HttpError } from "@core/middleware/error-handler"; +import { resolveTrustedOwnerId } from "@core/request-context/owner-scope-storage"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; import { initDb } from "../../db/client"; @@ -136,6 +137,7 @@ async function ensureBuiltInPresets(): Promise { } async function ensureSettings(ownerId: string = DEFAULT_OWNER_ID): Promise { + ownerId = resolveTrustedOwnerId(ownerId); await ensureBuiltInPresets(); const db = await initDb(); const rows = await db.select().from(uiThemeSettings).where(eq(uiThemeSettings.ownerId, ownerId)).limit(1); @@ -183,7 +185,7 @@ async function ensureActivePresetExists(ownerId: string): Promise { } export async function listUiThemePresets(params?: { ownerId?: string }): Promise { - const ownerId = params?.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params?.ownerId); await ensureBuiltInPresets(); const db = await initDb(); const rows = await db @@ -202,7 +204,7 @@ export async function getUiThemePresetById(params: { presetId: string; ownerId?: string; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); await ensureBuiltInPresets(); const db = await initDb(); const rows = await db @@ -226,7 +228,7 @@ export async function createUiThemePreset(params: { description?: string; payload: UiThemePresetPayload; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); await ensureBuiltInPresets(); const db = await initDb(); const now = new Date(); @@ -257,7 +259,7 @@ export async function updateUiThemePreset(params: { description?: string | null; payload?: UiThemePresetPayload; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getUiThemePresetById({ presetId: params.presetId, ownerId }); if (!current) throw new HttpError(404, "UI theme preset not found", "NOT_FOUND"); if (current.builtIn) { @@ -293,7 +295,7 @@ export async function deleteUiThemePreset(params: { ownerId?: string; presetId: string; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); const preset = await getUiThemePresetById({ presetId: params.presetId, ownerId }); if (!preset) throw new HttpError(404, "UI theme preset not found", "NOT_FOUND"); if (preset.builtIn) { @@ -332,7 +334,7 @@ export async function importUiThemePresets(params: { ownerId?: string; items: UiThemeExportV1[]; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); await ensureBuiltInPresets(); const existing = await listUiThemePresets({ ownerId }); const names = new Set(existing.map((x) => x.name)); @@ -353,7 +355,7 @@ export async function importUiThemePresets(params: { } export async function getUiThemeSettings(params?: { ownerId?: string }): Promise { - const ownerId = params?.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params?.ownerId); await ensureBuiltInPresets(); await ensureActivePresetExists(ownerId); return ensureSettings(ownerId); @@ -364,7 +366,7 @@ export async function patchUiThemeSettings(params: { activePresetId?: string | null; colorScheme?: UiThemeColorScheme; }): Promise { - const ownerId = params.ownerId ?? DEFAULT_OWNER_ID; + const ownerId = resolveTrustedOwnerId(params.ownerId); await ensureBuiltInPresets(); const current = await ensureSettings(ownerId); diff --git a/server/src/services/world-info/world-info-repositories.ts b/server/src/services/world-info/world-info-repositories.ts index 70423a62..100ffd3f 100644 --- a/server/src/services/world-info/world-info-repositories.ts +++ b/server/src/services/world-info/world-info-repositories.ts @@ -3,6 +3,7 @@ import { randomUUID as uuidv4 } from "node:crypto"; import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm"; import { safeJsonParse, safeJsonStringify } from "../../chat-core/json"; +import { resolveTrustedOwnerId } from "../../core/request-context/owner-scope-storage"; import { initDb } from "../../db/client"; import { chatEntries, @@ -172,7 +173,7 @@ export async function listWorldInfoBooks(params: { before?: number; }): Promise<{ items: WorldInfoBookSummaryDto[]; nextCursor: number | null }> { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const limit = Math.max(1, Math.min(200, params.limit ?? 50)); const query = params.query?.trim().toLowerCase() ?? ""; const where = [eq(worldInfoBooks.ownerId, ownerId), isNull(worldInfoBooks.deletedAt)]; @@ -210,7 +211,13 @@ export async function getWorldInfoBookById(id: string): Promise { if (params.ids.length === 0) return []; const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const rows = await db .select() .from(worldInfoBooks) @@ -240,7 +247,7 @@ export async function listWorldInfoBooksForIndexing(params?: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params?.ownerId); const rows = await db .select() .from(worldInfoBooks) @@ -259,7 +266,7 @@ export async function createWorldInfoBook(params: { source?: WorldInfoBookSource; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const ts = new Date(); const id = uuidv4(); const normalized = normalizeWorldInfoBookPayload(params.data ?? {}); @@ -304,7 +311,7 @@ export async function updateWorldInfoBook(params: { version?: number; }): Promise<{ item: WorldInfoBookDto | null; conflict: boolean }> { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getWorldInfoBookById(params.id); if (!current || current.ownerId !== ownerId) return { item: null, conflict: false }; @@ -349,7 +356,7 @@ export async function softDeleteWorldInfoBook(params: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getWorldInfoBookById(params.id); if (!current || current.ownerId !== ownerId) return false; const ts = new Date(); @@ -369,7 +376,7 @@ export async function duplicateWorldInfoBook(params: { const src = await getWorldInfoBookById(params.id); if (!src) return null; return createWorldInfoBook({ - ownerId: params.ownerId ?? src.ownerId, + ownerId: resolveTrustedOwnerId(params.ownerId ?? src.ownerId), name: params.name ?? `${src.name} (copy)`, slug: params.slug ?? `${src.slug}-copy`, description: src.description, @@ -383,7 +390,7 @@ export async function getWorldInfoSettings(params?: { ownerId?: string; }): Promise { const db = await initDb(); - const ownerId = params?.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params?.ownerId); const rows = await db .select() .from(worldInfoSettings) @@ -427,7 +434,7 @@ export async function patchWorldInfoSettings(params: { patch: Partial>; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const current = await getWorldInfoSettings({ ownerId }); const ts = new Date(); @@ -468,7 +475,7 @@ export async function listWorldInfoBindings(params: { scopeId?: string | null; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const where = [eq(worldInfoBindings.ownerId, ownerId)]; if (params.scope) where.push(eq(worldInfoBindings.scope, params.scope)); if (typeof params.scopeId === "string") where.push(eq(worldInfoBindings.scopeId, params.scopeId)); @@ -495,7 +502,7 @@ export async function replaceWorldInfoBindings(params: { }>; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const scopeId = params.scope === "global" ? null : (params.scopeId ?? null); const ts = new Date(); await db.transaction((tx) => { @@ -536,7 +543,12 @@ export async function replaceWorldInfoBindings(params: { : safeJsonStringify(item.meta, "{}"), updatedAt: ts, }) - .where(eq(worldInfoBindings.id, found.id)) + .where( + and( + eq(worldInfoBindings.id, found.id), + eq(worldInfoBindings.ownerId, ownerId) + ) + ) .run(); continue; } @@ -569,7 +581,12 @@ export async function replaceWorldInfoBindings(params: { if (toDelete.length > 0) { tx .delete(worldInfoBindings) - .where(inArray(worldInfoBindings.id, toDelete.map((item) => item.id))) + .where( + and( + inArray(worldInfoBindings.id, toDelete.map((item) => item.id)), + eq(worldInfoBindings.ownerId, ownerId) + ) + ) .run(); } }); @@ -583,7 +600,7 @@ export async function listWorldInfoTimedEffects(params: { branchId: string; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const rows = await db .select() .from(worldInfoTimedEffects) @@ -600,7 +617,14 @@ export async function listWorldInfoTimedEffects(params: { export async function deleteWorldInfoTimedEffectsByIds(ids: string[]): Promise { if (ids.length === 0) return; const db = await initDb(); - await db.delete(worldInfoTimedEffects).where(inArray(worldInfoTimedEffects.id, ids)); + await db + .delete(worldInfoTimedEffects) + .where( + and( + inArray(worldInfoTimedEffects.id, ids), + eq(worldInfoTimedEffects.ownerId, resolveTrustedOwnerId()) + ) + ); } export async function upsertWorldInfoTimedEffect(params: { @@ -616,7 +640,7 @@ export async function upsertWorldInfoTimedEffect(params: { protected?: boolean; }): Promise { const db = await initDb(); - const ownerId = params.ownerId ?? "global"; + const ownerId = resolveTrustedOwnerId(params.ownerId); const ts = new Date(); const id = uuidv4(); await db @@ -685,6 +709,7 @@ export async function getBranchMessageIndex(params: { .from(chatEntries) .where( and( + eq(chatEntries.ownerId, resolveTrustedOwnerId()), eq(chatEntries.chatId, params.chatId), eq(chatEntries.branchId, params.branchId), eq(chatEntries.softDeleted, false) @@ -695,6 +720,7 @@ export async function getBranchMessageIndex(params: { .from(chatMessages) .where( and( + eq(chatMessages.ownerId, resolveTrustedOwnerId()), eq(chatMessages.chatId, params.chatId), eq(chatMessages.branchId, params.branchId), sql`not ( diff --git a/server/yarn.lock b/server/yarn.lock index 02787958..43434975 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -36,6 +36,11 @@ dependencies: tslib "^2.4.0" +"@epic-web/invariant@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813" + integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== + "@esbuild-kit/core-utils@^3.3.2": version "3.3.2" resolved "https://registry.yarnpkg.com/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz#186b6598a5066f0413471d7c4d45828e399ba96c" @@ -715,6 +720,11 @@ dependencies: semver "7.7.4" +"@phc/format@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@phc/format/-/format-1.0.0.tgz#b5627003b3216dc4362125b13f48a4daa76680e4" + integrity sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== + "@rollup/rollup-android-arm-eabi@4.57.1": version "4.57.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" @@ -1335,6 +1345,16 @@ arg@^4.1.0: resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== +argon2@0.44.0: + version "0.44.0" + resolved "https://registry.yarnpkg.com/argon2/-/argon2-0.44.0.tgz#65a5ba662bba66af41407aa0457decf4af101742" + integrity sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig== + dependencies: + "@phc/format" "^1.0.0" + cross-env "^10.0.0" + node-addon-api "^8.5.0" + node-gyp-build "^4.8.4" + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -1752,6 +1772,14 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cross-env@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783" + integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== + dependencies: + "@epic-web/invariant" "^1.0.0" + cross-spawn "^7.0.6" + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -3252,6 +3280,16 @@ node-abi@^3.3.0: dependencies: semver "^7.3.5" +node-addon-api@^8.5.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.9.0.tgz#d2467090e6195c428ccd510dfd604f01c027f0a0" + integrity sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q== + +node-gyp-build@^4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + nodemon@^3.1.11: version "3.1.11" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.11.tgz#04a54d1e794fbec9d8f6ffd8bf1ba9ea93a756ed" diff --git a/shared/types/llm.ts b/shared/types/llm.ts index a488184b..70106980 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -19,6 +19,23 @@ export type LlmMessageNormalizationConfig = { enabled?: boolean; }; +export type LlmOpenRouterRoutingStrategy = + | "auto" + | "price" + | "throughput" + | "latency" + | "priority" + | "only"; + +export type LlmOpenRouterRoutingConfig = { + strategy: LlmOpenRouterRoutingStrategy; + providerOrder?: string[]; + allowFallbacks?: boolean; + zdr?: boolean; + dataCollection?: "allow" | "deny"; + requireParameters?: boolean; +}; + export type LlmProviderUiField = | { key: "baseUrl"; @@ -53,11 +70,22 @@ export type LlmRuntime = { activeModel: string | null; }; +export type LlmRuntimeProviderState = { + scope: LlmScope; + scopeId: string; + providerId: LlmProviderId; + lastTokenId: string | null; + lastModel: string | null; +}; + export type LlmTokenListItem = { id: string; providerId: LlmProviderId; name: string; tokenHint: string; + createdAt?: string; + updatedAt?: string; + lastUsedAt?: string | null; }; export type LlmProviderConfig = { @@ -66,6 +94,7 @@ export type LlmProviderConfig = { tokenPolicy?: LlmTokenPolicy; anthropicCache?: LlmAnthropicCacheConfig; messageNormalization?: LlmMessageNormalizationConfig; + openRouterRouting?: LlmOpenRouterRoutingConfig; [key: string]: unknown; }; @@ -97,6 +126,30 @@ export type LlmPresetSettings = { export type LlmModel = { id: string; name: string; + contextLength?: number; + pricing?: { + prompt?: string; + completion?: string; + }; + inputModalities?: string[]; + outputModalities?: string[]; + supportedParameters?: string[]; + createdAt?: number; +}; + +export type LlmOpenRouterEndpoint = { + name: string; + providerName: string; + tag: string; + contextLength?: number; + maxCompletionTokens?: number; + quantization?: string; + pricing?: { + prompt?: string; + completion?: string; + }; + supportedParameters?: string[]; + uptimeLast30m?: number; }; export type LlmProviderConnectionIssueCode = @@ -122,4 +175,3 @@ export type LlmProviderConnectionCheckResult = { statusCode: number | null; modelCount: number; }; - diff --git a/shared/types/operation-profiles.ts b/shared/types/operation-profiles.ts index 22f9dc4a..8e55f07f 100644 --- a/shared/types/operation-profiles.ts +++ b/shared/types/operation-profiles.ts @@ -188,6 +188,7 @@ export type LlmOperationParams = { providerId: "openrouter" | "openai_compatible"; credentialRef: string; model?: string; + llmPresetId?: string; system?: string; prompt: string; strictVariables?: boolean; diff --git a/shared/types/rag.ts b/shared/types/rag.ts index 4b909828..3fc458ee 100644 --- a/shared/types/rag.ts +++ b/shared/types/rag.ts @@ -39,6 +39,25 @@ export type RagRuntime = { activeTokenHint: string | null; }; +export type RagProviderConnectionIssueCode = + | 'TOKEN_MISSING' + | 'TOKEN_NOT_FOUND' + | 'AUTH_ERROR' + | 'ENDPOINT_NOT_FOUND' + | 'NETWORK_ERROR' + | 'PROVIDER_ERROR' + | null; + +export type RagProviderConnectionCheckResult = { + ok: boolean; + providerId: RagProviderId; + issueCode: RagProviderConnectionIssueCode; + message: string; + checkedUrl: string; + statusCode: number | null; + modelCount: number; +}; + export type RagPresetPayload = { activeProviderId: RagProviderId; activeTokenId: string | null; diff --git a/web/src/api/api-json.ts b/web/src/api/api-json.ts index 430ea23d..740986dc 100644 --- a/web/src/api/api-json.ts +++ b/web/src/api/api-json.ts @@ -1,9 +1,11 @@ import { BASE_URL } from "../const"; +import { authFetch } from "./auth-fetch"; + type ApiEnvelope = { data: T; error?: unknown }; export async function apiJson(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, headers: { "Content-Type": "application/json", diff --git a/web/src/api/app-backgrounds.ts b/web/src/api/app-backgrounds.ts index c202ad2b..20b9b612 100644 --- a/web/src/api/app-backgrounds.ts +++ b/web/src/api/app-backgrounds.ts @@ -1,6 +1,7 @@ import { BASE_URL } from "../const"; import { apiJson } from "./api-json"; +import { authFetch } from "./auth-fetch"; import type { AppBackgroundActiveSelection, @@ -20,7 +21,7 @@ export async function importAppBackground(file: File): Promise { + const headers = new Headers(init.headers); + if (csrfToken && isMutation(init.method)) { + headers.set('X-CSRF-Token', csrfToken); + } + return fetch(input, { + ...init, + headers, + credentials: 'include', + }); +} diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 00000000..3b26c7f2 --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,167 @@ +import { BASE_URL } from '../const'; + +import { authFetch, setAuthCsrfToken } from './auth-fetch'; + +export type AccessMode = 'local' | 'public'; + +export type AuthUser = { + id: string; + username: string; + displayName: string; + role: 'admin' | 'user'; + status: 'active' | 'disabled'; + hasPassword: boolean; +}; + +export type AuthStatus = { + mode: AccessMode; + registrationAllowed: boolean; + setupRequired: boolean; + authenticated: boolean; + user: AuthUser | null; + accounts: AuthUser[]; + csrfToken?: string; +}; + +type AuthResult = { + user: AuthUser; + csrfToken: string; + expiresAt: string; +}; + +async function authJson(path: string, init?: RequestInit): Promise { + const response = await authFetch(`${BASE_URL}/auth${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + }); + const body = (await response.json().catch(() => ({}))) as { + data?: T; + error?: { message?: string }; + }; + if (!response.ok) { + throw new Error(body.error?.message ?? `HTTP error ${response.status}`); + } + return body.data as T; +} + +export async function getAuthStatus(): Promise { + const status = await authJson('/status'); + setAuthCsrfToken(status.csrfToken ?? null); + return status; +} + +export async function setupAccount(params: { + username: string; + displayName?: string; + password: string; + setupToken?: string; +}): Promise { + const result = await authJson('/setup', { + method: 'POST', + headers: params.setupToken ? { 'X-Setup-Token': params.setupToken } : undefined, + body: JSON.stringify({ + username: params.username, + displayName: params.displayName, + password: params.password, + }), + }); + setAuthCsrfToken(result.csrfToken); + return result; +} + +export async function loginAccount(params: { + username?: string; + userId?: string; + password: string; +}): Promise { + const result = await authJson('/login', { + method: 'POST', + body: JSON.stringify(params), + }); + setAuthCsrfToken(result.csrfToken); + return result; +} + +export async function registerAccount(params: { + username: string; + displayName?: string; + password: string; +}): Promise { + const result = await authJson('/register', { + method: 'POST', + body: JSON.stringify(params), + }); + setAuthCsrfToken(result.csrfToken); + return result; +} + +export async function switchAccount(params: { + username?: string; + userId?: string; + password: string; +}): Promise { + const result = await authJson('/switch', { + method: 'POST', + body: JSON.stringify(params), + }); + setAuthCsrfToken(result.csrfToken); + return result; +} + +export async function logoutAccount(): Promise { + await authJson<{ ok: true }>('/logout', { method: 'POST' }); + setAuthCsrfToken(null); +} + +export async function listAuthUsers(): Promise { + return authJson('/users'); +} + +export async function createAuthUser(params: { + username: string; + displayName?: string; + password: string; + role: 'admin' | 'user'; +}): Promise { + return authJson('/users', { + method: 'POST', + body: JSON.stringify(params), + }); +} + +export async function updateAuthUser(params: { + id: string; + role?: 'admin' | 'user'; + status?: 'active' | 'disabled'; +}): Promise { + const { id, ...body } = params; + return authJson(`/users/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }); +} + +export async function resetAuthUserPassword(params: { + id: string; + newPassword: string; +}): Promise { + return authJson(`/users/${encodeURIComponent(params.id)}/password`, { + method: 'POST', + body: JSON.stringify({ newPassword: params.newPassword }), + }); +} + +export async function changeOwnPassword(params: { + currentPassword: string; + newPassword: string; +}): Promise { + const result = await authJson('/password', { + method: 'POST', + body: JSON.stringify(params), + }); + setAuthCsrfToken(result.csrfToken); + return result; +} diff --git a/web/src/api/bundles.ts b/web/src/api/bundles.ts index 4d201f2d..1909ad7d 100644 --- a/web/src/api/bundles.ts +++ b/web/src/api/bundles.ts @@ -1,5 +1,7 @@ import { BASE_URL } from "../const"; +import { authFetch } from "./auth-fetch"; + import type { TaleSpinnerBundleResourceKind } from "@shared/types/bundles"; type ApiEnvelope = { data: T; error?: unknown }; @@ -46,7 +48,7 @@ export async function exportBundle(input: { selections: BundleSelectionHandle[]; format?: "json" | "archive" | "auto"; }): Promise<{ blob: Blob; filename: string; contentType: string }> { - const res = await fetch(`${BASE_URL}/bundles/export`, { + const res = await authFetch(`${BASE_URL}/bundles/export`, { method: "POST", headers: { "Content-Type": "application/json", @@ -81,7 +83,7 @@ export async function importBundle(file: File): Promise { const form = new FormData(); form.append("file", file); - const res = await fetch(`${BASE_URL}/bundles/import`, { + const res = await authFetch(`${BASE_URL}/bundles/import`, { method: "POST", body: form, }); diff --git a/web/src/api/chat-core.ts b/web/src/api/chat-core.ts index d2fe3ca4..2af698ac 100644 --- a/web/src/api/chat-core.ts +++ b/web/src/api/chat-core.ts @@ -1,6 +1,7 @@ import { BASE_URL } from '../const'; import { getApiErrorMessage } from './api-error'; +import { authFetch } from './auth-fetch'; import type { OperationBlock, @@ -17,7 +18,7 @@ type ApiEnvelope = { data: T; error?: unknown }; export const BACKEND_ORIGIN = BASE_URL.replace(/\/api\/?$/, ''); async function apiJson(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, headers: { 'Content-Type': 'application/json', @@ -38,7 +39,7 @@ async function apiJson(path: string, init?: RequestInit): Promise { } async function apiForm(path: string, form: FormData, init?: Omit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, method: init?.method ?? 'POST', body: form, @@ -169,7 +170,7 @@ export async function exportEntityProfileFile(params: { format: 'json' | 'png'; preferredName?: string; }): Promise<{ blob: Blob; filename: string; contentType: string }> { - const res = await fetch( + const res = await authFetch( `${BASE_URL}/entity-profiles/${encodeURIComponent(params.id)}/export?format=${encodeURIComponent(params.format)}`, { method: 'GET', diff --git a/web/src/api/chat-entry-parts.ts b/web/src/api/chat-entry-parts.ts index 897f919e..9cf51aff 100644 --- a/web/src/api/chat-entry-parts.ts +++ b/web/src/api/chat-entry-parts.ts @@ -1,5 +1,7 @@ import { BASE_URL } from '../const'; +import { authFetch } from './auth-fetch'; + import type { SseEnvelope } from './chat-core'; import type { Variant, Entry } from '@shared/types/chat-entry-parts'; import type { ChatOperationRuntimeStateDto } from '@shared/types/chat-runtime-state'; @@ -11,7 +13,7 @@ const CHAT_GENERATION_DEBUG_STORAGE_KEY = 'chat_generation_debug'; const CHAT_GENERATION_DEBUG_SETTINGS_KEY = '__chatGenerationDebug'; async function apiJson(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, headers: { 'Content-Type': 'application/json', @@ -168,7 +170,7 @@ async function* streamSseRequest(params: { body: Record; signal?: AbortSignal; }): AsyncGenerator { - const res = await fetch(`${BASE_URL}${params.path}`, { + const res = await authFetch(`${BASE_URL}${params.path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/web/src/api/llm.ts b/web/src/api/llm.ts index 333fce48..e58f3d5a 100644 --- a/web/src/api/llm.ts +++ b/web/src/api/llm.ts @@ -1,7 +1,10 @@ import { BASE_URL } from '../const'; +import { authFetch } from './auth-fetch'; + import type { LlmModel, + LlmOpenRouterEndpoint, LlmPreset, LlmPresetPayload, LlmPresetSettings, @@ -10,6 +13,7 @@ import type { LlmProviderDefinition, LlmProviderId, LlmRuntime, + LlmRuntimeProviderState, LlmScope, LlmTokenListItem, } from '@shared/types/llm'; @@ -17,7 +21,7 @@ import type { type ApiEnvelope = { data: T; error?: unknown }; async function apiJson(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, headers: { 'Content-Type': 'application/json', @@ -61,6 +65,15 @@ export async function patchRuntime(params: { }); } +export async function getRuntimeProviderState(params: { + scope: LlmScope; + scopeId: string; + providerId: LlmProviderId; +}): Promise { + const query = new URLSearchParams(params); + return apiJson(`/llm/runtime/provider-state?${query.toString()}`); +} + export async function getProviderConfig(providerId: LlmProviderId): Promise<{ providerId: LlmProviderId; config: LlmProviderConfig; @@ -90,18 +103,15 @@ export async function checkProviderConnection(params: { tokenId?: string | null; config?: LlmProviderConfig; }): Promise { - return apiJson( - `/llm/providers/${encodeURIComponent(params.providerId)}/check`, - { - method: 'POST', - body: JSON.stringify({ - scope: params.scope, - scopeId: params.scopeId, - tokenId: params.tokenId ?? null, - config: params.config, - }), - }, - ); + return apiJson(`/llm/providers/${encodeURIComponent(params.providerId)}/check`, { + method: 'POST', + body: JSON.stringify({ + scope: params.scope, + scopeId: params.scopeId, + tokenId: params.tokenId ?? null, + config: params.config, + }), + }); } export async function listTokens(providerId: LlmProviderId): Promise { @@ -150,6 +160,13 @@ export async function getModels(params: { return data.models; } +export async function getOpenRouterModelEndpoints(modelId: string): Promise { + const data = await apiJson<{ endpoints: LlmOpenRouterEndpoint[] }>( + `/llm/openrouter/endpoints?modelId=${encodeURIComponent(modelId)}`, + ); + return data.endpoints; +} + export type LlmPresetDto = Omit & { createdAt: string; updatedAt: string; diff --git a/web/src/api/rag.ts b/web/src/api/rag.ts index 22d5433e..5b76fece 100644 --- a/web/src/api/rag.ts +++ b/web/src/api/rag.ts @@ -1,7 +1,7 @@ import { apiJson } from './api-json'; import type { LlmTokenListItem } from '@shared/types/llm'; -import type { RagModel, RagPreset, RagPresetSettings, RagProviderConfig, RagProviderDefinition, RagProviderId, RagRuntime } from '@shared/types/rag'; +import type { RagModel, RagPreset, RagPresetSettings, RagProviderConfig, RagProviderConnectionCheckResult, RagProviderDefinition, RagProviderId, RagRuntime } from '@shared/types/rag'; export async function getRagProviders(): Promise { const data = await apiJson<{ providers: RagProviderDefinition[] }>('/rag/providers'); @@ -27,6 +27,17 @@ export async function patchRagProviderConfig(providerId: RagProviderId, config: }); } +export async function checkRagProviderConnection(params: { + providerId: RagProviderId; + tokenId: string | null; + config: RagProviderConfig; +}): Promise { + return apiJson(`/rag/providers/${encodeURIComponent(params.providerId)}/check`, { + method: 'POST', + body: JSON.stringify({ tokenId: params.tokenId, config: params.config }), + }); +} + export async function listRagTokens(providerId: RagProviderId): Promise { const data = await apiJson<{ tokens: LlmTokenListItem[] }>(`/rag/tokens?providerId=${encodeURIComponent(providerId)}`); return data.tokens; diff --git a/web/src/api/world-info.ts b/web/src/api/world-info.ts index 97d9887b..367f4c49 100644 --- a/web/src/api/world-info.ts +++ b/web/src/api/world-info.ts @@ -1,6 +1,7 @@ import { BASE_URL } from '../const'; import { apiJson } from './api-json'; +import { authFetch } from './auth-fetch'; export type WorldInfoScope = 'global' | 'chat' | 'entity_profile' | 'persona'; export type WorldInfoBindingRole = 'primary' | 'additional'; @@ -90,7 +91,7 @@ export type WorldInfoBookListResponse = { type ApiEnvelope = { data: T; error?: unknown }; async function apiForm(path: string, form: FormData, init?: Omit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { + const res = await authFetch(`${BASE_URL}${path}`, { ...init, method: init?.method ?? 'POST', body: form, diff --git a/web/src/const.ts b/web/src/const.ts index 5f388927..b0c17024 100644 --- a/web/src/const.ts +++ b/web/src/const.ts @@ -3,6 +3,4 @@ const explicitBackendUrl = import.meta.env.VITE_BACKEND_URL?.trim(); export const BASE_URL = explicitBackendUrl && explicitBackendUrl.length > 0 ? explicitBackendUrl - : import.meta.env.DEV - ? 'http://localhost:5000/api' - : '/api'; + : '/api'; diff --git a/web/src/features/auth/account-chooser.tsx b/web/src/features/auth/account-chooser.tsx new file mode 100644 index 00000000..874c0c2f --- /dev/null +++ b/web/src/features/auth/account-chooser.tsx @@ -0,0 +1,153 @@ +import { + Avatar, + Badge, + Box, + Button, + Collapse, + Group, + PasswordInput, + Stack, + Text, + UnstyledButton, +} from '@mantine/core'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuCheck, LuChevronRight, LuKeyRound } from 'react-icons/lu'; + +import type { AuthUser } from '../../api/auth'; + +type AccountChooserProps = { + accounts: AuthUser[]; + currentUserId?: string; + pending: boolean; + onSelect: (params: { userId: string; password: string }) => void; +}; + +function getInitials(name: string): string { + return name + .split(/\s+/) + .map((part) => part[0]) + .join('') + .slice(0, 2) + .toLocaleUpperCase(); +} + +export function AccountChooser({ + accounts, + currentUserId, + pending, + onSelect, +}: AccountChooserProps) { + const { t } = useTranslation(); + const [selectedId, setSelectedId] = useState(null); + const [password, setPassword] = useState(''); + + const chooseAccount = (account: AuthUser) => { + if (account.id === currentUserId) return; + if (!account.hasPassword) { + onSelect({ userId: account.id, password: '' }); + return; + } + setPassword(''); + setSelectedId(account.id); + }; + + return ( + + {accounts.map((account) => { + const current = account.id === currentUserId; + const selected = account.id === selectedId; + return ( + + chooseAccount(account)} + style={{ + background: selected ? 'var(--mantine-color-cyan-light)' : 'var(--mantine-color-body)', + cursor: current ? 'default' : 'pointer', + }} + > + + + {getInitials(account.displayName)} + + + + + {account.displayName} + + {current && ( + }> + {t('auth.chooser.current')} + + )} + + + @{account.username} + + + {account.hasPassword ? ( + + ) : ( + + )} + + + + + + setPassword(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && password) { + onSelect({ userId: account.id, password }); + } + }} + autoComplete="current-password" + autoFocus + /> + + + + + + + + ); + })} + + ); +} diff --git a/web/src/features/auth/account-manager.tsx b/web/src/features/auth/account-manager.tsx new file mode 100644 index 00000000..04a7f62e --- /dev/null +++ b/web/src/features/auth/account-manager.tsx @@ -0,0 +1,141 @@ +import { Alert, Button, Tabs } from '@mantine/core'; +import { useUnit } from 'effector-react'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuCircleAlert, LuKeyRound, LuShield, LuUserRound } from 'react-icons/lu'; + +import { + $authError, + $authStatus, + $authUsers, + accountManagerOpened, + changeOwnPasswordFx, + createAuthUserFx, + createUserSubmitted, + loadAuthUsersFx, + logoutAccountFx, + logoutRequested, + ownPasswordChangeSubmitted, + resetAuthUserPasswordFx, + switchAccountFx, + switchAccountSubmitted, + updateAuthUserFx, + userAdministrationSubmitted, + userPasswordResetSubmitted, +} from '@model/auth'; +import { Dialog } from '@ui/dialog'; + +import { AccountOverviewSection } from './account-overview-section'; +import { AccountSecuritySection } from './account-security-section'; +import { AccountUsersSection } from './account-users-section'; + +export function AccountManager({ opened, onClose }: { opened: boolean; onClose: () => void }) { + const { t } = useTranslation(); + const [ + status, + users, + error, + load, + create, + updateUser, + resetUserPassword, + changePassword, + switchUser, + logout, + loadingUsers, + creating, + updating, + resetting, + changingPassword, + switching, + loggingOut, + ] = useUnit([ + $authStatus, + $authUsers, + $authError, + accountManagerOpened, + createUserSubmitted, + userAdministrationSubmitted, + userPasswordResetSubmitted, + ownPasswordChangeSubmitted, + switchAccountSubmitted, + logoutRequested, + loadAuthUsersFx.pending, + createAuthUserFx.pending, + updateAuthUserFx.pending, + resetAuthUserPasswordFx.pending, + changeOwnPasswordFx.pending, + switchAccountFx.pending, + logoutAccountFx.pending, + ]); + + useEffect(() => { + if (opened && status.user?.role === 'admin') load(); + }, [load, opened, status.user?.role]); + + return ( + { + if (!next) onClose(); + }} + title={t('auth.accounts.title')} + size="lg" + footer={ + + } + > + {error && ( + }> + {error} + + )} + + + }> + {t('auth.accounts.tabs.account')} + + }> + {t('auth.accounts.tabs.security')} + + {status.user?.role === 'admin' && ( + }> + {t('auth.accounts.tabs.users')} + + )} + + + + + + + + + {status.user?.role === 'admin' && ( + + + + )} + + + ); +} diff --git a/web/src/features/auth/account-overview-section.tsx b/web/src/features/auth/account-overview-section.tsx new file mode 100644 index 00000000..68cf9da4 --- /dev/null +++ b/web/src/features/auth/account-overview-section.tsx @@ -0,0 +1,119 @@ +import { + Avatar, + Badge, + Box, + Button, + Divider, + Group, + Stack, + Text, +} from '@mantine/core'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuLogOut, LuRefreshCw } from 'react-icons/lu'; + +import { AccountChooser } from './account-chooser'; +import { AuthCredentialsForm } from './auth-credentials-form'; + +import type { AuthStatus } from '../../api/auth'; + +type AccountOverviewSectionProps = { + status: AuthStatus; + pending: boolean; + onSwitch: (params: { username?: string; userId?: string; password: string }) => void; + onLogout: () => void; +}; + +export function AccountOverviewSection({ + status, + pending, + onSwitch, + onLogout, +}: AccountOverviewSectionProps) { + const { t } = useTranslation(); + const [manualSwitch, setManualSwitch] = useState(false); + const currentUser = status.user; + if (!currentUser) return null; + + const otherLocalAccounts = status.accounts.filter((account) => account.id !== currentUser.id); + + return ( + + + + {currentUser.displayName.slice(0, 2).toLocaleUpperCase()} + + + + {currentUser.displayName} + + + @{currentUser.username} + + + {t(`auth.accounts.roles.${currentUser.role}`)} + + + + + + {t('auth.accounts.switchTitle')} + + {t(`auth.accounts.switchDescription.${status.mode}`)} + + + {status.mode === 'local' && otherLocalAccounts.length > 0 && ( + + )} + {status.mode === 'local' && otherLocalAccounts.length === 0 && ( + + {t('auth.accounts.noOtherAccounts')} + + )} + {status.mode === 'public' && !manualSwitch && ( + + )} + {status.mode === 'public' && manualSwitch && ( + setManualSwitch(false)} + onLogin={onSwitch} + /> + )} + + + + + + + {t('auth.accounts.endSession')} + + {t('auth.accounts.endSessionDescription')} + + + + + + ); +} diff --git a/web/src/features/auth/account-security-section.tsx b/web/src/features/auth/account-security-section.tsx new file mode 100644 index 00000000..d4a8205c --- /dev/null +++ b/web/src/features/auth/account-security-section.tsx @@ -0,0 +1,70 @@ +import { Box, Button, Group, PasswordInput, Stack, Text } from '@mantine/core'; +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuKeyRound } from 'react-icons/lu'; + +import type { AuthStatus } from '../../api/auth'; + +type AccountSecuritySectionProps = { + status: AuthStatus; + pending: boolean; + onChangePassword: (params: { currentPassword: string; newPassword: string }) => void; +}; + +export function AccountSecuritySection({ + status, + pending, + onChangePassword, +}: AccountSecuritySectionProps) { + const { t } = useTranslation(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const hasPassword = status.user?.hasPassword ?? false; + + const submit = (event: FormEvent) => { + event.preventDefault(); + onChangePassword({ currentPassword, newPassword }); + }; + + return ( + + + + {t('auth.accounts.changeOwnPassword')} + + {t(`auth.accounts.passwordDescription.${status.mode}`)} + + + {hasPassword && ( + } + value={currentPassword} + onChange={(event) => setCurrentPassword(event.currentTarget.value)} + autoComplete="current-password" + /> + )} + } + value={newPassword} + onChange={(event) => setNewPassword(event.currentTarget.value)} + autoComplete="new-password" + /> + + + + + + ); +} diff --git a/web/src/features/auth/account-user-row.tsx b/web/src/features/auth/account-user-row.tsx new file mode 100644 index 00000000..29b0e799 --- /dev/null +++ b/web/src/features/auth/account-user-row.tsx @@ -0,0 +1,132 @@ +import { + ActionIcon, + Avatar, + Badge, + Box, + Button, + Collapse, + Group, + PasswordInput, + Select, + SimpleGrid, + Stack, + Text, +} from '@mantine/core'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuChevronDown, LuKeyRound } from 'react-icons/lu'; + +import type { AuthUser } from '../../api/auth'; + +type AccountUserRowProps = { + user: AuthUser; + allowEmptyPassword: boolean; + pending: boolean; + onUpdate: (params: { id: string; role?: AuthUser['role']; status?: AuthUser['status'] }) => void; + onResetPassword: (params: { id: string; newPassword: string }) => void; +}; + +export function AccountUserRow({ + user, + allowEmptyPassword, + pending, + onUpdate, + onResetPassword, +}: AccountUserRowProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + const [newPassword, setNewPassword] = useState(''); + + return ( + + + + {user.displayName.slice(0, 2).toLocaleUpperCase()} + + + + {user.displayName} + + + @{user.username} + + + + {t(`auth.accounts.roles.${user.role}`)} + + {t(`auth.accounts.statuses.${user.status}`)} + + + setOpened((value) => !value)} + > + + + + + + + + { + if (status === 'active' || status === 'disabled') onUpdate({ id: user.id, status }); + }} + data={[ + { value: 'active', label: t('auth.accounts.statuses.active') }, + { value: 'disabled', label: t('auth.accounts.statuses.disabled') }, + ]} + /> + + + } + value={newPassword} + disabled={pending} + onChange={(event) => setNewPassword(event.currentTarget.value)} + style={{ flex: '1 1 240px' }} + /> + + + + + + ); +} diff --git a/web/src/features/auth/account-users-section.tsx b/web/src/features/auth/account-users-section.tsx new file mode 100644 index 00000000..9afee159 --- /dev/null +++ b/web/src/features/auth/account-users-section.tsx @@ -0,0 +1,167 @@ +import { + Box, + Button, + Collapse, + Group, + Loader, + PasswordInput, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuPlus, LuUsers } from 'react-icons/lu'; + +import { AccountUserRow } from './account-user-row'; + +import type { AccessMode, AuthUser } from '../../api/auth'; + +type AccountUsersSectionProps = { + mode: AccessMode; + users: AuthUser[]; + loading: boolean; + mutating: boolean; + onCreate: (params: { + username: string; + displayName?: string; + password: string; + role: AuthUser['role']; + }) => void; + onUpdate: (params: { id: string; role?: AuthUser['role']; status?: AuthUser['status'] }) => void; + onResetPassword: (params: { id: string; newPassword: string }) => void; +}; + +export function AccountUsersSection({ + mode, + users, + loading, + mutating, + onCreate, + onUpdate, + onResetPassword, +}: AccountUsersSectionProps) { + const { t } = useTranslation(); + const [creating, setCreating] = useState(false); + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('user'); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!username.trim()) return; + onCreate({ + username, + displayName: displayName || undefined, + password, + role, + }); + }; + + return ( + + + + + + {t('auth.accounts.users')} + + + {t('auth.accounts.usersDescription', { count: users.length })} + + + + + + + + + {t('auth.accounts.createTitle')} + + setUsername(event.currentTarget.value)} + autoComplete="off" + /> + setDisplayName(event.currentTarget.value)} + /> + setPassword(event.currentTarget.value)} + autoComplete="new-password" + /> + value && void onProviderChange(value as LlmProviderId)} + allowDeselect={false} + comboboxProps={{ withinPortal: false }} + /> + + {showProviderConfig && providerId === 'openai_compatible' ? ( + onConfigChange?.({ ...config, baseUrl: event.currentTarget.value })} + placeholder="http://localhost:1234/v1" + /> + ) : null} + + + + patch({ strategy: (strategy ?? 'auto') as LlmOpenRouterRoutingConfig['strategy'] })} + allowDeselect={false} + data={[ + { value: 'auto', label: t('provider.routing.strategies.auto') }, + { value: 'price', label: t('provider.routing.strategies.price') }, + { value: 'latency', label: t('provider.routing.strategies.latency') }, + { value: 'throughput', label: t('provider.routing.strategies.throughput') }, + { value: 'priority', label: t('provider.routing.strategies.priority') }, + { value: 'only', label: t('provider.routing.strategies.only') }, + ]} + comboboxProps={{ withinPortal: false }} + /> + + {requiresProviders ? ( + + + + {t('provider.routing.providers')} + + + + patch({ providerOrder: providerOrderValue })} + placeholder={modelId ? t('provider.routing.providerPlaceholder') : t('provider.routing.selectModelFirst')} + disabled={!modelId} + searchable + comboboxProps={{ withinPortal: false }} + error={providerOrder.length === 0 ? t('provider.routing.providerRequired') : undefined} + /> + + {providerOrder.length > 0 ? ( + + {providerOrder.map((tag, index) => { + const endpoint = endpointByTag.get(tag); + const meta = [ + formatContextLength(endpoint?.contextLength), + formatPricePerMillion(endpoint?.pricing?.prompt), + endpoint?.uptimeLast30m ? `${endpoint.uptimeLast30m.toFixed(1)}%` : null, + ] + .filter(Boolean) + .join(' · '); + return ( + + + + {endpoint?.providerName ?? tag} + + + {tag} + {meta ? ` · ${meta}` : ''} + + + + move(index, -1)} + aria-label={t('provider.routing.moveUp')} + > + + + move(index, 1)} + aria-label={t('provider.routing.moveDown')} + > + + + patch({ providerOrder: providerOrder.filter((item) => item !== tag) })} + aria-label={t('common.delete')} + > + + + + + ); + })} + + ) : null} + + ) : null} + + patch({ allowFallbacks: event.currentTarget.checked })} + label={t('provider.routing.allowFallbacks')} + /> + + + patch({ zdr: event.currentTarget.checked })} + label={t('provider.routing.zdr')} + /> + patch({ requireParameters: event.currentTarget.checked })} + label={t('provider.routing.requireParameters')} + /> + { - const nextProviderId = (value ?? 'openrouter') as LlmProviderId; - if (nextProviderId === activeProviderId) return; - onProviderSelect(nextProviderId); - }} - placeholder={t('provider.placeholders.selectProvider')} - searchable - allowDeselect={false} - comboboxProps={{ withinPortal: false }} - /> - - - - - {t('provider.tokens.title')} - {allowTokenManager && onOpenTokenManager && tokenManagerScope && tokenManagerScopeId ? ( - - ) : null} - - - { - const nextModel = value ?? null; - if (nextModel !== activeModel) { - onModelSelect(nextModel); - } - setManualModel(nextModel ?? ''); - }} - clearable - searchable - placeholder={canLoadModels ? t('provider.placeholders.selectModel') : t('provider.placeholders.selectTokenFirst')} - disabled={!canLoadModels} - comboboxProps={{ withinPortal: false }} - /> - - - setManualModel(event.currentTarget.value)} - onBlur={applyManualModel} - placeholder={t('provider.model.manualPlaceholder')} - disabled={!canLoadModels} - style={{ flex: 1 }} - /> - - - - - {t('provider.model.helpText')} - - - - ); -}; diff --git a/web/src/features/llm-provider/token-manager.tsx b/web/src/features/llm-provider/token-manager.tsx deleted file mode 100644 index c52140f9..00000000 --- a/web/src/features/llm-provider/token-manager.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useUnit } from 'effector-react'; -import { useMemo } from 'react'; - -import { llmProviderModel } from '@model/provider'; - -import { LlmTokenManagerDialog } from './llm-token-manager-dialog'; - -import type { LlmProviderId, LlmScope } from '@shared/types/llm'; - -type Props = { - providerId: LlmProviderId; - scope: LlmScope; - scopeId: string; -}; - -export const TokenManager: React.FC = ({ providerId, scope, scopeId }) => { - const [isOpen, setOpen, runtimeByKey] = useUnit([ - llmProviderModel.$isTokenManagerOpen, - llmProviderModel.tokenManagerOpened, - llmProviderModel.$runtimeByScopeKey, - ]); - - const activeTokenId = useMemo(() => { - const runtime = runtimeByKey[`${scope}:${scopeId}`]; - return runtime?.activeTokenId ?? null; - }, [runtimeByKey, scope, scopeId]); - - return ( - llmProviderModel.tokenSelected({ scope, scopeId, tokenId })} - /> - ); -}; diff --git a/web/src/features/sidebars/left-bar.tsx b/web/src/features/sidebars/left-bar.tsx index 77a9a20a..dcec0690 100644 --- a/web/src/features/sidebars/left-bar.tsx +++ b/web/src/features/sidebars/left-bar.tsx @@ -1,12 +1,15 @@ import { Box } from '@mantine/core'; import { useUnit } from 'effector-react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { type IconType } from 'react-icons'; -import { LuBookOpen, LuFileText, LuIdCard, LuSettings, LuSettings2, LuSquareUser, LuWorkflow } from 'react-icons/lu'; +import { LuBookOpen, LuFileText, LuIdCard, LuSettings, LuSettings2, LuSquareUser, LuUsers, LuWorkflow } from 'react-icons/lu'; import { $sidebars, toggleSidebarOpen, type SidebarName } from '@model/sidebars'; import { IconButtonWithTooltip } from '@ui/icon-button-with-tooltip'; +import { AccountManager } from '../auth/account-manager'; + type SidebarButton = { name: SidebarName; labelKey: string; @@ -31,6 +34,7 @@ const appSettingsButton: SidebarButton = { export const LeftBar = () => { const { t } = useTranslation(); const sidebars = useUnit($sidebars); + const [accountsOpened, setAccountsOpened] = useState(false); const renderButton = (button: SidebarButton) => { const section = t(button.labelKey); @@ -55,9 +59,21 @@ export const LeftBar = () => { }; return ( - - {sidebarButtons.map(renderButton)} - {renderButton(appSettingsButton)} - + <> + + {sidebarButtons.map(renderButton)} + + } + variant="ghost" + onClick={() => setAccountsOpened(true)} + /> + {renderButton(appSettingsButton)} + + + setAccountsOpened(false)} /> + ); }; diff --git a/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.test.ts b/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.test.ts index 082fe8a6..443dcc6e 100644 --- a/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.test.ts +++ b/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.test.ts @@ -316,6 +316,7 @@ describe('operation profile form mapping', () => { providerId: 'openrouter', credentialRef: 'cred-1', model: 'gpt-test', + llmPresetId: 'preset-1', }, }, }, @@ -323,6 +324,51 @@ describe('operation profile form mapping', () => { expect((llm as { config: { params: { params: { samplers?: unknown } } } }).config.params.params.samplers).toBeUndefined(); }); + it('restores the selected LLM preset from a saved operation', () => { + const profile: OperationProfileDto = { + profileId: 'profile-llm', + ownerId: 'owner-1', + name: 'Profile', + description: undefined, + enabled: true, + executionMode: 'sequential', + operationProfileSessionId: 'session-llm', + blockRefs: [], + operations: [ + { + opId: 'llm-1', + name: 'LLM op', + kind: 'llm', + config: { + enabled: true, + required: false, + hooks: ['before_main_llm'], + triggers: ['generate'], + order: 10, + params: { + params: { + providerId: 'openrouter', + credentialRef: 'cred-1', + model: 'gpt-test', + llmPresetId: 'preset-1', + prompt: 'Prompt', + }, + artifact: makeArtifact({ artifactId: 'artifact:llm-1', tag: 'llm_op' }), + }, + }, + }, + ], + meta: {}, + version: 1, + createdAt: '2026-07-17T00:00:00.000Z', + updatedAt: '2026-07-17T00:00:00.000Z', + }; + + const form = toOperationProfileForm(profile); + + expect(form.operations[0]?.config.params).toMatchObject({ llmPresetId: 'preset-1' }); + }); + it('normalizes knowledge operations into dedicated form params', () => { const profile: OperationProfileDto = { profileId: 'profile-knowledge', diff --git a/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts b/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts index acb5620d..642ebfc1 100644 --- a/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts +++ b/web/src/features/sidebars/operation-profiles/form/operation-profile-form-mapping.ts @@ -252,7 +252,7 @@ function normalizeLlmKindParams(op: Extract providerId, credentialRef: typeof llmParamsRaw.credentialRef === 'string' ? llmParamsRaw.credentialRef : '', model: typeof llmParamsRaw.model === 'string' ? llmParamsRaw.model : '', - llmPresetId: '', + llmPresetId: typeof llmParamsRaw.llmPresetId === 'string' ? llmParamsRaw.llmPresetId : '', system: typeof llmParamsRaw.system === 'string' ? llmParamsRaw.system : '', prompt: typeof llmParamsRaw.prompt === 'string' ? llmParamsRaw.prompt : '', strictVariables: llmParamsRaw.strictVariables === true, @@ -424,6 +424,7 @@ export function fromOperationProfileForm( if (op.kind === 'llm') { const params = op.config.params as FormLlmKindParams; const model = params.model.trim(); + const llmPresetId = params.llmPresetId.trim(); const system = params.system.trim(); const samplerPresetId = params.samplerPresetId.trim(); const jsonCustomPattern = params.jsonCustomPattern.trim(); @@ -464,6 +465,7 @@ export function fromOperationProfileForm( providerId: params.providerId, credentialRef: params.credentialRef.trim(), model: model.length > 0 ? model : undefined, + llmPresetId: llmPresetId.length > 0 ? llmPresetId : undefined, system: system.length > 0 ? system : undefined, prompt: params.prompt, strictVariables: params.strictVariables ? true : undefined, diff --git a/web/src/features/sidebars/operation-profiles/index.tsx b/web/src/features/sidebars/operation-profiles/index.tsx index ba52f206..be37159b 100644 --- a/web/src/features/sidebars/operation-profiles/index.tsx +++ b/web/src/features/sidebars/operation-profiles/index.tsx @@ -1,8 +1,8 @@ -import { Button, Group, Select, Stack, Tabs, Text } from '@mantine/core'; +import { Button, Select, Stack, Tabs, Text } from '@mantine/core'; import { useUnit } from 'effector-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { LuGitFork, LuSave, LuUndo2 } from 'react-icons/lu'; +import { LuGitFork } from 'react-icons/lu'; import { v4 as uuidv4 } from 'uuid'; import { @@ -34,7 +34,9 @@ import { resolveBundleAutoApplyTargets } from '../common/bundle-helpers'; import { OperationBlockEditor, type OperationBlockToolbarState } from './operation-block-editor'; import { OperationProfileBlocksEditor, type OperationProfileBlocksToolbarState } from './operation-profile-blocks-editor'; import './operation-profiles.css'; +import './operation-workspace.css'; import { BlockActions } from './ui/block-actions'; +import { EditorSaveActions } from './ui/editor-save-actions'; import { ProfileActions } from './ui/profile-actions'; import { ProfilePicker } from './ui/profile-picker'; import { RunTracePanel } from './ui/run-trace-panel'; @@ -134,7 +136,9 @@ export const OperationProfilesSidebar: React.FC = () => { const sidebarState = sidebars.operationProfiles; const preferSplitLayout = sidebarState.isFullscreen || sidebarState.size === 'full'; - const uiClassName = preferSplitLayout ? 'op-ui' : 'op-ui op-ui--drawer'; + const uiClassName = preferSplitLayout + ? `op-ui op-ui--fullscreen op-ui--${activeTab}` + : `op-ui op-ui--drawer op-ui--${activeTab}`; return ( @@ -184,52 +188,13 @@ export const OperationProfilesSidebar: React.FC = () => { /> - {selectedProfile && profileToolbarState && ( + {selectedProfile && profileToolbarState && preferSplitLayout && (
- - {preferSplitLayout ? ( - <> - - - - ) : ( - <> - } - size="input-sm" - variant="ghost" - tooltipSettings={TOOLBAR_TOOLTIP_SETTINGS} - disabled={!profileToolbarState.canSave} - onClick={profileToolbarState.onSave} - /> - } - size="input-sm" - variant="ghost" - tooltipSettings={TOOLBAR_TOOLTIP_SETTINGS} - disabled={!profileToolbarState.canDiscard} - onClick={profileToolbarState.onDiscard} - /> - - )} - +
)}
+ {!preferSplitLayout && } {!selectedProfile ? ( @@ -317,51 +282,12 @@ export const OperationProfilesSidebar: React.FC = () => { /> )} - {selectedBlock && blockToolbarState && ( - - {preferSplitLayout ? ( - <> - - - - ) : ( - <> - } - size="input-sm" - variant="ghost" - tooltipSettings={TOOLBAR_TOOLTIP_SETTINGS} - disabled={!blockToolbarState.canSave} - onClick={blockToolbarState.onSave} - /> - } - size="input-sm" - variant="ghost" - tooltipSettings={TOOLBAR_TOOLTIP_SETTINGS} - disabled={!blockToolbarState.canDiscard} - onClick={blockToolbarState.onDiscard} - /> - - )} - + {selectedBlock && blockToolbarState && preferSplitLayout && ( + )} + {!preferSplitLayout && } {!selectedBlock ? ( diff --git a/web/src/features/sidebars/operation-profiles/operation-block-editor.tsx b/web/src/features/sidebars/operation-profiles/operation-block-editor.tsx index 57fe8d41..c50b97a9 100644 --- a/web/src/features/sidebars/operation-profiles/operation-block-editor.tsx +++ b/web/src/features/sidebars/operation-profiles/operation-block-editor.tsx @@ -1,20 +1,23 @@ -import { Alert, Button, Card, Collapse, Group, Stack, Text } from '@mantine/core'; +import { Alert, Text } from '@mantine/core'; import { useMediaQuery } from '@mantine/hooks'; import { useUnit } from 'effector-react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FormProvider, useFieldArray, useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { LuChevronDown, LuChevronUp, LuPlus } from 'react-icons/lu'; import { updateOperationBlockFx } from '@model/operation-blocks'; -import { FormInput, FormSwitch } from '@ui/form-components'; import { toOperationBlockFormValues } from './form/operation-block-form-values'; import { fromOperationProfileForm, makeDefaultOperation, type OperationProfileFormValues } from './form/operation-profile-form-mapping'; import { OperationBlockNodeEditorModal } from './node-editor/block-node-editor-modal'; +import { BlockSettingsPanel } from './ui/block-settings-panel'; import { OperationEditor } from './ui/operation-editor/operation-editor'; -import { OperationList } from './ui/operation-list'; -import { getOperationListLayout } from './ui/operation-list-layout'; +import { OperationWorkspace } from './ui/operation-workspace'; +import { + resolveOperationWorkspaceMode, + shouldShowBlockSettings, + type CompactOperationView, +} from './ui/operation-workspace-mode'; import { isOperationKind } from './utils/operation-kind'; import type { OperationListRowMeta } from './ui/types'; @@ -116,9 +119,8 @@ export const OperationBlockEditor: React.FC = ({ }) => { const { t } = useTranslation(); const doUpdate = useUnit(updateOperationBlockFx); - const isMobile = useMediaQuery('(max-width: 767px)'); - const useSplitLayout = preferSplitLayout && !isMobile; - const operationListLayout = useMemo(() => getOperationListLayout(useSplitLayout), [useSplitLayout]); + const isCompactViewport = useMediaQuery('(max-width: 1023px)'); + const useSplitLayout = preferSplitLayout && !isCompactViewport; const initial = useMemo(() => toOperationBlockFormValues(block), [block]); const methods = useForm({ defaultValues: initial }); @@ -152,10 +154,10 @@ export const OperationBlockEditor: React.FC = ({ .filter((row): row is OperationListRowMeta => row !== null); }, [fields, watchedOperations]); - const [isProfileOpen, setIsProfileOpen] = useState(true); const [jsonError, setJsonError] = useState(null); const [baselineValues, setBaselineValues] = useState(initial); const [editingOpId, setEditingOpId] = useState(() => resolveEditingOperationId(null, initial.operations)); + const [compactView, setCompactView] = useState('list'); const hasUnsavedChanges = useMemo(() => { const current = (watchedValues as OperationProfileFormValues | undefined) ?? baselineValues; @@ -169,6 +171,10 @@ export const OperationBlockEditor: React.FC = ({ setEditingOpId((prev) => resolveEditingOperationId(prev, initial.operations)); }, [initial, reset]); + useEffect(() => { + setCompactView('list'); + }, [block.blockId]); + const selectedIndex = useMemo(() => { if (rows.length === 0) return null; if (editingOpId) { @@ -180,6 +186,7 @@ export const OperationBlockEditor: React.FC = ({ const selectedRow = selectedIndex === null ? null : rows.find((row) => row.index === selectedIndex) ?? null; const selectedOpId = selectedRow?.opId ?? null; + const workspaceMode = resolveOperationWorkspaceMode({ useSplitLayout, compactView, selectedOpId }); const saveBlockDraft = useCallback( async (values: OperationProfileFormValues, meta?: unknown) => { @@ -253,21 +260,27 @@ export const OperationBlockEditor: React.FC = ({ const next = makeDefaultOperation(); append(next); setEditingOpId(next.opId); - }, [append]); - - const moveSelection = useCallback( - (direction: 'prev' | 'next') => { - if (rows.length === 0) return; - const current = selectedOpId ? rows.findIndex((row) => row.opId === selectedOpId) : 0; - const safeCurrent = current >= 0 ? current : 0; - const nextIndex = direction === 'prev' ? Math.max(0, safeCurrent - 1) : Math.min(rows.length - 1, safeCurrent + 1); - const next = rows[nextIndex]; - if (!next) return; - setEditingOpId(next.opId); + if (!useSplitLayout) setCompactView('inspector'); + }, [append, useSplitLayout]); + + const selectOperation = useCallback( + (opId: string) => { + setEditingOpId(opId); + if (!useSplitLayout) setCompactView('inspector'); }, - [rows, selectedOpId], + [useSplitLayout], ); + const moveSelection = (direction: 'prev' | 'next') => { + if (rows.length === 0) return; + const current = selectedOpId ? rows.findIndex((row) => row.opId === selectedOpId) : 0; + const safeCurrent = current >= 0 ? current : 0; + const nextIndex = direction === 'prev' ? Math.max(0, safeCurrent - 1) : Math.min(rows.length - 1, safeCurrent + 1); + const next = rows[nextIndex]; + if (!next) return; + setEditingOpId(next.opId); + }; + const removeOperationAt = useCallback( (targetIndex: number, targetOpId: string) => { if (!window.confirm(t('operationProfiles.confirm.deleteOperation'))) return; @@ -275,11 +288,13 @@ export const OperationBlockEditor: React.FC = ({ if (currentPosition < 0) { remove(targetIndex); setEditingOpId(rows[0]?.opId ?? null); + if (rows.length <= 1) setCompactView('list'); return; } const next = rows[currentPosition + 1]?.opId ?? rows[currentPosition - 1]?.opId ?? null; remove(targetIndex); setEditingOpId(next); + if (!next) setCompactView('list'); }, [remove, rows, t], ); @@ -300,95 +315,25 @@ export const OperationBlockEditor: React.FC = ({ return ( - - - setIsProfileOpen((v) => !v)} - > - - {isProfileOpen ? : } - {t('operationProfiles.blocks.blockSettingsTitle')} - - - - - - - - - - - - - - - +
+ {shouldShowBlockSettings(workspaceMode) && } {jsonError && ( {jsonError} )} - - {rows.length === 0 ? ( - - - {t('operationProfiles.operations.title')} - - {t('operationProfiles.operations.empty')} - - - - - ) : useSplitLayout ? ( -
-
- setEditingOpId(opId)} - className={operationListLayout.listClassName} - scrollAreaClassName={operationListLayout.scrollAreaClassName} - /> -
- -
-
- - {t('operationProfiles.inspector.title')} - - {selectedIndex === null ? t('operationProfiles.inspector.noneSelected') : t('operationProfiles.inspector.operationNumber', { number: selectedIndex + 1 })} - - -
- - {inspectorContent} -
-
- ) : ( - <> - - setEditingOpId(opId)} - className={operationListLayout.listClassName} - scrollAreaClassName={operationListLayout.scrollAreaClassName} - /> - - {inspectorContent} - - )} - + setCompactView('list')} + /> +
= ({ profile, blocks, return ( - - - - - - - - -
- - } - size="input-sm" - variant="ghost" - tooltipSettings={ACTION_TOOLTIP_SETTINGS} - onClick={onResetSessionId} - /> -
-
-
+ {error && ( @@ -151,7 +113,7 @@ export const OperationProfileBlocksEditor: React.FC = ({ profile, blocks, )} - +
{t('operationProfiles.blocks.profileCompositionTitle')} @@ -189,22 +151,25 @@ export const OperationProfileBlocksEditor: React.FC = ({ profile, blocks, fields.map((field, index) => { const block = blocks.find((item) => item.blockId === field.blockId); return ( - +
{block?.name ?? field.blockId} - - {field.blockId} - - index > 0 && move(index, index - 1)} disabled={index === 0}> + index > 0 && move(index, index - 1)} + disabled={index === 0} + > index < fields.length - 1 && move(index, index + 1)} disabled={index >= fields.length - 1} > @@ -219,37 +184,45 @@ export const OperationProfileBlocksEditor: React.FC = ({ profile, blocks, > - remove(index)}> + remove(index)} + > - - { - setValue(`blockRefs.${index}.enabled`, event.currentTarget.checked, { shouldDirty: true }); - }} - /> - +
+ { + setValue(`blockRefs.${index}.enabled`, event.currentTarget.checked, { shouldDirty: true }); + }} + /> +
+ { const numeric = typeof value === 'number' && Number.isFinite(value) ? value : 0; setValue(`blockRefs.${index}.order`, numeric, { shouldDirty: true }); }} step={10} - style={{ width: 160 }} - /> -
- + style={{ width: 160 }} + /> +
+ ); }) )}
- +
); diff --git a/web/src/features/sidebars/operation-profiles/operation-profiles.css b/web/src/features/sidebars/operation-profiles/operation-profiles.css index 327f7a4a..6689cc58 100644 --- a/web/src/features/sidebars/operation-profiles/operation-profiles.css +++ b/web/src/features/sidebars/operation-profiles/operation-profiles.css @@ -7,6 +7,23 @@ --op-space-lg: 20px; --op-space-xl: 24px; width: 100%; + min-height: 0; +} + +.op-ui--fullscreen.op-ui--blocks { + height: 100%; + overflow: hidden; + gap: var(--op-space-sm) !important; +} + +.op-ui--fullscreen.op-ui--run { + height: 100%; + overflow: hidden; +} + +.op-ui--fullscreen.op-ui--blocks > .mantine-Tabs-root, +.op-ui--fullscreen.op-ui--blocks > .op-command { + flex: 0 0 auto; } .op-ui .op-command { @@ -45,6 +62,25 @@ margin-left: auto; } +.op-ui .op-compactSaveBar { + position: sticky; + top: 0; + z-index: 8; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--op-space-sm); + padding: 10px 12px; + background: color-mix(in srgb, var(--op-surface) 94%, var(--op-accent)); + border: 1px solid var(--op-accent); + border-radius: var(--op-radius-md); + box-shadow: var(--op-shadow); +} + +.op-ui .op-saveStatus { + color: var(--op-accent); +} + .op-ui .op-nodeButton { min-width: 200px; } @@ -84,88 +120,6 @@ padding: var(--op-space-md); } -.op-ui .op-editorHeader { - display: flex; - gap: var(--op-space-sm); - align-items: center; - justify-content: space-between; - flex-wrap: wrap; -} - -.op-ui .op-stickyHeader { - position: sticky; - top: 0; - background: var(--op-surface); - z-index: 5; - padding-bottom: var(--op-space-sm); - border-bottom: 1px solid var(--op-border); -} - -.op-ui .op-inspectorHeader { - margin-bottom: var(--op-space-sm); -} - -.op-ui .op-sectionToggle { - cursor: pointer; - user-select: none; -} - -.op-ui .op-workspace { - display: grid; - grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); - gap: var(--op-space-md); - align-items: start; -} - -.op-ui .op-listPane { - display: flex; - flex-direction: column; - min-height: 0; -} - -.op-ui .op-stickyPane { - position: sticky; - top: 0; - max-height: calc(100dvh - 180px); - overflow: hidden; -} - -.op-ui .op-listPane, -.op-ui .op-inspectorPane { - background: var(--op-surface); - border: 1px solid var(--op-border); - border-radius: var(--op-radius-lg); - box-shadow: var(--op-shadow); - padding: var(--op-space-md); -} - -.op-ui .op-listHeader { - background: var(--op-surface); - padding-bottom: var(--op-space-sm); - border-bottom: 1px solid var(--op-border); - margin-bottom: var(--op-space-sm); -} - -.op-ui .op-listLayout { - min-height: 0; -} - -.op-ui .op-stickyPane .op-listLayout { - height: 100%; -} - -.op-ui .op-listScrollArea { - max-height: min(56vh, 640px); - overflow-y: auto; - padding-right: 4px; -} - -.op-ui .op-listScrollArea--fill { - flex: 1; - min-height: 0; - max-height: none; -} - .op-ui .op-listRows { display: flex; flex-direction: column; @@ -269,12 +223,6 @@ outline-offset: 2px; } -@media (max-width: 1199px) { - .op-ui .op-workspace { - grid-template-columns: 1fr; - } -} - @media (max-width: 767px) { .op-ui .op-command, .op-ui .op-listPane, @@ -319,4 +267,5 @@ .op-ui .op-listScrollArea { max-height: 50vh; } + } diff --git a/web/src/features/sidebars/operation-profiles/operation-workspace.css b/web/src/features/sidebars/operation-profiles/operation-workspace.css new file mode 100644 index 00000000..feea7a5e --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/operation-workspace.css @@ -0,0 +1,301 @@ +.op-ui .op-blockEditorRoot { + display: flex; + flex-direction: column; + gap: var(--op-space-sm); + min-height: 0; +} + +.op-ui--fullscreen.op-ui--blocks .op-blockEditorRoot { + flex: 1 1 auto; + overflow: hidden; +} + +.op-ui .op-settingsPanel { + flex: 0 0 auto; + background: var(--op-surface); + border: 1px solid var(--op-border); + border-radius: var(--op-radius-lg); + overflow: hidden; +} + +.op-ui .op-settingsSummary { + display: flex; + align-items: center; + gap: var(--op-space-sm); + flex: 1 1 auto; + min-width: 0; + padding: 10px 14px; + text-align: left; +} + +.op-ui .op-settingsHeader { + display: flex; + align-items: center; +} + +.op-ui .op-profileEnabledToggle { + flex: 0 0 auto; + padding: 10px 14px 10px 8px; + white-space: nowrap; +} + +.op-ui .op-settingsIdentity { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.op-ui .op-settingsCopy { + min-width: 0; +} + +.op-ui .op-settingsChevron { + flex: 0 0 auto; + transition: transform 160ms ease; +} + +.op-ui .op-settingsChevron[data-opened='true'] { + transform: rotate(180deg); +} + +.op-ui .op-settingsFields { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.4fr) auto; + gap: var(--op-space-sm); + align-items: end; + padding: var(--op-space-sm) 14px 14px; + border-top: 1px solid var(--op-border); +} + +.op-ui .op-profileSettingsFields { + display: flex; + flex-direction: column; + gap: var(--op-space-sm); + padding: var(--op-space-sm) 14px 14px; + border-top: 1px solid var(--op-border); +} + +.op-ui .op-profileIdentityFields, +.op-ui .op-profileRuntimeFields { + display: grid; + gap: var(--op-space-sm); + align-items: end; +} + +.op-ui .op-profileIdentityFields { + grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.35fr); +} + +.op-ui .op-profileRuntimeFields { + grid-template-columns: minmax(280px, 520px); +} + +.op-ui .op-switchField { + display: flex; + align-items: center; + align-self: end; + min-height: 36px; +} + +.op-ui--drawer .op-settingsFields, +.op-ui--drawer .op-profileIdentityFields, +.op-ui--drawer .op-profileRuntimeFields { + grid-template-columns: 1fr; +} + +.op-ui .op-blockRefControls { + display: grid; + grid-template-columns: minmax(220px, 1fr) 160px; + gap: var(--op-space-md); + align-items: end; + margin-top: var(--op-space-xs); +} + +.op-ui .op-compositionPanel, +.op-ui .op-compactList, +.op-ui .op-compactInspectorBody { + background: var(--op-surface); + border: 1px solid var(--op-border); + border-radius: var(--op-radius-lg); + padding: var(--op-space-md); +} + +.op-ui .op-compositionRow { + padding: var(--op-space-sm) 0; + border-top: 1px solid var(--op-border); +} + +.op-ui .op-compositionRow:last-child { + padding-bottom: 0; +} + +.op-ui .op-editorHeader { + display: flex; + gap: var(--op-space-sm); + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.op-ui .op-inspectorHeader { + flex: 0 0 auto; + padding: 14px var(--op-space-md) var(--op-space-sm); + border-bottom: 1px solid var(--op-border); +} + +.op-ui .op-sectionToggle { + cursor: pointer; + user-select: none; +} + +.op-ui .op-workspace { + display: grid; + grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); + gap: var(--op-space-md); + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.op-ui .op-listPane, +.op-ui .op-inspectorPane { + min-height: 0; + background: var(--op-surface); + border: 1px solid var(--op-border); + border-radius: var(--op-radius-lg); + overflow: hidden; +} + +.op-ui .op-listPane, +.op-ui .op-inspectorPane, +.op-ui .op-listLayout { + display: flex; + flex-direction: column; +} + +.op-ui .op-listPane { + padding: var(--op-space-md); +} + +.op-ui .op-listHeader { + background: var(--op-surface); + padding-bottom: var(--op-space-sm); + border-bottom: 1px solid var(--op-border); + margin-bottom: var(--op-space-sm); +} + +.op-ui .op-listLayout, +.op-ui .op-inspectorScroll { + flex: 1 1 auto; + min-height: 0; +} + +.op-ui .op-inspectorScroll { + overflow-y: auto; + padding: var(--op-space-md); +} + +.op-ui .op-listScrollArea { + max-height: min(56vh, 640px); + overflow-y: auto; + padding-right: 4px; +} + +.op-ui .op-listScrollArea--fill { + flex: 1; + min-height: 0; + max-height: none; +} + +.op-ui .op-compactScreen { + animation: op-panel-enter 160ms ease-out; +} + +.op-ui .op-compactInspector { + min-width: 0; +} + +.op-ui .op-compactNav { + position: sticky; + top: 0; + z-index: 7; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--op-space-sm); + margin-bottom: var(--op-space-sm); + padding: 8px 4px; + background: var(--ts-surface); + border-bottom: 1px solid var(--op-border); +} + +.op-ui .op-emptyState, +.op-ui .op-runEmpty { + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--op-space-md); + padding: var(--op-space-xl); +} + +.op-ui .op-emptyState { + min-height: 260px; + color: var(--op-muted); + background: var(--op-surface); + border: 1px dashed var(--op-border-strong); + border-radius: var(--op-radius-lg); +} + +.op-ui .op-runEmpty { + min-height: 320px; + max-width: 480px; + margin: auto; +} + +.op-ui .op-runEmptyIcon { + display: grid; + place-items: center; + width: 52px; + height: 52px; + color: var(--op-accent); + background: var(--op-accent-soft); + border-radius: 50%; +} + +@keyframes op-panel-enter { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (max-width: 767px) { + .op-ui .op-settingsFields, + .op-ui .op-profileIdentityFields, + .op-ui .op-profileRuntimeFields, + .op-ui .op-blockRefControls { + grid-template-columns: 1fr; + } + + .op-ui .op-profileEnabledToggle { + padding-left: 4px; + } +} + +@media (prefers-reduced-motion: reduce) { + .op-ui .op-compactScreen { + animation: none; + } + + .op-ui .op-settingsChevron, + .op-ui .op-listRow { + transition: none; + } +} diff --git a/web/src/features/sidebars/operation-profiles/ui/block-actions.tsx b/web/src/features/sidebars/operation-profiles/ui/block-actions.tsx index dc981fb5..ac28f260 100644 --- a/web/src/features/sidebars/operation-profiles/ui/block-actions.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/block-actions.tsx @@ -1,26 +1,9 @@ -import { Group } from '@mantine/core'; -import React, { useRef } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { LuCopyPlus, LuPlus, LuTrash2 } from 'react-icons/lu'; -import { EXPORT_FILE_ICON, IMPORT_FILE_ICON } from '@ui/file-transfer-icons'; -import { IconButtonWithTooltip } from '@ui/icon-button-with-tooltip'; -import { toaster } from '@ui/toaster'; -import { TOOLTIP_PORTAL_SETTINGS } from '@ui/z-index'; +import { EntityActionsMenu } from './entity-actions-menu'; type SelectedBlock = { blockId: string; name: string } | null; -const QUICK_ACTION_TOOLTIP_SETTINGS = TOOLTIP_PORTAL_SETTINGS; - -function downloadJson(filename: string, blob: Blob) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} type Props = { selected: SelectedBlock; @@ -33,96 +16,25 @@ type Props = { export const BlockActions: React.FC = ({ selected, onCreate, onDuplicate, onDelete, onExport, onImport }) => { const { t } = useTranslation(); - const fileInputRef = useRef(null); - return ( - - } - size="input-sm" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - onClick={onCreate} - /> - } - size="input-sm" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.blockId} - onClick={() => selected?.blockId && onDuplicate(selected.blockId)} - /> - } - size="input-sm" - colorPalette="red" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.blockId} - onClick={() => { - if (!selected?.blockId) return; - if (!window.confirm(t('operationProfiles.confirm.deleteBlock'))) return; - onDelete(selected.blockId); - }} - /> - - } - size="input-sm" - variant="ghost" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.blockId} - onClick={async () => { - if (!selected?.blockId) return; - try { - const exported = await onExport(selected.blockId); - downloadJson(exported.filename, exported.blob); - } catch (e) { - toaster.error({ - title: t('operationProfiles.toasts.exportError'), - description: e instanceof Error ? e.message : String(e), - }); - } - }} - /> - - { - const file = e.currentTarget.files?.[0]; - if (!file) return; - void onImport(file) - .catch((err) => { - toaster.error({ - title: t('operationProfiles.toasts.importError'), - description: err instanceof Error ? err.message : String(err), - }); - }) - .finally(() => { - e.currentTarget.value = ''; - }); - }} - /> - - } - size="input-sm" - variant="ghost" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - onClick={() => { - fileInputRef.current?.click(); - }} - /> - + ); }; diff --git a/web/src/features/sidebars/operation-profiles/ui/block-settings-panel.tsx b/web/src/features/sidebars/operation-profiles/ui/block-settings-panel.tsx new file mode 100644 index 00000000..b3026b86 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/block-settings-panel.tsx @@ -0,0 +1,53 @@ +import { Badge, Collapse, Stack, Text, UnstyledButton } from '@mantine/core'; +import React from 'react'; +import { useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { LuChevronDown } from 'react-icons/lu'; + +import { FormInput, FormSwitch } from '@ui/form-components'; + +type Props = { + operationCount: number; +}; + +export const BlockSettingsPanel: React.FC = ({ operationCount }) => { + const { t } = useTranslation(); + const [opened, setOpened] = React.useState(false); + const [name, enabled] = useWatch({ name: ['name', 'enabled'] }) as [unknown, unknown]; + const blockName = typeof name === 'string' && name.trim() ? name.trim() : t('operationProfiles.blocks.blockSettingsTitle'); + + return ( +
+ setOpened((value) => !value)} + aria-expanded={opened} + > +
+ + + + {blockName} + + + {t('operationProfiles.blocks.operationCount', { count: operationCount })} + + +
+ + {enabled ? t('operationProfiles.status.enabled') : t('operationProfiles.status.disabled')} + +
+ + +
+ + +
+ +
+
+
+
+ ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/editor-save-actions.tsx b/web/src/features/sidebars/operation-profiles/ui/editor-save-actions.tsx new file mode 100644 index 00000000..fc4e7c92 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/editor-save-actions.tsx @@ -0,0 +1,45 @@ +import { Button, Group, Text } from '@mantine/core'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuSave, LuUndo2 } from 'react-icons/lu'; + +export type EditorSaveState = { + canSave: boolean; + canDiscard: boolean; + onSave: () => void; + onDiscard: () => void; +}; + +type Props = { + state: EditorSaveState | null; + compact?: boolean; +}; + +export const EditorSaveActions: React.FC = ({ state, compact = false }) => { + const { t } = useTranslation(); + if (!state || (compact && !state.canSave)) return null; + + return ( +
+ {compact && ( + + {t('operationProfiles.operationEditor.unsaved')} + + )} + + + + +
+ ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/entity-actions-menu.tsx b/web/src/features/sidebars/operation-profiles/ui/entity-actions-menu.tsx new file mode 100644 index 00000000..5e261a00 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/entity-actions-menu.tsx @@ -0,0 +1,122 @@ +import { ActionIcon, Button, Group, Menu } from '@mantine/core'; +import React, { useRef } from 'react'; +import { LuCopyPlus, LuEllipsis, LuPlus, LuTrash2 } from 'react-icons/lu'; + +import { EXPORT_FILE_ICON, IMPORT_FILE_ICON } from '@ui/file-transfer-icons'; +import { toaster } from '@ui/toaster'; + +type Selection = { id: string; name: string } | null; + +type Props = { + selected: Selection; + labels: { + create: string; + more: string; + duplicate: string; + remove: string; + export: string; + import: string; + confirmRemove: string; + exportError: string; + importError: string; + }; + onCreate: () => void; + onDuplicate: (id: string) => void; + onRemove: (id: string) => void; + onExport: (id: string) => Promise<{ blob: Blob; filename: string }>; + onImport: (file: File) => Promise; +}; + +function downloadJson(filename: string, blob: Blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export const EntityActionsMenu: React.FC = ({ + selected, + labels, + onCreate, + onDuplicate, + onRemove, + onExport, + onImport, +}) => { + const fileInputRef = useRef(null); + + return ( + + + + + + + + + + } + disabled={!selected} + onClick={() => selected && onDuplicate(selected.id)} + > + {labels.duplicate} + + } + disabled={!selected} + onClick={() => { + if (!selected) return; + void onExport(selected.id) + .then((result) => downloadJson(result.filename, result.blob)) + .catch((error) => + toaster.error({ title: labels.exportError, description: error instanceof Error ? error.message : String(error) }), + ); + }} + > + {labels.export} + + } onClick={() => fileInputRef.current?.click()}> + {labels.import} + + + } + disabled={!selected} + onClick={() => { + if (!selected || !window.confirm(labels.confirmRemove)) return; + onRemove(selected.id); + }} + > + {labels.remove} + + + + + { + const file = event.currentTarget.files?.[0]; + if (!file) return; + void onImport(file) + .catch((error) => + toaster.error({ title: labels.importError, description: error instanceof Error ? error.message : String(error) }), + ) + .finally(() => { + event.currentTarget.value = ''; + }); + }} + /> + + ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-config-controls.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-config-controls.tsx index e4d8380f..876df616 100644 --- a/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-config-controls.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-config-controls.tsx @@ -36,26 +36,32 @@ export const OperationLlmConfigControls: React.FC = ({ index }) => { modelsByProviderTokenKey, presets, samplerPresets, - loadProvidersFx, - loadTokensFx, + ensureProvidersFx, + ensureTokensFx, loadModelsFx, - loadLlmPresetsFx, + ensureModelsFx, + ensureLlmPresetsFx, createLlmPresetFx, updateLlmPresetFx, deleteLlmPresetFx, + isLoadingModels, + isEnsuringModels, ] = useUnit([ llmProviderModel.$providers, llmProviderModel.$tokensByProviderId, llmProviderModel.$modelsByProviderTokenKey, llmProviderModel.$llmPresets, samplersModel.$items, - llmProviderModel.loadProvidersFx, - llmProviderModel.loadTokensFx, + llmProviderModel.ensureProvidersFx, + llmProviderModel.ensureTokensFx, llmProviderModel.loadModelsFx, - llmProviderModel.loadLlmPresetsFx, + llmProviderModel.ensureModelsFx, + llmProviderModel.ensureLlmPresetsFx, llmProviderModel.createLlmPresetFx, llmProviderModel.updateLlmPresetFx, llmProviderModel.deleteLlmPresetFx, + llmProviderModel.loadModelsFx.pending, + llmProviderModel.ensureModelsFx.pending, ]); const fieldPrefix = `operations.${index}.config.params` as const; @@ -94,13 +100,23 @@ export const OperationLlmConfigControls: React.FC = ({ index }) => { ); useEffect(() => { - void loadProvidersFx(); - void loadLlmPresetsFx(); - }, [loadLlmPresetsFx, loadProvidersFx]); + void ensureProvidersFx(); + void ensureLlmPresetsFx(); + }, [ensureLlmPresetsFx, ensureProvidersFx]); useEffect(() => { - void loadTokensFx(providerId); - }, [loadTokensFx, providerId]); + void ensureTokensFx(providerId); + }, [ensureTokensFx, providerId]); + + useEffect(() => { + if (!credentialRef) return; + void ensureModelsFx({ + providerId, + scope: 'global', + scopeId: 'global', + tokenId: credentialRef, + }); + }, [credentialRef, ensureModelsFx, providerId]); const patchRuntime = (patch: Partial) => { if (typeof patch.providerId !== 'undefined') { @@ -185,6 +201,7 @@ export const OperationLlmConfigControls: React.FC = ({ index }) => { providers={providers} tokens={tokens} models={models} + isLoadingModels={isLoadingModels || isEnsuringModels} presets={presets} runtime={runtime} onRuntimeChange={patchRuntime} diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-runtime-dialog.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-runtime-dialog.tsx index 73736041..3d0bbcd0 100644 --- a/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-runtime-dialog.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/operation-editor/sections/kind-params/shared/operation-llm-runtime-dialog.tsx @@ -1,12 +1,9 @@ import { Button, Stack, Text } from '@mantine/core'; -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog } from '@ui/dialog'; -import { toaster } from '@ui/toaster'; -import { LlmTokenManagerDialog } from '../../../../../../../llm-provider/llm-token-manager-dialog'; -import { LlmRuntimeSelectorFields } from '../../../../../../../llm-provider/runtime-selector-fields'; +import { LlmConnectionSelector } from '../../../../../../../llm-provider/llm-connection-editor'; import { OperationLlmPresetManager } from './operation-llm-preset-manager'; @@ -20,6 +17,7 @@ type Props = { providers: LlmProviderDefinition[]; tokens: LlmTokenListItem[]; models: LlmModel[]; + isLoadingModels: boolean; presets: LlmPresetDto[]; runtime: OperationLlmRuntimeFields; onRuntimeChange: (patch: Partial) => void; @@ -41,6 +39,7 @@ export const OperationLlmRuntimeDialog: React.FC = ({ providers, tokens, models, + isLoadingModels, presets, runtime, onRuntimeChange, @@ -51,86 +50,64 @@ export const OperationLlmRuntimeDialog: React.FC = ({ onDeletePreset, }) => { const { t } = useTranslation(); - const [isTokenManagerOpen, setTokenManagerOpen] = useState(false); return ( - <> - onOpenChange(false)}> - {t('common.close')} - - } - > - - - {t('operationProfiles.llmRuntime.dialogDescription')} - + onOpenChange(false)}> + {t('common.close')} + + } + > + + + {t('operationProfiles.llmRuntime.dialogDescription')} + - + - - onRuntimeChange({ - providerId, - credentialRef: '', - model: '', - }) - } - onTokenSelect={(tokenId: string | null) => - onRuntimeChange({ - credentialRef: tokenId ?? '', - model: '', - }) - } - onModelSelect={(model: string | null) => onRuntimeChange({ model: model ?? '' })} - onLoadModels={async () => { - try { - await onLoadModels(); - } catch (error) { - toaster.error({ - title: t('provider.toasts.modelsLoadFailed'), - description: error instanceof Error ? error.message : String(error), - }); - } - }} - allowTokenManager - showInlineTokenManager={false} - tokenManagerScope="global" - tokenManagerScopeId="global" - onOpenTokenManager={setTokenManagerOpen} - /> - - {t('operationProfiles.llmRuntime.tokenHelp')} - - - - - onRuntimeChange({ credentialRef: tokenId ?? '', model: '' })} - /> - + + onRuntimeChange({ + providerId, + credentialRef: '', + model: '', + }) + } + onTokenChange={async (tokenId: string | null) => + onRuntimeChange({ + credentialRef: tokenId ?? '', + model: '', + }) + } + onModelChange={async (model: string) => onRuntimeChange({ model })} + onRefreshModels={onLoadModels} + showProviderConfig={false} + showConnectionCheck={false} + /> + + {t('operationProfiles.llmRuntime.tokenHelp')} + + + ); }; diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-list.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-list.tsx index a754a234..f7e44839 100644 --- a/web/src/features/sidebars/operation-profiles/ui/operation-list.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/operation-list.tsx @@ -1,10 +1,10 @@ -import { Button, Group, Select, Stack, Text, TextInput } from '@mantine/core'; +import { Badge, Button, Group, Popover, Select, Stack, Text, TextInput } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import { useVirtualizer } from '@tanstack/react-virtual'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { LuPlus, LuSearch } from 'react-icons/lu'; +import { LuPlus, LuSearch, LuSlidersHorizontal } from 'react-icons/lu'; import { isOperationKind } from '../utils/operation-kind'; @@ -151,6 +151,7 @@ export const OperationList: React.FC = ({ if (!selectedOpId) return -1; return filteredRows.findIndex((row) => row.opId === selectedOpId); }, [filteredRows, selectedOpId]); + const activeFilterCount = [filters.kind, filters.enabled, filters.required].filter((value) => value !== 'all').length; useEffect(() => { if (!shouldVirtualize) return; @@ -195,65 +196,60 @@ export const OperationList: React.FC = ({ - setFilters((prev) => ({ ...prev, query: event.currentTarget.value }))} - placeholder={t('operationProfiles.filters.searchPlaceholder')} - leftSection={} - aria-label={t('operationProfiles.filters.searchAria')} - /> - - - - setFilters((prev) => ({ - ...prev, - enabled: - next === 'enabled' || next === 'disabled' || next === 'all' - ? next - : DEFAULT_FILTERS.enabled, - })) - } - comboboxProps={{ withinPortal: false }} - aria-label={t('operationProfiles.filters.byEnabledAria')} - /> - setFilters((prev) => ({ ...prev, kind: next === 'all' || next === null ? 'all' : (next as OperationFilterState['kind']) }))} + comboboxProps={{ withinPortal: false }} + /> + setFilters((prev) => ({ ...prev, required: next === 'required' || next === 'optional' || next === 'all' ? next : DEFAULT_FILTERS.required }))} + comboboxProps={{ withinPortal: false }} + /> +
+ +
diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-row.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-row.tsx index f2c7b29d..db86bd04 100644 --- a/web/src/features/sidebars/operation-profiles/ui/operation-row.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/operation-row.tsx @@ -1,5 +1,6 @@ import { Badge, Group, Paper, Stack, Text } from '@mantine/core'; import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import type { OperationKind } from '@shared/types/operation-profiles'; @@ -16,6 +17,7 @@ type OperationRowProps = { }; export const OperationRow: React.FC = memo(({ opId, index, name, kind, enabled, required, depsCount, selected, onSelect }) => { + const { t } = useTranslation(); return ( = memo(({ opId, index, na } }} > - + {name} - {opId} + {t(`operationProfiles.kind.${kind}`)} - - - #{index + 1} - - - {kind} - + + #{index + 1} + + + {(!enabled || required || depsCount > 0) && ( + {!enabled && ( - disabled + {t('operationProfiles.status.disabled')} )} {required && ( - required + {t('operationProfiles.sectionsLabels.required')} )} {depsCount > 0 && ( - deps {depsCount} + {t('operationProfiles.operations.dependencies', { count: depsCount })} )} - + )} ); }); diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.test.ts b/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.test.ts new file mode 100644 index 00000000..740c1b04 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveOperationWorkspaceMode, shouldShowBlockSettings } from './operation-workspace-mode'; + +describe('operation-workspace-mode', () => { + it('uses the split workspace whenever the large layout is available', () => { + expect( + resolveOperationWorkspaceMode({ + useSplitLayout: true, + compactView: 'list', + selectedOpId: null, + }), + ).toBe('split'); + }); + + it('opens the selected operation as a focused compact screen', () => { + expect( + resolveOperationWorkspaceMode({ + useSplitLayout: false, + compactView: 'inspector', + selectedOpId: 'operation-1', + }), + ).toBe('inspector'); + }); + + it('falls back to the compact list when no operation is selected', () => { + expect( + resolveOperationWorkspaceMode({ + useSplitLayout: false, + compactView: 'inspector', + selectedOpId: null, + }), + ).toBe('list'); + }); + + it('keeps block settings out of the focused compact inspector', () => { + expect(shouldShowBlockSettings('list')).toBe(true); + expect(shouldShowBlockSettings('split')).toBe(true); + expect(shouldShowBlockSettings('inspector')).toBe(false); + }); +}); diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.ts b/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.ts new file mode 100644 index 00000000..b98fe63f --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/operation-workspace-mode.ts @@ -0,0 +1,22 @@ +export type CompactOperationView = 'list' | 'inspector'; +export type OperationWorkspaceMode = 'split' | CompactOperationView; + +type ResolveModeParams = { + useSplitLayout: boolean; + compactView: CompactOperationView; + selectedOpId: string | null; +}; + +export function resolveOperationWorkspaceMode({ + useSplitLayout, + compactView, + selectedOpId, +}: ResolveModeParams): OperationWorkspaceMode { + if (useSplitLayout) return 'split'; + if (compactView === 'inspector' && selectedOpId) return 'inspector'; + return 'list'; +} + +export function shouldShowBlockSettings(mode: OperationWorkspaceMode): boolean { + return mode !== 'inspector'; +} diff --git a/web/src/features/sidebars/operation-profiles/ui/operation-workspace.tsx b/web/src/features/sidebars/operation-profiles/ui/operation-workspace.tsx new file mode 100644 index 00000000..3f29b00c --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/operation-workspace.tsx @@ -0,0 +1,103 @@ +import { Button, Stack, Text } from '@mantine/core'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuArrowLeft, LuPlus, LuWorkflow } from 'react-icons/lu'; + +import { OperationList } from './operation-list'; +import { getOperationListLayout } from './operation-list-layout'; + +import type { OperationWorkspaceMode } from './operation-workspace-mode'; +import type { OperationListRowMeta } from './types'; + +type Props = { + mode: OperationWorkspaceMode; + rows: OperationListRowMeta[]; + selectedOpId: string | null; + selectedIndex: number | null; + inspectorContent: React.ReactNode; + onAdd: () => void; + onMoveSelection: (direction: 'prev' | 'next') => void; + onSelect: (opId: string) => void; + onBackToList: () => void; +}; + +export const OperationWorkspace: React.FC = ({ + mode, + rows, + selectedOpId, + selectedIndex, + inspectorContent, + onAdd, + onMoveSelection, + onSelect, + onBackToList, +}) => { + const { t } = useTranslation(); + const listLayout = getOperationListLayout(mode === 'split'); + const list = ( + onSelect(selectedOpId) : undefined} + className={listLayout.listClassName} + scrollAreaClassName={listLayout.scrollAreaClassName} + /> + ); + + if (rows.length === 0) { + return ( +
+ + + {t('operationProfiles.operations.emptyTitle')} + + {t('operationProfiles.operations.empty')} + + + +
+ ); + } + + if (mode === 'split') { + return ( +
+ +
+
+ {t('operationProfiles.inspector.title')} + + {selectedIndex === null + ? t('operationProfiles.inspector.noneSelected') + : t('operationProfiles.inspector.operationNumber', { number: selectedIndex + 1 })} + +
+
{inspectorContent}
+
+
+ ); + } + + if (mode === 'list') { + return
{list}
; + } + + return ( +
+
+ + + {selectedIndex === null ? null : t('operationProfiles.inspector.operationNumber', { number: selectedIndex + 1 })} + +
+
{inspectorContent}
+
+ ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/profile-actions.tsx b/web/src/features/sidebars/operation-profiles/ui/profile-actions.tsx index 8cec79d3..c6763029 100644 --- a/web/src/features/sidebars/operation-profiles/ui/profile-actions.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/profile-actions.tsx @@ -1,26 +1,9 @@ -import { Group } from '@mantine/core'; -import React, { useRef } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { LuCopyPlus, LuPlus, LuTrash2 } from 'react-icons/lu'; -import { EXPORT_FILE_ICON, IMPORT_FILE_ICON } from '@ui/file-transfer-icons'; -import { IconButtonWithTooltip } from '@ui/icon-button-with-tooltip'; -import { toaster } from '@ui/toaster'; -import { TOOLTIP_PORTAL_SETTINGS } from '@ui/z-index'; +import { EntityActionsMenu } from './entity-actions-menu'; type SelectedProfile = { profileId: string; name: string } | null; -const QUICK_ACTION_TOOLTIP_SETTINGS = TOOLTIP_PORTAL_SETTINGS; - -function downloadJson(filename: string, blob: Blob) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} type Props = { selected: SelectedProfile; @@ -33,96 +16,25 @@ type Props = { export const ProfileActions: React.FC = ({ selected, onCreate, onDuplicate, onDelete, onExport, onImport }) => { const { t } = useTranslation(); - const fileInputRef = useRef(null); - return ( - - } - size="input-sm" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - onClick={onCreate} - /> - } - size="input-sm" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.profileId} - onClick={() => selected?.profileId && onDuplicate(selected.profileId)} - /> - } - size="input-sm" - colorPalette="red" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.profileId} - onClick={() => { - if (!selected?.profileId) return; - if (!window.confirm(t('operationProfiles.confirm.deleteProfile'))) return; - onDelete(selected.profileId); - }} - /> - - } - size="input-sm" - variant="ghost" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - disabled={!selected?.profileId} - onClick={async () => { - if (!selected?.profileId) return; - try { - const exported = await onExport(selected.profileId); - downloadJson(exported.filename, exported.blob); - } catch (e) { - toaster.error({ - title: t('operationProfiles.toasts.exportError'), - description: e instanceof Error ? e.message : String(e), - }); - } - }} - /> - - { - const file = e.currentTarget.files?.[0]; - if (!file) return; - void onImport(file) - .catch((err) => { - toaster.error({ - title: t('operationProfiles.toasts.importError'), - description: err instanceof Error ? err.message : String(err), - }); - }) - .finally(() => { - e.currentTarget.value = ''; - }); - }} - /> - - } - size="input-sm" - variant="ghost" - tooltipSettings={QUICK_ACTION_TOOLTIP_SETTINGS} - onClick={() => { - fileInputRef.current?.click(); - }} - /> - + ); }; diff --git a/web/src/features/sidebars/operation-profiles/ui/profile-settings-panel.tsx b/web/src/features/sidebars/operation-profiles/ui/profile-settings-panel.tsx new file mode 100644 index 00000000..d72cafb1 --- /dev/null +++ b/web/src/features/sidebars/operation-profiles/ui/profile-settings-panel.tsx @@ -0,0 +1,105 @@ +import { Collapse, Stack, Text, UnstyledButton } from '@mantine/core'; +import React from 'react'; +import { useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { LuChevronDown, LuRotateCcw } from 'react-icons/lu'; + +import { FormInput, FormSelect, FormSwitch } from '@ui/form-components'; +import { IconButtonWithTooltip } from '@ui/icon-button-with-tooltip'; +import { TOOLTIP_PORTAL_SETTINGS } from '@ui/z-index'; + +type Props = { + blockCount: number; + onResetSessionId: () => void; +}; + +export const ProfileSettingsPanel: React.FC = ({ blockCount, onResetSessionId }) => { + const { t } = useTranslation(); + const [opened, setOpened] = React.useState(false); + const executionModeDescriptionId = React.useId(); + const [name, executionMode] = useWatch({ name: ['name', 'executionMode'] }) as [unknown, unknown]; + const profileName = typeof name === 'string' && name.trim() ? name.trim() : t('operationProfiles.profileSettings.title'); + const modeLabel = + executionMode === 'sequential' + ? t('operationProfiles.profileSettings.executionModeSequential') + : t('operationProfiles.profileSettings.executionModeConcurrent'); + const modeDescription = + executionMode === 'sequential' + ? t('operationProfiles.profileSettings.executionModeSequentialDescription') + : t('operationProfiles.profileSettings.executionModeConcurrentDescription'); + + return ( +
+
+ setOpened((value) => !value)} + aria-expanded={opened} + > +
+ + + + {profileName} + + + {modeLabel} · {t('operationProfiles.profileSettings.blockCount', { count: blockCount })} + + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + + {modeDescription} + +
+
+
+ + } + size="input-sm" + variant="ghost" + tooltipSettings={TOOLTIP_PORTAL_SETTINGS} + onClick={onResetSessionId} + /> +
+
+
+
+ ); +}; diff --git a/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx b/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx index 1ce5e2eb..ce0388e3 100644 --- a/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx +++ b/web/src/features/sidebars/operation-profiles/ui/run-trace-panel.tsx @@ -1,10 +1,11 @@ -import { Badge, Collapse, Group, Loader, Paper, Stack, Text, UnstyledButton } from '@mantine/core'; +import { Badge, Button, Collapse, Group, Loader, Paper, Stack, Text, UnstyledButton } from '@mantine/core'; import { useUnit } from 'effector-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { LuChevronDown, LuChevronRight, LuCircleCheck, LuCircleMinus, LuCircleX } from 'react-icons/lu'; +import { LuActivity, LuChevronDown, LuChevronRight, LuCircleCheck, LuCircleMinus, LuCircleX, LuMessageCircle } from 'react-icons/lu'; import { $lastRunTrace } from '@model/operation-run-trace'; +import { toggleSidebarOpen } from '@model/sidebars'; import { describeDestination, describeOperationSummary, runStatusColor } from './run-trace-summary'; @@ -110,9 +111,29 @@ export const RunTracePanel: React.FC = () => { if (!trace) { return ( - - {t('operationProfiles.runTrace.empty')} - +
+
+ +
+ + + {t('operationProfiles.runTrace.emptyTitle')} + + + {t('operationProfiles.runTrace.empty')} + + + {t('operationProfiles.runTrace.emptyHint')} + + + +
); } diff --git a/web/src/features/sidebars/settings/preset-controls.tsx b/web/src/features/sidebars/settings/preset-controls.tsx index 715b1de5..9416af52 100644 --- a/web/src/features/sidebars/settings/preset-controls.tsx +++ b/web/src/features/sidebars/settings/preset-controls.tsx @@ -38,6 +38,7 @@ type Props = { disableDelete?: boolean; layout?: PresetControlsLayout; extraActions?: ReactNode; + showSaveAction?: boolean; }; export const PresetControls: React.FC = ({ @@ -56,11 +57,18 @@ export const PresetControls: React.FC = ({ disableDelete = false, layout = 'inline', extraActions, + showSaveAction = true, }) => { const actionButtons = ( <> } tooltip={labels.create} aria-label={labels.create} onClick={onCreate} /> - } tooltip={labels.rename} aria-label={labels.rename} onClick={onRename} disabled={disableRename} /> + } + tooltip={labels.rename} + aria-label={labels.rename} + onClick={onRename} + disabled={disableRename} + /> } tooltip={labels.duplicate} @@ -69,15 +77,23 @@ export const PresetControls: React.FC = ({ disabled={disableDuplicate} /> {extraActions} + {showSaveAction ? ( + } + tooltip={labels.save} + aria-label={labels.save} + onClick={onSave} + disabled={disableSave} + variant="solid" + /> + ) : null} } - tooltip={labels.save} - aria-label={labels.save} - onClick={onSave} - disabled={disableSave} - variant="solid" + icon={} + tooltip={labels.delete} + aria-label={labels.delete} + onClick={onDelete} + disabled={disableDelete} /> - } tooltip={labels.delete} aria-label={labels.delete} onClick={onDelete} disabled={disableDelete} /> ); diff --git a/web/src/features/sidebars/settings/rag-connection-editor.tsx b/web/src/features/sidebars/settings/rag-connection-editor.tsx new file mode 100644 index 00000000..b082d67c --- /dev/null +++ b/web/src/features/sidebars/settings/rag-connection-editor.tsx @@ -0,0 +1,179 @@ +import { Button, Group, Input, Select, Stack, Text, TextInput, UnstyledButton } from '@mantine/core'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuKeyRound, LuSearch } from 'react-icons/lu'; + +import { LlmModelPickerDialog } from '../../llm-provider/llm-model-picker-dialog'; +import { LlmTokenManagerDialog } from '../../llm-provider/llm-token-manager-dialog'; + +import type { LlmTokenListItem } from '@shared/types/llm'; +import type { RagModel, RagProviderConfig, RagProviderDefinition, RagProviderId } from '@shared/types/rag'; + +type Props = { + providers: RagProviderDefinition[]; + providerId: RagProviderId; + tokens: LlmTokenListItem[]; + tokenId: string | null; + models: RagModel[]; + modelId: string | null; + config: RagProviderConfig; + isLoadingModels: boolean; + isChecking: boolean; + onProviderChange: (providerId: RagProviderId) => Promise; + onTokenChange: (tokenId: string | null) => Promise; + onModelChange: (modelId: string | null) => Promise; + onConfigChange: (config: RagProviderConfig) => void; + onRefreshModels: () => Promise; + onRefreshTokens: () => Promise; + onCheckConnection: () => Promise; +}; + +export const RagConnectionEditor: React.FC = ({ + providers, + providerId, + tokens, + tokenId, + models, + modelId, + config, + isLoadingModels, + isChecking, + onProviderChange, + onTokenChange, + onModelChange, + onConfigChange, + onRefreshModels, + onRefreshTokens, + onCheckConnection, +}) => { + const { t } = useTranslation(); + const [tokenManagerOpen, setTokenManagerOpen] = useState(false); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const activeProvider = providers.find((provider) => provider.id === providerId); + const activeModel = models.find((model) => model.id === modelId); + const providerOptions = useMemo( + () => providers.filter((provider) => provider.enabled).map((provider) => ({ value: provider.id, label: provider.name })), + [providers], + ); + const tokenOptions = useMemo( + () => tokens.map((token) => ({ value: token.id, label: `${token.name} · ${token.tokenHint}` })), + [tokens], + ); + + return ( + + {t('rag.connection.title')} + void onTokenChange(value ?? null)} + placeholder={tokens.length ? t('rag.placeholders.selectToken') : t('rag.placeholders.noTokens')} + clearable + searchable + style={{ flex: 1 }} + comboboxProps={{ withinPortal: false }} + /> + + + + )} + + {providerId === 'openrouter' ? ( + + setModelPickerOpen(true)} + disabled={!tokenId} + aria-label={t('rag.model.openPicker')} + style={{ + width: '100%', + minHeight: 58, + border: '1px solid var(--mantine-color-default-border)', + borderRadius: 'var(--mantine-radius-md)', + padding: '9px 12px', + opacity: tokenId ? 1 : 0.55, + }} + > + + + + {activeModel?.name ?? modelId ?? t('rag.placeholders.selectModel')} + + {modelId ? {modelId} : null} + + + + + + ) : ( + void onModelChange(event.currentTarget.value || null)} + placeholder="nomic-embed-text" + /> + )} + + + + + + {providerId === 'openrouter' ? ( + <> + void onTokenChange(value)} + onTokensChanged={onRefreshTokens} + /> + void onModelChange(value)} + showCapabilityFilters={false} + /> + + ) : null} + + ); +}; diff --git a/web/src/features/sidebars/settings/rag-preset-manager.tsx b/web/src/features/sidebars/settings/rag-preset-manager.tsx new file mode 100644 index 00000000..9d9c384f --- /dev/null +++ b/web/src/features/sidebars/settings/rag-preset-manager.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from 'react-i18next'; + +import { toaster } from '@ui/toaster'; + +import { PresetControls } from './preset-controls'; + +import type { RagPreset, RagPresetPayload, RagPresetSettings } from '@shared/types/rag'; + +type Props = { + presets: RagPreset[]; + settings: RagPresetSettings | null; + hasUnsavedChanges: boolean; + buildPayload: () => RagPresetPayload; + onCreate: (params: { name: string; payload: RagPresetPayload }) => Promise; + onUpdate: (preset: RagPreset) => Promise; + onDelete: (id: string) => Promise<{ id: string }>; + onSelect: (id: string, options?: { skipUnsavedConfirm?: boolean }) => Promise; + onSaveCurrent: () => Promise; +}; + +export const RagPresetManager: React.FC = ({ + presets, + settings, + hasUnsavedChanges, + buildPayload, + onCreate, + onUpdate, + onDelete, + onSelect, + onSaveCurrent, +}) => { + const { t } = useTranslation(); + const activePreset = presets.find((preset) => preset.id === settings?.selectedId) ?? null; + const options = presets.map((preset) => ({ value: preset.id, label: preset.name })); + const fail = (error: unknown) => + toaster.error({ + title: t('rag.presets.toasts.failed'), + description: error instanceof Error ? error.message : String(error), + }); + + const create = async () => { + const name = window.prompt(t('rag.presets.actions.createPrompt'), t('rag.presets.defaults.newPresetName'))?.trim(); + if (!name) return; + try { + const preset = await onCreate({ name, payload: buildPayload() }); + await onSelect(preset.id, { skipUnsavedConfirm: true }); + toaster.success({ title: t('rag.presets.toasts.created'), description: preset.name }); + } catch (error) { + fail(error); + } + }; + + const rename = async () => { + if (!activePreset) return; + const name = window.prompt(t('rag.presets.actions.renamePrompt'), activePreset.name)?.trim(); + if (!name) return; + try { + await onUpdate({ ...activePreset, name, updatedAt: new Date().toISOString() }); + toaster.success({ title: t('rag.presets.toasts.saved'), description: name }); + } catch (error) { + fail(error); + } + }; + + const duplicate = async () => { + if (!activePreset) return; + try { + const preset = await onCreate({ name: `${activePreset.name} copy`, payload: activePreset.payload }); + await onSelect(preset.id, { skipUnsavedConfirm: true }); + toaster.success({ title: t('rag.presets.toasts.created'), description: preset.name }); + } catch (error) { + fail(error); + } + }; + + const remove = async () => { + if (!activePreset || !window.confirm(t('rag.presets.confirm.delete'))) return; + try { + await onDelete(activePreset.id); + toaster.success({ title: t('rag.presets.toasts.deleted'), description: activePreset.name }); + } catch (error) { + fail(error); + } + }; + + const select = async (id: string) => { + try { + await onSelect(id); + } catch (error) { + fail(error); + } + }; + + return ( + id && void select(id)} + onCreate={() => void create()} + onRename={() => void rename()} + onDuplicate={() => void duplicate()} + onSave={() => void onSaveCurrent()} + onDelete={() => void remove()} + disableRename={!activePreset} + disableDuplicate={!activePreset} + disableSave={!activePreset || !hasUnsavedChanges} + disableDelete={!activePreset} + /> + ); +}; diff --git a/web/src/features/sidebars/settings/rag-provider-advanced-config.tsx b/web/src/features/sidebars/settings/rag-provider-advanced-config.tsx new file mode 100644 index 00000000..416b44c5 --- /dev/null +++ b/web/src/features/sidebars/settings/rag-provider-advanced-config.tsx @@ -0,0 +1,67 @@ +import { Select, Stack, Switch, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +import type { RagProviderConfig, RagProviderId } from '@shared/types/rag'; + +type Props = { + providerId: RagProviderId; + config: RagProviderConfig; + onChange: (config: RagProviderConfig) => void; +}; + +export const RagProviderAdvancedConfig: React.FC = ({ providerId, config, onChange }) => { + const { t } = useTranslation(); + const update = (patch: RagProviderConfig) => onChange({ ...config, ...patch }); + + return ( + + update({ defaultModel: event.currentTarget.value || undefined })} + placeholder={providerId === 'openrouter' ? 'openai/text-embedding-3-small' : 'nomic-embed-text'} + /> + {providerId === 'openrouter' ? ( + <> + { + const value = Number.parseInt(event.currentTarget.value, 10); + update({ dimensions: Number.isInteger(value) && value > 0 ? value : undefined }); + }} + /> + ragProviderModel.ragProviderSelected((value ?? 'openrouter') as RagProviderId)} - allowDeselect={false} - comboboxProps={{ withinPortal: false }} + return ( + + + buildPayload()} + onCreate={createPresetFx} + onUpdate={updatePresetFx} + onDelete={deletePresetFx} + onSelect={selectPreset} + onSaveCurrent={save} /> - - - {activeProviderId === 'openrouter' && ( - - { - setModelDraft(value ?? ''); - ragProviderModel.ragModelSelected(value ?? null); + + setValue('config', next, { shouldDirty: true })} + onRefreshModels={refreshModels} + onRefreshTokens={async () => { + await loadTokensFx(providerId); }} - placeholder={canLoadModels ? t('provider.placeholders.selectModel') : t('provider.placeholders.selectTokenFirst')} - disabled={!canLoadModels} - clearable - searchable - comboboxProps={{ withinPortal: false }} + onCheckConnection={checkConnection} /> - - setModelDraft(event.currentTarget.value)} - onBlur={applyModelDraft} - placeholder={t('rag.model.manualPlaceholder')} - style={{ flex: 1 }} + + setValue('config', next, { shouldDirty: true })} /> - + - - {t('rag.config.title')} - - {activeProvider?.configFields.map((field) => { - if (field.type === 'select') { - return ( -