biya:~$ cat the-error-message-was-pointing-at-the-wrong-layer.md

The request never arrived, and the error message blamed the database

Staff could not submit a payment request. The error pointed at MongoDB. The request had never reached MongoDB, or the backend, or the network.

Two symptoms, reported the same afternoon.

Staff submitting a payment request from the dashboard got an error and nothing happened: The string did not match the expected pattern. And the order-management payment list was empty for Staff, no transactions at all.

Owners were fine. Everything worked for them.

Stranger still: the point-of-sale "Settle" flow creates the same payment request, through the same backend, and it worked perfectly for Staff. Same user, same permission, same operation, two different outcomes depending on which screen it was started from.

The error message was very specific, and completely wrong

The string did not match the expected pattern. is a BSON error. MongoDB throws it when you hand it a string that cannot be cast to an ObjectId. That is a precise, informative message, and it sent us straight into the database layer.

We traced every ObjectId on the path. The staff member's own id on the request. The branch id derived on the dashboard. The ledger library's commit. The error handler itself, in case it was mangling something on the way out.

All of it was fine. Every id was well formed. Nothing on that path could have produced that error.

Which was the first real clue, and we did not treat it as one for a while: when a thorough search finds nothing, the assumption being searched under is usually the thing that is wrong.

The clue was an absence

We submitted a request with a deliberately invalid customer id. Something the backend would certainly reject, loudly, with a message we knew the wording of.

Nothing. No error. No log line. Not the rejection we were fishing for, not anything else.

That is the finding. Not "the backend returned an error", but the backend was never asked. The request was not failing somewhere deep in the stack. It was not arriving at all.

Middleware runs before everything

The frontend has middleware doing role-based navigation. Staff and Managers should not land on owner-only pages, so anything outside a permitted list redirects them back to the dashboard. Reasonable, and it worked exactly as written.

The problem is what it ran on. The matcher excludes static files and image assets and nothing else, which means it also matches /api/. And no API path was in the permitted list, because the list was written for pages.

So for a Staff member, a browser request to an API route was not an API request. It was an unpermitted page navigation, and middleware did what it was told: redirected it to the dashboard.

The rewrite that forwards those calls to the backend sits behind the middleware. It never got the chance to run. The request was turned around one layer before the layer everyone was reading.

Why the point-of-sale flow was immune

This is the detail that had made the bug look impossible, and it is the same detail that identifies it.

The dashboard form makes a client-side call: browser, then middleware, then rewrite, then backend. The Settle flow is a server action, which runs on the server itself and never passes through browser middleware at all.

Same user, same role, same endpoint. One goes through the middleware and one does not.

If an operation works as a server action but fails as a client-side call for the same user, the request is being stopped before it leaves the frontend. Middleware is the first place to look, not the last.

And the error message?

The browser's fetch followed the redirect, as browsers do, and received the dashboard: an HTML page, with a perfectly cheerful 200 OK. The calling code then did what it always does and asked for JSON.

Parsing HTML as JSON throws. The message that surfaced described a string that did not match an expected pattern, which is true, and which happens to read exactly like a database casting error.

Nothing lied. The parser reported precisely what it saw. It simply had no way to say the useful thing, which was "I was handed a web page when I asked for data, because your request was quietly sent somewhere else."

The fix

One condition. Exclude API paths from a rule that was only ever meant for page navigation:

const isApiRoute = path.startsWith('/api/')
 
if (!isApiRoute && !isGeneralAccessRoute && !isPrivileged(session.role)) {
  return NextResponse.redirect(new URL('/dashboard', req.url))
}

API routes have their own authorisation on the backend, where it belongs. Page-navigation rules and API access control are different jobs and should never have shared a condition.

We kept the matcher wide rather than excluding /api/ from it, because another rewrite does need to run on every path. The guard belongs in the logic, where it is visible to whoever reads the rule next, rather than in a regular expression at the bottom of the file.

Fixing it also silently fixed things nobody had reported yet. Every client-side API call from a Staff or Manager account had been failing the same way. The empty payment list was never a separate bug; it was the same one wearing different clothes.

What we took from it

A very specific error message is not a reliable pointer to where the problem is. It tells you what the last piece of code to give up was looking at. That code can be several layers away from the cause, and the more precise its wording, the more convincing the wrong direction looks.

Absence of a log is evidence, and it is stronger than most logs. We spent hours reading code on the path between the frontend and the database, when nothing had travelled that path. Now the first question on any "it does nothing" report is whether the backend saw the request at all, established with a deliberately bad input that would produce a known error. If that error does not appear, the search moves in front of the backend rather than behind it.

Two paths to the same operation are a free experiment. Server action worked, client call did not. That difference alone locates the fault in the browser-side request pipeline, and it was sitting in front of us from the first hour, looking like an unrelated curiosity.