From 014c96db1e89fdca7edabf6ab6ffb41201b2c25f Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 13 Jun 2026 12:10:02 +0300 Subject: [PATCH] =?UTF-8?q?feat(sim-builder):=20=D1=84=D0=B0=D0=B7=D0=B0?= =?UTF-8?q?=203=20=E2=80=94=20=D0=91=D0=94=20custom=5Fsims=20+=20CRUD=20AP?= =?UTF-8?q?I=20=D1=81=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=BE=D0=B9=20=D0=B2=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 9 + .../src/controllers/customSimController.js | 339 ++++++++++++++++++ backend/src/db/migrations/071_custom_sims.sql | 29 ++ backend/src/routes/customSims.js | 23 ++ backend/src/server.js | 1 + backend/tests/custom-sims.test.js | 212 +++++++++++ js/api.js | 6 + plans/sim-builder/CONTEXT.md | 15 +- plans/sim-builder/PLAN.md | 4 +- plans/sim-builder/phase-3-persistence-api.md | 83 ++++- 10 files changed, 697 insertions(+), 24 deletions(-) create mode 100644 backend/src/controllers/customSimController.js create mode 100644 backend/src/db/migrations/071_custom_sims.sql create mode 100644 backend/src/routes/customSims.js create mode 100644 backend/tests/custom-sims.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 80c4f4e..607f547 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,3 +84,12 @@ git push origin master - **Drag тела**: тело (point/circle с `body`, не fixed) перетаскиваемо по умолчанию (без `drag`-конфига). Тащишь — `body.x/y = курсор`, тело временно `fixed` в `_stepPhysics`; отпускаешь — `body.vx/vy` из сглаженной оценки скорости курсора (кламп 40 м/с). Хит-тест тела — max(16px, экранный радиус). drag-ручки Ф1 и физ-тела сосуществуют. - **Гочи**: (1) имя param **`e`** зарезервировано — это число Эйлера в SimExpr (parser проверяет CONSTANTS до env), выражение `e` даст 2.718, не значение param. Брать `el`/`elast`. (2) Радиус тела для коллизий: circle — мировой `r`; point — экранные px → мир через `_scale`, поэтому физика собирается в `reset()` ПОСЛЕ первого `_fit()`. (3) Слайдеры во время play меняют только env (readout/формулы), но НЕ силы/тела до reset (намеренно). На паузе при `t==0` — пересборка для предпросмотра старта. - Headless-тест физики: виртуальные часы (`vclock`) синхронны с `performance.now()` и таймстампом rAF (иначе первый кадр получит огромный/отрицательный dt и ничего не сдвинется). + +### Phase 3 — Learnings + +- **Персистентность**: таблица `custom_sims` (миграция **071**), API `/api/custom-sims` (контроллер `customSimController.js`, роутер `customSims.js`, смонтировано в `server.js` после `/api/materials`), клиент `LS.customSimsList/Get/Create/Update/Delete`. Спека хранится как `spec_json` TEXT(JSON); парс — только на чтение/валидацию, на сервере НЕ исполняется. `version` ++ на каждом update со `spec`. +- **`validateSpec(spec)` — серверная защита БЕЗ исполнения** (спека шарится между людьми): размер ≤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). Строки-выражения (x/y/expr/…) НЕ парсятся (это делает безопасный SimExpr на клиенте) — проверяется только длина. Текст-поля (text/label/unit/meta/param.label/drag.param/id) **обрезаются и экранируются** (`& < >` → entities). Возврат `{ ok, error?, clean? }` — в БД пишется `clean` (санитизированная). +- **Ownership-паттерн = studentMaterialsController**: read-роуты auth-only (видимость own+published решает контроллер), мутации — inline `requireRole('teacher','admin')` + per-row проверка (`owner_id === req.user.id || role==='admin'` → иначе 403; нет строки → 404). НЕ blanket `router.use(requireRole)` — иначе ученик не увидит published. +- **lint:routes (baseline 0)**: `:id`-роуты прикрыты router-level `authMiddleware` (линтер видит `router.use()`); read `GET/PUT/DELETE /:id` дополнительно помечены `// @public-by-design:` с указанием на ownership-проверку в хендлере (как в materials.js). +- **Тесты**: setup.js строит СВОЙ Express-app и НЕ монтирует новые роуты — тест-файл должен сам `app.use('/api/custom-sims', require(...))` (как lab-links.test.js). `getToken(role)`/`inject(method,path,body,token)` — готовые хелперы. seedRow(PRAGMA table_info) — для прямого посева строк, устойчив к дрейфу схемы (здесь не понадобился: пользователи создаются через getToken, спеки — через API). +- **Окружение тестов**: 8 fail из 209 = 3 baseline (`auth.test.js` — bcrypt/JWT в тест-окружении) + 5 page-тестов (`chemistry7/8-*`, `math5/6-page`), падающих на `Cannot find module 'jsdom'` (devDep не установлен) — оба класса не связаны с бэкенд-фазой. diff --git a/backend/src/controllers/customSimController.js b/backend/src/controllers/customSimController.js new file mode 100644 index 0000000..110e3ed --- /dev/null +++ b/backend/src/controllers/customSimController.js @@ -0,0 +1,339 @@ +'use strict'; +/* Custom simulations ("Конструктор симуляций" / SimForge), Фаза 3. + * + * Учитель/админ сохраняет интерактивную 2D-симуляцию как ДАННЫЕ (JSON-спека). + * CRUD под авторизацией с проверкой владения; спека валидируется на входе + * через validateSpec — БЕЗ исполнения (спека шарится между людьми, server + * не запускает движок выражений). draft видит только владелец; published — + * публичная (каталог /lab, Фаза 5). + * + * Стиль следует studentMaterialsController: node:sqlite db.prepare, + * per-row ownership на каждой мутации, статусы 400/403/404. + */ +const db = require('../db/db'); + +/* ── Лимиты валидации спеки ──────────────────────────────────────────── */ +const MAX_SPEC_BYTES = 200 * 1024; // 200 KB сериализованного JSON +const MAX_PARAMS = 50; +const MAX_OBJECTS = 200; +const MAX_WALLS = 20; +const MAX_SPRINGS = 50; +const MAX_EXPR_LEN = 500; // длина строки-выражения (x/y/expr/…) +const MAX_DEPTH = 8; // глубина вложенности JSON (анти-bomb) +const MAX_TEXT_LEN = 300; // подписи/заголовки/единицы +const MAX_POINTS = 1000; // точек в polyline/path/points + +// Типы объектов из whitelist (см. формат спеки v1 в _sim_engine.js). +const OBJECT_TYPES = new Set([ + 'point', 'segment', 'vector', 'circle', 'rect', + 'polyline', 'path', 'label', 'plot', 'readout', +]); + +const STATUSES = new Set(['draft', 'published']); +const CATS = new Set(['math', 'phys', 'chem', 'bio', 'game']); + +/* Экранирование подписей как ТЕКСТА (не HTML): спека рендерится в KaTeX/canvas, + но мы режем угловые скобки/амперсанд, чтобы исключить инъекцию при возможном + попадании строки в HTML-контекст. Также обрезаем по длине. */ +function sanitizeText(v, max = MAX_TEXT_LEN) { + if (v === null || v === undefined) return v; + let s = String(v).slice(0, max); + s = s.replace(/&/g, '&').replace(//g, '>'); + return s; +} + +/* Строка-выражение: число оставляем числом; строку обрезаем по длине, но НЕ + парсим/исполняем (это делает безопасный SimExpr на клиенте). Отклоняем + только превышение длины. */ +function checkExpr(v, label, errs) { + if (typeof v === 'string' && v.length > MAX_EXPR_LEN) { + errs.push(`${label}: выражение длиннее ${MAX_EXPR_LEN} символов`); + return false; + } + return true; +} + +/* Глубина вложенности — простая защита от «бомбы» из вложенных структур. */ +function depthOK(node, depth) { + if (depth > MAX_DEPTH) return false; + if (Array.isArray(node)) { + for (const x of node) if (!depthOK(x, depth + 1)) return false; + } else if (node && typeof node === 'object') { + for (const k of Object.keys(node)) if (!depthOK(node[k], depth + 1)) return false; + } + return true; +} + +/** + * validateSpec(spec) — серверная валидация спеки БЕЗ исполнения. + * Возвращает { ok:true, clean } с очищенной (санитизированной) спекой, + * либо { ok:false, error } для ответа 400. + */ +function validateSpec(spec) { + if (!spec || typeof spec !== 'object' || Array.isArray(spec)) { + return { ok: false, error: 'spec должна быть объектом' }; + } + + // Размер сериализованного JSON. + let json; + try { json = JSON.stringify(spec); } + catch { return { ok: false, error: 'spec не сериализуется в JSON' }; } + if (Buffer.byteLength(json, 'utf8') > MAX_SPEC_BYTES) { + return { ok: false, error: `spec превышает ${Math.round(MAX_SPEC_BYTES / 1024)} KB` }; + } + + // Глубина вложенности. + if (!depthOK(spec, 0)) return { ok: false, error: 'слишком глубокая вложенность spec' }; + + // specVersion. + if (spec.specVersion !== undefined && spec.specVersion !== 1) { + return { ok: false, error: 'неподдерживаемая specVersion (ожидается 1)' }; + } + + const errs = []; + const clean = {}; + clean.specVersion = 1; + + // meta: title/desc — текст. + if (spec.meta && typeof spec.meta === 'object') { + clean.meta = {}; + if (spec.meta.title !== undefined) clean.meta.title = sanitizeText(spec.meta.title); + if (spec.meta.desc !== undefined) clean.meta.desc = sanitizeText(spec.meta.desc, 1000); + } + + // viewport — числовые границы пропускаем как есть (числа/строки). + if (spec.viewport && typeof spec.viewport === 'object' && !Array.isArray(spec.viewport)) { + clean.viewport = spec.viewport; + } + + // time — конфиг t-цикла (autoplay/loop/duration/speed). + if (spec.time && typeof spec.time === 'object' && !Array.isArray(spec.time)) { + clean.time = spec.time; + } + + // params[] — слайдеры. + const params = Array.isArray(spec.params) ? spec.params : []; + if (params.length > MAX_PARAMS) return { ok: false, error: `params > ${MAX_PARAMS}` }; + clean.params = params.map((p, i) => { + if (!p || typeof p !== 'object') { errs.push(`params[${i}]: не объект`); return {}; } + const out = { ...p }; + if (p.label !== undefined) out.label = sanitizeText(p.label, 120); + if (p.unit !== undefined) out.unit = sanitizeText(p.unit, 40); + if (p.name !== undefined) out.name = sanitizeText(p.name, 60); + return out; + }); + + // objects[] — фигуры/подписи/графики/телá. + const objects = Array.isArray(spec.objects) ? spec.objects : []; + if (objects.length > MAX_OBJECTS) return { ok: false, error: `objects > ${MAX_OBJECTS}` }; + clean.objects = objects.map((o, i) => { + if (!o || typeof o !== 'object') { errs.push(`objects[${i}]: не объект`); return {}; } + const type = String(o.type || ''); + if (!OBJECT_TYPES.has(type)) errs.push(`objects[${i}]: недопустимый type "${type}"`); + + const out = { ...o }; + // Текстовые поля. + if (o.text !== undefined) out.text = sanitizeText(o.text, 1000); + if (o.label !== undefined) out.label = sanitizeText(o.label, 120); + if (o.unit !== undefined) out.unit = sanitizeText(o.unit, 40); + if (o.id !== undefined) out.id = sanitizeText(o.id, 60); + + // Строки-выражения: координаты/радиусы/выражения/диапазоны. + for (const k of ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'r', 'w', 'h', 'dx', 'dy', 'expr', 'size', 'width', 'precision', 'samples']) { + if (o[k] !== undefined) checkExpr(o[k], `objects[${i}].${k}`, errs); + } + + // points[] (polyline/path) — ограничиваем число точек. + if (Array.isArray(o.points) && o.points.length > MAX_POINTS) { + errs.push(`objects[${i}].points > ${MAX_POINTS}`); + } + + // body{} — физическое тело (mass/vx/vy/fixed). mass>0. + if (o.body && typeof o.body === 'object' && !Array.isArray(o.body)) { + const b = o.body; + for (const k of ['mass', 'vx', 'vy']) if (b[k] !== undefined) checkExpr(b[k], `objects[${i}].body.${k}`, errs); + if (typeof b.mass === 'number' && !(b.mass > 0)) errs.push(`objects[${i}].body.mass должна быть > 0`); + } + + // drag{} — параметр-привязка. + if (o.drag && typeof o.drag === 'object' && o.drag.param !== undefined) { + out.drag = { ...o.drag }; + out.drag.param = sanitizeText(o.drag.param, 60); + if (o.drag.paramY !== undefined) out.drag.paramY = sanitizeText(o.drag.paramY, 60); + } + return out; + }); + + // physics{} — глобальный блок сил/мира. + if (spec.physics && typeof spec.physics === 'object' && !Array.isArray(spec.physics)) { + const ph = spec.physics; + const cph = { ...ph }; + + // gravity: {x,y} — числа/выражения. + if (ph.gravity && typeof ph.gravity === 'object') { + checkExpr(ph.gravity.x, 'physics.gravity.x', errs); + checkExpr(ph.gravity.y, 'physics.gravity.y', errs); + } + // friction/restitution/dt — числа/выражения + границы для числовых. + for (const k of ['friction', 'restitution', 'dt']) if (ph[k] !== undefined) checkExpr(ph[k], `physics.${k}`, errs); + if (typeof ph.restitution === 'number' && (ph.restitution < 0 || ph.restitution > 1)) { + errs.push('physics.restitution вне диапазона 0..1'); + } + if (typeof ph.dt === 'number' && (ph.dt < 1 / 2000 || ph.dt > 1 / 30)) { + errs.push('physics.dt вне диапазона 1/2000..1/30'); + } + + // walls[] — лимит. + if (Array.isArray(ph.walls)) { + if (ph.walls.length > MAX_WALLS) return { ok: false, error: `physics.walls > ${MAX_WALLS}` }; + for (let i = 0; i < ph.walls.length; i++) { + const wl = ph.walls[i]; + if (wl && typeof wl === 'object') { + for (const k of ['x1', 'y1', 'x2', 'y2']) if (wl[k] !== undefined) checkExpr(wl[k], `physics.walls[${i}].${k}`, errs); + } + } + } + + // springs[] — лимит + поля. + if (Array.isArray(ph.springs)) { + if (ph.springs.length > MAX_SPRINGS) return { ok: false, error: `physics.springs > ${MAX_SPRINGS}` }; + for (let i = 0; i < ph.springs.length; i++) { + const sp = ph.springs[i]; + if (sp && typeof sp === 'object') { + for (const k of ['k', 'length', 'damping']) if (sp[k] !== undefined) checkExpr(sp[k], `physics.springs[${i}].${k}`, errs); + } + } + } + clean.physics = cph; + } + + if (errs.length) return { ok: false, error: errs.slice(0, 8).join('; ') }; + return { ok: true, clean }; +} + +/* ── Сериализация строки БД → ответ API ──────────────────────────────── */ +function rowToSim(row) { + if (!row) return null; + let spec = null; + try { spec = JSON.parse(row.spec_json); } catch { spec = null; } + return { + id: row.id, + owner_id: row.owner_id, + title: row.title, + description: row.description, + subject: row.subject, + grade: row.grade, + cat: row.cat, + status: row.status, + version: row.version, + spec, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/* Метаданные из body — общая нормализация для create/update. */ +function readMeta(b) { + return { + title: b.title !== undefined ? sanitizeText(b.title) : undefined, + description: b.description !== undefined ? sanitizeText(b.description, 2000) : undefined, + subject: b.subject !== undefined ? (b.subject != null ? String(b.subject).slice(0, 60) : null) : undefined, + grade: b.grade !== undefined ? (Number.isFinite(Number(b.grade)) ? Number(b.grade) : null) : undefined, + cat: b.cat !== undefined ? (CATS.has(String(b.cat)) ? String(b.cat) : null) : undefined, + }; +} + +/* GET /api/custom-sims — свои (любой статус) + чужие published. + Без выдачи spec_json в списке (тяжело); spec приходит в GET /:id. */ +function list(req, res) { + const uid = req.user.id; + const rows = db.prepare(` + SELECT id, owner_id, title, description, subject, grade, cat, status, version, created_at, updated_at + FROM custom_sims + WHERE owner_id = ? OR status = 'published' + ORDER BY updated_at DESC, created_at DESC, id DESC + `).all(uid); + res.json({ sims: rows }); +} + +/* GET /api/custom-sims/:id — свой (любой статус) ИЛИ чужой published. */ +function get(req, res) { + const row = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'not found' }); + if (row.owner_id !== req.user.id && row.status !== 'published') { + return res.status(403).json({ error: 'forbidden' }); + } + res.json({ sim: rowToSim(row) }); +} + +/* POST /api/custom-sims — создать (teacher/admin). Body: { title?, description?, + subject?, grade?, cat?, status?, spec }. */ +function create(req, res) { + const b = req.body || {}; + const v = validateSpec(b.spec); + if (!v.ok) return res.status(400).json({ error: v.error }); + + const m = readMeta(b); + const status = STATUSES.has(String(b.status)) ? String(b.status) : 'draft'; + const r = db.prepare(` + INSERT INTO custom_sims (owner_id, title, description, subject, grade, cat, spec_json, status, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1) + `).run( + req.user.id, + m.title ?? null, + m.description ?? null, + m.subject ?? null, + m.grade ?? null, + m.cat ?? null, + JSON.stringify(v.clean), + status, + ); + res.status(201).json({ id: Number(r.lastInsertRowid) }); +} + +/* PUT /api/custom-sims/:id — обновить (владелец/admin). Любое поле опционально; + spec, если передан, валидируется заново и поднимает version. */ +function update(req, res) { + const row = db.prepare('SELECT owner_id, version FROM custom_sims WHERE id = ?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'not found' }); + if (row.owner_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'forbidden' }); + } + + const b = req.body || {}; + const fields = [], args = []; + + if (b.spec !== undefined) { + const v = validateSpec(b.spec); + if (!v.ok) return res.status(400).json({ error: v.error }); + fields.push('spec_json = ?'); args.push(JSON.stringify(v.clean)); + fields.push('version = ?'); args.push(row.version + 1); + } + + const m = readMeta(b); + if (m.title !== undefined) { fields.push('title = ?'); args.push(m.title); } + if (m.description !== undefined) { fields.push('description = ?'); args.push(m.description); } + if (m.subject !== undefined) { fields.push('subject = ?'); args.push(m.subject); } + if (m.grade !== undefined) { fields.push('grade = ?'); args.push(m.grade); } + if (m.cat !== undefined) { fields.push('cat = ?'); args.push(m.cat); } + if (b.status !== undefined && STATUSES.has(String(b.status))) { fields.push('status = ?'); args.push(String(b.status)); } + + if (!fields.length) return res.json({ ok: true }); + fields.push("updated_at = datetime('now')"); + args.push(req.params.id); + db.prepare(`UPDATE custom_sims SET ${fields.join(', ')} WHERE id = ?`).run(...args); + res.json({ ok: true }); +} + +/* DELETE /api/custom-sims/:id — удалить (владелец/admin). */ +function remove(req, res) { + const row = db.prepare('SELECT owner_id FROM custom_sims WHERE id = ?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'not found' }); + if (row.owner_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'forbidden' }); + } + db.prepare('DELETE FROM custom_sims WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +} + +module.exports = { list, get, create, update, remove, validateSpec }; diff --git a/backend/src/db/migrations/071_custom_sims.sql b/backend/src/db/migrations/071_custom_sims.sql new file mode 100644 index 0000000..dada126 --- /dev/null +++ b/backend/src/db/migrations/071_custom_sims.sql @@ -0,0 +1,29 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 071: Custom simulations (Конструктор симуляций / SimForge), Фаза 3. +-- +-- Учитель/админ собирает интерактивную 2D-симуляцию из ДАННЫХ (JSON-спека: +-- params[], objects[], physics{}, …) и сохраняет её здесь. Спека хранится как +-- TEXT(JSON) в spec_json; её схема/лимиты валидируются на входе сервером +-- (validateSpec), БЕЗ исполнения. status='draft' видит только владелец; +-- status='published' — публичная (видна всем в каталоге /lab, Фаза 5). +-- +-- owner_id ON DELETE CASCADE — спеки удаляются вместе с автором. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS custom_sims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT, + description TEXT, + subject TEXT, -- курикулум (напр. 'physics') + grade INTEGER, -- класс + cat TEXT, -- категория каталога (math|phys|chem|bio|game) + spec_json TEXT NOT NULL, -- JSON-спека (данные, не код) + status TEXT NOT NULL DEFAULT 'draft', -- draft | published + version INTEGER NOT NULL DEFAULT 1, -- ++ на каждом update + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_custom_sims_owner ON custom_sims (owner_id); +CREATE INDEX IF NOT EXISTS idx_custom_sims_status ON custom_sims (status); diff --git a/backend/src/routes/customSims.js b/backend/src/routes/customSims.js new file mode 100644 index 0000000..c89b430 --- /dev/null +++ b/backend/src/routes/customSims.js @@ -0,0 +1,23 @@ +'use strict'; +/* /api/custom-sims — CRUD спек-симуляций «Конструктора симуляций» (Фаза 3). + * Read-роуты — auth-only (видимость своих + published проверяет контроллер). + * Мутации — inline requireRole('teacher','admin') + per-row ownership в хендлере. + * НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */ +const express = require('express'); +const router = express.Router(); +const { authMiddleware, requireRole } = require('../middleware/auth'); +const c = require('../controllers/customSimController'); + +router.use(authMiddleware); + +router.get('/', c.list); +// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler +router.get('/:id', c.get); + +router.post('/', requireRole('teacher', 'admin'), c.create); +// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler +router.put('/:id', requireRole('teacher', 'admin'), c.update); +// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler +router.delete('/:id', requireRole('teacher', 'admin'), c.remove); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index c66adcd..cdce198 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -196,6 +196,7 @@ app.use('/api/access', accessRoutes); app.use('/api/teacher-students', teacherStudentsRoutes); app.use('/api/lab', labRoutes); app.use('/api/materials', require('./routes/materials')); +app.use('/api/custom-sims', require('./routes/customSims')); app.use('/api/dashboard', require('./routes/dashboard')); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ diff --git a/backend/tests/custom-sims.test.js b/backend/tests/custom-sims.test.js new file mode 100644 index 0000000..bae903b --- /dev/null +++ b/backend/tests/custom-sims.test.js @@ -0,0 +1,212 @@ +'use strict'; +/** + * Integration tests: /api/custom-sims — CRUD спек-симуляций (Фаза 3). + * Covers: auth, role-gating (POST teacher/admin), CRUD happy-path, ownership + * (чужой PUT/DELETE → 403), видимость (own draft / others published), serverная + * валидация спеки (кривая/огромная → 400), version-bump на update. + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, db, inject, getToken, cleanup } = require('./setup'); + +// Mount /api/custom-sims on the shared test app (setup.js не монтирует его). +app.use('/api/custom-sims', require('../src/routes/customSims')); + +after(() => cleanup()); + +// Минимальная валидная спека (формат v1). +const VALID_SPEC = { + specVersion: 1, + meta: { title: 'Бросок' }, + viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true }, + params: [{ name: 'v', label: 'Скорость', min: 0, max: 30, step: 0.5, value: 18, unit: 'м/с' }], + objects: [ + { id: 'p', type: 'point', x: 'v*t', y: '-4.9*t*t', r: 6, color: '#06D6E0' }, + { type: 'segment', x1: 0, y1: 0, x2: 'p.x', y2: 'p.y', color: '#fff', width: 2 }, + ], + physics: { enabled: true, gravity: { x: 0, y: -9.8 }, restitution: 0.9, dt: 1 / 240 }, +}; + +describe('/api/custom-sims', () => { + let teacherToken, teacherId, otherTeacherToken, studentToken; + + before(async () => { + const t = await getToken('teacher'); + teacherToken = t.token; teacherId = t.userId; + otherTeacherToken = (await getToken('teacher')).token; + studentToken = (await getToken('student')).token; + }); + + it('GET /api/custom-sims requires auth (401 without token)', async () => { + const res = await inject('GET', '/api/custom-sims', null, null); + assert.equal(res.status, 401, `got ${res.status}`); + }); + + it('POST is role-gated: student → 403', async () => { + const res = await inject('POST', '/api/custom-sims', { spec: VALID_SPEC }, studentToken); + assert.equal(res.status, 403, `got ${res.status}`); + }); + + let simId; + it('teacher can create a sim (201) with valid spec', async () => { + const res = await inject('POST', '/api/custom-sims', + { title: 'Бросок тела', subject: 'physics', grade: 9, cat: 'phys', spec: VALID_SPEC }, teacherToken); + assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`); + assert.ok(Number.isFinite(res.body.id), 'returns numeric id'); + simId = res.body.id; + }); + + it('GET /:id returns own sim with parsed spec + metadata + version 1', async () => { + const res = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken); + assert.equal(res.status, 200, `got ${res.status}`); + const s = res.body.sim; + assert.equal(s.id, simId); + assert.equal(s.owner_id, teacherId); + assert.equal(s.title, 'Бросок тела'); + assert.equal(s.subject, 'physics'); + assert.equal(s.grade, 9); + assert.equal(s.cat, 'phys'); + assert.equal(s.status, 'draft'); + assert.equal(s.version, 1); + assert.equal(s.spec.specVersion, 1); + assert.equal(s.spec.objects.length, 2); + }); + + it('GET list returns own draft', async () => { + const res = await inject('GET', '/api/custom-sims', null, teacherToken); + assert.equal(res.status, 200, `got ${res.status}`); + assert.ok(Array.isArray(res.body.sims)); + assert.ok(res.body.sims.find(s => s.id === simId), 'own draft present'); + }); + + it("other teacher CANNOT see someone's draft in list, and GET draft → 403", async () => { + const list = await inject('GET', '/api/custom-sims', null, otherTeacherToken); + assert.ok(!list.body.sims.find(s => s.id === simId), 'draft not in other user list'); + const get = await inject('GET', `/api/custom-sims/${simId}`, null, otherTeacherToken); + assert.equal(get.status, 403, `got ${get.status}`); + }); + + it('owner PUT updates metadata + spec and bumps version', async () => { + const newSpec = { ...VALID_SPEC, meta: { title: 'Изменено' } }; + const res = await inject('PUT', `/api/custom-sims/${simId}`, + { title: 'Новое имя', status: 'published', spec: newSpec }, teacherToken); + assert.equal(res.status, 200, `got ${res.status}`); + const get = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken); + assert.equal(get.body.sim.title, 'Новое имя'); + assert.equal(get.body.sim.status, 'published'); + assert.equal(get.body.sim.version, 2, 'version bumped'); + assert.equal(get.body.sim.spec.meta.title, 'Изменено'); + }); + + it('published sim is visible to other users (list + GET)', async () => { + const list = await inject('GET', '/api/custom-sims', null, otherTeacherToken); + assert.ok(list.body.sims.find(s => s.id === simId), 'published in other user list'); + const get = await inject('GET', `/api/custom-sims/${simId}`, null, studentToken); + assert.equal(get.status, 200, 'student can read published'); + assert.equal(get.body.sim.id, simId); + }); + + it("other teacher CANNOT PUT/DELETE someone else's sim (403)", async () => { + const put = await inject('PUT', `/api/custom-sims/${simId}`, { title: 'хак' }, otherTeacherToken); + assert.equal(put.status, 403, `PUT got ${put.status}`); + const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, otherTeacherToken); + assert.equal(del.status, 403, `DELETE got ${del.status}`); + }); + + it('admin can update/delete any sim', async () => { + const adminToken = (await getToken('admin')).token; + const put = await inject('PUT', `/api/custom-sims/${simId}`, { title: 'admin edit' }, adminToken); + assert.equal(put.status, 200, `admin PUT got ${put.status}`); + }); + + it('PUT/GET unknown id → 404', async () => { + assert.equal((await inject('GET', '/api/custom-sims/999999', null, teacherToken)).status, 404); + assert.equal((await inject('PUT', '/api/custom-sims/999999', { title: 'x' }, teacherToken)).status, 404); + assert.equal((await inject('DELETE', '/api/custom-sims/999999', null, teacherToken)).status, 404); + }); + + /* ── validateSpec: отклонение кривых/огромных спек (400) ── */ + it('rejects missing spec (400)', async () => { + const res = await inject('POST', '/api/custom-sims', { title: 'нет спеки' }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects non-object spec (400)', async () => { + const res = await inject('POST', '/api/custom-sims', { spec: 'just a string' }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects wrong specVersion (400)', async () => { + const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, specVersion: 99 } }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects disallowed object type (400)', async () => { + const bad = { ...VALID_SPEC, objects: [{ type: 'eval_me', x: 1, y: 1 }] }; + const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects too many objects (400)', async () => { + const objs = Array.from({ length: 201 }, () => ({ type: 'point', x: 1, y: 1 })); + const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, objects: objs } }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects too many params (400)', async () => { + const ps = Array.from({ length: 51 }, (_, i) => ({ name: 'p' + i, min: 0, max: 1, value: 0 })); + const res = await inject('POST', '/api/custom-sims', { spec: { ...VALID_SPEC, params: ps } }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects over-long expression string (400)', async () => { + const bad = { ...VALID_SPEC, objects: [{ type: 'point', x: 'a+'.repeat(300) + '1', y: 0 }] }; + const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects physics.restitution out of range (400)', async () => { + const bad = { ...VALID_SPEC, physics: { enabled: true, restitution: 5 } }; + const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects body.mass <= 0 (400)', async () => { + const bad = { ...VALID_SPEC, objects: [{ type: 'circle', x: 0, y: 0, r: 1, body: { mass: 0 } }] }; + const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects too many springs (400)', async () => { + const springs = Array.from({ length: 51 }, () => ({ a: [0, 0], b: [1, 1], k: 40, length: 1 })); + const res = await inject('POST', '/api/custom-sims', + { spec: { ...VALID_SPEC, physics: { enabled: true, springs } } }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('rejects huge spec (>200KB) (400)', async () => { + const huge = { ...VALID_SPEC, meta: { title: 'x', desc: 'a'.repeat(300000) } }; + const res = await inject('POST', '/api/custom-sims', { spec: huge }, teacherToken); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('sanitizes label/text fields (escapes angle brackets)', async () => { + const spec = { + ...VALID_SPEC, + objects: [{ type: 'label', x: 0, y: 0, text: '' }], + }; + const create = await inject('POST', '/api/custom-sims', { spec }, teacherToken); + assert.equal(create.status, 201, `got ${create.status}`); + const get = await inject('GET', `/api/custom-sims/${create.body.id}`, null, teacherToken); + const txt = get.body.sim.spec.objects[0].text; + assert.ok(!txt.includes(' { + const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken); + assert.equal(del.status, 200, `got ${del.status}`); + const get = await inject('GET', `/api/custom-sims/${simId}`, null, teacherToken); + assert.equal(get.status, 404, 'gone after delete'); + }); +}); diff --git a/js/api.js b/js/api.js index 03f5c31..5faf84e 100644 --- a/js/api.js +++ b/js/api.js @@ -1039,6 +1039,7 @@ window.LS = { crAdminGetAllHistory, crAdminGetTeachersList, listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, + customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, @@ -1259,6 +1260,11 @@ async function getActivity() { return req('GET', '/dashboard/activit async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); } async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); } async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); } +async function customSimsList() { return req('GET', '/custom-sims'); } +async function customSimGet(id) { return req('GET', `/custom-sims/${id}`); } +async function customSimCreate(data) { return req('POST', '/custom-sims', data); } +async function customSimUpdate(id, d) { return req('PUT', `/custom-sims/${id}`, d); } +async function customSimDelete(id) { return req('DELETE', `/custom-sims/${id}`); } async function assistantContext() { return req('GET', '/assistant/context'); } async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); } async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); } diff --git a/plans/sim-builder/CONTEXT.md b/plans/sim-builder/CONTEXT.md index bc49793..378cd6b 100644 --- a/plans/sim-builder/CONTEXT.md +++ b/plans/sim-builder/CONTEXT.md @@ -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-поля тел**: `.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 3 — Persistence + API +- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 реализованы, ещё не закоммичены — ждут оркестратора) +- Текущая фаза: Phase 3 — Persistence + API (✅ Implemented, pending commit) → дальше Phase 4 — Builder 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. diff --git a/plans/sim-builder/PLAN.md b/plans/sim-builder/PLAN.md index 064941a..a8a87ea 100644 --- a/plans/sim-builder/PLAN.md +++ b/plans/sim-builder/PLAN.md @@ -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 | ⬜ | ⬜ | ⬜ | diff --git a/plans/sim-builder/phase-3-persistence-api.md b/plans/sim-builder/phase-3-persistence-api.md index 34a203e..79ad047 100644 --- a/plans/sim-builder/phase-3-persistence-api.md +++ b/plans/sim-builder/phase-3-persistence-api.md @@ -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 это безопасно, но при сравнении «до/после» учитывать экранирование.