a73x

15c7f07f

feat: load middleware config in nono-proxy main

a73x   2026-03-31 05:53

Wire up middleware.yaml loading and fix edge case where
failed response body read left body unreconstructed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/cmd/nono-proxy/main.go b/cmd/nono-proxy/main.go
index 9b3c61e..4605617 100644
--- a/cmd/nono-proxy/main.go
+++ b/cmd/nono-proxy/main.go
@@ -44,9 +44,12 @@ func main() {
	}
	log.Printf("CA cert: %s/ca.pem", store)

	mwPath := filepath.Join(store, "middleware.yaml")

	opts := []proxy.Option{
		proxy.WithRules(rulesPath),
		proxy.WithCA(caCert, caKey),
		proxy.WithMiddleware(mwPath),
	}

	p := proxy.New(hostsFile, opts...)
diff --git a/docs/superpowers/plans/2026-03-31-middleware.md b/docs/superpowers/plans/2026-03-31-middleware.md
new file mode 100644
index 0000000..8068220
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-31-middleware.md
@@ -0,0 +1,532 @@
# Middleware Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a configurable middleware system that intercepts HTTPS responses matching URL patterns and writes response bodies to files.

**Architecture:** New `middleware` package loads a YAML config mapping `host+path` patterns to `save_response` actions with a destination file. The proxy gains a `WithMiddleware` option. In the MITM loop, after reading the upstream response, middleware checks the request URL and writes the response body to the configured destination (overwriting).

**Tech Stack:** Go stdlib, `gopkg.in/yaml.v3` (already a dependency)

---

### Task 1: Middleware package — config loading

**Files:**
- Create: `middleware/middleware.go`
- Create: `middleware/middleware_test.go`

- [ ] **Step 1: Write the failing test for YAML loading**

In `middleware/middleware_test.go`:

```go
package middleware_test

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/xanderle/nono/middleware"
)

func TestNewLoadsConfig(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "middleware.yaml")
	os.WriteFile(path, []byte(`middleware:
  - match: "api.anthropic.com/api/oauth/usage"
    action: save_response
    dest: "/tmp/usage.json"
`), 0644)

	mw, err := middleware.New(path)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if mw.RuleCount() != 1 {
		t.Errorf("expected 1 rule, got %d", mw.RuleCount())
	}
}

func TestNewReturnsEmptyForMissingFile(t *testing.T) {
	mw, err := middleware.New("/nonexistent/middleware.yaml")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if mw.RuleCount() != 0 {
		t.Errorf("expected 0 rules, got %d", mw.RuleCount())
	}
}

func TestNewRejectsUnknownAction(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "middleware.yaml")
	os.WriteFile(path, []byte(`middleware:
  - match: "example.com/foo"
    action: delete_everything
    dest: "/tmp/out"
`), 0644)

	_, err := middleware.New(path)
	if err == nil {
		t.Fatal("expected error for unknown action")
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./middleware/...`
Expected: package does not exist

- [ ] **Step 3: Implement middleware package**

In `middleware/middleware.go`:

```go
package middleware

import (
	"fmt"
	"os"

	"gopkg.in/yaml.v3"
)

type Rule struct {
	Match  string
	Action string
	Dest   string
}

type Middleware struct {
	rules []Rule
}

type yamlRule struct {
	Match  string `yaml:"match"`
	Action string `yaml:"action"`
	Dest   string `yaml:"dest"`
}

type yamlConfig struct {
	Middleware []yamlRule `yaml:"middleware"`
}

// New loads middleware config from a YAML file.
// Returns an empty Middleware if the file does not exist.
func New(path string) (*Middleware, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return &Middleware{}, nil
		}
		return nil, fmt.Errorf("reading middleware config: %w", err)
	}

	var cfg yamlConfig
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("parsing middleware YAML: %w", err)
	}

	rules := make([]Rule, 0, len(cfg.Middleware))
	for _, yr := range cfg.Middleware {
		if yr.Action != "save_response" {
			return nil, fmt.Errorf("unknown middleware action %q for match %q", yr.Action, yr.Match)
		}
		rules = append(rules, Rule{Match: yr.Match, Action: yr.Action, Dest: yr.Dest})
	}

	return &Middleware{rules: rules}, nil
}

func (m *Middleware) RuleCount() int {
	return len(m.rules)
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./middleware/...`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add middleware/
git commit -m "feat: add middleware package with YAML config loading"
```

---

### Task 2: Middleware — match and save logic

**Files:**
- Modify: `middleware/middleware.go`
- Modify: `middleware/middleware_test.go`

- [ ] **Step 1: Write failing test for Match**

Append to `middleware/middleware_test.go`:

```go
func TestMatchReturnsRuleForMatchingURL(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "middleware.yaml")
	os.WriteFile(path, []byte(`middleware:
  - match: "api.anthropic.com/api/oauth/usage"
    action: save_response
    dest: "/tmp/usage.json"
`), 0644)

	mw, _ := middleware.New(path)
	rule := mw.Match("api.anthropic.com", "/api/oauth/usage")
	if rule == nil {
		t.Fatal("expected a match")
	}
	if rule.Dest != "/tmp/usage.json" {
		t.Errorf("expected dest /tmp/usage.json, got %s", rule.Dest)
	}
}

func TestMatchReturnsNilForNoMatch(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "middleware.yaml")
	os.WriteFile(path, []byte(`middleware:
  - match: "api.anthropic.com/api/oauth/usage"
    action: save_response
    dest: "/tmp/usage.json"
`), 0644)

	mw, _ := middleware.New(path)
	rule := mw.Match("example.com", "/other")
	if rule != nil {
		t.Fatal("expected no match")
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./middleware/...`
Expected: `mw.Match` undefined

- [ ] **Step 3: Implement Match**

Add to `middleware/middleware.go`:

```go
// Match checks if host+path matches any middleware rule.
// Returns the first matching rule, or nil.
func (m *Middleware) Match(host, path string) *Rule {
	url := host + path
	for i := range m.rules {
		if m.rules[i].Match == url {
			return &m.rules[i]
		}
	}
	return nil
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./middleware/...`
Expected: PASS

- [ ] **Step 5: Write failing test for SaveResponse**

Append to `middleware/middleware_test.go`:

```go
func TestSaveResponseWritesBodyToFile(t *testing.T) {
	dest := filepath.Join(t.TempDir(), "out.json")
	rule := &middleware.Rule{
		Match:  "example.com/data",
		Action: "save_response",
		Dest:   dest,
	}

	body := []byte(`{"tokens": 42}`)
	err := rule.SaveResponse(body)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	got, err := os.ReadFile(dest)
	if err != nil {
		t.Fatalf("failed to read dest file: %v", err)
	}
	if string(got) != string(body) {
		t.Errorf("expected %q, got %q", body, got)
	}
}

func TestSaveResponseOverwritesExistingFile(t *testing.T) {
	dest := filepath.Join(t.TempDir(), "out.json")
	os.WriteFile(dest, []byte("old data"), 0644)

	rule := &middleware.Rule{
		Match:  "example.com/data",
		Action: "save_response",
		Dest:   dest,
	}

	body := []byte(`{"new": true}`)
	rule.SaveResponse(body)

	got, _ := os.ReadFile(dest)
	if string(got) != string(body) {
		t.Errorf("expected %q, got %q", body, got)
	}
}
```

- [ ] **Step 6: Run tests to verify they fail**

Run: `go test ./middleware/...`
Expected: `rule.SaveResponse` undefined

- [ ] **Step 7: Implement SaveResponse**

Add to `middleware/middleware.go`:

```go
// SaveResponse writes the response body to the rule's destination file,
// overwriting any existing content.
func (r *Rule) SaveResponse(body []byte) error {
	return os.WriteFile(r.Dest, body, 0644)
}
```

- [ ] **Step 8: Run tests to verify they pass**

Run: `go test ./middleware/...`
Expected: PASS

- [ ] **Step 9: Commit**

```bash
git add middleware/
git commit -m "feat: add Match and SaveResponse to middleware"
```

---

### Task 3: Wire middleware into proxy

**Files:**
- Modify: `proxy/proxy.go`
- Modify: `proxy/proxy_test.go`

- [ ] **Step 1: Write failing test for middleware in MITM path**

Append to `proxy/proxy_test.go`:

```go
func TestMiddlewareSavesResponseBody(t *testing.T) {
	responseBody := `{"usage": 100}`
	backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(responseBody))
	}))
	defer backend.Close()

	backendURL, _ := url.Parse(backend.URL)
	host := backendURL.Hostname()

	hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
	os.WriteFile(hostsFile, []byte(host+"\n"), 0644)

	destFile := filepath.Join(t.TempDir(), "usage.json")
	mwPath := filepath.Join(t.TempDir(), "middleware.yaml")
	os.WriteFile(mwPath, []byte(fmt.Sprintf(`middleware:
  - match: "%s/data"
    action: save_response
    dest: "%s"
`, host, destFile)), 0644)

	caDir := t.TempDir()
	caCert, caKey, err := nca.LoadOrCreate(caDir)
	if err != nil {
		t.Fatalf("CA setup: %v", err)
	}

	p := proxy.New(hostsFile,
		proxy.WithCA(caCert, caKey),
		proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}),
		proxy.WithMiddleware(mwPath),
	)
	srv := httptest.NewServer(p)
	defer srv.Close()

	caPool := x509.NewCertPool()
	caPool.AddCert(caCert)

	proxyURL, _ := url.Parse(srv.URL)
	client := &http.Client{
		Transport: &http.Transport{
			Proxy: http.ProxyURL(proxyURL),
			TLSClientConfig: &tls.Config{
				RootCAs: caPool,
			},
		},
	}

	resp, err := client.Get(backend.URL + "/data")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}

	got, err := os.ReadFile(destFile)
	if err != nil {
		t.Fatalf("dest file not written: %v", err)
	}
	if string(got) != responseBody {
		t.Errorf("expected %q, got %q", responseBody, got)
	}
}
```

Note: add `"fmt"` to the imports block in `proxy_test.go`.

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./proxy/...`
Expected: `proxy.WithMiddleware` undefined

- [ ] **Step 3: Add WithMiddleware option and wire into MITM loop**

In `proxy/proxy.go`, add the `middleware` import:

```go
import (
	// ... existing imports ...
	"github.com/xanderle/nono/middleware"
)
```

Add the field to the `Proxy` struct:

```go
type Proxy struct {
	hostsFile   string
	scanner     *scanner.Scanner
	middleware  *middleware.Middleware
	caCert      *x509.Certificate
	// ... rest unchanged ...
}
```

Add the option constructor:

```go
// WithMiddleware returns an Option that loads middleware from the given config path.
func WithMiddleware(path string) Option {
	return func(p *Proxy) {
		mw, err := middleware.New(path)
		if err != nil {
			log.Printf("WARNING: failed to load middleware from %q: %v", path, err)
			return
		}
		p.middleware = mw
	}
}
```

In `handleConnectMITM`, after `upstreamResp.Body.Close()` and before the `req.Close` check, add the middleware interception. Replace the response relay section:

```go
		// Replace this:
		//   upstreamResp.Write(tlsConn)
		//   upstreamResp.Body.Close()
		//
		// With this:
		if p.middleware != nil {
			if rule := p.middleware.Match(host, req.URL.Path); rule != nil {
				body, err := io.ReadAll(upstreamResp.Body)
				upstreamResp.Body.Close()
				if err != nil {
					log.Printf("ERROR: middleware failed to read response body from %s: %v", host, err)
				} else {
					if err := rule.SaveResponse(body); err != nil {
						log.Printf("ERROR: middleware failed to save response to %s: %v", rule.Dest, err)
					} else {
						log.Printf("MIDDLEWARE saved %s%s -> %s", host, req.URL.Path, rule.Dest)
					}
					upstreamResp.Body = io.NopCloser(bytes.NewReader(body))
				}
				upstreamResp.Write(tlsConn)
			} else {
				upstreamResp.Write(tlsConn)
				upstreamResp.Body.Close()
			}
		} else {
			upstreamResp.Write(tlsConn)
			upstreamResp.Body.Close()
		}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./proxy/...`
Expected: PASS

- [ ] **Step 5: Run all tests**

Run: `go test ./...`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add proxy/ middleware/
git commit -m "feat: wire middleware into proxy MITM loop"
```

---

### Task 4: Wire middleware into main and add default config

**Files:**
- Modify: `cmd/nono-proxy/main.go`

- [ ] **Step 1: Add middleware loading to main**

Add `middleware` to the import path. Load middleware config from `store/middleware.yaml` and pass it as an option:

```go
mwPath := filepath.Join(store, "middleware.yaml")

opts := []proxy.Option{
	proxy.WithRules(rulesPath),
	proxy.WithCA(caCert, caKey),
	proxy.WithMiddleware(mwPath),
}
```

No default config file writing — the middleware YAML is opt-in. If the file doesn't exist, `middleware.New` returns an empty middleware (no-op).

- [ ] **Step 2: Build and verify**

Run: `make build`
Expected: builds successfully

- [ ] **Step 3: Run all tests**

Run: `make test`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add cmd/nono-proxy/main.go
git commit -m "feat: load middleware config in nono-proxy main"
```
diff --git a/proxy/proxy.go b/proxy/proxy.go
index f336224..1a7c1ce 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -252,6 +252,7 @@ func (p *Proxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) {
				upstreamResp.Body.Close()
				if err != nil {
					log.Printf("ERROR: middleware failed to read response body from %s: %v", host, err)
					upstreamResp.Body = io.NopCloser(bytes.NewReader(nil))
				} else {
					if err := rule.SaveResponse(body); err != nil {
						log.Printf("ERROR: middleware failed to save response to %s: %v", rule.Dest, err)