Files
WhatIDo/AGENTS.md
T
dev 19b2365b4c fix(backup): ссылка на архив переживает F5 и повторное скачивание
HEAD-проба в downloadBackup() сжигала одноразовый тикет: Express 4
прогоняет HEAD через GET-хендлер /api/backup/:token, который удалял
тикет и файл до отдачи архива, поэтому настоящий GET всегда получал
404 «Ссылка на бэкап устарела».

- server.js: GET /api/backup/:token больше не удаляет тикет и файл —
  ссылка живёт BACKUP_TTL_MS (30 мин), Range-докачка работает
- вынес dropBackupTicket(), fs.rmSync обёрнут в try/catch
- BACKUP_TICKETS_MAX = 3: лишние тикеты вычищаются по возрасту
- sweepBackupStorage() режет с запасом 5 минут сверх TTL
- settings.js: убрана HEAD-проба, в #backupStatus рендерится реальная
  кликабельная ссылка вместо невидимого синтетического <a>
- AGENTS.md: раздел 8 — новое поведение + предупреждение про HEAD
2026-10-04 09:07:16 +03:00

37 KiB
Raw Blame History

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.


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/<name>; S3 object keys are the same <name> (plus .originals/<name>). 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:<token>, иначе деактивированный пользователь сохранит доступ
  • Секреты: пароль только в 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.<tailnet>.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): пусть он найдёт нужные места и вернёт путь:строка, а правки вносит основной агент.

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:

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

# 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