fix(ai-chat): резолв slugId→uuid в bound-chat — 500 (22P02) на открытии страницы (#312) #313
Reference in New Issue
Block a user
Delete Branch "fix/312-bound-chat-slug"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
POST /api/ai-chat/bound-chatпадал с 500 (Postgres22P02 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/serverjestai-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.tsc --noEmit— чисто по затронутым файлам; clienttsc— чисто по двум клиентским файлам.PageRepoрезолвится (DatabaseModule@Global(), экспортит PageRepo).Checklist
{chatId}, нет 22P02, переоткрывает именно тот чат; тест на резолв + отсутствие/чужой workspace → null)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>Ревью — #313 (ai-chat: резолв slugId→uuid в bound-chat, фикс 22P02 500, #312), round 1, head
0df62421, base developScope: реальная дельта — ТОЛЬКО
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): serverjest ai-chat.controller.bound-chat + export + generate-page-title→ 3 suites, 23 passed (компилит контроллер + все 3 сайта конструктора); ai-chat server-only, пересборка editor-ext/git-sync не нужна.Подтверждено по коду + прогоны
boundChat:findById(dto.pageId)(принимает uuid ИЛИ slugId) → guardif (!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) не ослаблен.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).PageRepoпредоставлен И экспортирован@Global()DatabaseModule → резолвится вAiChatControllerбез правки ai-chat.module.page.workspaceIdв baseFields (селектится). null-handling через guard.new AiChatController(3 spec) обновлены в диффе; 4-й «сайт» — Nest DI (авто-инжект). Клиентский ренеймpageId→slugId— ЛОКАЛЬНЫЙ, wire не изменился ({pageId: slugId}), DTO тот же.findLatestByPagenot.toHaveBeenCalled()+{chatId:null}(снятие/перестановка guard'а после lookup → падает; пиннится call-count'ом, не только значением); (3) unknown →{chatId:null}без throw; (4) свой-ws без чата →{chatId:null}. Риск-путь (cross-workspace) покрыт.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) — намеренный.