create-simo-api 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +33 -2
  2. package/index.js +74 -0
  3. package/package.json +24 -4
  4. package/template/README.md +61 -0
  5. package/template/_env.example +15 -0
  6. package/template/_gitignore +4 -0
  7. package/template/app.js +29 -0
  8. package/template/config/db.js +6 -0
  9. package/template/config/env.js +27 -0
  10. package/template/config/permissions.js +15 -0
  11. package/template/controllers/authController.js +53 -0
  12. package/template/controllers/productController.js +34 -0
  13. package/template/controllers/userController.js +22 -0
  14. package/template/index.js +6 -0
  15. package/template/middlewares/authMiddleware.js +15 -0
  16. package/template/middlewares/errorMiddleware.js +33 -0
  17. package/template/middlewares/rateLimitMiddleware.js +14 -0
  18. package/template/middlewares/roleMiddleware.js +22 -0
  19. package/template/middlewares/validateMiddleware.js +18 -0
  20. package/template/migrations/1792673478177_create-users-table.sql +16 -0
  21. package/template/migrations/1792842956065_create-refresh-tokens-table.sql +17 -0
  22. package/template/migrations/1792842957555_create-products-table.sql +14 -0
  23. package/template/models/productModel.js +42 -0
  24. package/template/models/refreshTokenModel.js +25 -0
  25. package/template/models/userModel.js +39 -0
  26. package/template/package.json +33 -0
  27. package/template/routes/authRoutes.js +13 -0
  28. package/template/routes/index.js +15 -0
  29. package/template/routes/productRoutes.js +17 -0
  30. package/template/routes/userRoutes.js +15 -0
  31. package/template/seeds/seed.js +50 -0
  32. package/template/services/authService.js +94 -0
  33. package/template/services/productService.js +37 -0
  34. package/template/services/userService.js +24 -0
  35. package/template/tests/api.test.js +174 -0
  36. package/template/utils/AppError.js +10 -0
  37. package/template/utils/verifyToken.js +12 -0
  38. package/template/validations/authValidation.js +18 -0
  39. package/template/validations/productValidation.js +21 -0
  40. package/template/validations/userValidation.js +10 -0
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # create-simo-api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Scaffold an Express 5 + PostgreSQL API pre-configured with:
4
+
5
+ - **JWT auth**: access token + refresh token in an `httpOnly` cookie, stored hashed, rotated on every refresh, reuse detection
6
+ - **Role permissions** with `authorize('PERMISSION')`
7
+ - **Zod** validation for request bodies, params and `.env`
8
+ - **Service layer** (routes → controllers → services → models)
9
+ - Central **error handling** (`AppError`, JSON 404, PostgreSQL errors)
10
+ - **Migrations** (`node-pg-migrate`), admin **seed**, **rate limiting**, **morgan** logs
11
+ - **Tests** with Supertest
12
+ - Example resources: users and products
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ npm create simo-api@latest my-api
18
+ cd my-api
19
+ # set DATABASE_URL in .env
20
+ npm run migrate:up
21
+ npm run seed
22
+ npm run dev
23
+ ```
24
+
25
+ `.env` is generated with random JWT secrets and a random admin password.
26
+
27
+ ## Requirements
28
+
29
+ - Node.js 20+
30
+ - PostgreSQL
31
+
32
+ ## License
33
+
34
+ MIT
package/index.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { randomBytes } from 'node:crypto'
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
9
+ const TEMPLATE_DIR = join(__dirname, 'template')
10
+
11
+ const RENAME = {
12
+ _gitignore: '.gitignore',
13
+ '_env.example': '.env.example',
14
+ }
15
+
16
+ function fail(msg) {
17
+ console.error(`\n ✖ ${msg}\n`)
18
+ process.exit(1)
19
+ }
20
+
21
+ function main() {
22
+ const projectName = process.argv[2]
23
+
24
+ if (!projectName) {
25
+ fail('Usage: npm create simo-api@latest <project-name>')
26
+ }
27
+
28
+ const targetDir = resolve(process.cwd(), projectName)
29
+
30
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
31
+ fail(`Directory "${projectName}" already exists and is not empty.`)
32
+ }
33
+
34
+ console.log(`\n Scaffolding project in ${targetDir} ...`)
35
+
36
+ mkdirSync(targetDir, { recursive: true })
37
+ cpSync(TEMPLATE_DIR, targetDir, { recursive: true })
38
+
39
+ for (const [from, to] of Object.entries(RENAME)) {
40
+ const src = join(targetDir, from)
41
+ if (existsSync(src)) renameSync(src, join(targetDir, to))
42
+ }
43
+
44
+ const pkgPath = join(targetDir, 'package.json')
45
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
46
+ pkg.name = projectName
47
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
48
+
49
+ const env = readFileSync(join(targetDir, '.env.example'), 'utf8')
50
+ .replace(/^ACCESS_TOKEN_SECRET=$/m, `ACCESS_TOKEN_SECRET=${randomBytes(64).toString('hex')}`)
51
+ .replace(/^REFRESH_TOKEN_SECRET=$/m, `REFRESH_TOKEN_SECRET=${randomBytes(64).toString('hex')}`)
52
+ .replace(/^ADMIN_PASSWORD=$/m, `ADMIN_PASSWORD=${randomBytes(12).toString('base64url')}`)
53
+ writeFileSync(join(targetDir, '.env'), env)
54
+
55
+ console.log(' Installing dependencies with npm ...\n')
56
+ const install = spawnSync('npm', ['install'], { cwd: targetDir, stdio: 'inherit' })
57
+
58
+ if (install.status !== 0) {
59
+ fail('Dependency installation failed. You can cd into the folder and run "npm install" manually.')
60
+ }
61
+
62
+ console.log(`
63
+ ✔ Done! Project created.
64
+
65
+ Next steps:
66
+ cd ${projectName}
67
+ # set DATABASE_URL in .env
68
+ npm run migrate:up
69
+ npm run seed
70
+ npm run dev
71
+ `)
72
+ }
73
+
74
+ main()
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "create-simo-api",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Scaffold an Express 5 + PostgreSQL API with JWT auth (access + refresh rotation), role permissions, Zod validation, service layer, migrations, seed and tests.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-simo-api": "index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "template"
12
+ ],
13
+ "keywords": [
14
+ "express",
15
+ "postgresql",
16
+ "jwt",
17
+ "api",
18
+ "boilerplate",
19
+ "scaffold",
20
+ "create"
21
+ ],
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=20"
25
+ }
26
+ }
@@ -0,0 +1,61 @@
1
+ # Simo API
2
+
3
+ API Express 5 + PostgreSQL.
4
+
5
+ ## Démarrage
6
+
7
+ ```bash
8
+ npm install
9
+ cp .env.example .env # si .env n'existe pas, puis remplir DATABASE_URL
10
+ npm run migrate:up
11
+ npm run seed # crée l'admin du .env
12
+ npm run dev
13
+ ```
14
+
15
+ Générer un secret JWT :
16
+
17
+ ```bash
18
+ node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
19
+ ```
20
+
21
+ ## Structure
22
+
23
+ ```
24
+ app.js app Express (middlewares, routes)
25
+ index.js lance le serveur
26
+ config/ env (validé avec Zod), db, permissions
27
+ routes/ auth, user, product
28
+ controllers/ req/res seulement
29
+ services/ logique métier
30
+ models/ requêtes SQL
31
+ middlewares/ auth, authorize, validate, rateLimit, errors
32
+ validations/ schémas Zod
33
+ utils/ AppError, verifyToken
34
+ migrations/
35
+ seeds/
36
+ tests/
37
+ ```
38
+
39
+ ## Routes
40
+
41
+ | Route | Accès |
42
+ |---|---|
43
+ | `POST /api/auth/register` · `login` | public |
44
+ | `POST /api/auth/refresh` · `logout` | cookie `refreshToken` |
45
+ | `GET /api/user/me` | connecté |
46
+ | `GET /api/user/getAll` | admin |
47
+ | `POST /api/user/create` | connecté (seul l'admin choisit le rôle) |
48
+ | `GET /api/product/getAll` · `/:id` | public |
49
+ | `POST /api/product/create` | admin |
50
+ | `PATCH /api/product/update/:id` | admin |
51
+ | `DELETE /api/product/delete/:id` | admin |
52
+
53
+ ## Fonctionnement
54
+
55
+ - **Auth** : access token JWT (`ACCESS_TOKEN_EXPIRES_IN`, 15m par défaut) dans `Authorization: Bearer`, refresh token (`REFRESH_TOKEN_EXPIRES_DAYS`, 7 j par défaut) en cookie `httpOnly`, stocké hashé dans `refresh_tokens`. Rotation à chaque refresh ; un token réutilisé révoque toutes les sessions de l'utilisateur.
56
+ - **Permissions** : `authorize('PERMISSION')` sur la route, rôles autorisés dans `config/permissions.js`.
57
+ - **Validation** : `validate(schema)` pour le body, `validate(schema, 'params')` pour les paramètres.
58
+ - **Erreurs** : `throw new AppError(message, status)` dans les controllers (pas de try/catch). `errorHandler` renvoie du JSON, convertit les erreurs PostgreSQL (doublon → 409) et renvoie 404 pour les routes inconnues.
59
+ - **Env** : le serveur refuse de démarrer si une variable manque ou est invalide.
60
+ - **Logs** : morgan (`dev` en développement, `combined` en production).
61
+ - **Rate limit** : 100 requêtes / 15 min par IP sur `/api`.
@@ -0,0 +1,15 @@
1
+ NODE_ENV=development
2
+ PORT=3000
3
+
4
+ DATABASE_URL=postgres://user:password@localhost:5432/dbname
5
+
6
+ CORS_ORIGIN=http://localhost:5173
7
+
8
+ ACCESS_TOKEN_SECRET=
9
+ REFRESH_TOKEN_SECRET=
10
+ ACCESS_TOKEN_EXPIRES_IN=15m
11
+ REFRESH_TOKEN_EXPIRES_DAYS=7
12
+
13
+ ADMIN_NAME=Admin
14
+ ADMIN_EMAIL=admin@example.com
15
+ ADMIN_PASSWORD=
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ .env
3
+ *.log
4
+ coverage
@@ -0,0 +1,29 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import cookieParser from 'cookie-parser';
4
+ import morgan from 'morgan';
5
+ import env from './config/env.js';
6
+ import routes from './routes/index.js';
7
+ import { notFound, errorHandler } from './middlewares/errorMiddleware.js';
8
+ import { apiLimiter } from './middlewares/rateLimitMiddleware.js';
9
+
10
+ const app = express();
11
+
12
+ if (env.NODE_ENV !== 'test') {
13
+ app.use(morgan(env.NODE_ENV === 'production' ? 'combined' : 'dev'));
14
+ }
15
+ app.use(express.json());
16
+ app.use(cookieParser());
17
+
18
+ app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
19
+
20
+ app.get('/', (req, res) => {
21
+ res.send('API is running...');
22
+ });
23
+
24
+ app.use('/api', apiLimiter, routes);
25
+
26
+ app.use(notFound);
27
+ app.use(errorHandler);
28
+
29
+ export default app;
@@ -0,0 +1,6 @@
1
+ import pg from 'pg';
2
+ import env from './env.js';
3
+
4
+ const { Pool } = pg;
5
+
6
+ export default new Pool({ connectionString: env.DATABASE_URL });
@@ -0,0 +1,27 @@
1
+ import 'dotenv/config';
2
+ import { z } from 'zod';
3
+
4
+ const envSchema = z.object({
5
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
6
+ PORT: z.coerce.number().int().positive().default(3000),
7
+ DATABASE_URL: z.url(),
8
+ ACCESS_TOKEN_SECRET: z.string().min(32),
9
+ REFRESH_TOKEN_SECRET: z.string().min(32),
10
+ ACCESS_TOKEN_EXPIRES_IN: z.string().regex(/^\d+(ms|s|m|h|d)$/, 'Use a duration like 15m, 1h or 1d').default('15m'),
11
+ REFRESH_TOKEN_EXPIRES_DAYS: z.coerce.number().int().positive().default(7),
12
+ CORS_ORIGIN: z.string().min(1).transform((value) => value.split(',').map((origin) => origin.trim())),
13
+ });
14
+
15
+ const result = envSchema.safeParse(process.env);
16
+
17
+ if (!result.success) {
18
+ console.error('Invalid environment variables:');
19
+ for (const issue of result.error.issues) {
20
+ console.error(` - ${issue.path.join('.')}: ${issue.message}`);
21
+ }
22
+ process.exit(1);
23
+ }
24
+
25
+ const env = result.data;
26
+
27
+ export default env;
@@ -0,0 +1,15 @@
1
+ export const ROLES = {
2
+ ADMIN: 'admin',
3
+ USER: 'user'
4
+ };
5
+
6
+
7
+ export const PERMISSIONS = {
8
+ USER_CREATE: [ROLES.ADMIN, ROLES.USER],
9
+ USER_DELETE: [ROLES.ADMIN],
10
+ USER_GETALL: [ROLES.ADMIN],
11
+ PRODUCT_CREATE: [ROLES.ADMIN],
12
+ PRODUCT_UPDATE: [ROLES.ADMIN],
13
+ PRODUCT_DELETE: [ROLES.ADMIN],
14
+ PRODUCT_GETALL: [ROLES.ADMIN, ROLES.USER]
15
+ }
@@ -0,0 +1,53 @@
1
+ import authService, { REFRESH_TOKEN_MAX_AGE } from '../services/authService.js';
2
+ import env from '../config/env.js';
3
+
4
+ const refreshCookieOptions = {
5
+ httpOnly: true,
6
+ secure: env.NODE_ENV === "production",
7
+ sameSite: "strict",
8
+ path: "/api/auth",
9
+ };
10
+
11
+ const sendRefreshCookie = (res, token) => {
12
+ res.cookie("refreshToken", token, {
13
+ ...refreshCookieOptions,
14
+ maxAge: REFRESH_TOKEN_MAX_AGE
15
+ });
16
+ };
17
+
18
+ const login = async (req, res) => {
19
+ const { accessToken, refreshToken, user } = await authService.login(req.body.email, req.body.password);
20
+ sendRefreshCookie(res, refreshToken);
21
+ res.status(200).json({ message: 'Login successful', accessToken, user });
22
+ };
23
+
24
+ const register = async (req, res) => {
25
+ const userId = await authService.register(req.body);
26
+ res.status(201).json({ message: 'User registered successfully', userId });
27
+ };
28
+
29
+ const refresh = async (req, res) => {
30
+ const { accessToken, refreshToken } = await authService.refresh(req.cookies.refreshToken);
31
+ sendRefreshCookie(res, refreshToken);
32
+ res.status(200).json({ accessToken });
33
+ };
34
+
35
+ const clearRefreshCookieOnError = (err, req, res, next) => {
36
+ res.clearCookie("refreshToken", refreshCookieOptions);
37
+ next(err);
38
+ };
39
+
40
+ const logout = async (req, res) => {
41
+ await authService.logout(req.cookies.refreshToken);
42
+ res.clearCookie("refreshToken", refreshCookieOptions);
43
+ res.status(200).json({ message: 'Logged out' });
44
+ };
45
+
46
+
47
+ export default {
48
+ login,
49
+ register,
50
+ refresh,
51
+ clearRefreshCookieOnError,
52
+ logout
53
+ };
@@ -0,0 +1,34 @@
1
+ import productService from '../services/productService.js';
2
+
3
+ const getAllProducts = async (req, res) => {
4
+ const products = await productService.getAll();
5
+ res.status(200).json({ products });
6
+ };
7
+
8
+ const getProductById = async (req, res) => {
9
+ const product = await productService.getById(req.params.id);
10
+ res.status(200).json({ product });
11
+ };
12
+
13
+ const createProduct = async (req, res) => {
14
+ const product = await productService.create(req.body);
15
+ res.status(201).json({ message: 'Product created successfully', product });
16
+ };
17
+
18
+ const updateProduct = async (req, res) => {
19
+ const product = await productService.update(req.params.id, req.body);
20
+ res.status(200).json({ message: 'Product updated successfully', product });
21
+ };
22
+
23
+ const deleteProduct = async (req, res) => {
24
+ await productService.remove(req.params.id);
25
+ res.status(200).json({ message: 'Product deleted successfully' });
26
+ };
27
+
28
+ export default {
29
+ getAllProducts,
30
+ getProductById,
31
+ createProduct,
32
+ updateProduct,
33
+ deleteProduct
34
+ };
@@ -0,0 +1,22 @@
1
+ import userService from '../services/userService.js';
2
+
3
+ const getAllUsers = async (req, res) => {
4
+ const users = await userService.getAll();
5
+ res.status(200).json({ users });
6
+ };
7
+
8
+ const getMe = async (req, res) => {
9
+ const user = await userService.getById(req.user.id);
10
+ res.status(200).json({ user });
11
+ };
12
+
13
+ const createUser = async (req, res) => {
14
+ const userId = await userService.create(req.body, req.user);
15
+ res.status(201).json({ message: 'User created successfully', userId });
16
+ };
17
+
18
+ export default {
19
+ getAllUsers,
20
+ getMe,
21
+ createUser
22
+ };
@@ -0,0 +1,6 @@
1
+ import env from './config/env.js';
2
+ import app from './app.js';
3
+
4
+ app.listen(env.PORT, () => {
5
+ console.log(`Server is running on port ${env.PORT}`);
6
+ });
@@ -0,0 +1,15 @@
1
+ import env from '../config/env.js';
2
+ import AppError from '../utils/AppError.js';
3
+ import verifyToken from '../utils/verifyToken.js';
4
+
5
+ const authenticate = (req, res, next) => {
6
+ const header = req.headers.authorization;
7
+ if (!header?.startsWith('Bearer ')) {
8
+ throw new AppError('No access token', 401);
9
+ }
10
+
11
+ req.user = verifyToken(header.split(' ')[1], env.ACCESS_TOKEN_SECRET, 'Invalid or expired access token');
12
+ next();
13
+ };
14
+
15
+ export default authenticate;
@@ -0,0 +1,33 @@
1
+ import AppError from '../utils/AppError.js';
2
+
3
+ export const notFound = (req, res, next) => {
4
+ next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404));
5
+ };
6
+
7
+ export const errorHandler = (err, req, res, next) => {
8
+ let error = err;
9
+
10
+ if (err.type === 'entity.parse.failed') {
11
+ error = new AppError('Invalid JSON body', 400);
12
+ }
13
+
14
+ if (err.code === '23505') {
15
+ const field = err.detail?.match(/Key \((.+?)\)/)?.[1] ?? 'value';
16
+ error = new AppError(`${field} already exists`, 409);
17
+ }
18
+
19
+ if (err.code === '22P02') {
20
+ error = new AppError('Invalid input value', 400);
21
+ }
22
+
23
+ const statusCode = error instanceof AppError ? error.statusCode : 500;
24
+
25
+ if (statusCode === 500) {
26
+ console.error(err);
27
+ }
28
+
29
+ res.status(statusCode).json({
30
+ message: statusCode === 500 ? 'Internal server error' : error.message,
31
+ ...(error.details && { errors: error.details }),
32
+ });
33
+ };
@@ -0,0 +1,14 @@
1
+ import rateLimit from 'express-rate-limit';
2
+ import env from '../config/env.js';
3
+ import AppError from '../utils/AppError.js';
4
+
5
+ export const apiLimiter = rateLimit({
6
+ windowMs: 15 * 60 * 1000,
7
+ limit: 100,
8
+ standardHeaders: 'draft-8',
9
+ legacyHeaders: false,
10
+ skip: () => env.NODE_ENV === 'test',
11
+ handler: (req, res, next) => {
12
+ next(new AppError('Too many requests, please try again in 15 minutes', 429));
13
+ },
14
+ });
@@ -0,0 +1,22 @@
1
+ import { PERMISSIONS } from '../config/permissions.js';
2
+ import AppError from '../utils/AppError.js';
3
+
4
+ export const authorize = (permission) => {
5
+ return (req, res, next) => {
6
+ if (!req.user?.role) {
7
+ return next(new AppError('Not authenticated', 401));
8
+ }
9
+
10
+ const allowedRoles = PERMISSIONS[permission];
11
+
12
+ if (!allowedRoles) {
13
+ return next(new AppError('Permission not defined', 403));
14
+ }
15
+
16
+ if (!allowedRoles.includes(req.user.role)) {
17
+ return next(new AppError('Access denied', 403));
18
+ }
19
+
20
+ next();
21
+ };
22
+ };
@@ -0,0 +1,18 @@
1
+ import AppError from '../utils/AppError.js';
2
+
3
+ export const validate = (schema, source = 'body') => {
4
+ return (req, res, next) => {
5
+ const result = schema.safeParse(req[source] ?? {});
6
+
7
+ if (!result.success) {
8
+ const errors = result.error.issues.map((issue) => ({
9
+ field: issue.path.join('.'),
10
+ message: issue.message,
11
+ }));
12
+ return next(new AppError('Validation failed', 400, errors));
13
+ }
14
+
15
+ req[source] = result.data;
16
+ next();
17
+ };
18
+ };
@@ -0,0 +1,16 @@
1
+ -- Up Migration
2
+ CREATE TYPE user_role AS ENUM ('user', 'admin');
3
+
4
+ CREATE TABLE users (
5
+ id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
6
+ name VARCHAR(255) NOT NULL,
7
+ email VARCHAR(255) NOT NULL UNIQUE,
8
+ password VARCHAR(255) NOT NULL,
9
+ refresh_token CHAR(64),
10
+ role user_role NOT NULL DEFAULT 'user',
11
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
12
+ );
13
+
14
+ -- Down Migration
15
+ DROP TABLE users;
16
+ DROP TYPE user_role;
@@ -0,0 +1,17 @@
1
+ -- Up Migration
2
+ CREATE TABLE refresh_tokens (
3
+ id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
4
+ user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
+ token_hash CHAR(64) NOT NULL UNIQUE,
6
+ expires_at TIMESTAMPTZ NOT NULL,
7
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
8
+ );
9
+
10
+ CREATE INDEX refresh_tokens_user_id_idx ON refresh_tokens (user_id);
11
+
12
+ ALTER TABLE users DROP COLUMN refresh_token;
13
+
14
+ -- Down Migration
15
+ ALTER TABLE users ADD COLUMN refresh_token CHAR(64);
16
+
17
+ DROP TABLE refresh_tokens;
@@ -0,0 +1,14 @@
1
+ -- Up Migration
2
+ CREATE TABLE products (
3
+ id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
4
+ name VARCHAR(255) NOT NULL,
5
+ description TEXT,
6
+ price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
7
+ stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
8
+ image_url TEXT,
9
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
10
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
11
+ );
12
+
13
+ -- Down Migration
14
+ DROP TABLE products;
@@ -0,0 +1,42 @@
1
+ import db from '../config/db.js';
2
+
3
+ const getAll = async () => {
4
+ const { rows } = await db.query('SELECT * FROM products ORDER BY id');
5
+ return rows;
6
+ };
7
+
8
+ const findById = async (id) => {
9
+ const { rows } = await db.query('SELECT * FROM products WHERE id = $1', [id]);
10
+ return rows[0];
11
+ };
12
+
13
+ const create = async ({ name, description = null, price, stock = 0, image_url = null }) => {
14
+ const { rows } = await db.query(
15
+ 'INSERT INTO products (name, description, price, stock, image_url) VALUES ($1, $2, $3, $4, $5) RETURNING *',
16
+ [name, description, price, stock, image_url]
17
+ );
18
+ return rows[0];
19
+ };
20
+
21
+ const update = async (id, fields) => {
22
+ const columns = Object.keys(fields);
23
+ const setClause = columns.map((column, index) => `${column} = $${index + 1}`).join(', ');
24
+ const { rows } = await db.query(
25
+ `UPDATE products SET ${setClause}, updated_at = now() WHERE id = $${columns.length + 1} RETURNING *`,
26
+ [...Object.values(fields), id]
27
+ );
28
+ return rows[0];
29
+ };
30
+
31
+ const remove = async (id) => {
32
+ const { rowCount } = await db.query('DELETE FROM products WHERE id = $1', [id]);
33
+ return rowCount > 0;
34
+ };
35
+
36
+ export default {
37
+ getAll,
38
+ findById,
39
+ create,
40
+ update,
41
+ remove
42
+ };
@@ -0,0 +1,25 @@
1
+ import db from '../config/db.js';
2
+
3
+ const create = async (userId, tokenHash, expiresAt) => {
4
+ await db.query('INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', [userId, tokenHash, expiresAt]);
5
+ };
6
+
7
+ const findByHash = async (tokenHash) => {
8
+ const { rows } = await db.query('SELECT * FROM refresh_tokens WHERE token_hash = $1 AND expires_at > now()', [tokenHash]);
9
+ return rows[0];
10
+ };
11
+
12
+ const deleteByHash = async (tokenHash) => {
13
+ await db.query('DELETE FROM refresh_tokens WHERE token_hash = $1', [tokenHash]);
14
+ };
15
+
16
+ const deleteAllForUser = async (userId) => {
17
+ await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [userId]);
18
+ };
19
+
20
+ export default {
21
+ create,
22
+ findByHash,
23
+ deleteByHash,
24
+ deleteAllForUser
25
+ };
@@ -0,0 +1,39 @@
1
+ import db from '../config/db.js';
2
+ import bcrypt from 'bcrypt';
3
+
4
+ const PUBLIC_COLUMNS = 'id, name, email, role, created_at';
5
+
6
+ const getAllUsers = async () => {
7
+ const { rows } = await db.query(`SELECT ${PUBLIC_COLUMNS} FROM users`);
8
+ return rows;
9
+ };
10
+
11
+ const findOne = async (email) => {
12
+ const { rows } = await db.query('SELECT * FROM users WHERE email = $1', [email]);
13
+ return rows[0];
14
+ }
15
+
16
+ const findById = async (id) => {
17
+ const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id]);
18
+ return rows[0];
19
+ };
20
+
21
+ const findPublicById = async (id) => {
22
+ const { rows } = await db.query(`SELECT ${PUBLIC_COLUMNS} FROM users WHERE id = $1`, [id]);
23
+ return rows[0];
24
+ };
25
+
26
+ const createUser = async (userData) => {
27
+ const { name, email, password, role = 'user' } = userData;
28
+ const hashedPassword = await bcrypt.hash(password, 10);
29
+ const { rows } = await db.query('INSERT INTO users (name, email, password, role) VALUES ($1, $2, $3, $4) RETURNING id', [name, email, hashedPassword, role]);
30
+ return rows[0].id;
31
+ };
32
+
33
+ export default {
34
+ getAllUsers,
35
+ createUser,
36
+ findOne,
37
+ findById,
38
+ findPublicById
39
+ };
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "simo-api",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "type": "module",
6
+ "scripts": {
7
+ "migrate": "node-pg-migrate -j sql",
8
+ "migrate:up": "node-pg-migrate up -j sql",
9
+ "migrate:down": "node-pg-migrate down -j sql",
10
+ "start": "node index.js",
11
+ "dev": "nodemon index.js",
12
+ "test": "NODE_ENV=test node --test 'tests/**/*.test.js'",
13
+ "seed": "node seeds/seed.js"
14
+ },
15
+ "dependencies": {
16
+ "bcrypt": "^6.0.0",
17
+ "cookie-parser": "^1.4.7",
18
+ "cors": "^2.8.6",
19
+ "dotenv": "^18.0.8",
20
+ "express": "^5.3.0",
21
+ "express-rate-limit": "^8.7.1",
22
+ "jsonwebtoken": "^9.0.3",
23
+ "morgan": "^1.12.1",
24
+ "pg": "^8.23.1",
25
+ "zod": "^4.6.7"
26
+ },
27
+ "devDependencies": {
28
+ "node-pg-migrate": "^9.0.0",
29
+ "nodemon": "^3.1.14",
30
+ "supertest": "^7.3.1"
31
+ },
32
+ "private": true
33
+ }
@@ -0,0 +1,13 @@
1
+ import express from 'express';
2
+ const router = express.Router();
3
+
4
+ import authController from '../controllers/authController.js';
5
+ import { validate } from '../middlewares/validateMiddleware.js';
6
+ import { loginSchema, registerSchema } from '../validations/authValidation.js';
7
+
8
+ router.post('/login', validate(loginSchema), authController.login);
9
+ router.post('/register', validate(registerSchema), authController.register);
10
+ router.post('/refresh', authController.refresh, authController.clearRefreshCookieOnError);
11
+ router.post('/logout', authController.logout);
12
+
13
+ export default router;
@@ -0,0 +1,15 @@
1
+ import express from 'express';
2
+ const router = express.Router();
3
+
4
+ import authRoutes from './authRoutes.js';
5
+ import userRoutes from './userRoutes.js';
6
+ import productRoutes from './productRoutes.js';
7
+
8
+
9
+
10
+ router.use('/auth', authRoutes);
11
+ router.use('/user', userRoutes);
12
+ router.use('/product', productRoutes);
13
+
14
+
15
+ export default router;
@@ -0,0 +1,17 @@
1
+ import express from 'express';
2
+ const router = express.Router();
3
+
4
+ import productController from '../controllers/productController.js';
5
+ import authenticate from '../middlewares/authMiddleware.js';
6
+ import { authorize } from '../middlewares/roleMiddleware.js';
7
+ import { validate } from '../middlewares/validateMiddleware.js';
8
+ import { createProductSchema, updateProductSchema, productIdSchema } from '../validations/productValidation.js';
9
+
10
+
11
+ router.get('/getAll', productController.getAllProducts);
12
+ router.get('/:id', validate(productIdSchema, 'params'), productController.getProductById);
13
+ router.post('/create', authenticate, authorize('PRODUCT_CREATE'), validate(createProductSchema), productController.createProduct);
14
+ router.patch('/update/:id', authenticate, authorize('PRODUCT_UPDATE'), validate(productIdSchema, 'params'), validate(updateProductSchema), productController.updateProduct);
15
+ router.delete('/delete/:id', authenticate, authorize('PRODUCT_DELETE'), validate(productIdSchema, 'params'), productController.deleteProduct);
16
+
17
+ export default router;
@@ -0,0 +1,15 @@
1
+ import express from 'express';
2
+ const router = express.Router();
3
+
4
+ import userController from '../controllers/userController.js';
5
+ import authenticate from '../middlewares/authMiddleware.js';
6
+ import { authorize } from '../middlewares/roleMiddleware.js';
7
+ import { validate } from '../middlewares/validateMiddleware.js';
8
+ import { createUserSchema } from '../validations/userValidation.js';
9
+
10
+
11
+ router.get('/me', authenticate, userController.getMe);
12
+ router.get('/getAll', authenticate, authorize('USER_GETALL'), userController.getAllUsers);
13
+ router.post('/create', authenticate, authorize('USER_CREATE'), validate(createUserSchema), userController.createUser);
14
+
15
+ export default router;
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ import db from '../config/db.js';
3
+ import userModel from '../models/userModel.js';
4
+ import { ROLES } from '../config/permissions.js';
5
+ import { name, email, password } from '../validations/authValidation.js';
6
+
7
+ const adminSchema = z.object({
8
+ ADMIN_NAME: name,
9
+ ADMIN_EMAIL: email,
10
+ ADMIN_PASSWORD: password,
11
+ });
12
+
13
+ const seedAdmin = async () => {
14
+ const result = adminSchema.safeParse(process.env);
15
+
16
+ if (!result.success) {
17
+ console.error('Invalid admin variables in .env:');
18
+ for (const issue of result.error.issues) {
19
+ console.error(` - ${issue.path.join('.')}: ${issue.message}`);
20
+ }
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+
25
+ const { ADMIN_NAME, ADMIN_EMAIL, ADMIN_PASSWORD } = result.data;
26
+
27
+ const existing = await userModel.findOne(ADMIN_EMAIL);
28
+ if (existing) {
29
+ console.log(`Admin already exists: ${ADMIN_EMAIL}`);
30
+ return;
31
+ }
32
+
33
+ const id = await userModel.createUser({
34
+ name: ADMIN_NAME,
35
+ email: ADMIN_EMAIL,
36
+ password: ADMIN_PASSWORD,
37
+ role: ROLES.ADMIN,
38
+ });
39
+
40
+ console.log(`Admin created: ${ADMIN_EMAIL} (id ${id})`);
41
+ };
42
+
43
+ try {
44
+ await seedAdmin();
45
+ } catch (error) {
46
+ console.error('Seed failed:', error);
47
+ process.exitCode = 1;
48
+ } finally {
49
+ await db.end();
50
+ }
@@ -0,0 +1,94 @@
1
+ import bcrypt from 'bcrypt';
2
+ import jwt from 'jsonwebtoken';
3
+ import crypto from 'crypto';
4
+ import env from '../config/env.js';
5
+ import { ROLES } from '../config/permissions.js';
6
+ import userModel from '../models/userModel.js';
7
+ import refreshTokenModel from '../models/refreshTokenModel.js';
8
+ import AppError from '../utils/AppError.js';
9
+ import verifyToken from '../utils/verifyToken.js';
10
+
11
+ export const REFRESH_TOKEN_MAX_AGE = env.REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000;
12
+
13
+ const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
14
+
15
+ const generateAccessToken = (user) => {
16
+ const payload = { id: user.id, email: user.email, role: user.role };
17
+ return jwt.sign(payload, env.ACCESS_TOKEN_SECRET, { expiresIn: env.ACCESS_TOKEN_EXPIRES_IN });
18
+ };
19
+
20
+ const generateRefreshToken = (user) => {
21
+ const payload = { id: user.id, jti: crypto.randomUUID() };
22
+ return jwt.sign(payload, env.REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_MAX_AGE / 1000 });
23
+ };
24
+
25
+ const createSession = async (user) => {
26
+ const accessToken = generateAccessToken(user);
27
+ const refreshToken = generateRefreshToken(user);
28
+ const expiresAt = new Date(Date.now() + REFRESH_TOKEN_MAX_AGE);
29
+
30
+ await refreshTokenModel.create(user.id, hashToken(refreshToken), expiresAt);
31
+
32
+ return { accessToken, refreshToken };
33
+ };
34
+
35
+ const login = async (email, password) => {
36
+ const user = await userModel.findOne(email);
37
+
38
+ if (!user || !(await bcrypt.compare(password, user.password))) {
39
+ throw new AppError('Invalid email or password', 401);
40
+ }
41
+
42
+ const tokens = await createSession(user);
43
+
44
+ return {
45
+ ...tokens,
46
+ user: { id: user.id, name: user.name, email: user.email, role: user.role },
47
+ };
48
+ };
49
+
50
+ const register = async ({ name, email, password }) => {
51
+ const existingUser = await userModel.findOne(email);
52
+
53
+ if (existingUser) {
54
+ throw new AppError('Email already exists', 409);
55
+ }
56
+
57
+ return userModel.createUser({ name, email, password, role: ROLES.USER });
58
+ };
59
+
60
+ const refresh = async (token) => {
61
+ if (!token) {
62
+ throw new AppError('No refresh token', 401);
63
+ }
64
+
65
+ const payload = verifyToken(token, env.REFRESH_TOKEN_SECRET, 'Invalid or expired refresh token');
66
+
67
+ const storedToken = await refreshTokenModel.findByHash(hashToken(token));
68
+ if (!storedToken || storedToken.user_id !== payload.id) {
69
+ await refreshTokenModel.deleteAllForUser(payload.id);
70
+ throw new AppError('Refresh token revoked', 401);
71
+ }
72
+
73
+ const user = await userModel.findById(payload.id);
74
+ if (!user) {
75
+ throw new AppError('User no longer exists', 401);
76
+ }
77
+
78
+ await refreshTokenModel.deleteByHash(hashToken(token));
79
+
80
+ return createSession(user);
81
+ };
82
+
83
+ const logout = async (token) => {
84
+ if (token) {
85
+ await refreshTokenModel.deleteByHash(hashToken(token));
86
+ }
87
+ };
88
+
89
+ export default {
90
+ login,
91
+ register,
92
+ refresh,
93
+ logout
94
+ };
@@ -0,0 +1,37 @@
1
+ import productModel from '../models/productModel.js';
2
+ import AppError from '../utils/AppError.js';
3
+
4
+ const getAll = () => productModel.getAll();
5
+
6
+ const getById = async (id) => {
7
+ const product = await productModel.findById(id);
8
+ if (!product) {
9
+ throw new AppError('Product not found', 404);
10
+ }
11
+ return product;
12
+ };
13
+
14
+ const create = (data) => productModel.create(data);
15
+
16
+ const update = async (id, data) => {
17
+ const product = await productModel.update(id, data);
18
+ if (!product) {
19
+ throw new AppError('Product not found', 404);
20
+ }
21
+ return product;
22
+ };
23
+
24
+ const remove = async (id) => {
25
+ const deleted = await productModel.remove(id);
26
+ if (!deleted) {
27
+ throw new AppError('Product not found', 404);
28
+ }
29
+ };
30
+
31
+ export default {
32
+ getAll,
33
+ getById,
34
+ create,
35
+ update,
36
+ remove
37
+ };
@@ -0,0 +1,24 @@
1
+ import { ROLES } from '../config/permissions.js';
2
+ import userModel from '../models/userModel.js';
3
+ import AppError from '../utils/AppError.js';
4
+
5
+ const getAll = () => userModel.getAllUsers();
6
+
7
+ const getById = async (id) => {
8
+ const user = await userModel.findPublicById(id);
9
+ if (!user) {
10
+ throw new AppError('User not found', 404);
11
+ }
12
+ return user;
13
+ };
14
+
15
+ const create = ({ name, email, password, role }, currentUser) => {
16
+ const finalRole = currentUser.role === ROLES.ADMIN ? role : ROLES.USER;
17
+ return userModel.createUser({ name, email, password, role: finalRole });
18
+ };
19
+
20
+ export default {
21
+ getAll,
22
+ getById,
23
+ create
24
+ };
@@ -0,0 +1,174 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import request from 'supertest';
4
+ import app from '../app.js';
5
+ import db from '../config/db.js';
6
+
7
+ const stamp = Date.now();
8
+ const userEmail = `test.user.${stamp}@test.com`;
9
+ const adminEmail = `test.admin.${stamp}@test.com`;
10
+ const password = 'secret123';
11
+
12
+ let userToken;
13
+ let adminToken;
14
+ let productId;
15
+
16
+ const login = (email) => request(app).post('/api/auth/login').send({ email, password });
17
+
18
+ const refreshCookie = (res) => res.headers['set-cookie'].find((cookie) => cookie.startsWith('refreshToken=')).split(';')[0];
19
+
20
+ before(async () => {
21
+ await request(app).post('/api/auth/register').send({ name: 'Test User', email: userEmail, password });
22
+ await request(app).post('/api/auth/register').send({ name: 'Test Admin', email: adminEmail, password });
23
+ await db.query("UPDATE users SET role = 'admin' WHERE email = $1", [adminEmail]);
24
+
25
+ userToken = (await login(userEmail)).body.accessToken;
26
+ adminToken = (await login(adminEmail)).body.accessToken;
27
+ });
28
+
29
+ after(async () => {
30
+ await db.query('DELETE FROM users WHERE email LIKE $1', [`test.%.${stamp}@test.com`]);
31
+ if (productId) await db.query('DELETE FROM products WHERE id = $1', [productId]);
32
+ await db.end();
33
+ });
34
+
35
+ describe('Auth', () => {
36
+ it('register returns userId', async () => {
37
+ const email = `test.register.${stamp}@test.com`;
38
+ const res = await request(app).post('/api/auth/register').send({ name: 'Reg', email, password });
39
+ assert.equal(res.status, 201);
40
+ assert.equal(typeof res.body.userId, 'number');
41
+ });
42
+
43
+ it('register rejects invalid body', async () => {
44
+ const res = await request(app).post('/api/auth/register').send({ email: 'abc', password: '1' });
45
+ assert.equal(res.status, 400);
46
+ assert.deepEqual(res.body.errors.map((e) => e.field).sort(), ['email', 'name', 'password']);
47
+ });
48
+
49
+ it('register rejects existing email (case insensitive)', async () => {
50
+ const res = await request(app).post('/api/auth/register').send({ name: 'Dup', email: userEmail.toUpperCase(), password });
51
+ assert.equal(res.status, 409);
52
+ });
53
+
54
+ it('login rejects wrong password', async () => {
55
+ const res = await request(app).post('/api/auth/login').send({ email: userEmail, password: 'wrong-pass' });
56
+ assert.equal(res.status, 401);
57
+ });
58
+
59
+ it('login returns access token and refresh cookie', async () => {
60
+ const res = await login(userEmail);
61
+ assert.equal(res.status, 200);
62
+ assert.ok(res.body.accessToken);
63
+ assert.ok(refreshCookie(res));
64
+ });
65
+
66
+ it('refresh rotates the token and detects reuse', async () => {
67
+ const oldCookie = refreshCookie(await login(userEmail));
68
+
69
+ const first = await request(app).post('/api/auth/refresh').set('Cookie', oldCookie);
70
+ assert.equal(first.status, 200);
71
+ const newCookie = refreshCookie(first);
72
+
73
+ const reused = await request(app).post('/api/auth/refresh').set('Cookie', oldCookie);
74
+ assert.equal(reused.status, 401);
75
+
76
+ const afterReuse = await request(app).post('/api/auth/refresh').set('Cookie', newCookie);
77
+ assert.equal(afterReuse.status, 401);
78
+ });
79
+
80
+ it('keeps sessions on two devices', async () => {
81
+ const deviceA = refreshCookie(await login(userEmail));
82
+ const deviceB = refreshCookie(await login(userEmail));
83
+
84
+ assert.equal((await request(app).post('/api/auth/refresh').set('Cookie', deviceA)).status, 200);
85
+ assert.equal((await request(app).post('/api/auth/refresh').set('Cookie', deviceB)).status, 200);
86
+ });
87
+
88
+ it('logout revokes the refresh token', async () => {
89
+ const cookie = refreshCookie(await login(userEmail));
90
+ assert.equal((await request(app).post('/api/auth/logout').set('Cookie', cookie)).status, 200);
91
+ assert.equal((await request(app).post('/api/auth/refresh').set('Cookie', cookie)).status, 401);
92
+ });
93
+ });
94
+
95
+ describe('Users', () => {
96
+ it('GET /me returns the connected user without password', async () => {
97
+ const res = await request(app).get('/api/user/me').set('Authorization', `Bearer ${userToken}`);
98
+ assert.equal(res.status, 200);
99
+ assert.equal(res.body.user.email, userEmail);
100
+ assert.equal(res.body.user.password, undefined);
101
+ });
102
+
103
+ it('GET /me requires a token', async () => {
104
+ const res = await request(app).get('/api/user/me');
105
+ assert.equal(res.status, 401);
106
+ });
107
+
108
+ it('getAll is admin only', async () => {
109
+ const asUser = await request(app).get('/api/user/getAll').set('Authorization', `Bearer ${userToken}`);
110
+ const asAdmin = await request(app).get('/api/user/getAll').set('Authorization', `Bearer ${adminToken}`);
111
+ assert.equal(asUser.status, 403);
112
+ assert.equal(asAdmin.status, 200);
113
+ assert.ok(asAdmin.body.users.every((user) => user.password === undefined));
114
+ });
115
+ });
116
+
117
+ describe('Products', () => {
118
+ it('user cannot create a product', async () => {
119
+ const res = await request(app).post('/api/product/create').set('Authorization', `Bearer ${userToken}`).send({ name: 'Nope', price: 10 });
120
+ assert.equal(res.status, 403);
121
+ });
122
+
123
+ it('rejects invalid product', async () => {
124
+ const res = await request(app).post('/api/product/create').set('Authorization', `Bearer ${adminToken}`).send({ name: 'X', price: -5 });
125
+ assert.equal(res.status, 400);
126
+ });
127
+
128
+ it('admin creates a product', async () => {
129
+ const res = await request(app).post('/api/product/create').set('Authorization', `Bearer ${adminToken}`).send({ name: 'Test Product', price: 99.5, stock: 3 });
130
+ assert.equal(res.status, 201);
131
+ productId = res.body.product.id;
132
+ });
133
+
134
+ it('lists and gets products publicly', async () => {
135
+ const list = await request(app).get('/api/product/getAll');
136
+ assert.equal(list.status, 200);
137
+ assert.ok(list.body.products.some((product) => product.id === productId));
138
+
139
+ const one = await request(app).get(`/api/product/${productId}`);
140
+ assert.equal(one.status, 200);
141
+ assert.equal(one.body.product.name, 'Test Product');
142
+ });
143
+
144
+ it('admin updates a product', async () => {
145
+ const res = await request(app).patch(`/api/product/update/${productId}`).set('Authorization', `Bearer ${adminToken}`).send({ stock: 10 });
146
+ assert.equal(res.status, 200);
147
+ assert.equal(res.body.product.stock, 10);
148
+ });
149
+
150
+ it('rejects update with unknown field', async () => {
151
+ const res = await request(app).patch(`/api/product/update/${productId}`).set('Authorization', `Bearer ${adminToken}`).send({ hacked: true });
152
+ assert.equal(res.status, 400);
153
+ });
154
+
155
+ it('returns 400 for invalid id and 404 for missing product', async () => {
156
+ assert.equal((await request(app).get('/api/product/abc')).status, 400);
157
+ assert.equal((await request(app).get('/api/product/999999999')).status, 404);
158
+ });
159
+
160
+ it('admin deletes a product', async () => {
161
+ const res = await request(app).delete(`/api/product/delete/${productId}`).set('Authorization', `Bearer ${adminToken}`);
162
+ assert.equal(res.status, 200);
163
+ assert.equal((await request(app).get(`/api/product/${productId}`)).status, 404);
164
+ productId = null;
165
+ });
166
+ });
167
+
168
+ describe('Errors', () => {
169
+ it('unknown route returns JSON 404', async () => {
170
+ const res = await request(app).get('/api/does-not-exist');
171
+ assert.equal(res.status, 404);
172
+ assert.match(res.body.message, /Route not found/);
173
+ });
174
+ });
@@ -0,0 +1,10 @@
1
+ class AppError extends Error {
2
+ constructor(message, statusCode = 500, details) {
3
+ super(message);
4
+ this.name = 'AppError';
5
+ this.statusCode = statusCode;
6
+ this.details = details;
7
+ }
8
+ }
9
+
10
+ export default AppError;
@@ -0,0 +1,12 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import AppError from './AppError.js';
3
+
4
+ const verifyToken = (token, secret, message = 'Invalid or expired token') => {
5
+ try {
6
+ return jwt.verify(token, secret);
7
+ } catch {
8
+ throw new AppError(message, 401);
9
+ }
10
+ };
11
+
12
+ export default verifyToken;
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+
3
+ export const email = z.string().trim().toLowerCase().pipe(z.email());
4
+
5
+ export const password = z.string().min(6, 'Password must be at least 6 characters').max(72);
6
+
7
+ export const name = z.string().trim().min(2, 'Name must be at least 2 characters').max(255);
8
+
9
+ export const registerSchema = z.object({
10
+ name,
11
+ email,
12
+ password,
13
+ });
14
+
15
+ export const loginSchema = z.object({
16
+ email,
17
+ password: z.string().min(1, 'Password is required'),
18
+ });
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+
3
+ const productFields = {
4
+ name: z.string().trim().min(2).max(255),
5
+ description: z.string().trim().max(5000).nullable().optional(),
6
+ price: z.coerce.number().nonnegative().max(99999999.99),
7
+ stock: z.coerce.number().int().nonnegative().optional(),
8
+ image_url: z.url().nullable().optional(),
9
+ };
10
+
11
+ export const createProductSchema = z.object(productFields);
12
+
13
+ export const updateProductSchema = z
14
+ .object(productFields)
15
+ .partial()
16
+ .strict()
17
+ .refine((data) => Object.keys(data).length > 0, { message: 'At least one field is required' });
18
+
19
+ export const productIdSchema = z.object({
20
+ id: z.coerce.number().int().positive(),
21
+ });
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ import { ROLES } from '../config/permissions.js';
3
+ import { name, email, password } from './authValidation.js';
4
+
5
+ export const createUserSchema = z.object({
6
+ name,
7
+ email,
8
+ password,
9
+ role: z.enum(Object.values(ROLES)).optional(),
10
+ });