# AGENT.md — Developer Agent Guidelines for WhatIDo This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly. --- ## 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 --- ## 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 - **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:`, `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`) ### 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**: `/api/backup` (download tar.gz), `/api/restore` (upload tar.gz) - **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level) - Backup format: `data.json` (all tables) + `uploads/` directory - Restore validates all data, resets sequences, sweeps orphans --- ## Common Tasks ### 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 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 | | `api.smoketest.js` | End-to-end API smoke test against a running stack | | `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker | | `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 | | `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) --- ## 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 ```