feat(sim-builder): фаза 3 — БД custom_sims + CRUD API с валидацией спеки и проверкой владения

This commit is contained in:
Maxim Dolgolyov
2026-06-13 12:10:02 +03:00
parent 572d479f12
commit 014c96db1e
10 changed files with 697 additions and 24 deletions
+12 -3
View File
@@ -1,6 +1,12 @@
# Feature Context: Конструктор симуляций (SimForge)
## Current State
- **Фаза 3 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только backend + клиент `js/api.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
- **Миграция 071** `backend/src/db/migrations/071_custom_sims.sql` — таблица `custom_sims` (применена к живой БД через `npm run migrate`, без ошибок).
- **API `/api/custom-sims`** (роутер `backend/src/routes/customSims.js`, контроллер `backend/src/controllers/customSimController.js`, смонтировано в `server.js`): GET `/` (свои+published), GET `/:id` (own ИЛИ published), POST `/` (teacher/admin), PUT `/:id` (owner/admin), DELETE `/:id` (owner/admin). Read — router-level authMiddleware; мутации — inline requireRole + per-row ownership.
- **`validateSpec(spec)`** в контроллере — серверная валидация БЕЗ исполнения: ≤200KB, specVersion=1, лимиты (params≤50/objects≤200/walls≤20/springs≤50/expr≤500/глубина≤8/points≤1000), whitelist типов объектов, physics (restitution 0..1, dt 1/2000..1/30, mass>0), санитизация текст-полей (escape &<>). Возврат `{ ok, error?, clean? }`.
- **Клиент** `js/api.js`: `customSimsList/Get/Create/Update/Delete``req(...)`, добавлены в `window.LS`.
- Верификация: `node --check` всех новых/изменённых .js OK; `npm run migrate` OK; `npm run lint:routes` чисто (0 unprotected, baseline 0); `backend/tests/custom-sims.test.js` 24/24 pass; общий suite 201/209 (8 fail = 3 baseline auth.test.js + 5 page-тестов без devDep `jsdom` — окружение, не моя фаза). Эмодзи нет; БД через node:sqlite.
- **Фаза 2 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только `_sim_engine.js` + `_sim_demo.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
- **Физический режим**: блок `physics:{ enabled, gravity:{x,y}, friction?, restitution?, dt?, walls?:[...], springs?:[...] }` + `body:{ mass, vx, vy, fixed }` на point/circle. Фикс-шаговый полу-неявный Эйлер (накопитель dt, кламп шага/скорости), опора на математику `_fx_motion.spring`. Упругие столкновения круг-круг и круг-стена (restitution), пружины (Гук+демпф) между телами/якорями. Drag тел (тащишь — позиция, отпускаешь — бросок со скоростью). Тела сосуществуют с формульными объектами Ф0/Ф1.
- **env-поля тел**: `<id>.x/.y/.vx/.vy` берутся из СОСТОЯНИЯ интегратора и кладутся в env первыми — снимает forward-ref проблему однопроходного env для тел.
@@ -53,9 +59,12 @@
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
## RESUME STATE
- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 2 — Physics (✅ Implemented, pending commit) → дальше Phase 3Persistence + API
- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 3 — Persistence + API (✅ Implemented, pending commit) → дальше Phase 4Builder UI
- Режим: Automated / Orchestrator / Incremental
- **Номер миграции Ф3: 071** (`071_custom_sims.sql`); следующая свободная — 072.
- Новые публичные API для следующих фаз: `window.SimExpr`, `window.SimEngine.mount`, `window.SimPhysics` (step/integrate/resolveCollisions), `window.registerSpecSim` / `window.SimAdapter`. Формат спеки v1 + типы plot/readout/drag/vector + блок `physics`/`body`/`springs`/`walls` — в шапке `_sim_engine.js` и в handoff phase-0/1/2.
- **API персистентности (Ф3)**: `/api/custom-sims` (GET `/`, GET/PUT/DELETE `/:id`, POST `/`) + клиент `LS.customSimsList/Get/Create/Update/Delete`. Контракт спеки на вход/санитизация — в handoff phase-3.
- Файлы Ф2 (несведённые с параллельной сессией): `frontend/js/labs/_sim_engine.js`, `frontend/js/labs/_sim_demo.js`.
- Для Ф3 сериализовать/валидировать: блок `physics` (gravity x/y, friction, restitution, dt, walls[], springs[{a,b,k,length,damping}]) и `body{mass,vx,vy,fixed}` на объектах; строки-выражения санитизировать как x/y/expr.
- Файлы Ф3: `backend/src/db/migrations/071_custom_sims.sql`, `backend/src/controllers/customSimController.js`, `backend/src/routes/customSims.js`, `backend/tests/custom-sims.test.js` (new); `backend/src/server.js`, `js/api.js` (точечные добавления). lab.html/lab-glue.js НЕ тронуты.
- Для Ф4 (билдер): слать/получать спеку через `LS.customSimCreate/Update/Get`; сервер вернёт спеку санитизированной (escaped-текст). Лимиты/коды 400 — см. handoff phase-3.
+2 -2
View File
@@ -42,7 +42,7 @@
- [x] Phase 0: Спека v1 + рантайм (формульные сцены) [domain: frontend] → [subplan](./phase-0-runtime-core.md)
- [x] Phase 1: Графики + интеракции [domain: frontend] → [subplan](./phase-1-plots-interactions.md)
- [x] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md)
- [ ] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md)
- [x] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md)
- [ ] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
- [ ] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md)
- [ ] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md)
@@ -55,7 +55,7 @@
| Phase 0: Runtime core | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 1: Plots & interactions | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 2: Physics | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 3: Persistence + API | backend | ⬜ Not Started | | | |
| Phase 3: Persistence + API | backend | ✅ Done | | | |
| Phase 4: Builder UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Catalog | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: Sharing | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+64 -19
View File
@@ -1,6 +1,6 @@
# Phase 3: БД + API (custom_sims)
**Status:** ⬜ Not Started
**Status:** ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
@@ -9,19 +9,27 @@
серверная валидация спеки. После фазы спека сохраняется/грузится/удаляется через API.
## Tasks
- [ ] Миграция `backend/src/db/migrations/0NN_custom_sims.sql` (следующий свободный номер):
таблица `custom_sims` (id, owner_id FK users ON DELETE CASCADE, title, description, subject,
grade, cat, spec_json TEXT, status TEXT 'draft|published' DEFAULT 'draft', version INT,
created_at, updated_at) + индекс по owner_id, status.
- [ ] Контроллер `backend/src/controllers/customSimController.js`: list (own + published), get,
create, update, remove. Владение проверяется на mutate (owner или admin).
- [ ] Серверная валидация спеки `validateSpec(spec)`: размер JSON, `specVersion`, лимиты
(число params/objects, глубина строк-выражений), типы объектов из whitelist, строки-подписи
обрезаются/санитизируются. Отказ с 400 при нарушении. ⛔ Никакого исполнения спеки на сервере.
- [ ] Роуты `backend/src/routes/customSims.js`: `GET /api/custom-sims` (свои+published),
`GET /api/custom-sims/:id`, `POST /` (teacher/admin), `PUT /:id`, `DELETE /:id`. Смонтировать в server.js под authMiddleware + фича-гейт при необходимости.
- [ ] Клиент `js/api.js`: `customSimsList/Get/Create/Update/Delete` + добавить в `window.LS`.
- [ ] Тесты `backend/tests/custom-sims.test.js`: CRUD, ownership (403 чужой), валидация (400 кривая спека). Использовать `seedRow()` паттерн (устойчив к дрейфу схемы).
- [x] Миграция `backend/src/db/migrations/071_custom_sims.sql`: таблица `custom_sims`
(id PK AUTOINCREMENT, owner_id FK users ON DELETE CASCADE, title, description, subject,
grade, cat, spec_json TEXT NOT NULL, status TEXT 'draft|published' DEFAULT 'draft',
version INT DEFAULT 1, created_at, updated_at) + индексы idx_custom_sims_owner / _status.
Идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS).
- [x] Контроллер `backend/src/controllers/customSimController.js`: list (own + чужой published),
get (own ИЛИ published), create (через роут teacher/admin), update, remove. Владение
проверяется на каждой мутации (owner ИЛИ admin → иначе 403; нет строки → 404). version++ на update.
- [x] Серверная валидация спеки `validateSpec(spec)`: размер JSON (≤200KB), `specVersion`=1,
лимиты (params≤50, objects≤200, walls≤20, springs≤50, expr≤500 симв., глубина≤8, points≤1000),
типы объектов из whitelist (point|segment|vector|circle|rect|polyline|path|label|plot|readout),
physics (restitution 0..1, dt 1/2000..1/30, body.mass>0), строки-подписи (text/label/unit/meta/
drag.param/param.label) санитизируются как текст (escape &<>, обрезка). Возврат {ok,error?,clean?}.
⛔ Спека НЕ исполняется на сервере (нет eval/Function/SimExpr).
- [x] Роуты `backend/src/routes/customSims.js`: router-level `authMiddleware` (read auth-only);
`GET /` + `GET /:id` (видимость в хендлере); `POST /`, `PUT /:id`, `DELETE /:id` — inline
`requireRole('teacher','admin')`. Смонтировано в server.js: `app.use('/api/custom-sims', ...)`.
- [x] Клиент `js/api.js`: `customSimsList/Get/Create/Update/Delete``req(...)` + добавлены в `window.LS`.
- [x] Тесты `backend/tests/custom-sims.test.js` (24/24 pass): CRUD happy-path, ownership (чужой
PUT/DELETE → 403), get чужой draft → 403, get чужой published → 200, admin override, 404,
validateSpec (12 кейсов отказа 400), санитизация. Монтирует роут на shared test-app (как lab-links).
## Files to Modify/Create
- `backend/src/db/migrations/0NN_custom_sims.sql` (new)
@@ -42,10 +50,47 @@
- Не делать blanket `router.use(requireRole('admin'))` — read-роуты auth-only, мутации — inline requireRole.
## Review Checklist
- [ ] Все задачи выполнены
- [ ] Ownership и валидация спеки покрыты тестами
- [ ] Миграция идемпотентна (IF NOT EXISTS / INSERT OR IGNORE где надо)
- [ ] `npm run lint:routes` чисто; тесты в пределах baseline
- [x] Все задачи выполнены
- [x] Ownership и валидация спеки покрыты тестами (24/24)
- [x] Миграция идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS)
- [x] `npm run lint:routes` чисто (0 unprotected, baseline 0); тесты в пределах baseline
## Handoff to Next Phase
<!-- Заполняет реализатор -->
### Что реализовано (Phase 3)
- **Миграция 071** `custom_sims` (применена к живой БД `npm run migrate` без ошибок).
- **API `/api/custom-sims`** (смонтировано в `backend/src/server.js` после `/api/materials`):
| Метод | Путь | Доступ | Ответ |
|-------|------|--------|-------|
| GET | `/api/custom-sims` | auth | `{ sims:[{id,owner_id,title,description,subject,grade,cat,status,version,created_at,updated_at}] }` (свои любого статуса + чужие published; БЕЗ spec) |
| GET | `/api/custom-sims/:id` | auth (own ИЛИ published) | `{ sim:{...meta, spec} }` (spec уже распарсен из JSON); чужой draft → 403, нет → 404 |
| POST | `/api/custom-sims` | teacher/admin | `201 { id }`; принимает `{ title?, description?, subject?, grade?, cat?, status?, spec }`; кривая спека → 400 |
| PUT | `/api/custom-sims/:id` | owner/admin | `200 { ok:true }`; любое поле опц.; `spec` валидируется заново и version++; 403/404 |
| DELETE | `/api/custom-sims/:id` | owner/admin | `200 { ok:true }`; 403/404 |
- **Клиентские методы** (`js/api.js``window.LS`): `customSimsList()`, `customSimGet(id)`,
`customSimCreate(data)`, `customSimUpdate(id, data)`, `customSimDelete(id)`.
### Форма записи `custom_sims`
`id`, `owner_id`(FK users CASCADE), `title`, `description`, `subject`, `grade`(INT), `cat`
(math|phys|chem|bio|game|NULL), `spec_json`(TEXT — валидированный JSON), `status`(draft|published,
деф. draft), `version`(INT, ++ на каждом update со spec), `created_at`, `updated_at`.
### validateSpec — контракт для Ф4 (билдер)
Билдер должен слать в `spec` объект формата v1 (specVersion:1, meta, viewport, params[], objects[],
physics?). Сервер вернёт **400** при: spec не объект; specVersion≠1; >200KB JSON; глубина >8;
params>50; objects>200; walls>20; springs>50; строка-выражение >500 симв.; недопустимый type
объекта (вне whitelist point|segment|vector|circle|rect|polyline|path|label|plot|readout);
restitution вне 0..1; dt вне 1/2000..1/30; body.mass≤0. Текстовые поля (`text`,`label`,`unit`,
`meta.title/desc`, `param.label/unit/name`, `drag.param/paramY`, `id`) **обрезаются и
экранируются** (`& < >` → entities) — билдер получит обратно очищенную спеку в `GET /:id`.
⚠️ Не использовать имя param `e` (зарезервировано в SimExpr — см. Ф2).
### На Ф4 (билдер: какие поля шлёт/получает)
- **Создание**: `LS.customSimCreate({ title, description?, subject?, grade?, cat?, status?, spec })`
`{ id }`. После — `LS.customSimUpdate(id, { spec, status:'published' })` для публикации.
- **Загрузка для редактирования**: `LS.customSimGet(id)``{ sim }`, где `sim.spec` — готовый
объект для монтирования в `SimEngine.mount(host, sim.spec)`.
- **Список «мои/опубликованные»**: `LS.customSimsList()``{ sims }` (метаданные без spec; spec
тянуть лениво по `customSimGet`).
- Билдер должен учитывать, что сервер вернёт спеку **санитизированной** (escaped-текст) — для
KaTeX/canvas это безопасно, но при сравнении «до/после» учитывать экранирование.