feat(sim-builder): фаза 3 — БД custom_sims + CRUD API с валидацией спеки и проверкой владения
This commit is contained in:
@@ -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(<guard>)`); 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 не установлен) — оба класса не связаны с бэкенд-фазой.
|
||||
|
||||
@@ -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, '<').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 };
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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) ── */
|
||||
|
||||
@@ -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: '<img src=x onerror=alert(1)>' }],
|
||||
};
|
||||
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('<img'), 'angle brackets escaped');
|
||||
assert.ok(txt.includes('<img'), 'escaped form present');
|
||||
});
|
||||
|
||||
it('owner can DELETE own sim (then 404)', async () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 }); }
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
@@ -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 | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
@@ -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 это безопасно, но при сравнении «до/после» учитывать экранирование.
|
||||
|
||||
Reference in New Issue
Block a user