| new file mode 100644 |
| index 000000000..7de62c891 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md |
| @@ -0,0 +1,747 @@ |
| +# Woodstar W0 — Relay Fan-out 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:** `cmd/relay` optionally fronts its five satellite services and |
| +reproduces prod Caddy's path fan-out and wildcard-host behavior, so one |
| +Host-routed CARLOS route can serve the whole Woodstar app. |
| + |
| +**Architecture:** a new `internal/relay/fanout.go` wraps the existing |
| +`Server.Handler()` with an outermost handler that (a) dispatches Caddy's |
| +path prefixes to per-service `httputil.ReverseProxy` instances and (b) |
| +when a public apex domain is configured, reproduces the |
| +`*.woodstar.app` host behavior (www→apex 301, `/.well-known/atproto-did` |
| +→ apbridge, other subdomain paths → 302 to the apex profile). All new |
| +behavior is opt-in via new flags; unset flags = byte-identical behavior. |
| + |
| +**Tech Stack:** Go (woodstar repo, pure Go, no CGO), `net/http/httputil`, |
| +`net/http/httptest` for tests. |
| + |
| +**Spec:** platform repo |
| +`docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md` |
| +§6 (branch `worktree-woodstar-carloku`). |
| + |
| +## Global Constraints |
| + |
| +- Work in the **woodstar repo** (`/home/paulca/github.com/woodstarapp/woodstar`) |
| + — but NEVER build in the main checkout (its CLAUDE.md; multiple |
| + sessions active). First action: `git worktree add`. |
| +- Unset flags = today's behavior **byte-identical**. Dev/devbox |
| + unaffected (Caddy keeps the fan-out there). |
| +- The fan-out route set mirrors `infra/devbox-init.sh`'s Caddy block |
| + (the truthful superset of `infra/prod-Caddyfile`): `/media*`→media, |
| + `/api/*`→indexer, `/stream/*`→notify, `/unfurl*`→unfurl, |
| + `/.well-known/webfinger` + `/.well-known/host-meta` + `/users/*` + |
| + `/inbox` + `/ap/*`→apbridge, everything else→relay. |
| +- Wildcard-host behavior mirrors `infra/prod-Caddyfile`'s |
| + `*.woodstar.app` block byte-for-byte: `www` → **permanent** (301) |
| + redirect to apex with URI preserved; `/.well-known/atproto-did` → |
| + apbridge; anything else → **temporary** (302) redirect to |
| + `https://<apex>/<leftmost-label>`. |
| +- Proxies preserve the inbound `Host` header (Caddy's default; the |
| + apbridge resolves users from it). |
| +- Gate: `make test` green; `gofmt -l` clean on touched files. |
| + |
| +--- |
| + |
| +### Task 1: Fan-out path dispatch |
| + |
| +**Files:** |
| +- Create: `internal/relay/fanout.go` |
| +- Test: `internal/relay/fanout_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `relay.FanoutConfig{Media, Indexer, Notify, Unfurl, |
| + APBridge, Domain string}` and |
| + `relay.Fanout(inner http.Handler, cfg FanoutConfig) (http.Handler, error)`. |
| + Task 3 adds host-aware behavior inside the same handler; Task 4 wires |
| + `cmd/relay` flags to it. |
| + |
| +- [ ] **Step 1: Create the worktree** |
| + |
| +```bash |
| +cd /home/paulca/github.com/woodstarapp/woodstar |
| +git worktree add .worktrees/w0-fanout -b w0-relay-fanout origin/main |
| +cd .worktrees/w0-fanout |
| +``` |
| + |
| +- [ ] **Step 2: Write the failing dispatch test** |
| + |
| +Create `internal/relay/fanout_test.go`: |
| + |
| +```go |
| +package relay |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// fanoutBackends starts one recording backend per satellite and returns |
| +// the config pointing at them plus the record of who got which request. |
| +type backendHit struct { |
| + name, method, path, host, body string |
| +} |
| + |
| +func fanoutBackends(t *testing.T) (FanoutConfig, *[]backendHit) { |
| + t.Helper() |
| + hits := &[]backendHit{} |
| + mk := func(name string) string { |
| + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _ := io.ReadAll(r.Body) |
| + *hits = append(*hits, backendHit{name, r.Method, r.URL.Path, r.Host, string(b)}) |
| + w.WriteHeader(299) // unmistakable status: proves the response came from the backend |
| + io.WriteString(w, name) |
| + })) |
| + t.Cleanup(s.Close) |
| + return s.URL |
| + } |
| + return FanoutConfig{ |
| + Media: mk("media"), |
| + Indexer: mk("indexer"), |
| + Notify: mk("notify"), |
| + Unfurl: mk("unfurl"), |
| + APBridge: mk("apbridge"), |
| + }, hits |
| +} |
| + |
| +func TestFanoutDispatch(t *testing.T) { |
| + cfg, hits := fanoutBackends(t) |
| + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + *hits = append(*hits, backendHit{"inner", r.Method, r.URL.Path, r.Host, ""}) |
| + w.WriteHeader(298) |
| + }) |
| + h, err := Fanout(inner, cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + front := httptest.NewServer(h) |
| + t.Cleanup(front.Close) |
| + |
| + cases := []struct { |
| + method, path, want string |
| + status int |
| + }{ |
| + {"GET", "/media/abc123", "media", 299}, |
| + {"POST", "/media", "media", 299}, |
| + {"GET", "/api/search?q=x", "indexer", 299}, |
| + {"GET", "/stream/notify", "notify", 299}, |
| + {"GET", "/unfurl?url=https%3A%2F%2Fexample.com", "unfurl", 299}, |
| + {"GET", "/.well-known/webfinger?resource=acct:paul", "apbridge", 299}, |
| + {"GET", "/.well-known/host-meta", "apbridge", 299}, |
| + {"GET", "/users/paul", "apbridge", 299}, |
| + {"POST", "/inbox", "apbridge", 299}, |
| + {"GET", "/ap/objects/1", "apbridge", 299}, |
| + // NOT proxied — the relay's own surface: |
| + {"GET", "/subscribe", "inner", 298}, |
| + {"GET", "/info", "inner", 298}, |
| + {"GET", "/events", "inner", 298}, |
| + {"GET", "/.well-known/woodstar.json", "inner", 298}, |
| + {"GET", "/.well-known/apple-app-site-association", "inner", 298}, |
| + {"GET", "/api", "inner", 298}, // Caddy /api/* does not match bare /api |
| + {"GET", "/users", "inner", 298}, // /users/* does not match bare /users |
| + {"GET", "/inboxes", "inner", 298}, // /inbox is an exact match |
| + } |
| + for _, c := range cases { |
| + *hits = (*hits)[:0] |
| + req, _ := http.NewRequest(c.method, front.URL+c.path, strings.NewReader("payload")) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("%s %s: %v", c.method, c.path, err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != c.status { |
| + t.Errorf("%s %s: status %d, want %d", c.method, c.path, resp.StatusCode, c.status) |
| + } |
| + if len(*hits) != 1 || (*hits)[0].name != c.want { |
| + t.Errorf("%s %s: hit %+v, want backend %q", c.method, c.path, *hits, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestFanoutPreservesHostMethodBody(t *testing.T) { |
| + cfg, hits := fanoutBackends(t) |
| + h, err := Fanout(http.NotFoundHandler(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + front := httptest.NewServer(h) |
| + t.Cleanup(front.Close) |
| + |
| + req, _ := http.NewRequest("POST", front.URL+"/media", strings.NewReader("blobbytes")) |
| + req.Host = "woodstar.app" // the Host the edge delivers |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if len(*hits) != 1 { |
| + t.Fatalf("hits: %+v", *hits) |
| + } |
| + got := (*hits)[0] |
| + if got.method != "POST" || got.body != "blobbytes" || got.host != "woodstar.app" { |
| + t.Errorf("backend saw %+v; want POST, body blobbytes, host woodstar.app", got) |
| + } |
| +} |
| + |
| +func TestFanoutPartialConfig(t *testing.T) { |
| + // Only media set: media paths proxy, all other satellite paths fall |
| + // through to inner (never a 502 to a nil proxy). |
| + media := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(299) |
| + })) |
| + t.Cleanup(media.Close) |
| + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(298) }) |
| + h, err := Fanout(inner, FanoutConfig{Media: media.URL}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + front := httptest.NewServer(h) |
| + t.Cleanup(front.Close) |
| + for path, want := range map[string]int{"/media/x": 299, "/api/x": 298, "/inbox": 298} { |
| + resp, err := http.Get(front.URL + path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != want { |
| + t.Errorf("%s: status %d, want %d", path, resp.StatusCode, want) |
| + } |
| + } |
| +} |
| + |
| +func TestFanoutRejectsBadUpstream(t *testing.T) { |
| + if _, err := Fanout(http.NotFoundHandler(), FanoutConfig{Media: "://not-a-url"}); err == nil { |
| + t.Fatal("want error for unparseable upstream URL") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Run the test to verify it fails** |
| + |
| +```bash |
| +cd /home/paulca/github.com/woodstarapp/woodstar/.worktrees/w0-fanout |
| +go test ./internal/relay/ -run TestFanout -count=1 |
| +``` |
| + |
| +Expected: FAIL — `undefined: FanoutConfig`, `undefined: Fanout`. |
| + |
| +- [ ] **Step 4: Implement `internal/relay/fanout.go`** |
| + |
| +```go |
| +package relay |
| + |
| +// Fan-out: the relay optionally fronts its satellite services, |
| +// reproducing the Caddy path routing that prod and the devbox do today |
| +// (infra/devbox-init.sh's block is the authoritative route set). On |
| +// CARLOS, the edge routes by Host only, so the relay carries the paths. |
| +// Everything here is opt-in: a zero FanoutConfig returns inner untouched. |
| +// |
| +// Spec: platform repo docs/superpowers/specs/ |
| +// 2026-08-12-woodstar-carloku-migration-design.md §6. |
| + |
| +import ( |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "net/http/httputil" |
| + "net/url" |
| + "strings" |
| +) |
| + |
| +// FanoutConfig names the satellite base URLs (empty = that satellite is |
| +// not fronted; its paths fall through to the relay) and the public apex |
| +// domain (empty = no host-aware handling; Task 3). |
| +type FanoutConfig struct { |
| + Media string |
| + Indexer string |
| + Notify string |
| + Unfurl string |
| + APBridge string |
| + Domain string |
| +} |
| + |
| +// fanout dispatches by (host, path); zero-value fields disable their leg. |
| +type fanout struct { |
| + inner http.Handler |
| + media http.Handler |
| + indexer http.Handler |
| + notify http.Handler |
| + unfurl http.Handler |
| + apbridge http.Handler |
| + domain string // lowercase apex, "" = host handling off |
| +} |
| + |
| +// Fanout wraps inner with the satellite path dispatch. A zero cfg |
| +// returns inner unchanged (the byte-identical default). |
| +func Fanout(inner http.Handler, cfg FanoutConfig) (http.Handler, error) { |
| + if cfg == (FanoutConfig{}) { |
| + return inner, nil |
| + } |
| + f := &fanout{inner: inner, domain: strings.ToLower(strings.TrimSpace(cfg.Domain))} |
| + for _, p := range []struct { |
| + name, target string |
| + dst *http.Handler |
| + }{ |
| + {"media", cfg.Media, &f.media}, |
| + {"indexer", cfg.Indexer, &f.indexer}, |
| + {"notify", cfg.Notify, &f.notify}, |
| + {"unfurl", cfg.Unfurl, &f.unfurl}, |
| + {"apbridge", cfg.APBridge, &f.apbridge}, |
| + } { |
| + if p.target == "" { |
| + continue |
| + } |
| + u, err := url.Parse(p.target) |
| + if err != nil || u.Scheme == "" || u.Host == "" { |
| + return nil, fmt.Errorf("fanout: %s upstream %q: not an absolute URL", p.name, p.target) |
| + } |
| + proxy := httputil.NewSingleHostReverseProxy(u) |
| + // Immediate flush so /stream/* SSE events cross the hop as they |
| + // are written; harmless for ordinary responses. |
| + proxy.FlushInterval = -1 |
| + *p.dst = proxy |
| + } |
| + return f, nil |
| +} |
| + |
| +func (f *fanout) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + // Host-aware wildcard handling is added in Task 3; path dispatch |
| + // applies to every host that reaches the relay. |
| + f.dispatch(w, r) |
| +} |
| + |
| +// dispatch mirrors the Caddy handle blocks: longest-specificity path |
| +// checks, satellite legs falling through to the relay when unset. |
| +func (f *fanout) dispatch(w http.ResponseWriter, r *http.Request) { |
| + p := r.URL.Path |
| + switch { |
| + case f.media != nil && (p == "/media" || strings.HasPrefix(p, "/media/")): |
| + f.media.ServeHTTP(w, r) |
| + case f.indexer != nil && strings.HasPrefix(p, "/api/"): |
| + f.indexer.ServeHTTP(w, r) |
| + case f.notify != nil && strings.HasPrefix(p, "/stream/"): |
| + f.notify.ServeHTTP(w, r) |
| + case f.unfurl != nil && (p == "/unfurl" || strings.HasPrefix(p, "/unfurl/")): |
| + f.unfurl.ServeHTTP(w, r) |
| + case f.apbridge != nil && (p == "/.well-known/webfinger" || |
| + p == "/.well-known/host-meta" || |
| + strings.HasPrefix(p, "/users/") || |
| + p == "/inbox" || |
| + strings.HasPrefix(p, "/ap/")): |
| + f.apbridge.ServeHTTP(w, r) |
| + default: |
| + f.inner.ServeHTTP(w, r) |
| + } |
| +} |
| + |
| +// requestHost returns r.Host lowercased with any port stripped. |
| +func requestHost(r *http.Request) string { |
| + h := strings.ToLower(r.Host) |
| + if host, _, err := net.SplitHostPort(h); err == nil { |
| + return host |
| + } |
| + return h |
| +} |
| +``` |
| + |
| +(`requestHost` is used by Task 3; declaring it here keeps Task 3's diff |
| +handler-only. If `go vet` flags it unused before Task 3 lands in the |
| +same branch, that is expected within this task only — it is used two |
| +tasks later; alternatively fold this function into Task 3's diff.) |
| +Note: `/media*` and `/unfurl*` in Caddy are literal-prefix wildcards; |
| +the deployed client only ever requests `/media`, `/media/<hash>`, and |
| +`/unfurl?url=…`, so exact + `/`-prefix matching above is the honest |
| +equivalent (a hypothetical `/mediafoo` falling to the relay 404s in |
| +both worlds — the relay mux has no such route). |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +```bash |
| +go test ./internal/relay/ -run TestFanout -count=1 |
| +``` |
| + |
| +Expected: PASS (all four tests). |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/relay/fanout.go internal/relay/fanout_test.go |
| +git commit -m "relay: opt-in satellite fan-out — Caddy's path routing, relay-side" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: SSE streams cross the hop unbuffered |
| + |
| +**Files:** |
| +- Modify: `internal/relay/fanout_test.go` (add one test) |
| + |
| +**Interfaces:** |
| +- Consumes: `Fanout` + `FanoutConfig` from Task 1 (the |
| + `FlushInterval = -1` behavior this test locks in). |
| +- Produces: nothing new — a regression lock. |
| + |
| +- [ ] **Step 1: Write the failing-or-locking SSE test** |
| + |
| +Append to `internal/relay/fanout_test.go`: |
| + |
| +```go |
| +func TestFanoutStreamsSSEUnbuffered(t *testing.T) { |
| + // The notify hop must deliver each SSE event as it is written, not |
| + // on backend close — the whole point of proxy.FlushInterval = -1. |
| + firstEventOut := make(chan struct{}) |
| + release := make(chan struct{}) |
| + notify := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + io.WriteString(w, "data: one\n\n") |
| + w.(http.Flusher).Flush() |
| + close(firstEventOut) |
| + <-release // hold the stream open: reading "one" must not need EOF |
| + })) |
| + t.Cleanup(func() { close(release); notify.Close() }) |
| + |
| + h, err := Fanout(http.NotFoundHandler(), FanoutConfig{Notify: notify.URL}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + front := httptest.NewServer(h) |
| + t.Cleanup(front.Close) |
| + |
| + resp, err := http.Get(front.URL + "/stream/notify") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + <-firstEventOut |
| + buf := make([]byte, 64) |
| + type readResult struct { |
| + n int |
| + err error |
| + } |
| + got := make(chan readResult, 1) |
| + go func() { |
| + n, err := resp.Body.Read(buf) |
| + got <- readResult{n, err} |
| + }() |
| + select { |
| + case r := <-got: |
| + if r.err != nil || !strings.Contains(string(buf[:r.n]), "data: one") { |
| + t.Fatalf("read %q err=%v; want the first event", buf[:r.n], r.err) |
| + } |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("first SSE event did not arrive while the stream was open — proxy is buffering") |
| + } |
| +} |
| +``` |
| + |
| +Add `"time"` to the test file's imports. |
| + |
| +- [ ] **Step 2: Run it** |
| + |
| +```bash |
| +go test ./internal/relay/ -run TestFanoutStreamsSSE -count=1 |
| +``` |
| + |
| +Expected: PASS immediately (Task 1 set `FlushInterval = -1`). If it |
| +FAILS on the timeout, the flush setting was dropped — restore |
| +`proxy.FlushInterval = -1` in `Fanout` and re-run. |
| + |
| +- [ ] **Step 3: Commit** |
| + |
| +```bash |
| +git add internal/relay/fanout_test.go |
| +git commit -m "relay: lock SSE pass-through flushing with a held-open stream test" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Host-aware wildcard behavior |
| + |
| +**Files:** |
| +- Modify: `internal/relay/fanout.go` (ServeHTTP + one new method) |
| +- Test: `internal/relay/fanout_test.go` (add tests) |
| + |
| +**Interfaces:** |
| +- Consumes: `fanout` struct, `dispatch`, `requestHost` from Task 1. |
| +- Produces: the complete `Fanout` behavior Task 4 wires flags to. |
| + |
| +- [ ] **Step 1: Write the failing host tests** |
| + |
| +Append to `internal/relay/fanout_test.go`: |
| + |
| +```go |
| +func TestFanoutWildcardHosts(t *testing.T) { |
| + cfg, hits := fanoutBackends(t) |
| + cfg.Domain = "woodstar.app" |
| + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + *hits = append(*hits, backendHit{"inner", r.Method, r.URL.Path, r.Host, ""}) |
| + w.WriteHeader(298) |
| + }) |
| + h, err := Fanout(inner, cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + front := httptest.NewServer(h) |
| + t.Cleanup(front.Close) |
| + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { |
| + return http.ErrUseLastResponse // inspect redirects, don't follow |
| + }} |
| + |
| + cases := []struct { |
| + host, path string |
| + wantStatus int |
| + wantLocation string // "" = no redirect expected |
| + wantBackend string // "" = redirect, no backend hit |
| + }{ |
| + // apex: ordinary dispatch, no host handling |
| + {"woodstar.app", "/info", 298, "", "inner"}, |
| + {"woodstar.app", "/media/x", 299, "", "media"}, |
| + // www: permanent redirect to apex, URI preserved |
| + {"www.woodstar.app", "/some/path?q=1", http.StatusMovedPermanently, "https://woodstar.app/some/path?q=1", ""}, |
| + // one-label subdomain: the Bluesky handle file goes to the bridge |
| + {"paul.woodstar.app", "/.well-known/atproto-did", 299, "", "apbridge"}, |
| + // one-label subdomain, anything else: temporary redirect to the profile |
| + {"paul.woodstar.app", "/", http.StatusFound, "https://woodstar.app/paul", ""}, |
| + {"paul.woodstar.app", "/anything/deep?x=1", http.StatusFound, "https://woodstar.app/paul", ""}, |
| + // port on the Host header is stripped before matching |
| + {"paul.woodstar.app:443", "/x", http.StatusFound, "https://woodstar.app/paul", ""}, |
| + // foreign hosts (rehearsal sslip.io routes) get the full app |
| + {"woodstar-rehearsal.99.81.104.219.sslip.io", "/media/x", 299, "", "media"}, |
| + {"woodstar-rehearsal.99.81.104.219.sslip.io", "/info", 298, "", "inner"}, |
| + // deeper-than-one-label: not the wildcard's business (no TLS there anyway) |
| + {"a.b.woodstar.app", "/info", 298, "", "inner"}, |
| + } |
| + for _, c := range cases { |
| + *hits = (*hits)[:0] |
| + req, _ := http.NewRequest("GET", front.URL+c.path, nil) |
| + req.Host = c.host |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + t.Fatalf("%s %s: %v", c.host, c.path, err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != c.wantStatus { |
| + t.Errorf("%s %s: status %d, want %d", c.host, c.path, resp.StatusCode, c.wantStatus) |
| + } |
| + if loc := resp.Header.Get("Location"); loc != c.wantLocation { |
| + t.Errorf("%s %s: Location %q, want %q", c.host, c.path, loc, c.wantLocation) |
| + } |
| + if c.wantBackend == "" { |
| + if len(*hits) != 0 { |
| + t.Errorf("%s %s: unexpected backend hit %+v", c.host, c.path, *hits) |
| + } |
| + } else if len(*hits) != 1 || (*hits)[0].name != c.wantBackend { |
| + t.Errorf("%s %s: hits %+v, want %q", c.host, c.path, *hits, c.wantBackend) |
| + } |
| + } |
| +} |
| + |
| +func TestFanoutAtprotoDIDPreservesHost(t *testing.T) { |
| + // The bridge resolves WHICH user's DID from the Host header — the |
| + // proxy must hand it through untouched. |
| + cfg, hits := fanoutBackends(t) |
| + cfg.Domain = "woodstar.app" |
| + h, err := Fanout(http.NotFoundHandler(), cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + front := httptest.NewServer(h) |
| + t.Cleanup(front.Close) |
| + req, _ := http.NewRequest("GET", front.URL+"/.well-known/atproto-did", nil) |
| + req.Host = "paul.woodstar.app" |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if len(*hits) != 1 || (*hits)[0].host != "paul.woodstar.app" { |
| + t.Fatalf("bridge saw %+v; want Host paul.woodstar.app", *hits) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify the new tests fail** |
| + |
| +```bash |
| +go test ./internal/relay/ -run TestFanoutWildcard -count=1 |
| +``` |
| + |
| +Expected: FAIL — www/subdomain cases dispatch instead of redirecting |
| +(status 298/299 where 301/302 is wanted). |
| + |
| +- [ ] **Step 3: Implement the host handling** |
| + |
| +In `internal/relay/fanout.go`, replace `ServeHTTP` with: |
| + |
| +```go |
| +func (f *fanout) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + if f.domain != "" { |
| + host := requestHost(r) |
| + if host != f.domain { |
| + if host == "www."+f.domain { |
| + // Caddy: redir https://woodstar.app{uri} permanent |
| + http.Redirect(w, r, "https://"+f.domain+r.URL.RequestURI(), http.StatusMovedPermanently) |
| + return |
| + } |
| + if label, ok := strings.CutSuffix(host, "."+f.domain); ok && label != "" && !strings.Contains(label, ".") { |
| + // One label under the apex: paul.woodstar.app. |
| + if r.URL.Path == "/.well-known/atproto-did" && f.apbridge != nil { |
| + // The one well-known file that makes the subdomain a |
| + // Bluesky handle — answered by the bridge, which reads |
| + // the user from the Host header. |
| + f.apbridge.ServeHTTP(w, r) |
| + return |
| + } |
| + // Caddy: redir https://woodstar.app/{labels.2} temporary |
| + http.Redirect(w, r, "https://"+f.domain+"/"+label, http.StatusFound) |
| + return |
| + } |
| + // Foreign or deeper hosts (rehearsal routes, a.b.woodstar.app): |
| + // serve the app; the wildcard owns exactly one label. |
| + } |
| + } |
| + f.dispatch(w, r) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the full relay package** |
| + |
| +```bash |
| +go test ./internal/relay/ -count=1 |
| +``` |
| + |
| +Expected: PASS (new tests and every pre-existing test). |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/relay/fanout.go internal/relay/fanout_test.go |
| +git commit -m "relay: Caddy-parity wildcard host handling — www, atproto-did, profile redirect" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: `cmd/relay` flag wiring |
| + |
| +**Files:** |
| +- Modify: `cmd/relay/main.go` (flags + handler wrap; today |
| + `log.Fatal(http.ListenAndServe(*addr, srv.Handler()))` is main.go:63) |
| + |
| +**Interfaces:** |
| +- Consumes: `relay.Fanout`, `relay.FanoutConfig` (Tasks 1+3). |
| +- Produces: the deployable flag surface the migration's units use: |
| + `-media-upstream`, `-indexer-upstream`, `-notify-upstream`, |
| + `-unfurl-upstream`, `-apbridge-upstream`, `-domain`. |
| + |
| +- [ ] **Step 1: Add the flags and the wrap** |
| + |
| +In `cmd/relay/main.go`, after the existing flag declarations (below |
| +`importMax`, before `flag.Parse()`): |
| + |
| +```go |
| + mediaUp := flag.String("media-upstream", "", "front the media service at this base URL (fan-out: /media*)") |
| + indexerUp := flag.String("indexer-upstream", "", "front the indexer at this base URL (fan-out: /api/*)") |
| + notifyUp := flag.String("notify-upstream", "", "front the notify service at this base URL (fan-out: /stream/*)") |
| + unfurlUp := flag.String("unfurl-upstream", "", "front the unfurl service at this base URL (fan-out: /unfurl*)") |
| + apbridgeUp := flag.String("apbridge-upstream", "", "front the AP bridge at this base URL (fan-out: webfinger, host-meta, /users/*, /inbox, /ap/*)") |
| + domain := flag.String("domain", "", "public apex domain; with -apbridge-upstream set, serves Caddy-parity subdomain behavior (www redirect, atproto-did via bridge, profile redirects)") |
| +``` |
| + |
| +And replace the final serve line (main.go:63): |
| + |
| +```go |
| + handler, err := relay.Fanout(srv.Handler(), relay.FanoutConfig{ |
| + Media: *mediaUp, |
| + Indexer: *indexerUp, |
| + Notify: *notifyUp, |
| + Unfurl: *unfurlUp, |
| + APBridge: *apbridgeUp, |
| + Domain: *domain, |
| + }) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if *domain != "" && *apbridgeUp == "" { |
| + log.Fatal("-domain requires -apbridge-upstream: subdomains must answer /.well-known/atproto-did (the Bluesky handle contract)") |
| + } |
| + log.Printf("relay %q listening on %s (db %s) — client at http://localhost%s/", *name, *addr, *db, *addr) |
| + log.Fatal(http.ListenAndServe(*addr, handler)) |
| +``` |
| + |
| +(The pre-existing `log.Printf` line stays; only `srv.Handler()` inside |
| +`ListenAndServe` changes to `handler`.) |
| + |
| +- [ ] **Step 2: Build and run the whole gate** |
| + |
| +```bash |
| +go build ./... && go vet ./... && make test |
| +gofmt -l cmd/relay internal/relay |
| +``` |
| + |
| +Expected: build/vet/test clean; gofmt prints nothing. |
| + |
| +- [ ] **Step 3: Manual smoke (optional but cheap)** |
| + |
| +```bash |
| +go run ./cmd/relay -addr 127.0.0.1:7071 -db /tmp/w0-smoke.db \ |
| + -apbridge-upstream http://127.0.0.1:9999 -domain example.test & |
| +curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \ |
| + -H 'Host: paul.example.test' http://127.0.0.1:7071/x |
| +kill %1 |
| +``` |
| + |
| +Expected: `302 https://example.test/paul`. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add cmd/relay/main.go |
| +git commit -m "relay: -*-upstream and -domain flags wire the fan-out" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: PR |
| + |
| +- [ ] **Step 1: Push and open the PR** |
| + |
| +```bash |
| +git push -u origin w0-relay-fanout |
| +gh pr create --title "relay: opt-in satellite fan-out + Caddy-parity wildcard hosts (CARLOS W0)" --body "$(cat <<'EOF' |
| +On CARLOS the edge routes by Host only, so the relay learns to front its |
| +satellites: new optional flags -media/-indexer/-notify/-unfurl/-apbridge-upstream |
| +reproduce the Caddy path fan-out (devbox-init.sh's route set), and -domain |
| +reproduces the *.woodstar.app block byte-for-byte (www→apex 301, |
| +/.well-known/atproto-did→bridge with Host preserved, everything else→302 |
| +to the apex profile). All flags unset = byte-identical behavior; dev and |
| +the devbox are untouched (Caddy keeps the fan-out there). |
| + |
| +Spec: platform repo docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md §6. |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
| +EOF |
| +)" |
| +``` |
| + |
| +- [ ] **Step 2: After merge (recorded here, executed at migration time):** |
| +rebuild all six binaries at the merged sha |
| +(`GOOS=linux GOARCH=arm64 CGO_ENABLED=0 -trimpath`) and |
| +`carlos ship -app woodstar-<svc> -kind binary -version <sha> -target linux-arm64 <bin>` |
| +for relay, media, indexer, notify, unfurl, apbridge. The old `dfe406a` |
| +shipment predates unfurl/apbridge and is superseded. |
| + |
| +## Self-review notes |
| + |
| +- Spec §6 coverage: path set ✓ (all nine legs + default), wildcard host |
| + behavior ✓ (www 301 URI-preserved, atproto-did→bridge Host-preserved, |
| + 302 profile redirect, apex untouched, foreign hosts serve the app), |
| + SSE flush ✓ (held-open stream test), WS untouched ✓ (/subscribe |
| + dispatches inner; upgrade path unchanged), unset-flags-byte-identical |
| + ✓ (zero cfg returns inner; `TestFanoutPartialConfig` covers partial). |
| +- The `-domain`-requires-`-apbridge-upstream` refusal is deliberate: |
| + a subdomain that redirects profiles but 404s atproto-did would |
| + silently break Bluesky handles — fail loud at boot instead. |