Three Small Fixes That Made the API More Honest
An empty note got blamed on the wrong field, a missing actor was recorded as zero, and email subjects carried raw submitter text. Three small fixes, one theme.
TL;DR
API returned a generic error for missing notes because the specific check was ordered last, so it now returns a clear note-required message first. Status updates now return 401 when user context is missing instead of silently recording the action as the system. Email subjects are also sanitized to strip control characters and prevent header injection.
An Answer That Points at the Wrong Suspect
An admin saves a follow-up with the note field left empty, and the API answers: INVALID_INPUT, "invalid type or status". The actual problem is the missing note, but the message blames type and status. The frontend did not misread anything; the API itself answered sideways. I blamed the frontend first, assuming it sent the wrong field. Turns out the server side was equally confused: the note-required error was wrapped inside the generic input error, and the handler checked the generic one first.
In the code, the note-required sentinel wraps the generic invalid-input sentinel, the usual error-wrapping pattern in Go, while the handler walks a chain of sentinels with errors.Is, most specific first [1]. Because the note check sat below the generic check, its specific message drowned. The fix is an ordering change: check the note error first, answer with a bad request, a NOTE_REQUIRED code, and a message that actually matches: the follow-up note is required, at least five characters. The status code stays 400, the right answer for a client-side mistake [2]; only the honesty of the body changed.
This kind of sentinel chain reads best as a priority list of messages: errors that wrap other errors get checked first, most specific to most generic. And it is cheap to verify from outside: submit the payload without a note and look at the response. It should now say NOTE_REQUIRED with the matching message; if it still says INVALID_INPUT, the ordering never moved. No code reading required to tell whether the fix is live.
A Missing Actor Gets Rejected, Not Recorded Quietly
The second fix is subtler. The actor for a status update is taken from the JWT context, and zero is the standing code for "the system", stored as NULL in the history table. The old code pulled that context value without checking it existed. If the context was missing, the actor silently became zero, and the trail recorded a system action that no system ever made. Now, the JWT middleware chain is supposed to always run before this handler; the only realistic way the context goes missing is a route misconfiguration. Exactly the situation that should fail loudly.
Now: no context, and the handler answers 401, "missing user context". Fail-closed. Authorization is a different question from authentication: one establishes who you are, the other what you are allowed to do [3], and Broken Access Control still sits at the top of the OWASP Top 10 [3]. Accurate actor trails are part of that story: logs and audit trails are investigation material, not decoration [4]. If a trail can be zeroed by a technical accident, its forensic value disappears exactly when it is needed. I would rather have this handler complain with a 401 once in a while than keep a beautiful history that lies; it is the same instinct as guarding the dev quick-login before: a convenience door that looks harmless until it becomes the hole.
An Email Subject That Cannot Be Broken
The third fix looks like the most trivial one. Notification emails carry the submission's subject line, free text typed by whoever filed the request. A few lines down, that text lands in an email header as-is. Control characters like a newline inside a subject can split the header, and a splittable header is the classic opening move of header injection.
Subjects now pass through one small sanitizer: strip every control character, the non-printing ones including newline and carriage return. One place for every subject that carries submitter free text, while the HTML body is already escaped on its own path. The side effect I like most: centralizing the sanitizer means the next email feature reuses the same door, and nobody writes their own escaping variant.
Three fixes, a few dozen lines total, one theme: a system whose answers can be trusted. Errors that point at their actual cause, actors that cannot become zero by accident, and notifications that cannot be broken by whatever someone types into a form. Details like these never show up in a demo, but they decide whose 3 a.m. it will be.
Sources