feat(share): custom /l/:alias pretty links (share_aliases table) (#205) #214

Merged
vvzvlad merged 3 commits from feat/205-share-aliases into develop 2026-06-26 20:00:51 +03:00

Closes #205

Custom human-readable vanity links /l/<alias> for public shares, parallel to the untouched /share/... routes.

Server

  • New share_aliases table (migration 20260626T130000-share-aliases): workspace-scoped, UNIQUE(workspace_id, alias), page_id nullable ON DELETE SET NULL (address outlives its target), index on page_id. Entity types + db.d.ts updated.
  • ShareAliasRepo (findByAliasAndWorkspace / findByPageId / findById / insert / updatePageId / delete, all workspace-scoped).
  • ShareAliasService: set (insert / no-op / 409 reassign guard with current target / unique-race -> 409), remove, availability probe, and resolveReadableTarget that re-runs the single existing share boundary (resolveReadableSharePage + isSharingAllowed).
  • ShareAliasRedirectController @Controller(l) GET /l/:alias -> 302 (never 301) to /share/:key/p/:slug; unknown/dangling/no-longer-readable aliases serve the SPA index with no existence leak. l/:alias added to the global /api prefix exclude in main.ts.
  • ShareAliasController @Controller(share-aliases): set/remove/availability/for-page (JWT-guarded, page edit/view checks).
  • Shared ASCII-only normalizeShareAlias/isValidShareAlias util.
  • /share/... left untouched.

Client

  • Custom address block in the share modal (live normalize + debounced availability check + pretty-link copy + reassign confirmation dialog). New service/query hooks + types.

Tests (unit only)

  • Server jest: util, repo SQL-shape, service semantics (create/no-op/409 reassign/race/availability/resolve), migration + entity sanity. Client vitest: alias util.
  • Verified: server tsc --noEmit green, client tsc -b green, new jest (32) + vitest (4) green.

🤖 Generated with Claude Code

Closes #205 Custom human-readable vanity links `/l/<alias>` for public shares, parallel to the untouched `/share/...` routes. ## Server - New `share_aliases` table (migration `20260626T130000-share-aliases`): workspace-scoped, `UNIQUE(workspace_id, alias)`, `page_id` nullable `ON DELETE SET NULL` (address outlives its target), index on `page_id`. Entity types + `db.d.ts` updated. - `ShareAliasRepo` (findByAliasAndWorkspace / findByPageId / findById / insert / updatePageId / delete, all workspace-scoped). - `ShareAliasService`: set (insert / no-op / 409 reassign guard with current target / unique-race -> 409), remove, availability probe, and `resolveReadableTarget` that re-runs the single existing share boundary (`resolveReadableSharePage` + `isSharingAllowed`). - `ShareAliasRedirectController` `@Controller(l)` `GET /l/:alias` -> 302 (never 301) to `/share/:key/p/:slug`; unknown/dangling/no-longer-readable aliases serve the SPA index with no existence leak. `l/:alias` added to the global `/api` prefix exclude in main.ts. - `ShareAliasController` `@Controller(share-aliases)`: set/remove/availability/for-page (JWT-guarded, page edit/view checks). - Shared ASCII-only `normalizeShareAlias`/`isValidShareAlias` util. - `/share/...` left untouched. ## Client - Custom address block in the share modal (live normalize + debounced availability check + pretty-link copy + reassign confirmation dialog). New service/query hooks + types. ## Tests (unit only) - Server jest: util, repo SQL-shape, service semantics (create/no-op/409 reassign/race/availability/resolve), migration + entity sanity. Client vitest: alias util. - Verified: server `tsc --noEmit` green, client `tsc -b` green, new jest (32) + vitest (4) green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-26 06:28:59 +03:00
Add a retargetable, human-readable vanity link namespace /l/<alias> that
sits alongside the untouched /share/... routes.

- New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias),
  page_id nullable ON DELETE SET NULL so the address outlives its target).
- ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard /
  availability / request-time readable-target resolution through the single
  existing share boundary).
- Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301,
  the target is mutable) to the canonical /share/:key/p/:slug page; unknown /
  dangling / no-longer-readable aliases serve the SPA index with no leak.
  'l/:alias' excluded from the global /api prefix.
- Authenticated ShareAliasController (set/remove/availability/for-page).
- Shared ASCII-only normalize/validate util (server + client copies).
- Client: Custom address block in the share modal (live normalize + debounced
  availability + copy + reassign confirmation dialog).
- Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity
  (server jest) + client alias util (vitest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad added the feature label 2026-06-26 15:49:29 +03:00
Owner

Code review — PR #214: кастомные pretty-ссылки /l/:alias через таблицу share_aliases (#205)

Вердикт: Request changes. Логика корректна и стабильна (security/stability/regressions без блокеров), но две новейшие и самые чувствительные поверхности — публичный редирект-контроллер /l/:alias и authz-гейты alias-контроллера — уходят в merge без единого теста, при том что у структурно идентичного ShareSeoController такой spec есть. Плюс мёртвый код (неиспользуемые Logger и PagePermissionRepo), отсутствие i18n-регистрации новых строк и записи в CHANGELOG. Must-fix: тесты на оба контроллера, удаление dead-code, регистрация строк в каталогах локалей.

Объём: дифф developfeat/205-share-aliases (merge-base 3ddc329b), 23 файлов, +1660/−4. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.

Must fix before merge

  • [test-coverage] Добавить routing/leak-spec для публичного редиректа /l/:aliasapps/server/src/core/share/share-alias-redirect.controller.ts
    Это самая security-чувствительная поверхность PR: неаутентифицированный публичный роут, который обязан отдавать обычный SPA-index на неизвестный ИЛИ нечитаемый алиас, чтобы существование имени не утекало. Ни одна ветка не покрыта (на ветке добавлены только service/util/migration/repo-specs). Параллельный ShareSeoController ровно для этого имеет share-seo.controller.routing.spec.ts. Fix: добавить share-alias-redirect.controller.spec.ts по образцу seo-spec и проверить: (a) резолвимый алиас → res.redirect('/share/<key>/p/<title-slug>-<slugId>', 302); (b) неизвестный/висячий/нечитаемый алиас и workspace === null → стрим index без 302; (c) битый percent-encoding декодируется защитно и трактуется как неизвестный; (d) self-hosted идёт через findFirst, subdomain — через findByHostname.

  • [test-coverage] Добавить spec на authz-гейты alias-контроллераapps/server/src/core/share/share-alias.controller.ts:53-134
    Решения о доступе при создании/ретаргете/удалении алиаса живут именно в контроллере (service-spec явно делегирует авторизацию вызывающему), но ни один гейт не протестирован. Непокрыты: cross-workspace/несуществующая страница → NotFoundException; validateCanEdit; resolveReadableSharePage null → BadRequestException('Page is not publicly shared'); isSharingAllowed false → ForbiddenException; remove() висячего алиаса (pageId null) пропускает validateCanEdit и всё равно удаляет; for-page зовёт validateCanView. Fix: добавить share-alias.controller.spec.ts с мокнутыми PageRepo/ShareService/ShareAliasService/PageAccessService, проверяющий каждый гейт и happy-path делегацию в setAlias.

  • [conventions] Удалить неиспользуемые Logger import и поле в ShareAliasRedirectControllerapps/server/src/core/share/share-alias-redirect.controller.ts:1,27
    Подтверждено: this.logger не вызывается ни разу (0 usages) — скопировано из SEO-контроллера, где логгер реально нужен. Change-introduced dead code. Fix: убрать Logger из импорта @nestjs/common и удалить строку private readonly logger = new Logger(...).

  • [conventions] Удалить неиспользуемые PagePermissionRepo import и зависимость в ShareAliasControllerapps/server/src/core/share/share-alias.controller.ts:17,40
    Подтверждено: this.pagePermissionRepo не используется (0 usages), авторизация идёт целиком через pageAccessService. Change-introduced dead code. Fix: удалить строку import { PagePermissionRepo } ... и параметр конструктора private readonly pagePermissionRepo: PagePermissionRepo,.

  • [documentation] Зарегистрировать новые share-alias строки в каталогах локалей en-US и ru-RUapps/client/src/features/share/components/share-alias-section.tsx:134-238, apps/client/src/features/share/queries/share-query.ts:332,357
    Политика i18n в apps/client/src/i18n.ts гласит, что en-US — source of truth, а en-US и ru-RU полностью поддерживаются, чтобы UI не рендерил mixed-language. PR вводит строки через t() («Custom address», «A short, memorable link…», «Use 2-60 lowercase letters, digits and hyphens», «This address is already in use», «Move custom address?», «Move here», два интерполируемых reassign-предложения, тосты «Failed to set/remove custom address»), но не трогает ни один translation.json (подтверждено по --stat). Русский пользователь получит этот блок на английском. Fix: добавить новые ключи в en-US/translation.json (текст = ключ = значение) и их переводы в ru-RU/translation.json.

  • [documentation] Добавить запись [Unreleased]/Added в CHANGELOG для /l/:aliasCHANGELOG.md
    CHANGELOG ведётся по Keep-a-Changelog, у каждой свежей фичи есть запись с номером issue, «changelog» — трекаемый review-item в истории develop. PR поставляет заметную публичную возможность (ретаргетируемые vanity-ссылки, новая таблица, публичный роут), но CHANGELOG.md не тронут (подтверждено). Fix: добавить bullet под ## [Unreleased] с описанием /l/:alias (workspace-scoped retargetable alias, 302 на каноническую share-страницу, ON DELETE SET NULL) и ссылкой (#205).

  • [security][suggestion] Закрыть раскрытие currentPageTitle/currentPageId в 409-ответе reassign проверкой текущей читаемости старой целиapps/server/src/core/share/share-alias.service.ts:88-99
    В ветке !confirmReassign (code: 'ALIAS_REASSIGN_REQUIRED') возвращаются existing.pageId и currentPage?.title СУЩЕСТВУЮЩЕЙ цели алиаса без проверки, что вызывающий вправе её видеть; контроллер авторизует только НОВУЮ целевую страницу. Утечка ограничена ранее-публичными страницами и только title+id (не контент), но если такую страницу позже расшарить отменили, любой участник workspace, угадав имя алиаса, узнаёт её (уже непубличный) заголовок. Fix: перед возвратом перепроверять, что старая цель всё ещё публично читаема (resolveReadableSharePage + isSharingAllowed по existing.pageId), и опускать title/id иначе — либо вовсе убрать currentPageTitle и подтверждать по имени.

  • [simplification][suggestion] Убрать тавтологичные runtime-проверки в тесте «entity types expose the alias columns»apps/server/src/database/migrations/share-aliases.migration.spec.ts:1574-1594
    Тест присваивает литералы типизированным объектам и тут же expect(row.alias).toBe('foo') и т.п. — эти проверки не могут упасть, реальную ценность даёт только compile-time проверка tsc. Fix: удалить блок it(...) или оставить только типизированные объявления без трёх expect.

  • [simplification][suggestion] Убрать boilerplate-тест «exports up and down functions»apps/server/src/database/migrations/share-aliases.migration.spec.ts:1512-1515
    expect(typeof migration.up).toBe('function') дублируется следующим тестом, который реально вызывает migration.up(...)/down(...). Соседние createTable/createIndex/dropTable-проверки несут сигнал (пинят имена таблиц/индексов) — их оставить. Fix: удалить только it('exports up and down functions', ...).

Test coverage

Новая бизнес-логика: сервис, util, миграция и repo покрыты (share-alias.service.spec.ts, share-alias.util.spec.ts, share-aliases.migration.spec.ts, share-alias.repo.spec.ts). Без тестов остаются два контроллера, и это самые чувствительные поверхности PR — обе вынесены в Must fix выше:

  • публичный редирект /l/:alias (share-alias-redirect.controller.ts) — no-leak гарантия и решение 302-vs-index не верифицированы;
  • authz-гейты share-alias.controller.ts — регрессия, убирающая любую из проверок доступа, не будет поймана.

Architecture & design (forward-looking, non-blocking)

1. Дублирование публичных excluded-prefix контроллеров (ShareSeoController + новый ShareAliasRedirectController). Новый контроллер построчно повторяет три вещи из SEO-контроллера: workaround резолва workspace (isSelfHosted() vs subdomain — нужен, т.к. NestJS не применяет middleware на excluded-путях), построение clientDistPath и sendIndex()-фолбэк. Копии уже разошлись: новый укрепил subdomain-ветку через header?.split('.')[0] и добавил 404 при отсутствии index.html, а SEO-контроллер всё ещё делает header.split('.') без guard. Не дефект здесь, но риск forward-looking: расхождение именно на этих публичных роутах — это enumeration/leak-риск.
вынести только резолв workspace в маленький чистый helper (resolvePublicWorkspace(req, env, workspaceRepo)), sendIndex оставить.** Effort: s. Pros: убирает самый рисковый дубль (tenant-resolution, гейтящий leak-гарантию) минимальным изменением, без base-class связывания, попутно переводит небезопасный header.split SEO-контроллера на общий путь. Cons: sendIndex/clientDistPath остаются продублированы (ниже риск).

2. Дублирование построения канонического page-slug (buildPageSlug + существующие call-sites). buildPageSlug (share-alias-redirect.controller.ts:94-95, slugify(title?.substring(0,70) || 'untitled')-${slugId}) — четвёртая копия правила <title-slug>-<slugId> (есть в import-formatter.ts:385, export.service.ts:505, клиентский page.utils.ts). Копии не идентичны: клиентский вариант передаёт slugify customReplacements (, 🦄), три серверных — нет; комментарий «mirrors the client» для таких заголовков неверен. Не баг: канонический роут резолвит страницу по trailing slugId, так что расхождение префикса косметическое (URL-эстетика/canonicalization).

оставить новую копию локальной, но выровнять её slugify-опции с клиентом (добавить те же customReplacements).** Effort: s. Pros: наименьшее изменение, комментарий становится буквально верным, без cross-module churn. Cons: всё ещё четыре копии, чинит только drift новой.

## Code review — PR #214: кастомные pretty-ссылки `/l/:alias` через таблицу `share_aliases` (#205) **Вердикт: Request changes.** Логика корректна и стабильна (security/stability/regressions без блокеров), но две новейшие и самые чувствительные поверхности — публичный редирект-контроллер `/l/:alias` и authz-гейты alias-контроллера — уходят в merge без единого теста, при том что у структурно идентичного `ShareSeoController` такой spec есть. Плюс мёртвый код (неиспользуемые `Logger` и `PagePermissionRepo`), отсутствие i18n-регистрации новых строк и записи в CHANGELOG. Must-fix: тесты на оба контроллера, удаление dead-code, регистрация строк в каталогах локалей. _Объём: дифф `develop`…`feat/205-share-aliases` (merge-base `3ddc329b`), 23 файлов, +1660/−4. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход._ ### Must fix before merge - **[test-coverage] Добавить routing/leak-spec для публичного редиректа `/l/:alias`** — `apps/server/src/core/share/share-alias-redirect.controller.ts` Это самая security-чувствительная поверхность PR: неаутентифицированный публичный роут, который обязан отдавать обычный SPA-index на неизвестный ИЛИ нечитаемый алиас, чтобы существование имени не утекало. Ни одна ветка не покрыта (на ветке добавлены только service/util/migration/repo-specs). Параллельный `ShareSeoController` ровно для этого имеет `share-seo.controller.routing.spec.ts`. Fix: добавить `share-alias-redirect.controller.spec.ts` по образцу seo-spec и проверить: (a) резолвимый алиас → `res.redirect('/share/<key>/p/<title-slug>-<slugId>', 302)`; (b) неизвестный/висячий/нечитаемый алиас и `workspace === null` → стрим index без 302; (c) битый percent-encoding декодируется защитно и трактуется как неизвестный; (d) self-hosted идёт через `findFirst`, subdomain — через `findByHostname`. - **[test-coverage] Добавить spec на authz-гейты alias-контроллера** — `apps/server/src/core/share/share-alias.controller.ts:53-134` Решения о доступе при создании/ретаргете/удалении алиаса живут именно в контроллере (service-spec явно делегирует авторизацию вызывающему), но ни один гейт не протестирован. Непокрыты: cross-workspace/несуществующая страница → `NotFoundException`; `validateCanEdit`; `resolveReadableSharePage` null → `BadRequestException('Page is not publicly shared')`; `isSharingAllowed` false → `ForbiddenException`; `remove()` висячего алиаса (pageId null) пропускает `validateCanEdit` и всё равно удаляет; `for-page` зовёт `validateCanView`. Fix: добавить `share-alias.controller.spec.ts` с мокнутыми `PageRepo`/`ShareService`/`ShareAliasService`/`PageAccessService`, проверяющий каждый гейт и happy-path делегацию в `setAlias`. - **[conventions] Удалить неиспользуемые `Logger` import и поле в `ShareAliasRedirectController`** — `apps/server/src/core/share/share-alias-redirect.controller.ts:1,27` Подтверждено: `this.logger` не вызывается ни разу (0 usages) — скопировано из SEO-контроллера, где логгер реально нужен. Change-introduced dead code. Fix: убрать `Logger` из импорта `@nestjs/common` и удалить строку `private readonly logger = new Logger(...)`. - **[conventions] Удалить неиспользуемые `PagePermissionRepo` import и зависимость в `ShareAliasController`** — `apps/server/src/core/share/share-alias.controller.ts:17,40` Подтверждено: `this.pagePermissionRepo` не используется (0 usages), авторизация идёт целиком через `pageAccessService`. Change-introduced dead code. Fix: удалить строку `import { PagePermissionRepo } ...` и параметр конструктора `private readonly pagePermissionRepo: PagePermissionRepo,`. - **[documentation] Зарегистрировать новые share-alias строки в каталогах локалей en-US и ru-RU** — `apps/client/src/features/share/components/share-alias-section.tsx:134-238`, `apps/client/src/features/share/queries/share-query.ts:332,357` Политика i18n в `apps/client/src/i18n.ts` гласит, что en-US — source of truth, а en-US и ru-RU полностью поддерживаются, чтобы UI не рендерил mixed-language. PR вводит строки через `t()` («Custom address», «A short, memorable link…», «Use 2-60 lowercase letters, digits and hyphens», «This address is already in use», «Move custom address?», «Move here», два интерполируемых reassign-предложения, тосты «Failed to set/remove custom address»), но не трогает ни один `translation.json` (подтверждено по `--stat`). Русский пользователь получит этот блок на английском. Fix: добавить новые ключи в `en-US/translation.json` (текст = ключ = значение) и их переводы в `ru-RU/translation.json`. - **[documentation] Добавить запись `[Unreleased]/Added` в CHANGELOG для `/l/:alias`** — `CHANGELOG.md` CHANGELOG ведётся по Keep-a-Changelog, у каждой свежей фичи есть запись с номером issue, «changelog» — трекаемый review-item в истории develop. PR поставляет заметную публичную возможность (ретаргетируемые vanity-ссылки, новая таблица, публичный роут), но CHANGELOG.md не тронут (подтверждено). Fix: добавить bullet под `## [Unreleased]` с описанием `/l/:alias` (workspace-scoped retargetable alias, 302 на каноническую share-страницу, ON DELETE SET NULL) и ссылкой (#205). - **[security][suggestion] Закрыть раскрытие `currentPageTitle`/`currentPageId` в 409-ответе reassign проверкой текущей читаемости старой цели** — `apps/server/src/core/share/share-alias.service.ts:88-99` В ветке `!confirmReassign` (`code: 'ALIAS_REASSIGN_REQUIRED'`) возвращаются `existing.pageId` и `currentPage?.title` СУЩЕСТВУЮЩЕЙ цели алиаса без проверки, что вызывающий вправе её видеть; контроллер авторизует только НОВУЮ целевую страницу. Утечка ограничена ранее-публичными страницами и только title+id (не контент), но если такую страницу позже расшарить отменили, любой участник workspace, угадав имя алиаса, узнаёт её (уже непубличный) заголовок. Fix: перед возвратом перепроверять, что старая цель всё ещё публично читаема (`resolveReadableSharePage` + `isSharingAllowed` по `existing.pageId`), и опускать title/id иначе — либо вовсе убрать `currentPageTitle` и подтверждать по имени. - **[simplification][suggestion] Убрать тавтологичные runtime-проверки в тесте «entity types expose the alias columns»** — `apps/server/src/database/migrations/share-aliases.migration.spec.ts:1574-1594` Тест присваивает литералы типизированным объектам и тут же `expect(row.alias).toBe('foo')` и т.п. — эти проверки не могут упасть, реальную ценность даёт только compile-time проверка `tsc`. Fix: удалить блок `it(...)` или оставить только типизированные объявления без трёх `expect`. - **[simplification][suggestion] Убрать boilerplate-тест «exports up and down functions»** — `apps/server/src/database/migrations/share-aliases.migration.spec.ts:1512-1515` `expect(typeof migration.up).toBe('function')` дублируется следующим тестом, который реально вызывает `migration.up(...)`/`down(...)`. Соседние createTable/createIndex/dropTable-проверки несут сигнал (пинят имена таблиц/индексов) — их оставить. Fix: удалить только `it('exports up and down functions', ...)`. ### Test coverage Новая бизнес-логика: сервис, util, миграция и repo покрыты (`share-alias.service.spec.ts`, `share-alias.util.spec.ts`, `share-aliases.migration.spec.ts`, `share-alias.repo.spec.ts`). Без тестов остаются два контроллера, и это самые чувствительные поверхности PR — обе вынесены в Must fix выше: - публичный редирект `/l/:alias` (`share-alias-redirect.controller.ts`) — no-leak гарантия и решение 302-vs-index не верифицированы; - authz-гейты `share-alias.controller.ts` — регрессия, убирающая любую из проверок доступа, не будет поймана. ### Architecture & design (forward-looking, non-blocking) **1. Дублирование публичных excluded-prefix контроллеров (`ShareSeoController` + новый `ShareAliasRedirectController`).** Новый контроллер построчно повторяет три вещи из SEO-контроллера: workaround резолва workspace (`isSelfHosted()` vs subdomain — нужен, т.к. NestJS не применяет middleware на excluded-путях), построение `clientDistPath` и `sendIndex()`-фолбэк. Копии уже разошлись: новый укрепил subdomain-ветку через `header?.split('.')[0]` и добавил 404 при отсутствии index.html, а SEO-контроллер всё ещё делает `header.split('.')` без guard. Не дефект здесь, но риск forward-looking: расхождение именно на этих публичных роутах — это enumeration/leak-риск. вынести только резолв workspace в маленький чистый helper (`resolvePublicWorkspace(req, env, workspaceRepo)`), `sendIndex` оставить.** Effort: s. Pros: убирает самый рисковый дубль (tenant-resolution, гейтящий leak-гарантию) минимальным изменением, без base-class связывания, попутно переводит небезопасный `header.split` SEO-контроллера на общий путь. Cons: `sendIndex`/`clientDistPath` остаются продублированы (ниже риск). **2. Дублирование построения канонического page-slug (`buildPageSlug` + существующие call-sites).** `buildPageSlug` (`share-alias-redirect.controller.ts:94-95`, `slugify(title?.substring(0,70) || 'untitled')-${slugId}`) — четвёртая копия правила `<title-slug>-<slugId>` (есть в `import-formatter.ts:385`, `export.service.ts:505`, клиентский `page.utils.ts`). Копии не идентичны: клиентский вариант передаёт `slugify` customReplacements (`♥`, `🦄`), три серверных — нет; комментарий «mirrors the client» для таких заголовков неверен. Не баг: канонический роут резолвит страницу по trailing `slugId`, так что расхождение префикса косметическое (URL-эстетика/canonicalization). оставить новую копию локальной, но выровнять её slugify-опции с клиентом (добавить те же customReplacements).** Effort: s. Pros: наименьшее изменение, комментарий становится буквально верным, без cross-module churn. Cons: всё ещё четыре копии, чинит только drift новой.
Ghost added 1 commit 2026-06-26 17:22:47 +03:00
Add the two blocking test-coverage specs requested in the PR #214 review and
clear the cheap non-blocking items.

Must-fix:
- share-alias-redirect.controller.spec.ts: routing/leak guard for the public
  GET /l/:alias resolver (modeled on share-seo.controller.routing.spec). Pins
  302-to-canonical on a hit; SPA index without a 302 for unknown/dangling/
  unreadable aliases and a null workspace (no name-existence leak); defensive
  percent-decoding treated as unknown; self-hosted findFirst vs subdomain
  findByHostname workspace resolution; 404 when no built client index exists.
- share-alias.controller.spec.ts: authz gates with mocked PageRepo/ShareService/
  ShareAliasService/PageAccessService. Covers cross-workspace/nonexistent page
  -> NotFoundException, validateCanEdit, resolveReadableSharePage null ->
  BadRequestException, isSharingAllowed false -> ForbiddenException, set happy
  path delegation, remove() of a dangling alias (pageId null) skipping
  validateCanEdit but still deleting, and for-page validateCanView.

Cheap review items:
- Remove dead Logger import/field from ShareAliasRedirectController.
- Remove dead PagePermissionRepo import/dependency from ShareAliasController.
- Register the new share-alias UI strings in en-US and ru-RU catalogs.
- Add an [Unreleased]/Added CHANGELOG entry for /l/:alias (#205).
- Drop the tautological boilerplate assertions from the migration spec
  (exports up/down; runtime checks of typed entity literals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner

Вердикт: Approve with comments. Все 6 прошлых блокеров/замечаний закрыты по существу; дельта добавляет только тесты и косметику, новых blocking-находок нет. Остаётся одна непокрытая ветка (70-символьный клэмп слага) — non-blocking.

Ре-ревью дельты fdeede00..1043fe3b (8 файлов, +527/−16). Аспекты: security, stability, conventions, documentation, regressions, test-coverage (параллельные ревьюеры + judge).

Must fix before merge

  • [test-coverage] Прогнать ветку 70-символьного клэмпа в buildPageSlugshare-alias-redirect.controller.spec.ts:95-119 Докстринг спеки (стр. 4-7) явно заявляет проверку «70-char clamp», но ни один тест её не драйвит: единственный titled-кейс — Quarterly Report (16 симв.), плюс untitled-fallback. Ветка title?.substring(0, 70) не исполняется, регрессия клэмпа не будет поймана, а докстринг переоценивает покрытие. Fix: добавить кейс с title длиннее 70 символов и проверить, что slug в 302-таргете отражает только первые 70 символов (усечённый title-slug + slugId).
## Code review (re-review) — PR #214: custom /l/:alias pretty-links для шаринга (share_aliases) **Вердикт: Approve with comments.** Все 6 прошлых блокеров/замечаний закрыты по существу; дельта добавляет только тесты и косметику, новых blocking-находок нет. Остаётся одна непокрытая ветка (70-символьный клэмп слага) — non-blocking. _Ре-ревью дельты `fdeede00..1043fe3b` (8 файлов, +527/−16). Аспекты: security, stability, conventions, documentation, regressions, test-coverage (параллельные ревьюеры + judge)._ ### Must fix before merge - **[test-coverage] Прогнать ветку 70-символьного клэмпа в `buildPageSlug`** — `share-alias-redirect.controller.spec.ts:95-119` Докстринг спеки (стр. 4-7) явно заявляет проверку «70-char clamp», но ни один тест её не драйвит: единственный titled-кейс — `Quarterly Report` (16 симв.), плюс `untitled`-fallback. Ветка `title?.substring(0, 70)` не исполняется, регрессия клэмпа не будет поймана, а докстринг переоценивает покрытие. Fix: добавить кейс с `title` длиннее 70 символов и проверить, что slug в `302`-таргете отражает только первые 70 символов (усечённый title-slug + slugId).
Ghost added 1 commit 2026-06-26 18:05:36 +03:00
The controller's buildPageSlug truncates the page title via
`title?.substring(0, 70)` before slugifying, but no test drove that
branch (the only titled case was 16 chars). Add a resolvable-alias
case with a 119-char title whose 70-char boundary falls mid-word and
assert the 302 target's slug reflects only the first 70 characters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad merged commit 13589b3973 into develop 2026-06-26 20:00:51 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#214