P2.2: fail-closed when EDGE_SECRET is unset

ober

1d421446347b8b7ed21aef6a237b710a79190313

diff --git a/edge.ss b/edge.ss
index b2be62b..8aaec77 100644
--- a/edge.ss
+++ b/edge.ss
@@ -58,6 +58,12 @@
 (def *port*     (or (and (getenv "EDGE_PORT")    (string->number (getenv "EDGE_PORT")))    8080))
 (def *workers*  (or (and (getenv "EDGE_WORKERS") (string->number (getenv "EDGE_WORKERS"))) 4))
 (def *secret*   (or (getenv "EDGE_SECRET") ""))
+;; P2.2 fix: empty *secret* must NOT silently accept all requests.
+;; To run without HMAC for local development, set EDGE_ALLOW_UNSIGNED=1
+;; explicitly. Any other unset/empty value fails closed.
+(def *allow-unsigned?*
+  (let ([v (getenv "EDGE_ALLOW_UNSIGNED")])
+    (and v (or (string=? v "1") (string=? v "true") (string=? v "yes")))))
 (def *db-path*  (getenv "EDGE_DB_PATH"))    ;; SQLite file path, or #f for in-memory only
 (def *tls-cert* (getenv "EDGE_TLS_CERT"))  ;; PEM cert path, or #f to disable TLS
 (def *tls-key*  (getenv "EDGE_TLS_KEY"))   ;; PEM key path, or #f to disable TLS
@@ -280,15 +286,23 @@
         (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 (bytes->hex
-                             (native-hmac-sha256
-                               (string->utf8 *secret*)
-                               (string->utf8 body)))])
-             (native-crypto-memcmp
-               (string->utf8 expected)
-               (string->utf8 sig))))))
+  ;; P2.2 fix: fail-closed when no secret is configured. Permit unsigned
+  ;; requests ONLY when an operator has explicitly set EDGE_ALLOW_UNSIGNED=1
+  ;; (development mode). An accidentally-unset EDGE_SECRET no longer
+  ;; defeats signature verification.
+  (cond
+    [(string=? *secret* "")
+     ;; No secret configured. Only honor unsigned requests in explicit dev mode.
+     *allow-unsigned?*]
+    [(not sig) #f]  ;; secret configured but no signature provided → reject
+    [else
+     (let ([expected (bytes->hex
+                       (native-hmac-sha256
+                         (string->utf8 *secret*)
+                         (string->utf8 body)))])
+       (native-crypto-memcmp
+         (string->utf8 expected)
+         (string->utf8 sig)))]))
 
 ;; ═══════════════════════════════════════════════════════════════
 ;; Sandboxed User Filters (Phase 2.1)
@@ -1004,7 +1018,11 @@
     (displayln "  ──────────────────────────────────────────────")
     (printf    "  port:      ~a~n" *port*)
     (printf    "  workers:   ~a (supervised, one-for-one)~n" *workers*)
-    (printf    "  hmac:      ~a~n" (if (string=? *secret* "") "disabled" "enabled"))
+    (printf    "  hmac:      ~a~n"
+      (cond
+        [(not (string=? *secret* "")) "enabled"]
+        [*allow-unsigned?* "DISABLED (EDGE_ALLOW_UNSIGNED=1 — dev mode, unsigned requests accepted)"]
+        [else "DISABLED — fail-closed (set EDGE_SECRET=... to enable HMAC, or EDGE_ALLOW_UNSIGNED=1 for dev)"]))
     (printf    "  retries:   ~a (1s/2s/4s backoff → dead-letter)~n" *max-retries*)
     (printf    "  sqlite:    ~a~n" (if *db-path* *db-path* "disabled"))
     (printf    "  tls:       ~a~n" (if *tls-cert* (format ":~a" *tls-port*) "disabled"))