feat: implement killer demo features — crash recovery, HMAC, dedup, benchmarks

ober

14031f65a1d2e612c5edbecabffa538da8054c4b

diff --git a/Makefile b/Makefile
index 39b543d..580b56e 100644
--- a/Makefile
+++ b/Makefile
@@ -21,6 +21,14 @@ run-custom:
 test:
 	./test-edge.sh
 
+## Run with HMAC enabled for signature tests
+test-hmac:
+	EDGE_SECRET=test-secret ./test-edge.sh
+
+## Benchmark with wrk (requires wrk)
+bench:
+	./bench-edge.sh
+
 ## Syntax check (no server needed)
 check:
 	LD_LIBRARY_PATH=$(NATIVE) $(SCHEME) --compile-imported-libraries --script /dev/null 2>&1 || true
diff --git a/bench-edge.sh b/bench-edge.sh
new file mode 100755
index 0000000..835a16b
--- /dev/null
+++ b/bench-edge.sh
@@ -0,0 +1,67 @@
+#!/bin/bash
+# bench-edge.sh — Benchmark Jerboa Edge throughput
+#
+# Requires: wrk (apt install wrk / brew install wrk)
+# Usage: Start the server first (make run), then run this script.
+
+set -euo pipefail
+
+PORT=${EDGE_PORT:-8080}
+BASE="http://localhost:${PORT}"
+DURATION=${BENCH_DURATION:-10s}
+THREADS=${BENCH_THREADS:-4}
+CONNECTIONS=${BENCH_CONNECTIONS:-100}
+
+# Verify server is up
+if ! curl -sf "$BASE/health" > /dev/null 2>&1; then
+  echo "Error: Server not responding at $BASE/health"
+  echo "Start with: make run"
+  exit 1
+fi
+
+echo "═══════════════════════════════════════════════"
+echo " Jerboa Edge Benchmark"
+echo "═══════════════════════════════════════════════"
+echo " target:      $BASE"
+echo " duration:    $DURATION"
+echo " threads:     $THREADS"
+echo " connections: $CONNECTIONS"
+echo ""
+
+# Create Lua script for POST requests with unique IDs
+cat > /tmp/edge-bench-post.lua << 'LUAEOF'
+local counter = 0
+
+request = function()
+  counter = counter + 1
+  local body = string.format('{"id":"bench_%d_%d","amount":%d}',
+    counter, math.random(1000000), math.random(10000))
+  return wrk.format("POST", "/hooks/payment.completed",
+    {["Content-Type"] = "application/json"}, body)
+end
+LUAEOF
+
+# ── GET /health (baseline) ────────────────────────────────────
+echo "--- GET /health (baseline) ---"
+wrk -t"$THREADS" -c"$CONNECTIONS" -d"$DURATION" "$BASE/health"
+echo ""
+
+# ── POST /hooks/:type (webhook ingestion) ─────────────────────
+echo "--- POST /hooks/payment.completed ---"
+wrk -t"$THREADS" -c"$CONNECTIONS" -d"$DURATION" -s /tmp/edge-bench-post.lua "$BASE/hooks/payment.completed"
+echo ""
+
+# ── GET /api/stats (read path) ────────────────────────────────
+echo "--- GET /api/stats ---"
+wrk -t"$THREADS" -c"$CONNECTIONS" -d"$DURATION" "$BASE/api/stats"
+echo ""
+
+# Cleanup
+rm -f /tmp/edge-bench-post.lua
+
+# Final stats
+echo "═══════════════════════════════════════════════"
+echo " Post-benchmark stats:"
+curl -s "$BASE/api/stats" | python3 -m json.tool 2>/dev/null || curl -s "$BASE/api/stats"
+echo ""
+echo "═══════════════════════════════════════════════"
diff --git a/edge.ss b/edge.ss
index 52b9c9b..96e5c54 100644
--- a/edge.ss
+++ b/edge.ss
@@ -96,14 +96,25 @@
 ;; HMAC-SHA256 Signature Verification
 ;; ═══════════════════════════════════════════════════════════════
 
+(def (bytes->hex bv)
+  (let ([out (make-string (* 2 (bytevector-length bv)))])
+    (do ([i 0 (+ i 1)])
+      ((= i (bytevector-length bv)) out)
+      (let* ([b (bytevector-u8-ref bv i)]
+             [hi (fxsrl b 4)]
+             [lo (fxand b #xf)])
+        (string-set! out (* i 2) (string-ref "0123456789abcdef" hi))
+        (string-set! out (+ (* i 2) 1) (string-ref "0123456789abcdef" lo))))))
+
 (def (verify-signature sig body)
   (or (string=? *secret* "")  ;; skip when no secret configured
       (and sig
-           (let ([expected (native-hmac-sha256
-                             (string->utf8 *secret*)
-                             (string->utf8 body))])
+           (let ([expected (bytes->hex
+                             (native-hmac-sha256
+                               (string->utf8 *secret*)
+                               (string->utf8 body)))])
              (native-crypto-memcmp
-               expected
+               (string->utf8 expected)
                (string->utf8 sig))))))
 
 ;; ═══════════════════════════════════════════════════════════════
@@ -157,6 +168,13 @@
   (lambda (evt)
     (displayln "[order] " (hash-ref evt "id") " shipped")))
 
+;; Crash handler — deliberately errors to demonstrate supervisor restart.
+;; POST /hooks/test.crash to trigger. The worker that picks this up
+;; will crash; the supervisor restarts it, other workers are unaffected.
+(register-handler! "test.crash"
+  (lambda (evt)
+    (error 'worker "deliberate crash for demo")))
+
 (def (dispatch-handler type)
   (or (hash-get handler-registry type)
       (lambda (evt)
@@ -169,6 +187,8 @@
 ;; No intermediate allocations — compose-transducers fuses steps.
 ;; ═══════════════════════════════════════════════════════════════
 
+(def seen-ids (make-hash-table))
+
 (def ingest-xf
   (compose-transducers
     ;; Validate: drop malformed events
@@ -176,6 +196,15 @@
       (lambda (evt)
         (and (hash-key? evt "type")
              (hash-key? evt "payload"))))
+    ;; Deduplicate: drop events with IDs we've already seen
+    (filtering
+      (lambda (evt)
+        (let ([id (hash-ref evt "id" #f)])
+          (cond
+            [(not id) #t]  ;; no ID → pass through
+            [(hash-key? seen-ids id)
+             (displayln "[dedup] dropping duplicate " id) #f]
+            [else (hash-put! seen-ids id #t) #t]))))
     ;; Normalize: ensure every event has a timestamp
     (mapping
       (lambda (evt)
@@ -207,14 +236,21 @@
            (when (and event (not (eof-object? event)))
              (let* ([type    (hash-ref event "type" "unknown")]
                     [handler (dispatch-handler type)]
-                    [status  (guard (exn [#t
-                               (displayln "[worker-" id "] error: "
-                                 (if (message-condition? exn)
-                                   (condition-message exn) exn))
-                               "error"])
-                               (handler event) "ok")])
+                    [result  (try (ok (handler event))
+                               (catch (e) (err e)))]
+                    [status  (if (ok? result) "ok" "error")])
+               (when (err? result)
+                 (let ([e (unwrap-err result)])
+                   (displayln "[worker-" id "] error: "
+                     (if (message-condition? e)
+                       (condition-message e) e))))
                (store-result! event status)
-               (notify-watchers! event status))
+               (notify-watchers! event status)
+               ;; Deliberate crash: re-raise to kill actor.
+               ;; Supervisor will restart it automatically.
+               (when (and (err? result) (string=? type "test.crash"))
+                 (displayln "[worker-" id "] crashing — supervisor will restart")
+                 (raise (unwrap-err result))))
              (loop))))]
       [_ (void)])))
 
diff --git a/test-edge.sh b/test-edge.sh
index 35d8a85..847816c 100755
--- a/test-edge.sh
+++ b/test-edge.sh
@@ -2,6 +2,7 @@
 # test-edge.sh — Smoke tests for Jerboa Edge
 #
 # Usage: Start the server first (make run), then run this script.
+# For HMAC tests, start with: EDGE_SECRET=test-secret make run
 
 set -euo pipefail
 
@@ -23,16 +24,27 @@ check() {
   fi
 }
 
+check_code() {
+  local name="$1" expected="$2" actual="$3"
+  if [ "$actual" = "$expected" ]; then
+    echo "  PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "  FAIL: $name (expected HTTP $expected, got $actual)"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
 echo "=== Jerboa Edge Smoke Tests ==="
 echo "  target: $BASE"
 echo ""
 
-# 1. Health check
+# ── 1. Health check ──────────────────────────────────────────
 echo "--- Health ---"
 R=$(curl -s "$BASE/health")
 check "GET /health" '"status":"ok"' "$R"
 
-# 2. Ingest a payment webhook
+# ── 2. Webhook Ingestion ─────────────────────────────────────
 echo "--- Webhook Ingestion ---"
 R=$(curl -s -X POST "$BASE/hooks/payment.completed" \
   -H "Content-Type: application/json" \
@@ -40,13 +52,11 @@ R=$(curl -s -X POST "$BASE/hooks/payment.completed" \
 check "POST /hooks/payment.completed" '"status":"accepted"' "$R"
 check "  returns event ID" '"id":"test_1"' "$R"
 
-# 3. Ingest a user webhook
 R=$(curl -s -X POST "$BASE/hooks/user.created" \
   -H "Content-Type: application/json" \
   -d '{"id":"test_2","name":"Alice"}')
 check "POST /hooks/user.created" '"status":"accepted"' "$R"
 
-# 4. Ingest with auto-generated ID
 R=$(curl -s -X POST "$BASE/hooks/order.shipped" \
   -H "Content-Type: application/json" \
   -d '{"tracking":"TRACK123"}')
@@ -55,32 +65,118 @@ check "POST /hooks/order.shipped (auto-ID)" '"status":"accepted"' "$R"
 # Give workers time to process
 sleep 1
 
-# 5. Query stats
+# ── 3. Query API ─────────────────────────────────────────────
 echo "--- Query API ---"
 R=$(curl -s "$BASE/api/stats")
 check "GET /api/stats" '"total":3' "$R"
 check "  all succeeded" '"ok":3' "$R"
 
-# 6. Query specific event
 R=$(curl -s "$BASE/api/events/test_1")
 check "GET /api/events/test_1" '"type":"payment.completed"' "$R"
 check "  has status" '"status":"ok"' "$R"
 
-# 7. Query nonexistent event
 R=$(curl -s "$BASE/api/events/nonexistent")
 check "GET /api/events/nonexistent" '"error":"not found"' "$R"
 
-# 8. Duplicate event (same ID)
+# ── 4. Deduplication ─────────────────────────────────────────
+echo "--- Deduplication ---"
 R=$(curl -s -X POST "$BASE/hooks/payment.completed" \
   -H "Content-Type: application/json" \
   -d '{"id":"test_1","amount":9999}')
-check "POST duplicate ID accepted" '"status":"accepted"' "$R"
+check "POST duplicate ID accepted (HTTP level)" '"status":"accepted"' "$R"
+
+sleep 1
+
+# Stats should still show 3 processed (duplicate was dropped by pipeline)
+R=$(curl -s "$BASE/api/stats")
+check "  dedup: total still 3" '"total":3' "$R"
+
+# ── 5. Crash Recovery ────────────────────────────────────────
+echo "--- Crash Recovery (supervisor restart) ---"
+
+# Send a crash event
+R=$(curl -s -X POST "$BASE/hooks/test.crash" \
+  -H "Content-Type: application/json" \
+  -d '{"id":"crash_1","payload":"boom"}')
+check "POST /hooks/test.crash accepted" '"status":"accepted"' "$R"
+
+sleep 2  # Give supervisor time to restart the worker
+
+# Verify the system is still operational after crash
+R=$(curl -s -X POST "$BASE/hooks/payment.completed" \
+  -H "Content-Type: application/json" \
+  -d '{"id":"post_crash_1","amount":777}')
+check "  post-crash webhook works" '"status":"accepted"' "$R"
 
-# 9. 404 for unknown route
+sleep 1
+
+R=$(curl -s "$BASE/api/events/post_crash_1")
+check "  post-crash event processed" '"status":"ok"' "$R"
+
+# ── 6. 404 ───────────────────────────────────────────────────
+echo "--- Error Handling ---"
 R=$(curl -s "$BASE/nope")
 check "GET /nope → 404" "Not Found" "$R"
 
-# 10. Rapid-fire ingestion
+# ── 7. HMAC Signature Verification ───────────────────────────
+echo "--- HMAC Signature Verification ---"
+if [ "${EDGE_SECRET:-}" != "" ]; then
+  # Test: no signature → 401
+  CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/hooks/payment.completed" \
+    -H "Content-Type: application/json" \
+    -d '{"id":"hmac_nosig","amount":1}')
+  check_code "  no signature → 401" "401" "$CODE"
+
+  # Test: wrong signature → 401
+  CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/hooks/payment.completed" \
+    -H "Content-Type: application/json" \
+    -H "x-signature: deadbeef" \
+    -d '{"id":"hmac_wrong","amount":1}')
+  check_code "  wrong signature → 401" "401" "$CODE"
+
+  # Test: valid signature → 202
+  BODY='{"id":"hmac_valid","amount":1}'
+  SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$EDGE_SECRET" -hex 2>/dev/null | sed 's/.*= //')
+  CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/hooks/payment.completed" \
+    -H "Content-Type: application/json" \
+    -H "x-signature: $SIG" \
+    -d "$BODY")
+  check_code "  valid signature → 202" "202" "$CODE"
+else
+  echo "  SKIP: HMAC tests (no EDGE_SECRET set — start with EDGE_SECRET=test-secret)"
+fi
+
+# ── 8. WebSocket Dashboard ────────────────────────────────────
+echo "--- WebSocket Dashboard ---"
+if command -v websocat &> /dev/null; then
+  # Connect WebSocket, capture initial stats message
+  WSOUT=$(timeout 3 websocat -1 "ws://localhost:${PORT}/dashboard" 2>/dev/null || true)
+  if [ -n "$WSOUT" ]; then
+    check "  WebSocket initial stats" '"total"' "$WSOUT"
+  else
+    echo "  SKIP: WebSocket connected but no data received"
+  fi
+
+  # Connect and send an event to see live notification
+  timeout 4 websocat "ws://localhost:${PORT}/dashboard" > /tmp/ws-edge-test.out 2>/dev/null &
+  WS_PID=$!
+  sleep 1
+  curl -s -X POST "$BASE/hooks/payment.completed" \
+    -H "Content-Type: application/json" \
+    -d '{"id":"ws_live","amount":42}' > /dev/null
+  sleep 2
+  kill $WS_PID 2>/dev/null || true
+  wait $WS_PID 2>/dev/null || true
+  if [ -f /tmp/ws-edge-test.out ]; then
+    WSDATA=$(cat /tmp/ws-edge-test.out)
+    check "  WebSocket live notification" '"event-processed"' "$WSDATA"
+    rm -f /tmp/ws-edge-test.out
+  fi
+else
+  echo "  SKIP: WebSocket tests (install websocat to enable)"
+fi
+
+# ── 9. Load Test (50 events) ─────────────────────────────────
 echo "--- Load Test (50 events) ---"
 for i in $(seq 1 50); do
   curl -s --max-time 5 -X POST "$BASE/hooks/load.test" \
@@ -99,7 +195,7 @@ else
   FAIL=$((FAIL + 1))
 fi
 
-# Summary
+# ── Summary ───────────────────────────────────────────────────
 echo ""
 echo "======================================="
 echo "  Results: $PASS passed, $FAIL failed"