# AGENT.md — Полное руководство по проекту IPWhiteList > Этот файл предназначен для AI-агентов и разработчиков без контекста. > Читай этот файл **первым** перед любой задачей. > Обновляй после значимых изменений. --- ## 1. Суть проекта **IP WhiteList** — веб-приложение для управления белыми IP-списками (CIDR) по компаниям. - Каждая компания имеет свой список IPv4/CIDR записей. - Есть лимит записей на компанию (по умолчанию 15, admin может менять). - Есть одна роль admin (задаётся через env) и обычные пользователи. - Аутентификация через JWT (RS256): в проде — внешний OIDC (Keycloak/Nubes auth-api), в dev — mock. - Весь UI — server-side rendered (EJS). API — REST JSON. --- ## 2. Репозиторий и деплой | Параметр | Значение | |---|---| | Репо (рабочий) | `https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git` ветка `master` | | Локальный путь | `/home/naeel/ipwhitelist-app` | | Деплой URL | `https://white.nodejsk8s.dev.nubes.ru` | | Платформа | Nubes k8s — редеплой через Nubes UI из Gitea после `git push` | | Версия | `package.json` → поле `version` (сейчас `0.4.9`) | **Правило версий:** bump `version` в `package.json` при каждом значимом изменении. --- ## 3. Стек ``` Node.js 18+ + Express 4 EJS (server-side rendering) PostgreSQL 17 (pg pool, SSL require) jsonwebtoken RS256 express-session + connect-pg-simple helmet (CSP) express-rate-limit supertest (тесты) ``` --- ## 4. Переменные окружения ```env NODE_ENV=development # production → отключает DEV_MODE, требует SESSION_SECRET PORT=3000 # Auth DEV_MODE=true # true → mock JWT, ЗАПРЕЩЕНО в production DEV_SECRET= # опционально: пароль для /dev-login на staging ADMIN_CLIENT_ID=WZ01112 # clientId пользователя с правами admin JWT_ISSUER=mock-auth-api # issuer в JWT # OIDC (если DEV_MODE=false) KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud KC_CLIENT_ID= # пусто = mock mode KC_CLIENT_SECRET= APP_URL=http://localhost:3000 # База данных DB_HOST=write.bde8229b-1381-4330-b24b-727ad73fcb44.dev.nubes.ru DB_PORT=5432 DB_NAME=ipwhitelist DB_USER=super DB_PASS= DB_SSLMODE=require # Сессия SESSION_SECRET= # обязателен в production (fail-fast при запуске) # Прочее DEFAULT_LIMIT=15 # лимит записей по умолчанию (если нет custom_limit) API_BASE=http://localhost:3000 # UI-слой → куда идти за /api/v1/* ``` --- ## 5. Структура файлов — карта ответственности ``` server.js ← точка входа: helmet, session, монтирование роутеров src/ config.js ← MOCK_USERS, APP_VERSION, backUrl() db.js ← pg Pool, checkConnection() auth.js ← initAuth() → { middleware, issueMockToken, ... } queries.js ← ВСЕ SQL-запросы — единственное место работы с БД validators.js ← validate(cidr), overlaps(), aggregateCIDRs(), BLOCKED_RANGES api/ index.js ← createApiRouter({auth, q}) — монтируется на /api/v1 middleware/ bearerAuth.js ← requireBearer: нет заголовка → 401 JSON routes/ entries.js ← GET/POST/PATCH/DELETE /api/v1/entries + GET /api/v1/entries/export admin.js ← GET /api/v1/companies, PATCH limit, GET /api/v1/audit middleware/ rateLimit.js ← mutationLimiter, exportLimiter, authLimiter session.js ← createSessionMiddleware(pool) csrf.js ← initCsrf() — НЕ используется в новом UI csp.js ← CSP_DIRECTIVES для helmet routes/ export.js ← GET /export — требует авторизации (Bearer) exp.js ← ⚠️ ВРЕМЕННЫЙ GET /exp — без авторизации (удалить после prod) ui/ index.js ← createUiRouter({auth, MOCK_USERS, authLimiter}) api-client.js ← HTTP-клиент к /api/v1/* (берёт Bearer из session.token) routes/ auth.js ← GET/POST /login, /login-token, /logout entries.js ← /, /add, /edit/:id, /delete/:id admin.js ← /admin, /admin/limit/:id, /audit export.js ← /export (UI-обёртка, проксирует к /api/v1/entries/export) views/ index.ejs ← главная: таблица записей + форма добавления admin.ejs ← список компаний с лимитами (admin only) audit.ejs ← журнал аудита (admin only) ui-login.ejs ← страница входа (mock-пресеты + вставить Bearer вручную) error.ejs ← 403/404/500 sql/ schema.sql ← DDL: CREATE TABLE IF NOT EXISTS (идемпотентно) tests/ integration.js ← интеграционные тесты (supertest + реальный PG) run-tests.js ← runner ``` --- ## 6. Поток запроса — HTTP → БД ### 6.1 REST API запрос ``` HTTP Request → server.js: helmet, session, cookieParser → GET /exp → src/routes/exp.js (без auth, временный) ← ТОЛЬКО ЭТО без auth → /api/v1/* → src/api/index.js → requireBearer : нет заголовка Authorization: Bearer → 401 JSON → auth.middleware : валидация JWT → req.user = { clientId, isAdmin, email, ... } → /entries/* → src/api/routes/entries.js → resolveCompany() : admin+?company=id → getCompanyById() | иначе getOrCreateCompany() → q.*() : SQL через pg pool → JSON response → /companies, /audit → src/api/routes/admin.js → apiRequireAdmin() : !isAdmin → 403 JSON → q.*() ``` ### 6.2 UI запрос (SSR) ``` HTTP Request → server.js → ui/index.js (createUiRouter) → createAuthRouter : /login, /login-token, /logout — публичные → requireToken : нет session.token → redirect /login → resolveUser : jwt.decode(token) → req.user (без верификации подписи) → createEntriesRouter, createAdminRouter, createExportRouter → ui/api-client.js : HTTP запрос к /api/v1/* с Bearer из session.token → разбирает JSON ответ → res.render('view.ejs', data) → Если API вернул 401 → destroy session → redirect /login ``` --- ## 7. req.user — структура После прохождения `auth.middleware` (API) или `resolveUser` (UI): ```js req.user = { clientId: 'WZ01112', // из JWT claim ClientID (заглавная!) companyId: 'uuid-...', // из JWT claim company_id companyName: 'Nubes Admin', // из JWT claim company_name email: 'admin@nubes.ru', // из JWT claim email isAdmin: true, // clientId === ADMIN_CLIENT_ID } ``` --- ## 8. База данных — схема ```sql -- Компании (1 строка на WZ-номер, создаётся при первом входе) companies ( id SERIAL PRIMARY KEY, client_id VARCHAR(64) UNIQUE NOT NULL, -- WZ01112, WZ01325 и т.д. name VARCHAR(255), custom_limit INTEGER DEFAULT NULL, -- NULL = использует DEFAULT_LIMIT created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ ) -- Записи белого списка (soft delete через deleted_at) whitelist_entries ( id SERIAL PRIMARY KEY, company_id INTEGER REFERENCES companies(id), value_cidr VARCHAR(18), -- '192.168.1.0/24' comment VARCHAR(255), created_by VARCHAR(255), -- email created_at TIMESTAMPTZ, updated_by VARCHAR(255), updated_at TIMESTAMPTZ, deleted_by VARCHAR(255), deleted_at TIMESTAMPTZ -- NULL = активная запись ) -- INDEX: uq_entries_active_cidr(company_id, value_cidr) WHERE deleted_at IS NULL -- Аудит audit_log ( id SERIAL PRIMARY KEY, user_email VARCHAR(255), company_id INTEGER, action VARCHAR(32), -- 'CREATE' | 'UPDATE' | 'DELETE' old_value TEXT, new_value TEXT, entry_id INTEGER, created_at TIMESTAMPTZ ) ``` --- ## 9. src/queries.js — все функции | Функция | Сигнатура | Описание | |---|---|---| | `getOrCreateCompany` | `(clientId, companyName) → company` | UPSERT компании по clientId | | `getCompanyById` | `(id) → company\|null` | по числовому PK | | `getLimit` | `(company) → number` | custom_limit ?? DEFAULT_LIMIT | | `listEntries` | `(companyId, includeDeleted=false) → []` | список записей | | `createEntry` | `(companyId, rawCidr, comment, email) → {entry, wasNormalized}` | INSERT + audit (транзакция) | | `updateEntry` | `(entryId, companyId, rawCidr, comment, email) → {entry, wasNormalized}` | UPDATE + audit (транзакция) | | `deleteEntry` | `(entryId, companyId, email) → void` | soft delete + audit (транзакция) | | `getAllCompanies` | `() → []` | все компании + active_count | | `setLimit` | `(companyId, newLimit) → void` | custom_limit = newLimit | | `getExportCIDRs` | `(companyId=null) → string[]` | CIDR строки; null = все компании | | `logAudit` | `(email, companyId, action, old, new, entryId, db?) → void` | внутренняя, вызывается из транзакций | | `getAudit` | `(companyId=null) → []` | журнал с JOIN companies | --- ## 10. src/validators.js — функции ```js validate(rawValue) → { cidr: '1.2.3.0/24', wasNormalized: boolean } → throws Error если невалидно // Нормализует host bits, проверяет BLOCKED_RANGES overlaps(cidr1, cidr2) → boolean // true если диапазоны пересекаются aggregateCIDRs(cidrArray) → string[] // Объединяет пересекающиеся и смежные диапазоны → минимальный набор BLOCKED_RANGES // приватные/зарезервированные диапазоны — запрещены для добавления ``` --- ## 11. src/auth.js — initAuth() ```js const auth = await initAuth(); // Возвращает: auth.middleware // Express middleware: валидирует Bearer JWT → req.user auth.issueMockToken(claims) → jwtString // только в mock-режиме, для тестов auth.jwksHandler // GET /.well-known/jwks.json (только mock) // OIDC-режим: auth.getAuthUrl(state, nonce) → string // redirect URL на Keycloak auth.exchangeCode(code) → {token, user} // обмен code → JWT ``` **Логика определения режима:** - `KC_CLIENT_ID && KC_CLIENT_SECRET` → OIDC-режим - иначе → mock-режим (генерирует RSA-пару при старте) --- ## 12. ui/api-client.js — интерфейс ```js api.token(req) // → Bearer string из req.session.token api.get(path, token) // → { status, data } api.post(path, token, body) // → { status, data } api.patch(path, token, body) // → { status, data } api.delete(path, token) // → { status, data } // path примеры: '/api/v1/entries', '/api/v1/entries/42', '/api/v1/companies/5/limit' ``` --- ## 13. Все HTTP эндпоинты ### Публичные (без auth) | Метод | URL | Файл | Описание | |---|---|---|---| | GET | `/healthz` | server.js | k8s liveness probe | | GET | `/.well-known/jwks.json` | server.js | mock JWKS (только DEV_MODE) | | GET | `/login` | ui/routes/auth.js | страница входа | | POST | `/login` | ui/routes/auth.js | выбор mock-пользователя | | POST | `/login-token` | ui/routes/auth.js | вставить Bearer вручную | | GET | `/logout` | ui/routes/auth.js | destroy session | | GET | `/callback` | ui/routes/auth.js | OIDC callback | | GET | `/exp` | src/routes/exp.js | ⚠️ ВРЕМЕННЫЙ: экспорт без auth | ### UI (требуют session.token) | Метод | URL | Файл | Описание | |---|---|---|---| | GET | `/` | ui/routes/entries.js | главная страница | | POST | `/add` | ui/routes/entries.js | добавить запись | | POST | `/edit/:id` | ui/routes/entries.js | изменить запись | | POST | `/delete/:id` | ui/routes/entries.js | удалить запись | | GET | `/admin` | ui/routes/admin.js | список компаний (admin) | | POST | `/admin/limit/:id` | ui/routes/admin.js | изменить лимит (admin) | | GET | `/audit` | ui/routes/admin.js | журнал аудита (admin) | | GET | `/export` | ui/routes/export.js | скачать whitelist.txt | ### REST API `/api/v1` (требуют Bearer JWT) | Метод | URL | Файл | Кто | Описание | |---|---|---|---|---| | GET | `/api/v1/entries` | api/routes/entries.js | все | список + limit + used | | GET | `/api/v1/entries?company=` | api/routes/entries.js | admin | записи другой компании | | POST | `/api/v1/entries` | api/routes/entries.js | все | создать | | PATCH | `/api/v1/entries/:id` | api/routes/entries.js | все | обновить | | DELETE | `/api/v1/entries/:id` | api/routes/entries.js | все | удалить | | GET | `/api/v1/entries/export` | api/routes/entries.js | все | text/plain CIDR | | GET | `/api/v1/companies` | api/routes/admin.js | admin | все компании | | PATCH | `/api/v1/companies/:id/limit` | api/routes/admin.js | admin | установить лимит | | GET | `/api/v1/audit` | api/routes/admin.js | admin | журнал аудита | --- ## 14. Rate limiting | Лимитер | Применяется к | Лимит | |---|---|---| | `authLimiter` | POST /login, /login-token, GET /callback | 10 req / 5 мин | | `exportLimiter` | GET /export, GET /exp, GET /api/v1/entries/export | 20 req / мин | | `mutationLimiter` | POST /add, /edit/:id, /delete/:id | 30 req / мин | --- ## 15. Mock-пользователи (DEV_MODE=true) | id | clientId | Роль | companyName | email | |---|---|---|---|---| | `admin` | `WZ01112` | **admin** | Nubes Admin | admin@nubes.ru | | `test` | `WZ01325` | user | Тест | tazet@narod.ru | | `client2` | `WZ02001` | user | Вторая Компания | user2@example.com | Файл: `src/config.js` → `MOCK_USERS` --- ## 16. Бизнес-логика — важные правила 1. **Изоляция компаний**: везде `company_id` в WHERE — данные одной компании недоступны другой. 2. **Транзакции с FOR UPDATE**: createEntry/updateEntry/deleteEntry блокируют строку компании — защита от race condition при параллельных вставках. 3. **Soft delete**: `deleted_at IS NULL` = активная запись. Удалённые остаются в БД. 4. **Дубли/пересечения**: при создании и обновлении проверяются через `overlaps()` против всех активных записей компании. 5. **Лимит**: снижение лимита не удаляет записи — только блокирует создание новых. 6. **CIDR нормализация**: `validate()` нормализует host bits (192.168.1.5/24 → 192.168.1.0/24), возвращает `wasNormalized: true`. 7. **Агрегация при экспорте**: `aggregateCIDRs()` объединяет смежные диапазоны — минимальный набор. --- ## 17. HTTP коды ошибок API | Код | Когда | |---|---| | 400 | Невалидный CIDR, некорректные параметры | | 401 | Нет/невалидный Bearer токен | | 403 | Не admin на admin-endpoint | | 404 | Запись/компания не найдена | | 409 | Дубликат CIDR, пересечение, лимит исчерпан | | 500 | Ошибки БД и прочие | --- ## 18. Запуск и тесты ```bash # Установка зависимостей npm install # Запуск локально (нужен .env) node server.js # Тесты (supertest + реальная БД) npm test # или node tests/run-tests.js ``` Тесты требуют реального PG подключения (переменные из .env). --- ## 19. ⚠️ ВРЕМЕННЫЕ ЭЛЕМЕНТЫ — удалить перед продом ### GET /exp — публичный экспорт без авторизации (добавлен v0.4.9) **Причина:** По ТЗ п. 4.7 `/export` должен быть публичным, но сейчас требует авторизацию. Создан временный `/exp` как workaround. **Что удалить:** 1. Файл `src/routes/exp.js` — полностью 2. В `server.js` строку: `app.use(require('./src/routes/exp'));` **Когда:** когда `/export` сделают публичным по ТЗ. --- ## 20. Расхождения с ТЗ (открытые задачи) | Пункт ТЗ | Суть | Статус | |---|---|---| | 3.1 | Несколько компаний на пользователя | Не реализовано — ждём devops | | 4.1 | Отображение soft-deleted для admin | Не реализовано | | 4.7 | `/export` без авторизации | Обход: `/exp` (временный) |