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 User = require("./models/user.model");
const requestIdMiddleware = require("./middleware/request-id");
const { sendError } = require("./utils/http");
const app = express();
app.use(requestIdMiddleware);
@ -27,12 +28,12 @@ app.use(
})
);
app.get('/', async (req, res) => {
resText = `Grocery List API is running.\n` +
`Roles available: ${Object.values(User.ROLES).join(', ')}`
res.status(200).type("text/plain").send(resText);
});
app.get('/', async (req, res) => {
const resText = `Grocery List API is running.\n` +
`Roles available: ${Object.values(User.ROLES).join(', ')}`;
res.status(200).type("text/plain").send(resText);
});
const authRoutes = require("./routes/auth.routes");
@ -53,7 +54,19 @@ app.use("/config", configRoutes);
const householdsRoutes = require("./routes/households.routes");
app.use("/households", householdsRoutes);
const storesRoutes = require("./routes/stores.routes");
app.use("/stores", storesRoutes);
const storesRoutes = require("./routes/stores.routes");
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;

View File

@ -1,44 +1,45 @@
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const User = require("../models/user.model");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const User = require("../models/user.model");
const { sendError } = require("../utils/http");
exports.register = async (req, res) => {
let { username, password, name } = req.body;
username = username.toLowerCase();
console.log(`Registration attempt for ${name} => username:${username}`);
try {
const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(username, hash, name);
console.log(`User registered: ${username}`);
res.json({ message: "User registered", user });
} catch (err) {
res.status(400).json({ message: "Registration failed", error: err });
}
};
exports.login = async (req, res) => {
let { username, password } = req.body;
username = username.toLowerCase();
const user = await User.findByUsername(username);
if (!user) {
console.log(`⚠️ Login attempt -> No user found: ${username}`);
return res.status(401).json({ message: "User not found" });
}
try {
const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(username, hash, name);
console.log(`User registered: ${username}`);
res.json({ message: "User registered", user });
} catch (err) {
sendError(res, 400, "Registration failed");
}
};
exports.login = async (req, res) => {
let { username, password } = req.body;
username = username.toLowerCase();
const user = await User.findByUsername(username);
if (!user) {
console.log(`Login attempt with unknown user: ${username}`);
return sendError(res, 401, "User not found");
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
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(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "1 year" }
);
res.json({ token, userId: user.id, username, role: user.role });
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "1 year" }
);
res.json({ token, userId: user.id, username, role: user.role });
};

View File

@ -1,18 +1,19 @@
const jwt = require("jsonwebtoken");
const { sendError } = require("../utils/http");
function auth(req, res, next) {
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];
if (!token) return res.status(401).json({ message: "Invalid token format" });
if (!token) return sendError(res, 401, "Invalid token format");
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // id + role
next();
} 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 { sendError } = require("../utils/http");
// Middleware to check if user belongs to household
exports.householdAccess = async (req, res, next) => {
@ -7,16 +8,14 @@ exports.householdAccess = async (req, res, next) => {
const userId = req.user.id;
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
const isMember = await householdModel.isHouseholdMember(householdId, userId);
if (!isMember) {
return res.status(403).json({
error: "Access denied. You are not a member of this household."
});
return sendError(res, 403, "Access denied. You are not a member of this household.");
}
// Get user's role in household
@ -31,7 +30,7 @@ exports.householdAccess = async (req, res, next) => {
next();
} catch (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) => {
return (req, res, next) => {
if (!req.household) {
return res.status(500).json({
error: "Household context not set. Use householdAccess middleware first."
});
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
}
if (!allowedRoles.includes(req.household.role)) {
return res.status(403).json({
error: `Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
});
return sendError(
res,
403,
`Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
);
}
next();
@ -63,13 +62,11 @@ exports.storeAccess = async (req, res, next) => {
const storeId = parseInt(req.params.storeId || req.params.sId);
if (!storeId) {
return res.status(400).json({ error: "Store ID required" });
return sendError(res, 400, "Store ID required");
}
if (!req.household) {
return res.status(500).json({
error: "Household context not set. Use householdAccess middleware first."
});
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
}
// 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);
if (!hasStore) {
return res.status(403).json({
error: "This household does not have access to this store."
});
return sendError(res, 403, "This household does not have access to this store.");
}
// Attach store info to request
@ -90,20 +85,18 @@ exports.storeAccess = async (req, res, next) => {
next();
} catch (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
exports.requireSystemAdmin = (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: "Authentication required" });
return sendError(res, 401, "Authentication required");
}
if (req.user.role !== 'system_admin') {
return res.status(403).json({
error: "Access denied. System administrator privileges required."
});
return sendError(res, 403, "Access denied. System administrator privileges required.");
}
next();

View File

@ -1,6 +1,7 @@
const multer = require("multer");
const sharp = require("sharp");
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)
const upload = multer({
@ -42,7 +43,7 @@ const processImage = async (req, res, next) => {
next();
} 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) {
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))
return res.status(403).json({ message: "Forbidden" });
return sendError(res, 403, "Forbidden");
next();
};

View File

@ -1,4 +1,5 @@
const crypto = require("crypto");
const { normalizeErrorPayload } = require("../utils/http");
function generateRequestId() {
if (typeof crypto.randomUUID === "function") {
@ -25,10 +26,16 @@ function requestIdMiddleware(req, res, next) {
const originalJson = res.json.bind(res);
res.json = (payload) => {
if (isPlainObject(payload) && payload.request_id === undefined) {
return originalJson({ ...payload, request_id: requestId });
const normalizedPayload = normalizeErrorPayload(payload, res.statusCode);
if (
isPlainObject(normalizedPayload) &&
normalizedPayload.request_id === undefined
) {
return originalJson({ ...normalizedPayload, request_id: requestId });
}
return originalJson(payload);
return originalJson(normalizedPayload);
};
next();

View File

@ -1,13 +1,14 @@
const router = require("express").Router();
const controller = require("../controllers/auth.controller");
const router = require("express").Router();
const controller = require("../controllers/auth.controller");
const User = require("../models/user.model");
router.post("/register", controller.register);
router.post("/login", controller.login);
router.post("/", async (req, res) => {
resText = `Grocery List API is running.\n` +
`Roles available: ${Object.values(User.ROLES).join(', ')}`
res.status(200).type("text/plain").send(resText);
});
router.post("/register", controller.register);
router.post("/login", controller.login);
router.post("/", async (req, res) => {
const resText = `Grocery List API is running.\n` +
`Roles available: ${Object.values(User.ROLES).join(', ')}`;
res.status(200).type("text/plain").send(resText);
});
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,
};