changeset 221:ce7f4400c2de hg-web

[hg-web] Harden forge and add changeset UI
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 09:01:24 -0700
parents 8c9bb0b0759e
children a8d6435dc021
files hg-web/README.md hg-web/deploy.sh hg-web/main.c hg-web/src/components/app.tsx hg-web/src/components/graph.tsx hg-web/src/index.css markdown_converter/markdown_to_html.c markdown_converter/tests/BUILD markdown_converter/tests/markdown_to_html_test.c seobeo/s_http_client.c seobeo/s_web.c seobeo/seobeo.h seobeo/seobeo_internal.h seobeo/tests/BUILD seobeo/tests/seobeo_http_content_length_test.c seobeo/tests/seobeo_http_framing_test.c seobeo/tests/seobeo_http_timeout_test.c seobeo/tests/seobeo_response_test.c
diffstat 18 files changed, 1650 insertions(+), 543 deletions(-) [+]
line wrap: on
line diff
--- a/hg-web/README.md	Sun Aug 02 08:34:54 2026 -0700
+++ b/hg-web/README.md	Sun Aug 02 09:01:24 2026 -0700
@@ -111,6 +111,13 @@
   -> ApiGetGraph
   -> GET hg-serve/graph/<revision>?...
   -> React rows + custom pencil/panda canvas
+
+graph row selection
+  -> /changeset/<revision>
+  -> GET /api/changeset/<revision>
+  -> ApiGetChangeset
+  -> GET hg-serve/json-rev/<revision>
+  -> changeset metadata + rendered diff
 ```
 
 ### Mercurial clone, pull, and push
@@ -129,11 +136,14 @@
 | --- | --- | --- | --- |
 | `GET` | `/` | `GetReactHome` | Application shell |
 | `GET` | `/directories` | `GetReactHome` | Legacy application-shell route |
+| `GET` | `/directory` | `GetReactHome` | Repository browser application route |
 | `GET` | `/graph` | `GetReactHome` | Commit graph application route |
+| `GET` | `/changeset/:changeset_id` | `GetReactHome` | Changeset application route |
 | `GET` | `/api/repo/list` | `ApiListDirectory` | Directory listing JSON |
 | `GET` | `/api/repo/file` | `ApiGetFile` | Raw tracked file |
-| `GET` | `/api/repo/readme` | `ApiGetReadme` | Alias of the raw-file handler |
+| `GET` | `/api/repo/readme` | `ApiGetReadme` | Directory README content |
 | `GET` | `/api/graph/:graph_id` | `ApiGetGraph` | Mercurial graph JSON |
+| `GET` | `/api/changeset/:changeset_id` | `ApiGetChangeset` | Changeset metadata and diff JSON |
 | `GET`, `POST` | `/repo` | `StreamHgWireProtocol` | Mercurial wire protocol |
 
 ## Frontend ownership
@@ -159,6 +169,8 @@
 ```bash
 bazel build //hg-web:hg_web_server
 bazel build //hg-web:hg_web_server_bundle
+bazel test //markdown_converter/tests:markdown_to_html_test
+bazel test //seobeo/tests:all
 ```
 
 Run the Mercurial backend:
@@ -189,9 +201,12 @@
   -> hg serve service on 127.0.0.1:4444
 ```
 
-`deploy.sh` currently builds an optimized bundle, copies it to `/opt`, swaps
-the active directory, changes ownership to `hg_web_server:zenbu_team`, and
-restarts `hg_web_server.service`.
+`deploy.sh` builds an optimized bundle into a revisioned release directory,
+atomically repoints `/opt/hg_web_server_bundle_active`, restarts
+`hg_web_server.service`, and checks `http://127.0.0.1:6970/`. A failed restart
+or health check restores the previous release and restarts it. The service,
+release root, active path, health URL, user, and group can be overridden with
+environment variables.
 
 ## Forge capability tree
 
@@ -202,7 +217,7 @@
 │   ├── Syntax highlighting                    available
 │   ├── README rendering                       available
 │   ├── Commit graph                           available
-│   ├── Changeset detail and diff              planned
+│   ├── Changeset detail and diff              available
 │   ├── Branches, bookmarks, and tags          planned
 │   ├── File history and blame                 planned
 │   └── Search                                 planned
@@ -218,7 +233,7 @@
     ├── Live logs and job status API           planned
     ├── Forge status and log screens           planned
     ├── Artifact retention                     planned
-    └── Atomic deploy, health check, rollback  planned
+    └── Atomic deploy, health check, rollback  available
 ```
 
 ## Automation data flow
@@ -245,12 +260,10 @@
 
 ## Safe extension order
 
-1. Harden proxy errors, path validation, response headers, and timeouts.
-2. Add changeset detail and diff APIs, then wire graph clicks to those screens.
-3. Make deployment atomic with health checks and rollback.
-4. Add the hook, SQLite queue, single runner, and retained logs.
-5. Add run/status/log pages using the existing custom UI.
-6. Add authentication and authorization before accepting public pushes or
+1. Add branches, bookmarks, tags, file history, blame, and search.
+2. Add the hook, SQLite queue, single runner, and retained logs.
+3. Add run/status/log pages using the existing custom UI.
+4. Add authentication and authorization before accepting public pushes or
    user-defined automation.
 
 ## Invariants
--- a/hg-web/deploy.sh	Sun Aug 02 08:34:54 2026 -0700
+++ b/hg-web/deploy.sh	Sun Aug 02 09:01:24 2026 -0700
@@ -1,15 +1,82 @@
-#!/bin/bash
-# sudo groupadd zenbu_team -- already added
-# sudo useradd -r -s /usr/sbin/nologin -G zenbu_team hg_web_server
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+SERVICE_NAME="${SERVICE_NAME:-hg_web_server.service}"
+RELEASE_ROOT="${RELEASE_ROOT:-/opt/hg_web_server_releases}"
+ACTIVE_PATH="${ACTIVE_PATH:-/opt/hg_web_server_bundle_active}"
+HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6970/}"
+SERVICE_USER="${SERVICE_USER:-hg_web_server}"
+SERVICE_GROUP="${SERVICE_GROUP:-zenbu_team}"
+
+workspace="$(hg root)"
+cd "$workspace"
+
+revision="$(hg log -r . -T '{node|short}')"
+release_name="${revision}-$(date -u +%Y%m%dT%H%M%SZ)"
+release_dir="${RELEASE_ROOT}/${release_name}"
+staging_dir="${RELEASE_ROOT}/.${release_name}.tmp"
+next_link="${ACTIVE_PATH}.next"
+bundle_dir="bazel-bin/hg-web/hg_web_server_bundle"
+previous_release=""
+promoted=0
+
+health_check() {
+  for _ in $(seq 1 20); do
+    if curl --fail --silent --max-time 3 "$HEALTH_URL" >/dev/null; then
+      return 0
+    fi
+    sleep 1
+  done
+  echo "Health check failed: $HEALTH_URL" >&2
+  return 1
+}
+
+point_active_at() {
+  local target="$1"
+  sudo rm -f "$next_link"
+  sudo ln -s "$target" "$next_link"
+  sudo mv -Tf "$next_link" "$ACTIVE_PATH"
+}
+
+rollback() {
+  trap - ERR
+  if [[ "$promoted" -eq 1 && -n "$previous_release" && -d "$previous_release" ]]; then
+    echo "Deployment failed; rolling back to $previous_release" >&2
+    point_active_at "$previous_release"
+    sudo systemctl restart "$SERVICE_NAME"
+    health_check || echo "Rollback completed, but the health check still fails." >&2
+  else
+    echo "Deployment failed and no previous release is available for rollback." >&2
+  fi
+  sudo rm -rf "$staging_dir"
+  exit 1
+}
+trap rollback ERR
+
 bazel build -c opt //hg-web:hg_web_server_bundle
 
-# Create
-sudo cp -a bazel-bin/hg-web/hg_web_server_bundle /opt/hg_web_server_bundle_new
-sudo chown -R hg_web_server:zenbu_team /opt/hg_web_server_bundle_new
+sudo install -d -o root -g "$SERVICE_GROUP" -m 0755 "$RELEASE_ROOT"
+sudo rm -rf "$staging_dir"
+sudo install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$staging_dir"
+sudo cp -a "${bundle_dir}/." "$staging_dir/"
+sudo chown -R "$SERVICE_USER:$SERVICE_GROUP" "$staging_dir"
+
+sudo test -x "$staging_dir/hg_web_server"
+sudo test -f "$staging_dir/hg-web/src/index.html"
+sudo test -f "$staging_dir/hg-web/src/page.js"
+sudo mv "$staging_dir" "$release_dir"
 
-# Swap
-sudo rm -rf /opt/hg_web_server_bundle_active
-sudo mv /opt/hg_web_server_bundle_new /opt/hg_web_server_bundle_active
+if [[ -L "$ACTIVE_PATH" ]]; then
+  previous_release="$(readlink -f "$ACTIVE_PATH")"
+elif [[ -d "$ACTIVE_PATH" ]]; then
+  previous_release="${RELEASE_ROOT}/legacy-$(date -u +%Y%m%dT%H%M%SZ)"
+  sudo mv "$ACTIVE_PATH" "$previous_release"
+fi
 
-sudo systemctl restart hg_web_server.service
-echo "Deployment complete!"
+point_active_at "$release_dir"
+promoted=1
+sudo systemctl restart "$SERVICE_NAME"
+health_check
+
+trap - ERR
+echo "Deployment complete: $release_dir"
--- a/hg-web/main.c	Sun Aug 02 08:34:54 2026 -0700
+++ b/hg-web/main.c	Sun Aug 02 09:01:24 2026 -0700
@@ -1,450 +1,680 @@
 #include "seobeo/seobeo.h"
 #include "dowa/dowa.h"
+
+#include <ctype.h>
+#include <errno.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <ctype.h>
+#include <strings.h>
+#include <time.h>
 #include <unistd.h>
-#include <sys/socket.h>
-#include <netinet/in.h>
-#include <arpa/inet.h>
-#include <netdb.h>
 
 #define HG_SERVE_HOST "127.0.0.1"
 #define HG_SERVE_PORT "4444"
-
-#define MAX_PATH 4096
+#define HG_API_TIMEOUT_MS 15000
+#define HG_STREAM_IDLE_TIMEOUT_MS 60000
+#define MAX_PATH_LENGTH 4096
+#define MAX_WIRE_QUERY_LENGTH 8192
+#define MAX_WIRE_HEADER_LENGTH 8192
 
-static char* sanitize_path(const char *input_path, Dowa_Arena *arena)
+static const char *map_value_case_insensitive(Seobeo_Request_Entry *map, const char *key)
 {
-  if (!input_path || strlen(input_path) == 0)
+  if (!map || !key)
+    return NULL;
+
+  for (size_t i = 0; i < Dowa_Array_Length(map); i++)
   {
-    char *empty = Dowa_Arena_Allocate(arena, 1);
-    empty[0] = '\0';
-    return empty;
+    if (map[i].key && strcasecmp(map[i].key, key) == 0)
+      return map[i].value;
   }
+  return NULL;
+}
+
+static char *arena_string(Dowa_Arena *arena, const char *value)
+{
+  size_t length = strlen(value);
+  char *copy = Dowa_Arena_Allocate(arena, length + 1);
+  memcpy(copy, value, length + 1);
+  return copy;
+}
 
-  size_t len = strlen(input_path);
-  char *result = Dowa_Arena_Allocate(arena, len + 1);
-  size_t j = 0;
+static Seobeo_Request_Entry *text_response(
+    Dowa_Arena *arena,
+    const char *status,
+    const char *content_type,
+    const char *body)
+{
+  Seobeo_Request_Entry *response = NULL;
+  Dowa_HashMap_Push_Arena(response, "status", arena_string(arena, status), arena);
+  Dowa_HashMap_Push_Arena(
+      response, "content-type", arena_string(arena, content_type), arena);
+  Dowa_HashMap_Push_Arena(response, "body", arena_string(arena, body), arena);
+  return response;
+}
 
-  for (size_t i = 0; i < len; i++)
+static boolean decode_url_component(
+    const char *encoded,
+    Dowa_Arena *arena,
+    char **decoded_out,
+    size_t *decoded_length_out)
+{
+  if (!encoded || !decoded_out)
+    return FALSE;
+
+  size_t encoded_length = strlen(encoded);
+  if (encoded_length >= MAX_PATH_LENGTH)
+    return FALSE;
+
+  char *decoded = Dowa_Arena_Allocate(arena, encoded_length + 1);
+  size_t output_length = 0;
+  for (size_t i = 0; i < encoded_length; i++)
   {
-    if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/'))
+    unsigned char value = (unsigned char)encoded[i];
+    if (encoded[i] == '%')
     {
-      if (i + 1 < len && input_path[i+1] == '.')
-      {
-        // Skip ".."
-        i++;
-        continue;
-      }
-      // Skip "."
-      continue;
+      if (i + 2 >= encoded_length ||
+          !isxdigit((unsigned char)encoded[i + 1]) ||
+          !isxdigit((unsigned char)encoded[i + 2]))
+        return FALSE;
+
+      char hex[3] = {encoded[i + 1], encoded[i + 2], '\0'};
+      value = (unsigned char)strtoul(hex, NULL, 16);
+      i += 2;
+      if (value == '\0')
+        return FALSE;
     }
-    result[j++] = input_path[i];
+    decoded[output_length++] = (char)value;
   }
-  result[j] = '\0';
+  decoded[output_length] = '\0';
 
-  // Remove leading/trailing slashes
-  while (result[0] == '/')
-    memmove(result, result + 1, strlen(result));
-  while (j > 0 && result[j-1] == '/')
-    result[--j] = '\0';
-
-  return result;
+  *decoded_out = decoded;
+  if (decoded_length_out)
+    *decoded_length_out = output_length;
+  return TRUE;
 }
 
-Seobeo_Client_Response  *hg_proxy_request(
-  const char *method,
-  const char *path,
-  const char *req_body,
-  const char *hg_custom)
+static boolean normalize_repository_path(
+    const char *encoded_path,
+    Dowa_Arena *arena,
+    char **normalized_out)
 {
-  char full_path[MAX_PATH];
-  snprintf(full_path, MAX_PATH, "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path);
-  Seobeo_Log(SEOBEO_DEBUG, "HG Proxy PATH %s\n", full_path);
-  Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(full_path);
-  Seobeo_Client_Request_Set_Method(p_req, method);
-  Seobeo_Client_Request_Add_Header_Array(p_req, "User-Agent: Seobeo/1.0");
-  Seobeo_Client_Request_Add_Header_Array(p_req, "Accept: application/json");
+  char *decoded = NULL;
+  size_t decoded_length = 0;
+  if (!decode_url_component(encoded_path ? encoded_path : "", arena, &decoded, &decoded_length))
+    return FALSE;
 
-  if (hg_custom && hg_custom[0] != '\0')
+  size_t start = 0;
+  size_t end = decoded_length;
+  if (start < end && decoded[start] == '/')
   {
-    char buffer[1024];
-    snprintf(buffer, 1024, "x-hgarg-1: %s", hg_custom);
-    Seobeo_Client_Request_Add_Header_Array(p_req, buffer);
-    Seobeo_Log(SEOBEO_DEBUG, "HG CUSTOM %s\n", buffer);
+    start++;
+    if (start < end && decoded[start] == '/')
+      return FALSE;
+  }
+  if (end > start && decoded[end - 1] == '/')
+  {
+    if (end - 1 > start && decoded[end - 2] == '/')
+      return FALSE;
+    end--;
   }
 
-  if (req_body)
-    Seobeo_Client_Request_Set_Body(p_req, req_body, strlen(req_body));
-  Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req);
-  Seobeo_Client_Request_Destroy(p_req);
-  return p_resp;
+  size_t segment_start = start;
+  for (size_t i = start; i <= end; i++)
+  {
+    boolean at_end = i == end;
+    unsigned char c = at_end ? '/' : (unsigned char)decoded[i];
+    if (!at_end && (iscntrl(c) || c == '\\' || c == '?' || c == '#'))
+      return FALSE;
+
+    if (c == '/')
+    {
+      size_t segment_length = i - segment_start;
+      if (segment_length == 0 && !at_end)
+        return FALSE;
+      if ((segment_length == 1 && decoded[segment_start] == '.') ||
+          (segment_length == 2 && decoded[segment_start] == '.' &&
+           decoded[segment_start + 1] == '.'))
+        return FALSE;
+      segment_start = i + 1;
+    }
+  }
+
+  size_t normalized_length = end - start;
+  char *normalized = Dowa_Arena_Allocate(arena, normalized_length + 1);
+  memcpy(normalized, decoded + start, normalized_length);
+  normalized[normalized_length] = '\0';
+  *normalized_out = normalized;
+  return TRUE;
+}
+
+static boolean validate_revision(const char *revision)
+{
+  if (!revision || revision[0] == '\0')
+    return FALSE;
+  if (strcmp(revision, "tip") == 0)
+    return TRUE;
+
+  size_t length = strlen(revision);
+  if (length > 40)
+    return FALSE;
+  for (size_t i = 0; i < length; i++)
+  {
+    if (!isxdigit((unsigned char)revision[i]))
+      return FALSE;
+  }
+  return TRUE;
 }
 
-Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+static char *encode_repository_path(const char *path, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL;
-
-  void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path");
-  const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
+  static const char hex[] = "0123456789ABCDEF";
+  size_t length = strlen(path);
+  char *encoded = Dowa_Arena_Allocate(arena, length * 3 + 1);
+  size_t output = 0;
+  for (size_t i = 0; i < length; i++)
+  {
+    unsigned char c = (unsigned char)path[i];
+    if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '/')
+      encoded[output++] = (char)c;
+    else
+    {
+      encoded[output++] = '%';
+      encoded[output++] = hex[c >> 4];
+      encoded[output++] = hex[c & 0x0F];
+    }
+  }
+  encoded[output] = '\0';
+  return encoded;
+}
 
-  char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
-  Seobeo_Url_Decode(decoded_path, rel_path);
-
-  char *safe_path = sanitize_path(decoded_path, arena);
-
-  Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path);
+static boolean safe_header_value(const char *value, size_t maximum_length)
+{
+  if (!value)
+    return TRUE;
+  size_t length = strlen(value);
+  return length <= maximum_length &&
+         strchr(value, '\r') == NULL &&
+         strchr(value, '\n') == NULL;
+}
 
-  char hg_path[MAX_PATH];
-  if (strlen(safe_path) > 0)
-    snprintf(hg_path, sizeof(hg_path), "/file/tip/%s?style=json", safe_path);
-  else
-    snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json");
-
-  Seobeo_Client_Response  *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
+static Seobeo_Client_Response *hg_proxy_request(
+    const char *method,
+    const char *path,
+    const char *request_body,
+    size_t request_body_length,
+    const char *hg_argument,
+    const char *accept)
+{
+  char url[MAX_PATH_LENGTH];
+  int url_length = snprintf(
+      url, sizeof(url), "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path);
+  if (url_length < 0 || (size_t)url_length >= sizeof(url))
+    return NULL;
 
-  Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
+  Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
+  if (!request)
+    return NULL;
 
-  if (hg_response->status_code != 200)
+  Seobeo_Client_Request_Set_Method(request, method);
+  Seobeo_Client_Request_Add_Header_Map(request, "User-Agent", "Seobeo/1.0");
+  Seobeo_Client_Request_Add_Header_Map(
+      request, "Accept", accept ? accept : "application/json");
+  Seobeo_Client_Request_Set_Timeout_Milliseconds(request, HG_API_TIMEOUT_MS);
+
+  if (hg_argument && hg_argument[0] != '\0')
   {
-    Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n");
-    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena);
-    return resp;
+    if (!safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH))
+    {
+      Seobeo_Client_Request_Destroy(request);
+      return NULL;
+    }
+    Seobeo_Client_Request_Add_Header_Map(request, "x-hgarg-1", hg_argument);
   }
 
-  char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
-  char *temp2 = Dowa_Arena_Allocate(arena, 256);
-  snprintf(temp2, 256, "%zu", hg_response->body_length);
+  if (request_body && request_body_length > 0)
+    Seobeo_Client_Request_Set_Body(request, request_body, request_body_length);
 
-  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
-  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
-  return resp;
+  Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
+  Seobeo_Client_Request_Destroy(request);
+  return response;
 }
 
-Seobeo_Request_Entry* ApiGetGraph(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+static Seobeo_Request_Entry *forward_hg_response(
+    Seobeo_Client_Response *hg_response,
+    const char *default_content_type,
+    Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL;
+  if (!hg_response)
+    return text_response(
+        arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}");
 
-  void *path_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
-  const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
-  Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: rel_path='%s'\n", rel_path);
-  void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id");
-  char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value;
-  Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: graph_id='%s'\n", graph_id);
-  char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
-  Seobeo_Url_Decode(decoded_path, rel_path);
-  char *safe_path = sanitize_path(decoded_path, arena);
+  const char *upstream_content_type =
+      map_value_case_insensitive(hg_response->headers, "Content-Type");
+  const char *upstream_or_default_content_type =
+      upstream_content_type ? upstream_content_type : default_content_type;
+  if (!upstream_or_default_content_type)
+    upstream_or_default_content_type = "application/octet-stream";
+  char *content_type = arena_string(arena, upstream_or_default_content_type);
 
-  Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: safe_path='%s'\n", safe_path);
+  char *status = Dowa_Arena_Allocate(arena, 8);
+  snprintf(status, 8, "%d", hg_response->status_code);
 
-  if (strlen(safe_path) == 0)
-  {
-    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena);
-    return resp;
-  }
+  size_t body_length = hg_response->body ? hg_response->body_length : 0;
+  char *body = Dowa_Arena_Allocate(arena, body_length + 1);
+  if (body_length > 0)
+    memcpy(body, hg_response->body, body_length);
+  body[body_length] = '\0';
+  char *content_length = Dowa_Arena_Allocate(arena, 32);
+  snprintf(content_length, 32, "%zu", body_length);
+
+  Seobeo_Request_Entry *response = NULL;
+  Dowa_HashMap_Push_Arena(response, "status", status, arena);
+  Dowa_HashMap_Push_Arena(response, "content-type", content_type, arena);
+  Dowa_HashMap_Push_Arena(response, "body", body, arena);
+  Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena);
+  Seobeo_Client_Response_Destroy(hg_response);
+  return response;
+}
 
-  char hg_path[MAX_PATH];
-  // void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id");
-  // char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value;
-  snprintf(hg_path, sizeof(hg_path), "/graph/%s?%s", graph_id, safe_path);
-  Seobeo_Client_Response  *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
+Seobeo_Request_Entry *ApiListDirectory(Seobeo_Request_Entry *request, Dowa_Arena *arena)
+{
+  const char *encoded_path = map_value_case_insensitive(request, "query_path");
+  char *path = NULL;
+  if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Invalid repository path\"}");
 
-  Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
+  char *encoded = encode_repository_path(path, arena);
+  char hg_path[MAX_PATH_LENGTH];
+  int length = snprintf(
+      hg_path,
+      sizeof(hg_path),
+      encoded[0] ? "/file/tip/%s?style=json" : "/file/tip/?style=json",
+      encoded);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Repository path is too long\"}");
 
-  char status[4];
-  snprintf(status, 4, "%i", hg_response->status_code);
+  return forward_hg_response(
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
+      "application/json",
+      arena);
+}
 
-  if (!hg_response->body)
-  {
-    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena);
-    return resp;
-  }
+Seobeo_Request_Entry *ApiGetFile(Seobeo_Request_Entry *request, Dowa_Arena *arena)
+{
+  const char *encoded_path = map_value_case_insensitive(request, "query_path");
+  char *path = NULL;
+  if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path) ||
+      path[0] == '\0')
+    return text_response(arena, "400", "text/plain", "A valid file path is required");
+
+  char *encoded = encode_repository_path(path, arena);
+  char hg_path[MAX_PATH_LENGTH];
+  int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "text/plain", "File path is too long");
+
+  return forward_hg_response(
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream"),
+      "application/octet-stream",
+      arena);
+}
 
-  if (hg_response->status_code != 200)
-  {
-    Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: error hg_response: %s\n", hg_response->body);
-    Dowa_HashMap_Push_Arena(resp, "status", status, arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena);
-    return resp;
-  }
+Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena)
+{
+  const char *encoded_path = map_value_case_insensitive(request, "query_path");
+  char *directory = NULL;
+  if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &directory))
+    return text_response(arena, "400", "text/plain", "Invalid repository path");
 
+  size_t readme_length = strlen(directory) + strlen("/README.md") + 1;
+  if (readme_length >= MAX_PATH_LENGTH)
+    return text_response(arena, "400", "text/plain", "README path is too long");
 
-  char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
-  char *temp2 = Dowa_Arena_Allocate(arena, 256);
-  snprintf(temp2, 256, "%zu", hg_response->body_length);
+  char *readme_path = Dowa_Arena_Allocate(arena, readme_length);
+  snprintf(
+      readme_path,
+      readme_length,
+      directory[0] ? "%s/README.md" : "README.md",
+      directory);
+  char *encoded = encode_repository_path(readme_path, arena);
 
-  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
+  char hg_path[MAX_PATH_LENGTH];
+  int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "text/plain", "README path is too long");
 
-  return resp;
+  return forward_hg_response(
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"),
+      "text/markdown",
+      arena);
 }
 
-Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL;
+  const char *graph_id = map_value_case_insensitive(request, ":graph_id");
+  if (!validate_revision(graph_id))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph revision\"}");
 
-  void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path");
-  const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
-  char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
-  Seobeo_Url_Decode(decoded_path, rel_path);
-  char *safe_path = sanitize_path(decoded_path, arena);
-
-  Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path);
-
-  if (strlen(safe_path) == 0)
+  const char *encoded_graph_top =
+      map_value_case_insensitive(request, "query_graphtop");
+  char *graph_top = NULL;
+  if (encoded_graph_top)
   {
-    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena);
-    return resp;
-  }
-
-  char hg_path[MAX_PATH];
-  snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path);
-  Seobeo_Client_Response  *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
-
-  Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
-
-  char status[4];
-  snprintf(status, 4, "%i", hg_response->status_code);
-
-  if (!hg_response->body)
-  {
-    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena);
-    return resp;
+    if (!decode_url_component(encoded_graph_top, arena, &graph_top, NULL) ||
+        !validate_revision(graph_top))
+      return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph top revision\"}");
   }
 
-  if (hg_response->status_code != 200)
-  {
-    Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error hg_response: %s\n", hg_response->body);
-    Dowa_HashMap_Push_Arena(resp, "status", status, arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena);
-    return resp;
-  }
-
+  char hg_path[MAX_PATH_LENGTH];
+  int length = graph_top
+      ? snprintf(
+            hg_path,
+            sizeof(hg_path),
+            "/graph/%s?graphtop=%s&style=json",
+            graph_id,
+            graph_top)
+      : snprintf(hg_path, sizeof(hg_path), "/graph/%s?style=json", graph_id);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Graph request is too long\"}");
 
-  char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
-  char *temp2 = Dowa_Arena_Allocate(arena, 256);
-  snprintf(temp2, 256, "%zu", hg_response->body_length);
-
-  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
-
-  return resp;
+  return forward_hg_response(
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
+      "application/json",
+      arena);
 }
 
-Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) {
-  return ApiGetFile(req, arena);
+Seobeo_Request_Entry *ApiGetChangeset(Seobeo_Request_Entry *request, Dowa_Arena *arena)
+{
+  const char *changeset_id = map_value_case_insensitive(request, ":changeset_id");
+  if (!validate_revision(changeset_id))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Invalid changeset revision\"}");
+
+  char hg_path[128];
+  int length = snprintf(hg_path, sizeof(hg_path), "/json-rev/%s", changeset_id);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Changeset request is too long\"}");
+
+  return forward_hg_response(
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
+      "application/json",
+      arena);
+}
+
+static int64_t monotonic_milliseconds(void)
+{
+  struct timespec now;
+  clock_gettime(CLOCK_MONOTONIC, &now);
+  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+}
+
+static size_t find_http_header_length(const uint8 *buffer, size_t length)
+{
+  if (!buffer || length < 4)
+    return 0;
+  for (size_t i = 0; i + 3 < length; i++)
+  {
+    if (buffer[i] == '\r' && buffer[i + 1] == '\n' &&
+        buffer[i + 2] == '\r' && buffer[i + 3] == '\n')
+      return i + 4;
+  }
+  return 0;
 }
 
-// Streaming handler for hg wire protocol - pipes data directly without buffering
-void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena)
+static void send_proxy_error(Seobeo_Handle *client, int status, const char *message)
 {
-  void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
-  const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";
-
-  void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
-  const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";
-
-  void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
-  const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : "";
-
-  const char *hg_custom = req[7].value;
+  const char *reason = status == 504 ? "Gateway Timeout" : "Bad Gateway";
+  char response[512];
+  int length = snprintf(
+      response,
+      sizeof(response),
+      "HTTP/1.1 %d %s\r\n"
+      "Content-Type: text/plain\r\n"
+      "Content-Length: %zu\r\n"
+      "Connection: close\r\n"
+      "\r\n"
+      "%s",
+      status,
+      reason,
+      strlen(message),
+      message);
+  if (length > 0 && (size_t)length < sizeof(response))
+  {
+    Seobeo_Handle_Queue(client, (const uint8 *)response, (uint32)length);
+    Seobeo_Handle_Flush(client);
+  }
+}
 
-  Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string);
+static boolean parse_content_length(const char *value, size_t *length_out)
+{
+  if (!value || !length_out || value[0] == '\0')
+    return FALSE;
+  errno = 0;
+  char *end = NULL;
+  unsigned long long parsed = strtoull(value, &end, 10);
+  if (errno != 0 || !end || *end != '\0' || parsed > SIZE_MAX)
+    return FALSE;
+  *length_out = (size_t)parsed;
+  return TRUE;
+}
 
-  // THINKING: Connect to hg serve
-  // This kinda blows, but not a good way to handle it since my client API assumes it is all stored in
-  // buffer and what not.
-  Seobeo_Handle *p_upstream = Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE);
-  if (!p_upstream || p_upstream->socket < 0)
+void StreamHgWireProtocol(
+    Seobeo_Handle *client,
+    Seobeo_Request_Entry *request,
+    Dowa_Arena *arena)
+{
+  (void)arena;
+  const char *method = map_value_case_insensitive(request, "HTTP_Method");
+  const char *query = map_value_case_insensitive(request, "QueryString");
+  const char *body = map_value_case_insensitive(request, "Body");
+  const char *content_length_value =
+      map_value_case_insensitive(request, "Content-Length");
+  const char *content_type = map_value_case_insensitive(request, "Content-Type");
+  const char *hg_argument = map_value_case_insensitive(request, "x-hgarg-1");
+
+  if (!method || !query ||
+      (strcmp(method, "GET") != 0 && strcmp(method, "POST") != 0) ||
+      !safe_header_value(query, MAX_WIRE_QUERY_LENGTH) ||
+      !safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH) ||
+      !safe_header_value(content_type, 256))
   {
-    const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 26\r\n\r\nFailed to connect upstream";
-    Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp));
-    Seobeo_Handle_Flush(p_client);
-    if (p_upstream)
-      Seobeo_Handle_Destroy(p_upstream);
+    send_proxy_error(client, 502, "Invalid Mercurial proxy request");
     return;
   }
 
-  // Create headers
-  // we only allow x-hgarg-1 and content-length
-  char request_buf[8192];
-  int req_len = snprintf(request_buf, sizeof(request_buf),
-    "%s /?%s HTTP/1.1\r\n"
-    "Host: %s:%s\r\n"
-    "User-Agent: Seobeo/1.0\r\n"
-    "Connection: close\r\n",
-    method, query_string, HG_SERVE_HOST, HG_SERVE_PORT);
+  size_t body_length = 0;
+  if (content_length_value &&
+      !parse_content_length(content_length_value, &body_length))
+  {
+    send_proxy_error(client, 502, "Invalid Mercurial request length");
+    return;
+  }
+  if (body_length > 0 && !body)
+  {
+    send_proxy_error(client, 502, "Missing Mercurial request body");
+    return;
+  }
+  if (body_length > UINT32_MAX)
+  {
+    send_proxy_error(client, 502, "Mercurial request body is too large");
+    return;
+  }
 
-  if (hg_custom && hg_custom[0] != '\0')
-    req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "x-hgarg-1: %s\r\n", hg_custom);
+  Seobeo_Handle *upstream =
+      Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE);
+  if (!upstream || upstream->socket < 0)
+  {
+    if (upstream)
+      Seobeo_Handle_Destroy(upstream);
+    send_proxy_error(client, 502, "Mercurial backend unavailable");
+    return;
+  }
 
-  if (req_body && req_body[0] != '\0')
-    req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "Content-Length: %zu\r\n\r\n%s", strlen(req_body), req_body);
-  else
-    req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "\r\n");
+  char request_header[16384];
+  int header_length = snprintf(
+      request_header,
+      sizeof(request_header),
+      "%s /?%s HTTP/1.1\r\n"
+      "Host: %s:%s\r\n"
+      "User-Agent: Seobeo/1.0\r\n"
+      "Connection: close\r\n",
+      method,
+      query,
+      HG_SERVE_HOST,
+      HG_SERVE_PORT);
+  if (header_length < 0 || (size_t)header_length >= sizeof(request_header))
+  {
+    Seobeo_Handle_Destroy(upstream);
+    send_proxy_error(client, 502, "Mercurial request headers are too large");
+    return;
+  }
 
-  Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len);
-  if (Seobeo_Handle_Flush(p_upstream) < 0)
+#define APPEND_WIRE_HEADER(...) \
+  do { \
+    int appended = snprintf( \
+        request_header + header_length, \
+        sizeof(request_header) - (size_t)header_length, \
+        __VA_ARGS__); \
+    if (appended < 0 || (size_t)appended >= sizeof(request_header) - (size_t)header_length) { \
+      Seobeo_Handle_Destroy(upstream); \
+      send_proxy_error(client, 502, "Mercurial request headers are too large"); \
+      return; \
+    } \
+    header_length += appended; \
+  } while (0)
+
+  if (hg_argument && hg_argument[0] != '\0')
+    APPEND_WIRE_HEADER("x-hgarg-1: %s\r\n", hg_argument);
+  if (content_type && content_type[0] != '\0')
+    APPEND_WIRE_HEADER("Content-Type: %s\r\n", content_type);
+  if (body_length > 0)
+    APPEND_WIRE_HEADER("Content-Length: %zu\r\n", body_length);
+  APPEND_WIRE_HEADER("\r\n");
+#undef APPEND_WIRE_HEADER
+
+  if (Seobeo_Handle_Queue(
+          upstream, (const uint8 *)request_header, (uint32)header_length) != 0 ||
+      (body_length > 0 &&
+       Seobeo_Handle_Queue(upstream, (const uint8 *)body, (uint32)body_length) != 0) ||
+      Seobeo_Handle_Flush(upstream) != 0)
   {
-    const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 21\r\n\r\nUpstream write failed";
-    Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp));
-    Seobeo_Handle_Flush(p_client);
-    Seobeo_Handle_Destroy(p_upstream);
+    Seobeo_Handle_Destroy(upstream);
+    send_proxy_error(client, 502, "Mercurial backend write failed");
     return;
   }
 
-  // Responses 
-  while (1)
+  boolean response_started = FALSE;
+  int64_t last_progress = monotonic_milliseconds();
+  while (!response_started)
   {
-    int r = Seobeo_Handle_Read(p_upstream);
-    if (r < 0)
+    int read_result = Seobeo_Handle_Read(upstream);
+    if (read_result == -2 || read_result < 0)
     {
-      Seobeo_Handle_Destroy(p_upstream);
+      Seobeo_Handle_Destroy(upstream);
+      send_proxy_error(client, 502, "Mercurial backend closed before responding");
       return;
     }
-    if (p_upstream->read_buffer_len >= 4 &&
-        strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL)
+    if (read_result > 0)
+      last_progress = monotonic_milliseconds();
+
+    size_t header_size =
+        find_http_header_length(upstream->read_buffer, upstream->read_buffer_len);
+    if (header_size > 0)
+    {
+      (void)header_size;
+      if (Seobeo_Handle_Queue(
+              client, upstream->read_buffer, upstream->read_buffer_len) != 0 ||
+          Seobeo_Handle_Flush(client) != 0)
+      {
+        Seobeo_Handle_Destroy(upstream);
+        return;
+      }
+      Seobeo_Handle_Consume(upstream, upstream->read_buffer_len);
+      response_started = TRUE;
       break;
-    if (r == 0)
-      continue;
+    }
+
+    if (read_result == 0)
+    {
+      if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS)
+      {
+        Seobeo_Handle_Destroy(upstream);
+        send_proxy_error(client, 504, "Mercurial backend response timed out");
+        return;
+      }
+      usleep(1000);
+    }
   }
 
-  // TODO: Maybe make this into a separate function instead of internal function as doing this over and over again blows.
-  char *hdr_end = strstr((char*)p_upstream->read_buffer, "\r\n\r\n");
-  if (!hdr_end)
-  {
-    Seobeo_Handle_Destroy(p_upstream);
-    return;
-  }
-  size_t hdr_len = hdr_end - (char*)p_upstream->read_buffer + 4;
-  Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, hdr_len);
-  Seobeo_Handle_Flush(p_client);
-
-  // All body 
-  size_t body_in_buffer = p_upstream->read_buffer_len - hdr_len;
-  if (body_in_buffer > 0)
+  while (TRUE)
   {
-    Seobeo_Handle_Queue(p_client, p_upstream->read_buffer + hdr_len, body_in_buffer);
-    Seobeo_Handle_Flush(p_client);
-  }
-  Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len);
-  while (1)
-  {
-    int n = Seobeo_Handle_Read(p_upstream);
-    if (n > 0)
+    int read_result = Seobeo_Handle_Read(upstream);
+    if (read_result == -2)
+      break;
+    if (read_result < 0)
+      break;
+    if (read_result == 0)
     {
-      Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, p_upstream->read_buffer_len);
-      Seobeo_Handle_Flush(p_client);
-      Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len);
+      if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS)
+      {
+        Seobeo_Log(SEOBEO_ERROR, "Mercurial response stream timed out\n");
+        break;
+      }
+      usleep(1000);
+      continue;
     }
-    else if (n == -2)
+
+    last_progress = monotonic_milliseconds();
+    if (Seobeo_Handle_Queue(
+            client, upstream->read_buffer, upstream->read_buffer_len) != 0 ||
+        Seobeo_Handle_Flush(client) != 0)
       break;
-    else if (n < 0)
-      break;
+    Seobeo_Handle_Consume(upstream, upstream->read_buffer_len);
   }
 
-  Seobeo_Handle_Destroy(p_upstream);
+  Seobeo_Handle_Destroy(upstream);
 }
 
-Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+Seobeo_Request_Entry *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL;
-
-  void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
-  const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";
-
-  void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
-  const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";
-
-  void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
-  const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : "";
-  size_t body_len = strlen(req_body);
-
-  const char *hg_custom = req[7].value;
-  Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len);
-
-  Seobeo_Client_Response *hg_response;
+  (void)request;
+  size_t file_size = 0;
+  char *html = Seobeo_Web_LoadFile("/index.html", &file_size);
+  if (!html)
+    return text_response(arena, "500", "text/plain", "Application shell unavailable");
 
-  char hg_path[MAX_PATH];
-  snprintf(hg_path, sizeof(hg_path), "/?%s", query_string);
-
-  hg_response = hg_proxy_request(method, hg_path, req_body, hg_custom);
-
-  Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", hg_response->body_length);
-
-  Seobeo_Request_Entry *kv = Dowa_HashMap_Get_Ptr(hg_response->headers, "Content-Type");
-
-  char *status = Dowa_Arena_Allocate(arena, 5);
-  snprintf(status, 4, "%i", hg_response->status_code);
-
-  // Use binary-safe copy to handle null bytes in mercurial bundle data
-  char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
-  char *temp2 = Dowa_Arena_Allocate(arena, 256);
-  snprintf(temp2, 256, "%zu", hg_response->body_length);
-
-  Dowa_HashMap_Push_Arena(resp, "status", status, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", kv->value, arena);
-  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
-
-  return resp;
+  Seobeo_Request_Entry *response = NULL;
+  char *content_length = Dowa_Arena_Allocate(arena, 32);
+  snprintf(content_length, 32, "%zu", file_size);
+  Dowa_HashMap_Push_Arena(response, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(response, "content-type", "text/html", arena);
+  Dowa_HashMap_Push_Arena(response, "body", html, arena);
+  Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena);
+  return response;
 }
 
-Seobeo_Request_Entry* GetReactHome(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+int main(void)
 {
-  size_t file_size = 0;
-  char *html = Seobeo_Web_LoadFile("/index.html", &file_size);
-
-  printf("%s", html);
-  Seobeo_Request_Entry *resp = NULL; 
-  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", "text/html", arena);
-  Dowa_HashMap_Push_Arena(resp, "body", html, arena);
-  return resp;
-}
-
-int main(void) {
   Seobeo_Router_Init();
 
-
   Seobeo_Router_Register("GET", "/", GetReactHome);
   Seobeo_Router_Register("GET", "/directories", GetReactHome);
+  Seobeo_Router_Register("GET", "/directory", GetReactHome);
   Seobeo_Router_Register("GET", "/graph", GetReactHome);
+  Seobeo_Router_Register("GET", "/changeset/:changeset_id", GetReactHome);
 
   Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory);
   Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile);
+  Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme);
   Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph);
-  Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme);
+  Seobeo_Router_Register("GET", "/api/changeset/:changeset_id", ApiGetChangeset);
 
-  // Use streaming handler for hg wire protocol... 
   Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol);
   Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol);
 
   printf("Starting on Port 6970...\n");
-
-  int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1);
-
+  int result =
+      Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1);
   Seobeo_Router_Destroy();
-
   return result;
 }
--- a/hg-web/src/components/app.tsx	Sun Aug 02 08:34:54 2026 -0700
+++ b/hg-web/src/components/app.tsx	Sun Aug 02 09:01:24 2026 -0700
@@ -5,15 +5,32 @@
 import { Footer } from "hg-web/src/components/footer";
 import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
 
-type Page = 'landing' | 'graph' | 'directory';
+type Page = 'landing' | 'graph' | 'directory' | 'changeset';
 
 type RouteState = {
   page: Page;
   graphCommit?: string;
   graphTip?: string;
   dirPath?: string;
+  changesetId?: string;
 }
 
+type ChangesetDetail = {
+  node: string;
+  date: [number, number];
+  desc: string;
+  branch: string;
+  bookmarks: string[];
+  tags: string[];
+  user: string;
+  parents: string[];
+  files: string[];
+  diff: Array<{
+    blockno: number;
+    lines: Array<{ t: string; n: number; l: string }>;
+  }>;
+};
+
 // Icons
 const ICONS = {
   folder: "/icons/folder.png",
@@ -40,6 +57,18 @@
 function parseRoute(): RouteState {
   const params = new URLSearchParams(window.location.search);
   const pathname = window.location.pathname;
+  const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/);
+
+  if (changesetMatch) {
+    try {
+      const changesetId = decodeURIComponent(changesetMatch[1]);
+      if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) {
+        return { page: 'changeset', changesetId };
+      }
+    } catch {
+      return { page: 'landing' };
+    }
+  }
 
   if (pathname.startsWith('/graph') || params.has('graph')) {
     return {
@@ -70,6 +99,8 @@
     case 'directory':
       if (state.dirPath) params.set('path', state.dirPath);
       return `/directory${params.toString() ? '?' + params.toString() : ''}`;
+    case 'changeset':
+      return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph';
     default:
       return '/';
   }
@@ -79,9 +110,11 @@
 function LandingPage({
   onNavigateToGraph,
   onNavigateToDirectory,
+  onNavigateToChangeset,
 }: {
   onNavigateToGraph: () => void;
   onNavigateToDirectory: (path?: string) => void;
+  onNavigateToChangeset: (node: string) => void;
 }) {
   const [directories, setDirectories] = useState<any[]>([]);
   const [files, setFiles] = useState<any[]>([]);
@@ -128,9 +161,7 @@
             <Graph
               data={graphData}
               maxRows={8}
-              onCommitClick={(node) => {
-                console.log('Clicked commit:', node);
-              }}
+              onCommitClick={onNavigateToChangeset}
             />
           ) : (
             <div className="empty-state">Failed to load commits</div>
@@ -186,10 +217,12 @@
   onBack,
   initialCommit,
   initialTip,
+  onOpenChangeset,
 }: {
   onBack: () => void;
   initialCommit?: string;
   initialTip?: string;
+  onOpenChangeset: (node: string) => void;
 }) {
   const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({
     initialCommit: initialCommit || null,
@@ -239,14 +272,130 @@
         loading={loading}
         hasMore={hasMore}
         onLoadMore={loadMore}
-        onCommitClick={(node) => {
-          console.log('Clicked commit:', node);
-        }}
+        onCommitClick={onOpenChangeset}
       />
     </div>
   );
 }
 
+function ChangesetPage({
+  changesetId,
+  onBack,
+  onOpenChangeset,
+}: {
+  changesetId: string;
+  onBack: () => void;
+  onOpenChangeset: (node: string) => void;
+}) {
+  const [changeset, setChangeset] = useState<ChangesetDetail | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    const controller = new AbortController();
+
+    setLoading(true);
+    setError(null);
+    setChangeset(null);
+
+    fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal })
+      .then(async response => {
+        if (!response.ok) {
+          const message = await response.text();
+          throw new Error(message || `Unable to load changeset (${response.status})`);
+        }
+        return response.json() as Promise<ChangesetDetail>;
+      })
+      .then(setChangeset)
+      .catch(err => {
+        if (err.name !== 'AbortError') setError(err.message);
+      })
+      .finally(() => {
+        if (!controller.signal.aborted) setLoading(false);
+      });
+
+    return () => controller.abort();
+  }, [changesetId]);
+
+  return (
+    <div>
+      <div className="page-header">
+        <button className="back-button" onClick={onBack}>
+          &larr; Back to graph
+        </button>
+        <span className="page-title">Changeset</span>
+      </div>
+
+      {loading && <div className="loading-state">Loading changeset...</div>}
+      {error && <div className="error-message">Error: {error}</div>}
+
+      {changeset && (
+        <article className="changeset-paper">
+          <div className="changeset-heading">
+            <code>{changeset.node}</code>
+            <span className="changeset-branch">{changeset.branch}</span>
+          </div>
+          <h2>{changeset.desc}</h2>
+          <div className="changeset-meta">
+            <span>{changeset.user}</span>
+            <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}>
+              {new Date(changeset.date[0] * 1000).toLocaleString()}
+            </time>
+          </div>
+
+          {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && (
+            <div className="changeset-labels">
+              {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)}
+              {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)}
+            </div>
+          )}
+
+          {changeset.parents.length > 0 && (
+            <div className="changeset-parents">
+              <strong>Parents</strong>
+              {changeset.parents.map(parent => (
+                <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}>
+                  {parent.substring(0, 12)}
+                </button>
+              ))}
+            </div>
+          )}
+
+          {changeset.files.length > 0 && (
+            <div className="changeset-files">
+              <strong>Files</strong>
+              {changeset.files.map(file => <code key={file}>{file}</code>)}
+            </div>
+          )}
+
+          <section className="changeset-diff" aria-label="Changeset diff">
+            <h3>Diff</h3>
+            {changeset.diff.length === 0 ? (
+              <div className="empty-state">No textual changes in this changeset.</div>
+            ) : changeset.diff.map(block => (
+              <pre key={block.blockno}>
+                {block.lines.map((line, index) => (
+                  <span
+                    key={`${line.n}-${index}`}
+                    className={
+                      line.t === '+' ? 'diff-add' :
+                      line.t === '-' ? 'diff-remove' :
+                      line.t === '@' ? 'diff-range' : 'diff-context'
+                    }
+                  >
+                    <span className="diff-line-number">{line.n}</span>
+                    <span>{line.l}</span>
+                  </span>
+                ))}
+              </pre>
+            ))}
+          </section>
+        </article>
+      )}
+    </div>
+  );
+}
+
 // Directory Page Component
 function DirectoryPage({
   onBack,
@@ -306,6 +455,10 @@
     navigate({ page: 'directory', dirPath: path || '' });
   }, [navigate]);
 
+  const navigateToChangeset = useCallback((changesetId: string) => {
+    navigate({ page: 'changeset', changesetId });
+  }, [navigate]);
+
   const handleDirectoryPathChange = useCallback((path: string) => {
     // Update URL without full navigation
     const params = new URLSearchParams();
@@ -333,7 +486,7 @@
           Home
         </button>
         <button
-          className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`}
+          className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`}
           onClick={() => navigateToGraph()}
         >
           <GraphIcon />
@@ -353,6 +506,7 @@
         <LandingPage
           onNavigateToGraph={() => navigateToGraph()}
           onNavigateToDirectory={navigateToDirectory}
+          onNavigateToChangeset={navigateToChangeset}
         />
       )}
 
@@ -361,6 +515,7 @@
           onBack={navigateToLanding}
           initialCommit={route.graphCommit}
           initialTip={route.graphTip}
+          onOpenChangeset={navigateToChangeset}
         />
       )}
 
@@ -372,6 +527,14 @@
         />
       )}
 
+      {route.page === 'changeset' && route.changesetId && (
+        <ChangesetPage
+          changesetId={route.changesetId}
+          onBack={() => navigateToGraph()}
+          onOpenChangeset={navigateToChangeset}
+        />
+      )}
+
       <Footer />
     </div>
   );
--- a/hg-web/src/components/graph.tsx	Sun Aug 02 08:34:54 2026 -0700
+++ b/hg-web/src/components/graph.tsx	Sun Aug 02 09:01:24 2026 -0700
@@ -177,17 +177,11 @@
 const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => {
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
+  const [assetError, setAssetError] = useState<string | null>(null);
 
   const changesets = useMemo(() => 
     maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]);
 
-  let pencilPattern;
-  const img = new Image();
-  img.src = "http://localhost:6970/pencil_lines.png";
-
-  const pandaImg = new Image();
-  pandaImg.src = "http://localhost:6970/panda.png";
-
   useEffect(() => {
     const canvas = canvasRef.current;
     if (!canvas || !changesets.length) return;
@@ -195,20 +189,11 @@
     const ctx = canvas.getContext('2d');
     if (!ctx) return;
 
-    // Grab colors from CSS variables or defaults
-    const getColors = () => {
-      const s = getComputedStyle(document.documentElement);
-      return [
-        s.getPropertyValue('--graph-1').trim() || '#4dabf7',
-        s.getPropertyValue('--graph-2').trim() || '#63e6be',
-        s.getPropertyValue('--graph-3').trim() || '#ffbc42',
-        s.getPropertyValue('--graph-4').trim() || '#b197fc',
-        s.getPropertyValue('--graph-5').trim() || '#ff8787',
-        s.getPropertyValue('--graph-6').trim() || '#f06595',
-      ];
-    };
-
-    const colors = getColors();
+    let cancelled = false;
+    let pencilPattern: CanvasPattern | null = null;
+    let loadedAssets = 0;
+    const pencilImage = new Image();
+    const pandaImage = new Image();
     const dpr = window.devicePixelRatio || 1;
     const maxCol = Math.max(...changesets.map(cs => cs.col), 0);
     const canvasWidth = (maxCol + 2) * colWidth;
@@ -241,13 +226,35 @@
       // Pass 2: Draw Commit Nodes
       changesets.forEach((cs, i) => {
         const x = getX(cs.col), y = getY(i);
-        ctx.drawImage(pandaImg, x-10, y-10, 20, 20);
+        ctx.drawImage(pandaImage, x-10, y-10, 20, 20);
       });
     };
 
-    img.onload = () => {
-      pencilPattern = ctx.createPattern(img, "repeat")!;
-      renderCanvas(); 
+    const handleAssetLoad = () => {
+      loadedAssets++;
+      if (loadedAssets !== 2 || cancelled) return;
+      pencilPattern = ctx.createPattern(pencilImage, "repeat");
+      renderCanvas();
+    };
+
+    const handleAssetError = () => {
+      if (!cancelled) setAssetError('Unable to load the graph artwork.');
+    };
+
+    setAssetError(null);
+    pencilImage.onload = handleAssetLoad;
+    pencilImage.onerror = handleAssetError;
+    pandaImage.onload = handleAssetLoad;
+    pandaImage.onerror = handleAssetError;
+    pencilImage.src = "/pencil_lines.png";
+    pandaImage.src = "/panda.png";
+
+    return () => {
+      cancelled = true;
+      pencilImage.onload = null;
+      pencilImage.onerror = null;
+      pandaImage.onload = null;
+      pandaImage.onerror = null;
     };
   }, [changesets]);
 
@@ -266,45 +273,38 @@
   }, [onLoadMore, hasMore, loading]);
 
   return (
-    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}>
+    <div className="graph-container" style={{ backgroundImage: 'url("/hg-web-background.jpg")' }}>
+      {assetError && <div className="error-message">{assetError}</div>}
       <div 
         ref={containerRef} 
-        style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }}
+        className="graph-wrapper"
       >
-        {/* Graph Column - Sticky to keep lines aligned with text during scroll */}
-        <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}>
+        <div className="graph-canvas-column">
           <canvas ref={canvasRef} style={{ display: 'block' }} />
         </div>
 
-        {/* Details Column */}
-        <div style={{ flex: 1 }}>
+        <div className="graph-details-column">
           {changesets.map((cs) => (
-            <div 
-              key={cs.node} 
-              style={{ 
-                height: rowHeight, 
-                display: 'flex', 
-                alignItems: 'center', 
-                padding: '0 15px', 
-                borderBottom: '1px solid #252525', 
-                cursor: 'pointer',
-                fontSize: '13px',
-                whiteSpace: 'nowrap'
-              }}
+            <button
+              type="button"
+              key={cs.node}
+              className="graph-row"
               onClick={() => onCommitClick?.(cs.node)}
-              onMouseEnter={(e) => (e.currentTarget.style.background = '#222')}
-              onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
+              aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`}
             >
-              <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span>
-              <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span>
-              <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span>
-            </div>
+              <span className="graph-row-meta">
+                <span className="graph-hash">{cs.node.substring(0, 12)}</span>
+                <span className="graph-user">{cs.user.split(' <')[0]}</span>
+                {cs.branch && <span className="graph-branch">{cs.branch}</span>}
+              </span>
+              <span className="graph-desc">{cs.desc}</span>
+            </button>
           ))}
           <div id="infinite-scroll-sentinel" style={{ height: '50px' }} />
         </div>
       </div>
       
-      {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>}
+      {loading && <div className="graph-loading-row">Loading repository history...</div>}
     </div>
   );
 };
--- a/hg-web/src/index.css	Sun Aug 02 08:34:54 2026 -0700
+++ b/hg-web/src/index.css	Sun Aug 02 09:01:24 2026 -0700
@@ -270,13 +270,15 @@
   align-items: flex-start;
   max-height: 600px;
   overflow-y: auto;
+  position: relative;
 }
 
 .graph-canvas-column {
   flex-shrink: 0;
-  background: var(--bg);
   position: sticky;
   left: 0;
+  z-index: 1;
+  border-right: 1px solid var(--border);
 }
 
 .graph-details-column {
@@ -285,11 +287,16 @@
 }
 
 .graph-row {
+  width: 100%;
   height: 40px;
   display: flex;
   flex-direction: column;
   justify-content: center;
   padding: 0 12px;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  border: 0;
   border-bottom: 1px solid var(--border);
   font-size: 12px;
   cursor: pointer;
@@ -300,6 +307,12 @@
   background: var(--hover);
 }
 
+.graph-row:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: -2px;
+  background: var(--hover);
+}
+
 .graph-row-meta {
   display: flex;
   gap: 10px;
@@ -365,6 +378,128 @@
 }
 
 /* ===========================================
+   Changeset Detail
+   =========================================== */
+.changeset-paper {
+  background: var(--bg);
+  background-image: url("/pencil_texture.png");
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 24px;
+}
+
+.changeset-heading,
+.changeset-meta,
+.changeset-parents,
+.changeset-files,
+.changeset-labels {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.changeset-heading {
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.changeset-heading code {
+  overflow-wrap: anywhere;
+}
+
+.changeset-paper h2 {
+  margin-bottom: 8px;
+}
+
+.changeset-meta {
+  color: var(--text-secondary);
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+
+.changeset-branch,
+.changeset-labels span {
+  background: var(--bg-subtle);
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  padding: 2px 8px;
+  font-size: 12px;
+}
+
+.changeset-labels,
+.changeset-parents,
+.changeset-files {
+  margin: 12px 0;
+}
+
+.changeset-parents button {
+  border: 0;
+  background: transparent;
+  color: var(--accent);
+  cursor: pointer;
+  font-family: monospace;
+}
+
+.changeset-parents button:hover {
+  text-decoration: underline;
+}
+
+.changeset-parents button:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: 2px;
+}
+
+.changeset-files code {
+  overflow-wrap: anywhere;
+}
+
+.changeset-diff {
+  margin-top: 24px;
+}
+
+.changeset-diff h3 {
+  margin-bottom: 10px;
+}
+
+.changeset-diff pre {
+  margin: 0 0 16px;
+  padding: 0;
+  overflow-x: auto;
+  border: 1px solid var(--border);
+}
+
+.changeset-diff pre > span {
+  display: flex;
+  min-width: max-content;
+  padding-right: 12px;
+}
+
+.diff-line-number {
+  width: 54px;
+  flex-shrink: 0;
+  margin-right: 12px;
+  padding-right: 10px;
+  color: var(--text-secondary);
+  text-align: right;
+  user-select: none;
+  border-right: 1px solid var(--border);
+}
+
+.diff-add {
+  background: color-mix(in srgb, var(--success) 18%, transparent);
+}
+
+.diff-remove {
+  background: color-mix(in srgb, var(--danger) 18%, transparent);
+}
+
+.diff-range {
+  color: var(--accent);
+  background: var(--bg-subtle);
+}
+
+/* ===========================================
    Common States
    =========================================== */
 .empty-state {
--- a/markdown_converter/markdown_to_html.c	Sun Aug 02 08:34:54 2026 -0700
+++ b/markdown_converter/markdown_to_html.c	Sun Aug 02 09:01:24 2026 -0700
@@ -61,14 +61,6 @@
   buf->length += len;
 }
 
-static void buffer_append_n(StringBuffer *buf, const char *str, size_t n)
-{
-  buffer_grow(buf, n);
-  memcpy(buf->data + buf->length, str, n);
-  buf->length += n;
-  buf->data[buf->length] = '\0';
-}
-
 static void buffer_append_char(StringBuffer *buf, char c)
 {
   buffer_grow(buf, 1);
@@ -76,6 +68,39 @@
   buf->data[buf->length] = '\0';
 }
 
+static void buffer_append_html_escaped_n(StringBuffer *buf, const char *text, size_t len)
+{
+  for (size_t i = 0; i < len; i++) {
+    switch (text[i]) {
+      case '&': buffer_append(buf, "&amp;"); break;
+      case '<': buffer_append(buf, "&lt;"); break;
+      case '>': buffer_append(buf, "&gt;"); break;
+      case '"': buffer_append(buf, "&quot;"); break;
+      case '\'': buffer_append(buf, "&#39;"); break;
+      default: buffer_append_char(buf, text[i]); break;
+    }
+  }
+}
+
+static int is_safe_url(const char *url, size_t len, int is_image)
+{
+  if (len == 0) return 0;
+
+  for (size_t i = 0; i < len; i++) {
+    unsigned char c = (unsigned char)url[i];
+    if (iscntrl(c) || isspace(c)) return 0;
+  }
+
+  const char *colon = memchr(url, ':', len);
+  if (!colon) return 1;
+
+  size_t scheme_len = (size_t)(colon - url);
+  if (scheme_len == 4 && strncasecmp(url, "http", scheme_len) == 0) return 1;
+  if (scheme_len == 5 && strncasecmp(url, "https", scheme_len) == 0) return 1;
+  if (!is_image && scheme_len == 6 && strncasecmp(url, "mailto", scheme_len) == 0) return 1;
+  return 0;
+}
+
 static void buffer_free(StringBuffer *buf)
 {
   if (buf) {
@@ -160,28 +185,6 @@
   return 1;
 }
 
-// Check if line starts with a specific HTML tag (e.g., "script", "style")
-static int is_html_tag(const char *line, const char *tag)
-{
-  line = skip_whitespace(line);
-  if (*line != '<') return 0;
-  line++;
-
-  // Skip optional /
-  int is_closing = 0;
-  if (*line == '/') {
-    is_closing = 1;
-    line++;
-  }
-
-  size_t tag_len = strlen(tag);
-  if (strncasecmp(line, tag, tag_len) != 0) return 0;
-
-  char next = line[tag_len];
-  // Tag must be followed by space, >, or end for closing tags
-  return next == '>' || next == ' ' || next == '\t' || next == '\n' || next == '\0';
-}
-
 // Check if line is ordered list item
 static int is_ordered_list(const char *line)
 {
@@ -359,11 +362,16 @@
         while (url_end < len && text[url_end] != ')') url_end++;
 
         if (url_end < len) {
-          buffer_append(buf, "<a href=\"");
-          buffer_append_n(buf, text + url_start, url_end - url_start);
-          buffer_append(buf, "\">");
-          buffer_append_n(buf, text + link_start, link_end - link_start);
-          buffer_append(buf, "</a>");
+          size_t url_len = url_end - url_start;
+          if (is_safe_url(text + url_start, url_len, 0)) {
+            buffer_append(buf, "<a href=\"");
+            buffer_append_html_escaped_n(buf, text + url_start, url_len);
+            buffer_append(buf, "\">");
+            process_inline(buf, text + link_start, link_end - link_start);
+            buffer_append(buf, "</a>");
+          } else {
+            process_inline(buf, text + link_start, link_end - link_start);
+          }
           i = url_end + 1;
           continue;
         }
@@ -382,11 +390,16 @@
         while (url_end < len && text[url_end] != ')') url_end++;
 
         if (url_end < len) {
-          buffer_append(buf, "<img src=\"");
-          buffer_append_n(buf, text + url_start, url_end - url_start);
-          buffer_append(buf, "\" alt=\"");
-          buffer_append_n(buf, text + alt_start, alt_end - alt_start);
-          buffer_append(buf, "\">");
+          size_t url_len = url_end - url_start;
+          if (is_safe_url(text + url_start, url_len, 1)) {
+            buffer_append(buf, "<img src=\"");
+            buffer_append_html_escaped_n(buf, text + url_start, url_len);
+            buffer_append(buf, "\" alt=\"");
+            buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start);
+            buffer_append(buf, "\">");
+          } else {
+            buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start);
+          }
           i = url_end + 1;
           continue;
         }
@@ -449,25 +462,14 @@
 
       if (end < len) {
         buffer_append(buf, "<code>");
-        buffer_append_n(buf, text + start, end - start);
+        buffer_append_html_escaped_n(buf, text + start, end - start);
         buffer_append(buf, "</code>");
         i = end + 1;
         continue;
       }
     }
 
-    // This might not be needed for now.
-    // HTML escape special characters
-    // if (text[i] == '<') {
-    //   buffer_append(buf, "&lt;");
-    // } else if (text[i] == '>') {
-    //   buffer_append(buf, "&gt;");
-    // } else if (text[i] == '&') {
-    //   buffer_append(buf, "&amp;");
-    // } else {
-    //   buffer_append_char(buf, text[i]);
-    // }
-    buffer_append_char(buf, text[i]);
+    buffer_append_html_escaped_n(buf, text + i, 1);
     i++;
   }
 }
@@ -759,48 +761,11 @@
       }
     }
 
-    // HTML block - pass through unchanged
+    // Repository markdown is untrusted. Render raw HTML as text.
     if (is_html_block_start(line)) {
-      // Check if it's a script or style tag that needs special handling
-      int is_script = is_html_tag(line, "script");
-      int is_style = is_html_tag(line, "style");
-
-      if (is_script || is_style) {
-        const char *end_tag = is_script ? "</script>" : "</style>";
-
-        // Output the opening line
-        buffer_append(buf, line);
-        buffer_append_char(buf, '\n');
-
-        free(line);
-        if (*ptr == '\n') ptr++;
-
-        // Collect content until closing tag
-        while (*ptr) {
-          line_start = ptr;
-          while (*ptr && *ptr != '\n') ptr++;
-          line_len = ptr - line_start;
-
-          line = (char *)malloc(line_len + 1);
-          if (!line) break;
-          memcpy(line, line_start, line_len);
-          line[line_len] = '\0';
-
-          buffer_append(buf, line);
-          buffer_append_char(buf, '\n');
-
-          int found_end = (strstr(line, end_tag) != NULL);
-          free(line);
-          if (*ptr == '\n') ptr++;
-
-          if (found_end) break;
-        }
-        continue;
-      }
-
-      // Regular HTML tag - just pass through the line
-      buffer_append(buf, line);
-      buffer_append_char(buf, '\n');
+      buffer_append(buf, "<p>");
+      process_inline(buf, line, line_len);
+      buffer_append(buf, "</p>");
       free(line);
       if (*ptr == '\n') ptr++;
       continue;
--- a/markdown_converter/tests/BUILD	Sun Aug 02 08:34:54 2026 -0700
+++ b/markdown_converter/tests/BUILD	Sun Aug 02 09:01:24 2026 -0700
@@ -1,3 +1,11 @@
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+cc_test(
+    name = "markdown_to_html_test",
+    srcs = ["markdown_to_html_test.c"],
+    deps = ["//markdown_converter:markdown_to_html_c"],
+)
+
 # load("//gui_ze:gui_ze.bzl", "bun_run", "move_files_into_dir")
 # 
 # # Test for WASM module (run with: bazel test //markdown_converter:markdown_to_html_wasm_test)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/markdown_converter/tests/markdown_to_html_test.c	Sun Aug 02 09:01:24 2026 -0700
@@ -0,0 +1,32 @@
+#include "markdown_converter/markdown_to_html.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static int failures = 0;
+
+static void expect_equal(const char *name, const char *markdown, const char *expected)
+{
+  char *actual = markdown_to_html(markdown);
+  if (!actual || strcmp(actual, expected) != 0) {
+    fprintf(stderr, "%s\nexpected: %s\nactual:   %s\n", name, expected, actual ? actual : "(null)");
+    failures++;
+  }
+  markdown_free(actual);
+}
+
+int main(void)
+{
+  expect_equal("normal link", "[link](https://example.com)",
+               "<p><a href=\"https://example.com\">link</a></p>");
+  expect_equal("escape paragraph", "a & <b>", "<p>a &amp; &lt;b&gt;</p>");
+  expect_equal("escape raw script", "<script>alert('x')</script>",
+               "<p>&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;</p>");
+  expect_equal("reject script link", "[open](javascript:alert)", "<p>open</p>");
+  expect_equal("reject data image", "![preview](data:image/svg+xml,test)", "<p>preview</p>");
+  expect_equal("escape link attribute", "[link](https://example.com/\"x)",
+               "<p><a href=\"https://example.com/&quot;x\">link</a></p>");
+  expect_equal("escape inline code", "`<script>`", "<p><code>&lt;script&gt;</code></p>");
+
+  return failures == 0 ? 0 : 1;
+}
--- a/seobeo/s_http_client.c	Sun Aug 02 08:34:54 2026 -0700
+++ b/seobeo/s_http_client.c	Sun Aug 02 09:01:24 2026 -0700
@@ -1,5 +1,46 @@
 #include "seobeo/seobeo.h"
 #include <ctype.h>
+#include <time.h>
+
+static int64_t Seobeo_Client_Monotonic_Milliseconds(void)
+{
+  struct timespec now;
+  clock_gettime(CLOCK_MONOTONIC, &now);
+  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+}
+
+static boolean Seobeo_Client_Read_Timed_Out(int32 timeout_ms, int64_t last_progress_ms)
+{
+  return timeout_ms > 0 &&
+         Seobeo_Client_Monotonic_Milliseconds() - last_progress_ms >= timeout_ms;
+}
+
+static size_t Seobeo_Client_Find_Header_Length(const uint8 *buffer, size_t length)
+{
+  if (!buffer || length < 4)
+    return 0;
+  for (size_t i = 0; i + 3 < length; i++)
+  {
+    if (buffer[i] == '\r' && buffer[i + 1] == '\n' &&
+        buffer[i + 2] == '\r' && buffer[i + 3] == '\n')
+      return i + 4;
+  }
+  return 0;
+}
+
+static const char *Seobeo_Client_Header_Value(
+    Seobeo_Request_Entry *headers,
+    const char *name)
+{
+  if (!headers || !name)
+    return NULL;
+  for (size_t i = 0; i < Dowa_Array_Length(headers); i++)
+  {
+    if (headers[i].key && strcasecmp(headers[i].key, name) == 0)
+      return headers[i].value;
+  }
+  return NULL;
+}
 
 static void Seobeo_Client_Parse_Url(const char *url, char **p_host, 
     char **p_port, char **p_path, boolean *p_use_tls, Dowa_Arena *p_arena)
@@ -87,6 +128,7 @@
 
   p_req->follow_redirects = FALSE;
   p_req->max_redirects = 10;
+  p_req->timeout_ms = 0;
 
   return p_req;
 }
@@ -149,6 +191,13 @@
   p_req->max_redirects = max_redirects > 0 ? max_redirects : 10;
 }
 
+void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms)
+{
+  if (!p_req)
+    return;
+  p_req->timeout_ms = timeout_ms > 0 ? timeout_ms : 0;
+}
+
 void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path)
 {
   if (!p_req || !path)
@@ -223,7 +272,10 @@
   return offset;
 }
 
-static Seobeo_Client_Response *Seobeo_Client_Parse_Response(Seobeo_Handle *p_handle, const char *download_path)
+static Seobeo_Client_Response *Seobeo_Client_Parse_Response(
+    Seobeo_Handle *p_handle,
+    const char *download_path,
+    int32 timeout_ms)
 {
   Seobeo_Client_Response *p_resp = malloc(sizeof(Seobeo_Client_Response));
   if (!p_resp)
@@ -238,27 +290,49 @@
     return NULL;
   }
 
+  int64_t last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
   while (TRUE)
   {
     int r = Seobeo_Handle_Read(p_handle);
-    if (r < 0)
-      return p_resp;
     if (r == -2)
       break;
+    if (r < 0)
+    {
+      Seobeo_Client_Response_Destroy(p_resp);
+      return NULL;
+    }
+    if (r > 0)
+      last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
 
-    if (p_handle->read_buffer_len >= 4 && strstr((char*)p_handle->read_buffer, "\r\n\r\n") != NULL)
+    if (Seobeo_Client_Find_Header_Length(
+            p_handle->read_buffer, p_handle->read_buffer_len) > 0)
       break;
 
     if (r == 0)
+    {
+      if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms))
+      {
+        Seobeo_Log(SEOBEO_ERROR, "HTTP response header timed out\n");
+        Seobeo_Client_Response_Destroy(p_resp);
+        return NULL;
+      }
+      usleep(1000);
       continue;
+    }
   }
 
-  char *buf = (char*)p_handle->read_buffer;
-  char *hdr_end = strstr(buf, "\r\n\r\n");
-  if (!hdr_end)
-    return p_resp;
+  size_t hdr_len = Seobeo_Client_Find_Header_Length(
+      p_handle->read_buffer, p_handle->read_buffer_len);
+  if (hdr_len == 0)
+  {
+    Seobeo_Client_Response_Destroy(p_resp);
+    return NULL;
+  }
 
-  size_t hdr_len = hdr_end - buf + 4;
+  char *buf = Dowa_Arena_Allocate(p_resp->p_arena, hdr_len + 1);
+  memcpy(buf, p_handle->read_buffer, hdr_len);
+  buf[hdr_len] = '\0';
+  char *hdr_end = buf + hdr_len - 4;
 
   char version[16];
   int status_code;
@@ -328,12 +402,12 @@
 
   Seobeo_Handle_Consume(p_handle, (uint32)hdr_len);
 
-  void *p_cl_kv = Dowa_HashMap_Get_Ptr(p_resp->headers, "Content-Length");
   size_t body_len = 0;
-  if (p_cl_kv)
+  const char *content_length = Seobeo_Client_Header_Value(
+      p_resp->headers, "Content-Length");
+  if (content_length)
   {
-    const char *content_length_str = ((Seobeo_Request_Entry*)p_cl_kv)->value;
-    body_len = atoi(content_length_str);
+    body_len = (size_t)strtoull(content_length, NULL, 10);
   }
 
   FILE *p_file = NULL;
@@ -366,15 +440,32 @@
 
         total_read += to_copy;
         Seobeo_Handle_Consume(p_handle, (uint32)to_copy);
+        last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
       }
 
       if (total_read < body_len)
       {
         int r = Seobeo_Handle_Read(p_handle);
-        if (r < 0 || r == -2)
-          break;
+        if (r == -2 || r < 0)
+        {
+          if (p_file) fclose(p_file);
+          Seobeo_Client_Response_Destroy(p_resp);
+          return NULL;
+        }
+        if (r > 0)
+          last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
         if (r == 0)
+        {
+          if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms))
+          {
+            Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n");
+            if (p_file) fclose(p_file);
+            Seobeo_Client_Response_Destroy(p_resp);
+            return NULL;
+          }
+          usleep(1000);
           continue;
+        }
       }
     }
 
@@ -397,9 +488,12 @@
 
     while (1)
     {
-      int n = Seobeo_Handle_Read(p_handle);
+      int n = p_handle->read_buffer_len > 0
+          ? (int)p_handle->read_buffer_len
+          : Seobeo_Handle_Read(p_handle);
       if (n > 0)
       {
+        last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
         if (download_path)
         {
           fwrite(p_handle->read_buffer, 1, p_handle->read_buffer_len, p_file);
@@ -420,9 +514,23 @@
       else if (n == -2)
         break;
       else if (n == 0)
+      {
+        if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms))
+        {
+          Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n");
+          if (p_file) fclose(p_file);
+          Seobeo_Client_Response_Destroy(p_resp);
+          return NULL;
+        }
+        usleep(1000);
         continue;
+      }
       else
-        break;
+      {
+        if (p_file) fclose(p_file);
+        Seobeo_Client_Response_Destroy(p_resp);
+        return NULL;
+      }
     }
 
     if (!download_path)
@@ -468,7 +576,8 @@
     return NULL;
   }
 
-  Seobeo_Client_Response *p_resp = Seobeo_Client_Parse_Response(p_handle, p_req->download_path);
+  Seobeo_Client_Response *p_resp =
+      Seobeo_Client_Parse_Response(p_handle, p_req->download_path, p_req->timeout_ms);
 
   Seobeo_Handle_Destroy(p_handle);
 
--- a/seobeo/s_web.c	Sun Aug 02 08:34:54 2026 -0700
+++ b/seobeo/s_web.c	Sun Aug 02 09:01:24 2026 -0700
@@ -58,11 +58,13 @@
     case HTTP_FORBIDDEN: status_text = "Forbidden"; break;
     case HTTP_NOT_FOUND: status_text = "Not Found"; break;
     case HTTP_INTERNAL_ERROR: status_text = "Internal Server Error"; break;
+    case 502: status_text = "Bad Gateway"; break;
+    case 504: status_text = "Gateway Timeout"; break;
     default: status_text = "Unknown"; break;
   }
 
-  sprintf(
-    buffer,
+  snprintf(
+    (char*)buffer, 1024,
     "HTTP/1.1 %d %s\r\n"
     "Content-Type: %s\r\n"
     "Content-Length: %d\r\n"
@@ -774,6 +776,11 @@
   Seobeo_Router_Send_Response_KeepAlive(p_handle, p_response_map, p_arena, FALSE);
 }
 
+static boolean Seobeo_Response_Header_Value_Is_Safe(const char *value)
+{
+  return value && strchr(value, '\r') == NULL && strchr(value, '\n') == NULL;
+}
+
 void Seobeo_Router_Send_Response_KeepAlive(
     Seobeo_Handle *p_handle,
     Seobeo_Request_Entry *p_response_map,
@@ -819,26 +826,63 @@
   else
     body_length = strlen(body);
 
-  char *header = Dowa_Arena_Allocate(p_arena, 4096);
-  Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive);
+  size_t header_capacity = 1024;
   for (int i = 0; i < Dowa_Array_Length(p_response_map); i++)
   {
+    const char *key = p_response_map[i].key;
+    const char *value = p_response_map[i].value;
     if (
-      strstr(p_response_map[i].key, "status") ||
-      strstr(p_response_map[i].key, "body") ||
-      strstr(p_response_map[i].key, "content-type") ||
-      strstr(p_response_map[i].key, "content-length")
+      strcasecmp(key, "status") == 0 ||
+      strcasecmp(key, "body") == 0 ||
+      strcasecmp(key, "content-type") == 0 ||
+      strcasecmp(key, "content-length") == 0
+    )
+      continue;
+    if (Seobeo_Response_Header_Value_Is_Safe(key) &&
+        Seobeo_Response_Header_Value_Is_Safe(value))
+      header_capacity += strlen(key) + strlen(value) + 4;
+  }
+
+  char *header = Dowa_Arena_Allocate(p_arena, header_capacity);
+  Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive);
+  size_t header_length = strlen(header);
+  if (header_length < 2)
+    return;
+  header_length -= 2;
+
+  for (int i = 0; i < Dowa_Array_Length(p_response_map); i++)
+  {
+    const char *key = p_response_map[i].key;
+    const char *value = p_response_map[i].value;
+    if (
+      strcasecmp(key, "status") == 0 ||
+      strcasecmp(key, "body") == 0 ||
+      strcasecmp(key, "content-type") == 0 ||
+      strcasecmp(key, "content-length") == 0
     )
       continue;
 
-    int32 current_header_len = strlen(header);
-    char *temp = malloc(sizeof(char) * 1024);
-    sprintf(temp, "%s: %s\r\n\r\n", p_response_map[i].key, p_response_map[i].value);
-    memcpy(&header[current_header_len - 2 /* \r\n */], temp, strlen(temp));
-    free(temp);
+    if (!Seobeo_Response_Header_Value_Is_Safe(key) ||
+        !Seobeo_Response_Header_Value_Is_Safe(value))
+    {
+      Seobeo_Log(SEOBEO_WARNING, "Skipping unsafe response header\n");
+      continue;
+    }
+
+    int written = snprintf(
+        header + header_length,
+        header_capacity - header_length,
+        "%s: %s\r\n",
+        key,
+        value);
+    if (written < 0 || (size_t)written >= header_capacity - header_length)
+    {
+      Seobeo_Log(SEOBEO_ERROR, "Response header exceeded allocated capacity\n");
+      return;
+    }
+    header_length += (size_t)written;
   }
-
-  printf("hEADER %s\n", header);
+  memcpy(header + header_length, "\r\n", 3);
 
   Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header));
   Seobeo_Handle_Queue(p_handle, (uint8_t*)body, body_length);
--- a/seobeo/seobeo.h	Sun Aug 02 08:34:54 2026 -0700
+++ b/seobeo/seobeo.h	Sun Aug 02 09:01:24 2026 -0700
@@ -82,6 +82,8 @@
 extern void                    Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length);
 /* Enable/disable following redirects with max redirect count. Default is FALSE with 10 max. */
 extern void                    Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects);
+/* Set the maximum idle time for HTTP response reads. Zero disables the timeout. */
+extern void                    Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms);
 /* Set download path to save response body to file instead of memory. */
 extern void                    Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path);
 /* Execute the HTTP request and return response. */
--- a/seobeo/seobeo_internal.h	Sun Aug 02 08:34:54 2026 -0700
+++ b/seobeo/seobeo_internal.h	Sun Aug 02 09:01:24 2026 -0700
@@ -131,6 +131,7 @@
 
   boolean  follow_redirects;
   int32    max_redirects;
+  int32    timeout_ms;
 
   char    *download_path;
 
@@ -158,6 +159,7 @@
 extern void                    Seobeo_Client_Request_Add_Header_Array(Seobeo_Client_Request *p_req, const char *header);
 extern void                    Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length);
 extern void                    Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects);
+extern void                    Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms);
 extern void                    Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path);
 extern Seobeo_Client_Response *Seobeo_Client_Request_Execute(Seobeo_Client_Request *p_req);
 extern void                    Seobeo_Client_Request_Destroy(Seobeo_Client_Request *p_req);
--- a/seobeo/tests/BUILD	Sun Aug 02 08:34:54 2026 -0700
+++ b/seobeo/tests/BUILD	Sun Aug 02 09:01:24 2026 -0700
@@ -28,3 +28,39 @@
   args = ["$(location //seobeo/examples:websocket_server_example)"],
   visibility = ["//visibility:public"],
 )
+
+cc_test(
+  name = "seobeo_response_test",
+  srcs = ["seobeo_response_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
+  name = "seobeo_http_timeout_test",
+  srcs = ["seobeo_http_timeout_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
+  name = "seobeo_http_framing_test",
+  srcs = ["seobeo_http_framing_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
+  name = "seobeo_http_content_length_test",
+  srcs = ["seobeo_http_content_length_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_http_content_length_test.c	Sun Aug 02 09:01:24 2026 -0700
@@ -0,0 +1,91 @@
+#include "seobeo/seobeo.h"
+
+#include <arpa/inet.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+
+typedef struct {
+  int listener;
+} Content_Length_Server;
+
+static int64_t monotonic_milliseconds(void)
+{
+  struct timespec now;
+  clock_gettime(CLOCK_MONOTONIC, &now);
+  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+}
+
+static void *serve_lowercase_content_length(void *argument)
+{
+  Content_Length_Server *server = argument;
+  int client = accept(server->listener, NULL, NULL);
+  if (client >= 0) {
+    char request[1024];
+    (void)read(client, request, sizeof(request));
+    const char response[] =
+        "HTTP/1.1 200 OK\r\n"
+        "content-length: 4\r\n"
+        "Connection: keep-alive\r\n"
+        "\r\n"
+        "done";
+    (void)write(client, response, sizeof(response) - 1);
+    usleep(500000);
+    close(client);
+  }
+  return NULL;
+}
+
+int main(void)
+{
+  int listener = socket(AF_INET, SOCK_STREAM, 0);
+  if (listener < 0)
+    return 1;
+
+  struct sockaddr_in address = {
+    .sin_family = AF_INET,
+    .sin_addr.s_addr = htonl(INADDR_LOOPBACK),
+    .sin_port = 0,
+  };
+  if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 ||
+      listen(listener, 1) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  socklen_t address_length = sizeof(address);
+  if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  Content_Length_Server server = {.listener = listener};
+  pthread_t thread;
+  if (pthread_create(&thread, NULL, serve_lowercase_content_length, &server) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  char url[128];
+  snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port));
+  Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
+  Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000);
+  int64_t started = monotonic_milliseconds();
+  Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
+  int64_t elapsed = monotonic_milliseconds() - started;
+
+  int failed = !response ||
+               response->body_length != 4 ||
+               memcmp(response->body, "done", 4) != 0 ||
+               elapsed > 300;
+  if (failed)
+    fprintf(stderr, "Case-insensitive Content-Length was not honored (%lldms)\n",
+            (long long)elapsed);
+
+  Seobeo_Client_Response_Destroy(response);
+  Seobeo_Client_Request_Destroy(request);
+  pthread_join(thread, NULL);
+  close(listener);
+  return failed;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_http_framing_test.c	Sun Aug 02 09:01:24 2026 -0700
@@ -0,0 +1,79 @@
+#include "seobeo/seobeo.h"
+
+#include <arpa/inet.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+
+typedef struct {
+  int listener;
+} Framing_Server;
+
+static void *serve_close_delimited_response(void *argument)
+{
+  Framing_Server *server = argument;
+  int client = accept(server->listener, NULL, NULL);
+  if (client >= 0) {
+    char request[1024];
+    (void)read(client, request, sizeof(request));
+    const char response[] =
+        "HTTP/1.1 200 OK\r\n"
+        "Content-Type: text/plain\r\n"
+        "Connection: close\r\n"
+        "\r\n"
+        "buffered-body";
+    (void)write(client, response, sizeof(response) - 1);
+    close(client);
+  }
+  return NULL;
+}
+
+int main(void)
+{
+  int listener = socket(AF_INET, SOCK_STREAM, 0);
+  if (listener < 0)
+    return 1;
+
+  struct sockaddr_in address = {
+    .sin_family = AF_INET,
+    .sin_addr.s_addr = htonl(INADDR_LOOPBACK),
+    .sin_port = 0,
+  };
+  if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 ||
+      listen(listener, 1) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  socklen_t address_length = sizeof(address);
+  if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  Framing_Server server = {.listener = listener};
+  pthread_t thread;
+  if (pthread_create(&thread, NULL, serve_close_delimited_response, &server) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  char url[128];
+  snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port));
+  Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
+  Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000);
+  Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
+
+  int failed = !response ||
+               response->status_code != 200 ||
+               response->body_length != strlen("buffered-body") ||
+               memcmp(response->body, "buffered-body", strlen("buffered-body")) != 0;
+  if (failed)
+    fprintf(stderr, "Close-delimited response body was not preserved\n");
+
+  Seobeo_Client_Response_Destroy(response);
+  Seobeo_Client_Request_Destroy(request);
+  pthread_join(thread, NULL);
+  close(listener);
+  return failed;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_http_timeout_test.c	Sun Aug 02 09:01:24 2026 -0700
@@ -0,0 +1,82 @@
+#include "seobeo/seobeo.h"
+
+#include <arpa/inet.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+
+typedef struct {
+  int listener;
+} Timeout_Server;
+
+static int64_t monotonic_milliseconds(void)
+{
+  struct timespec now;
+  clock_gettime(CLOCK_MONOTONIC, &now);
+  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+}
+
+static void *serve_stalled_response(void *argument)
+{
+  Timeout_Server *server = argument;
+  int client = accept(server->listener, NULL, NULL);
+  if (client >= 0) {
+    char request[1024];
+    (void)read(client, request, sizeof(request));
+    usleep(250000);
+    close(client);
+  }
+  return NULL;
+}
+
+int main(void)
+{
+  int listener = socket(AF_INET, SOCK_STREAM, 0);
+  if (listener < 0)
+    return 1;
+
+  struct sockaddr_in address = {
+    .sin_family = AF_INET,
+    .sin_addr.s_addr = htonl(INADDR_LOOPBACK),
+    .sin_port = 0,
+  };
+  if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 ||
+      listen(listener, 1) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  socklen_t address_length = sizeof(address);
+  if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  Timeout_Server server = {.listener = listener};
+  pthread_t thread;
+  if (pthread_create(&thread, NULL, serve_stalled_response, &server) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  char url[128];
+  snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port));
+  Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
+  Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 50);
+
+  int64_t started = monotonic_milliseconds();
+  Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
+  int64_t elapsed = monotonic_milliseconds() - started;
+
+  int failed = response != NULL || elapsed < 40 || elapsed > 2000;
+  if (failed)
+    fprintf(stderr, "Expected a bounded timeout; response=%p elapsed=%lldms\n",
+            (void *)response, (long long)elapsed);
+
+  Seobeo_Client_Response_Destroy(response);
+  Seobeo_Client_Request_Destroy(request);
+  pthread_join(thread, NULL);
+  close(listener);
+  return failed;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_response_test.c	Sun Aug 02 09:01:24 2026 -0700
@@ -0,0 +1,49 @@
+#include "seobeo/seobeo.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+
+int main(void)
+{
+  int sockets[2];
+  if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0)
+    return 1;
+
+  Seobeo_Handle handle = {0};
+  handle.socket = sockets[0];
+  handle.write_buffer_capacity = 4096;
+  handle.write_buffer = malloc(handle.write_buffer_capacity);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(4096);
+  Seobeo_Request_Entry *response = NULL;
+  Dowa_HashMap_Push_Arena(response, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena);
+  Dowa_HashMap_Push_Arena(response, "body", "hello", arena);
+  Dowa_HashMap_Push_Arena(response, "X-Test", "present", arena);
+  Dowa_HashMap_Push_Arena(response, "X-Unsafe", "bad\r\nInjected: yes", arena);
+
+  Seobeo_Router_Send_Response(&handle, response, arena);
+
+  char received[4096] = {0};
+  ssize_t length = read(sockets[1], received, sizeof(received) - 1);
+  int failed = 0;
+  if (length <= 0)
+    failed = 1;
+  if (!strstr(received, "Content-Length: 5\r\n"))
+    failed = 1;
+  if (!strstr(received, "X-Test: present\r\n\r\nhello"))
+    failed = 1;
+  if (strstr(received, "Injected: yes"))
+    failed = 1;
+
+  if (failed)
+    fprintf(stderr, "Unexpected response:\n%s\n", received);
+
+  close(sockets[0]);
+  close(sockets[1]);
+  free(handle.write_buffer);
+  Dowa_Arena_Free(arena);
+  return failed;
+}