The Merus API normally returns a JSON object. The code that calls it normally assumes a JSON object. For a long time those two assumptions held by accident — none of the endpoints we exercise return anything else.
This week's probe was about the boundary. What if Merus returns the literal JSON null, which is a valid JSON value? What if some endpoint returns a top-level array, or a bare string? The body parses successfully. The variable that holds it is non-undefined. The next line accesses a property:
const data = await res.json();
if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
// audit and exit
}
If data is null, the very first read — data.errors — throws TypeError: Cannot read properties of null (reading 'errors'). The process dies with an uncaught exception. The exit code is non-zero. From the user's perspective the command failed, which is fine. From the audit log's perspective the call never happened, which is not fine.
Why the audit log matters here
This tool sends write operations to a system of record. Every write attempt — accepted, rejected, malformed — has to be traceable. The previous releases worked hard to make this true even when the server returned HTTP 200 with an errors array (the rejected-write case), and even when the server returned HTML or a redirect (the malformed-success case). For each of those, the audit log gets an entry before the process exits.
The null-body case slipped through both guards. The HTTP status was 200. The Content-Type said application/json. The body parsed. None of the pre-conditions to call the audit-log function were violated. The crash happened in the line between "we have a parsed body" and "we know what to do with it."
The fix is mechanical: check that the parsed body is a plain object before doing anything else with it. If it isn't, branch on whether this is a write (audit-then-fail) or a read (surface the value, exit clean — a top-level array might be the legitimate response from an endpoint we haven't surveyed yet).
The same shape in two other tools
Once the shape was named, grep found it in two more places. Both are read-paths, so no audit hole — but the user sees an uncaught TypeError instead of a clear "Merus returned a null body" message. Same fix applied to both: typed error message, exit 1.
This is the third instance this month of the broader shape: valid JSON, but not the shape the code assumes. The first was the HTTP-200-with-errors-array shape (Merus returns 200 even for auth failures, with the error in the body). The second was a top-level non-object body in a different code path. This is the third.
Why it took this long to find
Two reasons. First, the actual Merus endpoints we hit never return null. They return error objects, or success objects, or in malformed cases they return HTML. None of those triggers the bug, so it sat there indefinitely. Second, the assumption "parsed JSON is an object" is so deeply embedded in how the code reads that nobody flags it in review. data.errors looks like a normal property access. It IS a normal property access. It's just that for one valid input shape, the property access throws.
The probe that found it was simple: write a small script that mocks fetch() to return { ok: true, json: async () => null }, run the code path that calls it, and see what happens. Three test calls, three boundary cases (null, array, primitive). The null case threw. The next ten minutes were the fix.
The general lesson
If you have a write path that audits before exiting, every line between "got the response" and "wrote the audit entry" is a potential audit hole. Any throw on those lines defeats the audit guarantee. The defensive move is to either (a) audit on entry and update the entry on result, or (b) make sure no line in the response-handling preamble can throw on any valid JSON shape.
We picked (b) because it composes better with the existing read/write split — reads don't need an audit entry, so adding one would be noise. The cost is that every time the codebase adds a new response-handling site, the negative-shape guard has to come with it. So far the pattern is small enough to do by hand. If a fourth instance shows up, that's the trigger to extract a shared helper.
The version on the install URL is 4.5.88.