plan: route extra hostnames implementation plan (#151)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+546
−0
docs/superpowers/plans/2026-08-12-route-extra-hostnames.md+546 −0
diff --git a/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md b/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md| new file mode 100644 |
| index 000000000..bf2ba15b6 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md |
| @@ -0,0 +1,546 @@ |
| +# Route Extra Hostnames (#151) 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:** Attach/detach an extra public hostname on an existing route: global claim record in the bucket → derived `route_aliases` registry table → edge serves the alias off the primary route's own upstream entry, with HTTP-01 certs, identical hibernation wake, a console API door (app-token capable), and a `carlos domains` CLI verb. |
| + |
| +**Architecture:** Desired state is one global create-only object per hostname (`domains/<host>.json`) — the bucket key is the cross-account uniqueness gate. A box reconcile pass derives `route_aliases` rows (wholly pass-owned, no ownership flag). The edge maps each alias to the primary's identical `upstream` value at Reload. Console door writes claims + bumps `control/serial`; boxes converge in ~2s. |
| + |
| +**Tech Stack:** Go stdlib, modernc.org/sqlite (registry), existing `release.Store` bucket abstraction. No new dependencies. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md` — read it first. |
| + |
| +## Global Constraints |
| + |
| +- Worktree: `/home/paulca/github.com/carlosframework/platform/.claude/worktrees/route-hostnames`, branch `worktree-route-hostnames`. NEVER touch the primary checkout (another session owns it). |
| +- Go commands need: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`, and may need the sandbox disabled ("read-only file system" is the tell). |
| +- Before any commit: the touched packages' tests pass; before the final commit: `go build ./... && go vet ./... && go test ./... -count=1` all green, `gofmt -l` clean for touched files, and `GOOS=darwin go build ./...` compiles (the CLI runs on Macs). |
| +- Wire shapes are additive: claim schema is 1; refuse `Schema > 1` on read (the `GetManifestETag` precedent); never repurpose a field. |
| +- `Fingerprint()` additions go in a SEPARATE second query/hash section — do NOT edit the existing routes SELECT (agreed with the HA session; their #152 columns land on that line). |
| +- Do NOT hash `dns_status` / `dns_checked_at` / `cert_expires_at` into the fingerprint — stamping runs every 5 minutes and would otherwise force a pointless edge reload each pass (the routes section deliberately omits the same columns). |
| +- The alias's `upstream` entry must be the primary's identical value (shared `proxy` handler), never a rebuilt proxy or copied Addr — #152 composition depends on it. |
| +- Neutral errors only for cross-tenant probes: "hostname unavailable" (409), never who owns it; detach of unknown/other-owned is a quiet 200. |
| +- Commit after every task, message style: `feat: <what> (#151)` / `test: ...`; end commit messages with the Co-Authored-By trailer from CLAUDE.md. |
| + |
| +--- |
| + |
| +### Task 1: Claim records (`internal/domains/claims.go`) |
| + |
| +**Files:** |
| +- Create: `internal/domains/claims.go` |
| +- Test: `internal/domains/claims_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `release.Store` (`internal/release/store.go:42` — `Get/Put/List/Delete`), `release.ErrCASConflict`, `provision.ValidHostShape` (`internal/provision/provision.go:131`). |
| +- Produces (later tasks rely on these exact names): |
| + - `const ClaimsPrefix = "domains/"` |
| + - `func ClaimKey(host string) string` → `"domains/" + host + ".json"` |
| + - `type Claim struct { Schema int; Host, Account, App, Route, CreatedAt, CreatedBy string }` (JSON tags: `schema`, `host`, `account`, `app`, `route,omitempty`, `created_at`, `created_by,omitempty`) |
| + - `var ErrHostUnavailable = errors.New("hostname unavailable")` |
| + - `func AttachClaim(ctx context.Context, s release.Store, c Claim) (created bool, err error)` |
| + - `func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (removed bool, err error)` |
| + - `func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error)` |
| + - `func ListClaims(ctx context.Context, s release.Store) ([]Claim, error)` |
| + - `func NormalizeAliasHost(raw string) (string, bool)` — lowercase, strip one trailing dot, then require `provision.ValidHostShape` AND at least one interior dot (a bare label is never a public hostname) |
| + - `func HostDenied(host string, deny []string) bool` — true when host equals any deny entry or ends in `"."+entry` (empty entries skipped) |
| + |
| +- [ ] **Step 1: Write the failing tests** — `internal/domains/claims_test.go`, using `release.NewDirStore(t.TempDir())` (the `serial_test.go` precedent). Cases: |
| + |
| +```go |
| +func TestAttachClaimCreates(t *testing.T) // fresh attach: created=true, GetClaim round-trips all fields |
| +func TestAttachClaimIdempotent(t *testing.T) // same owner, same route: created=false, nil error, object unchanged |
| +func TestAttachClaimConvergesRoute(t *testing.T) // same owner, new route: created=false, stored Route updated |
| +func TestAttachClaimOtherOwnerRefused(t *testing.T) // different account or app: errors.Is(err, ErrHostUnavailable), object unchanged |
| +func TestAttachClaimNewerSchemaRefused(t *testing.T) // stored {"schema":2}: error mentioning schema, object unchanged |
| +func TestDetachClaimRemoves(t *testing.T) // owner detach: removed=true, GetClaim → os.ErrNotExist |
| +func TestDetachClaimUnknownQuiet(t *testing.T) // no claim: removed=false, nil error |
| +func TestDetachClaimOtherOwnerQuiet(t *testing.T) // other owner: removed=false, nil error, object still there |
| +func TestListClaimsSkipsGarbage(t *testing.T) // a domains/ key with invalid JSON is skipped, valid ones returned |
| +func TestNormalizeAliasHost(t *testing.T) // "Tickets.Example.COM." → "tickets.example.com", ok; "localhost" (no dot) → !ok; "a..b" → !ok; "" → !ok; 254 chars → !ok |
| +func TestHostDenied(t *testing.T) // ("x.oncarlos.com", ["oncarlos.com"]) true; ("oncarlos.com", same) true; ("xoncarlos.com", same) false; empty deny entry never matches |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify failure** — `go test ./internal/domains/ -run 'Claim|AliasHost|HostDenied' -count=1`. Expected: compile error (`claims.go` doesn't exist). |
| + |
| +- [ ] **Step 3: Implement `claims.go`.** AttachClaim semantics (comment them in the file): |
| + |
| +```go |
| +// AttachClaim writes host's global claim, create-only by key: the bucket |
| +// key IS the cross-account uniqueness gate. Same owner re-attaching is |
| +// idempotent (converging Route in place); any other owner gets |
| +// ErrHostUnavailable — deliberately neutral, never naming the holder. |
| +func AttachClaim(ctx context.Context, s release.Store, c Claim) (bool, error) { |
| + c.Schema = 1 |
| + body, err := json.Marshal(c) |
| + if err != nil { |
| + return false, err |
| + } |
| + for attempt := 0; ; attempt++ { |
| + cur, etag, err := getClaimETag(ctx, s, c.Host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), body, ""); err == nil { |
| + return true, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue // lost the create race — re-read and judge the winner |
| + } else { |
| + return false, err |
| + } |
| + } else if err != nil { |
| + return false, err |
| + } |
| + if cur.Account != c.Account || cur.App != c.App { |
| + return false, ErrHostUnavailable |
| + } |
| + if cur.Route == c.Route { |
| + return false, nil |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), body, etag); err == nil { |
| + return false, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue // converge race — one retry, then surface |
| + } else { |
| + return false, err |
| + } |
| + } |
| +} |
| +``` |
| + |
| +`getClaimETag` is the unexported Get+parse (refusing `Schema > 1` with the `GetManifestETag`-style error); `GetClaim` wraps it. `DetachClaim`: get; `os.ErrNotExist` → `(false, nil)`; owner mismatch → `(false, nil)`; else `s.Delete` → `(true, nil)`. `ListClaims`: `s.List(ctx, ClaimsPrefix)`, parse each, skip unparseable/newer-schema with a `log.Printf`. `NormalizeAliasHost`: `strings.ToLower`, `strings.TrimSuffix(h, ".")` once, then `provision.ValidHostShape(h) && strings.Contains(h, ".")`. |
| + |
| +- [ ] **Step 4: Run to verify pass** — same command. Expected: PASS. |
| +- [ ] **Step 5: Commit** — `git add internal/domains/claims.go internal/domains/claims_test.go && git commit -m "feat: global hostname claim records (#151)"`. |
| + |
| +--- |
| + |
| +### Task 2: Registry `route_aliases` table |
| + |
| +**Files:** |
| +- Modify: `internal/registry/registry.go` — `migrate()` (after the routes ALTER block, `registry.go:291-360`), `Fingerprint()` (append a section AFTER the `fleet_boxes` section, `registry.go:1277-1295`) |
| +- Test: `internal/registry/registry_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `type RouteAlias struct { Alias, Host, Account, App, DNSStatus string; DNSCheckedAt, CertExpiresAt, CreatedAt int64 }` |
| + - `func (r *Registry) ListAliases() ([]RouteAlias, error)` (ordered by alias) |
| + - `func (r *Registry) UpsertAlias(a RouteAlias) error` — `ON CONFLICT(alias) DO UPDATE SET host,account,app` only (stamps survive re-upsert; CreatedAt kept from first insert) |
| + - `func (r *Registry) DeleteAlias(alias string) error` (idempotent) |
| + - `func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error` |
| + - `func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error` |
| + |
| +- [ ] **Step 1: Write the failing tests** (follow the file's existing open-registry helper): |
| + |
| +```go |
| +func TestRouteAliasCRUD(t *testing.T) // upsert, list round-trip, re-upsert repoints host but keeps stamps+created_at, delete idempotent |
| +func TestRouteAliasStamps(t *testing.T) // SetAliasDNSStatus / SetAliasCertExpiresAt persist and survive UpsertAlias |
| +func TestFingerprintCoversAliases(t *testing.T) // fp1; UpsertAlias → fp2 != fp1; SetAliasDNSStatus → fp3 == fp2 (stamps NEVER move the fingerprint); DeleteAlias → fp4 != fp2 |
| +func TestMigrateIdempotentWithAliases(t *testing.T) // open the same db path twice; second open succeeds |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure** — `go test ./internal/registry/ -run 'Alias|FingerprintCovers' -count=1`. Expected: compile error. |
| +- [ ] **Step 3: Implement.** DDL in `migrate()`: |
| + |
| +```go |
| +`CREATE TABLE IF NOT EXISTS route_aliases ( |
| + alias TEXT PRIMARY KEY, |
| + host TEXT NOT NULL, |
| + account TEXT NOT NULL DEFAULT '', |
| + app TEXT NOT NULL DEFAULT '', |
| + dns_status TEXT NOT NULL DEFAULT '', |
| + dns_checked_at INTEGER NOT NULL DEFAULT 0, |
| + cert_expires_at INTEGER NOT NULL DEFAULT 0, |
| + created_at INTEGER NOT NULL DEFAULT 0 |
| +)` |
| +``` |
| + |
| +Fingerprint addition — a new section appended after `fleet_boxes`, same shape (comment why stamps are excluded, mirroring the routes section's own omission): |
| + |
| +```go |
| +fmt.Fprint(h, "\x1droute_aliases\x1e") |
| +arows, err := db.Query(`SELECT alias, host, account, app FROM route_aliases ORDER BY alias`) |
| +// scan alias, host, account, app; Fprintf "%s\x1f%s\x1f%s\x1f%s\x1e" |
| +``` |
| + |
| +- [ ] **Step 4: Verify pass** — same command, then `go test ./internal/registry/ -count=1` (whole package: the fingerprint golden tests must still pass — if an existing test pins an exact digest for an empty registry, update it once, stating why in the test comment). |
| +- [ ] **Step 5: Commit** — `feat: derived route_aliases registry table (#151)`. |
| + |
| +--- |
| + |
| +### Task 3: Alias reconcile pass (`internal/domains/aliases.go`) |
| + |
| +**Files:** |
| +- Create: `internal/domains/aliases.go` |
| +- Test: `internal/domains/aliases_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1 (`ListClaims`, `Claim`), Task 2 (`ListAliases/UpsertAlias/DeleteAlias`, `RouteAlias`), `release.RouteAccount(rt)` (the resolver `domains/reconcile.go:396` already uses), `registry.Registry.List()`. |
| +- Produces: `func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error` |
| + |
| +- [ ] **Step 1: Failing tests** — `release.NewDirStore` + a temp registry with seeded routes: |
| + |
| +```go |
| +func TestReconcileAliasesMaterializes(t *testing.T) // claim {route: R owned by acct/app matching R's row} → alias row appears with account/app/created_at |
| +func TestReconcileAliasesOwnershipGate(t *testing.T) // claim whose account or app differs from the route row's → NO row, pass returns nil |
| +func TestReconcileAliasesRoutesWin(t *testing.T) // claim whose host equals an existing routes.host → NO row |
| +func TestReconcileAliasesMissingRouteSkips(t *testing.T) // claim naming an absent route → NO row, no error |
| +func TestReconcileAliasesRemovesOrphans(t *testing.T) // pre-seeded alias row with no claim → deleted |
| +func TestReconcileAliasesRouteless(t *testing.T) // claim with Route:"" (the #82 first-route case) → ignored entirely |
| +func TestReconcileAliasesIdempotent(t *testing.T) // second run: same rows, CreatedAt unchanged |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/domains/ -run ReconcileAliases -count=1` |
| +- [ ] **Step 3: Implement:** |
| + |
| +```go |
| +// ReconcileAliases derives route_aliases from the global claim records. |
| +// The table is wholly pass-owned: every row traces to a claim, so removal |
| +// needs no ownership flag. The authoritative tenancy gate is HERE — the |
| +// door's route check is best-effort (it cannot see the registry); a claim |
| +// only materializes when the named route exists locally AND its |
| +// account/app match the claim. Routes always win a host collision: an |
| +// alias must never shadow a real route. |
| +func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error { |
| + claims, err := ListClaims(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + byHost := make(map[string]registry.Route, len(routes)) |
| + for _, rt := range routes { |
| + byHost[rt.Host] = rt |
| + } |
| + existing, err := reg.ListAliases() |
| + if err != nil { |
| + return err |
| + } |
| + desired := map[string]bool{} |
| + for _, c := range claims { |
| + if c.Route == "" { // first-route claims (#82's case) are not aliases |
| + continue |
| + } |
| + rt, ok := byHost[c.Route] |
| + if !ok { |
| + continue // primary not on this box — another box's claim |
| + } |
| + if rt.App != c.App || release.RouteAccount(rt) != c.Account { |
| + log.Printf("domains: alias %s refused: route %s belongs to %s/%s, claim says %s/%s", |
| + c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App) |
| + continue |
| + } |
| + if _, taken := byHost[c.Host]; taken { |
| + log.Printf("domains: alias %s skipped: a real route owns that host", c.Host) |
| + continue |
| + } |
| + desired[c.Host] = true |
| + if err := reg.UpsertAlias(registry.RouteAlias{ |
| + Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App, |
| + CreatedAt: now().Unix(), |
| + }); err != nil { |
| + log.Printf("domains: upsert alias %s: %v", c.Host, err) |
| + } |
| + } |
| + for _, a := range existing { |
| + if !desired[a.Alias] { |
| + if err := reg.DeleteAlias(a.Alias); err != nil { |
| + log.Printf("domains: remove alias %s: %v", a.Alias, err) |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +(CreatedAt-preservation on re-upsert comes from Task 2's ON CONFLICT clause — assert it in the idempotency test.) |
| + |
| +- [ ] **Step 4: Verify pass**, then `go test ./internal/domains/ -count=1`. |
| +- [ ] **Step 5: Commit** — `feat: alias reconcile pass derives route_aliases from claims (#151)`. |
| + |
| +--- |
| + |
| +### Task 4: Edge serves aliases off the primary's entry |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge.go` — `upstream` struct (`:146`), `Reload()` (insert after the routes loop ends at `:420`, before the fleet-auth block at `:421`), `currentHosts()` (`:491`) |
| +- Test: create `internal/edge/alias_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 2 `ListAliases`. |
| +- Produces: behavior only — no new exports. `upstream` gains `alias bool`. |
| + |
| +- [ ] **Step 1: Failing tests** (package `edge`, follow existing `edge_test.go` harness style — registry in a temp dir, `New(reg, act)`): |
| + |
| +```go |
| +func TestAliasServesPrimaryBackend(t *testing.T) // route ready + alias row; GET with Host=alias returns the primary's backend response |
| +func TestAliasSharesUpstreamEntry(t *testing.T) // e.current()[alias].proxy is the IDENTICAL handler value as [primary].proxy (the #152 composition pin: never a rebuilt proxy) |
| +func TestAliasAllowed(t *testing.T) // e.Allowed(alias) true; e.Allowed("stranger.example") false |
| +func TestAliasNeverShadowsRoute(t *testing.T) // alias row whose Alias equals another route's host: the ROUTE's backend answers |
| +func TestAliasSkipsMissingPrimary(t *testing.T) // alias whose Host has no ready route: unknownHost answer, Allowed false |
| +func TestAliasWakesPrimaryOnce(t *testing.T) // hibernating primary + fake Activator: request with Host=alias reaches act.ServeHTTP with up.route.Host == primary (never the alias) — the #129-class regression pin |
| +func TestCurrentHostsSkipsAliases(t *testing.T) // platform-minted primary + alias: currentHosts() contains primary, NOT the alias (wildcard derivation must never see customer domains) |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/edge/ -run Alias -count=1` |
| +- [ ] **Step 3: Implement.** `upstream` gains a field: |
| + |
| +```go |
| + // alias marks an entry mapped in from route_aliases: it shares the |
| + // primary's proxy/route/hibernating verbatim (so hibernation wakes the |
| + // primary's actEntry and #152's per-primary prober state applies), and |
| + // the flag exists so wildcard parent derivation (currentHosts) never |
| + // treats a customer hostname as a platform host. |
| + alias bool |
| +``` |
| + |
| +In `Reload()`, after the routes loop and before the fleet-auth block: |
| + |
| +```go |
| + // Attached hostnames (#151): each alias resolves to the primary's OWN |
| + // entry — same proxy value, same route (so activator keying, version |
| + // header, and purge tenancy are all the primary's). Routes always win |
| + // a collision; an alias whose primary isn't in m (not ready, not |
| + // local) is skipped — never a 502 route to nowhere. Errors abort the |
| + // reload like every other registry read here: a transient failure must |
| + // leave the old table serving. |
| + aliases, err := e.reg.ListAliases() |
| + if err != nil { |
| + return err |
| + } |
| + for _, a := range aliases { |
| + if _, taken := m[a.Alias]; taken { |
| + log.Printf("edge: alias %s shadowed by a real route — skipped", a.Alias) |
| + continue |
| + } |
| + up, ok := m[a.Host] |
| + if !ok { |
| + continue |
| + } |
| + up.alias = true |
| + m[a.Alias] = up |
| + } |
| +``` |
| + |
| +`currentHosts()`: the filter line becomes `if up.route.Platform && !up.alias {`. |
| +`ServeHTTP` needs NO change (map lookup covers aliases; `up.route` is the primary's row, so the activator wake, version header, and cache `Host` keying all behave per spec §4). `managed` is untouched — the primary's own loop iteration already registered it. |
| + |
| +- [ ] **Step 4: Verify pass**, then `go test ./internal/edge/ -count=1` (the package is 16s — run it whole; hibernation and wildcard suites must stay green). |
| +- [ ] **Step 5: Commit** — `feat: edge serves attached hostnames off the primary route's entry (#151)`. |
| + |
| +--- |
| + |
| +### Task 5: DNS + cert stamping for aliases |
| + |
| +**Files:** |
| +- Modify: `internal/domains/reconcile.go` — extend sections 4 (`:409-431`) and 5 (`:433-441`) |
| +- Test: `internal/domains/reconcile_test.go` (append; follow the existing fake-resolver pattern in this file) |
| + |
| +**Interfaces:** consumes Task 2's `SetAliasDNSStatus`/`SetAliasCertExpiresAt`; produces behavior only. |
| + |
| +- [ ] **Step 1: Failing tests:** |
| + |
| +```go |
| +func TestReconcileStampsAliasDNS(t *testing.T) // alias row + cfg.Resolve returning the edge IP → dns_status "pointing"; returning another IP → "elsewhere"; resolver error → "" (unknown, never a guess) |
| +func TestReconcileStampsAliasCert(t *testing.T) // acme dir with a cert file named exactly the alias → cert_expires_at stamped (reuse the cert fixture helper the existing TLS-health tests use) |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement.** In section 4, after the routes loop, iterate `reg.ListAliases()` with the same resolve-then-stamp shape (`SetAliasDNSStatus(a.Alias, status, now)`); in section 5 likewise with `hostCertExpiry(cfg.AcmeDir, a.Alias, cfg.AppsDomain)` → `SetAliasCertExpiresAt`. One `ListAliases` call shared by both sections (list once above section 4, guarded the same way — only when `cfg.Resolve != nil || cfg.AcmeDir != ""`). Log-and-continue per item, matching the route stamping. |
| +- [ ] **Step 4: Verify pass**, then whole package. |
| +- [ ] **Step 5: Commit** — `feat: DNS and cert health stamps for route aliases (#151)`. |
| + |
| +---### Task 6: Alias readings in the box snapshot |
| + |
| +**Files:** |
| +- Modify: `internal/status/snapshot.go` — `Probes` (`:41`), `BoxMetrics` (`:103`), `CollectBox` (`:290`) |
| +- Modify: `internal/host/agent.go` — the `CollectBox` call site (grep `status.CollectBox(`; populate the new field from `reg.ListAliases()`, log-and-continue on error) |
| +- Test: `internal/status/snapshot_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `Probes.Aliases []registry.RouteAlias` (additive field) |
| + - `BoxMetrics.Aliases map[string]AliasReading` — JSON `aliases,omitempty` |
| + - `type AliasReading struct { Route string `json:"route"`; DNSStatus string `json:"dns_status,omitempty"`; CertExpiresAt int64 `json:"cert_expires_at,omitempty"` }` |
| + |
| +- [ ] **Step 1: Failing test:** |
| + |
| +```go |
| +func TestCollectBoxAliases(t *testing.T) // Probes.Aliases with one stamped alias → Box.Metrics.Aliases[alias] carries route/dns/cert; empty Probes.Aliases → field omitted from JSON (assert `"aliases"` absent from marshaled bytes) |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/status/ -run Aliases -count=1` |
| +- [ ] **Step 3: Implement.** In `CollectBox`, beside where `RouteVersions` is built (`:396`): map `p.Aliases` → `AliasReading`. Wire the agent call site. `status/boxes/*.json` is bucket-private (the dry-run leak scan guards the PUBLIC feed only), and nothing here feeds `BuildFleet`'s public aggregation — state that in the field's doc comment. |
| +- [ ] **Step 4: Verify pass** — `go test ./internal/status/ ./internal/host/ -count=1` (host package: the dry-run self-check must stay green). |
| +- [ ] **Step 5: Commit** — `feat: alias readings in box status snapshot (#151)`. |
| + |
| +--- |
| + |
| +### Task 7: Store-door read access to `domains/` |
| + |
| +**Files:** |
| +- Modify: `internal/edge/storedoor.go` — `aclRead` (`:372`), `aclDir` (`:396`), and the listing-prefix switch (`:481-501`) |
| +- Test: `internal/edge/storedoor_test.go` (append to the existing acl table tests) |
| + |
| +- [ ] **Step 1: Failing tests** — extend the existing acl test tables: `aclRead(sqid, fleet, "domains/x.example.json")` → true; `aclRead(..., "domains")` (no slash, an object AT the name) → false; listing prefix `"domains/"` allowed; write `aclWrite(..., "domains/x.json")` → false (boxes read claims, only the console writes them). |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/edge/ -run Acl -count=1` (match the existing test names — grep first). |
| +- [ ] **Step 3: Implement** — one case each in `aclRead` (`strings.HasPrefix(key, "domains/")`), `aclDir`, and the listing switch (`prefix == "domains" || prefix == "domains/"` plus `strings.HasPrefix(prefix, "domains/")`), commented as the #151 claim records fleet boxes' alias pass reads. |
| +- [ ] **Step 4: Verify pass.** |
| +- [ ] **Step 5: Commit** — `feat: store door reads domains/ claims (#151)`. |
| + |
| +--- |
| + |
| +### Task 8: Agent wiring — fast leg + 5-minute floor |
| + |
| +**Files:** |
| +- Modify: `internal/host/agent.go` — `fullPass` (`:1118-1130`) and `instancesLoop` (`:1421+`, the `provision.Reconcile` tick) |
| +- Test: none new (the legs are thin-by-convention like their siblings; behavior is Task 3's) |
| + |
| +- [ ] **Step 1: Implement.** In `fullPass`, after the `reconcileSerialized` call: |
| + |
| +```go |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil { |
| + log.Printf("agent: fast converge alias pass: %v", err) |
| + } |
| +``` |
| + |
| +In `instancesLoop`'s tick, after its `provision.Reconcile`/`reconcileSerialized` call, the same call with the same log shape — this is the 5-minute floor; it must NOT live in `domainsLoop`, whose `PLATFORM_APPS_DOMAIN` inert gate would silently disable aliases on boxes that never set it. Add one line to the `fullPass` doc comment naming the new leg. |
| +- [ ] **Step 2: Verify** — `go build ./... && go test ./internal/host/ -count=1`. |
| +- [ ] **Step 3: Commit** — `feat: alias reconcile rides the fast loop and the instances floor (#151)`. |
| + |
| +--- |
| + |
| +### Task 9: Console door — the `/domains` family |
| + |
| +**Files:** |
| +- Create: `internal/console/apidomains.go` |
| +- Modify: `internal/console/authcli.go` — register three routes beside the instances block (`:273-281`) and reword the "only door onto instances/" comment (`:263-266`) to "instances/ and domains/" |
| +- Modify: `cmd/console/main.go` — parse `CONSOLE_DENY_DOMAINS` (comma-separated) into a new `console.Config.DenyDomains []string` field (define the field in `internal/console/server.go` beside `Origin`, `:166` area) |
| +- Test: `internal/console/apidomains_test.go` (follow `apiinstances_test.go` harness patterns exactly, including the app-token fixtures) |
| + |
| +**Interfaces:** |
| +- Consumes: `instanceGate` (`apiinstances.go:103` — reused verbatim: app token bound to acct/app OR member user token, token-hash rate limit, Releases-nil 503), Task 1 (`AttachClaim/DetachClaim/ListClaims/NormalizeAliasHost/HostDenied/ErrHostUnavailable`), `provision.ValidHostShape`, `provision.ListRecords`, `status.LoadBoxes` (`internal/status/fleet.go:150`) + Task 6's `AliasReading`, `s.audit` (`server.go:1838` — `audit(acct, app, addr, action, detail)`), `s.bumpSerial`, `jsonError`, `readJSON`, `writeJSON`. |
| +- Produces (the CLI and titogo consume this wire shape): |
| + - `POST /api/cli/apps/{account}/{app}/domains` body `{"host": "...", "route": "..."}` (route optional) → 200 `{"host","route","created":bool}`; 400 invalid host/ambiguous route; 409 `{"error":"hostname unavailable"}`; 201 is NOT used (attach is idempotent — 200 either way) |
| + - `DELETE /api/cli/apps/{account}/{app}/domains/{host}` → 200 `{"host","removed":bool}` always (quiet idempotency) |
| + - `GET /api/cli/apps/{account}/{app}/domains` → 200 `{"domains":[{"host","route","status","dns_status","cert_expires_at"}]}` — `status` is `"live"` when any box reading carries the alias, else `"pending"` |
| + |
| +- [ ] **Step 1: Failing tests:** |
| + |
| +```go |
| +func TestAPIDomainAttachCreates(t *testing.T) // member token: 200, created true; claim object exists in the test store |
| +func TestAPIDomainAttachAppToken(t *testing.T) // app token bound to the acct/app: 200; token bound to ANOTHER app: byte-identical 404 (the apiInstancePrincipal contract) |
| +func TestAPIDomainAttachIdempotent(t *testing.T) // second identical attach: 200, created false |
| +func TestAPIDomainAttachConflictNeutral(t *testing.T) // claim held by another account: 409, body exactly {"error":"hostname unavailable"} |
| +func TestAPIDomainAttachValidation(t *testing.T) // "Bad_Host!", "nodots", host under a DenyDomains entry, host == console origin host: all 400 |
| +func TestAPIDomainAttachRouteDefault(t *testing.T) // app with exactly one instance record + no route in body: claim's Route is that record's host; two records + no route: 400 naming the flag |
| +func TestAPIDomainDetachQuiet(t *testing.T) // detach existing: 200 removed true (+ claim gone); detach unknown: 200 removed false; detach other-owner's: 200 removed false, claim intact |
| +func TestAPIDomainList(t *testing.T) // two claims for the app + a box snapshot carrying one as an alias reading: that one status "live" with dns_status, the other "pending"; another app's claim absent |
| +func TestAPIDomainSerialBump(t *testing.T) // attach and effective detach move control/serial; no-op detach does NOT |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** `go test ./internal/console/ -run APIDomain -count=1` |
| +- [ ] **Step 3: Implement `apidomains.go`.** Attach handler skeleton (the others follow the delete/list patterns already quoted in `apiinstances.go:560-640`): |
| + |
| +```go |
| +func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| + principal, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + var in struct{ Host, Route string } |
| + if !readJSON(w, r, &in) { |
| + return |
| + } |
| + host, ok := domains.NormalizeAliasHost(in.Host) |
| + if !ok { |
| + jsonError(w, http.StatusBadRequest, "invalid hostname") |
| + return |
| + } |
| + if domains.HostDenied(host, s.deniedDomains()) { |
| + jsonError(w, http.StatusBadRequest, "that hostname is platform-managed") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + route, ok := s.resolveAliasRoute(ctx, w, acct, app, in.Route) |
| + if !ok { |
| + return |
| + } |
| + created, err := domains.AttachClaim(ctx, s.cfg.Releases, domains.Claim{ |
| + Host: host, Account: acct.Sqid, App: app, Route: route, |
| + CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal, |
| + }) |
| + if errors.Is(err, domains.ErrHostUnavailable) { |
| + jsonError(w, http.StatusConflict, "hostname unavailable") |
| + return |
| + } else if err != nil { |
| + log.Printf("console: api domains attach %s/%s %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not attach the hostname — try again") |
| + return |
| + } |
| + s.audit(acct, app, principal, "domain-attach", "attached hostname "+host+" -> "+route+" (cli)") |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": created}) |
| +} |
| +``` |
| + |
| +Helpers in the same file: `s.deniedDomains()` = `cfg.DenyDomains` + the origin's hostname (parse `s.cfg.Origin` once); `s.resolveAliasRoute` = explicit route → `provision.ValidHostShape` gate then accept (records are best-effort; operator routes have none — the box gate is authoritative, say so in the comment); empty route → `provision.ListRecords`, exactly one → its host, zero or many → 400 `"specify -route: the app has N instances"`. Detach: `NormalizeAliasHost` (400 on garbage — traversal defence), `DetachClaim`, audit + `bumpSerial` only when `removed`, always 200. List: `ListClaims` filtered to `acct.Sqid`/`app`, merge `status.LoadBoxes` aliases sections. Registrations in `registerCLIRoutes`: |
| + |
| +```go |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainList) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainAttach) |
| + s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/domains/{host}", s.handleAPIDomainDetach) |
| +``` |
| + |
| +- [ ] **Step 4: Verify pass**, then `go test ./internal/console/ -count=1` (13s — run whole). |
| +- [ ] **Step 5: Commit** — `feat: console domains door — attach/detach/list hostnames (#151)`. |
| + |
| +--- |
| + |
| +### Task 10: `carlos domains` CLI verb |
| + |
| +**Files:** |
| +- Modify: `apimode.go` — wire types + three `apiSession` methods (follow `instanceCreate` at `:1273-1296`) |
| +- Create: `domains_cli.go` (follow `instances_cli.go:237-259` shape: `flag.NewFlagSet`, `requireAPISession`, print) |
| +- Modify: `cli.go` — top-level dispatch `case "domains": runDomains(args[1:])` beside `case "instances"` (`:227` area is the run func; find the dispatch switch by grepping `runInstances(`), plus one usage line in the help text |
| +- Test: `domains_cli_test.go` (httptest console fake, the pattern of the existing `instances_cli` tests; env via `carlosTestEnv`, `cmd.Dir` in scratch) |
| + |
| +**Interfaces:** |
| +- Consumes: Task 9's wire shapes. |
| +- Produces: |
| + |
| +```go |
| +type apiDomainRecord struct { |
| + Host, Route, Status, DNSStatus string // json: host, route, status, dns_status |
| + CertExpiresAt int64 // json: cert_expires_at |
| +} |
| +func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string) (created bool, resolvedRoute string, err error) |
| +func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host string) (removed bool, err error) |
| +func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, error) |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests** — fake console asserting method+path+body and returning canned JSON; verbs under test: |
| + - `carlos domains attach -app tito -route slug.go.tito.io tickets.example.com` → POSTs the pair, prints `attached tickets.example.com -> slug.go.tito.io` (or `already attached` when created=false) |
| + - `attach` with no positional host → usage error, exit 2 |
| + - `carlos domains detach -app tito tickets.example.com` → DELETE with the host path-escaped, prints `detached`/`was not attached` |
| + - `carlos domains list -app tito` → table: HOST ROUTE STATUS DNS CERT-EXPIRES (cert as date or `-` when 0) |
| +- [ ] **Step 2: Verify failure.** `go test . -run Domains -count=1` |
| +- [ ] **Step 3: Implement.** `domainDetach` decodes the 200 body's `removed`. 409 from attach surfaces the server's `hostname unavailable` verbatim (`apiErrorFromBody`, `apimode.go:249`). `runDomains` dispatches attach/detach/list, default prints usage. |
| +- [ ] **Step 4: Verify pass** — `go test . -count=1` (root package is 97s; run it whole once here). |
| +- [ ] **Step 5: Commit** — `feat: carlos domains attach/detach/list (#151)`. |
| + |
| +--- |
| + |
| +### Task 11: Full gate, docs, PR |
| + |
| +- [ ] **Step 1:** `gofmt -l` on every touched file (must print nothing); fix any. |
| +- [ ] **Step 2:** `go build ./... && go vet ./... && go test ./... -count=1` — all green; `GOOS=darwin go build ./...` compiles. |
| +- [ ] **Step 3:** README/CLAUDE.md: no changes needed (deploy reality unchanged until rollout); add one line to CLAUDE.md's Deploy reality section ONLY if asked. |
| +- [ ] **Step 4:** Push branch, open PR: title `route hostnames: attach/detach an extra public hostname on an existing route (#151)`, body = spec summary + `Closes #151` + the cross-session coordination note (unit-economics + HA + #82 merge agreements) + rollout order from spec §Rollout, ending with the Generated-with line from CLAUDE.md. |
| +- [ ] **Step 5:** Ping the #82 session with: package path `internal/domains`, exports `AttachClaim/DetachClaim/GetClaim/ListClaims/NormalizeAliasHost/HostDenied/ErrHostUnavailable/ClaimKey/ClaimsPrefix`, door file `internal/console/apidomains.go`, CLI file `domains_cli.go`, branch `worktree-route-hostnames`. |
| + |
| +## Self-review notes (done at write time) |
| + |
| +- Spec §1-§8 → Tasks 1-10 cover record/table/reconcile/edge/door/CLI/stamping/readings/acl/wiring; §Rollout → Task 11. |
| +- Type names cross-checked: `RouteAlias` (T2) consumed by T3/T4/T5/T6; `Claim` (T1) by T3/T9; `AliasReading` (T6) by T9's list merge. |
| +- Deliberately absent: `CONSOLE_DENY_DOMAINS` values themselves (deploy-time, per deployment: flagship `oncarlos.com`, tito `go.tito.io,platform.tito.io`) — recorded in the PR body's rollout section, not in code. |