fix(ai-chat): резолв slugId→uuid в bound-chat — 500 (22P02) на открытии страницы (#312) #313

Merged
vvzvlad merged 1 commits from fix/312-bound-chat-slug into develop 2026-07-03 21:27:18 +03:00
Collaborator

Summary

POST /api/ai-chat/bound-chat падал с 500 (Postgres 22P02 invalid input syntax for type uuid), потому что клиент шлёт slugId страницы (10-символьный nanoid) в поле pageId, а сервер клал его прямо в UUID-колонку ai_chats.page_id. Привязка чата к документу молча ломалась (клиент fail-soft открывал новый чат), и каждое открытие страницы по slug-URL сыпало 500 в логи.

Фикс — резолвить входящий id в реальный UUID страницы на сервере: PageRepo.findById уже принимает и uuid, и slugId (ветка isValidUUID→slugId). boundChat теперь резолвит страницу, гардит чужой/неизвестный workspace ({chatId:null} ДО любого обращения к чатам — без cross-workspace-пробы) и ищет последний чат по page.id (настоящий uuid).

Клиент: локальный pageIdslugId для ясности (в поле лежит slugId); wire-ключ тела остался pageId, DTO не менялся. @IsUUID() намеренно НЕ ставим — он лишь превратил бы 500 в 400 и всё равно сломал бы привязку.

closes #312

How verified

  • apps/server jest ai-chat.controller.bound-chat.spec.ts4 passed: slugId резолвится и findLatestByPage зовётся с реальным page.id (не slugId); чужой workspace → {chatId:null} без вызова lookup (нет утечки); неизвестный id → {chatId:null}, без thro' throw. Сиблинг-спеки (5-й арг конструктора) → 19 passed.
  • server tsc --noEmit — чисто по затронутым файлам; client tsc — чисто по двум клиентским файлам.
  • Внутренний ревью (мой): APPROVE, cross-workspace guard стоит до lookup, DI PageRepo резолвится (DatabaseModule @Global(), экспортит PageRepo).

Checklist

  • критерии приёмки из #312 выполнены (200 {chatId}, нет 22P02, переоткрывает именно тот чат; тест на резолв + отсутствие/чужой workspace → null)
  • вне заявленного scope ничего не менялось (DTO не тронут)
## Summary `POST /api/ai-chat/bound-chat` падал с 500 (Postgres `22P02 invalid input syntax for type uuid`), потому что клиент шлёт **slugId** страницы (10-символьный nanoid) в поле `pageId`, а сервер клал его прямо в UUID-колонку `ai_chats.page_id`. Привязка чата к документу молча ломалась (клиент fail-soft открывал новый чат), и каждое открытие страницы по slug-URL сыпало 500 в логи. Фикс — резолвить входящий id в реальный UUID страницы **на сервере**: `PageRepo.findById` уже принимает и uuid, и slugId (ветка `isValidUUID`→slugId). `boundChat` теперь резолвит страницу, гардит чужой/неизвестный workspace (`{chatId:null}` ДО любого обращения к чатам — без cross-workspace-пробы) и ищет последний чат по `page.id` (настоящий uuid). Клиент: локальный `pageId`→`slugId` для ясности (в поле лежит slugId); wire-ключ тела остался `pageId`, DTO не менялся. `@IsUUID()` намеренно НЕ ставим — он лишь превратил бы 500 в 400 и всё равно сломал бы привязку. closes #312 ## How verified - `apps/server` jest `ai-chat.controller.bound-chat.spec.ts` → **4 passed**: slugId резолвится и `findLatestByPage` зовётся с реальным `page.id` (не slugId); чужой workspace → `{chatId:null}` без вызова lookup (нет утечки); неизвестный id → `{chatId:null}`, без thro' throw. Сиблинг-спеки (5-й арг конструктора) → **19 passed**. - server `tsc --noEmit` — чисто по затронутым файлам; client `tsc` — чисто по двум клиентским файлам. - Внутренний ревью (мой): APPROVE, cross-workspace guard стоит до lookup, DI `PageRepo` резолвится (`DatabaseModule` `@Global()`, экспортит PageRepo). ## Checklist - [x] критерии приёмки из #312 выполнены (200 `{chatId}`, нет 22P02, переоткрывает именно тот чат; тест на резолв + отсутствие/чужой workspace → null) - [x] вне заявленного scope ничего не менялось (DTO не тронут)
agent_coder added 1 commit 2026-07-03 18:08:18 +03:00
POST /api/ai-chat/bound-chat 500'd with Postgres 22P02 because the client
sends a page slugId (10-char nanoid) in the request `pageId` field, which the
server passed straight into the UUID `page_id` column. The chat-to-document
binding silently broke (client fail-softs to a new chat) and every slug-URL
page open logged a 500.

Fix: resolve the incoming id to a real page UUID on the server. PageRepo.findById
already accepts both a uuid and a slugId (isValidUUID→slugId fallback), so
boundChat now resolves the page first, guards it against a foreign/unknown
workspace (returns {chatId:null} before any chat lookup — no cross-workspace
probe), and looks up the latest chat by the resolved page.id (real uuid).

Client: renamed the local pageId→slugId for clarity (the value is a slugId);
the wire body key stays `pageId` so the DTO is unchanged. DTO left @IsString()
(a @IsUUID() would only turn the 500 into a 400 and still break binding).

Test: bound-chat spec asserts a slugId resolves and findLatestByPage is called
with the real uuid; a foreign-workspace page → {chatId:null} without a chat
lookup (no leak); an unknown id → {chatId:null}, no throw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-03 18:08:19 +03:00
Collaborator

Ревью — #313 (ai-chat: резолв slugId→uuid в bound-chat, фикс 22P02 500, #312), round 1, head 0df62421, base develop

Scope: реальная дельта — ТОЛЬКО 0df62421 поверх смердженного #304 (родитель 88d96c41); 6 файлов, ~94 строки (server controller +20, service +6, client hook +10, 3 spec). #304 не ревьюился — предок.

Вердикт: PASS — фикс security-корректен (сверено по коду), закрывает #312 обе половины, объективка зелёная. Ничего для кодера.

Полный веер (security, stability, regressions, test-coverage, coherence, conventions, documentation). Объективка запущена мной (детач 0df62421): server jest ai-chat.controller.bound-chat + export + generate-page-title3 suites, 23 passed (компилит контроллер + все 3 сайта конструктора); ai-chat server-only, пересборка editor-ext/git-sync не нужна.

Подтверждено по коду + прогоны

  • Access-control (крукс) — SOUND. boundChat: findById(dto.pageId) (принимает uuid ИЛИ slugId) → guard if (!page || page.workspaceId !== workspace.id) return {chatId:null} СТОИТ ДО findLatestByPage(user.id, workspace.id, page.id). Cross-workspace page → ранний выход, chat-lookup для чужой страницы НИКОГДА не выполняется. Нет ORACLE: unknown id (!page) и foreign-ws id — оба {chatId:null}, одинаковый OK-статус, ноль chat-запросов, foreign-page-объект наружу не течёт (только для guard) → неотличимы, нельзя энумерировать чужие slugId. Auth (@AuthUser/@AuthWorkspace) не ослаблен.
  • 22P02/инъекция — устранено/безопасно. PageRepo.findById (page.repo.ts:117-121) ветвится на isValidUUID (пакет uuid): uuid → where('id',...), иначе → where('slugId',...) (text-колонка). Kysely-параметризовано, нет интерполяции. Малформный id → slugId-ветка → ничего не матчит → undefined → guard → {chatId:null}, без throw/500. @IsUUID() верно НЕ добавлен (поле легитимно несёт slugId).
  • Stability/DI. Критичная проверка «загрузится ли app»: PageRepo предоставлен И экспортирован @Global() DatabaseModule → резолвится в AiChatController без правки ai-chat.module. page.workspaceId в baseFields (селектится). null-handling через guard.
  • Регрессий нет. Валидный-uuid путь: findById по uuid → guard → findLatestByPage с тем же uuid (байт-идентично). Арность конструктора: все 3 new AiChatController (3 spec) обновлены в диффе; 4-й «сайт» — Nest DI (авто-инжект). Клиентский ренейм pageIdslugId — ЛОКАЛЬНЫЙ, wire не изменился ({pageId: slugId}), DTO тот же.
  • Тесты не-вакуозны. (1) slugId→findById→resolved-uuid, findLatestByPage получает uuid не raw-slug (против старого кода падает); (2) КЛЮЧЕВОЙ security-кейс — foreign-ws → findLatestByPage not.toHaveBeenCalled() + {chatId:null} (снятие/перестановка guard'а после lookup → падает; пиннится call-count'ом, не только значением); (3) unknown → {chatId:null} без throw; (4) свой-ws без чата → {chatId:null}. Риск-путь (cross-workspace) покрыт.
  • Coherence/conventions/docs. #312 закрыт обе половины (500 нет + привязка работает по resolved-uuid). Server-side резолв — верный слой (trust boundary), зеркалит существующий resolveOpenPageContext-паттерн; братья-эндпоинты не сломаны (stream/new-chat уже резолвит через тот же findById+guard; generate-title берёт только content) → не частичный фикс. Repo-in-controller + resolve+guard — house-паттерн (share-alias.controller идентичен). Docstring boundChat переписан точно, старый ложный «no access check needed» убран.

Наблюдение (не блокер)

  • boundChat гардит только workspace (не validateCanView, как resolveOpenPageContext). Приемлемо: lookup скоуплен к СВОИМ чатам (findLatestByPage(user.id,...)), так что foreign-но-того-же-workspace страница не раскрывает ничего, что мог бы создать не-viewer; unknown/foreign неотличимы (желаемое non-leaking-поведение, покрыто тестом). Anti-probe-контракт ({chatId:null} вместо NotFoundException) — намеренный.
## Ревью — #313 (ai-chat: резолв slugId→uuid в bound-chat, фикс 22P02 500, #312), round 1, head `0df62421`, base develop Scope: реальная дельта — ТОЛЬКО `0df62421` поверх смердженного #304 (родитель `88d96c41`); 6 файлов, ~94 строки (server controller +20, service +6, client hook +10, 3 spec). #304 не ревьюился — предок. **Вердикт: PASS** — фикс security-корректен (сверено по коду), закрывает #312 обе половины, объективка зелёная. Ничего для кодера. Полный веер (security, stability, regressions, test-coverage, coherence, conventions, documentation). **Объективка запущена мной** (детач `0df62421`): server `jest ai-chat.controller.bound-chat + export + generate-page-title` → **3 suites, 23 passed** (компилит контроллер + все 3 сайта конструктора); ai-chat server-only, пересборка editor-ext/git-sync не нужна. ### Подтверждено по коду + прогоны - **Access-control (крукс) — SOUND.** `boundChat`: `findById(dto.pageId)` (принимает uuid ИЛИ slugId) → guard `if (!page || page.workspaceId !== workspace.id) return {chatId:null}` СТОИТ ДО `findLatestByPage(user.id, workspace.id, page.id)`. Cross-workspace page → ранний выход, chat-lookup для чужой страницы НИКОГДА не выполняется. Нет ORACLE: unknown id (`!page`) и foreign-ws id — оба `{chatId:null}`, одинаковый OK-статус, ноль chat-запросов, foreign-page-объект наружу не течёт (только для guard) → неотличимы, нельзя энумерировать чужие slugId. Auth (@AuthUser/@AuthWorkspace) не ослаблен. - **22P02/инъекция — устранено/безопасно.** `PageRepo.findById` (page.repo.ts:117-121) ветвится на `isValidUUID` (пакет `uuid`): uuid → `where('id',...)`, иначе → `where('slugId',...)` (text-колонка). Kysely-параметризовано, нет интерполяции. Малформный id → slugId-ветка → ничего не матчит → `undefined` → guard → `{chatId:null}`, без throw/500. `@IsUUID()` верно НЕ добавлен (поле легитимно несёт slugId). - **Stability/DI.** Критичная проверка «загрузится ли app»: `PageRepo` предоставлен И экспортирован `@Global()` DatabaseModule → резолвится в `AiChatController` без правки ai-chat.module. `page.workspaceId` в baseFields (селектится). null-handling через guard. - **Регрессий нет.** Валидный-uuid путь: findById по uuid → guard → findLatestByPage с тем же uuid (байт-идентично). Арность конструктора: все 3 `new AiChatController` (3 spec) обновлены в диффе; 4-й «сайт» — Nest DI (авто-инжект). Клиентский ренейм `pageId`→`slugId` — ЛОКАЛЬНЫЙ, wire не изменился (`{pageId: slugId}`), DTO тот же. - **Тесты не-вакуозны.** (1) slugId→findById→resolved-uuid, findLatestByPage получает uuid не raw-slug (против старого кода падает); (2) КЛЮЧЕВОЙ security-кейс — foreign-ws → `findLatestByPage` `not.toHaveBeenCalled()` + `{chatId:null}` (снятие/перестановка guard'а после lookup → падает; пиннится call-count'ом, не только значением); (3) unknown → `{chatId:null}` без throw; (4) свой-ws без чата → `{chatId:null}`. Риск-путь (cross-workspace) покрыт. - **Coherence/conventions/docs.** #312 закрыт обе половины (500 нет + привязка работает по resolved-uuid). Server-side резолв — верный слой (trust boundary), зеркалит существующий `resolveOpenPageContext`-паттерн; братья-эндпоинты не сломаны (stream/new-chat уже резолвит через тот же findById+guard; generate-title берёт только content) → не частичный фикс. Repo-in-controller + resolve+guard — house-паттерн (`share-alias.controller` идентичен). Docstring boundChat переписан точно, старый ложный «no access check needed» убран. ### Наблюдение (не блокер) - `boundChat` гардит только workspace (не `validateCanView`, как `resolveOpenPageContext`). Приемлемо: lookup скоуплен к СВОИМ чатам (`findLatestByPage(user.id,...)`), так что foreign-но-того-же-workspace страница не раскрывает ничего, что мог бы создать не-viewer; unknown/foreign неотличимы (желаемое non-leaking-поведение, покрыто тестом). Anti-probe-контракт (`{chatId:null}` вместо NotFoundException) — намеренный. <!-- state:review reviewed_head=0df6242128e2... round=1 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-03 18:23:46 +03:00
vvzvlad merged commit b861266ff8 into develop 2026-07-03 21:27:18 +03:00
vvzvlad deleted branch fix/312-bound-chat-slug 2026-07-03 21:27:22 +03:00
Sign in to join this conversation.