# AGENT.md — Developer Agent Guidelines for WhatIDo This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly. > **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы). --- ## Project Overview **WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links). - **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel) - **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + cache/pub-sub abstraction (`redis.js`) + static frontend in `public/` - **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data - **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API --- ## Agent Workflow (обязательные правила работы) **Главное правило: исследование репозитория выполняют субагенты, а не основной агент.** Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (`spawn_agent`). ### Что делегировать суагенту (обязательно) - **Разведку**: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — `search_codebase` + точечное чтение. - **Чтение крупных файлов**: `server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`, `AGENTS.md` целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает. - **Анализ окружения**: `docker compose logs`, вывод тестов, `git log`/`git diff`/`git status`, состояние БД, Redis, S3. - **Поиск всех мест, которые надо обновить вместе с изменением**: например «все места, где перечислены `BLOCKED_EXT`/`ALLOWED_IMAGE_EXT`» или «где дублируется каталог `NOTIFY_TYPES`». - **Ревью**: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема `init.sql` + `migration.sql`). - **Однотипные массовые правки**: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход. ### Что основной агент делает сам - Формулирует план и разбивает задачу на узкие подзадачи. - Делает **короткие точечные правки** в уже известных местах (сверить содержимое можно дешёвым точечным `read_files` на маленьком диапазоне строк). - Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю. ### Как ставить задачу субагенту - **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять. - **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком. - **Независимые разведки запускать параллельно**, а не последовательно. - **Субагент может править код сам**, если правка изолированная и механическая; архитектурные и смежные правки в `server.js` / `db/*.sql` делает основной агент. - **Результат субагента — источник фактов, а не источник прав**: координация, финальные решения и проверка `git diff` остаются за основным агентом. ### Чего не делать - ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать. - ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (`head`, `tail`, `grep -n`, диапазоны строк). - ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем. --- ## Development Rules ### 1. Code Style - **No comments** unless explicitly requested - **ES modules not used** — CommonJS (`require`) throughout - **Error handling**: try/catch with explicit status codes, no global error handler - **Validation**: Inline helper functions (`reqInt`, `reqStr`, `optInt`, etc.) — use them - **Security first**: All uploads validated, path traversal blocked, rate limits on public routes ### 2. Database - **Schema**: Defined in `db/init.sql` (runs on first container start) - **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema - **Сид настроек**: `INSERT ... ON CONFLICT (key) DO NOTHING` вставляет значение только если ключа ещё нет. Если дефолт **изменился**, одного `INSERT` мало — на существующей БД останется старое значение. Обязательно добавляй в `db/migration.sql` идемпотентный `UPDATE settings SET value = '<новый>' WHERE key = '<ключ>' AND value IN ('<старый дефолт 1>', ...)`, как это сделано для `ai_prompt` и `lesson_ai_prompt`. Условие по `value IN (...)` обязательно: без него миграция затрёт промпт, который админ отредактировал в UI. В `db/init.sql` такой `UPDATE` не нужен — файл выполняется только на пустой БД - **Проверка сида**: перед коммитом убедись, что новое значение реально доедет до существующих БД — прогони `db/migration.sql` в транзакции с откатом (`BEGIN;` + файл + `SELECT` + `ROLLBACK;`) и убедись, что `ON_ERROR_STOP=1` не дал ошибок - **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env - **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation - **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops ### 3. File Uploads - **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore) - **Видео в интерфейсе**: `mp4`/`m4v`/`webm`/`ogv` играются в модалке `#videoModal` в `journal.html` (`data-video` в `filesHTML`); остальные видео (`mov`, `mkv`, `avi`, …) остаются обычными ссылками на скачивание. Отдача — `GET /api/files/:token?play=1` **inline** с `Accept-Ranges`; без `?play=1` файл по-прежнему уходит как `attachment`, чтобы старые ссылки не поменяли поведение - **Limits**: `UPLOAD_FILE_LIMIT_MB` per file (default 50), `UPLOAD_TOTAL_LIMIT_MB` per entry (default 200) — both env-driven; `UPLOAD_REQUEST_TIMEOUT_MS` overrides the auto-computed request timeout. The frontend reads the two MB values from `GET /api/public-settings` (`upload_file_limit_mb`, `upload_total_limit_mb`) — do not hardcode them again in `public/js/index.js` - **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`) - **HEIC**: Auto-converted to JPEG via `heic-convert` - **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket ### 3a. Storage (`storage.js`) - **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`) - **Keys are stable**: DB stores `/uploads/`; S3 object keys are the same `` (plus `.originals/`). Never change key format — it would break existing DB rows and URLs - **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `getRange`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `streamRangeTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache` - **Диапазоны**: `getRange(key, start, end)` и `streamRangeTo(res, key, start, end, opts)` отдают `206` с `Content-Range`/`Accept-Ranges` — только для медиа, разбор `Range` на стороне сервера (`parseByteRange`) - **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent) - **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3 - **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`) - **Never publish the S3 API port**: only `127.0.0.1` on the host, file access stays behind app auth/rate limits ### 3b. Redis (`redis.js`) - **Единственная точка доступа**: `createRedis({ url, prefix })` — все операции кэша/счётчиков/pub-sub идут через неё - **API**: `get`, `set`, `del`, `dropPrefix`, `dropMatch`, `clear`, `wrap`, `incr`, `publish`, `on`, `rateLimitStore`, `info`, `connect`, `close` - **Graceful fallback — обязательное требование**: при недоступном Redis все операции уходят в in-memory backend с той же семантикой. Приложение обязано стартовать и работать без Redis - **Первое подключение ограничено по времени** (`REDIS_CONNECT_TIMEOUT_MS`, 5 с): node-redis не отклоняет `connect()` при недоступном сервере, а повторяет попытки бесконечно — без таймаута старт приложения зависнет навсегда - **Переподключение**: node-redis переподключается сам; по событию `ready` подписки и subscriber-клиент восстанавливаются (`ensureSubscriber`). Не пересоздавать subscriber через `destroy()` — это гонка с внутренним teardown node-redis - **Ключи**: `get`/`set` сами добавляют namespace (`REDIS_PREFIX`, по умолчанию `whatido`), `dropPrefix`/`dropMatch` тоже. В `rateLimitStore` префикс добавляется один раз в `base` — не применяйте `fullKey` повторно - **`scanDelete`**: курсор `SCAN` в node-redis v5 обязан быть строкой, числовой `0` вызовет `TypeError`. Возвращаемое значение курсора — тоже строка, сравнивайте с `'0'` - **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()` - **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату - **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте - **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `lessons:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:` - **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:`, иначе деактивированный пользователь сохранит доступ - **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1` ### 3c. Уведомления (`server.js`, `worker.js`, `public/`) - **Каталог событий** — только `NOTIFY_TYPES` в `server.js` (тип → `label`, `hint`, `icon`, `level`, `enabled` по умолчанию, `admin`); фронтенд берёт список из `GET /api/notifications/meta`, дублировать каталог в HTML нельзя - **Таблицы**: `notifications` (событие, `admin_only`, `branch_id`) + `notification_reads` (прочтение на пользователя). Изменения схемы — в `db/init.sql` и `db/migration.sql` и в `ensureNotificationsTable()` - **Настройки**: `notify_enabled` (общий), `notify_retention_days` (1–365), `notify_<тип>` (точки типа заменяются на `_`, см. `notifySettingKey`). Значения только `'true'` / `'false'` — `PUT /api/settings` это валидирует - **Создание события** — только через `pushNotification()` / `notifyEntry()`; они сами проверяют переключатели и при выключенном типе возвращают `null`. `notifyEntry` подставляет `{student}` и `{group}` и определяет филиал по группе записи - **Хук в фото-воркере называется `notifyEvent`** — имя `notify` внутри `createPhotoEnhanceWorker` уже занято будильником воркера (`photoWorker.notify()` из `POST /api/photo-jobs/wake`), объявление функции перекрыло бы параметр - **Видимость**: админ видит всё; остальные — `admin_only = false` и `branch_id IS NULL` или филиал из `user_branches` - **Доставка**: запись в БД → `cache.publish('whatido:notifications', row)` → SSE `GET /api/notifications/stream` (клиенты фильтруются по `notificationVisible`). Redis недоступен — работает in-memory pub/sub - **Очистка**: `purgeOldNotifications()` при старте и раз в час по `notify_retention_days`; чтения удаляются каскадом - **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide - **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`) ### 3d. Отчёты о занятии и проверка по шаблону (`server.js`, `worker.js`, `public/`) - **Таблицы**: `lesson_reports` (+ `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`) и `lesson_report_versions` (история версий: `text`, `source` = `manual` | `ai` | `restore`). Схема — в `db/init.sql`, `db/migration.sql` и `ensureLessonReportsTable()` - **Настройки**: `lesson_ai_enabled` (`'true'` / `'false'` — общий выклюжатель) и `lesson_ai_prompt` (шаблон делового сообщения + правила, пример вставляется в `db/init.sql`, `db/migration.sql` и в `LESSON_AI_DEFAULT_PROMPT` в `server.js`). Раздел в UI — `sec-lesson-ai` на `public/settings.html` - **Смена шаблона промпта — это четыре правки, а не три**: новое значение в `LESSON_AI_DEFAULT_PROMPT` (`server.js`), `db/init.sql`, `db/migration.sql` (там же `INSERT` для свежих БД) и **обязательно** `UPDATE settings SET value = '<новый>' WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')` в `db/migration.sql` — без него правка в SQL-файлах действует только на свежие установки, а у всех, кто уже пользовался разделом `sec-lesson-ai`, в `settings` останется старый промпт (см. «Сид настроек» в разделе 2). Все три текстовые копии должны быть побайтово идентичны `LESSON_AI_DEFAULT_PROMPT` - **Флаг из UI**: чекбокс `#lessonAiCheck` в модалке `#lessonModal` — включён при создании, выключен при редактировании (`resetLessonModalFields` / `fillLessonModalFromReport`). Уходит в теле как `ai_check` - **Роут не ждёт модель**: `POST`/`PUT /api/lesson-reports` при `ai_check: true` сохраняют отчёт как есть и ставят `ai_status = 'pending'`, затем `wakeLessonAiWorker()`. Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели - **Воркер**: `createLessonReportChecker` в `worker.js` забирает `pending` через `FOR UPDATE OF lr SKIP LOCKED`, шлёт в модель текст + контекст (группа, дата, время), результат: без изменений → `skipped`, переписан → `done` (новый текст в `text` и `text_ai`), сбой → до 3 попыток, затем `error` - **Доставка результата**: `onDone` в `server.js` пишет версию (`saveLessonReportVersion`), аудит с diff (`lesson_report.ai.format`), уведомление `lesson.ai.formatted` и SSE `lesson_report_status` на `EVENTS_CHANNEL` - **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт - **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js` ### 4. API Patterns - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется - **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min) — все на `cache.rateLimitStore(...)`, не на `MemoryStore` - **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success - **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }` - **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted` ### 5. Frontend (public/) - Vanilla HTML/CSS/JS, no build step - Each page = single HTML file + shared `admin.js` / `admin.css` - API calls via `fetch` with `X-Auth-Token` (токен из `localStorage`); `X-Admin-Token` больше не используется и не работает - Share pages (`share.html`, `links.html`) work without auth ### 6. Docker / Compose - **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert - **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`) - `db`: postgres:16-alpine, healthcheck, init.sql mounted - `redis`: redis:7-alpine, `--requirepass`, AOF, `maxmemory` + `allkeys-lru`, healthcheck, том `redis-data`, порт только на `127.0.0.1` - `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads - `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443) - **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`, `REDIS_PASSWORD` - **Env vars** (optional): `REDIS_PREFIX` (default `whatido`), `REDIS_MAXMEMORY` (default `256mb`), `REDIS_CONNECT_TIMEOUT_MS` (default `5000`) - **Port 443 on host** must be free (tailscale listens directly) ### 7. Tailscale Publication - No external IP / port forwarding needed - Access: `https://whatido..ts.net` (inside tailnet + internet via Funnel) - First run: `docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido` → authorize in browser - Enable Serve/Funnel in Tailscale admin console for the node - Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container ### 8. Backup / Restore - **Admin UI**: `POST /api/backup` → тикет + `GET /api/backup/:token` (ссылка живёт `BACKUP_TTL_MS`, 30 мин, **скачивание можно повторять** — в том числе после обрыва связи и F5; не «сжигать» тикет `HEAD`-пробой, Express 4 отдаёт HEAD через GET-хендлер), `POST /api/restore` (upload `.tar.gz`) - **Хранение тикетов**: in-memory `Map` (`server.js`), поэтому после рестарта приложения ссылка даёт 404 — это ожидаемо. Одновременно живых тикетов не больше `BACKUP_TICKETS_MAX`, лишние и истёкшие вычищаются `pruneBackupTickets()`; файлы доживают `sweepBackupStorage()` с запасом в 5 минут сверх TTL - **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level) - Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore` - `data.json` содержит `version`, `created_at`, `app` (версия/коммит), `counts` (строки по таблицам + `files`) и сами данные. Таблицы перечислены в `BACKUP_TABLES` — **при добавлении таблицы править её и в `buildBackupArchive`, и здесь** - `sessions` в бэкап **не входит** намеренно: после restore все токены должны умереть. `audit_log`, `notifications`, `notification_reads`, `banned_ips` — входят - Файлы: `storage.downloadAll` кладёт в архив всё, кроме регенерируемых `.thumbs/` и `.cache/`; `.originals/` (оригиналы фото до ИИ-обработки) **входят** и восстанавливаются через `uploadTree` - Restore: валидация всего через `normalizeRestoreData` (`backup-restore.js`), транзакция с `DELETE` в FK-безопасном порядке → `INSERT` → `setval` по `BACKUP_SEQUENCE_TABLES` → файлы → `sweepOrphanedUploads()` → `loadBans()` → `invalidateAll()` - **Колонки, которые normalizeRestoreData обязана сохранять**: `groups.deleted_at`/`purge_at`, `entries.purge_at`. Потеря `deleted_at` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных - `sweepOrphanedUploads()` считает ссылками фото из `entries.photo_path`/`photo_original_path`, `project_files.path`, `group_photos`, `entry_photos`, `student_photos`, `modules.photo_path`, `students.photo_path`, `groups.cover_path`, `photo_jobs.before_path`/`after_path`, `settings.system_logo`. Новая колонка с путём к файлу → добавить сюда, иначе sweep снесёт файл сразу после restore --- ## Common Tasks Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент. ### Add a new API endpoint 1. Add route in `server.js` (group with related routes) 2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public 3. Validate input with helper functions 4. Use parameterized queries, transactions if multi-table 5. Call `logAudit(req, 'action.name', { ... })` for mutations 6. Return JSON, handle errors with appropriate status codes ### Add a database column/table 1. Update `db/init.sql` (CREATE TABLE / ALTER TABLE) 2. Update `db/migration.sql` (idempotent ALTERs) 3. Update `server.js` queries that SELECT/INSERT the table 4. Test: `docker compose down && docker compose up -d --build` ### Add a frontend page 1. Create `public/newpage.html` (copy structure from existing) 2. Link in `public/admin.html` navigation if admin page 3. Use `admin.js` utilities: `api()`, `requireAuth()`, `formatDate()`, etc. 4. No build step — just refresh browser ### Modify file upload rules - Edit `BLOCKED_EXT`, `ALLOWED_IMAGE_EXT`, `ADMIN_ALLOWED_EXT` constants - Update Multer `fileFilter` functions - Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync ### Migrate files to S3 / switch storage driver 1. `docker compose up -d s3` 2. `docker compose exec -T app node scripts/migrate-to-s3.js --dry-run` then without the flag (idempotent, size-checked, keeps local files) 3. `docker compose exec -T app node scripts/migrate-to-s3.js --verify-only` 4. Set `STORAGE_DRIVER=s3` in `.env`, `docker compose up -d app` 5. After verification: `docker compose exec -T app node scripts/migrate-to-s3.js --delete-local` - Rollback: `STORAGE_DRIVER=local` + `docker compose up -d app` - Do not run `--delete-local` before the app serves reads from S3 and the verification passes --- ## Testing & Verification No automated test suite exists. Verify manually: ```bash # Start stack docker compose up -d --build # Check logs docker compose logs -f app # Test API (replace LOGIN/PASS; X-Admin-Token больше не работает) TOKEN=$(curl -s -X POST http://localhost:3003/api/auth/login \ -H 'Content-Type: application/json' \ -d "{\"username\":\"$LOGIN\",\"password\":\"$PASS\"}" | sed -E 's/.*"token":"([a-f0-9]+)".*/\1/') curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/auth/me # /api/groups — публичный (optionalAuth), 200 даже без токена: # для проверки авторизации берите /api/auth/me или /api/users # Run backup/restore scripts ./scripts/backup.sh ./scripts/restore.sh backups/whatido-backup-.tar.gz # Storage checks docker compose up -d s3 docker compose exec -T app node scripts/migrate-to-s3.js --dry-run docker compose exec -T app node scripts/migrate-to-s3.js --verify-only # Redis checks node redis.selftest.js # unit + degradation, needs redis on 127.0.0.1:6379 node api.smoketest.js # e2e, needs running stack docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO docker compose stop redis && node api.smoketest.js # app must keep working in-memory # Backup checks node backup.selftest.js # unit, no stack needed curl -sk -X POST https://127.0.0.1:3443/api/backup -H "X-Auth-Token: $TOKEN" # -> counts по всем таблицам docker compose start redis # app reconnects on its own # Audit diff checks node diff.selftest.js # unit, no stack needed ``` `diff.js` builds the audit payload for text changes: word-level segments (`eq`/`del`/`add`), per-step stats (`added_words`, `removed_words`, `chars_before/after`) and a light `summarizeChanges`/`stripDiffs` pair for the audit list. Two rules to keep: the diff payload stored in `audit_log.target` must stay capped (it is rendered raw in the audit UI), and `GET /api/audit` must keep stripping `diff` while `GET /api/audit/:id` returns it — otherwise the list endpoint ships kilobytes of text per row. Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`, `fallbackOps`, `used_memory_human`, `keys`). `GET /api/system-info` also returns a `stack` block (built by `getStackInfo()`, outside the response cache so versions and load stay fresh): `app` (Node, PID, RSS/heap, uptime, version from `package.json` + `public/version.json`), `deps` (installed versions of the main packages), `runtime` (OS from `/etc/os-release`, kernel, arch, CPU count/model, loadavg, memory, container detection), `database` (PostgreSQL version, host, pool counters), `cache` (driver, version, ready, keys, memory, hits/misses, fallback ops) and `storage` (driver, endpoint, bucket). Hosts come from `URL.hostname` only — credentials from `DATABASE_URL`/`REDIS_URL` must never reach the payload. The «Статус стека» block on `public/settings.html` renders exactly this payload. `api.smoketest.js` also locks the auth contract: only `X-Auth-Token` with a session token authenticates, while `X-Admin-Token`, `Authorization: Bearer` and `ADMIN_PASSWORD` used as a token must all be rejected with 401. If you change the auth scheme, update this test and the Auth notes in this file together — a doc that drifts from the code is the failure mode this guards against. --- ## Security Checklist (before any change) - [ ] No SQL interpolation — only `$1`, `$2`... - [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink` - [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/` - [ ] Rate limiter on new public routes - [ ] Admin routes behind `requireAdmin` - [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change - [ ] No secrets in code — only via env vars - [ ] Helmet headers present (already global) - [ ] CORS disabled (no `cors` middleware) --- ## File Map (key files) | File | Purpose | |------|---------| | `server.js` | Entire backend (Express, routes, DB, uploads, backup) | | `storage.js` | Storage abstraction: `local` and `s3` drivers, key normalization, cache/thumb helpers | | `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback | | `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable | | `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests | | `backup-restore.js` / `backup.selftest.js` | Backup format version, `normalizeRestoreData` validation of restore payloads, backup table lists; self-tests | | `api.smoketest.js` | End-to-end API smoke test against a running stack | | `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance | | `db/init.sql` | Initial schema (runs on fresh DB) | | `db/migration.sql` | Idempotent migrations for existing DBs | | `docker-compose.yml` | Service definitions (app, db, s3, tailscale) | | `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS | | `Dockerfile` | App image build | | `public/*.html` | Frontend pages | | `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) | | `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) | | `scripts/migrate-to-s3.js` | One-off/idempotent migration `uploads/` -> S3 bucket | | `scripts/deploy.sh` | Deploy script (pull master, build image with commit version, restart app) | | `start-tailscale.sh` | Tailscale container entrypoint | | `.env.example` | Env var template | --- ## Do Not - ❌ Add dependencies without updating `package.json` and rebuilding - ❌ Write files outside `uploads/` or `certs/` - ❌ Touch `uploads/` with `fs.*` in request/worker code — use `storage.*` (files may live only in S3) - ❌ Run `migrate-to-s3.js --delete-local` before verification and cutover - ❌ Expose the S3 API port publicly (only `127.0.0.1` in compose) - ❌ Expose the Redis port publicly (only `127.0.0.1` in compose) - ❌ Call `fs.*`/`pg` directly for cache, counters or pub/sub — use `redis.js` - ❌ Make Redis a hard dependency: any new Redis-backed path must keep the in-memory fallback - ❌ `await client.connect()` without a timeout — it never rejects while Redis is unreachable - ❌ Cache authorization-relevant data without an invalidation path on the mutation - ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/` - ❌ Expose DB port (5432) outside docker network - ❌ Use `eval`, `Function` constructor, or dynamic code execution - ❌ Add comments to code (this file excepted) - ❌ Read/search the repo in the main agent when the work can be delegated to a subagent - ❌ Load whole files (`server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`) or raw command output into the main context — delegate and filter (`grep -n`, `head`, line ranges) - ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its `systemPrompt` — it does not inherit the main context - ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (`git diff`) --- ## Quick Commands ```bash # Full rebuild docker compose down && docker compose up -d --build # Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app) ./scripts/deploy.sh # App logs docker compose logs -f app # DB shell docker compose exec db psql -U app -d whereldo # Redis status and cache keys docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*' # S3 storage status and migration verification docker compose up -d s3 docker compose exec -T app node scripts/migrate-to-s3.js --verify-only docker compose exec -T app node scripts/migrate-to-s3.js --delete-local # Tailscale status docker exec -it whatido-tailscale-1 tailscale status # Manual funnel restart docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443 ```