DEV Community

Authenticated Node.js Web Chatbot Backend Validates Streaming Reviews (Without an SDK)

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. What failure signal matters in an authenticated web app chatbot backend API? 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 safe Go boundary: one stream, one validated result 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; d

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.