diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..b04429c
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,275 @@
+# Upload Platform — подробный план реализации
+
+> Целевой исполнитель: Flash / GPT-5.6 Luna. План самодостаточен: читай и кодируй по нему.
+> Источник-референс (паттерн уже отлажен): drhider v0.0.75 (`site/templates/index.html`,
+> `site/routes/api_bp.py`, `site/session.py`, `upload/`). Здесь — **standalone** проект:
+> те же 2 слоя, но вынесены в отдельный репозиторий и демонстрируются на минимальной обёртке.
+
+---
+
+## 0. Цель и границы
+
+**Что делаем:** отдельный переиспользуемый сервис загрузки файлов через ВМ-буфер.
+Паттерн: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` (egress pull) → сессия.
+
+**Что входит (переиспользуемое):**
+- Слой 1 — выбор файлов (фронт): таблица/дерево, дедуп, раскрытие ZIP, рекурсивный обход папки.
+- Слой 2 — закачка через ВМ (фронт + бэк): PUT, pull, сессия, лимиты, валидации.
+
+**Что НЕ входит (у каждого приложения своё):**
+- Слой 3 — логика приложения (обфускация/обработка/SSE-прогресс). Здесь — только **стаб-заглушка**,
+ которая показывает, что файлы доехали до сессии (список + размеры), без реальной обработки.
+
+**Жёсткие требования:**
+1. Vanilla JS, без React/сборщика. Единственная новая зависимость — `fflate` (CDN) для ZIP.
+2. Каждая функция — отдельный файл (мелкие файлы).
+3. Всё, что отличает один проект от другого — **в конфиге**, не в коде слоёв.
+4. Безопасность сохранить 1:1: SSRF-валидация URL, `_safe_name` (path traversal), ретраи pull,
+ лимиты, `esc()` имён (self-XSS).
+5. Запуск на Nubes: структура `site/app.py`, `python site/app.py`, `0.0.0.0:5000`,
+ **маршрут `/health` → 200** (иначе liveness-проба платформы убивает под), `debug=False`.
+
+---
+
+## 1. Архитектура (слои)
+
+| Слой | Где | Ответственность |
+|---|---|---|
+| **0. Конфиг** | общий | `vmUploadUrl`, `allowedExt`, лимиты, ретраи |
+| **1. Выбор файлов** | фронт | таблица/дерево, дедуп, раскрытие ZIP, папка, путь, статусы, кнопки |
+| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ (фронт) → `upload_refs` pull (бэк) → сессия |
+| **3. Логика приложения** | бэк | **стаб** (в реальном проекте своя — сюда приходит SSE/обработка) |
+
+**Слой 3 здесь — заглушка.** Он НЕ переиспользуется. Его задача — доказать, что слой 2
+корректно передаёт файлы в сессию и что в новом проекте к нему легко прицепить свою логику.
+
+---
+
+## 2. Структура проекта (обязательная)
+
+```
+upload-platform/
+├── requirements.txt
+├── README.md # что это + как интегрировать слои в другой проект
+├── PLAN.md # этот план
+├── config.json # слой 0 (рабочий конфиг ЭТОГО демо)
+├── upload/ # ← переиспользуемый модуль (копируется в любой проект)
+│ ├── README.md # короткая инструкция интеграции (для агента)
+│ ├── config.example.json # образец конфига
+│ ├── frontend/
+│ │ ├── zip/
+│ │ │ └── list_zip_files.js # listZipFiles(file, allowedExt) на fflate
+│ │ ├── table/ # слой 1
+│ │ │ ├── fs.js
+│ │ │ ├── esc.js
+│ │ │ ├── add_file_with_dedup.js
+│ │ │ ├── render.js
+│ │ │ ├── set_status.js
+│ │ │ ├── on_files_change.js
+│ │ │ ├── on_folder_change.js
+│ │ │ └── init_upload_table.js
+│ │ └── upload/ # слой 2 (фронт)
+│ │ ├── put_to_vm.js
+│ │ └── upload_via_vm.js
+│ └── backend/
+│ ├── upload_refs/ # слой 2 (бэк)
+│ │ ├── __init__.py
+│ │ ├── config.py
+│ │ ├── safe_name.py
+│ │ ├── pull_file.py
+│ │ └── blueprint.py
+│ └── session/
+│ ├── __init__.py
+│ ├── state.py
+│ ├── create_session.py
+│ ├── add_file.py
+│ ├── get_files.py
+│ └── cleanup.py
+└── site/ # демо-обёртка (НЕ переиспользуется)
+ ├── app.py # точка входа, / и /health
+ ├── routes/
+ │ └── api_bp.py # регистрирует blueprint из upload/ + стаб слоя 3
+ ├── templates/
+ │ └── index.html # слой 1 + 2 (подключает ES-модули из upload/frontend)
+ └── static/
+ ├── style.css
+ └── vendor/fflate.min.js # fflate (локально, не CDN — надёжнее)
+```
+
+> ⛔ `site/__init__.py` **НЕ создавать** — конфликтует со stdlib `site.py` (Nubes HowTo).
+> ⛔ Импорты бэка — всегда без префикса `site.` (запуск `python site/app.py` добавляет `site/` в path).
+> ⛔ Без factory pattern — платформа ждёт `app` на уровне модуля.
+
+---
+
+## 3. Слой 0 — конфиг (`config.json` / `config.example.json`)
+
+```json
+{
+ "vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
+ "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
+ "maxFileBytes": 52428800,
+ "maxSessionBytes": 524288000,
+ "apiPrefix": "/api",
+ "pullRetries": 3,
+ "pullRetryDelay": 2
+}
+```
+
+Каждое поле:
+- `vmUploadUrl` — префикс ВМ-буфера (для PUT и SSRF-валидации pull).
+- `allowedExt` — допустимые расширения (фильтр и в ZIP, и в папке).
+- `maxFileBytes` / `maxSessionBytes` — лимиты.
+- `apiPrefix` — префикс маршрутов бэка.
+- `pullRetries` / `pullRetryDelay` — ретраи pull (защита от разовых DNS/сетевых сбоев).
+
+В бэке конфиг читается из `os.getenv` (на Nubes — через `jsonEnv`), значения по умолчанию —
+из `config.example.json`. Никаких drhider-специфичных значений в коде слоёв.
+
+---
+
+## 4. Слой 1 — выбор файлов (фронт)
+
+### Поведение (1:1 с drhider)
+- Выбор файлов (``).
+- Выбор папки (`webkitdirectory`) → рекурсивный обход, в таблице виден относительный путь.
+- `.zip` → раскрытие через `fflate.unzip` **с `filter` по `allowedExt`** (не распаковывать
+ не-doc), рекурсивно по вложенным `.zip`; путь в таблице — `Папка/архив.zip/Внутри/файл.txt`.
+- Дедуп по «путь+размер»; имя совпало, размер другой → суффикс `_2`.
+- Лимиты: файл > `maxFileBytes` → «не учитывается»; сумма учитываемых > `maxSessionBytes` → тоже.
+- Имена экранируются через `esc()` (self-XSS).
+- Кнопка загрузки disabled при 0 учитываемых файлов; UI замораживается во время загрузки.
+
+### Интерфейс `initUploadTable(cfg) -> api`
+
+```
+cfg = {
+ allowedExt, maxFileBytes, maxSessionBytes,
+ fileInputEl, folderInputEl, tableBodyEl, countEl, uploadBtnEl,
+ onFilesChanged(list)
+}
+api = {
+ pickFiles(), pickFolder(),
+ addFiles(File[]), // дедуп + раскрытие zip + фильтр + лимиты
+ getFiles() -> [{path,name,size,file}], // ТОЛЬКО учитываемые
+ getOverNames() -> Set,
+ setStatus(path, html),
+ render(), clear()
+}
+```
+
+Состояние (`sf`, `fileMeta`, `overNames`) — внутри `init_upload_table.js`, наружу не торчит.
+
+### Файлы и их функции
+| Файл | Функция | Примечание |
+|---|---|---|
+| `zip/list_zip_files.js` | `listZipFiles(file, allowedExt)` | `fflate.unzip` + filter + рекурсия |
+| `table/fs.js` | `fs(b)` | формат размера |
+| `table/esc.js` | `esc(s)` | экранирование HTML |
+| `table/add_file_with_dedup.js` | `addFileWithDedup(file)` | дедуп + лимиты |
+| `table/render.js` | `render()` | обычная таблица |
+| `table/set_status.js` | `setStatus(path, html)` | статус в ячейке |
+| `table/on_files_change.js` | обработчик file input | |
+| `table/on_folder_change.js` | обработчик webkitdirectory | |
+| `table/init_upload_table.js` | `initUploadTable(cfg)` | сборка слоя 1 |
+
+> Дерево (сворачивание по папкам/архивам) — **фаза 2**, см. `PLAN-file-picker.md` (в drhider).
+> В первой итерации — плоская таблица с колонкой «путь», как сейчас в drhider.
+
+---
+
+## 5. Слой 2 — закачка через ВМ
+
+### Фронт: `uploadViaVM(files, vmUploadUrl) -> Promise<{ok, session, count, error?}>`
+
+```
+1. token = crypto.randomUUID()
+2. для каждого файла: PUT vmUploadUrl + token + '_' + k (XHR, progress -> setStatus)
+ refs.push({name, size, url})
+3. POST /api/upload_refs {session, files: refs}
+ -> {ok, session, count} | {ok:false, error}
+```
+
+- `put_to_vm.js` — `putToVm(file, url, onProgress)` (XHR, сырое тело, прогресс в ячейку).
+- `upload_via_vm.js` — `uploadViaVM(files, vmUploadUrl)`.
+- SSE-часть (слой 3) сюда НЕ входит — возвращаем `{ok, session, count}`, дальше приложение само.
+
+### Бэк: `create_upload_refs_blueprint(cfg) -> Blueprint`
+
+```
+POST /api/upload_refs {session?, files:[{name,size,url}]}
+ - name = _safe_name(name) (path traversal)
+ - url.startswith(vmUploadUrl) (SSRF)
+ - size > maxFileBytes -> delete+skip
+ - pull: pullRetries × (пауза pullRetryDelay), stream, лимит maxFileBytes
+ - len(content) > maxFileBytes -> delete+skip
+ - add_file: нет сессии -> 404; лимит сессии -> skip
+ - delete url (best-effort)
+ -> {ok, session, count}
+```
+
+- `upload_refs/config.py` — ретраи/префикс из конфига.
+- `upload_refs/safe_name.py` — `safe_name(name)` (path traversal, сохраняет подпапки).
+- `upload_refs/pull_file.py` — `pull_file(client, url)` (ретраи, stream, лимит).
+- `upload_refs/blueprint.py` — `create_upload_refs_blueprint(cfg)`.
+
+### Сессия (`backend/session/`)
+Самодостаточный модуль, дробится на файл-на-функцию. Общее состояние (`_sessions`, `_lock`) — в
+`state.py`; операции импортируют `state`.
+
+- `create_session.py` — `create_session()`
+- `add_file.py` — `add_file(sid, name, content)` → bool (лимит)
+- `get_files.py` — `get_files(sid)`, `file_count(sid)`
+- `cleanup.py` — `cleanup(sid)`
+- (при необходимости позже: `store_result`, `store_csv`, `ttl`, `cancel`)
+
+---
+
+## 6. Слой 3 — стаб (демо-обёртка `site/`)
+
+- `site/app.py` — `app = Flask(...)`, `@app.route("/")` → `index.html`, `@app.route("/health")` → `"ok", 200`,
+ `if __name__ == "__main__": app.run(debug=False, host="0.0.0.0", port=5000)`.
+- `site/routes/api_bp.py` — регистрирует `create_upload_refs_blueprint(cfg)`, добавляет демо-маршрут
+ `POST /api/process` → возвращает список файлов сессии (имя + размер) — **доказательство**, что
+ слой 2 довёз файлы; в реальном проекте здесь будет своя обработка.
+- `site/templates/index.html` — `