Merge pull request #169 from carlosframework/worktree-woodstar-carloku
Woodstar → Carloku migration: spec + Plans A/B (docs)
3 files changed,
+1602
−0
docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md+427 −0docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md+751 −0docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md+424 −0
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..f7e111893 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md |
| @@ -0,0 +1,751 @@ |
| +# 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*` + `/img*`→unfurl |
| + (`/img` serves every link-card image; added by final-review fix), |
| + `/.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>`, |
| +`/unfurl?url=…`, and `/img?u=…`, so exact + `/`-prefix matching above |
| +is the honest equivalent. (Correction from final review: a hypothetical |
| +`/mediafoo` falling to the relay does NOT 404 — extension-less unknown |
| +paths take the SPA fallback and return 200 HTML. The narrowing stands |
| +because the client never emits such paths, not because the fallback is |
| +harmless.) |
| + |
| +- [ ] **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| new file mode 100644 |
| index 000000000..80af54a52 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md |
| @@ -0,0 +1,424 @@ |
| +# Woodstar → Carloku migration |
| + |
| +**Date:** 2026-08-12 · **Status:** draft (design approved in conversation |
| +2026-08-12; amended same day after re-recon — see §0) · **Requested by:** |
| +Paul |
| + |
| +**Supersedes:** the plan half of |
| +`docs/superpowers/plans/2026-08-02-plan-woodstar-flagship.md` (its recon |
| +remains the authoritative description of where Woodstar lives today; its |
| +task list is replaced by the plan that follows this spec). |
| + |
| +**Builds on:** route records (#137), route hostnames/aliases (#151), |
| +domain attach (#82), wildcard certs (2026-08-05 — the DNS-01 machinery |
| +this spec extends), and the kass/keymail platform-tenant migrations |
| +(the playbook). |
| + |
| +## 0. Amendment — the 2026-08-02 recon is stale (re-recon 2026-08-12) |
| + |
| +Read from woodstar main `b61409a` (`infra/prod-Caddyfile`, |
| +`infra/devbox-init.sh`, `cmd/`, `web/public/lib/api.js`); prod-box |
| +actuals still owed to the §7 pre-flight: |
| + |
| +- **Six services, not four.** Added since the old plan: `unfurl` (:7075, |
| + link-card fetcher; SQLite `unfurl.db` is a disposable cache, |
| + deliberately outside litestream) and `apbridge` (:7076, the |
| + ActivityPub/Bluesky bridge: webfinger, host-meta, `/users/*`, |
| + `/inbox`, `/ap/*`, and `/.well-known/atproto-did` on user subdomains). |
| +- **apbridge carries identity.** Its SQLite `apbridge.db` is in the |
| + litestream set (three replicated DBs, not two), and its `-seed` file |
| + (`apbridge.seed`) holds the bridge key material — the federation |
| + identity. The seed is a flat file NOT covered by litestream or the |
| + media timer; it must be copied at cutover, and §7's pre-flight |
| + verifies how (or whether) prod backs it up today. apbridge also takes |
| + a required `-domain` flag (`woodstar.app` on the flagship) — the old |
| + plan's "no canonical-host pinning anywhere" claim is no longer true — |
| + and dials out to `bsky.social` and Bridgy Fed (ordinary egress; fine). |
| +- **The per-user subdomain / Bluesky behavior is LIVE, not |
| + aspirational.** Prod Caddy's `*.woodstar.app` block today: |
| + `www` → apex (permanent redirect); `/.well-known/atproto-did` → |
| + apbridge (what makes `paul.woodstar.app` a Bluesky handle); everything |
| + else → temporary redirect to `https://woodstar.app/<leftmost-label>` |
| + (the profile). §5's woodstar-side half is therefore **existing prod |
| + behavior W0 must preserve at cutover**, not a follow-up issue. |
| +- **The fan-out set is wider** (§6 lists it). The reference |
| + prod-Caddyfile lacks the `/unfurl*` route the devbox has — box |
| + actuals to be verified in pre-flight; the migration includes it |
| + either way. |
| +- The reference Caddyfile's header names `34.241.196.33` while the |
| + 2026-08-02 recon verified EIP `52.30.232.124`; pre-flight confirms |
| + the live address before any SSH step. |
| + |
| +Where this amendment contradicts the sections below, the amendment |
| +wins; §§2, 5, 6, 7 have been updated in place to match. |
| + |
| +## 1. Goal |
| + |
| +Woodstar's production — `woodstar.app`, `status.woodstar.app`, and |
| +working `*.woodstar.app` TLS + routing — serves from the Carloku |
| +flagship box (`i-092c0c1eea75723cb` / 99.81.104.219), riding carlos |
| +release channels. Carloku IS the CARLOS platform flagship deployment |
| +(Paul, 2026-08-12); "migrate to carloku" means onto that box. |
| + |
| +Hostnames survive byte-identical: passkeys (RP ID = page domain) and the |
| +iOS AASA binding (`J5NDF5PCD8.app.woodstar.ios`) are domain-bound. The |
| +devbox (`dev.woodstar.app`, `i-0c388ec47cf98067a`) stays put — it is the |
| +dev/canary workflow and is not migrating. |
| + |
| +Two deliberate product outcomes ride along: |
| + |
| +- The platform gains **delegated-challenge wildcard certs** (§4): the |
| + standing answer to "wildcard TLS on a customer domain whose DNS |
| + provider the platform must not know". Woodstar is treated as an |
| + untrusted third-party customer for this feature even though it is a |
| + platform tenant. |
| +- The platform gains **wildcard aliases** (§5): `*.<domain>` resolving |
| + to a primary route, preserving Woodstar's "any subdomain serves the |
| + app" contract and enabling the Bluesky handle story |
| + (`@paul.woodstar.app` → the user's woodstar profile; atproto handle |
| + verification needs `https://paul.woodstar.app/.well-known/atproto-did` |
| + to answer over valid TLS). |
| + |
| +## 2. Tenancy shape |
| + |
| +**Platform tenant**, the keymail/kass pattern: |
| + |
| +- Seven carlos apps: `woodstar-relay`, `woodstar-media`, |
| + `woodstar-indexer`, `woodstar-notify`, `woodstar-unfurl`, |
| + `woodstar-apbridge` (kind binary, linux-arm64) and `woodstar-status` |
| + (kind static). Six binaries stay six apps — matches the repo's |
| + one-binary-per-service design value. |
| +- Six bespoke hardened units |
| + `woodstar-{relay,media,indexer,notify,unfurl,apbridge}.service`: |
| + static system user `woodstar`, harden-host.sh sandbox drop-ins, |
| + loopback ports 8451 relay / 8452 media / 8453 indexer / 8454 notify / |
| + 8455 unfurl / 8456 apbridge, data under `/data/woodstar` (0700; |
| + `/data` 0711). apbridge runs with |
| + `-domain woodstar.app -seed /data/woodstar/apbridge.seed` plus |
| + relay/media upstream URLs; the seed file is migrated at cutover (§7). |
| +- Env file `/etc/carlos/apps/woodstar.env` (0600 root) carrying |
| + `WOODSTAR_APPLE_APP_ID=J5NDF5PCD8.app.woodstar.ios` — present |
| + **before** the DNS flip, or iOS passkey assertions break. No other |
| + runtime env; no secrets, no SSM staging, no IMDS allowance (the Go |
| + services contain zero AWS SDK; litestream and the media timer run as |
| + root). |
| +- Backups move to the platform data bucket: |
| + `s3://carlos-data-ie-271376211898/woodstar/{relay,indexer,apbridge,media}` |
| + (host role already has RW — zero IAM/tofu change). The old |
| + `woodstar-backups-271376211898` bucket keeps pre-migration history, |
| + frozen. Litestream config is extended root-side for the THREE |
| + replicated DBs (relay, indexer, apbridge — unfurl's cache is |
| + deliberately excluded, per the repo's own rule), NOT enabled until |
| + cutover; media backup is a root hourly additive `aws s3 sync` timer. |
| +- Headroom: the whole stack runs in a t4g.nano today (data in the |
| + single-digit MB); the flagship verdict from the 2026-08-02 plan |
| + stands, re-checked at rehearsal with all six services. |
| + |
| +## 3. Sequencing — after the custom-domain track |
| + |
| +Gate: **#137 merged and deployed, then #151, then #82 — live on the |
| +flagship** — before rehearsal-onward steps. `woodstar.app` and |
| +`status.woodstar.app` then attach through the product surface |
| +end-to-end: |
| + |
| +- The relay's primary route exists as a **declared route record** |
| + (#137 vocabulary); `woodstar.app` attaches as an **alias** to it via |
| + the #82/#151 door. |
| +- `status.woodstar.app` attaches for `woodstar-status` (alias to its |
| + primary route, or first-route static attach — whichever the landed |
| + door supports for the shape at hand). |
| +- No `carlos add` over SSM for the public hostnames. |
| + |
| +Work that does NOT wait (no code-path overlap with the track): W0 in |
| +the woodstar repo (§6), and the two platform features (§4, §5) up to |
| +review — their claim-shape integration point is coordinated with the |
| +#151/#82 sessions (§10). |
| + |
| +## 4. Delegated-challenge wildcard certs |
| + |
| +The platform must hold no customer DNS credentials and never know the |
| +customer's DNS provider. Wildcard issuance is DNS-01, which normally |
| +requires writing TXT records into the customer zone — squared by |
| +**ACME challenge delegation via CNAME** (the Cloudflare-for-SaaS |
| +pattern): |
| + |
| +1. The customer creates, once, in their own zone with any provider: |
| + `_acme-challenge.<domain>` CNAME → |
| + `_acme-challenge.<domain>.<delegation-domain>`, where the delegation |
| + domain is a platform-controlled zone (under `oncarlos.com`; exact |
| + label fixed in the plan). |
| +2. At issuance the edge publishes the challenge TXT **in its own zone** |
| + at the delegation target, via the DNSimple provider it already has. |
| +3. Let's Encrypt follows the CNAME from the customer zone into ours, |
| + sees the TXT, issues `*.<domain>`. |
| + |
| +Properties: no customer credentials; revocable by the customer deleting |
| +one record; the CNAME's existence is standing proof of domain control — |
| +strictly stronger evidence than #82's DNS-pointing ruling. This |
| +retires part of the "bring-your-own apps domain / DNS delegation" price |
| +tag #82 §9 left unpaid. |
| + |
| +Mechanics (all provider-blind, the `dnsProvider` seam is untouched): |
| + |
| +- `wildcard.go` computes `challengeFQDN = _acme-challenge.<parent>` |
| + today. Delegated parents get a parent → delegation-target mapping; |
| + `solveDNS01` publishes at the target. **`waitForTXT` polls the |
| + delegation target, not the original name** (amended per final |
| + review): we confirm the write we control, and the customer zone's |
| + negative-cache TTL is not ours to bound. Consequence, stated: |
| + `waitForTXT` is NOT a delegation-setup check — a missing customer |
| + CNAME sails through the wait and fails at Let's Encrypt, one failed |
| + validation per parent per hour (inside LE limits); the attach-surface |
| + CNAME gate below is the real setup check. |
| +- **Platform parents always win** (final-review ruling): a delegated |
| + entry colliding with a static domain or sitting under the apps |
| + domain is dropped at `refresh()`, never preferred — one bad claim |
| + must not degrade a live platform wildcard's renewal. |
| +- **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 |
| + failure. |
| +- Every failure degrades to per-host issuance, like today's wildcard |
| + failures — never a dead edge. |
| + |
| +## 5. Wildcard aliases |
| + |
| +A wildcard cert alone leaves arbitrary subdomains handshaking into the |
| +edge's no-route response. Woodstar's live contract (prod Caddy wildcard |
| +block, §0) is per-user subdomains: `paul.woodstar.app` answers |
| +`/.well-known/atproto-did` (the Bluesky handle) and redirects everything |
| +else to the apex profile. So: |
| + |
| +- `*.<domain>` becomes a `route_aliases`-shaped entry resolving to a |
| + named primary route: one-label matching only (a wildcard matches |
| + exactly one label, mirroring the cert), explicit named routes/aliases |
| + win over the wildcard. |
| +- Resolution is to the primary's upstream **entry** (the identical map |
| + value, not a copied address) — #151/#152's agreed rule — so wildcard |
| + traffic inherits multi-upstream health-checked routing the moment |
| + #152 lands, with zero wildcard-side changes. Derivation slots beside |
| + #151's `ReconcileAliases` (`internal/domains/aliases.go`), which |
| + ignores fields it doesn't know. |
| +- **Parent-derivation exclusion (#151's caution, pinned):** the edge's |
| + `currentHosts()` excludes alias-flagged entries from wildcard-cert |
| + parent derivation; the derived `*.<host>` entries must carry the same |
| + alias flag (`up.alias`) so DNS-01 parent derivation never |
| + double-counts a delegated domain. |
| +- The upstream sees the **original `Host` header** — per-user host |
| + interpretation is app logic, and the platform must not foreclose it. |
| +- Per #151's pinned rule, content/backing derivation uses the primary's |
| + host; the request host is TLS/cache-key/metering only. Metering |
| + consequence (flagged by the unit-economics session): each concrete |
| + subdomain becomes its own usage row attributed to the primary route's |
| + account/app — correct, provided alias resolution decides route |
| + identity before the metering wrap, which is #151's ordering. |
| + |
| +The woodstar-side half — the relay reproducing the Caddy wildcard |
| +block's host-aware behavior (`www` → apex permanent redirect, |
| +`/.well-known/atproto-did` → apbridge, anything else → temporary |
| +redirect to `https://woodstar.app/<leftmost-label>`) — is **live prod |
| +behavior and part of W0** (§6), not a follow-up. The platform |
| +guarantees: any `*.woodstar.app` request arrives at the relay with Host |
| +intact over valid TLS. |
| + |
| +## 6. W0 — relay path fan-out (the one woodstar code change) |
| + |
| +The edge routes by Host only; the deployed client and the federated |
| +world expect Caddy's path fan-out on one origin. Fix is relay-side |
| +(W0a from the 2026-08-02 plan, widened by §0's re-recon): |
| + |
| +- Optional flags `-media-upstream`, `-indexer-upstream`, |
| + `-notify-upstream`, `-unfurl-upstream`, `-apbridge-upstream` on |
| + `cmd/relay`. When set, the relay reverse-proxies: |
| + `/media` + `/media/*` → media (including `POST /media` uploads); |
| + `/api/*` → indexer; `/stream/*` → notify; `/unfurl*` AND `/img*` → |
| + unfurl (`/img` serves every link-card image — omitting it fails as a |
| + 200 SPA page inside `<img>` tags, caught in final review); |
| + `/.well-known/webfinger`, `/.well-known/host-meta`, `/users/*`, |
| + `/inbox`, `/ap/*` → apbridge — the devbox Caddyfile's route set, |
| + which is the truthful superset of prod's. `httputil.ReverseProxy` |
| + passes WS upgrades and flushes `text/event-stream`. |
| +- **Host-aware wildcard behavior** (only when fan-out is enabled and |
| + the request Host is a one-label subdomain of the relay's domain, not |
| + the apex): `www` → permanent redirect to the apex with URI preserved; |
| + `/.well-known/atproto-did` → apbridge; everything else → temporary |
| + redirect to `https://<apex>/<leftmost-label>` — byte-matching the |
| + prod Caddy wildcard block. Apex requests are untouched. |
| +- Unset = today's behavior byte-identical; dev/devbox unaffected (Caddy |
| + keeps the fan-out there). |
| +- TDD in the woodstar repo; lands via woodstar PR; cutover binaries are |
| + the merged W0 sha (the dfe406a shipment already in the bucket is |
| + pipeline-validation only, superseded — it predates unfurl and |
| + apbridge entirely). |
| + |
| +## 7. Rehearsal, cutover, rollback |
| + |
| +**Playbook constraints (inherited, standing):** no DNS changes and no |
| +touching running woodstar services until the gated steps; the flip is |
| +HELD for Paul's explicit go; rehearsals only ever read COPIES — never |
| +point a litestream at the live `prod/` prefixes (lineage fork); never |
| +run old-box and flagship stacks against the same replication prefixes |
| +at once; no `carlos bootstrap` on the live flagship. |
| + |
| +**Old-box access:** the prod box (`ec2-user@52.30.232.124`, SSH-only, |
| +no SSM) gets an **allow rule for the executing session's SSH** so the |
| +session drives verification and cutover steps directly. |
| + |
| +**Pre-flight (any time, no prod impact):** |
| + |
| +- Over SSH (confirming the live address first — §0's IP discrepancy): |
| + `systemctl cat` on all six `woodstar-*` units (exact flags/env, |
| + especially apbridge's `-domain`/`-seed`/`-bridgy`/`-bsky-api`), real |
| + per-service RSS, `ls /opt/woodstar/data` (expect relay.db, |
| + indexer.db, apbridge.db, apbridge.seed, unfurl.db, media — anything |
| + else is a finding), the box's actual Caddyfile vs the repo reference |
| + (`/unfurl*` presence), and `/etc/litestream.yml` actuals (which DBs |
| + replicate, and whether `apbridge.seed` has ANY backup today — if |
| + not, copy it to the backup bucket immediately as its own pre-flight |
| + fix, it is the federation identity). |
| +- Create the ACME delegation CNAME in Route53 (inert until used); |
| + obtain and handshake-verify `*.woodstar.app` on the flagship |
| + **before** the flip — issuance does not depend on the A records. |
| +- Attach `woodstar.app` + `status.woodstar.app` through the door; |
| + declare the wildcard alias. The relay's primary route record is |
| + materialized and adopted, and all alias entries are derived box-side, |
| + pre-flip. Per-host certs for the two named hosts mint on first |
| + post-flip hit. |
| + |
| +**Rehearsal (copies only):** server-side CopyObject of the backups into |
| +`carlos-data-ie-…/woodstar-rehearsal/`; litestream restore the three |
| +DBs + media sync + a seed-file copy on the flagship; |
| +`PRAGMA integrity_check` on all three; transient units on rehearsal |
| +ports; an sslip.io rehearsal route. apbridge rehearses with `-bridgy` |
| +and `-bsky-api` pointed at throwaway values (its own flags exist for |
| +exactly this) so the rehearsal never dials the real federation. The |
| +point is the **path-fan-out check through the edge**: feed renders |
| +restored posts, `/media/*` images load, `/api/search` returns, |
| +`/stream/*` SSE streams, `/subscribe` WS connects, `/unfurl` answers, |
| +webfinger + `/users/*` respond from the bridge — plus woodstar's own |
| +WebKit `console-check.mjs`. Record freeze-to-serving time; tear down |
| +units, data, routes. |
| + |
| +**Cutover (HELD for Paul's explicit go; quiet hour; TTLs already 300):** |
| + |
| +Pre-flight invariant (final-review catch): **`status.woodstar.app`'s |
| +exact-host route MUST exist before the wildcard alias serves** — with |
| +the alias live and the status route missing, status requests 302 to |
| +`woodstar.app/status` (an SPA client route) and the status page |
| +silently becomes the app. Exact-host beats wildcard; verify the |
| +precedence in rehearsal. |
| + |
| +1. Old box (SSH): stop the six `woodstar-*` units → run one final |
| + media-backup pass → wait ~30 s for litestream's final WAL frames → |
| + stop litestream → copy `apbridge.seed` off the box → verify newest |
| + backup timestamps are post-stop. |
| +2. Flagship: litestream restore the three DBs from `woodstar-backups` + |
| + media sync + seed file into `/data/woodstar`; chown woodstar; |
| + `PRAGMA integrity_check`; start the six units; enable flagship |
| + litestream config + media timer (fresh `carlos-data-ie` prefixes — |
| + order is stop-old → restore-new → start-new). |
| +3. Smoke BEFORE DNS: loopback `/info` 200, AASA correct, webfinger |
| + answers from the bridge, |
| + `curl --resolve woodstar.app:443:99.81.104.219` through the edge. |
| +4. Flip Route53 (zone `Z07702842ZC4MUKZR07JB`, same account): |
| + `woodstar.app` A and `*.woodstar.app` A → 99.81.104.219. Watch the |
| + per-host certs issue. |
| +5. Paul's browser pass: passkey sign-in, post, image upload + display, |
| + search, live notification, DM, link-card unfurl, iOS app AASA |
| + validation, status page, a `paul.woodstar.app` profile redirect + |
| + its `/.well-known/atproto-did`, and federation (a webfinger lookup |
| + from a real Fediverse server or Bridgy Fed still resolving). |
| + |
| +**Rollback (any point before Paul blesses it):** stop flagship units + |
| +its litestream; restart the old box's units + litestream; flip the two |
| +A records back. Writes accepted by the flagship during the window live |
| +only in its lineage — keep the window short. Old box: units stopped, |
| +disk intact, frozen 30 days. |
| + |
| +**Named failure shapes:** |
| + |
| +- **Degraded-not-broken:** a misconfigured fan-out or alias leaves the |
| + site "working" while media/search/notify silently fail. The four-path |
| + checks run at rehearsal AND at cutover — never skipped. |
| +- **Founder trap:** never start units against an empty `/data/woodstar` |
| + reachable on the real hostname — the first registrant becomes |
| + founder. Restore is explicit; there is no restore-on-boot. |
| +- **SSE/WS through two proxied hops** (edge → relay → notify): verified |
| + in rehearsal, not assumed. |
| + |
| +## 8. Testing |
| + |
| +- Platform: the full gate (`go build/vet/test ./...`, gofmt, darwin |
| + cross-compile) plus delegated-wildcard unit tests (parent→target |
| + mapping; TXT published at the target, not the origin; cleanup; |
| + degradation to per-host on failure), wildcard-alias tests (one-label |
| + matching, named-route precedence, Host preserved to upstream), and |
| + parse goldens for the agreed claim-shape extension (additive rule — |
| + pre-change claims must still parse). |
| +- Woodstar: W0 TDD (proxy wiring, SSE flush, WS `/subscribe` |
| + untouched); `make test` green; e2e harness where practical. |
| +- Live verification is §7's rehearsal + cutover checks — verify against |
| + the thing changed, with the binary built. |
| + |
| +## 9. Post-cutover |
| + |
| +- Promote the W0 sha to stable after the 72 h supply-chain bake. |
| +- Repoint status publishing: `carlos ship -kind static` + promote |
| + replaces `scripts/status-publish.sh`. |
| +- Note woodstar `infra/` tofu drift (records + stopped units outside |
| + its state); retire the old prod instance after the 30-day freeze; |
| + zone, bucket, devbox, IAM stay. |
| +- Update woodstar's CLAUDE.md "The boxes" section post-retirement, and |
| + its `infra/prod-Caddyfile` reference-copy header (the file describes |
| + a box that no longer serves). |
| + |
| +## 10. Coordination points |
| + |
| +- **Claim-shape: SETTLED 2026-08-12.** `wildcard: true` additive on |
| + schema 1, agreed with the #151 session (their branch fa5dc0b carries |
| + the field; §4 has the detail). The #151 session pings this one when |
| + their console door + CLI land, so deploy-flow tasks can cite exact |
| + paths. |
| +- **Delegation-domain label** (§4): fixed in the plan (a name under |
| + `oncarlos.com`). |
| +- **status.woodstar.app attach shape** (§3): alias vs first-route |
| + static — decided against the landed #82 door. |
| + |
| +## 11. Out of scope, named |
| + |
| +- Edge path-prefix routing (W0b) — relay fan-out covers it. |
| +- New per-user-subdomain features beyond parity with the live Caddy |
| + behavior (which W0 reproduces) — e.g. serving the full app on |
| + subdomains rather than redirecting — woodstar repo, later. |
| +- Devbox migration — stays on Caddy wildcard DNS-01, untouched. |
| +- DNS record automation — the delegation CNAME and the cutover flip are |
| + operator/customer acts, per #82's ruling. |
| +- Multi-provider DNS on one edge — delegation makes it unnecessary. |