DEV Community

Mafy Hidalgo
Mafy Hidalgo

Posted on

From Local Storage to a Real Backend: How I Built the RollbackHQ API

Part two of building RollbackHQ. In part one I built the React frontend with mock data in the browser. This time I gave it a real brain: a Node/Express + MongoDB backend with authentication, a safety pipeline, and analytics. Built for Uplift Code Camp, Project 5.

The problem with my first version

My P4 app, RollbackHQ, was a safety layer that catches bad AI-driven changes to a database before they're committed. It worked but it faked everything. The "database" was the browser's Local Storage, and the safety rules ran client-side. Clear the browser and your data was gone. Anyone could open DevTools and bypass the rules.

For P5, I moved the whole thing to where it belongs: a real server. The guiding principle became clients propose, the server decides. The frontend can only ask; the backend owns the truth.

The stack

  • Node.js + Express for the API
  • MongoDB (Atlas) with Mongoose for the database
  • Bcrypt + JWT for authentication
  • helmet, cors, dotenv for security and config

Lesson 1: passwords you can't read

The first thing I built was authentication, and the most important idea was that you never store a real password. You store a Bcrypt hash, a one-way scramble.

// register
const passwordHash = await bcrypt.hash(password, 10);
await User.create({ email, passwordHash });

// login
const isMatch = await bcrypt.compare(password, user.passwordHash);
Enter fullscreen mode Exit fullscreen mode

Hashing is one-way, like blending a smoothie, you can't un-blend it. At login you hash the typed password and compare hashes; you never need the original back. When I looked at my own database, the password field was pure gibberish like $2b$10$N9qo8.... Even I can't read it. That's the point.

Then, on successful login, the server hands out a JWT - a signed token that proves who you are on every future request:

const generateToken = (userId) =>
  jwt.sign({ id: userId }, process.env.JWT_SECRET, { expiresIn: "7d" });
Enter fullscreen mode Exit fullscreen mode

Lesson 2: the auth gate (middleware)

Every route except register and login is protected by one small function that runs before the route:

export const protect = async (req, res, next) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ message: "Not authorized, no token" });
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  req.user = await User.findById(decoded.id).select("-passwordHash");
  next(); // passed the check -> continue to the route
};
Enter fullscreen mode Exit fullscreen mode

Middleware was a new concept for me: it's a checkpoint that either calls next() to continue, or stops the request with a 401. And req.user is the magic part once the gate identifies the user, every decision downstream can be attributed to them.

Lesson 3: the safety pipeline (the heart of it)

When an AI change comes in, the server does three things in order and crucially, it does not touch the record:

// 1. Snapshot the current value FIRST
await Snapshot.create({ recordId, value: record.value, label: "auto-captured" });

// 2. Validate against a configurable threshold
const { status, reason } = evaluateChange(record.value, newValue, threshold);

// 3. Store the change as pending or flagged. Record is untouched.
await Change.create({ recordId, oldValue: record.value, newValue, status, reason });
Enter fullscreen mode Exit fullscreen mode

The rule engine is a pure function I ported straight from my P4 frontend, no database, no Express, just logic. That made it easy to move server-side. A change beyond the threshold gets flagged automatically; a normal one waits as pending. Either way, the actual data doesn't change until a human approves.

Lesson 4: one request, three entities

This was the big architectural shift from P4. In the frontend version, approving a change meant the browser updated three things itself. On a real backend, that's fragile and insecure. So now the frontend sends one request, and the server does all three updates:

// PATCH /api/changes/:id  { decision: "approve" }
record.value = change.newValue;        // 1. update the record
await record.save();
change.status = "approved";            // 2. flip the change
change.decidedBy = req.user._id;       //    ...attributed to the user
await AuditEntry.create({              // 3. write the audit log
  recordId, userId: req.user._id, action: "approved"
});
Enter fullscreen mode Exit fullscreen mode

One call, three entities, all attributed. And a guard stops you from deciding the same change twice (409 Conflict). The frontend just displays whatever the server returns.

Lesson 5: analytics with MongoDB aggregations

For the reporting requirement I built an "AI Trust Report", how often the AI produces risky changes. Instead of pulling everything into Node and counting, I let MongoDB do it:

const byStatus = await Change.aggregate([
  { $group: { _id: "$status", count: { $sum: 1 } } },
]);
Enter fullscreen mode Exit fullscreen mode

$group buckets documents and counts them, like SQL's GROUP BY, but in Mongo. The endpoint returns a flag rate, a status breakdown, and the most-corrected records. It's a genuine analytics feature, not a checkbox.

Deploying: the Atlas whitelist gotcha

I deployed the API to Render and immediately got a 502. The logs told the real story:

MongoDB connection failed: Could not connect... make sure your
current IP is on your Atlas cluster's IP whitelist

Render's servers weren't allowed to reach my database. The fix was setting Atlas Network Access to allow all IPs (0.0.0.0/0), fine for a student project. The other lesson: on a host, you don't hardcode a port. process.env.PORT lets Render assign its own, which is exactly why the app said "Server running on :10000" in the logs.

Connecting the two halves

The final step was pointing my deployed React frontend at the live API. I added a small API service layer that attaches the JWT to every request:

async function request(path, options = {}) {
  const res = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
    },
  });
  if (!res.ok) throw new Error((await res.json()).message);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Now the whole thing runs on the public internet: React on Netlify → Express on Render → MongoDB Atlas. Login, create a record, watch the server catch a bad AI change, approve it, see the analytics, all with real, persisted data.

What I learned

  • Clients propose, the server decides. Anything the frontend can do, a user can do, so the rules and the writes have to live on the server.
  • Middleware is just a checkpoint. Auth is one function that runs before your routes and either lets the request through or blocks it.
  • Read the deploy logs. My 502 wasn't mysterious, the logs named the exact problem (Atlas whitelist) in plain English.
  • Pure functions travel well. Because my rule engine had no dependencies, moving it from the browser to the server was almost copy-paste.

The AI is still simulated, the frontend proposes the changes. The next step is a real AI integration behind the API, plus email alerts when an anomaly is flagged and role-based permissions. Same product, growing up.

Try it: https://rollbackhq.netlify.app/live log in, create a record, and hit "Simulate Faulty AI" to watch the server catch a bad change.

Top comments (0)