···11-# wicket — Design Document
22-33-This document captures the design decisions for wicket, a webhook-to-SSE
44-gateway. It's written for a future implementation session.
55-66-## HTTP API
77-88-Two verbs, same path:
99-1010-```
1111-POST /<path> → publish an event
1212-GET /<path> Accept: text/event-stream → subscribe (SSE stream)
1313-```
1414-1515-The path is an arbitrary topic name. No registration, no schema. The
1616-path exists as soon as someone POSTs or subscribes to it.
1717-1818-A GET without `Accept: text/event-stream` returns 404 (or a simple
1919-status page at `/`).
2020-2121-## Hierarchical topics
2222-2323-Path separators (`/`) are topic hierarchy levels. Publishing to a path
2424-delivers to subscribers at every prefix level:
2525-2626-```
2727-POST /github.com/chrisguidry/docketeer
2828-```
2929-3030-delivers to subscribers on:
3131-- `/github.com/chrisguidry/docketeer` (exact match)
3232-- `/github.com/chrisguidry/` (parent prefix)
3333-- `/github.com/` (grandparent)
3434-- `/` (root — everything)
3535-3636-This is a fan-out from leaf to root, not a glob or pattern match.
3737-3838-## Event envelope
3939-4040-Every event delivered to subscribers is wrapped:
4141-4242-```json
4343-{
4444- "id": "unique-event-id",
4545- "timestamp": "2026-03-04T12:00:00Z",
4646- "path": "github.com/chrisguidry/docketeer",
4747- "headers": {"X-GitHub-Event": "push", "Content-Type": "application/json"},
4848- "payload": { ... raw webhook body ... }
4949-}
5050-```
5151-5252-SSE format:
5353-5454-```
5555-id: unique-event-id
5656-data: {"id":"...","timestamp":"...","path":"...","headers":{...},"payload":{...}}
5757-```
5858-5959-The `id` field is a UUID (or ULID — implementer's choice). `headers` is
6060-the subset of request headers from the POST (skip hop-by-hop headers).
6161-`payload` is the raw body, JSON-parsed if Content-Type is JSON, otherwise
6262-base64-encoded.
6363-6464-## Auth model: open by default, configuration for secrets
6565-6666-wicket works with zero configuration. A YAML configuration file layers in
6767-authentication only where needed:
6868-6969-```yaml
7070-paths:
7171- github.com/chrisguidry/docketeer:
7272- # Inbound: verify webhook signatures
7373- verify: hmac-sha256
7474- secret: "the-github-webhook-secret"
7575- signature_header: X-Hub-Signature-256
7676-7777- # Outbound: require Bearer token to subscribe
7878- subscribe_secret: "token-for-docketeer-subscribers"
7979-8080- github.com/chrisguidry:
8181- # Broader subscribe secret for all repos under this prefix
8282- subscribe_secret: "token-for-all-chris-repos"
8383-```
8484-8585-Behavior matrix:
8686-8787-| Scenario | Result |
8888-|---|---|
8989-| POST to unconfigured path | Accepted, forwarded to subscribers |
9090-| POST to configured path, valid signature | Accepted |
9191-| POST to configured path, invalid/missing signature | 403 Forbidden |
9292-| GET SSE on path with no subscribe_secret | Open |
9393-| GET SSE on path with subscribe_secret | Requires `Authorization: Bearer <token>` |
9494-9595-Configuration lookup walks up the path hierarchy. A path inherits the
9696-`subscribe_secret` of its nearest configured ancestor. Verification
9797-(`verify`/`secret`/`signature_header`) only applies to exact path matches
9898-— it doesn't inherit, because different webhook sources have different
9999-secrets.
100100-101101-Configuration is hot-reloaded via fsnotify file watching or SIGHUP signal.
102102-The server reads configuration on every request through an
103103-`atomic.Pointer` or `sync.RWMutex` — no restart needed.
104104-105105-There is no REST API for configuration. The file IS the configuration.
106106-107107-## CORS
108108-109109-wicket sends CORS headers on all responses so browser apps can publish
110110-and subscribe. `Access-Control-Allow-Origin: *`,
111111-`Access-Control-Allow-Methods: GET, POST, OPTIONS`,
112112-`Access-Control-Allow-Headers: Content-Type`. OPTIONS preflight requests
113113-get a 204.
114114-115115-Note: the browser's native `EventSource` API doesn't support custom
116116-headers, so there's no way to send `Authorization: Bearer` from a
117117-browser SSE connection. This means browser apps can only subscribe to
118118-paths that don't have a `subscribe_secret`. That's fine — protected
119119-paths are for server-to-server use; browsers subscribe to public topics.
120120-121121-## SSE reconnection
122122-123123-The SSE spec supports `Last-Event-ID`. When a subscriber reconnects with
124124-this header, wicket replays any events it missed.
125125-126126-Storage: a single global ring buffer (configurable size, default 1000
127127-events). Each event in the buffer has its path, so on replay we filter to
128128-only events matching the subscriber's path (respecting hierarchy). A
129129-per-path ring buffer would waste memory for sparse topics.
130130-131131-Events older than the buffer are gone. This is real-time delivery, not a
132132-durable queue.
133133-134134-## Query filters
135135-136136-Optional query params for filtering on the subscribe side:
137137-138138-```
139139-GET /github.com/chrisguidry/docketeer?filter=payload.ref:refs/heads/main
140140-```
141141-142142-Parsing: split on `:` to get `field.path` and `value`. The field path
143143-uses dot notation to navigate the JSON envelope. Multiple `filter` params
144144-are AND'd.
145145-146146-No operators, no regex, no full query language. Just `field.path:value`
147147-string equality. This covers the common case (filter GitHub pushes to a
148148-specific branch) without building a query engine.
149149-150150-## Project structure
151151-152152-```
153153-wicket/
154154- go.mod
155155- main.go # CLI entry point, flag parsing, signal handling
156156- server.go # HTTP server, route dispatch (POST vs SSE GET)
157157- configuration.go # YAML configuration loading + hot reload (fsnotify)
158158- broker.go # Topic tree, fan-out to subscribers, ring buffer
159159- verify.go # Signature verification (HMAC-SHA256, extensible)
160160- filter.go # Query filter parsing and matching
161161- server_test.go # Integration tests
162162- broker_test.go # Unit tests for topic matching and fan-out
163163- verify_test.go # Unit tests for signature verification
164164- filter_test.go # Unit tests for filter matching
165165- configuration_test.go # Unit tests for configuration loading
166166- README.md
167167- DESIGN.md
168168- Dockerfile
169169- wicket.yaml.example
170170-```
171171-172172-Single package (`main`). No internal packages, no pkg directory. This is
173173-a small, focused binary.
174174-175175-## Component details
176176-177177-### main.go
178178-179179-Parse flags: `-configuration` (path to YAML, optional), `-address` (listen
180180-address, default `:8080`), `-buffer-size` (ring buffer size, default 1000).
181181-182182-Load configuration if provided. Create broker. Start HTTP server. Handle
183183-signals: SIGHUP reloads configuration, SIGTERM/SIGINT triggers graceful shutdown (close
184184-listeners, drain SSE connections).
185185-186186-### server.go
187187-188188-Single `http.Handler` implementation. Route based on method + Accept header:
189189-190190-- `POST` → read body, look up path configuration, verify signature if configured,
191191- publish to broker, return 202 Accepted (or 403 if verification fails)
192192-- `GET` with `Accept: text/event-stream` → check subscribe auth if
193193- configured, subscribe via broker, write SSE stream with flushing,
194194- handle client disconnect via `request.Context()`
195195-- `GET` without SSE accept → 404
196196-197197-SSE streaming: set `Content-Type: text/event-stream`, `Cache-Control: no-cache`,
198198-`Connection: keep-alive`. Flush after each event. Use `http.Flusher`.
199199-200200-Return 202 (not 200) for POSTs to signal "accepted for delivery" since
201201-delivery is async.
202202-203203-### broker.go
204204-205205-The core component. A topic tree where each node holds a list of
206206-subscriber channels.
207207-208208-```go
209209-type Broker struct {
210210- mu sync.RWMutex
211211- subscribers map[string][]chan *Event // path → subscriber channels
212212- buffer *RingBuffer
213213-}
214214-```
215215-216216-Key methods:
217217-- `Publish(path string, event *Event)`: Add to ring buffer. Walk up the
218218- path hierarchy (split on `/`), delivering to subscribers at each level.
219219-- `Subscribe(path string, lastEventID string) (<-chan *Event, func())`:
220220- Register a channel at the path. If `lastEventID` is set, replay from
221221- buffer. Return the channel and an unsubscribe function.
222222-223223-The ring buffer is a fixed-size circular array with a monotonic index for
224224-efficient `Last-Event-ID` lookup.
225225-226226-### configuration.go
227227-228228-```go
229229-type Configuration struct {
230230- Paths map[string]PathConfiguration `yaml:"paths"`
231231-}
232232-233233-type PathConfiguration struct {
234234- Verify string `yaml:"verify"`
235235- Secret string `yaml:"secret"`
236236- SignatureHeader string `yaml:"signature_header"`
237237- SubscribeSecret string `yaml:"subscribe_secret"`
238238-}
239239-```
240240-241241-`LoadConfiguration(path string) (*Configuration, error)` reads and parses the file.
242242-`WatchConfiguration(path string, callback func(*Configuration))` uses fsnotify to
243243-detect changes and calls the callback with the new configuration.
244244-245245-The server holds the configuration in an `atomic.Pointer[Configuration]` for lock-free
246246-reads on every request.
247247-248248-### verify.go
249249-250250-Interface-based for extensibility:
251251-252252-```go
253253-type Verifier interface {
254254- Verify(body []byte, headers http.Header, secret string) error
255255-}
256256-```
257257-258258-Implementations:
259259-- `hmacSHA256Verifier`: reads signature from configured header, computes
260260- `HMAC-SHA256(secret, body)`, compares with `hmac.Equal`
261261-- `hmacSHA1Verifier`: same pattern for SHA1 (older GitHub webhooks)
262262-263263-Factory function: `NewVerifier(method string) (Verifier, error)` returns
264264-the right implementation based on the configuration string.
265265-266266-### filter.go
267267-268268-```go
269269-type Filter struct {
270270- Path string // dot-separated path into the event JSON
271271- Value string // expected string value
272272-}
273273-```
274274-275275-`ParseFilters(query url.Values) []Filter` extracts `filter` params.
276276-`MatchAll(filters []Filter, event *Event) bool` checks all filters
277277-against the event envelope (navigating nested JSON via the dot path).
278278-279279-## Dockerfile
280280-281281-```dockerfile
282282-FROM golang:1.24 AS build
283283-WORKDIR /src
284284-COPY go.mod go.sum ./
285285-RUN go mod download
286286-COPY . .
287287-RUN CGO_ENABLED=0 go build -o /wicket .
288288-289289-FROM scratch
290290-COPY --from=build /wicket /wicket
291291-ENTRYPOINT ["/wicket"]
292292-```
293293-294294-Two-stage build, scratch base, static binary.
295295-296296-## Test strategy
297297-298298-### Unit tests
299299-300300-**broker_test.go**:
301301-- Publish to exact path, subscriber receives event
302302-- Publish to child path, parent subscriber receives event
303303-- Publish to child path, unrelated subscriber does NOT receive
304304-- Multiple subscribers on same path all receive
305305-- Unsubscribe removes subscriber, no more events
306306-- Ring buffer wraps correctly at capacity
307307-- `Last-Event-ID` replay returns correct events
308308-- `Last-Event-ID` replay respects path hierarchy
309309-- `Last-Event-ID` for an event that fell off the buffer returns only new events
310310-311311-**verify_test.go**:
312312-- HMAC-SHA256 with known test vectors (GitHub's documented examples)
313313-- Missing signature header → error
314314-- Wrong signature → error
315315-- Unknown verify method → error
316316-317317-**filter_test.go**:
318318-- Single filter matches
319319-- Single filter doesn't match
320320-- Multiple filters AND'd — all match
321321-- Multiple filters AND'd — one doesn't match
322322-- Nested dot path navigation
323323-- Missing field in payload → no match (not an error)
324324-- Empty filter list → matches everything
325325-326326-**configuration_test.go**:
327327-- Load valid configuration
328328-- Load configuration with missing file → error
329329-- Empty configuration (no paths) is valid
330330-- Path lookup walks hierarchy for subscribe_secret
331331-- Path lookup does NOT inherit verify/secret
332332-333333-### Integration tests
334334-335335-**server_test.go**:
336336-- Start server, POST event, SSE subscriber receives it
337337-- POST with valid HMAC signature → 202
338338-- POST with invalid signature → 403
339339-- POST to unconfigured path → 202 (no verification)
340340-- SSE subscribe with valid Bearer token → connected
341341-- SSE subscribe with wrong token → 401
342342-- SSE subscribe to open path → connected
343343-- Subscribe to prefix, POST to child, subscriber receives event
344344-- `Last-Event-ID` reconnection replays missed events
345345-- Filter query param filters events correctly
346346-- Graceful shutdown closes SSE connections
347347-348348-All tests use `httptest.NewServer` for the integration tests. No external
349349-dependencies, no test containers, everything runs in-process.