diff --git a/backend/scripts/check-route-auth.js b/backend/scripts/check-route-auth.js index bfa62ef..4460b88 100644 --- a/backend/scripts/check-route-auth.js +++ b/backend/scripts/check-route-auth.js @@ -51,17 +51,29 @@ const GUARDS = [ ]; // Baseline: number of unprotected :id-routes. -// Reconciled 2026-06-11: drifted 56→66 via branch merges (lab-content-engine, -// red-book, exam-prep и др.) — pre-commit hook не запускается на merge, поэтому -// маршруты пришли без проверки. Это уже смерженный долг, а не новый риск. -// ONLY decrease this over time — never increase it (кроме сверки с уже смерженным). -const BASELINE = 66; +// 2026-06-11: линтер научился видеть router-level guards (router.use()), +// что убрало ложные срабатывания (admin/permissions/flashcards/… защищены на +// уровне роутера). Оставшиеся 8 публичных маршрутов (guest-доска по токену, +// справочные данные red-book, список тем) помечены @public-by-design. Долг закрыт. +// ONLY decrease this over time — never increase it. +const BASELINE = 0; function scanFile(filePath) { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split('\n'); const issues = []; + // Router-level guard: `router.use()` without a leading path string + // protects every route declared after it (same guards accepted inline). + // Find the earliest such line so those routes aren't false-flagged. + let globalGuardLine = Infinity; + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (!t.startsWith('router.use(')) continue; + if (/^router\.use\(\s*['"`]/.test(t)) continue; // path-scoped — not global + if (GUARDS.some(g => t.includes(g))) { globalGuardLine = i; break; } + } + for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); @@ -74,6 +86,9 @@ function scanFile(filePath) { if (!pathMatch) continue; if (!pathMatch[1].includes(':')) continue; + // Protected by a router-level guard declared earlier in this file + if (i > globalGuardLine) continue; + // Collect the full route call (may span multiple lines) let callText = line; let j = i + 1; diff --git a/backend/src/routes/guestClassroom.js b/backend/src/routes/guestClassroom.js index 0a50b63..a88c89e 100644 --- a/backend/src/routes/guestClassroom.js +++ b/backend/src/routes/guestClassroom.js @@ -40,6 +40,7 @@ function notifySession(sessionId, data) { } /* ── GET /api/classroom/guest/:token ─── session info (pre-join screen) */ +// @public-by-design: гостевой доступ по секретному токену (read-only доска) router.get('/:token', (req, res) => { const session = sessionByToken(req.params.token); if (!session) return res.status(404).json({ error: 'Ссылка недействительна' }); @@ -58,6 +59,7 @@ router.get('/:token', (req, res) => { }); /* ── POST /api/classroom/guest/:token/join ─── choose name, get guestId */ +// @public-by-design: гостевой доступ по секретному токену router.post('/:token/join', (req, res) => { const session = sessionByToken(req.params.token); if (!session) return res.status(404).json({ error: 'Ссылка недействительна' }); @@ -92,6 +94,7 @@ router.post('/:token/join', (req, res) => { }); /* ── GET /api/classroom/guest/:token/strokes ─── whiteboard strokes */ +// @public-by-design: гостевой доступ по секретному токену router.get('/:token/strokes', (req, res) => { const session = sessionByToken(req.params.token); if (!session) return res.status(404).json({ error: 'Ссылка недействительна' }); @@ -118,6 +121,7 @@ router.get('/:token/strokes', (req, res) => { }); /* ── GET /api/classroom/guest/:token/stream ─── SSE */ +// @public-by-design: гостевой доступ по секретному токену (SSE) router.get('/:token/stream', (req, res) => { const session = sessionByToken(req.params.token); if (!session) return res.status(404).end(); @@ -150,6 +154,7 @@ router.get('/:token/stream', (req, res) => { }); /* ── POST /api/classroom/guest/:token/leave ─── explicit goodbye */ +// @public-by-design: гостевой доступ по секретному токену router.post('/:token/leave', (req, res) => { const guestId = req.body?.guestId; const guest = guestId ? guests.get(guestId) : null; diff --git a/backend/src/routes/red-book.js b/backend/src/routes/red-book.js index 39aab3a..d63b573 100644 --- a/backend/src/routes/red-book.js +++ b/backend/src/routes/red-book.js @@ -11,7 +11,9 @@ router.get('/map-data', ctrl.getMapData); router.get('/food-web', ctrl.getFoodWeb); router.get('/daily', optionalAuth, ctrl.getDaily); router.get('/species', optionalAuth, ctrl.getSpecies); +// @public-by-design: публичные справочные данные о виде (read-only) router.get('/species/:id', optionalAuth, ctrl.getSpeciesById); +// @public-by-design: публичные справочные данные о биоме (read-only) router.get('/biome/:habitatId', ctrl.getBiomeSpecies); // Auth required diff --git a/backend/src/routes/subjects.js b/backend/src/routes/subjects.js index 0088ace..1de9c20 100644 --- a/backend/src/routes/subjects.js +++ b/backend/src/routes/subjects.js @@ -30,6 +30,7 @@ router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => { res.json(db.prepare('SELECT * FROM subjects WHERE slug = ?').get(req.params.slug)); }); +// @public-by-design: публичный список тем предмета (read-only каталог) router.get('/:slug/topics', (req, res) => { const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(req.params.slug); if (!subject) return res.status(404).json({ error: 'Subject not found' });