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

Initial project scaffolding

Set up wicket as a Go module with build tooling: pre-commit hooks
for gofmt, go vet, staticcheck, loq, and go test. Minimal main.go
that parses the planned CLI flags. Two-stage Dockerfile for scratch-
based container builds.

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

guid.foo 18e92100

+549
+6
.gitignore
··· 1 + wicket 2 + .idea/ 3 + .vscode/ 4 + *.swp 5 + .DS_Store 6 + .loq_cache
+52
.pre-commit-config.yaml
··· 1 + repos: 2 + - repo: https://github.com/pre-commit/pre-commit-hooks 3 + rev: v6.0.0 4 + hooks: 5 + - id: trailing-whitespace 6 + - id: end-of-file-fixer 7 + exclude: ^\.loq_cache$ 8 + - id: check-yaml 9 + - id: check-toml 10 + - id: check-added-large-files 11 + 12 + - repo: https://github.com/codespell-project/codespell 13 + rev: v2.4.1 14 + hooks: 15 + - id: codespell 16 + 17 + - repo: local 18 + hooks: 19 + - id: gofmt 20 + name: gofmt 21 + entry: gofmt -l -w 22 + language: system 23 + types: [go] 24 + 25 + - id: go-vet 26 + name: go vet 27 + entry: go vet ./... 28 + language: system 29 + types: [go] 30 + pass_filenames: false 31 + 32 + - id: staticcheck 33 + name: staticcheck 34 + entry: staticcheck ./... 35 + language: system 36 + types: [go] 37 + pass_filenames: false 38 + 39 + - id: loq 40 + name: loq (max 500 lines) 41 + entry: loq check 42 + language: system 43 + types: [go] 44 + pass_filenames: false 45 + 46 + - id: go-test 47 + name: go test 48 + entry: go test ./... 49 + language: system 50 + types: [go] 51 + pass_filenames: false 52 + require_serial: true
+10
Dockerfile
··· 1 + FROM golang:1.25 AS build 2 + WORKDIR /src 3 + COPY go.mod go.sum* ./ 4 + RUN go mod download 5 + COPY . . 6 + RUN CGO_ENABLED=0 go build -o /wicket . 7 + 8 + FROM scratch 9 + COPY --from=build /wicket /wicket 10 + ENTRYPOINT ["/wicket"]
+349
IMPLEMENTATION.md
··· 1 + # wicket — Design Document 2 + 3 + This document captures the design decisions for wicket, a webhook-to-SSE 4 + gateway. It's written for a future implementation session. 5 + 6 + ## HTTP API 7 + 8 + Two verbs, same path: 9 + 10 + ``` 11 + POST /<path> → publish an event 12 + GET /<path> Accept: text/event-stream → subscribe (SSE stream) 13 + ``` 14 + 15 + The path is an arbitrary topic name. No registration, no schema. The 16 + path exists as soon as someone POSTs or subscribes to it. 17 + 18 + A GET without `Accept: text/event-stream` returns 404 (or a simple 19 + status page at `/`). 20 + 21 + ## Hierarchical topics 22 + 23 + Path separators (`/`) are topic hierarchy levels. Publishing to a path 24 + delivers to subscribers at every prefix level: 25 + 26 + ``` 27 + POST /github.com/chrisguidry/docketeer 28 + ``` 29 + 30 + delivers to subscribers on: 31 + - `/github.com/chrisguidry/docketeer` (exact match) 32 + - `/github.com/chrisguidry/` (parent prefix) 33 + - `/github.com/` (grandparent) 34 + - `/` (root — everything) 35 + 36 + This is a fan-out from leaf to root, not a glob or pattern match. 37 + 38 + ## Event envelope 39 + 40 + Every event delivered to subscribers is wrapped: 41 + 42 + ```json 43 + { 44 + "id": "unique-event-id", 45 + "timestamp": "2026-03-04T12:00:00Z", 46 + "path": "github.com/chrisguidry/docketeer", 47 + "headers": {"X-GitHub-Event": "push", "Content-Type": "application/json"}, 48 + "payload": { ... raw webhook body ... } 49 + } 50 + ``` 51 + 52 + SSE format: 53 + 54 + ``` 55 + id: unique-event-id 56 + data: {"id":"...","timestamp":"...","path":"...","headers":{...},"payload":{...}} 57 + ``` 58 + 59 + The `id` field is a UUID (or ULID — implementer's choice). `headers` is 60 + the subset of request headers from the POST (skip hop-by-hop headers). 61 + `payload` is the raw body, JSON-parsed if Content-Type is JSON, otherwise 62 + base64-encoded. 63 + 64 + ## Auth model: open by default, configuration for secrets 65 + 66 + wicket works with zero configuration. A YAML configuration file layers in 67 + authentication only where needed: 68 + 69 + ```yaml 70 + paths: 71 + github.com/chrisguidry/docketeer: 72 + # Inbound: verify webhook signatures 73 + verify: hmac-sha256 74 + secret: "the-github-webhook-secret" 75 + signature_header: X-Hub-Signature-256 76 + 77 + # Outbound: require Bearer token to subscribe 78 + subscribe_secret: "token-for-docketeer-subscribers" 79 + 80 + github.com/chrisguidry: 81 + # Broader subscribe secret for all repos under this prefix 82 + subscribe_secret: "token-for-all-chris-repos" 83 + ``` 84 + 85 + Behavior matrix: 86 + 87 + | Scenario | Result | 88 + |---|---| 89 + | POST to unconfigured path | Accepted, forwarded to subscribers | 90 + | POST to configured path, valid signature | Accepted | 91 + | POST to configured path, invalid/missing signature | 403 Forbidden | 92 + | GET SSE on path with no subscribe_secret | Open | 93 + | GET SSE on path with subscribe_secret | Requires `Authorization: Bearer <token>` | 94 + 95 + Configuration lookup walks up the path hierarchy. A path inherits the 96 + `subscribe_secret` of its nearest configured ancestor. Verification 97 + (`verify`/`secret`/`signature_header`) only applies to exact path matches 98 + — it doesn't inherit, because different webhook sources have different 99 + secrets. 100 + 101 + Configuration is hot-reloaded via fsnotify file watching or SIGHUP signal. 102 + The server reads configuration on every request through an 103 + `atomic.Pointer` or `sync.RWMutex` — no restart needed. 104 + 105 + There is no REST API for configuration. The file IS the configuration. 106 + 107 + ## CORS 108 + 109 + wicket sends CORS headers on all responses so browser apps can publish 110 + and subscribe. `Access-Control-Allow-Origin: *`, 111 + `Access-Control-Allow-Methods: GET, POST, OPTIONS`, 112 + `Access-Control-Allow-Headers: Content-Type`. OPTIONS preflight requests 113 + get a 204. 114 + 115 + Note: the browser's native `EventSource` API doesn't support custom 116 + headers, so there's no way to send `Authorization: Bearer` from a 117 + browser SSE connection. This means browser apps can only subscribe to 118 + paths that don't have a `subscribe_secret`. That's fine — protected 119 + paths are for server-to-server use; browsers subscribe to public topics. 120 + 121 + ## SSE reconnection 122 + 123 + The SSE spec supports `Last-Event-ID`. When a subscriber reconnects with 124 + this header, wicket replays any events it missed. 125 + 126 + Storage: a single global ring buffer (configurable size, default 1000 127 + events). Each event in the buffer has its path, so on replay we filter to 128 + only events matching the subscriber's path (respecting hierarchy). A 129 + per-path ring buffer would waste memory for sparse topics. 130 + 131 + Events older than the buffer are gone. This is real-time delivery, not a 132 + durable queue. 133 + 134 + ## Query filters 135 + 136 + Optional query params for filtering on the subscribe side: 137 + 138 + ``` 139 + GET /github.com/chrisguidry/docketeer?filter=payload.ref:refs/heads/main 140 + ``` 141 + 142 + Parsing: split on `:` to get `field.path` and `value`. The field path 143 + uses dot notation to navigate the JSON envelope. Multiple `filter` params 144 + are AND'd. 145 + 146 + No operators, no regex, no full query language. Just `field.path:value` 147 + string equality. This covers the common case (filter GitHub pushes to a 148 + specific branch) without building a query engine. 149 + 150 + ## Project structure 151 + 152 + ``` 153 + wicket/ 154 + go.mod 155 + main.go # CLI entry point, flag parsing, signal handling 156 + server.go # HTTP server, route dispatch (POST vs SSE GET) 157 + configuration.go # YAML configuration loading + hot reload (fsnotify) 158 + broker.go # Topic tree, fan-out to subscribers, ring buffer 159 + verify.go # Signature verification (HMAC-SHA256, extensible) 160 + filter.go # Query filter parsing and matching 161 + server_test.go # Integration tests 162 + broker_test.go # Unit tests for topic matching and fan-out 163 + verify_test.go # Unit tests for signature verification 164 + filter_test.go # Unit tests for filter matching 165 + configuration_test.go # Unit tests for configuration loading 166 + README.md 167 + DESIGN.md 168 + Dockerfile 169 + wicket.yaml.example 170 + ``` 171 + 172 + Single package (`main`). No internal packages, no pkg directory. This is 173 + a small, focused binary. 174 + 175 + ## Component details 176 + 177 + ### main.go 178 + 179 + Parse flags: `-configuration` (path to YAML, optional), `-address` (listen 180 + address, default `:8080`), `-buffer-size` (ring buffer size, default 1000). 181 + 182 + Load configuration if provided. Create broker. Start HTTP server. Handle 183 + signals: SIGHUP reloads configuration, SIGTERM/SIGINT triggers graceful shutdown (close 184 + listeners, drain SSE connections). 185 + 186 + ### server.go 187 + 188 + Single `http.Handler` implementation. Route based on method + Accept header: 189 + 190 + - `POST` → read body, look up path configuration, verify signature if configured, 191 + publish to broker, return 202 Accepted (or 403 if verification fails) 192 + - `GET` with `Accept: text/event-stream` → check subscribe auth if 193 + configured, subscribe via broker, write SSE stream with flushing, 194 + handle client disconnect via `request.Context()` 195 + - `GET` without SSE accept → 404 196 + 197 + SSE streaming: set `Content-Type: text/event-stream`, `Cache-Control: no-cache`, 198 + `Connection: keep-alive`. Flush after each event. Use `http.Flusher`. 199 + 200 + Return 202 (not 200) for POSTs to signal "accepted for delivery" since 201 + delivery is async. 202 + 203 + ### broker.go 204 + 205 + The core component. A topic tree where each node holds a list of 206 + subscriber channels. 207 + 208 + ```go 209 + type Broker struct { 210 + mu sync.RWMutex 211 + subscribers map[string][]chan *Event // path → subscriber channels 212 + buffer *RingBuffer 213 + } 214 + ``` 215 + 216 + Key methods: 217 + - `Publish(path string, event *Event)`: Add to ring buffer. Walk up the 218 + path hierarchy (split on `/`), delivering to subscribers at each level. 219 + - `Subscribe(path string, lastEventID string) (<-chan *Event, func())`: 220 + Register a channel at the path. If `lastEventID` is set, replay from 221 + buffer. Return the channel and an unsubscribe function. 222 + 223 + The ring buffer is a fixed-size circular array with a monotonic index for 224 + efficient `Last-Event-ID` lookup. 225 + 226 + ### configuration.go 227 + 228 + ```go 229 + type Configuration struct { 230 + Paths map[string]PathConfiguration `yaml:"paths"` 231 + } 232 + 233 + type PathConfiguration struct { 234 + Verify string `yaml:"verify"` 235 + Secret string `yaml:"secret"` 236 + SignatureHeader string `yaml:"signature_header"` 237 + SubscribeSecret string `yaml:"subscribe_secret"` 238 + } 239 + ``` 240 + 241 + `LoadConfiguration(path string) (*Configuration, error)` reads and parses the file. 242 + `WatchConfiguration(path string, callback func(*Configuration))` uses fsnotify to 243 + detect changes and calls the callback with the new configuration. 244 + 245 + The server holds the configuration in an `atomic.Pointer[Configuration]` for lock-free 246 + reads on every request. 247 + 248 + ### verify.go 249 + 250 + Interface-based for extensibility: 251 + 252 + ```go 253 + type Verifier interface { 254 + Verify(body []byte, headers http.Header, secret string) error 255 + } 256 + ``` 257 + 258 + Implementations: 259 + - `hmacSHA256Verifier`: reads signature from configured header, computes 260 + `HMAC-SHA256(secret, body)`, compares with `hmac.Equal` 261 + - `hmacSHA1Verifier`: same pattern for SHA1 (older GitHub webhooks) 262 + 263 + Factory function: `NewVerifier(method string) (Verifier, error)` returns 264 + the right implementation based on the configuration string. 265 + 266 + ### filter.go 267 + 268 + ```go 269 + type Filter struct { 270 + Path string // dot-separated path into the event JSON 271 + Value string // expected string value 272 + } 273 + ``` 274 + 275 + `ParseFilters(query url.Values) []Filter` extracts `filter` params. 276 + `MatchAll(filters []Filter, event *Event) bool` checks all filters 277 + against the event envelope (navigating nested JSON via the dot path). 278 + 279 + ## Dockerfile 280 + 281 + ```dockerfile 282 + FROM golang:1.24 AS build 283 + WORKDIR /src 284 + COPY go.mod go.sum ./ 285 + RUN go mod download 286 + COPY . . 287 + RUN CGO_ENABLED=0 go build -o /wicket . 288 + 289 + FROM scratch 290 + COPY --from=build /wicket /wicket 291 + ENTRYPOINT ["/wicket"] 292 + ``` 293 + 294 + Two-stage build, scratch base, static binary. 295 + 296 + ## Test strategy 297 + 298 + ### Unit tests 299 + 300 + **broker_test.go**: 301 + - Publish to exact path, subscriber receives event 302 + - Publish to child path, parent subscriber receives event 303 + - Publish to child path, unrelated subscriber does NOT receive 304 + - Multiple subscribers on same path all receive 305 + - Unsubscribe removes subscriber, no more events 306 + - Ring buffer wraps correctly at capacity 307 + - `Last-Event-ID` replay returns correct events 308 + - `Last-Event-ID` replay respects path hierarchy 309 + - `Last-Event-ID` for an event that fell off the buffer returns only new events 310 + 311 + **verify_test.go**: 312 + - HMAC-SHA256 with known test vectors (GitHub's documented examples) 313 + - Missing signature header → error 314 + - Wrong signature → error 315 + - Unknown verify method → error 316 + 317 + **filter_test.go**: 318 + - Single filter matches 319 + - Single filter doesn't match 320 + - Multiple filters AND'd — all match 321 + - Multiple filters AND'd — one doesn't match 322 + - Nested dot path navigation 323 + - Missing field in payload → no match (not an error) 324 + - Empty filter list → matches everything 325 + 326 + **configuration_test.go**: 327 + - Load valid configuration 328 + - Load configuration with missing file → error 329 + - Empty configuration (no paths) is valid 330 + - Path lookup walks hierarchy for subscribe_secret 331 + - Path lookup does NOT inherit verify/secret 332 + 333 + ### Integration tests 334 + 335 + **server_test.go**: 336 + - Start server, POST event, SSE subscriber receives it 337 + - POST with valid HMAC signature → 202 338 + - POST with invalid signature → 403 339 + - POST to unconfigured path → 202 (no verification) 340 + - SSE subscribe with valid Bearer token → connected 341 + - SSE subscribe with wrong token → 401 342 + - SSE subscribe to open path → connected 343 + - Subscribe to prefix, POST to child, subscriber receives event 344 + - `Last-Event-ID` reconnection replays missed events 345 + - Filter query param filters events correctly 346 + - Graceful shutdown closes SSE connections 347 + 348 + All tests use `httptest.NewServer` for the integration tests. No external 349 + dependencies, no test containers, everything runs in-process.
+102
README.md
··· 1 + # wicket 2 + 3 + A webhook-to-SSE gateway. Receives webhook POSTs, delivers them to 4 + subscribers as Server-Sent Events. 5 + 6 + ## How it works 7 + 8 + The entire HTTP API is two verbs on the same path: 9 + 10 + ``` 11 + POST /<path> → publish an event 12 + GET /<path> Accept: text/event-stream → subscribe (SSE stream) 13 + ``` 14 + 15 + The path is an arbitrary topic name. Path separators create a hierarchy — 16 + subscribing to a prefix gets you all events underneath it: 17 + 18 + ``` 19 + GET /github.com/chrisguidry/docketeer → just docketeer events 20 + GET /github.com/chrisguidry/ → all chrisguidry repo events 21 + GET /github.com/ → all GitHub events 22 + GET / → everything 23 + ``` 24 + 25 + A POST to `/github.com/chrisguidry/docketeer` delivers to all four 26 + subscribers above. 27 + 28 + ## Zero configuration by default 29 + 30 + wicket works out of the box with no configuration — any path accepts 31 + POSTs and SSE connections. A YAML configuration file layers in authentication 32 + only where needed: 33 + 34 + ```yaml 35 + paths: 36 + github.com/chrisguidry/docketeer: 37 + verify: hmac-sha256 38 + secret: "the-github-webhook-secret" 39 + signature_header: X-Hub-Signature-256 40 + subscribe_secret: "token-for-docketeer-subscribers" 41 + ``` 42 + 43 + - Unconfigured paths accept POSTs without verification 44 + - Configured paths verify webhook signatures and reject invalid ones 45 + - Subscribe secrets require `Authorization: Bearer <token>` for SSE connections 46 + - Configuration is hot-reloaded on file change or SIGHUP 47 + 48 + ## Browser support 49 + 50 + CORS headers are sent on all responses, so browser apps can POST events 51 + and subscribe via `EventSource`. One limitation: the browser's 52 + `EventSource` API doesn't support custom headers, so browser clients 53 + can only subscribe to paths without a `subscribe_secret`. Protected 54 + paths are for server-to-server use. 55 + 56 + ## Event format 57 + 58 + Every event is wrapped in an envelope and delivered as SSE: 59 + 60 + ``` 61 + id: unique-event-id 62 + data: {"id":"...","timestamp":"...","path":"github.com/chrisguidry/docketeer","headers":{...},"payload":{...}} 63 + ``` 64 + 65 + Subscribers can reconnect with `Last-Event-ID` to replay missed events 66 + from an in-memory ring buffer (default 1000 events). 67 + 68 + ## Filtering 69 + 70 + Optional query params filter events on the subscribe side: 71 + 72 + ``` 73 + GET /github.com/chrisguidry/docketeer?filter=payload.ref:refs/heads/main 74 + ``` 75 + 76 + Simple dot-path equality matching. Multiple filters are AND'd. 77 + 78 + ## Usage 79 + 80 + ```bash 81 + wicket # listen on :8080, no configuration 82 + wicket -address :9090 # custom listen address 83 + wicket -configuration wicket.yaml # with webhook verification 84 + wicket -buffer-size 5000 # larger replay buffer 85 + ``` 86 + 87 + ## Building 88 + 89 + ```bash 90 + go build -o wicket . 91 + ``` 92 + 93 + Or with Docker: 94 + 95 + ```bash 96 + docker build -t wicket . 97 + ``` 98 + 99 + ## Why the name "wicket"? 100 + 101 + A "wicket" is a [small door or gate](https://en.wiktionary.org/wiki/wicket) 102 + next to a larger one.
+3
go.mod
··· 1 + module tangled.org/guid.foo/wicket 2 + 3 + go 1.24
+5
loq.toml
··· 1 + default_max_lines = 500 2 + respect_gitignore = true 3 + exclude = [ 4 + "**/vendor/**", 5 + ]
+16
main.go
··· 1 + package main 2 + 3 + import ( 4 + "flag" 5 + "fmt" 6 + "os" 7 + ) 8 + 9 + func main() { 10 + address := flag.String("address", ":8080", "listen address") 11 + _ = flag.String("configuration", "", "path to configuration file") 12 + _ = flag.Int("buffer-size", 1000, "event replay buffer size") 13 + flag.Parse() 14 + 15 + fmt.Fprintf(os.Stderr, "wicket listening on %s\n", *address) 16 + }
+6
wicket.yaml.example
··· 1 + paths: 2 + github.com/chrisguidry/docketeer: 3 + verify: hmac-sha256 4 + secret: "the-github-webhook-secret" 5 + signature_header: X-Hub-Signature-256 6 + subscribe_secret: "token-for-docketeer-subscribers"