carlos / platform Public

plans: W0 relay fan-out + delegated wildcard certs; spec pins settled wildcard/catchall claim shape

Plan A (woodstar repo): six-upstream fan-out + Caddy-parity wildcard
host behavior, full TDD. Plan B (internal/edge): delegation-target
challenge publication, inert until Plan C wires the claim-derived
source. Claim shape settled with #151 (their 086f150): wildcard +
catchall bools, same-change declaration rule, delegation_status GET
field name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cfad842f9ecfcf2730dbe154f05d7b47cd0ecfea parent 9d594fe
3 files changed, +1202 −11
  • docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md +427 −0
  • docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md +747 −0
  • docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md +28 −11
diff --git a/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md b/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md
new file mode 100644
index 000000000..55f6fe6c3
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md
@@ -0,0 +1,427 @@
+# Delegated-Challenge Wildcard Certs 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:** the edge can obtain `*.<customer-domain>` certs by publishing
+ACME DNS-01 challenge TXTs **in the platform's own zone**, at a
+delegation target the customer has CNAMEd to — no customer DNS
+credentials, no knowledge of their provider.
+
+**Architecture:** `wildcardManager` gains a delegated-parent source (a
+closure, like its existing `hosts` closure) and a delegation zone; the
+only issuance-path change is *where* the challenge TXT is published
+(`challengeFQDN()` mapping). The `dnsProvider` seam, obtain flow,
+backoff, cache, and fall-back-to-per-host behavior are untouched.
+The claim-derived parent source (`wildcard: true` on
+`internal/domains.Claim`) is **Plan C** (gated on #151 landing); this
+plan ships the mechanics inert-until-sourced.
+
+**Tech Stack:** Go, `internal/edge` (platform repo), existing
+`wildcard.go`/`dns.go` seams, `golang.org/x/crypto/acme`.
+
+**Spec:** `docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md`
+§4 (this branch).
+
+## Global Constraints
+
+- Platform repo gate before any commit lands: `go build ./...`,
+ `go vet ./...`, `go test ./... -count=1` all clean, every package;
+ `gofmt -l` clean on touched files; `GOOS=darwin go build ./...`
+ compiles (CI runs it).
+- Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`;
+ disable the sandbox if the toolchain reports "read-only file system".
+- Every change lands through a PR — no direct commits to `main`.
+- Delegation-target convention (fixed here, per spec §10):
+ `_acme-challenge.<parent>.<delegation-zone>`, with the deployment's
+ delegation zone in env `CARLOS_ACME_DELEGATION_ZONE` (flagship value:
+ `acme.oncarlos.com` — a zone the existing DNSimple token already
+ writes; no new credentials).
+- Every failure degrades to per-host issuance — never a dead edge
+ (wildcard.go's standing contract; nothing here may violate it).
+- Work in a fresh worktree off `main`:
+ `git worktree add .claude/worktrees/delegated-wildcards -b edge-delegated-wildcards main`
+ (run from the repo root `/home/paulca/github.com/carlosframework/platform`).
+ All paths below are relative to that worktree.
+
+---
+
+### Task 1: Delegated parents join the parent set; challenges publish at the delegation target
+
+**Files:**
+- Modify: `internal/edge/wildcard.go` (struct fields, constructor,
+ `parentsFor` call site in `refresh()`, `solveDNS01`, new
+ `challengeFQDN`/`isDelegated` methods)
+- Test: `internal/edge/wildcard_test.go`
+
+**Interfaces:**
+- Consumes: existing `wildcardManager`, `parentsFor`, `solveDNS01`,
+ `fakeDNS` test double (wildcard_test.go:231).
+- Produces: `newWildcardManager(static []string, appsDomain string,
+ hosts func() []string, delegated func() []string, delegationZone
+ string, cache autocert.Cache, dns dnsProvider) *wildcardManager` —
+ two NEW trailing params before cache: `delegated` (nil = none) and
+ `delegationZone`. Also `(*wildcardManager).challengeFQDN(parent
+ string) string`, which Task 2's env plumbing and Plan C's derivation
+ rely on.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/edge/wildcard_test.go`:
+
+```go
+func TestDelegatedParentsJoinTheSet(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string {
+ return []string{"app.bab.oncarlos.com"}
+ }, func() []string {
+ return []string{"woodstar.app"}
+ }, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ want := []string{"bab.oncarlos.com", "woodstar.app"}
+ if !reflect.DeepEqual(parents, want) {
+ t.Fatalf("parents = %v, want %v", parents, want)
+ }
+}
+
+func TestChallengeFQDNDelegation(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ func() []string { return []string{"woodstar.app"} },
+ "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ // A delegated parent's TXT publishes into OUR zone, at the target
+ // the customer's CNAME points to.
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want {
+ t.Errorf("delegated: %q, want %q", got, want)
+ }
+ // A platform-zone parent publishes directly, exactly as before.
+ if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want {
+ t.Errorf("own-zone: %q, want %q", got, want)
+ }
+}
+
+func TestChallengeFQDNWithoutZoneNeverDelegates(t *testing.T) {
+ // No delegation zone configured: even a listed delegated parent
+ // publishes directly (misconfiguration degrades to the historical
+ // behavior, which per-host issuance then covers — never a dead edge).
+ w := newWildcardManager(nil, "", func() []string { return nil },
+ func() []string { return []string{"woodstar.app"} },
+ "", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app"; got != want {
+ t.Errorf("%q, want %q", got, want)
+ }
+}
+
+func TestNilDelegatedSourceIsFine(t *testing.T) {
+ // Every existing construction site passes nil — the zero-source
+ // manager must behave exactly as before this feature existed.
+ w := newWildcardManager([]string{"go.tito.io"}, "", func() []string { return nil },
+ nil, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ if !reflect.DeepEqual(parents, []string{"go.tito.io"}) {
+ t.Fatalf("parents = %v, want [go.tito.io]", parents)
+ }
+ if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want {
+ t.Errorf("%q, want %q", got, want)
+ }
+}
+```
+
+Add `"reflect"` to the test file's imports if not already present.
+
+- [ ] **Step 2: Run to verify they fail**
+
+```bash
+cd /home/paulca/github.com/carlosframework/platform/.claude/worktrees/delegated-wildcards
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -run 'TestDelegated|TestChallengeFQDN|TestNilDelegated' -count=1
+```
+
+Expected: FAIL — `newWildcardManager` has the old arity;
+`challengeFQDN` undefined. (Compilation failure across
+wildcard_test.go's existing call sites is part of the expected output.)
+
+- [ ] **Step 3: Implement**
+
+In `internal/edge/wildcard.go`:
+
+1. Add fields to `wildcardManager` (after `hosts`):
+
+```go
+ // delegated lists customer domains approved for delegated-challenge
+ // wildcard issuance (spec §4: claim-derived; nil = none). Their
+ // challenge TXTs publish into delegationZone — our own zone — at
+ // _acme-challenge.<parent>.<delegationZone>, which the customer has
+ // CNAMEd _acme-challenge.<parent> to. The platform never touches,
+ // or knows, the customer's DNS provider.
+ delegated func() []string
+ delegationZone string
+
+ // delegatedSet is refresh()'s snapshot of delegated(), read by
+ // challengeFQDN under mu (issuance runs on run()'s goroutine).
+ delegatedSet map[string]bool
+```
+
+2. Extend the constructor (two new params before `cache`):
+
+```go
+func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, delegated func() []string, delegationZone string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
+ return &wildcardManager{
+ cache: cache,
+ dns: dns,
+ directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory),
+ staticDomains: staticDomains,
+ appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)),
+ hosts: hosts,
+ delegated: delegated,
+ delegationZone: strings.ToLower(strings.TrimSpace(delegationZone)),
+ certs: map[string]*tls.Certificate{},
+ nextAttempt: map[string]time.Time{},
+ delegatedSet: map[string]bool{},
+ wake: make(chan struct{}, 1),
+ }
+}
+```
+
+3. In `refresh()`, snapshot the delegated set and include it in the
+parent union. Replace the first two lines of `refresh()`:
+
+```go
+func (w *wildcardManager) refresh() {
+ var delegated []string
+ if w.delegated != nil {
+ for _, d := range w.delegated() {
+ if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
+ delegated = append(delegated, d)
+ }
+ }
+ }
+ parents := parentsFor(append(append([]string(nil), w.staticDomains...), delegated...), w.appsDomain, w.hosts())
+ now := time.Now()
+ w.mu.Lock()
+ w.parents = parents
+ set := make(map[string]bool, len(delegated))
+ for _, d := range delegated {
+ set[d] = true
+ }
+ w.delegatedSet = set
+```
+
+(the rest of `refresh()` — the `need` loop, unlock, wake — is
+unchanged; `parentsFor` already dedupes and sorts, so a delegated
+domain that is also a static domain appears once).
+
+4. Add the mapping methods (near `wildcardPEMKey`):
+
+```go
+// challengeFQDN returns where parent's DNS-01 TXT is published: the
+// delegation target inside our own zone for delegated parents, or
+// directly at _acme-challenge.<parent> otherwise. With no delegation
+// zone configured the direct form is used for everyone — the
+// misconfigured state degrades to the historical behavior and per-host
+// issuance keeps covering (obtain will fail on the CA's lookup, log,
+// and back off — never a dead edge).
+func (w *wildcardManager) challengeFQDN(parent string) string {
+ if w.delegationZone != "" && w.isDelegated(parent) {
+ return "_acme-challenge." + parent + "." + w.delegationZone
+ }
+ return "_acme-challenge." + parent
+}
+
+func (w *wildcardManager) isDelegated(parent string) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.delegatedSet[parent]
+}
+```
+
+5. In `solveDNS01`, replace the one line
+`challengeFQDN := "_acme-challenge." + parent` with:
+
+```go
+ challengeFQDN := w.challengeFQDN(parent)
+```
+
+(`present`, cleanup, and `waitForTXT(ctx, challengeFQDN, …)` then all
+operate on the delegation target for delegated parents — the TXT we
+wait for is the one we wrote, in our own zone, which also sidesteps
+negative-caching of the customer-side name. The CA independently
+follows the customer's CNAME.)
+
+6. Update `newWildcardFromEnv`'s construction call (Task 2 refines it;
+for this task pass the inert values so the package compiles):
+
+```go
+ return newWildcardManager(static, appsDomain, e.currentHosts, nil, os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), cache, dns)
+```
+
+7. Mechanically update every existing `newWildcardManager(` call in
+`internal/edge/wildcard_test.go` (six sites listed at lines 251, 256,
+277, 288, 324, and inside `TestOnReloadHookFires`; grep for the exact
+set) to pass `nil, ""` for the two new params, e.g.:
+
+```go
+ w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{})
+```
+
+- [ ] **Step 4: Run the full edge package**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -count=1
+```
+
+Expected: PASS — the four new tests and every pre-existing wildcard
+test (their behavior is pinned by `TestNilDelegatedSourceIsFine`'s
+contract).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: delegated-challenge wildcard parents — publish DNS-01 TXTs at a delegation target in our own zone"
+```
+
+---
+
+### Task 2: Env plumbing + provider-miss logging
+
+**Files:**
+- Modify: `internal/edge/wildcard.go` (`newWildcardFromEnv`)
+- Test: `internal/edge/wildcard_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's constructor.
+- Produces: `CARLOS_ACME_DELEGATION_ZONE` as the deployment config
+ surface (flagship: `acme.oncarlos.com`). Plan C replaces the `nil`
+ delegated source with the claim-derived one; nothing else changes.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/edge/wildcard_test.go` (match the env-isolation
+style of the existing `TestNewWildcardFromEnv` at line 358 — it uses
+`t.Setenv`):
+
+```go
+func TestNewWildcardFromEnvDelegationZone(t *testing.T) {
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
+ t.Setenv("CARLOS_ACME_DELEGATION_ZONE", " Acme.OnCarlos.Com ")
+ e := &Edge{}
+ w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
+ if w == nil {
+ t.Fatal("manager should be on")
+ }
+ if w.delegationZone != "acme.oncarlos.com" {
+ t.Errorf("delegationZone = %q, want lowercased+trimmed acme.oncarlos.com", w.delegationZone)
+ }
+}
+```
+
+(If `Edge{}` needs more construction in the existing
+`TestNewWildcardFromEnv`, mirror exactly what that test does — it is
+the pattern-setter for this seam.)
+
+- [ ] **Step 2: Run to verify it fails**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -run TestNewWildcardFromEnvDelegationZone -count=1
+```
+
+Expected: FAIL — `w.delegationZone` is `" Acme.OnCarlos.Com "` (raw
+env, not normalized) — Task 1's step 6 passed the env through without
+normalization; the constructor lowercases/trims, so if this PASSES
+already, note it and move on (the constructor normalizes). Either
+result is acceptable evidence; the test stays as the lock.
+
+- [ ] **Step 3: Confirm `newWildcardFromEnv` final form**
+
+```go
+func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
+ static := splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS"))
+ appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
+ if len(static) == 0 && appsDomain == "" {
+ return nil
+ }
+ dns, reason := dnsProviderFromEnv()
+ if dns == nil {
+ log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason)
+ return nil
+ }
+ // Delegated parents (spec §4): the SOURCE is claim-derived and lands
+ // with the claim-derivation pass (Plan C); until then the zone is
+ // configured but the set is empty — inert by construction.
+ return newWildcardManager(static, appsDomain, e.currentHosts, nil, os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), cache, dns)
+}
+```
+
+- [ ] **Step 4: Run the whole gate**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin go build ./... && \
+gofmt -l internal/edge
+```
+
+Expected: all clean; gofmt prints nothing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: CARLOS_ACME_DELEGATION_ZONE names the delegation zone; claim-derived source arrives with Plan C"
+```
+
+---
+
+### Task 3: PR
+
+- [ ] **Step 1: Push and open the PR**
+
+```bash
+git push -u origin edge-delegated-wildcards
+gh pr create --title "edge: delegated-challenge wildcard certs (customer domains, zero customer DNS knowledge)" --body "$(cat <<'EOF'
+Wildcard DNS-01 for a customer domain without holding customer DNS
+credentials: the customer CNAMEs _acme-challenge.<domain> to
+_acme-challenge.<domain>.<CARLOS_ACME_DELEGATION_ZONE> (a zone our
+existing DNSimple token already writes), and the edge publishes the
+challenge TXT at that target in OUR zone. Let's Encrypt follows the
+CNAME. The dnsProvider seam, obtain flow, backoff and
+degrade-to-per-host contract are untouched; the only issuance change is
+where the TXT goes (challengeFQDN mapping).
+
+Ships inert: the delegated-parent SOURCE is claim-derived
+(wildcard:true on internal/domains.Claim, agreed with #151) and lands
+with the claim-derivation pass after #151 merges; until then the set is
+empty by construction.
+
+Spec: docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md §4.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+## Self-review notes
+
+- Spec §4 coverage: delegation-target convention ✓ (fixed:
+ `_acme-challenge.<parent>.<zone>`, env `CARLOS_ACME_DELEGATION_ZONE`,
+ flagship `acme.oncarlos.com`), provider-blind ✓ (dnsProvider
+ untouched), degrade-to-per-host ✓ (no-zone case tested), claim-derived
+ source explicitly deferred to Plan C with the seam (`delegated func()
+ []string`) already shaped for it.
+- NOT in this plan (Plan C, gated on #151 landing): claims → delegated
+ set derivation, `catchall` alias derivation, the `up.alias`
+ parent-derivation exclusion, `delegation_status` stamps, attach-door
+ refusals. NOT in this plan (Plan D): everything operational.
+- Wire-shape check: no storage shapes change here; the cert cache keys
+ (`wildcard-<domain>.pem`) already accommodate any parent string.
diff --git a/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md b/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md
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.
diff --git a/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
index 51f4b04a7..e819bf46f 100644
--- a/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
+++ b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
@@ -164,17 +164,34 @@ Mechanics (all provider-blind, the `dnsProvider` seam is untouched):
today. Delegated parents get a parent → delegation-target mapping;
`solveDNS01` publishes at the target. `waitForTXT` polls the original
name — resolvers follow the CNAME, so it already works.
-- **Declaration — shape AGREED with the #151 session (2026-08-12):**
- additive optional `wildcard: true` on the domain claim
- (`internal/domains.Claim` gains `Wildcard bool
- `json:"wildcard,omitempty"`` — landed on `worktree-route-hostnames`
- fa5dc0b; absent = today's behavior byte-identical; #151's converge
- path preserves the field on route repoints). The one field means
- both halves: "this domain is a delegated wildcard parent for cert
- issuance" (§4) and "derive a `*.<host>` alias entry to the primary"
- (§5). The edge derives delegated wildcard parents from claims exactly
- as it derives `<acct>.<apps-domain>` parents from the route table —
- no bare env list.
+- **Declaration — two additive claim fields** (the #82 session's
+ catch: one bool must not fuse two orthogonal capabilities):
+ - `wildcard: true` — this domain is a **delegated wildcard parent
+ for cert issuance** (§4 only). Useful alone: one wildcard cert can
+ cover several named aliases without per-host CT-logged certs.
+ - `catchall: true` — **derive the `*.<host>` alias entry** to the
+ primary (§5). Requires `wildcard` (a catch-all no cert can serve
+ is refused at the attach surface, not stored).
+ - On a first-route claim (empty `route`), `catchall` aliases the
+ claim's **own materialized primary route** once #137 reconcile
+ mints it — defined here so the empty-route case is never
+ ambiguous. Woodstar sets both fields.
+ - Shape status: **SETTLED with the #151 session** — both fields
+ declared on `internal/domains.Claim` with the orthogonality and
+ refusal rule in doc comments (`worktree-route-hostnames` 086f150).
+ Absent fields = today's behavior byte-identical. Standing rule
+ from that agreement: #151's converge preserves only DECLARED
+ struct fields, so **any new claim field lands on
+ `internal/domains.Claim` in the same change that first writes
+ it**. Routeless claims are ignored by #151's reconcile, so this
+ spec's derivation owns the empty-route+catchall case outright.
+ The edge derives delegated wildcard parents from claims exactly
+ as it derives `<acct>.<apps-domain>` parents from the route
+ table — no bare env list.
+ - The delegation-CNAME stamp joins the domains GET row as an
+ additive `delegation_status` field (name agreed with #151; their
+ row JSON is additive and the CLI tolerates unknown fields), riding
+ the same read path as the `pointing`/`elsewhere` stamps.
- **Gate:** the attach surface verifies the delegation CNAME exists
before enabling (the #82 `pointing`/`elsewhere` stamp spirit) — a
missing CNAME is an honest stamp, not a silent hourly issuance