diff --git a/PLAN-upload-layers.md b/PLAN-upload-layers.md
new file mode 100644
index 0000000..09dd10f
--- /dev/null
+++ b/PLAN-upload-layers.md
@@ -0,0 +1,290 @@
+# ПЛАН: вынос 2 слоёв загрузки-через-ВМ в переиспользуемый модуль
+
+> Для Flash. Источник-референс: drhider v0.0.75 (`site/templates/index.html`,
+> `site/routes/api_bp.py`, `site/session.py`). Цель — **1:1** по поведению, но в виде
+> **переиспользуемого программного слоя ВНУТРИ приложения** (не отдельного сервиса):
+> его встраивает и drhider, и любые другие проекты.
+
+---
+
+## Требования (согласовано с владельцем)
+
+1. **2 слоя структурно выделены** — их можно взять и перенести в другой проект
+ **без изменения кода** (только свой конфиг).
+2. **Без общей репы** — модуль = подпапка внутри проекта, перенос копированием.
+3. **Не мудрить** — главное: агент в новом проекте за 2 минуты понял, как интегрировать
+ (для этого короткий `README.md` внутри модуля).
+4. **НЕ поломать текущий рабочий код drhider** — это рефакторинг 1:1 (extract, не rewrite).
+5. **Файлы мелкие** — каждая функция в отдельном файле.
+
+---
+
+## 0. Контекст (что уже проверено в drhider)
+
+Паттерн загрузки (шлюз managed-кластера рвёт тела >64КБ, egress не ограничен):
+браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` (pull) → сессия.
+
+Что НЕЛЬЗЯ потерять при переносе (накоплено и отлажено):
+- дедуп «имя+размер», иначе суффикс `_2`;
+- раскрытие ZIP с путями, фильтр расширений (`.pdf .doc .docx .txt .md`);
+- рекурсивный обход папки (`webkitdirectory`), путь в таблице;
+- **SSRF-валидация** URL (`url.startswith(VM_UPLOAD_PREFIX)`);
+- **`_safe_name`** (path traversal, сохраняет подпапки);
+- **ретраи pull** (3 попытки, пауза 2 с);
+- лимиты 50 МБ/файл, 500 МБ/сессия;
+- различение «сессия не найдена» vs «лимит»;
+- статусы «пропущен (лимит)» / «не извлечён»;
+- `esc()` для имён (self-XSS).
+
+---
+
+## 1. Граница слоёв
+
+| Слой | Где | Ответственность | drhider-источник |
+|---|---|---|---|
+| **0. Конфиг** | общий | `VM_UPLOAD_URL`, `allowedExt`, лимиты | константы в `index.html`/`api_bp.py` |
+| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки | `index.html` |
+| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ (фронт) → `upload_refs` pull (бэк) → сессия | `index.html` + `api_bp.py` + `session.py` |
+| **3. Логика приложения** | бэк | обфускация/обработка, SSE-прогресс | `drhider/*`, `process_stream` — **НЕ выносим** |
+
+**Слой 3 НЕ выносим** — он у каждого приложения свой. В drhider остаётся: `drhider/*`
+(extractor/scanner/replacer/builder/obfuscator), `process_stream`, `process`, `download`,
+`csv_download`, `cancel`.
+
+**Всё, что у drhider своё (URL, расширения, лимиты) — в конфиг (слой 0), а не в код слоя.**
+
+---
+
+## 2. Структура модуля (каждая функция — отдельный файл)
+
+```
+upload/
+ README.md # ← главное: как интегрировать (для агента)
+ config.example.json # слой 0: образец конфига
+
+ frontend/
+ zip/ # распаковка ZIP (чистые функции, без DOM)
+ dos_to_ms.js # dosToMs(date, time)
+ decode_zip_name.js # decodeZipName(bytes, isUtf8)
+ inflate_raw.js # inflateRaw(bytes)
+ parse_zip.js # parseZip(buf)
+ list_zip_files.js # listZipFiles(file) — рекурсия по вложенным zip
+
+ table/ # слой 1: выбор файлов
+ fs.js # fs(b) — формат размера
+ fmt_sec.js # fmtSec(s) — формат секунд
+ esc.js # esc(s) — экранирование HTML
+ est_for_file.js # estForFile(f) + EST_MB_SEC (из конфига)
+ add_file_with_dedup.js # addFileWithDedup(file)
+ render.js # rr() — обычная таблица
+ proc_row.js # procRow(i, stTxt)
+ render_proc_table.js # renderProcTable() — 3-секционная таблица
+ rm_file.js # rm(i) — удаление строки
+ set_status.js # ss(idx, h) — статус в ячейке
+ on_files_change.js # обработчик change
+ on_folder_change.js # обработчик webkitdirectory change
+ init_upload_table.js # initUploadTable(cfg) — сборка слоя 1 (состояние внутри)
+
+ upload/ # слой 2 (фронт): закачка через ВМ
+ put_to_vm.js # PUT одного файла (XHR, progress → setStatus)
+ upload_via_vm.js # uploadViaVM(files, vmUploadUrl) — цикл PUT + POST upload_refs
+
+ backend/
+ upload_refs/ # слой 2 (бэк): pull + валидация
+ __init__.py
+ config.py # PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX (из конфига)
+ safe_name.py # _safe_name(name)
+ pull_file.py # pull с ретраями (3×2с, stream, лимит 50МБ)
+ blueprint.py # create_upload_refs_blueprint(cfg) → Blueprint
+ session/
+ __init__.py
+ state.py # _sessions, _lock, _start_timer (общее состояние)
+ create_session.py # create_session()
+ add_file.py # add_file(sid, name, content)
+ get_files.py # get_files(sid), file_count(sid)
+ store_result.py # store_result(sid, zip), get_result(sid)
+ store_csv.py # store_csv(sid, csv), get_csv(sid)
+ ttl.py # touch, pause_ttl, resume_ttl
+ cancel.py # request_cancel, get_cancel_event
+ cleanup.py # cleanup(sid)
+```
+
+> Примечание по `backend/session/`: там **общее глобальное состояние** (`_sessions`, `_lock`).
+> Поэтому `state.py` выделен отдельно, а каждая операция — свой файл, импортирующий `state`.
+> Дробить `state.py` на файл-на-переменную не нужно — это данные, а не функции.
+
+---
+
+## 3. Маппинг: что откуда взять (v0.0.75, имена функций точные)
+
+Переносится **без изменения логики**. Меняется только способ обращения:
+глобальные переменные → параметры/состояние модуля.
+
+### Фронт (`site/templates/index.html`)
+
+| Функция в drhider | → файл модуля | Что меняется при переносе |
+|---|---|---|
+| `dosToMs` | `zip/dos_to_ms.js` | ничего (чистая) |
+| `decodeZipName` | `zip/decode_zip_name.js` | ничего |
+| `inflateRaw` | `zip/inflate_raw.js` | ничего |
+| `parseZip` | `zip/parse_zip.js` | ничего |
+| `listZipFiles` | `zip/list_zip_files.js` | `allowedExt` → параметр (сейчас захардкожен внутри) |
+| `fs` | `table/fs.js` | ничего |
+| `fmtSec` | `table/fmt_sec.js` | ничего |
+| `esc` | `table/esc.js` | ничего |
+| `estForFile` | `table/est_for_file.js` | `EST_MB_SEC` → конфиг |
+| `addFileWithDedup` | `table/add_file_with_dedup.js` | `MAX_FILE_BYTES`/`MAX_SESSION_BYTES` → конфиг; `sf`/`fileMeta`/`overNames` → состояние модуля |
+| `rr` | `table/render.js` | доступ к `sf`/`overNames` через состояние |
+| `procRow` | `table/proc_row.js` | ничего (чистая отрисовка) |
+| `renderProcTable` | `table/render_proc_table.js` | `procState`/`procExtractRate` → параметры/состояние |
+| `rm` | `table/rm_file.js` | `busy`, `fi.files` → состояние/колбэк |
+| `ss` | `table/set_status.js` | ничего |
+| `fi` change handler | `table/on_files_change.js` | вызывается из `initUploadTable` |
+| `folderInput` change handler | `table/on_folder_change.js` | вызывается из `initUploadTable` |
+| PUT-цикл из `uploadFiles()` | `upload/put_to_vm.js` | `VM_UPLOAD_URL` → параметр; `ss` → колбэк прогресса |
+| `uploadFiles()` фазы 1+1b | `upload/upload_via_vm.js` | **только** PUT + `POST upload_refs`; SSE-часть остаётся в drhider |
+
+> **Граница слоя 2/3:** `uploadFiles()` сейчас смешанный — в нём и закачка (слой 2), и SSE (слой 3).
+> При выносе разделить: `uploadViaVM(files, vmUploadUrl)` делает PUT + POST `/api/upload_refs`
+> и возвращает `{ok, session, count}`; drhider после этого запускает **свою** SSE-обработку.
+
+### Бэк (`site/routes/api_bp.py`, `site/session.py`)
+
+| Сейчас | → модуль | Что меняется |
+|---|---|---|
+| `PULL_RETRIES`, `PULL_RETRY_DELAY`, `VM_UPLOAD_PREFIX` | `upload_refs/config.py` | читаются из конфига |
+| `_safe_name` | `upload_refs/safe_name.py` | ничего |
+| тело `upload_refs` (цикл pull) | `upload_refs/pull_file.py` | вынести ретраи/стрим/лимит в `pull_file(client, url)` |
+| endpoint `upload_refs` | `upload_refs/blueprint.py` | `create_upload_refs_blueprint(cfg)` — Blueprint с префиксом из конфига |
+| `session.py` целиком | `backend/session/*` | дробление на файл-на-функцию (1:1) |
+
+`session.py` переносится **1:1** — это самодостаточный модуль, просто дробится на функции.
+
+---
+
+## 4. Интерфейсы (точные сигнатуры)
+
+### Слой 1 — `initUploadTable(cfg) -> api`
+
+```
+cfg = {
+ allowedExt: ['.pdf','.doc','.docx','.txt','.md'],
+ maxFileBytes: 50*1024*1024,
+ maxSessionBytes: 500*1024*1024,
+ fileInputEl, folderInputEl, tableBodyEl, countEl, uploadBtnEl,
+ onFilesChanged(list) // колбэк
+}
+api = {
+ pickFiles(), pickFolder(), // привязка к
+ addFiles(File[]), // дедуп+раскрытие zip+фильтр+лимиты
+ getFiles() -> [{name,size,file}], // ТОЛЬКО учитываемые (без over)
+ getOverNames() -> Set, // сверх лимита
+ setStatus(name, html), // статус в ячейке
+ render(), clear()
+}
+```
+
+### Слой 2 — фронт `uploadViaVM(files, vmUploadUrl)`
+
+```
+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}
+```
+
+### Слой 2 — бэк `create_upload_refs_blueprint(cfg)`
+
+```
+POST /api/upload_refs {session?, files:[{name,size,url}]}
+ - name = _safe_name(name) (path traversal)
+ - url.startswith(VM_UPLOAD_PREFIX) (SSRF)
+ - size > 50МБ -> delete+skip
+ - pull: 3 ретрая, пауза 2с
+ - len(content) > 50МБ -> delete+skip
+ - add_file: нет сессии -> 404; лимит 500МБ -> skip
+ - delete url (best-effort)
+ -> {ok, session, count}
+```
+
+---
+
+## 5. Конфиг (слой 0)
+
+```json
+{
+ "vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
+ "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
+ "maxFileBytes": 52428800,
+ "maxSessionBytes": 524288000,
+ "apiPrefix": "/api",
+ "pullRetries": 3,
+ "pullRetryDelay": 2
+}
+```
+
+Всё, что отличает drhider от другого проекта, задаётся **здесь**. Код слоёв не содержит
+ни одного drhider-специфичного значения.
+
+---
+
+## 6. Порядок работы для Flash (по шагам, после каждого — проверка)
+
+Строго последовательно, чтобы не сломать прод:
+
+1. **`zip/*`** — вынести 5 чистых функций, написать юнит-тест (кириллица, вложенный zip,
+ не-doc расширения). Самый изолированный кусок — безопасен.
+2. **`table/*`** (слой 1) — вынести функции таблицы/выбора, состояние `sf`/`fileMeta`/`overNames`
+ увести внутрь `init_upload_table.js`.
+3. **`upload/*`** (слой 2 фронт) — PUT + `upload_refs`, параметризовать `vmUploadUrl`.
+4. **`backend/upload_refs/*`** + **`backend/session/*`** — вынести `_safe_name`, pull, Blueprint, сессию.
+5. **Подключить модуль обратно в drhider** — заменить встроенный код вызовами модуля,
+ **по одному куску**, прогоняя тесты после каждого. Слой 3 не трогать.
+6. **`README.md`** модуля — короткая инструкция для агента нового проекта (раздел 8).
+
+---
+
+## 7. Обязательный чек-лист приёмки (без этого «1:1» не достичь)
+
+- [ ] дедуп имя+размер, суффикс `_2` при другом размере;
+- [ ] рекурсивный обход папки, путь (в т.ч. из архива) в таблице;
+- [ ] раскрытие ZIP только документов, вложенные ZIP;
+- [ ] SSRF-валидация URL, `_safe_name`, ретраи pull — на месте;
+- [ ] лимиты 50/500 МБ, статусы «пропущен (лимит)» и «не извлечён»;
+- [ ] `esc()` имён (нет XSS);
+- [ ] кнопка загрузки disabled при 0 файлов;
+- [ ] заморозка UI во время загрузки/обработки;
+- [ ] прогресс загрузки (PUT) в таблице;
+- [ ] поведение drhider на проде не изменилось.
+
+---
+
+## 8. Что писать в `README.md` модуля (главное для агента нового проекта)
+
+1. **Что это** — 2 слоя и их ответственность (таблицей из раздела 1).
+2. **Как подключить фронт** — подключить `zip/*`, `table/*`, `upload/*` как ES-модули
+ (`