Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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=
74 changes: 74 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: CI

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

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@v4

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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@v4
with:
fetch-depth: 0

- name: Gitleaks (secret scan)
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@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`
44 changes: 27 additions & 17 deletions IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2085,35 +2085,45 @@ 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` ⚠️ **нужно применить** (`supabase db push`) и перегенерировать типы
- [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 База данных
- [ ] FK для `chat_messages.user_id` + ON DELETE CASCADE (GDPR: удаление аккаунта)
- [ ] Документировать стратегию бэкапов (Supabase PITR / pg_dump расписание) + тест восстановления
- [ ] Выгрузить полную историю миграций в репо (сейчас воспроизводима лишь частично)
- [x] ~~Выгрузить полную историю миграций~~ — уточнение 2026-06-12: полная история (16 миграций) уже лежит в корневом `supabase/migrations/`; ревью смотрело на `packages/db/supabase/migrations` (там 1 файл — консолидировать в одно место)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

---

Expand Down
23 changes: 18 additions & 5 deletions SESSION_RESUME.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
# Session Resume — P0 Security Hardening

**Date:** 2026-06-11
**Branch:** `security/p0-hardening` (от main, НЕ смержена)
**Status:** P0 из IMPROVEMENTS.md §17 реализован в коде; остались действия пользователя (см. ниже)
# Session Resume — P0 Security Hardening + P1 Engineering Base

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

## ✅ 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.

## 🔴 Действия пользователя для P1
1. `supabase db push` (или применить `supabase/migrations/20260612000001_add_ai_usage.sql` в дашборде), затем `supabase gen types typescript --linked > packages/db/src/generated-types.ts`
2. Railway Variables: `OPENAI_DAILY_BUDGET_USD` (например 5), опц. `SENTRY_DSN`
3. Railway: healthcheckPath → `/api/health` (railway.json), Redis 6.2+ (BullMQ)
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);
});
});
101 changes: 101 additions & 0 deletions apps/api/src/ai-usage/__tests__/ai-usage.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
interface TableState {
insertedRows: Record<string, unknown>[];
selectResult: { data: Array<{ cost_usd: number }> | null; error: { message: string } | null };
}

const state: TableState = {
insertedRows: [],
selectResult: { data: [], error: null },
};

const fromMock = jest.fn((..._args: unknown[]) => ({
insert: (row: Record<string, unknown>) => {
state.insertedRows.push(row);
return Promise.resolve({ error: null });
},
select: () => ({
gte: () => Promise.resolve(state.selectResult),
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}));

jest.mock('@repo/db', () => ({
supabase: { from: (...args: unknown[]) => fromMock(...args) },
}));

import { AiUsageService } from '../ai-usage.service';

describe('AiUsageService.computeCostUsd', () => {
let service: AiUsageService;

beforeEach(() => {
service = new AiUsageService();
});

it('prices gpt-4o-mini at 0.15/0.60 per 1M tokens', () => {
// 1M prompt + 1M completion
expect(service.computeCostUsd('gpt-4o-mini', 1_000_000, 1_000_000)).toBeCloseTo(0.75, 6);
});

it('prices text-embedding-3-small at 0.02 per 1M tokens', () => {
expect(service.computeCostUsd('text-embedding-3-small', 500_000, 0)).toBeCloseTo(0.01, 6);
});

it('returns 0 for unknown models (still recorded, never blocks)', () => {
expect(service.computeCostUsd('some-future-model', 1000, 1000)).toBe(0);
});
});

describe('AiUsageService.record', () => {
let service: AiUsageService;

beforeEach(() => {
service = new AiUsageService();
state.insertedRows = [];
fromMock.mockClear();
});

it('inserts a usage row with computed cost', async () => {
await service.record({
endpoint: 'chat',
model: 'gpt-4o-mini',
promptTokens: 1000,
completionTokens: 500,
});

expect(state.insertedRows).toHaveLength(1);
const row = state.insertedRows[0];
expect(row.endpoint).toBe('chat');
expect(row.model).toBe('gpt-4o-mini');
expect(row.prompt_tokens).toBe(1000);
expect(row.completion_tokens).toBe(500);
expect(row.cost_usd).toBeCloseTo(0.00045, 8);
});

it('never throws even when the insert fails (table may not exist yet)', async () => {
fromMock.mockImplementationOnce(() => {
throw new Error('relation "ai_usage" does not exist');
});
await expect(
service.record({ endpoint: 'chat', model: 'gpt-4o-mini', promptTokens: 1, completionTokens: 1 }),
).resolves.toBeUndefined();
});
});

describe('AiUsageService.getTodaySpendUsd', () => {
let service: AiUsageService;

beforeEach(() => {
service = new AiUsageService();
state.selectResult = { data: [], error: null };
});

it('sums cost over today rows', async () => {
state.selectResult = { data: [{ cost_usd: 0.5 }, { cost_usd: 0.25 }], error: null };
expect(await service.getTodaySpendUsd()).toBeCloseTo(0.75, 6);
});

it('returns 0 when the query fails (fail-open: availability over budgeting)', async () => {
state.selectResult = { data: null, error: { message: 'db down' } };
expect(await service.getTodaySpendUsd()).toBe(0);
});
});
Loading
Loading