Webhook-to-SSE gateway with hierarchical topic routing and signature verification

Fix trailing slash on subscribe and publish paths

GET /test/ and POST /test/topic/ now normalize to test and test/topic.
Without this, subscribing to /test/ wouldn't receive events published
to /test/topic because the path hierarchy walk never matched "test/".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+48 -2
+6 -2
server.go
··· 42 42 } 43 43 } 44 44 45 + func normalizePath(raw string) string { 46 + return strings.TrimRight(strings.TrimPrefix(raw, "/"), "/") 47 + } 48 + 45 49 func setCORSHeaders(w http.ResponseWriter) { 46 50 w.Header().Set("Access-Control-Allow-Origin", "*") 47 51 w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") ··· 49 53 } 50 54 51 55 func (s *Server) handlePost(w http.ResponseWriter, r *http.Request) { 52 - path := strings.TrimPrefix(r.URL.Path, "/") 56 + path := normalizePath(r.URL.Path) 53 57 54 58 body, _ := io.ReadAll(r.Body) 55 59 ··· 91 95 } 92 96 93 97 func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { 94 - path := strings.TrimPrefix(r.URL.Path, "/") 98 + path := normalizePath(r.URL.Path) 95 99 96 100 cfg := s.config.Load() 97 101 if secret := cfg.LookupSubscribeSecret(path); secret != "" {
+20
server_test.go
··· 9 9 "strings" 10 10 "sync/atomic" 11 11 "testing" 12 + "time" 12 13 ) 13 14 14 15 func newTestServer(cfg *Configuration) (*httptest.Server, *Broker) { ··· 32 33 defer resp.Body.Close() 33 34 if resp.StatusCode != http.StatusAccepted { 34 35 t.Errorf("expected 202, got %d", resp.StatusCode) 36 + } 37 + } 38 + 39 + func TestServer_postTrailingSlashNormalized(t *testing.T) { 40 + ts, broker := newTestServer(nil) 41 + defer ts.Close() 42 + 43 + ch, unsub := broker.Subscribe("test/topic", "") 44 + defer unsub() 45 + 46 + http.Post(ts.URL+"/test/topic/", "application/json", strings.NewReader(`{}`)) 47 + 48 + select { 49 + case event := <-ch: 50 + if event.Path != "test/topic" { 51 + t.Errorf("expected normalized path test/topic, got %s", event.Path) 52 + } 53 + case <-time.After(time.Second): 54 + t.Fatal("timed out: POST with trailing slash should deliver to normalized path") 35 55 } 36 56 } 37 57
+22
sse_test.go
··· 156 156 } 157 157 } 158 158 159 + func TestServer_prefixSubscriptionWithTrailingSlash(t *testing.T) { 160 + ts, _ := newTestServer(nil) 161 + defer ts.Close() 162 + 163 + ctx, cancel := context.WithCancel(context.Background()) 164 + defer cancel() 165 + 166 + events := sseSubscribe(ctx, ts.URL+"/test/", nil) 167 + time.Sleep(50 * time.Millisecond) 168 + 169 + http.Post(ts.URL+"/test/topic", "application/json", strings.NewReader(`{"hello":"world"}`)) 170 + 171 + select { 172 + case event := <-events: 173 + if event.Path != "test/topic" { 174 + t.Errorf("expected test/topic, got %s", event.Path) 175 + } 176 + case <-time.After(2 * time.Second): 177 + t.Fatal("timed out: trailing slash subscribe should receive child events") 178 + } 179 + } 180 + 159 181 func TestServer_lastEventIDReplay(t *testing.T) { 160 182 ts, broker := newTestServer(nil) 161 183 defer ts.Close()