Authenticated Node.js Web Chatbot Backend Validates Streaming Reviews (Without an SDK) A developer describes building an authenticated Node.js web chatbot backend that validates streaming code-review responses before accepting them. The approach uses a narrow Go interface for the model client, typed progress events, and a state machine with idempotency keys to ensure only complete, validated findings are treated as authoritative. The developer emphasizes that transport activity is not business completion and recommends persisting terminal outcomes to handle retries safely. Short answer: put authentication, schema validation, and stream ownership in a small backend API, and treat a code-review response as accepted only after one complete, validated findings document arrives. The browser can render provisional events, but it must not turn partial model text into e-commerce release decisions. This is a correctness choice, not an SDK preference. A signed-in web app needs an authority boundary between a user's session and model access, while a code-review chatbot needs a second boundary between plausible text and findings that automation may consume. Keep both boundaries in code you operate. The upstream model client should sit behind a narrow Go interface so an SDK, a plain HTTP client, or a different runtime adapter can change without changing browser behavior. The operational recommendation is blunt: stream progress, commit structure . Send typed progress events for responsiveness, then send one terminal result event only after decoding and validating the full review object. If the connection closes first, the run is incomplete. No finding should quietly become authoritative because its opening brace happened to cross the wire. The dangerous signal is not a slow first token. It is a stream that looked successful to a person but never produced a valid terminal object. In an e-commerce pull request, a half-rendered finding about checkout tax logic can appear actionable even though the missing tail contained the file path, severity, or evidence. HTTP streaming makes bytes available incrementally; it does not make an incomplete application document correct. Server-sent events also have a defined event-stream format, including named events and data fields, but the application still owns the meaning of completion 1 . Bytes aren't findings. Count outcomes at the run level. A useful state machine is accepted - streaming - validated with terminal alternatives such as rejected , invalid , and abandoned . The metric that deserves an alert is the ratio of accepted runs that fail to reach validated within the service's own deadline. Time to first event is a latency objective; validated completion is the correctness objective. Don't merge them into one green average. I've been paged by missed jobs and duplicate deliveries. The lesson transfers cleanly: transport activity is not business completion, and a retry without an identity is a new side effect. Give every review request a client-generated idempotency key, bind it to the authenticated principal and a digest of the submitted diff, and persist the terminal outcome. A retry with the same key and same digest may observe the existing run; the same key with a different digest should be rejected as a conflict. HTTP defines 409 Conflict for a request that conflicts with current resource state 2 . Picture the failure sequence before writing the handler. A merchandiser asks the chatbot to review a checkout change, and the browser submits request key review-7f3 with a digest of that exact diff. The backend authenticates the user, records the key and digest, starts the adapter, and emits progress . The Wi-Fi connection then drops. The browser has some reassuring text, but the store has no validated result, so the UI labels the attempt incomplete and reconnects with review-7f3 . If the first run is still active, the second connection observes it rather than starting another model call. If the first run already committed, the backend returns or replays that stored terminal result. If a UI defect reuses review-7f3 for a different diff, the digest mismatch produces 409 instead of attaching old findings to new code. Now consider the opposite ordering: the adapter completes, validation passes, persistence commits, and the socket closes one instruction before the terminal event reaches the browser. The retry still finds the committed result. This is why the durable commit must precede the result event and why a browser-local “received some tokens” flag cannot be the source of truth. It also exposes the rollback rule: never clear idempotency records merely because an adapter deployment changed. They describe application work, not adapter health. No benchmark or vendor feature settles this sequence; the state machine does. Retries need identity. Keep auth boring. The browser sends its normal session credential to your backend; the backend resolves the principal before accepting a review; upstream credentials never enter the browser or an event payload. OAuth guidance for browser-based applications describes the threats and security measures for apps that execute in a browser 3 . Your exact session mechanism may differ, but the trust boundary should not. One trap is recording only HTTP status. A 200 can mean that headers were sent and the stream began, while the application result remains absent. Log the request ID, principal ID in a privacy-safe internal form, diff digest, schema version, terminal state, event count, and elapsed time. Never log the submitted source diff or model text by default; code and prompts can contain credentials, customer data, or unreleased business logic. The browser-facing contract can stay small: progress says the run is alive, result carries a complete versioned document, and error closes the attempt without a usable result. Those event names are application protocol, not vendor protocol. The backend translates whatever its selected model adapter emits into this stable contract. Below is the core shape. ReviewModel is deliberately generic. Its implementation may use plain HTTP or an SDK, but handlers don't know which. The validator belongs after full JSON decoding and before the terminal event; syntactic JSON alone cannot establish that a path is present, a severity is allowed, or line numbers are sensible. package review import "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" type Finding struct { Path string json:"path" Line int json:"line" Severity string json:"severity" Message string json:"message" } type Review struct { SchemaVersion string json:"schema version" Findings Finding json:"findings" } type ReviewModel interface { Review ctx context.Context, diff byte, onProgress func string error byte, error } type RunStore interface { Begin ctx context.Context, principal, key, digest string bool, error Commit ctx context.Context, principal, key string, review Review error Fail ctx context.Context, principal, key, reason string error } type Handler struct { model ReviewModel runs RunStore } func h Handler ServeHTTP w http.ResponseWriter, r http.Request { principal, ok := authenticatedPrincipal r.Context if ok { http.Error w, "authentication required", http.StatusUnauthorized return } key := r.Header.Get "Idempotency-Key" if key == "" { http.Error w, "idempotency key required", http.StatusBadRequest return } diff, err := io.ReadAll http.MaxBytesReader w, r.Body, 1<<20 if err = nil { http.Error w, "invalid request body", http.StatusBadRequest return } sum := sha256.Sum256 diff digest := hex.EncodeToString sum : started, err := h.runs.Begin r.Context , principal, key, digest if err = nil || started { http.Error w, "review request conflicts with an existing run", http.StatusConflict return } flusher, ok := w. http.Flusher if ok { http.Error w, "streaming unavailable", http.StatusNotImplemented return } w.Header .Set "Content-Type", "text/event-stream" w.Header .Set "Cache-Control", "no-cache" w.WriteHeader http.StatusOK emit := func event string, value any error { payload, err := json.Marshal value if err = nil { return err } if , err := fmt.Fprintf w, "event: %s\ndata: %s\n\n", event, payload ; err = nil { return err } flusher.Flush return nil } raw, err := h.model.Review r.Context , diff, func stage string error { return emit "progress", map string string{"stage": stage} } if err = nil { = h.runs.Fail r.Context , principal, key, "upstream failed" = emit "error", map string string{"code": "review failed"} return } var result Review if err := json.Unmarshal raw, &result ; err = nil || validate result = nil { = h.runs.Fail r.Context , principal, key, "invalid result" = emit "error", map string string{"code": "invalid result"} return } if err := h.runs.Commit r.Context , principal, key, result ; err = nil { = emit "error", map string string{"code": "commit failed"} return } = emit "result", result } func validate r Review error { if r.SchemaVersion = "1" { return errors.New "unsupported schema version" } for , f := range r.Findings { if f.Path == "" || f.Line < 1 || f.Message == "" { return errors.New "incomplete finding" } switch f.Severity { case "low", "medium", "high": default: return errors.New "invalid severity" } } return nil } The omitted authenticatedPrincipal implementation is application-specific, as are persistence and the model adapter. That separation is intentional. Authentication middleware should place a verified principal in the context; the handler should never infer identity from request JSON. The store also needs an atomic uniqueness rule over principal and idempotency key. Without that constraint, two replicas can both decide they started first. There is a sharp edge in the example: after response headers are flushed, the server cannot replace the response with a different HTTP status. Application errors therefore travel as typed stream events. Clients must require exactly one result event and treat EOF before it as failure. Small rule. Large consequence. For production, bound the request body, cap concurrent reviews per principal, set an end-to-end deadline, and cancel model work when the request context closes. Decide whether work should survive browser disconnects before implementation. Interactive reviews usually favor cancellation; durable reviews need a queued job and a reconnectable event log. Mixing those semantics creates orphan work that nobody can retrieve. JSON Schema provides a vocabulary for annotating and validating JSON document structure 4 . Use it at the boundary even if the model API offers a structured-output mode, because your application contract is narrower than “valid JSON.” The schema should reject unknown versions, missing fields, invalid severity values, impossible line numbers, and fields your UI would otherwise ignore. A second semantic pass should verify that each path belongs to the submitted change and that each line can be mapped to the diff. The catch is that strict validation trades availability for correctness. A review with one malformed finding becomes unusable under an all-or-nothing policy. That is appropriate when findings can block a release or feed automation. It is not suitable when the chatbot is purely conversational and users prefer partial prose over no answer; in that case, stream text as text and never label it a validated code review. Don't pretend the two products have the same contract. Prompt design still matters, but it is not the enforcement layer. Tell the model the schema version, allowed severity enum, and rule that evidence must refer to changed lines. Then test adversarial inputs: an empty diff, a deleted file, a renamed file, a patch containing prompt-like instructions, a very large generated file, and a valid change with no findings. Prompt engineering references can help organize those experiments 5 . Embeddings are useful for related retrieval tasks, but they do not validate a structured review document 6 . I'm not sure which schema strictness is right for every review team. The evidence that resolves it is local: identify every downstream consumer and ask what it does with a missing or unknown field. If a bot can post comments automatically, reject ambiguity. If only a person reads the output, a looser display contract may be acceptable, provided the UI marks the result as unvalidated. Model and transport choices now become secondary selection criteria. Run the same corpus through each adapter and score exact schema acceptance, semantic path-and-line validity, cancellation behavior, and duplicate suppression. Latency and cost belong in the report, but neither can rescue a backend that occasionally commits malformed findings. Start with a replayable fixture set drawn from synthetic diffs or code approved for test use. Never depend on live repositories in a release gate. For every fixture, assert the terminal state, schema version, finding paths, permitted severities, and the absence of a second terminal event. Add transport tests that cut the connection after the first progress event, retry the same idempotency key twice, reuse a key with a changed digest, and cancel the request while the adapter is producing output. Then canary by schema version. The browser advertises the versions it understands, the backend emits one explicit version, and persistence records it with the result. During rollout, compare accepted runs with validated runs and break the data down by adapter, schema version, and application release. An increase in invalid result is a correctness regression even if median latency improves. Rollback should change one thing: route new runs back to the previous adapter or prompt-and-schema bundle. Existing run identities and stored terminal results must remain readable. Do not retry every in-flight request during rollback; clients may already be reconnecting, and the idempotency store is the authority on whether work exists. Drain, observe, then retire. Keep the runbook trigger concrete. Pause rollout when validated-completion rate crosses the team's error-budget threshold, when duplicate terminal results are nonzero, or when semantic checks accept paths outside the submitted diff. I won't invent universal percentages; traffic shape, review criticality, and sample size determine the threshold. Record the chosen values before deployment so an incident is not the first time the team debates them. The selection decision follows from those tests. Choose the simplest backend adapter that preserves authentication, cancellation, idempotency, and exact result validation under your fixture corpus. Stick with an SDK when it materially reduces protocol maintenance and its stream semantics fit this boundary; use a plain HTTP adapter when dependency weight, runtime support, or provider portability matters more. Neither choice removes the application-level commit point.