feat: standardize error envelope and request id propagation
This commit is contained in:
parent
b3f607d8f8
commit
ac92bed8a1
@ -3,6 +3,7 @@ const cors = require("cors");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const User = require("./models/user.model");
|
const User = require("./models/user.model");
|
||||||
const requestIdMiddleware = require("./middleware/request-id");
|
const requestIdMiddleware = require("./middleware/request-id");
|
||||||
|
const { sendError } = require("./utils/http");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(requestIdMiddleware);
|
app.use(requestIdMiddleware);
|
||||||
@ -27,12 +28,12 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/', async (req, res) => {
|
app.get('/', async (req, res) => {
|
||||||
resText = `Grocery List API is running.\n` +
|
const resText = `Grocery List API is running.\n` +
|
||||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`
|
`Roles available: ${Object.values(User.ROLES).join(', ')}`;
|
||||||
|
|
||||||
res.status(200).type("text/plain").send(resText);
|
res.status(200).type("text/plain").send(resText);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const authRoutes = require("./routes/auth.routes");
|
const authRoutes = require("./routes/auth.routes");
|
||||||
@ -53,7 +54,19 @@ app.use("/config", configRoutes);
|
|||||||
const householdsRoutes = require("./routes/households.routes");
|
const householdsRoutes = require("./routes/households.routes");
|
||||||
app.use("/households", householdsRoutes);
|
app.use("/households", householdsRoutes);
|
||||||
|
|
||||||
const storesRoutes = require("./routes/stores.routes");
|
const storesRoutes = require("./routes/stores.routes");
|
||||||
app.use("/stores", storesRoutes);
|
app.use("/stores", storesRoutes);
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (res.headersSent) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = err.status || err.statusCode || 500;
|
||||||
|
const message =
|
||||||
|
statusCode >= 500 ? "Internal server error" : err.message || "Request failed";
|
||||||
|
|
||||||
|
return sendError(res, statusCode, message);
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
@ -1,44 +1,45 @@
|
|||||||
const bcrypt = require("bcryptjs");
|
const bcrypt = require("bcryptjs");
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const User = require("../models/user.model");
|
const User = require("../models/user.model");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
|
||||||
exports.register = async (req, res) => {
|
exports.register = async (req, res) => {
|
||||||
let { username, password, name } = req.body;
|
let { username, password, name } = req.body;
|
||||||
username = username.toLowerCase();
|
username = username.toLowerCase();
|
||||||
console.log(`Registration attempt for ${name} => username:${username}`);
|
console.log(`Registration attempt for ${name} => username:${username}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hash = await bcrypt.hash(password, 10);
|
const hash = await bcrypt.hash(password, 10);
|
||||||
const user = await User.createUser(username, hash, name);
|
const user = await User.createUser(username, hash, name);
|
||||||
console.log(`✅ User registered: ${username}`);
|
console.log(`User registered: ${username}`);
|
||||||
|
|
||||||
res.json({ message: "User registered", user });
|
res.json({ message: "User registered", user });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ message: "Registration failed", error: err });
|
sendError(res, 400, "Registration failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.login = async (req, res) => {
|
exports.login = async (req, res) => {
|
||||||
let { username, password } = req.body;
|
let { username, password } = req.body;
|
||||||
|
|
||||||
username = username.toLowerCase();
|
username = username.toLowerCase();
|
||||||
const user = await User.findByUsername(username);
|
const user = await User.findByUsername(username);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log(`⚠️ Login attempt -> No user found: ${username}`);
|
console.log(`Login attempt with unknown user: ${username}`);
|
||||||
return res.status(401).json({ message: "User not found" });
|
return sendError(res, 401, "User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, user.password);
|
const valid = await bcrypt.compare(password, user.password);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
console.log(`Invalid login attempt for user ${username}`);
|
console.log(`Invalid login attempt for user ${username}`);
|
||||||
return res.status(401).json({ message: "Invalid credentials" });
|
return sendError(res, 401, "Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user.id, role: user.role },
|
{ id: user.id, role: user.role },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: "1 year" }
|
{ expiresIn: "1 year" }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ token, userId: user.id, username, role: user.role });
|
res.json({ token, userId: user.id, username, role: user.role });
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
|
||||||
function auth(req, res, next) {
|
function auth(req, res, next) {
|
||||||
const header = req.headers.authorization;
|
const header = req.headers.authorization;
|
||||||
if (!header) return res.status(401).json({ message: "Missing token" });
|
if (!header) return sendError(res, 401, "Missing token");
|
||||||
|
|
||||||
const token = header.split(" ")[1];
|
const token = header.split(" ")[1];
|
||||||
if (!token) return res.status(401).json({ message: "Invalid token format" });
|
if (!token) return sendError(res, 401, "Invalid token format");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
req.user = decoded; // id + role
|
req.user = decoded; // id + role
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(401).json({ message: "Invalid or expired token" });
|
sendError(res, 401, "Invalid or expired token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const householdModel = require("../models/household.model");
|
const householdModel = require("../models/household.model");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
|
||||||
// Middleware to check if user belongs to household
|
// Middleware to check if user belongs to household
|
||||||
exports.householdAccess = async (req, res, next) => {
|
exports.householdAccess = async (req, res, next) => {
|
||||||
@ -7,16 +8,14 @@ exports.householdAccess = async (req, res, next) => {
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
if (!householdId) {
|
if (!householdId) {
|
||||||
return res.status(400).json({ error: "Household ID required" });
|
return sendError(res, 400, "Household ID required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is member of household
|
// Check if user is member of household
|
||||||
const isMember = await householdModel.isHouseholdMember(householdId, userId);
|
const isMember = await householdModel.isHouseholdMember(householdId, userId);
|
||||||
|
|
||||||
if (!isMember) {
|
if (!isMember) {
|
||||||
return res.status(403).json({
|
return sendError(res, 403, "Access denied. You are not a member of this household.");
|
||||||
error: "Access denied. You are not a member of this household."
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's role in household
|
// Get user's role in household
|
||||||
@ -31,7 +30,7 @@ exports.householdAccess = async (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Household access check error:", error);
|
console.error("Household access check error:", error);
|
||||||
res.status(500).json({ error: "Server error checking household access" });
|
sendError(res, 500, "Server error checking household access");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -39,15 +38,15 @@ exports.householdAccess = async (req, res, next) => {
|
|||||||
exports.requireHouseholdRole = (...allowedRoles) => {
|
exports.requireHouseholdRole = (...allowedRoles) => {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.household) {
|
if (!req.household) {
|
||||||
return res.status(500).json({
|
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
|
||||||
error: "Household context not set. Use householdAccess middleware first."
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowedRoles.includes(req.household.role)) {
|
if (!allowedRoles.includes(req.household.role)) {
|
||||||
return res.status(403).json({
|
return sendError(
|
||||||
error: `Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
|
res,
|
||||||
});
|
403,
|
||||||
|
`Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@ -63,13 +62,11 @@ exports.storeAccess = async (req, res, next) => {
|
|||||||
const storeId = parseInt(req.params.storeId || req.params.sId);
|
const storeId = parseInt(req.params.storeId || req.params.sId);
|
||||||
|
|
||||||
if (!storeId) {
|
if (!storeId) {
|
||||||
return res.status(400).json({ error: "Store ID required" });
|
return sendError(res, 400, "Store ID required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.household) {
|
if (!req.household) {
|
||||||
return res.status(500).json({
|
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
|
||||||
error: "Household context not set. Use householdAccess middleware first."
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if household has access to this store
|
// Check if household has access to this store
|
||||||
@ -77,9 +74,7 @@ exports.storeAccess = async (req, res, next) => {
|
|||||||
const hasStore = await storeModel.householdHasStore(req.household.id, storeId);
|
const hasStore = await storeModel.householdHasStore(req.household.id, storeId);
|
||||||
|
|
||||||
if (!hasStore) {
|
if (!hasStore) {
|
||||||
return res.status(403).json({
|
return sendError(res, 403, "This household does not have access to this store.");
|
||||||
error: "This household does not have access to this store."
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach store info to request
|
// Attach store info to request
|
||||||
@ -90,20 +85,18 @@ exports.storeAccess = async (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Store access check error:", error);
|
console.error("Store access check error:", error);
|
||||||
res.status(500).json({ error: "Server error checking store access" });
|
sendError(res, 500, "Server error checking store access");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Middleware to require system admin role
|
// Middleware to require system admin role
|
||||||
exports.requireSystemAdmin = (req, res, next) => {
|
exports.requireSystemAdmin = (req, res, next) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: "Authentication required" });
|
return sendError(res, 401, "Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.user.role !== 'system_admin') {
|
if (req.user.role !== 'system_admin') {
|
||||||
return res.status(403).json({
|
return sendError(res, 403, "Access denied. System administrator privileges required.");
|
||||||
error: "Access denied. System administrator privileges required."
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const multer = require("multer");
|
const multer = require("multer");
|
||||||
const sharp = require("sharp");
|
const sharp = require("sharp");
|
||||||
const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
|
const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
|
||||||
// Configure multer for memory storage (we'll process before saving to DB)
|
// Configure multer for memory storage (we'll process before saving to DB)
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
@ -42,7 +43,7 @@ const processImage = async (req, res, next) => {
|
|||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error processing image: " + error.message });
|
sendError(res, 400, `Error processing image: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
|
||||||
function requireRole(...allowedRoles) {
|
function requireRole(...allowedRoles) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.user) return res.status(401).json({ message: "Authentication required" });
|
if (!req.user) return sendError(res, 401, "Authentication required");
|
||||||
if (!allowedRoles.includes(req.user.role))
|
if (!allowedRoles.includes(req.user.role))
|
||||||
return res.status(403).json({ message: "Forbidden" });
|
return sendError(res, 403, "Forbidden");
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
|
const { normalizeErrorPayload } = require("../utils/http");
|
||||||
|
|
||||||
function generateRequestId() {
|
function generateRequestId() {
|
||||||
if (typeof crypto.randomUUID === "function") {
|
if (typeof crypto.randomUUID === "function") {
|
||||||
@ -25,10 +26,16 @@ function requestIdMiddleware(req, res, next) {
|
|||||||
|
|
||||||
const originalJson = res.json.bind(res);
|
const originalJson = res.json.bind(res);
|
||||||
res.json = (payload) => {
|
res.json = (payload) => {
|
||||||
if (isPlainObject(payload) && payload.request_id === undefined) {
|
const normalizedPayload = normalizeErrorPayload(payload, res.statusCode);
|
||||||
return originalJson({ ...payload, request_id: requestId });
|
|
||||||
|
if (
|
||||||
|
isPlainObject(normalizedPayload) &&
|
||||||
|
normalizedPayload.request_id === undefined
|
||||||
|
) {
|
||||||
|
return originalJson({ ...normalizedPayload, request_id: requestId });
|
||||||
}
|
}
|
||||||
return originalJson(payload);
|
|
||||||
|
return originalJson(normalizedPayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
const router = require("express").Router();
|
const router = require("express").Router();
|
||||||
const controller = require("../controllers/auth.controller");
|
const controller = require("../controllers/auth.controller");
|
||||||
|
const User = require("../models/user.model");
|
||||||
|
|
||||||
router.post("/register", controller.register);
|
router.post("/register", controller.register);
|
||||||
router.post("/login", controller.login);
|
router.post("/login", controller.login);
|
||||||
router.post("/", async (req, res) => {
|
router.post("/", async (req, res) => {
|
||||||
resText = `Grocery List API is running.\n` +
|
const resText = `Grocery List API is running.\n` +
|
||||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`
|
`Roles available: ${Object.values(User.ROLES).join(', ')}`;
|
||||||
|
|
||||||
res.status(200).type("text/plain").send(resText);
|
res.status(200).type("text/plain").send(resText);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
116
backend/utils/http.js
Normal file
116
backend/utils/http.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
function isPlainObject(value) {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.prototype.toString.call(value) === "[object Object]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCodeFromStatus(statusCode) {
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
return "bad_request";
|
||||||
|
case 401:
|
||||||
|
return "unauthorized";
|
||||||
|
case 403:
|
||||||
|
return "forbidden";
|
||||||
|
case 404:
|
||||||
|
return "not_found";
|
||||||
|
case 409:
|
||||||
|
return "conflict";
|
||||||
|
case 413:
|
||||||
|
return "payload_too_large";
|
||||||
|
case 415:
|
||||||
|
return "unsupported_media_type";
|
||||||
|
case 422:
|
||||||
|
return "unprocessable_entity";
|
||||||
|
case 429:
|
||||||
|
return "rate_limited";
|
||||||
|
case 500:
|
||||||
|
return "internal_error";
|
||||||
|
default:
|
||||||
|
return statusCode >= 500 ? "internal_error" : "request_error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrorPayload(payload, statusCode) {
|
||||||
|
if (statusCode < 400) return payload;
|
||||||
|
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message: payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(payload)) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message: "Request failed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(payload.error)) {
|
||||||
|
const code = payload.error.code || errorCodeFromStatus(statusCode);
|
||||||
|
const message = payload.error.message || "Request failed";
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
error: {
|
||||||
|
...payload.error,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.error === "string") {
|
||||||
|
const { error, ...rest } = payload;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message: error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.message === "string") {
|
||||||
|
const { message, ...rest } = payload;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
error: {
|
||||||
|
code: errorCodeFromStatus(statusCode),
|
||||||
|
message: "Request failed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(res, statusCode, message, code, extra = {}) {
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
...extra,
|
||||||
|
error: {
|
||||||
|
code: code || errorCodeFromStatus(statusCode),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
errorCodeFromStatus,
|
||||||
|
normalizeErrorPayload,
|
||||||
|
sendError,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user