feat: standardize error envelope and request id propagation

This commit is contained in:
Nico 2026-02-16 01:18:51 -08:00
parent b3f607d8f8
commit ac92bed8a1
9 changed files with 221 additions and 86 deletions

View File

@ -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;

View File

@ -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 });
}; };

View File

@ -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");
} }
} }

View File

@ -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();

View File

@ -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}`);
} }
}; };

View File

@ -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();
}; };

View File

@ -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();

View File

@ -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
View 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,
};