feat(#2): backend scaffold + auth (Express ESM, PG, JWT, bcrypt, rate-limit, закрытая регистрация)
Deploy / deploy (push) Failing after 4s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
german
2026-08-05 19:26:42 +03:00
co-authored by Claude Opus 4.8
parent c573e8bfe6
commit be29d02cc0
12 changed files with 1883 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
npm-debug.log
.env
.env.*
*.log
Dockerfile
.dockerignore
+18
View File
@@ -0,0 +1,18 @@
# Backend — Node.js 20 (Alpine). Порт 4000 наружу не публикуется (только через nginx).
FROM node:20-alpine
WORKDIR /app
# Сначала манифесты — кэш слоёв.
COPY package.json ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
EXPOSE 4000
# Запуск от непривилегированного пользователя.
USER node
CMD ["node", "src/index.js"]
+1144
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "nika-gallery-backend",
"version": "0.1.0",
"description": "Ника — Галерея & Аукцион: Express API (PostgreSQL 16, JWT)",
"type": "module",
"main": "src/index.js",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"check": "node --check src/index.js && node --check src/db/pool.js && node --check src/middleware/auth.js && node --check src/middleware/rateLimit.js && node --check src/routes/auth.js && node --check src/routes/works.js && node --check src/routes/auctions.js && node --check src/routes/subscriptions.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
// PostgreSQL connection pool.
// Единственный источник строки подключения — env DATABASE_URL. Не хардкодить.
import pg from 'pg';
const { Pool } = pg;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL не задан — задайте его в окружении (.env).');
}
export const pool = new Pool({
connectionString,
max: Number(process.env.PG_POOL_MAX ?? 10),
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
pool.on('error', (err) => {
// Ошибка на idle-клиенте пула не должна ронять процесс.
console.error('[db] неожиданная ошибка idle-клиента:', err.message);
});
// Тонкая обёртка для параметризованных запросов (никакой конкатенации SQL).
export function query(text, params) {
return pool.query(text, params);
}
export default pool;
+103
View File
@@ -0,0 +1,103 @@
// Ника — Галерея & Аукцион: точка входа Express API.
// Порт наружу НЕ публикуется — доступ только через nginx (см. docker-compose).
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { pool } from './db/pool.js';
import rateLimit from './middleware/rateLimit.js';
import authRoutes from './routes/auth.js';
import worksRoutes from './routes/works.js';
import auctionsRoutes from './routes/auctions.js';
import subscriptionsRoutes from './routes/subscriptions.js';
const app = express();
const PORT = Number(process.env.PORT ?? 4000);
const isProd = process.env.NODE_ENV === 'production';
// За nginx — доверяем первому прокси (корректный req.ip / X-Forwarded-For).
app.set('trust proxy', 1);
app.disable('x-powered-by');
// Security-заголовки (без внешних зависимостей).
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('X-XSS-Protection', '0');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
if (isProd) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
// CORS — список origin из env (CORS_ORIGIN, через запятую). Дефолт — SITE_URL или всё в dev.
const corsOrigins = (process.env.CORS_ORIGIN || process.env.SITE_URL || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
app.use(
cors({
origin: corsOrigins.length ? corsOrigins : true,
credentials: true,
})
);
app.use(express.json({ limit: '1mb' }));
// Health-check — не читает БД по умолчанию, но проверяет коннект если ?db=1.
app.get('/api/health', async (req, res) => {
const body = { status: 'ok', service: 'nika-gallery-backend', time: new Date().toISOString() };
if (req.query.db === '1') {
try {
await pool.query('SELECT 1');
body.db = 'ok';
} catch (err) {
body.status = 'degraded';
body.db = 'down';
return res.status(503).json({ ...body, error: err.message });
}
}
return res.json(body);
});
// Rate-limit только на аутентификацию (защита от подбора).
app.use('/api/auth', rateLimit, authRoutes);
app.use('/api/works', worksRoutes);
app.use('/api/auctions', auctionsRoutes);
app.use('/api/subscriptions', subscriptionsRoutes);
// 404
app.use((_req, res) => {
res.status(404).json({ error: 'Не найдено.' });
});
// Глобальный обработчик ошибок.
// eslint-disable-next-line no-unused-vars
app.use((err, _req, res, _next) => {
console.error('[error]', err);
const status = err.status || 500;
const payload = { error: isProd ? 'Внутренняя ошибка сервера.' : err.message };
res.status(status).json(payload);
});
// Запуск (только если файл запущен напрямую, а не импортирован в тесте).
const isMain = import.meta.url === `file://${process.argv[1]}` ||
process.argv[1]?.endsWith('index.js');
if (isMain) {
const server = app.listen(PORT, () => {
console.log(`[nika] API слушает :${PORT} (env=${process.env.NODE_ENV ?? 'dev'})`);
});
const shutdown = (sig) => {
console.log(`[nika] ${sig} — останавливаемся…`);
server.close(() => pool.end().finally(() => process.exit(0)));
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
export default app;
+98
View File
@@ -0,0 +1,98 @@
// JWT-аутентификация и ролевой доступ.
// Секреты только из env. Никаких дефолтных секретов в проде.
import jwt from 'jsonwebtoken';
const isProd = process.env.NODE_ENV === 'production';
const JWT_SECRET = process.env.JWT_SECRET ?? '';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET ?? JWT_SECRET;
const ACCESS_TTL = process.env.JWT_ACCESS_TTL ?? '15m';
const REFRESH_TTL = process.env.JWT_REFRESH_TTL ?? '30d';
// В проде запрещаем слабый/пустой секрет. В dev допускаем ослабленный.
if (isProd && JWT_SECRET.length < 16) {
throw new Error(
'JWT_SECRET слишком короткий (<16 символов) при NODE_ENV=production. ' +
'Задайте сильный секрет в окружении.'
);
}
if (!isProd && JWT_SECRET.length < 16) {
console.warn('[auth] JWT_SECRET короткий — допустимо только вне production.');
}
// Список ролей из схемы БД (users.role CHECK).
export const ROLES = ['owner', 'admin', 'buyer', 'subscriber'];
// Привилегированные роли для админ-действий.
export const ADMIN_ROLES = ['owner', 'admin'];
export function signAccessToken(user) {
return jwt.sign(
{ sub: user.id, role: user.role, email: user.email, name: user.name },
JWT_SECRET,
{ expiresIn: ACCESS_TTL }
);
}
export function signRefreshToken(user) {
return jwt.sign(
{ sub: user.id, type: 'refresh' },
JWT_REFRESH_SECRET,
{ expiresIn: REFRESH_TTL }
);
}
export function verifyAccessToken(token) {
return jwt.verify(token, JWT_SECRET);
}
export function verifyRefreshToken(token) {
return jwt.verify(token, JWT_REFRESH_SECRET);
}
function extractToken(req) {
const header = req.headers.authorization || '';
const [scheme, value] = header.split(' ');
if (scheme === 'Bearer' && value) return value.trim();
return null;
}
// Требует валидный access-токен. Кладёт payload в req.user.
export function authRequired(req, res, next) {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ error: 'Требуется авторизация.' });
}
try {
req.user = verifyAccessToken(token);
return next();
} catch {
return res.status(401).json({ error: 'Недействительный или истёкший токен.' });
}
}
// Требует, чтобы роль пользователя входила в список разрешённых.
export function requireRole(...roles) {
const allowed = roles.flat();
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Требуется авторизация.' });
}
if (!allowed.includes(req.user.role)) {
return res.status(403).json({ error: 'Недостаточно прав.' });
}
return next();
};
}
// Мягкая авторизация: если токен есть и валиден — заполняем req.user, иначе идём дальше.
export function optionalAuth(req, _res, next) {
const token = extractToken(req);
if (token) {
try {
req.user = verifyAccessToken(token);
} catch {
// игнорируем — гость
}
}
return next();
}
+56
View File
@@ -0,0 +1,56 @@
// Простой in-memory ограничитель попыток — защита /api/auth от подбора.
// По умолчанию: 10 попыток за 15 минут на IP. Без внешних зависимостей.
const WINDOW_MS = Number(process.env.AUTH_RATE_WINDOW_MS ?? 15 * 60 * 1000);
const MAX_HITS = Number(process.env.AUTH_RATE_MAX ?? 10);
// key -> { count, resetAt }
const buckets = new Map();
// Периодическая чистка протухших окон, чтобы Map не рос без предела.
const sweep = setInterval(() => {
const now = Date.now();
for (const [key, b] of buckets) {
if (b.resetAt <= now) buckets.delete(key);
}
}, WINDOW_MS).unref?.();
function clientKey(req) {
// За nginx реальный IP приходит в X-Forwarded-For (левый в цепочке).
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string' && xff.length) {
return xff.split(',')[0].trim();
}
return req.ip || req.socket?.remoteAddress || 'unknown';
}
export function rateLimit(req, res, next) {
const key = clientKey(req);
const now = Date.now();
let b = buckets.get(key);
if (!b || b.resetAt <= now) {
b = { count: 0, resetAt: now + WINDOW_MS };
buckets.set(key, b);
}
b.count += 1;
const remaining = Math.max(0, MAX_HITS - b.count);
res.setHeader('X-RateLimit-Limit', String(MAX_HITS));
res.setHeader('X-RateLimit-Remaining', String(remaining));
res.setHeader('X-RateLimit-Reset', String(Math.ceil(b.resetAt / 1000)));
if (b.count > MAX_HITS) {
const retryAfter = Math.ceil((b.resetAt - now) / 1000);
res.setHeader('Retry-After', String(retryAfter));
return res.status(429).json({
error: 'Слишком много попыток. Повторите позже.',
retry_after_seconds: retryAfter,
});
}
return next();
}
export { sweep };
export default rateLimit;
+70
View File
@@ -0,0 +1,70 @@
// Аукционы (primary/secondary). Заготовка под задачу #5 (+ WebSocket-ставки).
import { Router } from 'express';
import { pool } from '../db/pool.js';
import { authRequired, requireRole, ADMIN_ROLES } from '../middleware/auth.js';
const router = Router();
// GET /api/auctions — список аукционов, опционально фильтр по статусу.
router.get('/', async (req, res, next) => {
try {
const limit = Math.min(Number(req.query.limit) || 24, 100);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const status = req.query.status;
const params = [limit, offset];
let where = '';
if (status) {
params.push(String(status));
where = `WHERE a.status = $${params.length}`;
}
const { rows } = await pool.query(
`SELECT a.id, a.work_id, a.type, a.start_price, a.current_price, a.reserve_price,
a.min_step, a.start_at, a.end_at, a.status, a.winner_id, a.created_at,
w.title AS work_title, w.slug AS work_slug
FROM auctions a
JOIN works w ON w.id = a.work_id
${where}
ORDER BY a.end_at ASC
LIMIT $1 OFFSET $2`,
params
);
return res.json({ items: rows, limit, offset });
} catch (err) {
return next(err);
}
});
// GET /api/auctions/:id — карточка аукциона + последние ставки.
router.get('/:id', async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT a.*, w.title AS work_title, w.slug AS work_slug
FROM auctions a JOIN works w ON w.id = a.work_id
WHERE a.id = $1`,
[req.params.id]
);
const auction = rows[0];
if (!auction) return res.status(404).json({ error: 'Аукцион не найден.' });
const bids = await pool.query(
`SELECT id, user_id, amount, created_at
FROM bids WHERE auction_id = $1 ORDER BY created_at DESC LIMIT 20`,
[auction.id]
);
return res.json({ ...auction, bids: bids.rows });
} catch (err) {
return next(err);
}
});
// Создание аукциона / приём ставки — задача #5.
router.post('/', authRequired, requireRole(ADMIN_ROLES), (_req, res) => {
res.status(501).json({ error: 'Не реализовано (задача #5).' });
});
router.post('/:id/bids', authRequired, (_req, res) => {
res.status(501).json({ error: 'Не реализовано (задача #5).' });
});
export default router;
+208
View File
@@ -0,0 +1,208 @@
// Аутентификация: регистрация (ЗАКРЫТАЯ) и вход.
// Политика безопасности (инцидент 2026-08-05, открытая регистрация):
// - первый пользователь в системе получает роль 'admin' (bootstrap);
// - всех последующих заводит ТОЛЬКО админ по валидному access-токену;
// - пароли только через bcrypt;
// - все SQL — параметризованные (никакой конкатенации).
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { pool } from '../db/pool.js';
import {
authRequired,
requireRole,
ADMIN_ROLES,
ROLES,
signAccessToken,
signRefreshToken,
verifyRefreshToken,
} from '../middleware/auth.js';
const router = Router();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const BCRYPT_ROUNDS = Number(process.env.BCRYPT_ROUNDS ?? 12);
function sanitizeUser(u) {
return {
id: u.id,
email: u.email,
name: u.name,
role: u.role,
telegram_id: u.telegram_id ?? null,
created_at: u.created_at,
};
}
async function countUsers() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM users');
return rows[0].n;
}
// Внутреннее заведение пользователя. allowedRole — какие роли можно назначить.
async function createUser({ email, name, password, role }) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const { rows } = await pool.query(
`INSERT INTO users (email, name, password, role)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, role, telegram_id, created_at`,
[email.toLowerCase().trim(), name.trim(), hash, role]
);
return rows[0];
}
// POST /api/auth/register
// Первый пользователь → admin (без токена). Дальше — только админ по токену.
router.post('/register', async (req, res, next) => {
try {
const { email, name, password, role: requestedRole } = req.body ?? {};
if (!email || !EMAIL_RE.test(String(email))) {
return res.status(400).json({ error: 'Некорректный email.' });
}
if (!name || String(name).trim().length < 2) {
return res.status(400).json({ error: 'Имя обязательно (мин. 2 символа).' });
}
if (!password || String(password).length < 8) {
return res.status(400).json({ error: 'Пароль обязателен (мин. 8 символов).' });
}
const total = await countUsers();
// BOOTSTRAP: система пуста — создаём первого админа без авторизации.
if (total === 0) {
const user = await createUser({ email, name, password, role: 'admin' });
return res.status(201).json({
user: sanitizeUser(user),
note: 'Первый пользователь создан с ролью admin.',
});
}
// Система не пуста — регистрация закрыта, нужен валидный админ-токен.
// Оборачиваем authRequired + requireRole вручную, чтобы 401/403 были корректны.
await new Promise((resolve, reject) => {
authRequired(req, res, (err) => (err ? reject(err) : resolve()));
});
if (res.headersSent) return undefined;
await new Promise((resolve, reject) => {
requireRole(ADMIN_ROLES)(req, res, (err) => (err ? reject(err) : resolve()));
});
if (res.headersSent) return undefined;
// Роль назначает админ; по умолчанию 'buyer'. Валидируем по списку из схемы.
let role = requestedRole ?? 'buyer';
if (!ROLES.includes(role)) {
return res.status(400).json({ error: `Недопустимая роль. Допустимо: ${ROLES.join(', ')}.` });
}
const user = await createUser({ email, name, password, role });
return res.status(201).json({ user: sanitizeUser(user) });
} catch (err) {
if (err && err.code === '23505') {
return res.status(409).json({ error: 'Пользователь с таким email уже существует.' });
}
return next(err);
}
});
// POST /api/auth/login
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body ?? {};
if (!email || !password) {
return res.status(400).json({ error: 'Email и пароль обязательны.' });
}
const { rows } = await pool.query(
`SELECT id, email, name, password, role, telegram_id, created_at
FROM users WHERE email = $1`,
[String(email).toLowerCase().trim()]
);
const user = rows[0];
// Единый ответ на «нет юзера» и «неверный пароль» — не палим существование email.
const ok = user ? await bcrypt.compare(String(password), user.password) : false;
if (!ok) {
return res.status(401).json({ error: 'Неверный email или пароль.' });
}
const accessToken = signAccessToken(user);
const refreshToken = signRefreshToken(user);
// Храним refresh-токен на пользователе (для последующего отзыва).
await pool.query('UPDATE users SET refresh_token = $1, updated_at = now() WHERE id = $2', [
refreshToken,
user.id,
]);
return res.json({
user: sanitizeUser(user),
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'Bearer',
});
} catch (err) {
return next(err);
}
});
// POST /api/auth/refresh — обмен refresh-токена на новый access-токен.
router.post('/refresh', async (req, res, next) => {
try {
const { refresh_token: refreshToken } = req.body ?? {};
if (!refreshToken) {
return res.status(400).json({ error: 'refresh_token обязателен.' });
}
let payload;
try {
payload = verifyRefreshToken(refreshToken);
} catch {
return res.status(401).json({ error: 'Недействительный refresh-токен.' });
}
const { rows } = await pool.query(
`SELECT id, email, name, role, telegram_id, created_at, refresh_token
FROM users WHERE id = $1`,
[payload.sub]
);
const user = rows[0];
if (!user || user.refresh_token !== refreshToken) {
return res.status(401).json({ error: 'Refresh-токен отозван или устарел.' });
}
return res.json({
access_token: signAccessToken(user),
token_type: 'Bearer',
});
} catch (err) {
return next(err);
}
});
// POST /api/auth/logout — отзыв refresh-токена.
router.post('/logout', authRequired, async (req, res, next) => {
try {
await pool.query('UPDATE users SET refresh_token = NULL, updated_at = now() WHERE id = $1', [
req.user.sub,
]);
return res.json({ ok: true });
} catch (err) {
return next(err);
}
});
// GET /api/auth/me — текущий пользователь.
router.get('/me', authRequired, async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT id, email, name, role, telegram_id, created_at FROM users WHERE id = $1`,
[req.user.sub]
);
if (!rows[0]) return res.status(404).json({ error: 'Пользователь не найден.' });
return res.json({ user: sanitizeUser(rows[0]) });
} catch (err) {
return next(err);
}
});
export default router;
+50
View File
@@ -0,0 +1,50 @@
// Подписки на рассылку. Заготовка под задачу #6 (double opt-in + рассылка).
import { Router } from 'express';
import { pool } from '../db/pool.js';
import { authRequired, requireRole, ADMIN_ROLES } from '../middleware/auth.js';
const router = Router();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// POST /api/subscriptions — публичная подписка (double opt-in подтверждается в #6).
router.post('/', async (req, res, next) => {
try {
const { email, name } = req.body ?? {};
if (!email || !EMAIL_RE.test(String(email))) {
return res.status(400).json({ error: 'Некорректный email.' });
}
const { rows } = await pool.query(
`INSERT INTO subscriptions (email, name)
VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE SET name = COALESCE(EXCLUDED.name, subscriptions.name)
RETURNING id, email, name, confirmed_at, created_at`,
[String(email).toLowerCase().trim(), name ? String(name).trim() : null]
);
// Реальная отправка письма подтверждения — задача #6.
return res.status(201).json({
subscription: rows[0],
note: 'Подписка принята. Письмо подтверждения будет отправлено (задача #6).',
});
} catch (err) {
return next(err);
}
});
// GET /api/subscriptions — список подписчиков (только админ).
router.get('/', authRequired, requireRole(ADMIN_ROLES), async (req, res, next) => {
try {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const { rows } = await pool.query(
`SELECT id, email, name, confirmed_at, created_at
FROM subscriptions ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
[limit, offset]
);
return res.json({ items: rows, limit, offset });
} catch (err) {
return next(err);
}
});
export default router;
+77
View File
@@ -0,0 +1,77 @@
// Галерея работ. Заготовка под задачу #4 (CRUD + медиа).
// Пока реализованы публичные GET-списки из works_extended + мутирующие заглушки под авторизацией.
import { Router } from 'express';
import { pool } from '../db/pool.js';
import { authRequired, requireRole, ADMIN_ROLES, optionalAuth } from '../middleware/auth.js';
const router = Router();
// GET /api/works — публичный список опубликованных работ (агрегаты из works_extended).
router.get('/', optionalAuth, async (req, res, next) => {
try {
const isAdmin = req.user && ADMIN_ROLES.includes(req.user.role);
const limit = Math.min(Number(req.query.limit) || 24, 100);
const offset = Math.max(Number(req.query.offset) || 0, 0);
// Гостям и покупателям — только published; админам — все статусы.
const params = [limit, offset];
let where = "WHERE status = 'published'";
if (isAdmin) where = 'WHERE true';
const { rows } = await pool.query(
`SELECT id, title, slug, description, medium, dimensions, year, price,
status, auction_mode, likes_count, comments_count, avg_rating,
reviews_count, cover_url, cover_thumb, created_at
FROM works_extended
${where}
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`,
params
);
return res.json({ items: rows, limit, offset });
} catch (err) {
return next(err);
}
});
// GET /api/works/:slug — публичная карточка работы по slug.
router.get('/:slug', optionalAuth, async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT id, title, slug, description, medium, dimensions, year, price,
status, auction_mode, likes_count, comments_count, avg_rating,
reviews_count, cover_url, cover_thumb, created_at
FROM works_extended WHERE slug = $1`,
[req.params.slug]
);
const work = rows[0];
if (!work) return res.status(404).json({ error: 'Работа не найдена.' });
const isAdmin = req.user && ADMIN_ROLES.includes(req.user.role);
if (work.status !== 'published' && !isAdmin) {
return res.status(404).json({ error: 'Работа не найдена.' });
}
const media = await pool.query(
`SELECT id, url, thumb_url, type, sort_order
FROM media WHERE work_id = $1 ORDER BY sort_order`,
[work.id]
);
return res.json({ ...work, media: media.rows });
} catch (err) {
return next(err);
}
});
// Мутирующие маршруты — под авторизацией и админ-ролью. Реализация — задача #4.
router.post('/', authRequired, requireRole(ADMIN_ROLES), (_req, res) => {
res.status(501).json({ error: 'Не реализовано (задача #4).' });
});
router.patch('/:id', authRequired, requireRole(ADMIN_ROLES), (_req, res) => {
res.status(501).json({ error: 'Не реализовано (задача #4).' });
});
router.delete('/:id', authRequired, requireRole(ADMIN_ROLES), (_req, res) => {
res.status(501).json({ error: 'Не реализовано (задача #4).' });
});
export default router;