carlos / platform Public

plan: wildcard DNS-01 certs — 8 tasks, TDD, Eleven port

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3fc029ace923b027aff2d79393734986f89fb259 parent 128543c
1 file changed, +2073 −0
  • docs/superpowers/plans/2026-08-05-wildcard-certs.md +2073 −0
diff --git a/docs/superpowers/plans/2026-08-05-wildcard-certs.md b/docs/superpowers/plans/2026-08-05-wildcard-certs.md
new file mode 100644
index 000000000..c52ec4914
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-05-wildcard-certs.md
@@ -0,0 +1,2073 @@
+# 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:** Per-account wildcard DNS-01 certificates in the edge so freshly created app hostnames have working TLS instantly, with per-host HTTP-01 as the untouched fallback.
+
+**Architecture:** Port Eleven's `wildcard.go` + `route53dns.go` (elevenmessenger/messenger) into `internal/edge` behind the existing cert-cache seam, adapted for dynamic parents: one `*.<acct>.<apps-domain>` cert per account derived from the live route table, plus static parents from `CARLOS_WILDCARD_DOMAINS` (tito's `go.tito.io`). A wrapped `GetCertificate` serves the wildcard when SNI is exactly one label under a live parent; everything else falls through to autocert per-host HTTP-01. `domains.Reconcile`'s `certExpiry` learns to read the covering wildcard PEM (it parses the acme cache dir shape — the consumer rule).
+
+**Tech Stack:** Go, `golang.org/x/crypto/acme` (already vendored via autocert), `aws-sdk-go-v2/service/route53` v1.64.1 (new module, already in the local module cache; core SDK versions identical to what's in go.mod), DNSimple v2 REST API (plain net/http), OpenTofu for the region-host IAM variable.
+
+**Spec:** `docs/superpowers/specs/2026-08-05-wildcard-certs-design.md`
+
+## Global Constraints
+
+- Sandboxed go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off` (disable sandbox if the toolchain reports "read-only file system").
+- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean; `gofmt -l` clean for touched files.
+- Wire-shape changes are additive: new cert-cache keys (`wildcard-<parent>.pem`, `wildcard-account.key`) only — never rewrite existing autocert entries.
+- Env vocabulary (exact): `CARLOS_WILDCARD_DOMAINS`, `CARLOS_DNS_PROVIDER` (`""`/`dnsimple`/`route53`), `CARLOS_DNSIMPLE_TOKEN`, `CARLOS_DNSIMPLE_BASE` (test override), `CARLOS_ACME_DIRECTORY` (test override), plus existing `PLATFORM_APPS_DOMAIN`.
+- Zero-config state = today's behavior: nothing configured → wildcard machinery off, per-host issuance continues.
+- Log prefix is `edge:` (the package's existing convention), not Eleven's `router:`.
+
+---
+
+### Task 1: dnsProvider seam + DNSimple backend
+
+**Files:**
+- Modify: `go.mod` (add `github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1` — added here once so Task 2 compiles; harmless while unused)
+- Create: `internal/edge/dns.go`
+- Test: `internal/edge/dns_test.go`
+
+**Interfaces:**
+- Consumes: `envOr(key, fallback string) string` (exists, `edge.go`).
+- Produces:
+ - `type dnsProvider interface { present(ctx context.Context, fqdn, value string) (cleanup func(context.Context) error, err error) }` — publish a TXT at the full record FQDN (e.g. `_acme-challenge.abs.oncarlos.com`), backend discovers the owning zone itself. This is the CARLOS adaptation of Eleven's seam: Eleven passed (zone, name) because its parents were zones; per-account parents are NOT zones.
+ - `func dnsProviderFromEnv() (dnsProvider, string)` — `(nil, reason)` when unconfigured.
+ - `func newDNSimpleDNS(token string) *dnsimpleDNS`
+ - `func splitDomains(s string) []string`
+
+- [ ] **Step 1: Add the route53 dependency** (needed by Task 2; do it while go.mod is the only file touched)
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go get github.com/aws/aws-sdk-go-v2/service/route53@v1.64.1 && GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go mod tidy`
+Expected: go.mod gains the module; resolution succeeds from the module cache (core versions match Eleven's exactly). `go mod tidy` may drop it again since nothing imports it yet — if so, re-run the `go get` at the start of Task 2 instead; either way build stays green.
+
+- [ ] **Step 2: Write the failing tests**
+
+`internal/edge/dns_test.go`:
+
+```go
+package edge
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+)
+
+func TestSplitDomains(t *testing.T) {
+ cases := []struct {
+ in string
+ want []string
+ }{
+ {"", nil},
+ {"go.tito.io", []string{"go.tito.io"}},
+ {" Go.Tito.IO , dev.oncarlos.com ,", []string{"go.tito.io", "dev.oncarlos.com"}},
+ }
+ for _, c := range cases {
+ if got := splitDomains(c.in); !reflect.DeepEqual(got, c.want) {
+ t.Errorf("splitDomains(%q) = %v, want %v", c.in, got, c.want)
+ }
+ }
+}
+
+func TestDNSProviderSelection(t *testing.T) {
+ t.Setenv("CARLOS_DNS_PROVIDER", "")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "")
+ if p, reason := dnsProviderFromEnv(); p != nil || reason == "" {
+ t.Fatalf("unset env: want (nil, reason), got (%v, %q)", p, reason)
+ }
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
+ if p, reason := dnsProviderFromEnv(); p == nil {
+ t.Fatalf("token set: want dnsimple provider, got nil (%s)", reason)
+ } else if _, ok := p.(*dnsimpleDNS); !ok {
+ t.Fatalf("token set: want *dnsimpleDNS, got %T", p)
+ }
+ t.Setenv("CARLOS_DNS_PROVIDER", "bogus")
+ if p, reason := dnsProviderFromEnv(); p != nil || reason == "" {
+ t.Fatalf("bogus provider: want (nil, reason), got (%v, %q)", p, reason)
+ }
+ // route53 selection is exercised in route53dns_test.go — constructing it
+ // here would hit the AWS credential chain.
+}
+
+// fakeDNSimple is a minimal DNSimple v2 API: whoami, per-zone existence,
+// record create/delete. It records mutations for assertions.
+type fakeDNSimple struct {
+ zones map[string]bool // zone name → exists
+ created []string // "zone|name|content"
+ deleted []int64
+ nextID int64
+}
+
+func (f *fakeDNSimple) handler(t *testing.T) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /v2/whoami", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer tok" {
+ t.Errorf("whoami auth header = %q", r.Header.Get("Authorization"))
+ }
+ fmt.Fprint(w, `{"data":{"account":{"id":42}}}`)
+ })
+ mux.HandleFunc("GET /v2/42/zones/{zone}", func(w http.ResponseWriter, r *http.Request) {
+ if !f.zones[r.PathValue("zone")] {
+ http.Error(w, `{"message":"not found"}`, 404)
+ return
+ }
+ fmt.Fprintf(w, `{"data":{"name":%q}}`, r.PathValue("zone"))
+ })
+ mux.HandleFunc("POST /v2/42/zones/{zone}/records", func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name, Type, Content string
+ TTL int
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("create record body: %v", err)
+ }
+ f.nextID++
+ f.created = append(f.created, fmt.Sprintf("%s|%s|%s", r.PathValue("zone"), body.Name, body.Content))
+ w.WriteHeader(201)
+ fmt.Fprintf(w, `{"data":{"id":%d}}`, f.nextID)
+ })
+ mux.HandleFunc("DELETE /v2/42/zones/{zone}/records/{id}", func(w http.ResponseWriter, r *http.Request) {
+ var id int64
+ fmt.Sscan(r.PathValue("id"), &id)
+ f.deleted = append(f.deleted, id)
+ w.WriteHeader(204)
+ })
+ return mux
+}
+
+// The per-account adaptation: the zone is discovered by suffix-walking fqdn
+// against the API, so a challenge under abs.oncarlos.com lands in the
+// oncarlos.com zone with the account label in the record NAME.
+func TestDNSimplePresentDiscoversZone(t *testing.T) {
+ fake := &fakeDNSimple{zones: map[string]bool{"oncarlos.com": true}}
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ cleanup, err := d.present(context.Background(), "_acme-challenge.abs.oncarlos.com", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ want := []string{"oncarlos.com|_acme-challenge.abs|txtvalue"}
+ if !reflect.DeepEqual(fake.created, want) {
+ t.Fatalf("created = %v, want %v", fake.created, want)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if !reflect.DeepEqual(fake.deleted, []int64{1}) {
+ t.Fatalf("deleted = %v, want [1]", fake.deleted)
+ }
+}
+
+func TestDNSimplePresentNoZone(t *testing.T) {
+ fake := &fakeDNSimple{zones: map[string]bool{}}
+ srv := httptest.NewServer(fake.handler(t))
+ defer srv.Close()
+ t.Setenv("CARLOS_DNSIMPLE_BASE", srv.URL)
+
+ d := newDNSimpleDNS("tok")
+ if _, err := d.present(context.Background(), "_acme-challenge.abs.nowhere.example", "v"); err == nil {
+ t.Fatal("present with no owning zone: want error, got nil")
+ }
+}
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestSplitDomains|TestDNSProvider|TestDNSimple' -count=1`
+Expected: FAIL — `undefined: splitDomains`, `undefined: dnsProviderFromEnv`, etc.
+
+- [ ] **Step 4: Write `internal/edge/dns.go`**
+
+```go
+package edge
+
+// The DNS-01 provider seam for wildcard certs (wildcard.go) — ported from
+// Eleven's wildcard.go/dnsProvider with one deliberate change: present takes
+// the full record FQDN and the backend discovers the owning zone itself.
+// Eleven's parents were always zones (11ch.at); CARLOS's per-account parents
+// (<acct>.<apps-domain>) are not, so the split into (zone, record name) is
+// the backend's job — DNSimple suffix-walks the fqdn against the zones API,
+// Route53 suffix-matches ListHostedZonesByName (route53dns.go).
+//
+// Backends: dnsimpleDNS (default — oncarlos.com's registrar; needs
+// CARLOS_DNSIMPLE_TOKEN) and route53DNS (CARLOS_DNS_PROVIDER=route53 — the
+// tito.io path; standard AWS credential chain, no token on disk). Selection
+// lives in dnsProviderFromEnv; everything in wildcard.go is provider-blind.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+)
+
+// dnsProvider publishes the ACME DNS-01 challenge TXT and hands back a
+// closure that removes it — a closure so each backend keeps its teardown
+// detail private (DNSimple deletes by the numeric record id it minted;
+// Route53 re-submits the record set). cleanup runs on its own context (see
+// solveDNS01's defer) so it still fires after the issuance context is dead.
+type dnsProvider interface {
+ present(ctx context.Context, fqdn, value string) (cleanup func(context.Context) error, err error)
+}
+
+// dnsProviderFromEnv selects the DNS-01 backend:
+//
+// - unset / CARLOS_DNS_PROVIDER=dnsimple → DNSimple; needs
+// CARLOS_DNSIMPLE_TOKEN (an account API token). A missing token leaves
+// the wildcard off (per-host issuance continues) — the zero-config state.
+// - CARLOS_DNS_PROVIDER=route53 → Route53, authed by the standard AWS
+// credential chain (env vars / instance IAM role) — no DNS token on disk.
+//
+// Returns (nil, reason) when nothing is configured, so the caller logs the
+// human reason and falls back to per-host issuance rather than failing boot.
+func dnsProviderFromEnv() (dnsProvider, string) {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_DNS_PROVIDER"))) {
+ case "route53":
+ r53, err := newRoute53DNS(context.Background())
+ if err != nil {
+ return nil, fmt.Sprintf("CARLOS_DNS_PROVIDER=route53 but AWS config load failed: %v", err)
+ }
+ return r53, ""
+ case "", "dnsimple":
+ token := os.Getenv("CARLOS_DNSIMPLE_TOKEN")
+ if token == "" {
+ return nil, "CARLOS_DNSIMPLE_TOKEN missing"
+ }
+ return newDNSimpleDNS(token), ""
+ default:
+ return nil, fmt.Sprintf("unknown CARLOS_DNS_PROVIDER %q (want dnsimple or route53)", os.Getenv("CARLOS_DNS_PROVIDER"))
+ }
+}
+
+// splitDomains parses the CARLOS_WILDCARD_DOMAINS comma list.
+func splitDomains(s string) []string {
+ var out []string
+ for _, d := range strings.Split(s, ",") {
+ if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// ---- DNSimple zone records --------------------------------------------------
+
+type dnsimpleDNS struct {
+ base string
+ token string
+ mu sync.Mutex
+ account string // discovered via /v2/whoami on first use
+ zones map[string]bool // zone name → known-to-exist (positive cache only)
+}
+
+// newDNSimpleDNS builds the default DNS-01 backend from an account API token,
+// honouring the CARLOS_DNSIMPLE_BASE override (kept here so all env reads for
+// this backend live in one place).
+func newDNSimpleDNS(token string) *dnsimpleDNS {
+ return &dnsimpleDNS{
+ base: envOr("CARLOS_DNSIMPLE_BASE", "https://api.dnsimple.com"),
+ token: token,
+ zones: map[string]bool{},
+ }
+}
+
+// present satisfies dnsProvider: discover the zone owning fqdn, create the
+// challenge TXT inside it, and hand back a closure that deletes it by the
+// record id DNSimple returned.
+func (d *dnsimpleDNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) {
+ zone, name, err := d.zoneFor(ctx, fqdn)
+ if err != nil {
+ return nil, err
+ }
+ id, err := d.createTXT(ctx, zone, name, value)
+ if err != nil {
+ return nil, fmt.Errorf("dnsimple: create TXT: %v", err)
+ }
+ return func(cctx context.Context) error { return d.deleteRecord(cctx, zone, id) }, nil
+}
+
+// zoneFor walks fqdn's parent suffixes longest-first until the zones API
+// knows one, returning (zone, record name within it). Positive results are
+// cached; misses are not (a zone might be added later).
+func (d *dnsimpleDNS) zoneFor(ctx context.Context, fqdn string) (zone, name string, err error) {
+ fqdn = strings.ToLower(strings.TrimSuffix(fqdn, "."))
+ labels := strings.Split(fqdn, ".")
+ for i := 1; i <= len(labels)-2; i++ { // a zone has at least two labels
+ cand := strings.Join(labels[i:], ".")
+ ok, err := d.zoneExists(ctx, cand)
+ if err != nil {
+ return "", "", err
+ }
+ if ok {
+ return cand, strings.Join(labels[:i], "."), nil
+ }
+ }
+ return "", "", fmt.Errorf("dnsimple: no zone found for %s", fqdn)
+}
+
+func (d *dnsimpleDNS) zoneExists(ctx context.Context, zone string) (bool, error) {
+ d.mu.Lock()
+ known := d.zones[zone]
+ d.mu.Unlock()
+ if known {
+ return true, nil
+ }
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return false, err
+ }
+ err = d.do(ctx, "GET", "/v2/"+acct+"/zones/"+zone, nil, nil)
+ if err != nil {
+ if errors.Is(err, errDNSimpleNotFound) {
+ return false, nil
+ }
+ return false, err
+ }
+ d.mu.Lock()
+ d.zones[zone] = true
+ d.mu.Unlock()
+ return true, nil
+}
+
+// errDNSimpleNotFound distinguishes a 404 (zone genuinely absent — keep
+// walking suffixes) from transport or auth failures (stop and report).
+var errDNSimpleNotFound = errors.New("dnsimple: not found")
+
+func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error {
+ var rdr io.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ rdr = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, d.base+path, rdr)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+d.token)
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return fmt.Errorf("%s %s: %w", method, path, errDNSimpleNotFound)
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg)))
+ }
+ if out != nil {
+ return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
+ }
+ return nil
+}
+
+func (d *dnsimpleDNS) accountID(ctx context.Context) (string, error) {
+ d.mu.Lock()
+ acct := d.account
+ d.mu.Unlock()
+ if acct != "" {
+ return acct, nil
+ }
+ var who struct {
+ Data struct {
+ Account *struct {
+ ID int64 `json:"id"`
+ } `json:"account"`
+ } `json:"data"`
+ }
+ if err := d.do(ctx, "GET", "/v2/whoami", nil, &who); err != nil {
+ return "", err
+ }
+ if who.Data.Account == nil {
+ return "", errors.New("whoami: token is not an account token")
+ }
+ d.mu.Lock()
+ d.account = fmt.Sprint(who.Data.Account.ID)
+ acct = d.account
+ d.mu.Unlock()
+ return acct, nil
+}
+
+func (d *dnsimpleDNS) createTXT(ctx context.Context, zone, name, content string) (int64, error) {
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return 0, err
+ }
+ var created struct {
+ Data struct {
+ ID int64 `json:"id"`
+ } `json:"data"`
+ }
+ err = d.do(ctx, "POST", "/v2/"+acct+"/zones/"+zone+"/records", map[string]any{
+ "name": name, "type": "TXT", "content": content, "ttl": 60,
+ }, &created)
+ if err != nil {
+ return 0, err
+ }
+ return created.Data.ID, nil
+}
+
+func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) error {
+ acct, err := d.accountID(ctx)
+ if err != nil {
+ return err
+ }
+ return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil)
+}
+```
+
+Note: `newRoute53DNS` doesn't exist until Task 2. To keep this task independently green, add a one-line stub at the bottom of `dns.go` that Task 2 deletes when it lands the real one:
+
+```go
+// newRoute53DNS is implemented in route53dns.go (Task 2). Stub so the
+// dnsimple-first commit compiles; selection tests skip route53 construction.
+func newRoute53DNS(ctx context.Context) (dnsProvider, error) {
+ return nil, errors.New("route53 backend not built yet")
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestSplitDomains|TestDNSProvider|TestDNSimple' -count=1`
+Expected: PASS
+
+- [ ] **Step 6: Full gate + commit**
+
+Run: `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 && gofmt -l internal/edge/`
+Expected: all clean, no gofmt output.
+
+```bash
+git add go.mod go.sum internal/edge/dns.go internal/edge/dns_test.go
+git commit -m "edge: dnsProvider seam + DNSimple DNS-01 backend
+
+Ported from Eleven's wildcard.go with one deliberate change: present()
+takes the full record FQDN and the backend discovers the owning zone
+(per-account wildcard parents are not zones)."
+```
+
+---
+
+### Task 2: Route53 backend
+
+**Files:**
+- Create: `internal/edge/route53dns.go`
+- Test: `internal/edge/route53dns_test.go`
+- Modify: `internal/edge/dns.go` (delete the `newRoute53DNS` stub)
+
+**Interfaces:**
+- Consumes: `dnsProvider`, `errDNSimpleNotFound` pattern not needed here; `dnsFQDN` is produced here and used only here.
+- Produces: `func newRoute53DNS(ctx context.Context) (dnsProvider, error)` (replaces the Task 1 stub — same signature, so `dnsProviderFromEnv` doesn't change).
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/edge/route53dns_test.go`:
+
+```go
+package edge
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+// fakeRoute53 satisfies route53API and records the record-set changes.
+type fakeRoute53 struct {
+ zones map[string]string // zone name (with trailing dot) → id
+ changes []string // "ACTION|name|value"
+}
+
+func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, in *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) {
+ out := &route53.ListHostedZonesByNameOutput{}
+ for name, id := range f.zones {
+ out.HostedZones = append(out.HostedZones, r53types.HostedZone{Name: aws.String(name), Id: aws.String("/hostedzone/" + id)})
+ }
+ return out, nil
+}
+
+func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
+ for _, c := range in.ChangeBatch.Changes {
+ f.changes = append(f.changes, string(c.Action)+"|"+aws.ToString(c.ResourceRecordSet.Name)+"|"+aws.ToString(c.ResourceRecordSet.ResourceRecords[0].Value))
+ }
+ return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("c1"), Status: r53types.ChangeStatusInsync}}, nil
+}
+
+func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) {
+ return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil
+}
+
+// The fqdn seam: the backend must find the most specific zone that is a
+// parent of the record fqdn — tito.io owns _acme-challenge.go.tito.io when
+// go.tito.io has no zone of its own, and the value must be quoted (Route53
+// stores TXT values as quoted character-strings).
+func TestRoute53PresentUpsertsAndCleansUp(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "io.": "Z0"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+
+ cleanup, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ if len(fake.changes) != 1 || fake.changes[0] != `UPSERT|_acme-challenge.go.tito.io.|"txtvalue"` {
+ t.Fatalf("changes after present = %v", fake.changes)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if len(fake.changes) != 2 || !strings.HasPrefix(fake.changes[1], "DELETE|_acme-challenge.go.tito.io.|") {
+ t.Fatalf("changes after cleanup = %v", fake.changes)
+ }
+ if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z1" {
+ t.Fatalf("cached zone id = %q, want Z1 (most specific parent zone)", got)
+ }
+}
+
+func TestRoute53NoZone(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"example.org.": "Z9"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+ if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "v"); err == nil {
+ t.Fatal("present with no owning zone: want error, got nil")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run TestRoute53 -count=1`
+Expected: FAIL — `undefined: route53DNS` (the stub returns a plain error type, no struct exists).
+
+- [ ] **Step 3: Write `internal/edge/route53dns.go`** (port of Eleven's, minus `upsertA` — that's its relocation feature, YAGNI here; and `hostedZoneID` now takes the record fqdn directly)
+
+```go
+package edge
+
+// route53DNS — the Route53 DNS-01 backend (CARLOS_DNS_PROVIDER=route53), the
+// tito path: obtain a *.go.tito.io wildcard with NO DNS token on disk; auth
+// rides the standard AWS credential chain (env vars or the box IAM role,
+// scoped to the hosted zone — infra/modules/region-host). Ported from
+// Eleven's route53dns.go minus its relocation-only upsertA.
+//
+// Same seam as DNSimple (dnsProvider): UPSERT the challenge TXT, wait for
+// the change to go INSYNC, and hand back a closure that removes it. The
+// zone owning the record fqdn is discovered via ListHostedZonesByName's
+// suffix match, so per-account parents (which have no zone of their own)
+// resolve to the registered domain's zone.
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+// route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to
+// reach INSYNC. Route53 usually syncs in well under a minute; on timeout we
+// proceed anyway — the CA runs its own retries and waitForTXT still gates
+// public visibility (best-effort, never fatal).
+const route53InSyncTimeout = 2 * time.Minute
+
+// route53API is the slice of the Route53 client route53DNS calls — an
+// interface so tests substitute a fake and never reach AWS.
+type route53API interface {
+ ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error)
+ ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
+ GetChange(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error)
+}
+
+type route53DNS struct {
+ client route53API
+ mu sync.Mutex
+ zoneIDs map[string]string // record fqdn → hosted-zone id
+}
+
+// newRoute53DNS builds the backend from the default AWS credential chain.
+// Route53 is a global service but the SDK still needs a signing region, so
+// default to us-east-1 when the environment names none — harmless, the
+// endpoint is global either way, and an explicit AWS_REGION still wins.
+func newRoute53DNS(ctx context.Context) (dnsProvider, error) {
+ var opts []func(*awsconfig.LoadOptions) error
+ if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
+ opts = append(opts, awsconfig.WithRegion("us-east-1"))
+ }
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("route53: load aws config: %w", err)
+ }
+ return &route53DNS{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil
+}
+
+// present satisfies dnsProvider: UPSERT the challenge TXT, wait for INSYNC,
+// and return a closure that deletes the exact record set it created. UPSERT
+// (not CREATE) so a TXT stranded by a crashed prior attempt is overwritten
+// instead of colliding. Route53 stores a TXT value as a quoted
+// character-string; the ACME token is a bare base64url string, so wrap it.
+func (r *route53DNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) {
+ zoneID, err := r.hostedZoneID(ctx, fqdn)
+ if err != nil {
+ return nil, err
+ }
+ rrset := &r53types.ResourceRecordSet{
+ Name: aws.String(dnsFQDN(fqdn)),
+ Type: r53types.RRTypeTxt,
+ TTL: aws.Int64(60),
+ ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(strconv.Quote(value))}},
+ }
+ out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{
+ Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}},
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("route53: upsert TXT: %w", err)
+ }
+ if out.ChangeInfo != nil {
+ r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id))
+ }
+ cleanup := func(cctx context.Context) error {
+ // DELETE must resubmit the same record set that was created.
+ _, err := r.client.ChangeResourceRecordSets(cctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{
+ Changes: []r53types.Change{{Action: r53types.ChangeActionDelete, ResourceRecordSet: rrset}},
+ },
+ })
+ return err
+ }
+ return cleanup, nil
+}
+
+// hostedZoneID resolves the hosted zone authoritative for fqdn, caching the
+// lookup: the most specific returned zone whose name equals fqdn or is a
+// parent suffix of it. Ids come back as "/hostedzone/Z123"; we store the
+// bare id.
+func (r *route53DNS) hostedZoneID(ctx context.Context, fqdn string) (string, error) {
+ r.mu.Lock()
+ if id, ok := r.zoneIDs[fqdn]; ok {
+ r.mu.Unlock()
+ return id, nil
+ }
+ r.mu.Unlock()
+
+ want := dnsFQDN(fqdn)
+ out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(want)})
+ if err != nil {
+ return "", fmt.Errorf("route53: list hosted zones: %w", err)
+ }
+ var bestName, bestID string
+ for _, hz := range out.HostedZones {
+ zn := strings.ToLower(aws.ToString(hz.Name))
+ if zn == want || strings.HasSuffix(want, "."+zn) {
+ if len(zn) > len(bestName) {
+ bestName, bestID = zn, strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/")
+ }
+ }
+ }
+ if bestID == "" {
+ return "", fmt.Errorf("route53: no hosted zone for %s", fqdn)
+ }
+ r.mu.Lock()
+ r.zoneIDs[fqdn] = bestID
+ r.mu.Unlock()
+ return bestID, nil
+}
+
+// waitInSync polls GetChange until the change is INSYNC or the timeout
+// passes. Best-effort, mirroring waitForTXT: a timeout is not fatal (the CA
+// retries).
+func (r *route53DNS) waitInSync(ctx context.Context, changeID string) {
+ if changeID == "" {
+ return
+ }
+ deadline := time.Now().Add(route53InSyncTimeout)
+ for time.Now().Before(deadline) && ctx.Err() == nil {
+ out, err := r.client.GetChange(ctx, &route53.GetChangeInput{Id: aws.String(changeID)})
+ if err == nil && out.ChangeInfo != nil && out.ChangeInfo.Status == r53types.ChangeStatusInsync {
+ return
+ }
+ time.Sleep(3 * time.Second)
+ }
+}
+
+// dnsFQDN appends the root dot Route53 stores names with.
+func dnsFQDN(name string) string {
+ name = strings.ToLower(name)
+ if strings.HasSuffix(name, ".") {
+ return name
+ }
+ return name + "."
+}
+```
+
+Also delete the `newRoute53DNS` stub from `dns.go` (and its now-unused comment).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestRoute53|TestDNSProvider' -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Full gate + commit**
+
+Run the full gate (Global Constraints), then:
+
+```bash
+git add internal/edge/route53dns.go internal/edge/route53dns_test.go internal/edge/dns.go go.mod go.sum
+git commit -m "edge: Route53 DNS-01 backend (CARLOS_DNS_PROVIDER=route53)
+
+Port of Eleven's route53dns.go minus relocation-only upsertA; zone for a
+record fqdn is discovered by suffix match, so per-account parents with no
+zone of their own land in the registered domain's zone."
+```
+
+---
+
+### Task 3: wildcardManager state — parents, matching, PEM, cache
+
+**Files:**
+- Create: `internal/edge/wildcard.go`
+- Test: `internal/edge/wildcard_test.go`
+
+**Interfaces:**
+- Consumes: `dnsProvider` (Task 1), `autocert.Cache` (certcache.go seam), `envOr`.
+- Produces (used by Tasks 4-5):
+ - `type wildcardManager struct` with fields `cache autocert.Cache`, `dns dnsProvider`, `directory string`, `staticDomains []string`, `appsDomain string`, `hosts func() []string`, `mu sync.Mutex`, `parents []string`, `certs map[string]*tls.Certificate`, `wake chan struct{}`
+ - `func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, cache autocert.Cache, dns dnsProvider) *wildcardManager`
+ - `func (w *wildcardManager) certFor(serverName string) *tls.Certificate`
+ - `func (w *wildcardManager) refresh()` — recompute parents, wake run loop if any parent needs obtaining
+ - `func parentsFor(static []string, appsDomain string, hosts []string) []string` — sorted, deduped
+ - `func (w *wildcardManager) needsObtain(parent string, now time.Time) bool`
+ - `func (w *wildcardManager) loadCached(ctx context.Context, parent string) bool`
+ - `func wildcardPEMKey(domain string) string` → `"wildcard-" + domain + ".pem"`
+ - `func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error)`, `func parseWildcardPEM(raw []byte) (*tls.Certificate, error)`
+ - `const wildcardRenewBefore = 30 * 24 * time.Hour`, `const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"`, `const wildcardAccountKeyName = "wildcard-account.key"`
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/edge/wildcard_test.go`:
+
+```go
+package edge
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "math/big"
+ "reflect"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+)
+
+// selfSignedWildcard mints a throwaway *.parent cert valid for the given
+// window — enough for matching/renewal/PEM tests, no ACME involved.
+func selfSignedWildcard(t *testing.T, parent string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "*." + parent},
+ DNSNames: []string{"*." + parent},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: notAfter,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ leaf, err := x509.ParseCertificate(der)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key
+}
+
+func TestParentsFor(t *testing.T) {
+ hosts := []string{
+ "kass.abs.oncarlos.com", // app host → parent abs.oncarlos.com
+ "blog.abs.oncarlos.com", // same parent, deduped
+ "echo.bab.oncarlos.com", // second account
+ "canary.kass.abs.oncarlos.com", // canary depth → no wildcard parent
+ "oncarlos.com", // apex → skipped
+ "console.example.com", // foreign domain → skipped
+ "UPPER.CASE.ONCARLOS.COM", // case-normalized → case.oncarlos.com
+ }
+ got := parentsFor([]string{"go.tito.io"}, "oncarlos.com", hosts)
+ want := []string{"abs.oncarlos.com", "bab.oncarlos.com", "case.oncarlos.com", "go.tito.io"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("parentsFor = %v, want %v", got, want)
+ }
+ // No apps domain → static only.
+ if got := parentsFor([]string{"go.tito.io"}, "", hosts); !reflect.DeepEqual(got, []string{"go.tito.io"}) {
+ t.Fatalf("static only = %v", got)
+ }
+ // Nothing at all.
+ if got := parentsFor(nil, "", nil); got != nil {
+ t.Fatalf("empty = %v, want nil", got)
+ }
+}
+
+func TestCertForMatchesExactlyOneLabel(t *testing.T) {
+ w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} },
+ autocert.DirCache(t.TempDir()), nil)
+ w.refresh()
+ cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+
+ cases := []struct {
+ sni string
+ want bool
+ }{
+ {"kass.abs.oncarlos.com", true}, // exactly one label under parent
+ {"NEW.abs.oncarlos.com.", true}, // never-deployed name, case+dot normalized: instant
+ {"abs.oncarlos.com", false}, // the parent itself (wildcard doesn't cover apex)
+ {"x.y.abs.oncarlos.com", false}, // two labels deep
+ {"kass.bab.oncarlos.com", false}, // parent without a loaded cert (bab not in parents)
+ {"slug.go.tito.io", false}, // static parent present but no cert loaded yet
+ }
+ for _, c := range cases {
+ if got := w.certFor(c.sni) != nil; got != c.want {
+ t.Errorf("certFor(%q) served=%v, want %v", c.sni, got, c.want)
+ }
+ }
+
+ // An expired wildcard is never served — fall back to per-host issuance.
+ expired, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(-time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = expired
+ w.mu.Unlock()
+ if w.certFor("kass.abs.oncarlos.com") != nil {
+ t.Fatal("expired wildcard was served")
+ }
+}
+
+func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) {
+ cert, key := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ raw, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ back, err := parseWildcardPEM(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !back.Leaf.NotAfter.Equal(cert.Leaf.NotAfter) {
+ t.Fatalf("roundtrip NotAfter = %v, want %v", back.Leaf.NotAfter, cert.Leaf.NotAfter)
+ }
+ if _, err := parseWildcardPEM([]byte("junk")); err == nil {
+ t.Fatal("parseWildcardPEM(junk): want error")
+ }
+
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, autocert.DirCache(t.TempDir()), nil)
+ if !w.needsObtain("abs.oncarlos.com", time.Now()) {
+ t.Fatal("no cert loaded: needsObtain should be true")
+ }
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+ if w.needsObtain("abs.oncarlos.com", time.Now()) {
+ t.Fatal("60 days left: needsObtain should be false")
+ }
+ if !w.needsObtain("abs.oncarlos.com", cert.Leaf.NotAfter.Add(-15*24*time.Hour)) {
+ t.Fatal("15 days left (<30): needsObtain should be true")
+ }
+}
+
+func TestLoadCachedAndRefreshWake(t *testing.T) {
+ dir := t.TempDir()
+ cache := autocert.DirCache(dir)
+ cert, key := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ raw, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), raw); err != nil {
+ t.Fatal(err)
+ }
+
+ w := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} }, cache, nil)
+ if !w.loadCached(context.Background(), "abs.oncarlos.com") {
+ t.Fatal("loadCached: want true for cached parent")
+ }
+ if w.loadCached(context.Background(), "bab.oncarlos.com") {
+ t.Fatal("loadCached: want false for uncached parent")
+ }
+
+ // refresh derives the parent and, since its cert is already loaded and
+ // fresh, does NOT wake the run loop; a parent with no cert does.
+ w.refresh()
+ select {
+ case <-w.wake:
+ t.Fatal("refresh with fresh cert should not wake")
+ default:
+ }
+ w2 := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} }, autocert.DirCache(t.TempDir()), nil)
+ w2.refresh()
+ select {
+ case <-w2.wake:
+ default:
+ t.Fatal("refresh with missing cert should wake the run loop")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestParentsFor|TestCertFor|TestWildcardPEM|TestLoadCached' -count=1`
+Expected: FAIL — `undefined: parentsFor`, `undefined: newWildcardManager`, etc.
+
+- [ ] **Step 3: Write `internal/edge/wildcard.go`** (state half; Task 4 appends the ACME flow)
+
+```go
+package edge
+
+// Wildcard TLS (ACME DNS-01) for instant app hostnames — the port of
+// Eleven's wildcard.go this file's TLS wiring always reserved space for
+// (spec: docs/superpowers/specs/2026-08-05-wildcard-certs-design.md).
+//
+// A TLS wildcard matches exactly one label, and app hosts are two labels
+// under the apps domain (<app>.<acct>.<apps-domain>), so there is no single
+// *.<apps-domain> cert. Instead one wildcard per PARENT:
+//
+// - dynamic parents <acct>.<apps-domain>, derived from the live route
+// table under PLATFORM_APPS_DOMAIN — a new account's first route starts
+// issuance immediately, so every later app under it has working TLS the
+// moment its route exists (and CT logs see the account, not app names);
+// - static parents from CARLOS_WILDCARD_DOMAINS (tito: go.tito.io, whose
+// instance hosts are one label deep).
+//
+// Canary hosts (<name>.<app>.<acct>.…, one label too deep) and custom
+// domains stay per-host HTTP-01. The wildcard only changes how a handshake
+// GETS a certificate: routing, HostPolicy and the :80 handler are
+// untouched, and every failure (DNS provider outage, ACME outage,
+// unreadable cache) degrades to exactly the per-host behaviour — never a
+// dead edge.
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdsa"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "log"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+)
+
+const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"
+
+// wildcardRenewBefore is how close to expiry we re-issue. LE certs live 90
+// days; renewing with 30 left tolerates a month of provider outage.
+const wildcardRenewBefore = 30 * 24 * time.Hour
+
+type wildcardManager struct {
+ // cache is the shared cert cache (certcache.go) — the same backend
+ // autocert uses for per-host certs, so a future pooled-edge S3 backend
+ // shares wildcards too. Keys: wildcardPEMKey / wildcardAccountKeyName.
+ cache autocert.Cache
+ dns dnsProvider
+ directory string
+
+ staticDomains []string
+ appsDomain string
+ hosts func() []string // live route hosts (Edge.currentHosts)
+
+ mu sync.Mutex
+ parents []string // last computed parent set (sorted)
+ certs map[string]*tls.Certificate
+
+ // wake nudges run() out of its 12h sleep when refresh() finds a parent
+ // with no usable cert (a new account's first route). Capacity 1: a wake
+ // is a level, not a count.
+ wake chan struct{}
+}
+
+func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []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,
+ certs: map[string]*tls.Certificate{},
+ wake: make(chan struct{}, 1),
+ }
+}
+
+// parentsFor computes the wildcard parent set: the static domains plus
+// <acct>.<appsDomain> for every route host exactly two labels under
+// appsDomain (<app>.<acct>.<appsDomain>). Canary hosts (three labels), the
+// apex, and foreign domains contribute nothing. Sorted and deduped so the
+// set is deterministic for logs and tests.
+func parentsFor(static []string, appsDomain string, hosts []string) []string {
+ seen := map[string]bool{}
+ var out []string
+ add := func(p string) {
+ if !seen[p] {
+ seen[p] = true
+ out = append(out, p)
+ }
+ }
+ for _, d := range static {
+ add(strings.ToLower(d))
+ }
+ if appsDomain != "" {
+ suffix := "." + strings.ToLower(appsDomain)
+ for _, h := range hosts {
+ rest, ok := strings.CutSuffix(strings.ToLower(h), suffix)
+ if !ok {
+ continue
+ }
+ if labels := strings.Split(rest, "."); len(labels) == 2 {
+ add(labels[1] + suffix)
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// refresh recomputes the parent set from the live route table (called from
+// the reload hook on every route swap, and once at startup). If any parent
+// lacks a usable cert it wakes run() — that is what makes a NEW account's
+// wildcard start issuing the moment its first route lands, instead of on
+// the next 12h tick.
+func (w *wildcardManager) refresh() {
+ parents := parentsFor(w.staticDomains, w.appsDomain, w.hosts())
+ now := time.Now()
+ w.mu.Lock()
+ w.parents = parents
+ need := false
+ for _, p := range parents {
+ c := w.certs[p]
+ if c == nil || now.Add(wildcardRenewBefore).After(c.Leaf.NotAfter) {
+ need = true
+ break
+ }
+ }
+ w.mu.Unlock()
+ if need {
+ select {
+ case w.wake <- struct{}{}:
+ default:
+ }
+ }
+}
+
+// certFor returns the wildcard cert for serverName, or nil when the name
+// isn't exactly one label under a current parent (a wildcard doesn't cover
+// the apex or deeper levels) or no usable cert is loaded — nil sends the
+// handshake to the per-host autocert path.
+func (w *wildcardManager) certFor(serverName string) *tls.Certificate {
+ sn := strings.ToLower(strings.TrimSuffix(serverName, "."))
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for _, p := range w.parents {
+ sub, ok := strings.CutSuffix(sn, "."+p)
+ if !ok || sub == "" || strings.Contains(sub, ".") {
+ continue
+ }
+ cert := w.certs[p]
+ if cert == nil || time.Now().After(cert.Leaf.NotAfter) {
+ // Never serve a dead cert: a wildcard whose renewal has been
+ // broken past its actual expiry falls back to per-host issuance
+ // (the pre-wildcard behaviour) instead of hard-failing every
+ // handshake under the parent.
+ return nil
+ }
+ return cert
+ }
+ return nil
+}
+
+func (w *wildcardManager) needsObtain(parent string, now time.Time) bool {
+ w.mu.Lock()
+ cert := w.certs[parent]
+ w.mu.Unlock()
+ return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter)
+}
+
+// loadCached pulls parent's wildcard from the cert cache into memory,
+// reporting whether a parseable cert was loaded. Called by run() before any
+// obtain attempt, which covers both startup (certs an earlier process
+// obtained) and a pooled-edge future (certs a sibling box obtained).
+func (w *wildcardManager) loadCached(ctx context.Context, parent string) bool {
+ raw, err := w.cache.Get(ctx, wildcardPEMKey(parent))
+ if errors.Is(err, autocert.ErrCacheMiss) {
+ return false
+ }
+ if err == nil {
+ var cert *tls.Certificate
+ if cert, err = parseWildcardPEM(raw); err == nil {
+ w.mu.Lock()
+ w.certs[parent] = cert
+ w.mu.Unlock()
+ log.Printf("edge: wildcard *.%s loaded from cache (valid until %s)", parent, cert.Leaf.NotAfter.Format(time.RFC3339))
+ return true
+ }
+ }
+ log.Printf("edge: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", parent, err)
+ return false
+}
+
+// wildcardPEMKey is the cache key for a parent's wildcard cert+key PEM —
+// Eleven's file name, so a dir cache written by that lineage reads with no
+// migration, and the keys sit additively beside autocert's per-host entries.
+func wildcardPEMKey(domain string) string {
+ return "wildcard-" + domain + ".pem"
+}
+
+// ---- PEM encode/parse (key + chain in one blob; the cert cache owns
+// placement, permissions, and write atomicity) -------------------------------
+
+func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error) {
+ var buf bytes.Buffer
+ der, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ return nil, err
+ }
+ if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil {
+ return nil, err
+ }
+ for _, c := range cert.Certificate {
+ if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil {
+ return nil, err
+ }
+ }
+ return buf.Bytes(), nil
+}
+
+func parseWildcardPEM(raw []byte) (*tls.Certificate, error) {
+ cert := &tls.Certificate{}
+ for {
+ var block *pem.Block
+ block, raw = pem.Decode(raw)
+ if block == nil {
+ break
+ }
+ switch block.Type {
+ case "EC PRIVATE KEY":
+ key, err := x509.ParseECPrivateKey(block.Bytes)
+ if err != nil {
+ return nil, err
+ }
+ cert.PrivateKey = key
+ case "CERTIFICATE":
+ cert.Certificate = append(cert.Certificate, block.Bytes)
+ }
+ }
+ if cert.PrivateKey == nil || len(cert.Certificate) == 0 {
+ return nil, errors.New("missing key or certificate in PEM")
+ }
+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
+ if err != nil {
+ return nil, err
+ }
+ cert.Leaf = leaf
+ return cert, nil
+}
+```
+
+Note: this import block is exactly what this half uses. Task 4's appended code additionally needs `crypto/elliptic`, `crypto/rand`, `crypto/x509/pkix`, `fmt`, `net`, and `golang.org/x/crypto/acme` — add them in Task 4's step.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestParentsFor|TestCertFor|TestWildcardPEM|TestLoadCached' -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Full gate + commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: wildcardManager state — dynamic parents, SNI matching, PEM cache
+
+Per-account parents derived from the route table (app.acct.domain →
+acct.domain; canary depth and apex excluded), static parents from
+CARLOS_WILDCARD_DOMAINS, refresh() wakes issuance when a parent has no
+usable cert. certFor serves exactly-one-label matches and never a dead
+cert."
+```
+
+---
+
+### Task 4: ACME DNS-01 issuance flow + run loop
+
+**Files:**
+- Modify: `internal/edge/wildcard.go` (append)
+- Test: `internal/edge/wildcard_test.go` (append)
+
+**Interfaces:**
+- Consumes: everything Task 3 produced; `w.dns.present(ctx, fqdn, value)` (Task 1 signature).
+- Produces (used by Task 5):
+ - `func (w *wildcardManager) run()` — blocking loop; caller starts it with `go w.run()`
+ - `func (w *wildcardManager) obtain(ctx context.Context, parent string) error`
+ - `func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, parent, authzURL string) error`
+ - `func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error)`
+ - `const wildcardAccountKeyName = "wildcard-account.key"`
+ - `func waitForTXT(ctx context.Context, name, value string, timeout time.Duration)`
+
+- [ ] **Step 1: Write the failing tests** (append to `internal/edge/wildcard_test.go`)
+
+```go
+// fakeDNS records present/cleanup calls — the seam obtain talks through.
+type fakeDNS struct {
+ presented []string // fqdn|value
+ cleaned []string // fqdn
+ failWith error
+}
+
+func (f *fakeDNS) present(_ context.Context, fqdn, value string) (func(context.Context) error, error) {
+ if f.failWith != nil {
+ return nil, f.failWith
+ }
+ f.presented = append(f.presented, fqdn+"|"+value)
+ return func(context.Context) error {
+ f.cleaned = append(f.cleaned, fqdn)
+ return nil
+ }, nil
+}
+
+func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) {
+ dir := t.TempDir()
+ cache := autocert.DirCache(dir)
+ w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ k1, err := w1.accountKey(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ k2, err := w2.accountKey(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !k1.Equal(k2) {
+ t.Fatal("account key not stable across managers sharing a cache")
+ }
+}
+
+// A cache backend fault must NOT mint a fresh ACME account — only a genuine
+// miss does (a fault treated as a miss would register a new account per
+// hiccup).
+type faultyCache struct{ autocert.Cache }
+
+func (f faultyCache) Get(context.Context, string) ([]byte, error) {
+ return nil, errors.New("backend down")
+}
+
+func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{})
+ if _, err := w.accountKey(context.Background()); err == nil {
+ t.Fatal("backend fault: want error, got a (freshly minted) key")
+ }
+}
+```
+
+(Requires adding `"errors"` to the test file imports.) Coverage line drawn deliberately, same place Eleven drew it: the full `obtain`/`solveDNS01`/`run` path needs a live ACME directory, which unit tests never touch — that path is exercised by the rollout verification (spec §7). The dead-context TXT cleanup is enforced structurally (the `defer` in `solveDNS01` uses a fresh 30s context, commented as such); the provider tests already prove present→cleanup ordering per backend.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestWildcardAccountKey|TestAccountKeyBackendFault' -count=1`
+Expected: FAIL — `undefined: (*wildcardManager).accountKey` (method not written), fakeDNS unused compile error is acceptable at this stage; fix by writing Step 3.
+
+- [ ] **Step 3: Append the issuance flow to `internal/edge/wildcard.go`**
+
+```go
+// run keeps the wildcard certs current: an immediate pass at startup (first
+// issuance / cache load), then every 12h, dropping to hourly retries after a
+// failure — and refresh() wakes it early when a route swap surfaces a parent
+// with no usable cert (a new account).
+func (w *wildcardManager) run() {
+ for {
+ delay := 12 * time.Hour
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ for _, p := range parents {
+ if !w.needsObtain(p, time.Now()) {
+ continue
+ }
+ // Another process (or pool sibling) may have obtained it already.
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ w.loadCached(ctx, p)
+ cancel()
+ if !w.needsObtain(p, time.Now()) {
+ continue
+ }
+ ctx, cancel = context.WithTimeout(context.Background(), 10*time.Minute)
+ err := w.obtain(ctx, p)
+ cancel()
+ if err != nil {
+ log.Printf("edge: wildcard *.%s: %v — per-host issuance keeps covering; retrying in 1h", p, err)
+ delay = time.Hour
+ continue
+ }
+ w.mu.Lock()
+ cert := w.certs[p]
+ w.mu.Unlock()
+ log.Printf("edge: wildcard *.%s obtained (valid until %s)", p, cert.Leaf.NotAfter.Format(time.RFC3339))
+ }
+ select {
+ case <-time.After(delay):
+ case <-w.wake:
+ }
+ }
+}
+
+// obtain runs one full ACME DNS-01 issuance for *.parent and swaps the
+// result in.
+func (w *wildcardManager) obtain(ctx context.Context, parent string) error {
+ acctKey, err := w.accountKey(ctx)
+ if err != nil {
+ return fmt.Errorf("account key: %v", err)
+ }
+ client := &acme.Client{Key: acctKey, DirectoryURL: w.directory}
+ if _, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists {
+ return fmt.Errorf("acme register: %v", err)
+ }
+
+ order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: "*." + parent}})
+ if err != nil {
+ return fmt.Errorf("authorize order: %v", err)
+ }
+ for _, zurl := range order.AuthzURLs {
+ if err := w.solveDNS01(ctx, client, parent, zurl); err != nil {
+ return err
+ }
+ }
+
+ certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return err
+ }
+ csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
+ Subject: pkix.Name{CommonName: "*." + parent},
+ DNSNames: []string{"*." + parent},
+ }, certKey)
+ if err != nil {
+ return fmt.Errorf("csr: %v", err)
+ }
+ ders, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
+ if err != nil {
+ return fmt.Errorf("finalize: %v", err)
+ }
+ leaf, err := x509.ParseCertificate(ders[0])
+ if err != nil {
+ return fmt.Errorf("parse issued cert: %v", err)
+ }
+ cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf}
+ raw, err := encodeWildcardPEM(cert, certKey)
+ if err != nil {
+ return fmt.Errorf("encode: %v", err)
+ }
+ if err := w.cache.Put(ctx, wildcardPEMKey(parent), raw); err != nil {
+ return fmt.Errorf("persist: %v", err)
+ }
+ w.mu.Lock()
+ w.certs[parent] = cert
+ w.mu.Unlock()
+ return nil
+}
+
+// solveDNS01 answers one authorization: publish the challenge TXT record,
+// wait for the CA to see it, and always clean the record up afterwards.
+func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, parent, authzURL string) error {
+ authz, err := client.GetAuthorization(ctx, authzURL)
+ if err != nil {
+ return fmt.Errorf("get authorization: %v", err)
+ }
+ if authz.Status == acme.StatusValid {
+ return nil // a recent order already proved this name
+ }
+ var chal *acme.Challenge
+ for _, c := range authz.Challenges {
+ if c.Type == "dns-01" {
+ chal = c
+ break
+ }
+ }
+ if chal == nil {
+ return errors.New("CA offered no dns-01 challenge")
+ }
+ txt, err := client.DNS01ChallengeRecord(chal.Token)
+ if err != nil {
+ return err
+ }
+
+ challengeFQDN := "_acme-challenge." + parent
+ cleanup, err := w.dns.present(ctx, challengeFQDN, txt)
+ if err != nil {
+ return fmt.Errorf("publish challenge TXT: %v", err)
+ }
+ // Clean up with a fresh context: the record must not outlive the attempt
+ // even when ctx is already dead (stale challenge TXTs break the NEXT run).
+ defer func() {
+ cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := cleanup(cctx); err != nil {
+ log.Printf("edge: wildcard *.%s: cleanup of challenge TXT failed: %v", parent, err)
+ }
+ }()
+
+ // Best-effort: give the record a moment to reach the authoritative NS
+ // before telling the CA to look. Proceed on timeout — the CA does its own
+ // retries, and a hard failure just comes back through the retry loop.
+ waitForTXT(ctx, challengeFQDN, txt, time.Minute)
+
+ if _, err := client.Accept(ctx, chal); err != nil {
+ return fmt.Errorf("accept challenge: %v", err)
+ }
+ if _, err := client.WaitAuthorization(ctx, authz.URI); err != nil {
+ return fmt.Errorf("authorization: %v", err)
+ }
+ return nil
+}
+
+// wildcardAccountKeyName is the ACME account key's cache key — Eleven's file
+// name, for the same no-migration reason as wildcardPEMKey.
+const wildcardAccountKeyName = "wildcard-account.key"
+
+// accountKey loads (or mints) the ACME account key. Separate from autocert's
+// account on purpose: this flow manages its own registration lifecycle.
+func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error) {
+ raw, err := w.cache.Get(ctx, wildcardAccountKeyName)
+ if err == nil {
+ block, _ := pem.Decode(raw)
+ if block == nil {
+ return nil, fmt.Errorf("%s: not PEM", wildcardAccountKeyName)
+ }
+ return x509.ParseECPrivateKey(block.Bytes)
+ }
+ if !errors.Is(err, autocert.ErrCacheMiss) {
+ // Only a genuine miss mints a key: treating a backend fault as "no
+ // key yet" would register a fresh ACME account on every hiccup. Fail
+ // and let obtain()'s hourly retry come back around.
+ return nil, err
+ }
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+ der, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ return nil, err
+ }
+ // Crash-safety (temp + rename, 0600) is the cache backend's contract —
+ // the dir backend inherits it from autocert.DirCache.
+ raw = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
+ if err := w.cache.Put(ctx, wildcardAccountKeyName, raw); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+// waitForTXT polls DNS until name resolves to a TXT containing value, or the
+// deadline passes (best-effort — see caller).
+func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) && ctx.Err() == nil {
+ if txts, err := net.DefaultResolver.LookupTXT(ctx, name); err == nil {
+ for _, t := range txts {
+ if t == value {
+ return
+ }
+ }
+ }
+ time.Sleep(3 * time.Second)
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -count=1`
+Expected: PASS (whole package)
+
+- [ ] **Step 5: Full gate + commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: wildcard ACME DNS-01 issuance — obtain, solve, run loop
+
+Eleven's flow: dedicated account key (minted only on a genuine cache
+miss), challenge TXT published through the provider seam with always-runs
+cleanup on a fresh context, 12h loop with 1h failure retries, refresh()
+wake for new accounts."
+```
+
+---
+
+### Task 5: Wire into the edge's TLS path
+
+**Files:**
+- Modify: `internal/edge/edge.go` (Edge struct: reload hook; `RunWithActivator`: wildcard setup replacing the "drops in here" comment; package header comment)
+- Test: `internal/edge/wildcard_test.go` (append)
+
+**Interfaces:**
+- Consumes: Tasks 1-4; `Edge.current()`, `Edge.Reload()`, `newCertCache`.
+- Produces:
+ - `func (e *Edge) OnReload(fn func())` — registers a hook run after every successful route swap (atomic pointer, so registration after Watch has started is race-free)
+ - `func (e *Edge) currentHosts() []string`
+ - `func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager` — nil when unconfigured (logged reason)
+
+- [ ] **Step 1: Write the failing tests** (append to `internal/edge/wildcard_test.go`)
+
+```go
+func TestWrappedGetCertificateFallsThrough(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com",
+ func() []string { return []string{"kass.abs.oncarlos.com"} },
+ autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
+ w.mu.Lock()
+ w.certs["abs.oncarlos.com"] = cert
+ w.mu.Unlock()
+
+ fallbackHit := ""
+ inner := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ fallbackHit = hello.ServerName
+ return nil, errors.New("inner path")
+ }
+ wrapped := wrapGetCertificate(w, inner)
+
+ got, err := wrapped(&tls.ClientHelloInfo{ServerName: "new.abs.oncarlos.com"})
+ if err != nil || got != cert {
+ t.Fatalf("covered name: got (%v, %v), want the wildcard", got, err)
+ }
+ if fallbackHit != "" {
+ t.Fatalf("covered name reached the inner path (%q)", fallbackHit)
+ }
+ if _, err := wrapped(&tls.ClientHelloInfo{ServerName: "custom.example.com"}); err == nil || fallbackHit != "custom.example.com" {
+ t.Fatalf("uncovered name must fall through to inner; fallbackHit=%q err=%v", fallbackHit, err)
+ }
+}
+
+func TestNewWildcardFromEnv(t *testing.T) {
+ e := &Edge{}
+ e.upstreams.Store(&map[string]upstream{})
+ cache := autocert.DirCache(t.TempDir())
+
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "")
+ t.Setenv("PLATFORM_APPS_DOMAIN", "")
+ t.Setenv("CARLOS_DNS_PROVIDER", "")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "")
+ if w := newWildcardFromEnv(e, cache); w != nil {
+ t.Fatal("nothing configured: want nil (per-host only, the historical default)")
+ }
+ // Domains but no provider → off (logged), never a broken half-config.
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ if w := newWildcardFromEnv(e, cache); w != nil {
+ t.Fatal("domains without provider: want nil")
+ }
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
+ w := newWildcardFromEnv(e, cache)
+ if w == nil {
+ t.Fatal("configured: want a manager")
+ }
+ if !reflect.DeepEqual(w.staticDomains, []string{"go.tito.io"}) {
+ t.Fatalf("staticDomains = %v", w.staticDomains)
+ }
+}
+
+func TestOnReloadHookFires(t *testing.T) {
+ reg := openReg(t) // edge_test.go's existing registry fixture
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fired := 0
+ e.OnReload(func() { fired++ })
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if fired != 1 {
+ t.Fatalf("hook fired %d times, want 1", fired)
+ }
+}
+```
+
+Check `edge_test.go` for the existing helper that builds a `*registry.Registry` for tests (there is one — `New` is exercised there) and use its actual name in `TestOnReloadHookFires`.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestWrappedGetCertificate|TestNewWildcardFromEnv|TestOnReloadHook' -count=1`
+Expected: FAIL — `undefined: wrapGetCertificate`, `undefined: newWildcardFromEnv`, `(*Edge).OnReload undefined`.
+
+- [ ] **Step 3: Implement the wiring**
+
+In `internal/edge/edge.go`, add to the `Edge` struct (after `purgeLogN`):
+
+```go
+ // reloadHook, when set (OnReload), runs after every successful route
+ // swap — the wildcard manager recomputes its parent set from it. Atomic
+ // because registration happens in RunWithActivator after Watch may
+ // already be reloading.
+ reloadHook atomic.Pointer[func()]
+```
+
+Add near `Reload`:
+
+```go
+// OnReload registers fn to run after every successful Reload — after the new
+// route set is swapped in, so fn sees the post-reload table via current().
+func (e *Edge) OnReload(fn func()) {
+ e.reloadHook.Store(&fn)
+}
+```
+
+At the end of `Reload()` (immediately before its final `return nil`, after the upstreams swap and any retire pass):
+
+```go
+ if fn := e.reloadHook.Load(); fn != nil {
+ (*fn)()
+ }
+```
+
+Add near `current()`:
+
+```go
+// currentHosts lists the hosts currently served — the wildcard manager's
+// view of the route table for parent derivation.
+func (e *Edge) currentHosts() []string {
+ m := e.current()
+ out := make([]string, 0, len(m))
+ for h := range m {
+ out = append(out, h)
+ }
+ return out
+}
+```
+
+In `internal/edge/wildcard.go`, add:
+
+```go
+// newWildcardFromEnv builds the manager from the environment, or returns nil
+// when wildcards are off: no CARLOS_WILDCARD_DOMAINS and no
+// PLATFORM_APPS_DOMAIN (nothing to cover — silent, the historical default),
+// or no DNS provider configured (logged: half a config deserves a reason in
+// the journal). nil means RunWithActivator wires nothing and the edge
+// behaves exactly as before this feature existed.
+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
+ }
+ return newWildcardManager(static, appsDomain, e.currentHosts, cache, dns)
+}
+
+// wrapGetCertificate serves the wildcard for covered names and hands
+// everything else to inner (autocert's per-host path) — split out so the
+// fall-through contract is testable without a TLS listener.
+func wrapGetCertificate(w *wildcardManager, inner func(*tls.ClientHelloInfo) (*tls.Certificate, error)) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+ return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ if c := w.certFor(hello.ServerName); c != nil {
+ return c, nil
+ }
+ return inner(hello)
+ }
+}
+```
+
+(`os` joins wildcard.go's imports.) In `RunWithActivator`, replace the drop-in comment block:
+
+```go
+ // Per-host HTTP-01 only for now. When per-tenant subdomains outgrow the
+ // Let's Encrypt per-domain rate limit (~50 new certs/week), Eleven's
+ // wildcard.go (ACME DNS-01) drops in here, wrapping tlsCfg.GetCertificate.
+ tlsCfg := m.TLSConfig()
+```
+
+with:
+
+```go
+ tlsCfg := m.TLSConfig()
+ // Wildcard certs (wildcard.go, ACME DNS-01): covered names — one label
+ // under a per-account or configured parent — are served the wildcard;
+ // everything else (custom domains, canary hosts, parents not yet
+ // obtained) stays on autocert's per-host HTTP-01 above. nil = not
+ // configured = exactly the pre-wildcard edge.
+ if w := newWildcardFromEnv(e, certCache); w != nil {
+ tlsCfg.GetCertificate = wrapGetCertificate(w, tlsCfg.GetCertificate)
+ e.OnReload(w.refresh)
+ w.refresh()
+ go w.run()
+ log.Printf("edge: wildcard certs on (static %v, apps domain %q)",
+ splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"))
+ }
+```
+
+Update the package header comment (edge.go line ~14): change "Dropped on port: … Eleven's mesh and wildcard DNS-01 certs." to note the wildcard came back: "Dropped on port: titogo's Stripe relay, auth/identity, root-domain platformIndex; Eleven's mesh (its wildcard DNS-01 certs were later ported back — wildcard.go)."
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Full gate + commit**
+
+```bash
+git add internal/edge/edge.go internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: serve wildcard certs from the TLS path
+
+GetCertificate tries the wildcard first and falls through to autocert
+per-host HTTP-01; OnReload recomputes parents on every route swap so a
+new account's wildcard starts issuing the moment its first route lands.
+Unconfigured = the pre-wildcard edge, byte for byte."
+```
+
+---
+
+### Task 6: certExpiry learns the wildcard fallback (the consumer fix)
+
+**Files:**
+- Modify: `internal/domains/reconcile.go` (`certExpiry` call site ~line 314-317; add `wildcardParent`)
+- Test: `internal/domains/reconcile_test.go` (append)
+
+**Interfaces:**
+- Consumes: `certExpiry(path string) int64` (exists), `Config.AcmeDir`, `Config.AppsDomain`.
+- Produces: `func wildcardParent(host, appsDomain string) string` — `""` when no wildcard could cover host.
+
+- [ ] **Step 1: Write the failing test** (append to `internal/domains/reconcile_test.go`; match the file's existing test helpers for Config/store setup — read it first and reuse its fixture style)
+
+```go
+func TestWildcardParent(t *testing.T) {
+ cases := []struct {
+ host, appsDomain, want string
+ }{
+ {"kass.abs.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
+ {"canary.kass.abs.oncarlos.com", "oncarlos.com", ""}, // canary depth: not covered
+ {"abs.oncarlos.com", "oncarlos.com", ""}, // apex of a parent
+ {"custom.example.com", "oncarlos.com", ""}, // foreign domain
+ {"kass.abs.oncarlos.com", "", ""}, // no apps domain configured
+ {"Kass.ABS.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
+ }
+ for _, c := range cases {
+ if got := wildcardParent(c.host, c.appsDomain); got != c.want {
+ t.Errorf("wildcardParent(%q, %q) = %q, want %q", c.host, c.appsDomain, got, c.want)
+ }
+ }
+}
+
+// A wildcard-covered host has no per-host acme cache entry — its expiry must
+// come from the covering wildcard-<parent>.pem instead of reporting 0
+// (certless) forever.
+func TestCertExpiryWildcardFallback(t *testing.T) {
+ dir := t.TempDir()
+ // certExpiry parses "key PEM block(s) first, then CERTIFICATE" — build a
+ // minimal self-signed cert the same way the edge's wildcard PEM is laid
+ // out. Reuse/adapt the cert-building helper if reconcile_test.go already
+ // has one; otherwise inline ecdsa+x509 self-sign exactly as in
+ // internal/edge/wildcard_test.go's selfSignedWildcard.
+ notAfter := time.Now().Add(45 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, "wildcard-abs.oncarlos.com.pem"), "*.abs.oncarlos.com", notAfter)
+
+ host := "kass.abs.oncarlos.com"
+ if got := hostCertExpiry(dir, host, "oncarlos.com"); got != notAfter.Unix() {
+ t.Fatalf("hostCertExpiry = %d, want %d (wildcard fallback)", got, notAfter.Unix())
+ }
+ // A per-host entry, when present, still wins.
+ perHost := time.Now().Add(80 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, host), host, perHost)
+ if got := hostCertExpiry(dir, host, "oncarlos.com"); got != perHost.Unix() {
+ t.Fatalf("hostCertExpiry with per-host file = %d, want %d", got, perHost.Unix())
+ }
+ // Nothing at all → 0, unknown, never a guess.
+ if got := hostCertExpiry(dir, "kass.zzz.oncarlos.com", "oncarlos.com"); got != 0 {
+ t.Fatalf("hostCertExpiry with no files = %d, want 0", got)
+ }
+}
+```
+
+`writeWildcardPEM` is a test helper in this file (the edge package's equivalents aren't importable):
+
+```go
+// writeWildcardPEM writes a self-signed cert for name valid until notAfter,
+// in the acme-cache bundle layout certExpiry parses: key block first, then
+// the certificate.
+func writeWildcardPEM(t *testing.T, path, name string, notAfter time.Time) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: name},
+ DNSNames: []string{name},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: notAfter,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var buf bytes.Buffer
+ if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
+ t.Fatal(err)
+ }
+ if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+```
+
+(Adds `bytes`, `crypto/ecdsa`, `crypto/elliptic`, `crypto/rand`, `crypto/x509`, `crypto/x509/pkix`, `encoding/pem`, `math/big`, `os`, `path/filepath`, `time` to the test file's imports as needed.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/domains/ -run 'TestWildcardParent|TestCertExpiryWildcardFallback' -count=1`
+Expected: FAIL — `undefined: wildcardParent`, `undefined: hostCertExpiry`.
+
+- [ ] **Step 3: Implement in `internal/domains/reconcile.go`**
+
+Add beside `certExpiry`:
+
+```go
+// wildcardParent returns the parent domain whose per-account wildcard cert
+// would cover host — host exactly two labels under appsDomain
+// (<app>.<acct>.<appsDomain>) → <acct>.<appsDomain> — or "" when no wildcard
+// could cover it (canary depth, a parent apex, a foreign domain, no apps
+// domain configured).
+func wildcardParent(host, appsDomain string) string {
+ if appsDomain == "" {
+ return ""
+ }
+ suffix := "." + strings.ToLower(appsDomain)
+ rest, ok := strings.CutSuffix(strings.ToLower(host), suffix)
+ if !ok {
+ return ""
+ }
+ if labels := strings.Split(rest, "."); len(labels) == 2 {
+ return labels[1] + suffix
+ }
+ return ""
+}
+
+// hostCertExpiry resolves host's cert expiry from the acme cache dir: the
+// per-host autocert entry when it exists, else the covering per-account
+// wildcard's PEM (wildcard-<parent>.pem — a wildcard-covered host never gets
+// a per-host entry, and without this fallback status would call it certless
+// forever). 0 = unknown, never a guess.
+func hostCertExpiry(acmeDir, host, appsDomain string) int64 {
+ if at := certExpiry(filepath.Join(acmeDir, host)); at != 0 {
+ return at
+ }
+ if parent := wildcardParent(host, appsDomain); parent != "" {
+ return certExpiry(filepath.Join(acmeDir, "wildcard-"+parent+".pem"))
+ }
+ return 0
+}
+```
+
+At the call site (currently `at := certExpiry(filepath.Join(cfg.AcmeDir, rt.Host))` around line 316), replace with:
+
+```go
+ at := hostCertExpiry(cfg.AcmeDir, rt.Host, cfg.AppsDomain)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/domains/ -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Full gate + commit**
+
+```bash
+git add internal/domains/reconcile.go internal/domains/reconcile_test.go
+git commit -m "domains: cert expiry falls back to the covering wildcard PEM
+
+certExpiry parses the acme cache dir shape, which makes it a consumer of
+the edge's new wildcard-<parent>.pem entries: a wildcard-covered host
+never gets a per-host cache file, and without the fallback status would
+report it certless forever."
+```
+
+---
+
+### Task 7: region-host IAM — optional Route53 statement
+
+**Files:**
+- Modify: `infra/modules/region-host/variables.tf`, `infra/modules/region-host/main.tf`, `infra/modules/region-host/README.md` (if the module has one; skip otherwise)
+
+**Interfaces:**
+- Produces: module variable `route53_zone_ids` (`list(string)`, default `[]`) — hosted-zone IDs the host's role may write DNS-01 challenge records into. Tito's stack (operator tofu queue) will set it to the `tito.io` zone id; flagship passes nothing and its role is untouched.
+
+- [ ] **Step 1: Add the variable** (`variables.tf`)
+
+```hcl
+variable "route53_zone_ids" {
+ description = "Hosted-zone IDs the host may write ACME DNS-01 challenge records into (wildcard certs, CARLOS_DNS_PROVIDER=route53). Empty = no Route53 access."
+ type = list(string)
+ default = []
+}
+```
+
+- [ ] **Step 2: Add the conditional policy** (`main.tf`, after `aws_iam_role_policy.host_buckets`)
+
+```hcl
+# Wildcard-cert DNS-01 (edge wildcard.go, CARLOS_DNS_PROVIDER=route53):
+# write challenge TXTs into the named zones only. ListHostedZonesByName has
+# no resource-level scoping in IAM, hence the split statement.
+resource "aws_iam_role_policy" "host_route53" {
+ count = length(var.route53_zone_ids) > 0 ? 1 : 0
+ name = "${var.iam_name}-route53"
+ role = aws_iam_role.host.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["route53:ChangeResourceRecordSets"]
+ Resource = [for z in var.route53_zone_ids : "arn:aws:route53:::hostedzone/${z}"]
+ },
+ {
+ Effect = "Allow"
+ Action = ["route53:GetChange"]
+ Resource = "arn:aws:route53:::change/*"
+ },
+ {
+ Effect = "Allow"
+ Action = ["route53:ListHostedZonesByName"]
+ Resource = "*"
+ }
+ ]
+ })
+}
+```
+
+- [ ] **Step 3: Validate**
+
+Run: `tofu -chdir=infra/modules/region-host fmt -check && tofu -chdir=infra/modules/region-host validate` (validate needs an `init`; if the module has never been init'd standalone, `tofu -chdir=infra/modules/region-host init -backend=false` first. If tofu isn't runnable in the sandbox, `fmt -check` alone is acceptable — the module is exercised by the deployment stacks.)
+Expected: formatted, valid.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add infra/modules/region-host/
+git commit -m "infra/region-host: optional zone-scoped Route53 DNS-01 policy
+
+route53_zone_ids (default empty = no statement, flagship untouched);
+tito's stack names the tito.io zone so the box role can write
+_acme-challenge TXTs for *.go.tito.io — no DNS token on disk."
+```
+
+---
+
+### Task 8: Docs + full gate
+
+**Files:**
+- Modify: `internal/host/agent.go` (env documentation comment block, ~line 54)
+- Modify: `docs/superpowers/specs/2026-08-05-wildcard-certs-design.md` (status line only)
+
+**Interfaces:** none — documentation.
+
+- [ ] **Step 1: Document the new env vars in agent.go's env comment block**
+
+Find the comment block listing agent environment (starts around `agent.go:54` with `// - PLATFORM_APPS_DOMAIN — the deployment's platform apps domain`) and add, matching its comment style exactly:
+
+```go
+// - CARLOS_WILDCARD_DOMAINS — comma list of static wildcard-cert parent
+// domains for the edge (tito: go.tito.io); per-account parents under
+// PLATFORM_APPS_DOMAIN are derived automatically (edge wildcard.go)
+// - CARLOS_DNS_PROVIDER / CARLOS_DNSIMPLE_TOKEN — DNS-01 backend for
+// wildcard certs: unset/dnsimple needs the token (SSM → unit env),
+// route53 rides the box IAM role; nothing set = wildcards off,
+// per-host HTTP-01 only
+```
+
+- [ ] **Step 2: Mark the spec implemented**
+
+In `docs/superpowers/specs/2026-08-05-wildcard-certs-design.md`, change `**Status:** Approved` to `**Status:** Implemented (this branch)`.
+
+- [ ] **Step 3: Full gate across everything**
+
+Run: `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`
+Expected: all 14+ packages pass.
+
+Run: `gofmt -l internal/edge internal/domains internal/host`
+Expected: no output (the three pre-existing unformatted files elsewhere are left alone).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/host/agent.go docs/superpowers/specs/2026-08-05-wildcard-certs-design.md
+git commit -m "docs: wildcard-cert env vocabulary in the agent env block"
+```
+
+---
+
+## Out of scope (deliberately)
+
+- **Deploy execution** (spec §7): SSM token placement, unit env changes, agent restarts, and the tito tofu apply are operator/deploy steps after merge — not code in this branch.
+- **S3 cert-cache backend**: still reserved (`CARLOS_CERT_CACHE=s3` refuses to start); wildcards go through the same seam and inherit it when it lands.
+- **Per-app wildcards for canary hosts**: rejected in brainstorming — canaries stay per-host HTTP-01.
+- **Eleven's `upsertA`/relocation**: not ported; relocation is not a CARLOS feature today.