diff --git a/backend/.env.example b/backend/.env.example index e69de29..acbb71c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -0,0 +1,17 @@ + +# --- JWT Configuration --- +# Secret key for signing access tokens. Should be a long, random string. +JWT_SECRET=your_jwt_secret_key_here + +# Secret key for signing refresh tokens. Should be a long, random string, different from JWT_SECRET. +JWT_REFRESH_SECRET=your_jwt_refresh_secret_key_here + +# Access token expiration time (e.g., 10m, 1h, 1d) +ACCESS_TOKEN_EXPIRY=10m + +# Refresh token expiration time (e.g., 7d, 30d) +REFRESH_TOKEN_EXPIRY=7d + +# --- Server Configuration --- +# Port for the server to run on +PORT=4000 \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 2d1c5cd..c4d65e6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,23 +9,28 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "bcryptjs": "^3.0.2", "body-parser": "^1.20.2", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "dotenv": "^16.3.1", - "express": "^4.18.2", + "dotenv": "^16.6.1", + "express": "^4.21.2", "jsonwebtoken": "^9.0.2", - "mongoose": "^8.19.1", + "mongoose": "^8.19.2", "morgan": "^1.10.0", "nodemon": "^3.1.10" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/body-parser": "^1.19.2", + "@types/cookie-parser": "^1.4.9", "@types/cors": "^2.8.13", - "@types/express": "^4.17.21", + "@types/express": "^4.17.23", "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.7", + "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", - "typescript": "^5.2.2" + "typescript": "^5.9.3" } }, "node_modules/@cspotcode/source-map-support": { @@ -106,6 +111,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -127,6 +139,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -380,6 +402,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -558,6 +589,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -1360,9 +1413,9 @@ } }, "node_modules/mongoose": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.19.1.tgz", - "integrity": "sha512-oB7hGQJn4f8aebqE7mhE54EReb5cxVgpCxQCQj0K/cK3q4J3Tg08nFP6sM52nJ4Hlm8jsDnhVYpqIITZUAhckQ==", + "version": "8.19.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.19.2.tgz", + "integrity": "sha512-ww2T4dBV+suCbOfG5YPwj9pLCfUVyj8FEA1D3Ux1HHqutpLxGyOYEPU06iPRBW4cKr3PJfOSYsIpHWPTkz5zig==", "license": "MIT", "dependencies": { "bson": "^6.10.4", diff --git a/backend/package.json b/backend/package.json index dbc0ed5..b021519 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,22 +11,27 @@ "author": "", "license": "MIT", "dependencies": { + "bcryptjs": "^3.0.2", "body-parser": "^1.20.2", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "dotenv": "^16.3.1", - "express": "^4.18.2", + "dotenv": "^16.6.1", + "express": "^4.21.2", "jsonwebtoken": "^9.0.2", - "mongoose": "^8.19.1", + "mongoose": "^8.19.2", "morgan": "^1.10.0", "nodemon": "^3.1.10" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/body-parser": "^1.19.2", + "@types/cookie-parser": "^1.4.9", "@types/cors": "^2.8.13", - "@types/express": "^4.17.21", + "@types/express": "^4.17.23", "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.7", + "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", - "typescript": "^5.2.2" + "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 4bab589..200ca75 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,26 +1,132 @@ -import express, { Application } from 'express'; +import express, { Application, Request, Response } from 'express'; import cors from 'cors'; import morgan from 'morgan'; import bodyParser from 'body-parser'; import healthRoutes from './routes/healthRoutes'; + +// Consolidated Imports import productRoutes from "./routes/product.routes"; import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; +import cookieParser from "cookie-parser"; +import { signAccessToken, signRefreshToken, verifyRefreshToken } from "./controllers/auth"; +import { authenticate, AuthRequest } from "./middleware/authMiddleware"; + const app: Application = express(); import dotenv from "dotenv"; dotenv.config(); + // Middleware setup app.use(cors()); app.use(morgan('dev')); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); +// Middleware needed for Auth +app.use(cookieParser()); + + +// 3. User Interface and Mock Data +interface User { + id: string; + username: string; + passwordHash: string; + role: string; +} + +const users: User[] = [ + { id: "1", username: "alice", passwordHash: bcrypt.hashSync("password", 8), role: "admin" }, +]; + +const refreshTokens = new Map() + + +// 4. Authentication API Routes +app.post("/login", async (req: Request, res: Response) => { + const { username, password } = req.body; + const user = users.find((u) => u.username === username); + if (!user) return res.status(401).json({ error: "Invalid credentials" }); + + const match = await bcrypt.compare(password, user.passwordHash); + if (!match) return res.status(401).json({ error: "Invalid credentials" }); + + const payload = { sub: user.id, username: user.username, role: user.role }; + const accessToken = signAccessToken(payload); + const refreshToken = signRefreshToken(payload); + + refreshTokens.set(user.id, refreshToken); + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + res.json({ accessToken }); +}); + +app.post("/refresh", (req: Request, res: Response) => { + const token = req.cookies?.refreshToken || req.body.refreshToken; + + if (!token) { + return res.status(401).json({ error: "Refresh token is missing" }); + } + + try { + const payload = verifyRefreshToken(token); + const storedToken = refreshTokens.get(payload.sub); + + if (!storedToken) { + return res.status(401).json({ error: "Session not found or already logged out" }); + } + + if (storedToken !== token) { + return res.status(401).json({ error: "Token used is not the latest valid token" }); + } + + const cleanPayload = { + sub: payload.sub, + username: payload.username, + role: payload.role + }; + + const newAccess = signAccessToken(cleanPayload); + const newRefresh = signRefreshToken(cleanPayload); + + refreshTokens.set(cleanPayload.sub, newRefresh); + + res.cookie("refreshToken", newRefresh, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + res.json({ accessToken: newAccess }); + + } catch (error) { + console.error("Refresh token verification failed:", error); + res.status(401).json({ error: "Refresh token is expired or invalid" }); + } +}); + +app.post("/logout", authenticate, (req: AuthRequest, res: Response) => { + // req.user is guaranteed to exist by the 'authenticate' middleware + refreshTokens.delete(req.user!.sub); + res.clearCookie("refreshToken"); + res.status(204).send(); +}); + +app.get("/protected", authenticate, (req: AuthRequest, res: Response) => { + res.json({ message: "Protected route accessed", user: req.user }); +}); -// API Routes +// Existing API Routes app.use('/api/health', healthRoutes); app.use("/api/products", productRoutes); // Default app.get("/", (_req, res) => { - res.send("API is running 🚀"); + res.send("API is running "); }); // MongoDB connect (optional) diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts new file mode 100644 index 0000000..bd6f4de --- /dev/null +++ b/backend/src/controllers/auth.ts @@ -0,0 +1,50 @@ +import jwt, { SignOptions, JwtPayload } from "jsonwebtoken"; +import dotenv from "dotenv"; +dotenv.config(); + +if (!process.env.JWT_SECRET || !process.env.JWT_REFRESH_SECRET) { + throw new Error("JWT secrets missing in environment variables"); +} + +const accessSecret = process.env.JWT_SECRET as string; +const refreshSecret = process.env.JWT_REFRESH_SECRET as string; + +// Define your payload type +export interface TokenPayload extends JwtPayload { + sub: string; + username?: string; + role?: string; + //role?:"user"|"admin"; // Example roles +} + +export function signAccessToken(payload: TokenPayload) { + const options: SignOptions = { expiresIn: process.env.ACCESS_TOKEN_EXPIRY as any }; + + // --- CRITICAL FIX: Create a clean payload copy --- + const cleanPayload = { ...payload }; + delete cleanPayload.iat; // Remove "Issued At" claim + delete cleanPayload.exp; // Remove "Expiration" claim to allow options.expiresIn to work + // --- END FIX --- + + return jwt.sign(cleanPayload, accessSecret, options); +} + +export function signRefreshToken(payload: TokenPayload) { + const options: SignOptions = { expiresIn: process.env.REFRESH_TOKEN_EXPIRY as any }; + + // --- CRITICAL FIX: Create a clean payload copy --- + const cleanPayload = { ...payload }; + delete cleanPayload.iat; // Remove "Issued At" claim + delete cleanPayload.exp; // Remove "Expiration" claim to allow options.expiresIn to work + // --- END FIX --- + + return jwt.sign(cleanPayload, refreshSecret, options); +} + +export function verifyAccessToken(token: string): TokenPayload { + return jwt.verify(token, accessSecret) as TokenPayload; +} + +export function verifyRefreshToken(token: string): TokenPayload { + return jwt.verify(token, refreshSecret) as TokenPayload; +} \ No newline at end of file diff --git a/backend/src/middleware/authMiddleware.ts b/backend/src/middleware/authMiddleware.ts new file mode 100644 index 0000000..9368ec5 --- /dev/null +++ b/backend/src/middleware/authMiddleware.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyAccessToken } from '../controllers/auth'; + +export interface AuthenticatedRequest extends Request { + user?: any; +} + +export type AuthRequest = AuthenticatedRequest; + +// (Middleware file - authMiddleware.ts) + +export function authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction) { + const header = req.headers.authorization; + const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined; + + if (!token) { + // Use 401 for NO credentials (token missing entirely) + return res.status(401).json({ error: 'No token provided' }); + } + + try { + const payload = verifyAccessToken(token); + req.user = payload; + next(); + } catch { + // Use 403 for INVALID credentials (token present but invalid/expired/rejected) + // This is often seen as a better status for expired tokens. + return res.status(403).json({ error: 'Invalid or expired token' }); + } +} +export function authorizeRole(role: string) { + return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ error: 'Not authenticated' }); + } + if (req.user.role !== role) { + return res.status(403).json({ error: 'Forbidden' }); + } + next(); + }; +} diff --git a/backend/src/middleware/roleMiddleWare.ts b/backend/src/middleware/roleMiddleWare.ts new file mode 100644 index 0000000..2580d7e --- /dev/null +++ b/backend/src/middleware/roleMiddleWare.ts @@ -0,0 +1,16 @@ +import { Response, NextFunction } from "express"; +import { AuthRequest } from "./authMiddleware"; + +export const authorizeRoles = (...allowedRoles: string[]) => { + return (req: AuthRequest, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { role } = req.user; + if (!allowedRoles.includes(role)) { + return res.status(403).json({ error: "Forbidden: insufficient privileges" }); + } + next(); + }; +}; diff --git a/backend/src/server.ts b/backend/src/server.ts index d5c4ee8..e359b33 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,12 +1,11 @@ -import dotenv from 'dotenv'; +import dotenv from "dotenv"; dotenv.config(); -import app from './app'; +import app from './app'; -const PORT = process.env.PORT ? Number(process.env.PORT) : 5000; +const PORT = process.env.PORT ? Number(process.env.PORT) : 4000; app.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`Server running on port ${PORT}`); + console.log(`Server running on port ${PORT}`); }); diff --git a/backend/src/types.d.ts b/backend/src/types.d.ts new file mode 100644 index 0000000..ee0fbd1 --- /dev/null +++ b/backend/src/types.d.ts @@ -0,0 +1,5 @@ +declare namespace Express { + export interface Request { + user?: { sub: string; username: string; role: string }; + } +} diff --git a/package-lock.json b/package-lock.json index 453e899..4c8157e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "devDependencies": { "@commitlint/cli": "^19.1.0", "@commitlint/config-conventional": "^19.1.0", + "@types/cookie-parser": "^1.4.9", "autoprefixer": "^10.4.21", "husky": "^9.0.11", "postcss": "^8.5.6", @@ -374,6 +375,29 @@ "node": ">=v18" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", @@ -383,6 +407,59 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/node": { "version": "20.11.26", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.26.tgz", @@ -393,6 +470,58 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", + "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", + "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", diff --git a/package.json b/package.json index 529539e..270cb8d 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "devDependencies": { "@commitlint/cli": "^19.1.0", "@commitlint/config-conventional": "^19.1.0", + "@types/cookie-parser": "^1.4.9", "autoprefixer": "^10.4.21", "husky": "^9.0.11", "postcss": "^8.5.6",