Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Secrets must never enter the image build context
.env
.env.*
!.env.example

# VCS / local state
.git
.claude
.turbo
*.log

# Dependencies and build outputs (rebuilt inside the image)
node_modules
**/node_modules
**/.next
**/dist
coverage

# Not needed in the API image
apps/docs
docs
supabase
scripts
setup-production.sh
*.md
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,11 @@ ADMIN_API_KEY=your_admin_api_key_here
# Chat-specific rate limit (ttl in milliseconds; defaults 60000 / 10)
RATE_LIMIT_CHAT_TTL_MS=60000
RATE_LIMIT_CHAT_MAX_REQUESTS=10

# Sentry (optional; error tracking is disabled when unset)
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=0.1

# Daily OpenAI budget in USD; chat/explain endpoints return 429 once reached.
# Unset = unlimited. Requires the ai_usage table (migration 20260612000001).
OPENAI_DAILY_BUDGET_USD=
81 changes: 81 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

# Least privilege: jobs only read the repo; nothing here pushes or comments.
permissions:
contents: read

jobs:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
checks:
name: Lint, typecheck, test, build
runs-on: ubuntu-latest
env:
# Dummy values: module-load env asserts (packages/ai, packages/db) and
# Next.js prerender need *some* value; no real services are called in CI.
OPENAI_API_KEY: ci-dummy
SUPABASE_URL: https://ci-dummy.supabase.co
SUPABASE_SERVICE_KEY: ci-dummy
SUPABASE_ANON_KEY: ci-dummy
NEXT_PUBLIC_SUPABASE_URL: https://ci-dummy.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-dummy
NEXT_PUBLIC_API_URL: http://localhost:3001
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

# pnpm version comes from packageManager in package.json (single source
# of truth) — specifying it here too makes action-setup error out.
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

# Two steps on purpose: next build rewrites .next/types while tsc reads
# them, so check-types must not run concurrently with build.
- name: Lint, typecheck, test
run: pnpm exec turbo run lint check-types test

- name: Build
run: pnpm exec turbo run build

security:
name: Secret scan & dependency audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false

- name: Gitleaks (secret scan)
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# workflow has contents:read only — PR comments would need extra perms
GITLEAKS_ENABLE_COMMENTS: false

- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

# Non-blocking for now: existing transitive vulns are tracked in IMPROVEMENTS §13.3
- name: pnpm audit (high+)
run: pnpm audit --audit-level high
continue-on-error: true
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Env загружается из корневого `.env` (см. `.env.example`;

## Известные ловушки
- `apps/api/src/queues/processors/imdb-update.processor.ts` — НЕ компилируется (вызывает несуществующие методы tmdbService.updateMoviesImdbIds/updateTvShowsImdbIds). Это WIP для IMPROVEMENTS §15. Не регистрирован в модуле. Перед `nest build` учитывать.
- Тестов мало (26); TDD обязателен для нового кода (superpowers skill)
- Миграции в репо неполные — БД не воспроизводится с нуля (см. review §2.5)
- Тестов 62 (2026-06-12); TDD обязателен для нового кода (superpowers skill)
- Миграции: полная история — в корневом `supabase/migrations/` (16 файлов); в `packages/db/supabase/migrations` лежит 1 дублирующий файл — консолидировать
- CI: lint/check-types/test и build идут ДВУМЯ шагами (next build переписывает .next/types — гонка с tsc)
- Миграция `20260612000001_add_ai_usage.sql` могла быть ещё не применена — AiUsageService деградирует мягко (warn в логах)
- KEY_ROTATION_GUIDE.md и SECURITY_AUDIT_REPORT.md упомянуты в IMPROVEMENTS, но в репо отсутствуют; актуальный чеклист: `docs/KEY_ROTATION_CHECKLIST.md`
51 changes: 34 additions & 17 deletions IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2085,35 +2085,52 @@ GET /api/recommendations/*

## 18. 🟠 Инженерная база (добавлено при ревизии 2026-06-11)

### 18.1 CI/CD (сейчас отсутствует полностью)
- [ ] `.github/workflows/ci.yml`: pnpm install → turbo lint → typecheck → test → build (с turbo cache)
- [ ] Gate на PR в main
- [ ] gitleaks + `pnpm audit` шаг
### 18.1 CI/CD ✅ **РЕАЛИЗОВАНО 2026-06-12** (ветка security/p0-hardening)
- [x] `.github/workflows/ci.yml`: pnpm install → turbo lint/check-types/test → build ✅
- Починены сломанные скрипты: web `next typegen` (Next15-only), отсутствующие eslint-конфиги в db/ai, eslint v8/v9 конфликт
- Исправлены все 24 lint-warnings в packages/ai и packages/db
- ⚠️ web lint — храповик `--max-warnings 30` (30 застарелых warnings; новые валят CI; burn-down — задача)
- [x] Gate на PR в main ✅
- [x] gitleaks + `pnpm audit` (audit пока non-blocking) ✅
- [ ] (позже) CI-проверка реплея миграций: `supabase db reset` на эфемерной БД
- [ ] `.dockerignore` для apps/api (сейчас в build context попадает весь workspace)
- [x] `.dockerignore` ✅ — критично: раньше `COPY . .` затаскивал локальный `.env` в слои образа
- [x] Node engines >=20 + `.nvmrc` ✅ (§13.1; Redis 6.2+ — инфраструктурное действие пользователя)

### 18.2 Тестирование (сейчас 1 тест-файл на ~15k LOC)
- [ ] Unit: smart-search (filter-builder, query-extractor с mock LLM — уже в спеке), movies/tv-shows services (mock supabase), cursor.utils
### 18.2 Тестирование 🟡 (62 теста на 2026-06-12; было 1)
- [x] Unit: guards (admin-key, supabase-auth), DTO, env.utils, cursor.utils, health, ai-usage, movies.autocomplete (mock supabase) ✅
- [ ] Unit: smart-search filter-builder/query-extractor (появятся вместе с реализацией Phase 1), tv-shows service
- [ ] Integration: smart-search против seeded test DB (по спеке)
- [ ] E2E (Playwright): auth flow, поиск, watchlist add/remove — 3-5 критических сценариев
- [ ] Цель: покрытие критических путей, а не % ради % (ROADMAP-цель «>80%» нереалистична и не нужна)
- [ ] Burn-down 30 web lint-warnings (храповик в apps/web lint script)
- Цель: покрытие критических путей, а не % ради % (ROADMAP-цель «>80%» нереалистична и не нужна)

### 18.3 Observability
- [ ] Sentry (api + web) — запланирован с Day 14, так и не подключён
### 18.3 Observability 🟡 (частично 2026-06-12)
- [x] `/api/health` endpoint (DB + Redis ping; 503 при недоступной БД) ✅ — переключить Railway healthcheckPath с `/api/tmdb/health`
- [x] Sentry в API — инициализируется при наличии `SENTRY_DSN` ✅ (нужно: создать проект в Sentry, задать DSN в Railway; web — отдельно)
- [ ] Sentry в web (@sentry/nextjs)
- [ ] Structured logging (pino/winston, JSON в prod)
- [ ] `/health` endpoint (DB + Redis ping) для Railway healthcheck и uptime-мониторинга
- [ ] Интеграция Sentry с ExceptionFilter (когда появится, см. P3)

### 18.4 Контроль AI-расходов 💰
- [ ] Таблица `ai_usage` (endpoint, model, prompt/completion tokens, cost, user_id, created_at) — писать после каждого OpenAI-вызова
- [ ] Дневной бюджет-лимит (env `OPENAI_DAILY_BUDGET_USD`) — при превышении отключать chat/explain с понятной ошибкой
- [ ] Перевести chat default на gpt-4o-mini, gpt-4o — только по необходимости (аналогично tiering из smart-search спеки)
### 18.4 Контроль AI-расходов 💰 ✅ **РЕАЛИЗОВАНО 2026-06-12** (кроме Batch API)
- [x] Таблица `ai_usage` — миграция `20260612000001_add_ai_usage.sql` ✅ **применена к prod 2026-06-12** (через Management API; типы перегенерированы)
- [x] Запись после каждого OpenAI-вызова — хук `onAiUsage` в @repo/ai (chat, explain, embeddings; без смены сигнатур) ✅
- [x] Дневной бюджет: `OPENAI_DAILY_BUDGET_USD` → AiBudgetGuard, 429 на /api/chat и /api/movies/:id/explain ✅ (fail-open при недоступной БД)
- [x] Chat default уже gpt-4o-mini (env `OPENAI_CHAT_MODEL_SMALL`) ✅ — было сделано ранее, отмечено при реализации
- [ ] OpenAI Batch API для массовых embeddings (backfill, re-embedding) — минус 50% стоимости
- [ ] Колонка `embedding_model` в movies/tv_shows — фиксировать версию модели для будущих миграций embeddings
- [x] Колонка `embedding_model` в movies/tv_shows + backfill в миграции ✅ (запись при новых embeddings — после регенерации типов)
- [ ] Per-user атрибуция расходов (user_id в ai_usage заполняется null — прокинуть из guard-контекста)

### 18.5 База данных

⚠️ **Обнаружено 2026-06-12: Supabase-проект был в статусе INACTIVE (free-tier auto-pause) — продакшен не работал.** Проект восстановлен. Решить системно:
- [ ] Либо платный план Supabase (без auto-pause), либо keep-alive (uptime-монитор на `GET /api/health` каждые N часов будит не БД, а Railway — нужен пинг именно Supabase REST, например GitHub Actions cron с `select 1` через PostgREST)
- [ ] Консолидировать ручной `packages/db/src/types.ts` с generated-types (ручной Database без Relationships ломает insert-дженерики новых supabase-js → untyped cast в AiUsageService)
- [x] Дрейф миграций устранён ✅: `20260130000001_add_imdb_fields` (была только на проде) возвращена в репо; `20260501152500_add_search_vectors` и `20260505000001` (были только в репо) применены к проду; история синхронизирована (17 записей)
- [x] Реальные размеры базы (2026-06-12): **7697 фильмов, 3030 сериалов** (в доках было «1675»)
- [ ] FK для `chat_messages.user_id` + ON DELETE CASCADE (GDPR: удаление аккаунта)
- [ ] Документировать стратегию бэкапов (Supabase PITR / pg_dump расписание) + тест восстановления
- [ ] Выгрузить полную историю миграций в репо (сейчас воспроизводима лишь частично)
- [x] ~~Выгрузить полную историю миграций~~ — уточнение 2026-06-12: полная история уже лежит в корневом `supabase/migrations/` (каноничное место)
- [x] Консолидация миграций ✅ (2026-06-12): дубликат `packages/db/supabase/migrations/003_import_progress_tracking.sql` (идентичен `20260117000001`) удалён — остался только `supabase/migrations/`

---

Expand Down
35 changes: 31 additions & 4 deletions SESSION_RESUME.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
# Session Resume — P0 Security Hardening
# Session Resume — P0 Security Hardening + P1 Engineering Base

**Date:** 2026-06-11
**Branch:** `security/p0-hardening` (от main, НЕ смержена)
**Status:** P0 из IMPROVEMENTS.md §17 реализован в коде; остались действия пользователя (см. ниже)
**Date:** 2026-06-12
Comment thread
coderabbitai[bot] marked this conversation as resolved.
**Branch:** `security/p0-hardening` (от main, НЕ смержена; включает также реализацию P1 — инженерная база, §18)
**Status:** P0 (§17) и P1 (§18, кроме отмеченного) реализованы; остались действия пользователя (см. ниже)

**ADMIN_API_KEY — как настраивать:**
- Локально каждый разработчик генерирует свой: `openssl rand -hex 32` → в `.env` (не в git)
- Production: одно и то же значение задаётся в Railway Variables (API); ops-скриптам передаётся через `export ADMIN_API_KEY=...`
- **Деплой связки:** новый API требует Bearer-токен от Web → деплоить API и Web вместе; если старый Web уже получает 401 — это ожидаемо до выката нового Web (фолбэк не предусмотрен сознательно: эндпоинты не должны работать без auth)

## ✅ P1 (2026-06-12): что добавлено
- **CI:** `.github/workflows/ci.yml` (lint→check-types→test, затем build; gitleaks; pnpm audit non-blocking). Node engines >=20 + .nvmrc. `.dockerignore` (раньше .env попадал в образ Railway!). Починены сломанные lint/check-types скрипты по всему монорепо.
- **Health:** `GET /api/health` (DB+Redis; 503 при недоступной БД). Переключить Railway healthcheckPath.
- **Sentry (API):** активируется при `SENTRY_DSN` в env.
- **AI-расходы:** таблица `ai_usage` (миграция `20260612000001` — ПРИМЕНИТЬ!), хук onAiUsage в @repo/ai, `OPENAI_DAILY_BUDGET_USD` → 429 на chat/explain.
- **Тесты:** 26 → 62. Web lint — храповик 30 warnings.

## ✅ Миграции применены к prod (2026-06-12, через Supabase Management API)
- `20260501152500_add_search_vectors` (tsvector + триггеры + GIN — smart-search Phase 1 готов на стороне БД; backfill-скрипт ещё не запускался)
- `20260505000001_restore_match_translations`
- `20260612000001_add_ai_usage` (+ backfill embedding_model: 7697 movies, 3030 tv)
- История миграций синхронизирована (17 записей); `20260130000001_add_imdb_fields` возвращена с прода в репо
- Типы перегенерированы (`packages/db/src/generated-types.ts`)
- ⚠️ **Проект Supabase был ЗАПАУЗЕН (free-tier) — прод не работал.** Восстановлен; нужно решение против auto-pause (см. IMPROVEMENTS §18.5)
- ⚠️ CLI-доступ к БД с этой машины не работает (нет SUPABASE_DB_PASSWORD; direct connect — таймаут; login-role — permission denied). SQL применялся через Management API query endpoint (токен — в keyring от `supabase login`)

## 🔴 Действия пользователя для P1
1. Railway Variables: `OPENAI_DAILY_BUDGET_USD` (например 5), опц. `SENTRY_DSN`
2. Railway: healthcheckPath → `/api/health` (railway.json), Redis 6.2+ (BullMQ)
3. Решить вопрос Supabase auto-pause (платный план или keep-alive пинг)
4. Локально на других машинах: Node 20 (`nvm use`)

---

Expand Down
4 changes: 3 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main"
"start:prod": "node dist/main",
"check-types": "tsc --noEmit"
},
"keywords": [],
"author": "",
Expand All @@ -36,6 +37,7 @@
"@nestjs/throttler": "^6.5.0",
"@repo/ai": "workspace:*",
"@repo/db": "workspace:*",
"@sentry/node": "^10.57.0",
"@supabase/supabase-js": "^2.39.0",
"@types/node": "^20.0.0",
"axios": "^1.6.0",
Expand Down
53 changes: 53 additions & 0 deletions apps/api/src/ai-usage/__tests__/ai-budget.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ExecutionContext, HttpException } from '@nestjs/common';
import { AiBudgetGuard } from '../ai-budget.guard';
import { AiUsageService } from '../ai-usage.service';

jest.mock('@repo/db', () => ({ supabase: { from: jest.fn() } }));

function ctx(): ExecutionContext {
return {} as ExecutionContext;
}

function usageServiceWithSpend(spend: number): AiUsageService {
return { getTodaySpendUsd: async () => spend } as unknown as AiUsageService;
}

describe('AiBudgetGuard', () => {
const originalBudget = process.env.OPENAI_DAILY_BUDGET_USD;

afterEach(() => {
if (originalBudget === undefined) {
delete process.env.OPENAI_DAILY_BUDGET_USD;
} else {
process.env.OPENAI_DAILY_BUDGET_USD = originalBudget;
}
});

it('allows requests when no budget is configured', async () => {
delete process.env.OPENAI_DAILY_BUDGET_USD;
const guard = new AiBudgetGuard(usageServiceWithSpend(999));
await expect(guard.canActivate(ctx())).resolves.toBe(true);
});

it('allows requests while spend is under the budget', async () => {
process.env.OPENAI_DAILY_BUDGET_USD = '5';
const guard = new AiBudgetGuard(usageServiceWithSpend(4.99));
await expect(guard.canActivate(ctx())).resolves.toBe(true);
});

it('rejects with 429 when the daily budget is exhausted', async () => {
process.env.OPENAI_DAILY_BUDGET_USD = '5';
const guard = new AiBudgetGuard(usageServiceWithSpend(5));

await expect(guard.canActivate(ctx())).rejects.toThrow(HttpException);
await guard.canActivate(ctx()).catch((e: HttpException) => {
expect(e.getStatus()).toBe(429);
});
});

it('ignores invalid budget values (fail-open)', async () => {
process.env.OPENAI_DAILY_BUDGET_USD = 'not-a-number';
const guard = new AiBudgetGuard(usageServiceWithSpend(999));
await expect(guard.canActivate(ctx())).resolves.toBe(true);
});
});
Loading
Loading